diff --git a/app/git_sync.py b/app/git_sync.py new file mode 100644 index 0000000..dcb9bc5 --- /dev/null +++ b/app/git_sync.py @@ -0,0 +1,115 @@ +""" +Projection Markdown durable des memory_entries + commit Git automatique. +SQLite (models.py) reste la source servie en instantane par l'API/MCP ; +ce module ne fait que projeter et versionner, jamais l'inverse (on ne relit +jamais le .md pour repondre a une requete). +""" +import os +import logging +from datetime import datetime, timezone +from typing import Dict, Any, List + +import httpx + +from app.models import list_memory_entries + +logger = logging.getLogger(__name__) + +GITEA_INTERNAL_URL = "http://172.17.0.1:3232" # jamais l'IP LAN depuis un container +GITEA_REPO = "bolbol/context-hub" +GITEA_TOKEN_ENV = "GITEA_TOKEN" + +TYPE_ORDER = ["decision", "constraint", "best-practice", "common-error", "do-not-use"] +TYPE_LABELS = { + "decision": "Decisions", + "constraint": "Contraintes", + "best-practice": "Bonnes pratiques", + "common-error": "Erreurs communes", + "do-not-use": "A ne pas reessayer", +} + + +def _render_entry(e: Dict[str, Any]) -> str: + tags = ", ".join(e.get("tags") or []) + tags_line = f" — tags: {tags}" if tags else "" + return ( + f"- **[{e['id']}]** {e['title']}\n" + f" {e['body']}\n" + f" _cree par {e['created_by']} le {e['created_at']}{tags_line}_\n" + ) + + +def render_scope_markdown(scope: str, entries: List[Dict[str, Any]]) -> str: + generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + lines = [ + f"# Memoire {scope} — genere automatiquement, ne pas editer a la main", + "", + "> Source de verite operationnelle : table `memory_entries` dans context-hub (SQLite + API/MCP).", + "> Ce fichier est une projection durable et versionnee, regeneree a chaque `record_lesson`.", + f"> Derniere generation : {generated_at}", + "", + ] + + by_project: Dict[Any, List[Dict[str, Any]]] = {} + for e in entries: + by_project.setdefault(e.get("project"), []).append(e) + + projects = sorted(by_project.keys(), key=lambda p: (p is not None, p or "")) + + if not entries: + lines.append("_Aucune entree active pour ce scope._") + + for project in projects: + heading = "## Transverse" if project is None else f"## Projet: {project}" + lines.append(heading) + lines.append("") + by_type: Dict[str, List[Dict[str, Any]]] = {} + for e in by_project[project]: + by_type.setdefault(e["type"], []).append(e) + for type_ in TYPE_ORDER: + if type_ not in by_type: + continue + lines.append(f"### {TYPE_LABELS[type_]}") + lines.append("") + for e in sorted(by_type[type_], key=lambda x: x["created_at"], reverse=True): + lines.append(_render_entry(e)) + lines.append("") + + return "\n".join(lines) + + +async def push_scope_markdown(scope: str) -> Dict[str, Any]: + token = os.environ.get(GITEA_TOKEN_ENV) + if not token: + logger.warning("GITEA_TOKEN absent, projection %s.md non poussee", scope) + return {"pushed": False, "reason": "GITEA_TOKEN absent"} + + entries = await list_memory_entries(scope, status="active") + content = render_scope_markdown(scope, entries) + path = f"docs/context-memory/{scope}.md" + url = f"{GITEA_INTERNAL_URL}/api/v1/repos/{GITEA_REPO}/contents/{path}" + headers = {"Authorization": f"token {token}"} + + async with httpx.AsyncClient(timeout=10) as client: + get_resp = await client.get(url, headers=headers) + sha = get_resp.json().get("sha") if get_resp.status_code == 200 else None + + import base64 + payload = { + "message": f"memory: regeneration {scope}.md ({len(entries)} entrees actives)", + "content": base64.b64encode(content.encode()).decode(), + "branch": "main", + } + if sha: + payload["sha"] = sha + put_resp = await client.put(url, headers=headers, json=payload) + else: + put_resp = await client.post(url, headers=headers, json=payload) + + if put_resp.status_code not in (200, 201): + logger.error("Push Gitea echoue pour %s: %s %s", path, put_resp.status_code, put_resp.text[:300]) + return {"pushed": False, "reason": f"HTTP {put_resp.status_code}"} + + commit_sha = put_resp.json().get("commit", {}).get("sha", "")[:8] + logger.info("Projection %s poussee, commit %s", path, commit_sha) + return {"pushed": True, "commit": commit_sha, "path": path}