235 lines
8.8 KiB
Python
235 lines
8.8 KiB
Python
#!/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 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))
|
||
|
||
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", ""))
|
||
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(event.get("branch_count_gitea", 0)),
|
||
"github_branch_count": int(event.get("branch_count_github", 0)),
|
||
"gitea_tag_count": int(event.get("tag_count_gitea", 0)),
|
||
"github_tag_count": int(event.get("tag_count_github", 0)),
|
||
"gitea_main_sha": str(event.get("latest_sha_gitea", "")),
|
||
"github_main_sha": str(event.get("latest_sha_github", "")),
|
||
"blocking_reason": str(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 工具。",
|
||
"",
|
||
"## 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。",
|
||
"",
|
||
]
|
||
)
|
||
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("--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())
|