259 lines
9.7 KiB
Python
259 lines
9.7 KiB
Python
#!/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())
|