#!/usr/bin/env python3 """產生 source-control refs reconcile plan 草案。 此工具只讀取既有 redacted snapshot,不呼叫遠端 Git,不 push、不 fetch、不改 remote。 輸出用途是 review / AwoooP mirror / approval candidate,不是 execution plan runner。 """ from __future__ import annotations import argparse import json from pathlib import Path from typing import Any DEFAULT_STILL_FORBIDDEN = [ "push refs", "force push", "delete refs", "create GitHub repo", "change repo visibility", "switch GitHub primary", "disable Gitea", "move secret values", ] DEFAULT_EXECUTION_GATES = [ "Gitea authenticated 或 admin_export server-side repo inventory status=ok", "branch-by-branch SHA diff 已完成", "tag-by-tag SHA diff 已完成", "workflow / webhook / runner / secret 名稱 inventory 已完成", "repo owner / visibility / branch protection / CODEOWNERS 已確認", "rollback plan 與 GitHub primary ADR 已完成", "人工批准只針對單一 repo 生效,不得批次套用到所有 repo", ] def load_json(path: Path) -> dict[str, Any]: return json.loads(path.read_text(encoding="utf-8")) def risk_for_repo(gitea_repo: str) -> str: if gitea_repo == "wooo/awoooi": return "HIGH" return "MEDIUM" def proposed_steps(event: dict[str, Any]) -> list[str]: gitea_repo = str(event.get("gitea_repo", "")) github_repo = str(event.get("github_repo", "")) steps = [ f"針對 `{gitea_repo}` 與 `{github_repo}` 產生 branch-by-branch diff 表。", f"針對 `{gitea_repo}` 與 `{github_repo}` 產生 tag-by-tag diff 表。", "標記每個 diff 的真相來源候選:Gitea、GitHub、人工指定或 deprecated。", "列出 workflow / webhook / runner / secret 名稱差異,只記名稱不記 value。", "產生 dry-run PR / ADR 草案,仍不 push refs。", ] if gitea_repo == "wooo/awoooi": steps.insert( 0, "先確認目前 production deploy 真相來源與 deploy marker 流程,避免主控切換影響發版。", ) return steps def detail_by_repo(path: str | None) -> dict[tuple[str, str], dict[str, Any]]: if not path: return {} payload = load_json(Path(path)) return { (str(item.get("gitea_repo", "")), str(item.get("github_repo", ""))): item for item in payload.get("repos", []) if isinstance(item, dict) } def main_shas_from_detail(detail: dict[str, Any]) -> tuple[str, str]: branch = detail.get("branch_diff", {}) if not isinstance(branch, dict): return "", "" for item in branch.get("sha_mismatch", []): if isinstance(item, dict) and item.get("name") == "main": return str(item.get("gitea_sha", "")), str(item.get("github_sha", "")) for key in ("matching", "only_gitea", "only_github"): for item in branch.get(key, []): if isinstance(item, dict) and item.get("name") == "main": sha = str(item.get("sha", "")) if key == "only_gitea": return sha, "" if key == "only_github": return "", sha return sha, sha return "", "" def build_plan(args: argparse.Namespace) -> dict[str, Any]: events = [load_json(Path(path)) for path in args.source_events] gitea_inventory = load_json(Path(args.gitea_inventory)) details = detail_by_repo(args.ref_detail_diff) inventory_ready = gitea_inventory.get("status") == "ok" inventory_gate_status = "ready" if inventory_ready else "blocked" inventory_reason = ( "Gitea server-side inventory 已完成,可在人工批准後進入更細 diff。" if inventory_ready else "Gitea authenticated / admin_export server-side inventory 尚未完成;本 plan 只能作草案,不可執行 refs sync。" ) plans: list[dict[str, Any]] = [] for event in events: gitea_repo = str(event.get("gitea_repo", "")) github_repo = str(event.get("github_repo", "")) detail = details.get((gitea_repo, github_repo), {}) branch_detail = detail.get("branch_diff", {}) if isinstance(detail, dict) else {} tag_detail = detail.get("tag_diff", {}) if isinstance(detail, dict) else {} detail_gitea_main, detail_github_main = main_shas_from_detail(detail) plans.append( { "gitea_repo": gitea_repo, "github_repo": github_repo, "risk": risk_for_repo(gitea_repo), "source_status": str(event.get("status", "")), "divergence_summary": { "gitea_branch_count": int(branch_detail.get("gitea_count", event.get("branch_count_gitea", 0))), "github_branch_count": int(branch_detail.get("github_count", event.get("branch_count_github", 0))), "gitea_tag_count": int(tag_detail.get("gitea_count", event.get("tag_count_gitea", 0))), "github_tag_count": int(tag_detail.get("github_count", event.get("tag_count_github", 0))), "gitea_main_sha": detail_gitea_main or str(event.get("latest_sha_gitea", "")), "github_main_sha": detail_github_main or str(event.get("latest_sha_github", "")), "blocking_reason": str(detail.get("blocking_reason") or event.get("blocking_reason", "")), }, "proposed_plan_steps": proposed_steps(event), "execution_gates": DEFAULT_EXECUTION_GATES, "allowed_now": [ "更新 read-only evidence", "更新 approval board", "產生 draft reconcile plan", "讓 AwoooP mirror plan 狀態", ], "still_forbidden": DEFAULT_STILL_FORBIDDEN, "evidence_refs": [str(value) for value in event.get("evidence_refs", [])], "awooop_consumption": "approval_candidate", } ) return { "schema_version": "source_control_reconcile_plan_v1", "status": "draft_blocked", "date": args.date, "default_mode": "plan_only", "inventory_gate": { "status": inventory_gate_status, "reason": inventory_reason, "required_before_execution": DEFAULT_EXECUTION_GATES, }, "plan_count": len(plans), "plans": plans, } def short_sha(value: str) -> str: return value[:8] if value else "" def write_markdown(plan: dict[str, Any], path: Path) -> None: gate = plan["inventory_gate"] lines = [ "# Source Control Draft Reconcile Plan", "", "| 項目 | 內容 |", "|------|------|", f"| 日期 | {plan['date']} |", f"| 狀態 | `{plan['status']}` |", f"| 預設模式 | `{plan['default_mode']}` |", f"| inventory gate | `{gate['status']}` |", f"| gate 原因 | {gate['reason']} |", f"| plan count | {plan['plan_count']} |", "", "## 0. 核心結論", "", "這份文件只是 refs reconcile 草案,不是同步腳本,也不授權任何 GitHub primary 切換。AwoooP 可以 mirror 成 approval candidate,但不得執行 board item 或呼叫任何 push / sync 工具。", "", "若已存在 `source_control_ref_truth_classification_v1`,請把它視為本 plan 的人工 review lane 補充:分類結果只協助 repo owner 判定,不授權同步或刪除。", "", "## 1. Repo 差異摘要", "", "| Repo | Risk | Gitea branches | GitHub branches | Gitea tags | GitHub tags | Gitea main | GitHub main |", "|------|------|----------------|-----------------|------------|-------------|------------|-------------|", ] for item in plan["plans"]: diff = item["divergence_summary"] lines.append( "| " + " | ".join( [ f"`{item['gitea_repo']} -> {item['github_repo']}`", f"`{item['risk']}`", f"`{diff['gitea_branch_count']}`", f"`{diff['github_branch_count']}`", f"`{diff['gitea_tag_count']}`", f"`{diff['github_tag_count']}`", f"`{short_sha(diff['gitea_main_sha'])}`", f"`{short_sha(diff['github_main_sha'])}`", ] ) + " |" ) lines.extend(["", "## 2. Draft Plan", ""]) for item in plan["plans"]: diff = item["divergence_summary"] lines.extend( [ f"### {item['gitea_repo']} -> {item['github_repo']}", "", f"- 狀態:`{item['source_status']}`", f"- 阻塞原因:{diff['blocking_reason']}", "- 允許現在做:", ] ) for value in item["allowed_now"]: lines.append(f" - {value}") lines.append("- 草案步驟:") for value in item["proposed_plan_steps"]: lines.append(f" - {value}") lines.append("- 執行前 gate:") for value in item["execution_gates"]: lines.append(f" - {value}") lines.append("- 仍然禁止:") for value in item["still_forbidden"]: lines.append(f" - {value}") lines.append("- Evidence refs:") for value in item["evidence_refs"]: lines.append(f" - `{value}`") lines.append("") lines.extend( [ "## 3. AwoooP 消費方式", "", "1. 只 mirror `source_control_reconcile_plan_v1`。", "2. 只顯示 `draft_blocked` 與 blocking reason。", "3. 可產生 approval candidate,但不得自動批准。", "4. 不得新增 execution action button。", "5. 真相來源分類請讀 `docs/security/SOURCE-CONTROL-REF-TRUTH-CLASSIFICATION.md`,並維持單 repo / 單 ref 人工 gate。", "", ] ) path.write_text("\n".join(lines), encoding="utf-8") def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--date", required=True) parser.add_argument("--gitea-inventory", default="docs/security/gitea-repo-inventory.snapshot.json") parser.add_argument("--ref-detail-diff") parser.add_argument("--source-event", action="append", dest="source_events", required=True) parser.add_argument("--output-json", required=True) parser.add_argument("--output-md", required=True) args = parser.parse_args() plan = build_plan(args) Path(args.output_json).write_text( json.dumps(plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) write_markdown(plan, Path(args.output_md)) print(f"OK source-control reconcile plan count={plan['plan_count']}") return 0 if __name__ == "__main__": raise SystemExit(main())