#!/usr/bin/env python3 """產生 Gitea -> GitHub 逐 repo approval board。 此工具只讀取既有 redacted snapshot,不呼叫 Gitea/GitHub API,不需要 token。 用途是讓 AwoooP / PR reviewer 可以看見每個 repo 的下一個低摩擦決策點。 """ from __future__ import annotations import argparse import json from pathlib import Path from typing import Any def load_json(path: Path) -> dict[str, Any]: return json.loads(path.read_text(encoding="utf-8")) def infer_lane(recommended_action: str) -> str: if recommended_action == "hold_refs_reconcile": return "refs_reconcile" if recommended_action == "confirm_internal_remote_purpose": return "internal_remote_purpose" if recommended_action == "scope_review_only": return "scope_review" return "target_creation_or_access" def required_decision(lane: str) -> str: mapping = { "refs_reconcile": "決定 Gitea / GitHub refs 真相來源,並批准只產生 reconcile plan。", "target_creation_or_access": "決定 GitHub repo owner / visibility / 是否建立或授權既有 repo。", "internal_remote_purpose": "決定 110 internal remote 是 active source、legacy mirror 或應降級。", "scope_review": "決定此 repo 是否屬於 AWOOOI 資安供應鏈範圍。", } return mapping[lane] def low_friction_next_step(lane: str) -> str: mapping = { "refs_reconcile": "先產生 draft reconcile plan,不 push refs、不切 primary。", "target_creation_or_access": "先取得 owner / visibility 決策,不自動建立 repo。", "internal_remote_purpose": "先文件化用途與風險,不刪除 remote、不同步 refs。", "scope_review": "只標記 scope review,不納入主控切換。", } return mapping[lane] def awooop_consumption(lane: str, approval_required: bool) -> str: if lane == "scope_review": return "scope_review_only" if approval_required: return "approval_candidate" return "mirror_only" def build_board(args: argparse.Namespace) -> dict[str, Any]: decisions = load_json(Path(args.github_target_decision)) packages = load_json(Path(args.repo_approval_package)) gitea_inventory = load_json(Path(args.gitea_inventory)) package_by_repo = { str(item.get("github_repo", "")): item for item in packages.get("approval_items", []) if isinstance(item, dict) } board_items: list[dict[str, Any]] = [] pending_count = 0 for decision in decisions.get("decisions", []): if not isinstance(decision, dict): continue github_repo = str(decision.get("github_repo", "")) package = package_by_repo.get(github_repo, {}) approval_required = bool(decision.get("approval_required", False)) if approval_required: pending_count += 1 lane = infer_lane(str(decision.get("recommended_action", ""))) approval_status = str(package.get("approval_status") or ("pending" if approval_required else "not_required")) blocked_until = package.get("blocked_until") or decision.get("blocked_until") or [] evidence_refs = sorted( { *[str(value) for value in decision.get("evidence_refs", [])], *[str(value) for value in package.get("evidence_refs", [])], } ) board_items.append( { "github_repo": github_repo, "source_key": str(decision.get("source_key", "")), "lane": lane, "risk": str(decision.get("risk", "LOW")), "probe_status": str(decision.get("probe_status", "")), "target_state": str(decision.get("target_state", "")), "approval_status": approval_status, "required_decision": required_decision(lane), "low_friction_next_step": low_friction_next_step(lane), "blocked_until": [str(value) for value in blocked_until], "allowed_after_approval": [ str(value) for value in (package.get("allowed_after_approval") or ["mirror_decision_only"]) ], "still_forbidden": [ str(value) for value in ( package.get("still_forbidden") or ["auto_execute", "sync_refs", "switch_primary"] ) ], "evidence_refs": evidence_refs, "awooop_consumption": awooop_consumption(lane, approval_required), } ) inventory_status = str(gitea_inventory.get("status", "blocked")) gate_status = "ready" if inventory_status == "ok" else "blocked" gate_reason = ( "Gitea authenticated 或 admin_export inventory 已完成。" if gate_status == "ready" else "GITEA_READONLY_TOKEN 未提供,且不使用可 push 的既有 remote credential 當 read-only token;server-side private/internal repo list 仍未完成。" ) return { "schema_version": "source_control_approval_board_v1", "status": "draft", "date": args.date, "default_mode": "mirror_only", "authenticated_inventory_gate": { "status": gate_status, "reason": gate_reason, "allowed_next_step": [ "提供 read-only token 後重跑 gitea-repo-inventory", "或提供 redacted admin export JSON", "在 gate 前仍可維護 approval board 與 decision table", ], "still_forbidden": [ "使用 write-capable credential 當作 read-only token", "建立 GitHub repo", "修改 repo visibility", "sync refs", "switch GitHub primary", ], }, "item_count": len(board_items), "pending_approval_count": pending_count, "board_items": board_items, } def write_markdown(board: dict[str, Any], path: Path) -> None: gate = board["authenticated_inventory_gate"] lines = [ "# Source Control Approval Board", "", "| 項目 | 內容 |", "|------|------|", f"| 日期 | {board['date']} |", f"| 狀態 | `{board['status']}` |", f"| 預設模式 | `{board['default_mode']}` |", f"| authenticated inventory gate | `{gate['status']}` |", f"| gate 原因 | {gate['reason']} |", f"| repo items | {board['item_count']} |", f"| pending approval | {board['pending_approval_count']} |", "", "## 0. 核心原則", "", "本 board 只整理決策,不授權執行。AwoooP 可以 mirror 成 approval candidate,但不得建立 repo、修改 visibility、同步 refs、切 GitHub primary 或保存 credential value。", "", "## 1. 逐 repo 決策隊列", "", "| GitHub repo | Lane | Risk | Probe | Approval | 下一步 |", "|-------------|------|------|-------|----------|--------|", ] for item in board["board_items"]: lines.append( "| " + " | ".join( [ f"`{item['github_repo']}`", f"`{item['lane']}`", f"`{item['risk']}`", f"`{item['probe_status']}`", f"`{item['approval_status']}`", item["low_friction_next_step"], ] ) + " |" ) lines.extend(["", "## 2. 詳細阻塞點", ""]) for item in board["board_items"]: lines.extend( [ f"### {item['github_repo']}", "", f"- Source key:`{item['source_key']}`", f"- Required decision:{item['required_decision']}", f"- AwoooP consumption:`{item['awooop_consumption']}`", "- Blocked until:", ] ) for value in item["blocked_until"]: lines.append(f" - {value}") lines.append("- Still forbidden:") 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. Gate 前允許做的事", "", "1. 更新 read-only evidence。", "2. 更新 approval board / decision table。", "3. 寫 draft reconcile plan。", "4. 把 pending approval mirror 到 AwoooP。", "", "## 4. Gate 前仍禁止", "", ] ) for value in gate["still_forbidden"]: lines.append(f"- {value}") lines.append("") path.write_text("\n".join(lines), encoding="utf-8") def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--date", required=True) parser.add_argument("--github-target-decision", default="docs/security/github-target-decision.snapshot.json") parser.add_argument( "--repo-approval-package", default="docs/security/github-target-repo-approval-package.snapshot.json", ) parser.add_argument("--gitea-inventory", default="docs/security/gitea-repo-inventory.snapshot.json") parser.add_argument("--output-json", required=True) parser.add_argument("--output-md", required=True) args = parser.parse_args() board = build_board(args) Path(args.output_json).write_text( json.dumps(board, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) write_markdown(board, Path(args.output_md)) print(f"OK source-control approval board items={board['item_count']}") return 0 if __name__ == "__main__": raise SystemExit(main())