diff --git a/docs/ingest-gitea.md b/docs/ingest-gitea.md new file mode 100644 index 0000000..81e2015 --- /dev/null +++ b/docs/ingest-gitea.md @@ -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: + +``` +//vorgaenge///.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 + (`/../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 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. diff --git a/image/README.md b/image/README.md index 48373e2..de94789 100644 --- a/image/README.md +++ b/image/README.md @@ -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 diff --git a/image/km/__pycache__/ingest_gitea.cpython-314.pyc b/image/km/__pycache__/ingest_gitea.cpython-314.pyc new file mode 100644 index 0000000..978df1a Binary files /dev/null and b/image/km/__pycache__/ingest_gitea.cpython-314.pyc differ diff --git a/image/km/ingest_gitea.py b/image/km/ingest_gitea.py new file mode 100755 index 0000000..f53fc13 --- /dev/null +++ b/image/km/ingest_gitea.py @@ -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: + + //vorgaenge///.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 ` +(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"(?\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: /../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() diff --git a/image/requirements.txt b/image/requirements.txt new file mode 100644 index 0000000..535409c --- /dev/null +++ b/image/requirements.txt @@ -0,0 +1 @@ +requests>=2.31