From 1e08de2d050d7595946e1edfb5d7051fa57db43f Mon Sep 17 00:00:00 2001 From: Elton Turing Date: Fri, 4 Sep 2026 17:29:44 +0200 Subject: [PATCH] Hole Tickets und Kommentare aus Gitea in die km-Ablage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 //vorgaenge///.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 --- docs/ingest-gitea.md | 102 +++++++ image/README.md | 5 +- .../__pycache__/ingest_gitea.cpython-314.pyc | Bin 0 -> 22345 bytes image/km/ingest_gitea.py | 288 ++++++++++++++++++ image/requirements.txt | 1 + 5 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 docs/ingest-gitea.md create mode 100644 image/km/__pycache__/ingest_gitea.cpython-314.pyc create mode 100755 image/km/ingest_gitea.py create mode 100644 image/requirements.txt 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 0000000000000000000000000000000000000000..978df1a40d68c8a018bec15ffb67851723ec6bc9 GIT binary patch literal 22345 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 literal 0 HcmV?d00001 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