Hole Tickets und Kommentare aus Gitea in die km-Ablage

Erster Baustein von spine/core#13: image/km/ingest_gitea.py liest Issues,
Pull Requests und Kommentare je Repo oder Organisation und schreibt je
Vorgang eine Markdown-Datei mit Frontmatter (Zustand, Labels, Milestone,
Beteiligte, Zeitstempel, Verweise auf andere Vorgänge als Kanten) unter
<ablage>/<namespace>/vorgaenge/<owner>/<repo>/<nummer>.md.

Inkrementell über since je Repo, idempotent, Stand erst nach vollständigem
Durchlauf. Zugang aus OpenBao wie beim Gitea-Helfer, nie in der Ausgabe.
Schreibt nichts nach Gitea zurück. Gemessen: 1165 Vorgänge in drei Minuten.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 17:29:44 +02:00
co-authored by Claude Fable 5.1
parent 2353d3725d
commit 1e08de2d05
5 changed files with 395 additions and 1 deletions
+102
View File
@@ -0,0 +1,102 @@
# Gitea-Ingest — Tickets und Kommentare in die Ablage
Umsetzung von [spine/core#13](https://git.42i.org/spine/core/issues/13).
Code: `image/km/ingest_gitea.py`. Stand: Prototyp, läuft am Baum, noch nicht
als Dienst.
## Was er tut
Er liest Issues, Pull Requests und deren Kommentare aus einer Gitea-Instanz
und legt **je Vorgang eine Markdown-Datei** in der km-Ablage ab:
```
<ablage>/<namespace>/vorgaenge/<owner>/<repo>/<nummer>.md
```
Die Datei ist das, was km über den Vorgang weiß — lesbar ohne den Dienst, in
SilverBullet blätterbar, für die Suche indexierbar. Sie besteht aus:
| Teil | Inhalt |
|---|---|
| Frontmatter | Forge, Namespace, Klassifikation, Repo, Nummer, Art (issue/pull), Titel, Zustand, Autor, Beteiligte, Labels, Milestone, Zuweisung, erstellt/aktualisiert/geschlossen, URL, **Verweise** |
| Stand-Block | der `tldr`-Block aus dem Ticketkopf, wenn vorhanden, vor dem Body |
| Body | der Ticket-Text |
| Kommentare | alle, in Reihenfolge, mit Autor und Zeit; „(bearbeitet)" wenn nachträglich geändert |
**Verweise** sind das, was über eine Volltextsuche hinausgeht: jedes
`owner/repo#N`, jedes nackte `#N` (im eigenen Repo) und jede Ticket-URL in Body
oder Kommentaren wird zu einem Eintrag `verweise:` im Frontmatter — also zu
einer Kante, nicht einer Zeichenkette. Aufzählungen wie `42i/intern#193 / #198`
werden dem qualifizierten Repo zugeordnet, nicht dem eigenen.
## Wie er läuft
```bash
# Erstbefüllung oder Abgleich einzelner Repos
AGENT_PERSONA=elton image/km/ingest_gitea.py --ablage /srv/km/ablage \
--repo spine/core --repo 42i/intern --repo live/live
# alle Repos einer Organisation
image/km/ingest_gitea.py --ablage /srv/km/ablage --org spine --org hive
# zweite Forge, eigener Namespace
KM_GITEA_URL=https://git.home KM_NAMESPACE=familie KM_CLASSIFICATION=home \
image/km/ingest_gitea.py --ablage /srv/km/ablage --org ahmann
```
- **Inkrementell.** Je Repo merkt er sich in einer Standdatei
(`<ablage>/../km-ingest-state.json`, außerhalb der Ablage) den Zeitpunkt des
letzten vollständigen Durchlaufs und fragt Gitea nur nach seither geänderten
Vorgängen (`since`, Gitea filtert nach `updated`). Ein geänderter Vorgang
wird **komplett neu geschrieben** — ein umgeschriebener Body ist eine
Änderung des Stands, kein Anhang.
- **Idempotent.** Dieselbe Eingabe ergibt dieselbe Datei; unveränderte Dateien
werden nicht angefasst. Damit ist `--full` zugleich der Abgleich aus
spine/core#17: wiederholbar, mit belegbarem „zuletzt geprüft um".
- **Abbruch ohne Lücke.** Der Stand wird erst nach vollständigem Durchlauf
eines Repos gesetzt. Bricht ein Lauf ab, holt der nächste dieselben Vorgänge
noch einmal — wiederholen ist billig, Lücken sind es nicht.
- **Zugang** kommt aus OpenBao (`secret get <persona> git username|password`),
wie beim Gitea-Helfer der Agenten. Nichts davon erscheint in Ausgabe,
Kommandozeile oder Datei. Der Ingest **schreibt nichts nach Gitea** (die
Festlegung aus #13: Vorgänge bleiben in der Forge, km liest).
## Gemessen am 2026-09-04
| Repo | Vorgänge | Dauer | Anmerkung |
|---|---|---|---|
| spine/core | 20 | 6 s | Erstbefüllung |
| spine/core | 0 gesehen | 0,3 s | inkrementell, nichts geändert |
| 42i/intern | 1165 | ~3 min | Erstbefüllung, ein API-Aufruf je Vorgang für die Kommentare |
Die Erstbefüllung kostet einen Aufruf je Vorgang mit Kommentaren; das ist
die Last auf der Forge, von der #13 spricht. Sie fällt einmal an. Danach
kostet ein Lauf einen Aufruf je Repo plus einen je geändertem Vorgang.
## Was noch fehlt (Reihenfolge)
1. **Bus-Anschluss.** Heute ist der Ingest ein Aufruf; er soll von
`wake.km` geweckt werden (spine/core#12: Gitea-Webhook → HTTP→Bus-Adapter
→ Bus → km) und dann den inkrementellen Lauf machen. Der periodische
Abgleich bleibt daneben Pflicht.
2. **Suche über die Ablage.** Volltext führend, Vektor ergänzend (spine/core#1).
Der bestehende Indexer `info/ai/scripts/index_docs.py` chunkt Markdown mit
Frontmatter — die Vorgangsdateien sind dafür gebaut. Welche Datenbank(en)
dahinter stehen, ist noch nicht entschieden (Stand 04.09.).
3. **Verknüpfung mit Doku und Memory.** Die `verweise:` sind erst Kanten
zwischen Vorgängen. Kanten zu Doku-Seiten (ein Ticket, das eine Seite
betrifft) und die Konsistenzprüfung gegen abgelegte Festlegungen kommen
mit der prüfenden Schreibfunktion.
4. **Commit und Push** der Ablage nach dem Lauf, wie `wissens_mcp/repo.py`
(Serialisierung, Rebase bei abgelehntem Push). Beim Prototyp am Baum läuft
das noch von Hand.
## Offen aus #13, hier vorgeschlagen
- **Historientiefe:** Erstbefüllung vollständig, weil sie ohnehin nur einmal
anfällt und drei Minuten je tausend Vorgänge kostet. Ein Stichtag spart
nichts Nennenswertes.
- **Zwei Forges:** getrennt über `--namespace` und `--classification`;
`git.home``familie`/`home`, `git.42i.org``42i`/`lan`. Mit einem km je
Instanz (hive/core#61) liegt die Familien-Forge ohnehin in einem anderen
Dienst.
+4 -1
View File
@@ -3,7 +3,10 @@
Hier entsteht `git.42i.org/spine/km`: die API, die Ablage, der MCP-Endpunkt und
die prüfende Schreibfunktion.
**Noch leer.** Was hineingehört, steht in [../docs/konzept.md](../docs/konzept.md).
**Erster Baustein vorhanden:** `km/ingest_gitea.py` holt Tickets und Kommentare
aus Gitea in die Ablage (spine/core#13, Doku in
[../docs/ingest-gitea.md](../docs/ingest-gitea.md)). Der Rest steht in
[../docs/konzept.md](../docs/konzept.md).
Die Reihenfolge, in der es sinnvoll wächst:
1. `read` und `search` über eine Ablage aus Markdown-Dateien — ohne die beiden
Binary file not shown.
+288
View File
@@ -0,0 +1,288 @@
#!/usr/bin/env python3
"""Gitea-Ingest fuer km (spine/core#13).
Liest Issues, Pull Requests und ihre Kommentare aus einer Gitea-Instanz und
legt je Vorgang eine Markdown-Datei in der km-Ablage ab:
<ablage>/<namespace>/vorgaenge/<owner>/<repo>/<number>.md
Die Datei traegt Frontmatter (Titel, Zustand, Labels, Milestone, Zuweisung,
Zeitstempel, URL, erkannte Verweise auf andere Vorgaenge) und darunter den
Body und alle Kommentare in Reihenfolge. Sie wird bei jeder Aenderung am
Vorgang komplett neu geschrieben -- ein umgeschriebener Body ist eine
Aenderung des Stands, kein Anhang.
Inkrementell ueber `since` je Repo (Gitea filtert nach `updated`); der Stand
liegt in einer JSON-Datei ausserhalb der Ablage. Ein Lauf ohne Stand ist die
Erstbefuellung. Jeder Lauf ist idempotent: dieselbe Eingabe ergibt dieselbe
Datei, also kann man ihn beliebig wiederholen -- das ist der Abgleich aus
spine/core#17, nicht nur die Erstbefuellung.
Zugangsdaten kommen aus OpenBao ueber `secret get <persona> <service>`
(HTTP Basic, wie ~/.agents/mcp/gitea/bin/gitea) und werden nie ausgegeben.
Der Ingest schreibt nichts nach Gitea zurueck.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterator
import requests
# owner/repo#123 -- oder nacktes #123, das dann im eigenen Repo liegt
_REF_QUALIFIED = re.compile(r"(?<![\w/])([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)\b")
_REF_BARE = re.compile(r"(?<![\w/#&])#(\d+)\b")
# "42i/intern#193 / #198": nackte Nummern in einer Aufzaehlung direkt hinter
# einem qualifizierten Verweis gehoeren zu dessen Repo, nicht zum eigenen.
_REF_CHAIN = re.compile(r"(?<![\w/])([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)#\d+((?:\s*[/,]\s*#\d+)+)")
_TLDR = re.compile(r"<!-- tldr:start.*?<!-- tldr:end -->\s*", re.DOTALL)
_MENTION_URL = re.compile(r"https?://[^\s)]+/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)/(?:issues|pulls)/(\d+)")
@dataclass
class Forge:
url: str
namespace: str
persona: str = "elton"
service: str = "git"
classification: str = "lan"
session: requests.Session = field(default_factory=requests.Session)
@property
def host(self) -> str:
return re.sub(r"^https?://", "", self.url).split("/")[0]
def login(self, secret_bin: str) -> None:
def get(fld: str) -> str:
r = subprocess.run(
[secret_bin, "get", self.persona, self.service, fld],
capture_output=True, text=True,
)
return r.stdout.strip() if r.returncode == 0 else ""
user = get("username") or self.persona
pw = get("password")
if not pw:
sys.exit(f"ingest: kein Gitea-Zugang fuer '{self.persona}' in OpenBao "
f"(agents/{self.persona}/{self.service})")
self.session.auth = (user, pw)
self.session.headers["Accept"] = "application/json"
def get(self, path: str, **params) -> object:
for attempt in range(4):
r = self.session.get(f"{self.url}/api/v1{path}", params=params, timeout=60)
if r.status_code in (429, 502, 503, 504) and attempt < 3:
time.sleep(2 ** attempt)
continue
r.raise_for_status()
return r.json()
raise RuntimeError("unreachable")
def paged(self, path: str, **params) -> Iterator[dict]:
page = 1
while True:
batch = self.get(path, page=page, limit=50, **params)
if not batch:
return
yield from batch
if len(batch) < 50:
return
page += 1
def org_repos(self, org: str) -> list[str]:
return [f"{org}/{r['name']}" for r in self.paged(f"/orgs/{org}/repos")]
def issues(self, repo: str, kind: str, since: str | None) -> Iterator[dict]:
params = {"state": "all", "type": kind, "sort": "recentupdate"}
if since:
params["since"] = since
try:
yield from self.paged(f"/repos/{repo}/issues", **params)
except requests.HTTPError as e:
# Repo ohne Pull Requests (Einheit abgeschaltet) antwortet mit 404
# auf type=pulls -- das ist "keine", kein Fehler.
if kind == "pulls" and e.response is not None and e.response.status_code == 404:
return
raise
def comments(self, repo: str, number: int) -> list[dict]:
return list(self.paged(f"/repos/{repo}/issues/{number}/comments"))
# ---------------------------------------------------------------- Markdown
def _iso(s: str | None) -> str | None:
if not s or s.startswith("0001-"):
return None
return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _yaml_str(s: str) -> str:
return json.dumps(s, ensure_ascii=False)
def _refs(text: str, own_repo: str) -> set[str]:
out: set[str] = set()
for o, r, n in _REF_QUALIFIED.findall(text):
out.add(f"{o}/{r}#{n}")
chained: set[str] = set()
for repo, tail in _REF_CHAIN.findall(text):
for n in re.findall(r"#(\d+)", tail):
out.add(f"{repo}#{n}")
chained.add(n)
for n in _REF_BARE.findall(text):
if n not in chained:
out.add(f"{own_repo}#{n}")
for o, r, n in _MENTION_URL.findall(text):
out.add(f"{o}/{r}#{n}")
return out
def render(forge: Forge, repo: str, issue: dict, comments: list[dict]) -> str:
kind = "pull" if issue.get("pull_request") else "issue"
number = issue["number"]
own = f"{repo}#{number}"
body = (issue.get("body") or "").replace("\r\n", "\n").strip()
stand = None
m = _TLDR.search(body)
if m:
stand = m.group(0).strip()
body = _TLDR.sub("", body).strip()
refs: set[str] = _refs(body, repo)
if stand:
refs |= _refs(stand, repo)
for c in comments:
refs |= _refs(c.get("body") or "", repo)
refs.discard(own)
participants = {issue["user"]["login"]}
participants |= {c["user"]["login"] for c in comments if c.get("user")}
fm = [
"---",
f"quelle: gitea",
f"forge: {forge.host}",
f"namespace: {forge.namespace}",
f"classification: {forge.classification}",
f"repo: {repo}",
f"nummer: {number}",
f"art: {kind}",
f"titel: {_yaml_str(issue['title'])}",
f"zustand: {issue['state']}",
f"autor: {issue['user']['login']}",
f"beteiligte: [{', '.join(sorted(participants))}]",
f"labels: [{', '.join(_yaml_str(l['name']) for l in issue.get('labels') or [])}]",
f"milestone: {_yaml_str(issue['milestone']['title']) if issue.get('milestone') else 'null'}",
f"zugewiesen: [{', '.join(a['login'] for a in issue.get('assignees') or [])}]",
f"erstellt: {_iso(issue['created_at'])}",
f"aktualisiert: {_iso(issue['updated_at'])}",
f"geschlossen: {_iso(issue.get('closed_at')) or 'null'}",
f"kommentare: {len(comments)}",
f"url: {issue['html_url']}",
f"verweise: [{', '.join(_yaml_str(r) for r in sorted(refs))}]",
"---",
]
out = [*fm, "", f"# {own}: {issue['title']}", ""]
if stand:
out += [stand, ""]
out += [body or "_(kein Text)_", ""]
if comments:
out += ["## Kommentare", ""]
for c in comments:
when = _iso(c["created_at"])
who = c["user"]["login"] if c.get("user") else "?"
edited = " (bearbeitet)" if _iso(c.get("updated_at")) != when else ""
out += [f"### {who} · {when}{edited}", "",
(c.get("body") or "").replace("\r\n", "\n").strip() or "_(leer)_", ""]
return "\n".join(out).rstrip() + "\n"
# ---------------------------------------------------------------- Lauf
def load_state(path: Path) -> dict:
if path.exists():
return json.loads(path.read_text())
return {}
def save_state(path: Path, state: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n")
tmp.replace(path)
def ingest_repo(forge: Forge, repo: str, ablage: Path, state: dict, full: bool) -> tuple[int, int]:
key = f"{forge.host}/{repo}"
since = None if full else state.get(key, {}).get("since")
started = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
target = ablage / forge.namespace / "vorgaenge" / repo
target.mkdir(parents=True, exist_ok=True)
seen = written = 0
for kind in ("issues", "pulls"):
for issue in forge.issues(repo, kind, since):
seen += 1
comments = forge.comments(repo, issue["number"]) if issue.get("comments") else []
text = render(forge, repo, issue, comments)
f = target / f"{issue['number']}.md"
if not f.exists() or f.read_text(encoding="utf-8") != text:
f.write_text(text, encoding="utf-8")
written += 1
# Stand erst nach vollstaendigem Durchlauf setzen -- bricht der Lauf ab,
# holt der naechste dieselben Vorgaenge noch einmal (Wiederholen ist billig,
# Luecken sind es nicht).
state[key] = {"since": started, "letzter_lauf": started, "gesehen": seen, "geschrieben": written}
return seen, written
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("--url", default=os.environ.get("KM_GITEA_URL", "https://git.42i.org"))
ap.add_argument("--namespace", default=os.environ.get("KM_NAMESPACE", "42i"))
ap.add_argument("--classification", default=os.environ.get("KM_CLASSIFICATION", "lan"))
ap.add_argument("--persona", default=os.environ.get("AGENT_PERSONA", "elton"))
ap.add_argument("--secret-bin", default=os.environ.get("SECRET_BIN", str(Path.home() / ".agents/bin/secret")))
ap.add_argument("--ablage", required=True, type=Path, help="Wurzel der km-Ablage (Git-Checkout)")
ap.add_argument("--state", type=Path, help="Standdatei (Default: <ablage>/../km-ingest-state.json)")
ap.add_argument("--repo", action="append", default=[], help="owner/repo, mehrfach")
ap.add_argument("--org", action="append", default=[], help="alle Repos einer Organisation")
ap.add_argument("--full", action="store_true", help="Stand ignorieren, alles neu lesen")
a = ap.parse_args()
forge = Forge(url=a.url.rstrip("/"), namespace=a.namespace, persona=a.persona,
classification=a.classification)
forge.login(a.secret_bin)
repos = list(a.repo)
for org in a.org:
repos += forge.org_repos(org)
if not repos:
sys.exit("ingest: --repo oder --org angeben")
state_path = a.state or (a.ablage.parent / "km-ingest-state.json")
state = load_state(state_path)
t0 = time.time()
total_seen = total_written = 0
for repo in dict.fromkeys(repos):
seen, written = ingest_repo(forge, repo, a.ablage, state, a.full)
total_seen += seen
total_written += written
print(f"{forge.host}/{repo}: {seen} gesehen, {written} geschrieben", file=sys.stderr)
save_state(state_path, state)
print(f"fertig: {len(repos)} repos, {total_seen} vorgaenge gesehen, "
f"{total_written} dateien geschrieben, {time.time() - t0:.1f}s", file=sys.stderr)
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
requests>=2.31