Files
foodlinkk-command-center/tools-api/app/connectors/gitea_repo_sync.py
T
2026-06-09 10:41:13 +00:00

249 lines
8.0 KiB
Python

"""Volledige codebase sync naar Gitea main — SysOps."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from app.db import execute, fetch_all, fetch_one
from app.middleware import log_agent_event
REPO_ROOT = Path(os.getenv("BACKUP_ROOT", "/data/backup-root"))
GITEA_REMOTE = os.getenv(
"GITEA_BACKUP_REMOTE",
"http://sysops:Foodlinkk%23SysOps2026@gitea:3001/aissa/foodlinkk-command-center.git",
)
GITEA_GIT_USER = os.getenv("GITEA_SYSOPS_USER", "sysops")
GITEA_GIT_EMAIL = os.getenv("GITEA_SYSOPS_EMAIL", "sysops@foodlinkk.local")
GITEA_GIT_NAME = os.getenv("GITEA_SYSOPS_NAME", "sysops")
GITEA_BRANCH = os.getenv("GITEA_BACKUP_BRANCH", "main")
SKIP_NAMES = {
".git",
"__pycache__",
".env",
"node_modules",
".cursor",
".venv",
"venv",
"*.pyc",
}
def _iso_now() -> str:
return datetime.now(timezone.utc).isoformat()
def _run(cmd: list[str], cwd: Path | None = None, timeout: int = 180) -> dict[str, Any]:
try:
proc = subprocess.run( # noqa: S603
cmd,
cwd=str(cwd) if cwd else None,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
except subprocess.TimeoutExpired:
return {"ok": False, "error": "timeout", "cmd": cmd}
except FileNotFoundError as exc:
return {"ok": False, "error": str(exc), "cmd": cmd}
return {
"ok": proc.returncode == 0,
"code": proc.returncode,
"stdout": (proc.stdout or "").strip(),
"stderr": (proc.stderr or "").strip(),
"cmd": cmd,
}
def _table_exists(name: str) -> bool:
row = fetch_one(
"""
SELECT EXISTS(
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = %s
) AS ok
""",
(name,),
)
return bool(row and row.get("ok"))
def _record_activity(
action_type: str,
title: str,
body: str = "",
commit_ref: str | None = None,
files_changed: int = 0,
status: str = "success",
metadata: dict | None = None,
) -> dict[str, Any] | None:
if not _table_exists("sysops_activity"):
return None
row = fetch_one(
"""
INSERT INTO sysops_activity (action_type, title, body, commit_ref, files_changed, status, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb)
RETURNING id, created_at
""",
(action_type, title, body, commit_ref, files_changed, status, json.dumps(metadata or {})),
)
return dict(row) if row else None
def list_activity(limit: int = 30) -> list[dict[str, Any]]:
if not _table_exists("sysops_activity"):
return []
rows = fetch_all(
"SELECT * FROM sysops_activity ORDER BY created_at DESC LIMIT %s",
(max(1, min(limit, 100)),),
)
out = []
for r in rows:
item = dict(r)
if hasattr(item.get("created_at"), "isoformat"):
item["created_at"] = item["created_at"].isoformat()
out.append(item)
return out
def _should_skip(name: str) -> bool:
if name in SKIP_NAMES:
return True
if name.endswith(".pyc") or name.endswith(".pyo"):
return True
return False
def _copy_tree(src: Path, dest: Path) -> int:
"""Kopieer repo-inhoud naar git worktree; retour aantal bestanden."""
count = 0
if not src.exists():
return 0
for item in src.iterdir():
if _should_skip(item.name):
continue
target = dest / item.name
if item.is_file():
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(item, target)
count += 1
elif item.is_dir():
if target.exists():
shutil.rmtree(target, ignore_errors=True)
shutil.copytree(
item,
target,
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".env", "node_modules", ".git"),
)
count += sum(1 for _ in target.rglob("*") if _.is_file())
return count
def run_gitea_repo_sync(reason: str = "deploy", approval_request_id: int | None = None) -> dict[str, Any]:
"""Push volledige codebase naar Gitea main branch."""
work = Path("/tmp/foodlinkk-gitea-sync")
if work.exists():
shutil.rmtree(work, ignore_errors=True)
work.mkdir(parents=True, exist_ok=True)
if not REPO_ROOT.exists():
msg = f"Repo root niet gevonden: {REPO_ROOT}"
_record_activity("gitea_sync", "Gitea sync mislukt", msg, status="failed")
return {"ok": False, "message": msg}
repo = work / "repo"
clone = _run(["git", "clone", "--depth", "1", "-b", GITEA_BRANCH, GITEA_REMOTE, str(repo)])
if not clone.get("ok"):
repo = work
_run(["git", "init", "-b", GITEA_BRANCH], cwd=repo)
_run(["git", "remote", "add", "origin", GITEA_REMOTE], cwd=repo)
_run(["git", "config", "user.email", GITEA_GIT_EMAIL], cwd=repo)
_run(["git", "config", "user.name", GITEA_GIT_NAME], cwd=repo)
copied = _copy_tree(REPO_ROOT, repo)
stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
commit_msg = f"SysOps: {reason}{stamp}"
_run(["git", "add", "-A"], cwd=repo)
commit = _run(["git", "commit", "-m", commit_msg], cwd=repo)
nothing = "nothing to commit" in (commit.get("stdout", "") + commit.get("stderr", ""))
if not commit.get("ok") and not nothing:
msg = "Git commit mislukt: " + (commit.get("stderr") or "")
_record_activity("gitea_sync", "Gitea commit mislukt", msg, files_changed=copied, status="failed")
return {"ok": False, "message": msg, "copied": copied}
commit_ref = None
if not nothing:
push = _run(["git", "push", "-u", "origin", GITEA_BRANCH], cwd=repo, timeout=300)
if not push.get("ok"):
msg = "Git push mislukt: " + (push.get("stderr") or "")
_record_activity("gitea_sync", "Gitea push mislukt", msg, files_changed=copied, status="failed")
return {"ok": False, "message": msg, "copied": copied}
rev = _run(["git", "rev-parse", "--short", "HEAD"], cwd=repo)
commit_ref = rev.get("stdout") or None
summary = (
f"Volledige codebase gesynchroniseerd naar Gitea ({reason}). "
f"{copied} bestanden · commit {commit_ref or 'geen wijzigingen'}"
)
activity = _record_activity(
"gitea_sync",
f"Gitea push: {reason}",
summary,
commit_ref=commit_ref,
files_changed=copied,
status="success" if (commit_ref or nothing) else "skipped",
metadata={"reason": reason, "approval_request_id": approval_request_id, "nothing_to_commit": nothing},
)
try:
log_agent_event(
agent_name="sysops",
agent_type="sysops",
event_type="gitea_sync",
title=f"Gitea sync: {reason}" + (f" ({commit_ref})" if commit_ref else " — geen wijzigingen"),
body=summary,
metadata={
"commit_ref": commit_ref,
"files_changed": copied,
"reason": reason,
"for_herman": True,
},
channel="sysops",
)
except Exception:
pass
if _table_exists("config_backups"):
fetch_one(
"""
INSERT INTO config_backups (approval_request_id, commit_ref, files_count, status, message, payload)
VALUES (%s, %s, %s, %s, %s, %s::jsonb)
RETURNING id
""",
(
approval_request_id,
commit_ref,
copied,
"success" if commit_ref else "unchanged",
summary,
json.dumps({"reason": reason, "full_repo": True}),
),
)
return {
"ok": True,
"message": summary,
"commit_ref": commit_ref,
"files_count": copied,
"nothing_to_commit": nothing,
"activity_id": (activity or {}).get("id"),
}