diff --git a/.github/workflows/board-sync.yml b/.github/workflows/board-sync.yml new file mode 100644 index 0000000..862ddec --- /dev/null +++ b/.github/workflows/board-sync.yml @@ -0,0 +1,46 @@ +name: board-sync +# 手动/演习驱动 factory-floor 板同步 + dashboard 账本刷新(W1-C3 #166 / ADR-0055 决策 10)。 +# **本工作流刻意不带 schedule**:日常每 15min cron 由 butler-ledger.yml(ADR-0057, +# 唤醒矩阵行 2)统一驱动 governance/board-sync.py 与 governance/dashboard-update.py—— +# 若两处 cron 并存会对同一投影双写竞态;本 dispatch 面 = 演习/手动补偿通道。 +# concurrency 组与 butler-ledger 同名(GitHub concurrency 组按仓全局生效):手动面 +# 与 cron 面互斥串行,杜绝并发写同一 Project/issue。 +on: + workflow_dispatch: {} + +permissions: + contents: read # checkout 本仓两脚本;写操作全走 GOVERNANCE_TOKEN(非 GITHUB_TOKEN) + +concurrency: + group: butler-ledger # 与 butler-ledger.yml 共组(ADR-0055 决策 10——防双写竞态) + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + timeout-minutes: 10 # GraphQL 投影 + SLI 采集的硬上限(宪法 workflow 规范) + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # GOVERNANCE_TOKEN(org admin PAT):board-sync 需 org project 权限(GraphQL), + # dashboard-update 需 .github 仓 issues 写——GITHUB_TOKEN 两者皆无,不分开铸币 + - name: board-sync + dashboard 刷新(fail-closed) + env: + GH_TOKEN: ${{ secrets.GOVERNANCE_TOKEN }} + BUTLER_TRIGGER: ${{ github.event_name }} + run: | + set -uo pipefail + source governance/butler-audit.sh + if ! python3 governance/board-sync.py; then + audit_emit board-sync manual infra-fail '{"rc":"nonzero"}' || true + echo "::error::board-sync.py 失败(fail-closed——投影失败不得静默,ADR-0055)" >&2 + exit 2 + fi + if ! python3 governance/dashboard-update.py; then + audit_emit dashboard-update manual infra-fail '{"rc":"nonzero"}' || true + echo "::error::dashboard-update.py 失败(fail-closed——账本刷新失败不得静默)" >&2 + exit 2 + fi diff --git a/.github/workflows/conductor.yml b/.github/workflows/conductor.yml index 7f13803..0fedd29 100644 --- a/.github/workflows/conductor.yml +++ b/.github/workflows/conductor.yml @@ -2,6 +2,10 @@ name: conductor # 状态机路由器(IR-0001 W0-C3 / ADR-0049)。W0 事件面=本仓(issues.labeled + # issue_comment);跨仓扩展随产品仓接入。全部状态标签写操作以 cloudbrid-agent # App 令牌执行(INV-02:GITHUB_TOKEN 身份不持有状态写权)。 +# W1-C3(ADR-0055 决策 6):写入类 front-desk 命令(/claim /release)前置转介 +# arbiter 裁决(宪法 §11 唤醒矩阵事件行"仲裁请求处理(/claim 等,转 arbiter)"); +# 仲裁是叠加授权层,transitions.yaml 转移表语义不变。三态:0=allow 继续原动作、 +# 1=deny 审计 no-op(对齐 silent-drop)、2=infra run 红灯 fail-closed 不放行。 on: issues: types: [labeled] @@ -9,13 +13,16 @@ on: types: [created] # INV-09:每 issue 一个 concurrency group、cancel-in-progress=false——重复投递 -# 排队串行而非并发竞态;幂等由 from_state 匹配承担(重复事件=当前态已变=no-op) +# 排队串行而非并发竞态;幂等由 from_state 匹配承担(重复事件=当前态已变=no-op)。 +# 同卡两次 /claim 并发的双层防线(ADR-0055):本串行化使第二个 run 排队、届时 +# from_state 已不匹配=no-op(第一层);事件乱序时 arbiter CAS(422=lost-race) +# 兜底恰一胜者(第二层,ADR-0054) concurrency: group: conductor-issue-${{ github.event.issue.number }} cancel-in-progress: false permissions: - contents: read # checkout 本仓(transitions.yaml + gh-app-token.sh) + contents: read # checkout 本仓(transitions.yaml + gh-app-token.sh)+ arbiter(adjudicate.sh,ADR-0055) jobs: route: @@ -30,7 +37,14 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: 铸 App 令牌(AG-2:本仓单仓作用域) + # arbiter 受信 checkout(ADR-0055):conductor 只在 main 上下文运行事件路由, + # arbiter main 与本仓 transitions.yaml 同级信任——裁决内核不落本仓副本 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: Cloudbird-Software/arbiter + path: arbiter + persist-credentials: false + - name: 铸 App 令牌(AG-2:本仓 + arbiter 各一枚单仓作用域) env: CB_APP_ID: ${{ secrets.CB_APP_ID }} AGENT_APP_SECRET: ${{ secrets.AGENT_APP_SECRET }} @@ -38,26 +52,36 @@ jobs: TOKEN=$(REPO=.github CB_APP_ID="$CB_APP_ID" AGENT_APP_SECRET="$AGENT_APP_SECRET" \ bash scripts/gh-app-token.sh) echo "APP_TOKEN=$TOKEN" >>"$GITHUB_ENV" + # 第二枚(ADR-0055):REPO=arbiter 单仓作用域(租约宿主仓,installation + # #154584760)——adjudicate.sh 优先取 env 令牌、免二次铸币 + ATOKEN=$(REPO=arbiter CB_APP_ID="$CB_APP_ID" AGENT_APP_SECRET="$AGENT_APP_SECRET" \ + bash scripts/gh-app-token.sh) + echo "ARBITER_TOKEN=$ATOKEN" >>"$GITHUB_ENV" # 事件路由与守卫:transitions.yaml 是唯一转移定义;guard 受限求值 # (变量白名单注入、无内建);非授权=静默丢弃(回退标签、不评论、审计进 # run 日志——AC-11)。注释/标签正文绝不进入任何求值(命令白名单精确匹配)。 + # W1-C3:/claim(T3)/release 前置转介 arbiter(ADR-0055;参数以 arbiter 仓 + # cli.py 为准);delivery-id=comment node_id(稳定幂等键,重投→arbiter noop)。 - name: route(INV-02/09) id: route env: APP_TOKEN: ${{ env.APP_TOKEN }} + ARBITER_TOKEN: ${{ env.ARBITER_TOKEN }} GOV_TOKEN: ${{ secrets.GOVERNANCE_TOKEN }} EVENT_NAME: ${{ github.event_name }} ACTION: ${{ github.event.action }} LABEL_NAME: ${{ github.event.label.name }} COMMENT_BODY: ${{ github.event.comment.body }} COMMENT_ASSOC: ${{ github.event.comment.author_association }} + COMMENT_NODE_ID: ${{ github.event.comment.node_id }} ACTOR: ${{ github.actor }} ISSUE_NUMBER: ${{ github.event.issue.number }} ISSUE_TITLE: ${{ github.event.issue.title }} REPO: ${{ github.repository }} + RUN_ID: ${{ github.run_id }} run: | python3 - <<'PYEOF' - import json, os, re, urllib.parse, urllib.request, urllib.error, yaml + import json, os, re, subprocess, urllib.parse, urllib.request, urllib.error, yaml E = os.environ ORG, REPO = "Cloudbird-Software", E["REPO"] @@ -88,7 +112,7 @@ jobs: elif E["EVENT_NAME"] == "issue_comment" and E["ACTION"] == "created": head = (E.get("COMMENT_BODY") or "").strip().split() token = head[0] if head else "" - if token in ("/start", "/claim", "/retry"): + if token in ("/start", "/claim", "/retry", "/release"): ev = f"comment:{token}" else: raise SystemExit(0) # 普通评论:无审计面(噪音) @@ -117,13 +141,40 @@ jobs: audit(f"verdict=abort 多状态标签并存: {states}"); raise SystemExit(1) # ---- 转移表匹配(幂等:from_state 不符=no-op)---- + # /release 不在表内(transitions.yaml 未列该事件)——纯租约面命令: + # 直达 arbiter 裁决,不产生任何标签转移(ADR-0055 决策 6) table = yaml.safe_load(open("governance/transitions.yaml", encoding="utf-8")) cands = [t for t in table["transitions"] if t["event"] == ev] t = next((x for x in cands if x["from_state"] == current), None) - if t is None: + if t is None and ev != "comment:/release": audit(f"event={ev} from={current} verdict=noop(无匹配转移——跳态/重复/未列组合)") raise SystemExit(0) + # ---- arbiter 前置裁决(ADR-0055:/claim /release 转介;退出码三态)---- + # cwd 必须是 arbiter checkout 根(python -m arbiter.cli 的包根在那里) + ARBITER_DIR = os.path.join(os.getcwd(), "arbiter") + def adjudicate(command): + argv = ["bash", os.path.join(ARBITER_DIR, "scripts", "adjudicate.sh"), command, + "--card", f"{REPO}#{ISSUE}", "--sender", actor, + "--sender-role", role, + "--delivery-id", E.get("COMMENT_NODE_ID") or f"run-{E.get('RUN_ID', 'unknown')}", + "--event", "created", "--current-state", current, "--backend", "github"] + return subprocess.call(argv, cwd=ARBITER_DIR) + + if ev == "comment:/release": + rc = adjudicate("/release") + if rc == 0: + audit(f"event={ev} from={current} sender_role={role} arbiter=allow " + f"verdict=ALLOWED release(租约已释放——无标签转移定义,纯租约面)") + raise SystemExit(0) + if rc == 1: + audit(f"event={ev} sender_role={role} verdict=DENIED-by-arbiter " + f"(非 holder/无租约/过期——无租约变更、无标签变更)") + raise SystemExit(0) + audit(f"event={ev} arbiter=infra rc={rc} verdict=ABORT " + f"(fail-closed——仲裁器失明不放行;delivery 幂等可安全重投)") + raise SystemExit(1) + # ---- guard 受限求值 ---- env_vars = {"sender_role": role, "author_association": assoc, "label_set": labels} ok = False @@ -143,10 +194,24 @@ jobs: if ev.startswith("label:"): api(E["APP_TOKEN"], f"/repos/{REPO}/issues/{ISSUE}/labels/" + urllib.parse.quote(ev[len("label:"):], safe=""), "DELETE") - audit(f"event={ev} transition={t['id']} sender_role={role} assoc={assoc} " + audit(f"event={ev} transition={t['id']} sender_role={role} assoc={assoc} " f"verdict=DENIED-silent-drop(标签已回退,无评论,无阶段启动)") raise SystemExit(0) + # ---- /claim 前置裁决(guard 先行——为注定拒绝的请求不建租约;ADR-0055)---- + if ev == "comment:/claim": + rc = adjudicate("/claim") + if rc == 1: + audit(f"event={ev} transition={t['id']} sender_role={role} assoc={assoc} " + f"verdict=DENIED-by-arbiter(无标签变更、无 assignee——对齐 silent-drop)") + raise SystemExit(0) + if rc != 0: + audit(f"event={ev} transition={t['id']} arbiter=infra rc={rc} verdict=ABORT " + f"(fail-closed——不许绕过仲裁;delivery 幂等可安全重投)") + raise SystemExit(1) + audit(f"event={ev} transition={t['id']} sender_role={role} arbiter=allow " + f"(租约已建——T3 落地;TTL 到期由下一 /claim 原子接管,ADR-0054)") + # ---- 执行转移(状态标签写=App 身份,INV-02)---- out = open(E["GITHUB_OUTPUT"], "a", encoding="utf-8") action = t["action"] diff --git a/AGENTS.md b/AGENTS.md index 1454742..07cdc5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,36 +1,40 @@ # AGENTS.md -AI agent 进入本仓的工作契约(索引型,CG-1;细节按需读引用文件,不常驻上下文)。 +AI agent 进入本仓的工作契约(索引型,CG-1;治理仓豁免 ≤40 行——协议块+硬规则+索引三合一,ADR-0055 决策 4)。细节按需读引用文件,不常驻上下文。 + + + +### 入口协议(陌生 agent 从这里开始——宪法 §11 / ADR-0055) + +1. 取 ghcb(钉 SHA,禁浮动 main):`curl -sS -o ghcb https://raw.githubusercontent.com/Cloudbird-Software/.github/f72d9520706c8fca974d92456f65cae5c1412bb7/scripts/ghcb && chmod +x ghcb`(凭据用你自己的:`gh auth login` 或 `export GH_TOKEN=`) +2. 找活:`bash ghcb next [owner/repo]` → 列 state:ready 卡(卡 issue 是唯一工作凭证,无卡不开工) +3. 认领:`bash ghcb claim [owner/repo]` → 评论 /claim——conductor 转介 arbiter 原子 CAS 租约,先到先得;败者换下一张(`bash ghcb status ` 看持有者) +4. 开工:`make card-test CARD=`(读卡 AC、测试先行)→ `make gates-pr`(本地复现 CI 关卡) +5. 提 PR:body 必带一行卡元数据 `Card: /#`(`bash ghcb card-meta ` 生成;缺失=后续关卡 exit 3) +6. front-desk 命令(卡 issue 评论,conductor 转介 arbiter 处理):/claim 认领 · /release 释放租约 · /retry 隔离回流 + + ## 硬规则 -- 治理文件(governance/ standards/ scripts/ .github/ CODEOWNERS profile/)= C1 路径:PR 必须引用 ADR-NNNN,owner-only review(GOVERNANCE flows.governance_change;与 gate adr-required 机器检查同路径集) -- agent 写仓库身份 = GitHub App `cloudbrid-agent`(AG-1);令牌经 scripts/gh-app-token.sh,单仓作用域、1h 过期 +- 治理文件(governance/ standards/ scripts/ .github/ CODEOWNERS profile/ Makefile docs/)= C1 路径:PR 必须引用 ADR-NNNN,owner-only review(GOVERNANCE flows.governance_change;与 gate adr-required 机器检查同路径集) +- agent 写仓库身份 = GitHub App `cloudbrid-agent`(AG-1);令牌经 scripts/gh-app-token.sh,单仓作用域、1h 过期(本仓驻留 agent 直接用 `scripts/ghcb`,等价协议块下载版) - 本仓只读治理声明;ADR 与注册条目落盘 agent-registry(REPOS.yaml L1) - 不引入新第三方 Action:白名单见 expected-state.json#actions_policy(CI-2) - 无人值守护栏(ADR-0040,跨仓生效):(a) 每次任务派发与 `gh pr merge --auto` 前,必须检查 org 变量 `AUTO_MERGE_DISABLED`(`gh api /orgs/Cloudbird-Software/actions/variables/AUTO_MERGE_DISABLED --jq .value`,404=未置位)——置位即停一切派发与 automerge,禁止任何绕过尝试;(b) 同一 PR 的修红重试 ≤ policy/automation-limits.yaml `auto_fix.max_attempts`(默认 3),达上限即停手(auto-fix-limit workflow 会关 PR + 开 issue);(c) 不得 reopen 带 `auto-fix-limit-exhausted` 标签的 PR;计数真源 = Checks API(commit 元数据),删标签/重开不重置计数;(d) 派发前确认 .github 仓无未决 `cost-infra`/`cost-circuit-breaker` issue(用量不可知时同样停) -## 常用命令 - -- 校验本仓声明:`.github/workflows/gate.yml`(本地等价:yaml/json 解析 + `bash -n` 各脚本) -- 漂移检测:`GH_TOKEN= bash governance/drift-check.sh`(每日 CI 自动跑) -- 修复循环上限执法:`GH_TOKEN= bash governance/auto-fix-limit.sh`(小时级 CI 自动跑;`AUTOFIX_DRY_RUN=1` 只报告) -- 成本熔断检查:`GH_TOKEN= bash governance/cost-check.sh`(6h CI 自动跑;`COST_USAGE_MINUTES_OVERRIDE=` 注入测试) -- 漂移修复:`GH_TOKEN= bash governance/apply.sh`(幂等;失败 loud 退出) -- 新仓初始化:`bash scripts/new-repo-init.sh `(失败 loud 退出) -- 取 App 令牌:`GH_TOKEN=$(scripts/ghcb )`(缓存命中零网络;`--refresh` 强刷;Windows Git Bash 开箱可用——ADR-0044) -- 找活/认领(ADR-0051):`scripts/ghcb next`(列 state:ready 卡)→ `scripts/ghcb claim `(评论 /claim,conductor 置 state:in-progress)→ `make gates-pr`(本地复现关卡,W1-C5 落地) - -## 索引 - -| 主题 | 文件 | -|---|---| -| 治理总声明(域/措施/流程) | governance/GOVERNANCE.yaml | -| 组织仓库地图 | governance/REPOS.yaml | -| 期望状态(漂移真源) | governance/expected-state.json | -| 语言/依赖政策 | governance/policy/languages.yaml | -| 测试政策 | governance/policy/testing.yaml | -| 无人值守护栏阈值(auto-fix 上限/成本熔断,ADR-0040) | governance/policy/automation-limits.yaml | -| agent 标准 schema | standards/agent/*.schema.yaml | -| 自动化规范(CI 链路 / bot 反馈通道 / App 权限与工作流变更通道,ADR-0031/0032/0045) | standards/automation/ | -| 原型 profiles / 注册条目 | Cloudbird-Software/agent-registry | +## 常用命令(本仓驻留) + +- 校验本仓声明:`.github/workflows/gate.yml`(本地等价:`make gates-pr`——bash -n + yaml 全量解析) +- 漂移检测:`GH_TOKEN= bash governance/drift-check.sh`(每日 CI 自动跑;§17=入口协议块对账) +- 修复循环上限执法:`GH_TOKEN= bash governance/auto-fix-limit.sh`(小时级;`AUTOFIX_DRY_RUN=1` 只报告) +- 成本熔断检查:`GH_TOKEN= bash governance/cost-check.sh`(6h;`COST_USAGE_MINUTES_OVERRIDE=` 注入测试) +- 漂移修复:`GH_TOKEN= bash governance/apply.sh`(幂等;失败 loud 退出)· 新仓初始化:`bash scripts/new-repo-init.sh ` +- 取 App 令牌:`GH_TOKEN=$(scripts/ghcb )`(缓存命中零网络;`--refresh` 强刷,ADR-0044) +- factory-floor 板/账本手动刷新:Actions → board-sync(dispatch-only;日常 cron 归 butler-ledger,ADR-0055) + +## 索引(用到再读) + +- 治理总声明 governance/GOVERNANCE.yaml · 组织地图 governance/REPOS.yaml · 期望状态 governance/expected-state.json +- 政策集 governance/policy/(languages.yaml、testing.yaml、无人值守阈值 automation-limits.yaml ADR-0040;入口协议/卡元数据=ADR-0055) +- agent 标准 schema standards/agent/ · 自动化规范 standards/automation/(ADR-0031/0032/0045)· 注册条目与 ADR → agent-registry 仓 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0543dd1 --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +# .github 治理仓 Makefile(W1-C3 #166 / ADR-0055 决策 11) +# 入口协议块第 4 步(make card-test / make gates-pr)在"卡实际所在仓"的兑现面。 +# 治理仓无产品测试镜像——两目标是诚实薄封装:card-test 拉卡 AC 列表提醒测试先行; +# gates-pr 真实执行 gate.yml 的本地可等价部分(bash -n / yaml 解析),CI 关卡语义 +# 仍以 .github/workflows/gate.yml 为准,不伪装已运行 CI。 +CARD ?= +REPO ?= Cloudbird-Software/.github # 卡所在仓(W1 波次卡都在治理仓;产品仓自有卡时 REPO=... 覆盖) + +.PHONY: card-test gates-pr +card-test: ## 读卡 AC 列表并提示测试先行:make card-test CARD= + @test -n "$(CARD)" || { echo "用法: make card-test CARD=(缺 CARD)" >&2; exit 2; } + @echo "== 卡 $(REPO)#$(CARD) 的 AC(测试先行:先按 AC 写红测试再实现)==" + @gh issue view "$(CARD)" -R "$(REPO)" --json number,title,body \ + --jq '"#\(.number) \(.title)\n\n\(.body)"' 2>/dev/null \ + | awk 'NR==1{print;print ""} /^## AC/{f=1} f{print} f && /^## / && !/^## AC/{exit}' | head -60 + @echo "(空=拉取失败或卡无 AC 节——手动: gh issue view $(CARD) -R $(REPO))" + @echo "== 提示:治理仓改动无产品测试面;用 make gates-pr 自检后再开 PR ==" + +gates-pr: ## 本地等价关卡清单(gate.yml 语义):make gates-pr + @echo "== gates-pr:gate.yml 的本地可等价部分(真实执行;CI 关卡仍以 gate.yml 为准)==" + @bash -n scripts/ghcb scripts/gh-app-token.sh scripts/new-repo-init.sh \ + governance/apply.sh governance/drift-check.sh governance/cost-check.sh \ + governance/auto-fix-limit.sh governance/butler-reconcile.sh governance/butler-audit.sh \ + && echo "OK bash -n 治理脚本" + @python3 -c "import glob,yaml;[yaml.safe_load(open(f,encoding='utf-8')) for f in glob.glob('governance/**/*.yaml',recursive=True)+glob.glob('standards/**/*.yaml',recursive=True)+glob.glob('.github/workflows/*.yml')];print('OK yaml 解析(governance/standards/workflows)')" + @echo "== 开 PR 前检查单(机器不可判部分):PR body 引用 ADR-NNNN(C1)/ body 带 Card: 元数据行 / diff<400 行 ==" diff --git a/governance/REPOS.yaml b/governance/REPOS.yaml index 084e359..cebb90a 100644 --- a/governance/REPOS.yaml +++ b/governance/REPOS.yaml @@ -24,6 +24,7 @@ repos: status: active role: 治理总仓——GOVERNANCE/rulesets/expected-state/policies/agent 标准 schema/初始化与漂移脚本 key_paths: [governance/, standards/agent/, scripts/] + entry_protocol: true # AGENTS.md 携带统一入口协议块(宪法 §11;drift §17 对账,ADR-0055) - name: CI-Workflows layer: L0 @@ -55,8 +56,9 @@ repos: layer: L2 visibility: public status: active - role: 项目模板仓——新仓由此派生,自动继承 gate/护栏/AGENTS.md 骨架 + role: 项目模板仓——新仓由此派生,自动继承 gate/护栏/AGENTS.md 骨架;统一入口协议块下发真源(ADR-0055) key_paths: [AGENTS.md, .github/workflows/ci.yml, docs/ARCHITECTURE.md] + entry_protocol: true # 协议块真源自身(drift §17 以其 main 的块为 canon 比对,ADR-0055) - name: agent-tools layer: L2 diff --git a/governance/board-sync.py b/governance/board-sync.py new file mode 100644 index 0000000..3261e1d --- /dev/null +++ b/governance/board-sync.py @@ -0,0 +1,426 @@ +#!/usr/bin/env python3 +"""board-sync.py —— label→Project 投影板单向同步(宪法 §12 投影一 / ADR-0055 决策 7) + +真相源唯一 = issue label;org Project(v2)「factory-floor」是只读投影: +- 幂等确保项目与字段存在(State 单选=state 全集,颜色取 expected-state.json#labels) +- 对 REPOS.yaml 全部 active 仓的 open 且带 state:* 标签的卡:加/更新项目条目字段=当前 label 态 +- 覆盖前比对:board 字段与 label 不一致 → WARN board-drift + 照 label 纠正(人工改动 + 将被纠正并报警,宪法 §12;纠正本身是设计行为不是故障) +- 已 closed 的条目:v1 状态字段照实设(issue 最终 label 态),不删条目 +- 每 run 输出 AUDIT 行(同步数/纠正数/报警数);任何 API 失败 exit 2(fail-closed: + 投影失明不得伪装成功——butler-ledger 按 infra 处置) + +驱动:butler-ledger.yml 每 15min(唤醒矩阵行 2);board-sync.yml 仅 dispatch 演习面。 +凭据:GH_TOKEN=GOVERNANCE_TOKEN(org admin PAT——GITHUB_TOKEN 无 org project 权限)。 +用法:python3 governance/board-sync.py [--dry-run](dry-run=只读对账+打印计划写,不落任何写) +""" +import datetime as _dt +import json +import os +import re +import sys +import urllib.error +import urllib.request + +try: # REPOS.yaml 解析(CI ubuntu 与治理仓 gate 环境均预装 PyYAML;本地须自备) + import yaml +except ImportError: # pragma: no cover + print("FATAL 缺少 PyYAML(CI 预装;本地 pip install pyyaml)", file=sys.stderr) + raise SystemExit(2) + +ORG = "Cloudbird-Software" +PROJECT_TITLE = "factory-floor" +GH_API = "https://api.github.com" +DRY_RUN = "--dry-run" in sys.argv or os.environ.get("BOARD_SYNC_DRY_RUN") == "1" +TOKEN = os.environ.get("GH_TOKEN") or os.environ.get("GOVERNANCE_TOKEN") or "" +DIR = os.path.dirname(os.path.abspath(__file__)) +NOW = _dt.datetime.now(_dt.timezone.utc) +TRIGGER = os.environ.get("BUTLER_TRIGGER") or "manual" + + +class Infra(Exception): + """API/数据面故障——fail-closed exit 2,不降级继续。""" + + +def _req(url, body=None, method=None, graphql=False): + headers = {"Authorization": f"Bearer {TOKEN}", "User-Agent": "board-sync", + "Accept": "application/vnd.github+json"} + data = None + if body is not None: + data = json.dumps(body).encode() + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, method=method, headers=headers) + try: + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read().decode() + return r.status, (json.loads(raw) if raw.strip() else {}) + except urllib.error.HTTPError as e: + raw = e.read().decode() + try: + return e.code, json.loads(raw) + except Exception: + return e.code, {"message": raw} + except Exception as e: # 传输层失败同样 fail-closed + raise Infra(f"请求失败 {url}: {e}") from e + + +def gql(query, variables): + st, payload = _req(f"{GH_API}/graphql", {"query": query, "variables": variables}, "POST") + if st != 200 or payload.get("errors"): + # 报 message+problems,不回显 input value(含 id 等噪音) + msgs = [] + for e in (payload.get("errors") or [payload])[:3]: + m = e.get("message", str(e))[:200] + probs = "; ".join(f"{p.get('path')}: {p.get('explanation')}" for p in (e.get("extensions") or {}).get("problems") or []) + msgs.append(m + (f" [{probs}]" if probs else "")) + raise Infra(f"GraphQL HTTP {st}: " + " | ".join(msgs)) + return payload["data"] + + +def api_get(path): + st, payload = _req(f"{GH_API}{path}") + if st != 200: + raise Infra(f"GET {path} HTTP {st}: {str(payload.get('message'))[:120]}") + return payload + + +def api_send(method, path, body, ok_codes=(200, 201)): + st, payload = _req(f"{GH_API}{path}", body, method) + if st not in ok_codes: + raise Infra(f"{method} {path} HTTP {st}: {str(payload.get('message'))[:160]}") + return payload + + +# ---------- 期望状态(state 全集与颜色唯一来源:expected-state.json#labels.items) ---------- + +def _hex_to_option_color(hexs): + """expected-state 的 label hex → ProjectV2 单选选项颜色枚举(仅 8 色)。 + + 确定性映射(HSV 色相分桶 + 低饱和→GRAY)——真源仍是 expected-state.json, + 板选项颜色是其最近似展示,不参与任何判定。 + """ + try: + h = hexs.lstrip("#") + r, g, b = (int(h[i:i + 2], 16) / 255 for i in (0, 2, 4)) + except Exception: + return "GRAY" + mx, mn = max(r, g, b), min(r, g, b) + if mx <= 0: # 黑就近 GRAY(枚举无黑) + return "GRAY" + d = mx - mn + s = 0 if mx == 0 else d / mx + if s < 0.10: # 阈值须放过淡彩(BFD4F2 类 pastel 蓝 s≈0.21) + return "GRAY" + if mx == r: + hue = 60 * ((g - b) / d) % 360 + elif mx == g: + hue = 60 * ((b - r) / d) + 120 + else: + hue = 60 * ((r - g) / d) + 240 + if hue < 15 or hue >= 345: + return "RED" + if hue < 45: + return "ORANGE" + if hue < 70: + return "YELLOW" + if hue < 170: + return "GREEN" + if hue < 250: + return "BLUE" + return "PURPLE" + + +def load_states(): + try: + with open(os.path.join(DIR, "expected-state.json"), encoding="utf-8") as f: + items = json.load(f)["labels"]["items"] + except Exception as e: + raise Infra(f"expected-state.json 读取失败: {e}") from e + states = [{ + "name": it["name"][len("state:"):], + "color": _hex_to_option_color(str(it.get("color", ""))), + "description": str(it.get("description") or it["name"][len("state:"):])[:256], + } for it in items if it.get("name", "").startswith("state:")] + if not states: + raise Infra("expected-state.json 无 state:* 标签——期望状态缺失(fail-closed,同 drift §16)") + return states + + +def active_repos(): + try: + with open(os.path.join(DIR, "REPOS.yaml"), encoding="utf-8") as f: + repos = yaml.safe_load(f)["repos"] + except Exception as e: + raise Infra(f"REPOS.yaml 读取失败: {e}") from e + names = [r["name"] for r in repos if r.get("status") == "active"] + if not names: + raise Infra("REPOS.yaml 无 active 仓") + return names + + +# ---------- 卡扫描(REST,真相源=issue label) ---------- + +def scan_cards(repos): + """全部 active 仓 open issue 且带 state:* 标签 → 卡列表(label 是唯一判据)。""" + cards = [] + for repo in repos: + page = 1 + while True: + batch = api_get(f"/repos/{ORG}/{repo}/issues?state=open&per_page=100&page={page}") + for it in batch: + if "pull_request" in it: # issues 端点混入 PR——不是卡 + continue + sl = [l["name"] for l in it.get("labels", []) if str(l.get("name", "")).startswith("state:")] + if not sl: + continue + body = it.get("body") or "" + boxes = re.findall(r"^\s*[-*]\s+\[( |x|X)\]", body, re.M) + cards.append({ + "node_id": it["node_id"], "repo": repo, "number": it["number"], + "title": it["title"], "state": sl[0][len("state:"):], + "assignee": (it.get("assignees") or [{}])[0].get("login", ""), + "url": it["html_url"], "updated_at": it.get("updated_at") or "", + "days_idle": max(0, (NOW - _dt.datetime.fromisoformat( + (it.get("updated_at") or NOW.isoformat()).replace("Z", "+00:00"))).days), + "ac_progress": (f"{sum(1 for b in boxes if b.strip())}/{len(boxes)}" if boxes else ""), + }) + if len(batch) < 100: + break + page += 1 + return cards + + +# ---------- Project(v2) 幂等准备 ---------- + +Q_ORG = """query($org:String!){ organization(login:$org){ + id projectsV2(first:100){ nodes{ id title url } } } }""" +Q_FIELDS = """query($pid:ID!){ node(id:$pid){ ... on ProjectV2 { + fields(first:50){ nodes{ __typename + ... on ProjectV2Field{ id name dataType } + ... on ProjectV2SingleSelectField{ id name options{ id name } } } } } } }""" +Q_ITEMS = """query($pid:ID!,$cur:String){ node(id:$pid){ ... on ProjectV2 { + items(first:100, after:$cur){ pageInfo{ hasNextPage endCursor } nodes{ + id content{ __typename ... on Issue{ id number url state updatedAt + repository{ name } labels(first:20){ nodes{ name } } } } + fieldValues(first:30){ nodes{ __typename + ... on ProjectV2ItemFieldTextValue{ text field{ ...on ProjectV2FieldCommon{ name } } } + ... on ProjectV2ItemFieldNumberValue{ number field{ ...on ProjectV2FieldCommon{ name } } } + ... on ProjectV2ItemFieldSingleSelectValue{ name field{ ...on ProjectV2FieldCommon{ name } } } + } } } } } } }""" +M_CREATE_PROJECT = """mutation($i:CreateProjectV2Input!){ + createProjectV2(input:$i){ projectV2{ id url } } }""" +M_CREATE_FIELD = """mutation($i:CreateProjectV2FieldInput!){ + createProjectV2Field(input:$i){ projectV2Field{ + ... on ProjectV2Field{ id } ... on ProjectV2SingleSelectField{ id } } } }""" +M_UPDATE_FIELD = """mutation($i:UpdateProjectV2FieldInput!){ + updateProjectV2Field(input:$i){ projectV2Field{ + ... on ProjectV2Field{ id } ... on ProjectV2SingleSelectField{ id } } } }""" +M_ADD_ITEM = """mutation($i:AddProjectV2ItemByIdInput!){ + addProjectV2ItemById(input:$i){ item{ id } } }""" # 本 API 版本无 ByContentId 变体(键 projectId/contentId) +M_SET_VALUE = """mutation($i:UpdateProjectV2ItemFieldValueInput!){ + updateProjectV2ItemFieldValue(input:$i){ projectV2Item{ id } } }""" # 输入键=projectId/itemId/fieldId + +FIELD_SPEC = [ # (字段名, 类型)——State 单选(选项=state 全集);中文仓/认领者避开 + # GitHub 保留名("Repo"/"Assignee" 会撞内建 Repository/Assignees → reserved 拒绝) + ("State", "SINGLE_SELECT"), ("仓", "TEXT"), ("认领者", "TEXT"), + ("卡号", "NUMBER"), ("停留天数", "NUMBER"), ("AC 进度", "TEXT"), +] + + +def ensure_project(): + org = gql(Q_ORG, {"org": ORG})["organization"] + if org is None: + raise Infra(f"organization {ORG} 不可见(GOVERNANCE_TOKEN 权限?)") + for p in (org.get("projectsV2") or {}).get("nodes") or []: + if p.get("title") == PROJECT_TITLE: + return p["id"], p.get("url") or "" + if DRY_RUN: + print(f"[dry-run] 将创建 org Project(v2)「{PROJECT_TITLE}」") + return None, "" + node = gql(M_CREATE_PROJECT, {"i": {"ownerId": org["id"], "title": PROJECT_TITLE}})\ + ["createProjectV2"]["projectV2"] + return node["id"], node.get("url") or "" + + +def ensure_fields(pid, states): + """返回 {字段名: field_id};State 单选补齐缺失选项(只增不删——保留既有 item 值)。""" + nodes = gql(Q_FIELDS, {"pid": pid})["node"]["fields"]["nodes"] + by_name = {n["name"]: n for n in nodes if n.get("name")} + out = {} + for name, dtype in FIELD_SPEC: + f = by_name.get(name) + if f is None: + if DRY_RUN: + print(f"[dry-run] 将创建字段 {name}({dtype})") + out[name] = None + continue + inp = {"projectId": pid, "name": name, "dataType": dtype} + if dtype == "SINGLE_SELECT": + # 选项 color=枚举(8 色)+description 必填(均来自 expected-state) + inp["singleSelectOptions"] = [ + {"name": s["name"], "color": s["color"], "description": s["description"]} + for s in states] + f = gql(M_CREATE_FIELD, {"i": inp})["createProjectV2Field"]["projectV2Field"] + elif dtype == "SINGLE_SELECT" and f.get("__typename") != "ProjectV2SingleSelectField": + raise Infra(f"字段 {name} 已存在但类型={f.get('__typename')}(期望单选)——人工核板") + elif dtype != "SINGLE_SELECT" and f.get("dataType") != dtype: + raise Infra(f"字段 {name} 已存在但 dataType={f.get('dataType')}(期望 {dtype})——人工核板") + if dtype == "SINGLE_SELECT": + have = {o["name"]: o["id"] for o in f.get("options") or []} + missing = [s for s in states if s["name"] not in have] + if missing: + # updateProjectV2Field 的 options 输入按名字匹配保留既有选项(不删 + # 不重置),仅补缺失项;既有项 description/color 须回填(必填字段) + desc = {s["name"]: s["description"] for s in states} + color = {s["name"]: s["color"] for s in states} + merged = [{"name": n, "description": desc.get(n, n), "color": color.get(n, "GRAY")} + for n in have] + [ + {"name": s["name"], "color": s["color"], "description": s["description"]} + for s in missing] + if not DRY_RUN: + gql(M_UPDATE_FIELD, {"i": {"projectId": pid, "fieldId": f["id"], + "singleSelectOptions": merged}}) + else: + print(f"[dry-run] State 单选补选项: {[s['name'] for s in missing]}") + out[name] = f["id"] + return out + + +def fetch_items(pid): + """board 现有条目:{(repo, number): {item_id, fields:{名: 当前值}}}。""" + items, cur = {}, None + while True: + node = gql(Q_ITEMS, {"pid": pid, "cur": cur})["node"]["items"] + for it in node["nodes"]: + content = it.get("content") or {} + if content.get("__typename") != "Issue" or not content.get("repository"): + continue # 非 issue 条目(草稿/PR)不参与对账 + vals = {} + for fv in (it.get("fieldValues") or {}).get("nodes") or []: + fname = ((fv.get("field") or {}).get("name")) + if not fname: + continue + if fv["__typename"] == "ProjectV2ItemFieldSingleSelectValue": + vals[fname] = fv.get("name", "") + elif fv["__typename"] in ("ProjectV2ItemFieldTextValue", "ProjectV2ItemFieldNumberValue"): + vals[fname] = fv.get("text", fv.get("number")) + closed_labels = [l["name"] for l in (content.get("labels") or {}).get("nodes") or []] + items[(content["repository"]["name"], content["number"])] = { + "item_id": it["id"], "fields": vals, + "issue_state": content.get("state", ""), + "labels": closed_labels, "url": content.get("url", ""), + } + if not node["pageInfo"]["hasNextPage"]: + return items + cur = node["pageInfo"]["endCursor"] + + +def set_field(pid, item_id, field_id, kind, value): + """单字段写入;value 形态按 kind:text/number/singleSelectOptionId。""" + val = {kind: value} + if not DRY_RUN: + gql(M_SET_VALUE, {"i": {"projectId": pid, "itemId": item_id, + "fieldId": field_id, "value": val}}) + + +def main(): + if not TOKEN: + print("FATAL 需要环境变量 GH_TOKEN=GOVERNANCE_TOKEN(org project 权限)", file=sys.stderr) + return 2 + stats = {"repos": 0, "cards": 0, "added": 0, "updated": 0, + "corrected": 0, "warned": 0, "closed_set": 0, "noop": 0} + try: + states = load_states() + state_names = {s["name"] for s in states} + repos = active_repos() + stats["repos"] = len(repos) + cards = scan_cards(repos) + stats["cards"] = len(cards) + pid, purl = ensure_project() + if pid is None: # dry-run 且项目尚不存在——计划已打印,无从对账 + print(f"AUDIT | butler=board-sync | trigger={TRIGGER} | outcome=ok | dry-run=1 | " + f"actions={json.dumps(stats, ensure_ascii=False)}") + return 0 + fields = ensure_fields(pid, states) + # State 单选选项名→id 映射(用于写入 singleSelectOptionId) + q = gql(Q_FIELDS, {"pid": pid})["node"]["fields"]["nodes"] + state_field = next((n for n in q if n.get("name") == "State"), None) + opt_ids = {o["name"]: o["id"] for o in (state_field or {}).get("options") or []} + board = fetch_items(pid) + for c in cards: + key = (c["repo"], c["number"]) + if c["state"] not in state_names: + print(f"WARN unknown-state {c['repo']}#{c['number']}: label 态 {c['state']} " + f"不在 expected-state 全集——字段照设为文本态名,请修标签") + entry = board.get(key) + preexisting = entry is not None # 报警面只认"板上有旧值"的漂移(新增不算) + if entry is None: + if DRY_RUN: + print(f"[dry-run] 将新增条目 {c['repo']}#{c['number']} " + f"State={c['state']} assignee={c['assignee'] or '-'}") + stats["added"] += 1 + continue + item = gql(M_ADD_ITEM, {"i": {"projectId": pid, "contentId": c["node_id"]}})\ + ["addProjectV2ItemById"]["item"] + entry = {"item_id": item["id"], "fields": {}} + stats["added"] += 1 + want = {"仓": c["repo"], "认领者": c["assignee"] or "", + "卡号": c["number"], "停留天数": c["days_idle"], + "AC 进度": c["ac_progress"]} + have = entry["fields"] + # State 先比对(漂移报警面 = 宪法 §12 人工改动将被纠正;仅对板上 + # 既有条目报警——新增条目无旧值,不算人工改动) + if have.get("State") != c["state"]: + if preexisting: + print(f"WARN board-drift {c['repo']}#{c['number']}: " + f"board={have.get('State')} label={c['state']}" + f"(人工改动将被纠正,宪法 §12)") + stats["warned"] += 1 + stats["corrected"] += 1 + if c["state"] in opt_ids: + set_field(pid, entry["item_id"], fields["State"], + "singleSelectOptionId", opt_ids[c["state"]]) + else: # 未知态兜底:文本写不进单选——报警留观,不 crash + print(f"WARN unknown-state {c['repo']}#{c['number']}: " + f"{c['state']} 无单选选项,跳过 State 写入") + # 空值归一:板上未设(None/键缺失)与期望空串等价(空文本 GitHub 不落值) + diff = [] + for k, v in want.items(): + hv = have.get(k) + if hv is None and v == "": + continue + if hv != v: + diff.append(k) + for k in diff: + kind = "number" if k in ("卡号", "停留天数") else "text" + set_field(pid, entry["item_id"], fields[k], kind, want[k]) + stats["updated"] += 1 + if not diff and have.get("State") == c["state"]: + stats["noop"] += 1 + # 已 closed 的条目:状态照实设(最终 label 态),不删(ADR-0055 决策 7 v1) + card_keys = {(c["repo"], c["number"]) for c in cards} + for key, entry in board.items(): + if key in card_keys or entry.get("issue_state") != "CLOSED": + continue + final = next((n[len("state:"):] for n in entry["labels"] + if n.startswith("state:")), None) + if final and entry["fields"].get("State") != final and final in opt_ids: + if DRY_RUN: + print(f"[dry-run] closed 条目照实设 State={final}: {key}") + else: + set_field(pid, entry["item_id"], fields["State"], + "singleSelectOptionId", opt_ids[final]) + stats["closed_set"] += 1 + except Infra as e: + print(f"AUDIT | butler=board-sync | trigger={TRIGGER} | outcome=infra-fail | " + f"actions={json.dumps(stats, ensure_ascii=False)} | error={e}", flush=True) + print(f"FATAL {e}", file=sys.stderr) + return 2 + print(f"AUDIT | butler=board-sync | trigger={TRIGGER} | outcome=ok | " + f"dry-run={1 if DRY_RUN else 0} | project={purl} | " + f"actions={json.dumps(stats, ensure_ascii=False)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/governance/dashboard-update.py b/governance/dashboard-update.py new file mode 100644 index 0000000..12ad88f --- /dev/null +++ b/governance/dashboard-update.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +"""dashboard-update.py —— 管家账本 dashboard issue 刷新(宪法 §12 投影二 / ADR-0055 决策 8) + +幂等找到/创建 .github 仓 issue「管家账本 dashboard(factory-floor)」(label +`dashboard` 幂等创建);body 两区: +- 机器可读区:`` 标记后 fenced JSON(generated_at、cards[]、 + sli{automerge_rate, human_touch_per_pr, escape_rate, stuck_prs, false_red_rate, + entropy_delta}——字段名与 .github#98 SLI 口径对齐;v1 能算的算,算不了的置 null + 并在 sli_pending 标 "W5-C3") +- 人类一屏摘要区:数字+链接 +更新=issue edit 覆盖 body(内容相同则跳过写);历史靠 issue 编辑历史天然留痕。 +API 失败 exit 2(fail-closed)。驱动:butler-ledger.yml 每 15min;board-sync.yml 演习面。 + +v1 SLI 口径(诚实标注,#98 T2 分母陷阱:零分母→null+N/A,不除零不出 100%): +- automerge_rate:近 7 天 merged PR 中 merged_by==cloudbrid-agent[bot] 占比 + (proxy:App 身份执行合并;timeline 级 auto-merge 事件归 W5-C3) +- stuck_prs:open PR 停留 >24h 数(跨 active 仓求和) +- 其余四项(human_touch_per_pr / escape_rate / false_red_rate / entropy_delta): + 需要 timeline/revert/flaky/熵事件流——置 null + pending W5-C3 +""" +import datetime as _dt +import json +import os +import re +import sys +import urllib.error +import urllib.request + +try: + import yaml +except ImportError: # pragma: no cover + print("FATAL 缺少 PyYAML(CI 预装;本地 pip install pyyaml)", file=sys.stderr) + raise SystemExit(2) + +ORG = "Cloudbird-Software" +HOME_REPO = ".github" +ISSUE_TITLE = "管家账本 dashboard(factory-floor)" +LABEL = {"name": "dashboard", "color": "F9D0C4", + "description": "管家账本投影二(宪法 §12,机器可读 JSON+一屏摘要)"} +GH_API = "https://api.github.com" +DRY_RUN = "--dry-run" in sys.argv or os.environ.get("DASHBOARD_DRY_RUN") == "1" +TOKEN = os.environ.get("GH_TOKEN") or os.environ.get("GOVERNANCE_TOKEN") or "" +DIR = os.path.dirname(os.path.abspath(__file__)) +NOW = _dt.datetime.now(_dt.timezone.utc) +TRIGGER = os.environ.get("BUTLER_TRIGGER") or "manual" +APP_BOT = "cloudbrid-agent[bot]" +JSON_MARK = "" + + +class Infra(Exception): + pass + + +def _req(url, body=None, method=None, ok_codes=(200, 201)): + headers = {"Authorization": f"Bearer {TOKEN}", "User-Agent": "dashboard-update", + "Accept": "application/vnd.github+json"} + data = json.dumps(body).encode() if body is not None else None + if data: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, method=method, headers=headers) + try: + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read().decode() + return r.status, (json.loads(raw) if raw.strip() else {}) + except urllib.error.HTTPError as e: + raw = e.read().decode() + try: + return e.code, json.loads(raw) + except Exception: + return e.code, {"message": raw} + except Exception as e: + raise Infra(f"请求失败 {url}: {e}") from e + + +def get(path): + st, payload = _req(f"{GH_API}{path}") + if st != 200: + raise Infra(f"GET {path} HTTP {st}: {str(payload.get('message'))[:120]}") + return payload + + +def send(method, path, body, ok_codes=(200, 201)): + st, payload = _req(f"{GH_API}{path}", body, method, ok_codes) + if st not in ok_codes: + raise Infra(f"{method} {path} HTTP {st}: {str(payload.get('message'))[:160]}") + return payload + + +def active_repos(): + try: + with open(os.path.join(DIR, "REPOS.yaml"), encoding="utf-8") as f: + repos = yaml.safe_load(f)["repos"] + except Exception as e: + raise Infra(f"REPOS.yaml 读取失败: {e}") from e + names = [r["name"] for r in repos if r.get("status") == "active"] + if not names: + raise Infra("REPOS.yaml 无 active 仓") + return names + + +def _iso(s): + return _dt.datetime.fromisoformat((s or NOW.isoformat()).replace("Z", "+00:00")) + + +def scan_cards(repos): + """与 board-sync.py 同判据的独立轻量扫描(自包含;真相源=issue label)。""" + cards = [] + for repo in repos: + page = 1 + while True: + batch = get(f"/repos/{ORG}/{repo}/issues?state=open&per_page=100&page={page}") + for it in batch: + if "pull_request" in it: + continue + sl = [l["name"] for l in it.get("labels", []) if str(l.get("name", "")).startswith("state:")] + if not sl: + continue + cards.append({"repo": repo, "number": it["number"], "title": it["title"], + "state": sl[0][len("state:"):], + "assignee": (it.get("assignees") or [{}])[0].get("login", ""), + "url": it["html_url"], "updated_at": it.get("updated_at") or "", + "days_idle": max(0, (NOW - _iso(it.get("updated_at"))).days)}) + if len(batch) < 100: + break + page += 1 + return cards + + +Q_MERGED_PRS = """query($o:String!,$r:String!,$cur:String){ + repository(owner:$o,name:$r){ + pullRequests(states:MERGED, first:100, after:$cur, + orderBy:{field:UPDATED_AT,direction:DESC}){ + pageInfo{ hasNextPage endCursor } + nodes{ mergedAt updatedAt mergedBy{ login } } } } }""" + + +def sli_automerge(repos): + """近 7 天 merged PR 中 App 身份合并占比(proxy;零分母→null N/A,#98 T2)。 + + GraphQL 批量取 mergedBy(REST 列表端点不含该字段、逐 PR detail 在 15min + 节奏下配额浪费——ADR-0055 决策 8 的诚实轻量实现)。 + """ + since = NOW - _dt.timedelta(days=7) + merged, auto = 0, 0 + for repo in repos: + cur = None + while True: + body = {"query": Q_MERGED_PRS, + "variables": {"o": ORG, "r": repo, "cur": cur}} + st, payload = _req(f"{GH_API}/graphql", body, "POST") + if st != 200 or payload.get("errors"): + raise Infra(f"GraphQL merged PRs {repo} HTTP {st}: " + + json.dumps(payload.get("errors", payload), ensure_ascii=False)[:200]) + conn = payload["data"]["repository"]["pullRequests"] + page_min_updated = min((_iso(n["updatedAt"]) for n in conn["nodes"]), + default=_dt.datetime(1970, 1, 1, tzinfo=_dt.timezone.utc)) + for n in conn["nodes"]: + if not n.get("mergedAt") or _iso(n["mergedAt"]) < since: + continue + merged += 1 + if (n.get("mergedBy") or {}).get("login") == APP_BOT: + auto += 1 + # 按 UPDATED_AT 倒序翻页:页内最小 updatedAt 已出窗即止——后续页 + # updatedAt 更旧,而 mergedAt<=updatedAt,不可能再有 7 天内合并 + if page_min_updated < since or not conn["pageInfo"]["hasNextPage"]: + break + cur = conn["pageInfo"]["endCursor"] + if merged == 0: + return None, 0 + return round(auto / merged, 4), merged + + +def sli_stuck(repos): + """open PR 停留 >24h 数。""" + cutoff = NOW - _dt.timedelta(hours=24) + stuck = 0 + for repo in repos: + prs = get(f"/repos/{ORG}/{repo}/pulls?state=open&per_page=100") + stuck += sum(1 for pr in prs if _iso(pr.get("created_at")) < cutoff) + return stuck + + +def build_payload(repos, cards, purl=""): + rate, denom = sli_automerge(repos) + sli = {"automerge_rate": rate, "human_touch_per_pr": None, "escape_rate": None, + "stuck_prs": sli_stuck(repos), "false_red_rate": None, "entropy_delta": None} + pending = {"human_touch_per_pr": "W5-C3", "escape_rate": "W5-C3", + "false_red_rate": "W5-C3", "entropy_delta": "W5-C3"} + if rate is None: + pending["automerge_rate"] = "N/A(近 7 天零 merged PR——分母陷阱 #98 T2,不造数)" + return { + "generated_at": NOW.strftime("%Y-%m-%dT%H:%M:%SZ"), + "schema": "dashboard-json v1(ADR-0055;#98 SLI 字段名兼容)", + "project": {"title": "factory-floor", "url": purl}, + "cards": cards, + "sli": sli, + "sli_pending": pending, + "sli_meta": { + "automerge_rate": f"近7天 merged PR 中 merged_by=={APP_BOT} 占比(proxy,W5-C3 换 timeline 事件)", + "automerge_denominator_7d": denom, + "stuck_prs": "open PR 停留>24h(active 仓求和)", + }, + } + + +def render_body(payload): + cards = payload["cards"] + by_state = {} + for c in cards: + by_state.setdefault(c["state"], []).append(c) + state_lines = "\n".join( + f"- {s}: {len(v)} 张(" + " ".join(f"[{c['repo']}#{c['number']}]({c['url']})" for c in v[:8]) + + ("…" if len(v) > 8 else "") + ")" for s, v in sorted(by_state.items())) or "- (队列空)" + sli, meta = payload["sli"], payload["sli_meta"] + rate_txt = f"{sli['automerge_rate']*100:.0f}%(分母 {meta['automerge_denominator_7d']})" \ + if sli["automerge_rate"] is not None else "N/A(零分母)" + human = f"""# 管家账本 dashboard(factory-floor 投影二,宪法 §12 / ADR-0055) + +## 机器可读区(agent 一次读取全局;历史留痕=本 issue 编辑历史) + +{JSON_MARK} +```json +{json.dumps(payload, ensure_ascii=False, indent=2)} +``` + +## 人类一屏摘要 + +- 在制卡:**{len(cards)}** 张(active 仓 open+state:*) +{state_lines} +- factory-floor 板:{payload["project"]["url"] or "(board-sync 首轮后回填链接)"} +- SLI(#98 口径,v1 子集):自动合并率 {rate_txt} · 卡死 PR(>24h){sli['stuck_prs']} +- 待补(W5-C3):人类触碰/PR · 门禁逃逸率 · 假红率 · 熵增——见 sli_pending +- 刷新节奏:butler-ledger 每 15min(唤醒矩阵行 2);手动:workflow_dispatch board-sync +""" + return human + + +def ensure_issue(body): + """幂等找到/创建账本 issue;返回 (number, created)。""" + found = None + page = 1 + while True: + batch = get(f"/repos/{ORG}/{HOME_REPO}/issues?state=open&per_page=100&page={page}") + found = next((i for i in batch if i["title"] == ISSUE_TITLE), None) + if found or len(batch) < 100: + break + page += 1 + if found: + return found["number"], False + # 幂等建 label(422=已存在,容忍) + _req(f"{GH_API}/repos/{ORG}/{HOME_REPO}/labels", + {"name": LABEL["name"], "color": LABEL["color"], "description": LABEL["description"]}, + "POST", ok_codes=(201, 422)) + if DRY_RUN: + print(f"[dry-run] 将创建 dashboard 账本 issue「{ISSUE_TITLE}」") + return None, True + issue = send("POST", f"/repos/{ORG}/{HOME_REPO}/issues", + {"title": ISSUE_TITLE, "body": body, "labels": [LABEL["name"]]}) + return issue["number"], True + + +def project_url(): + """只读取 factory-floor 项目链接(board-sync 已建;失败不阻塞账本——置空)。""" + try: + st, payload = _req(f"{GH_API}/graphql", { + "query": "query($o:String!){ organization(login:$o){ projectsV2(first:100){ nodes{ title url } } } }", + "variables": {"o": ORG}}, "POST") + if st == 200 and not payload.get("errors"): + for p in payload["data"]["organization"]["projectsV2"]["nodes"]: + if p["title"] == "factory-floor": + return p["url"] + except Exception: + pass + return "" + + +def main(): + if not TOKEN: + print("FATAL 需要环境变量 GH_TOKEN=GOVERNANCE_TOKEN", file=sys.stderr) + return 2 + stats = {"cards": 0, "issue": None, "created": 0, "edited": 0, "unchanged": 0} + try: + repos = active_repos() + cards = scan_cards(repos) + stats["cards"] = len(cards) + payload = build_payload(repos, cards, project_url()) + body = render_body(payload) + num, created = ensure_issue(body) + stats["created"] = 1 if created else 0 + stats["issue"] = num + if num is None: # dry-run 新建路径 + print(f"AUDIT | butler=dashboard-update | trigger={TRIGGER} | outcome=ok | " + f"dry-run=1 | actions={json.dumps(stats, ensure_ascii=False)}") + return 0 + if not created: + cur = get(f"/repos/{ORG}/{HOME_REPO}/issues/{num}") + if (cur.get("body") or "").strip() == body.strip(): + stats["unchanged"] = 1 + elif DRY_RUN: + print(f"[dry-run] 将编辑 issue #{num} body({len(body)} 字节)") + stats["edited"] = 1 + else: + send("PATCH", f"/repos/{ORG}/{HOME_REPO}/issues/{num}", {"body": body}) + stats["edited"] = 1 + except Infra as e: + print(f"AUDIT | butler=dashboard-update | trigger={TRIGGER} | outcome=infra-fail | " + f"actions={json.dumps(stats, ensure_ascii=False)} | error={e}", flush=True) + print(f"FATAL {e}", file=sys.stderr) + return 2 + print(f"AUDIT | butler=dashboard-update | trigger={TRIGGER} | outcome=ok | " + f"dry-run={1 if DRY_RUN else 0} | " + f"actions={json.dumps(stats, ensure_ascii=False)}") + print(f"issue: https://github.com/{ORG}/{HOME_REPO}/issues/{stats['issue']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/governance/drift-check.sh b/governance/drift-check.sh index c1ca391..7baf9f0 100644 --- a/governance/drift-check.sh +++ b/governance/drift-check.sh @@ -725,6 +725,54 @@ else [[ $LBL_DRIFT -eq 0 ]] && ok "治理标签全集($LBL_N 项 × 受管仓)一致" fi +# ---------- 17. 统一入口协议块一致性(W1-C3 #166 / ADR-0055,宪法 §11/§4D)---------- +# REPOS.yaml 中 entry_protocol: true 的仓:其 main 的 AGENTS.md 须携带与 +# template-service main(统一下发真源)逐字节一致的协议块—— +# 提取 标记间内容(含标记)比对。 +# 缺失标记/内容不一致/拉取失败 = 漂移(fail-closed:检测器失明不得伪装通过)。 +# 产品仓 rollout(7 个业务仓下发协议块)是后续 fleet 小卡——届时逐仓加 +# entry_protocol: true 即纳入本节管辖。 +PROTO_OK=0 +PROTO_REPOS=$(jq -r '[.repos[] | select((.entry_protocol // false) == true) | .name] | join(" ")' \ + "$DIR/REPOS.yaml" 2>/dev/null || echo "") +proto_block() { # 提取协议块(标记间内容,含首尾标记行)——无标记则输出空 + awk '//{f=1} f{print} //{f=0}' +} +CANON_RAW=$(api "https://raw.githubusercontent.com/$ORG/template-service/main/AGENTS.md" 2>/dev/null || true) +CANON_BLOCK="" +if [[ -z "$CANON_RAW" || "$CANON_RAW" == "404:"* ]]; then + drift "template-service main AGENTS.md 拉取失败(协议块真源不可读——fail-closed)" + PROTO_OK=1 +else + CANON_BLOCK=$(proto_block <<<"$CANON_RAW") + if [[ -z "$CANON_BLOCK" ]]; then + drift "template-service main AGENTS.md 缺协议块或标记不完整(宪法 §4D:块由 template-service 统一下发)" + PROTO_OK=1 + fi +fi +PROTO_N=0 +for r in $PROTO_REPOS; do + PROTO_N=$((PROTO_N+1)) + [[ "$r" == "template-service" ]] && continue # 真源自身即 canon(同 URL 已验) + RAW=$(api "https://raw.githubusercontent.com/$ORG/$r/main/AGENTS.md" 2>/dev/null || true) + if [[ -z "$RAW" || "$RAW" == "404:"* ]]; then + drift "repo '$r' AGENTS.md 拉取失败(协议块对账跳过——fail-closed,ADR-0055 §17)" + PROTO_OK=1 + continue + fi + BLOCK=$(proto_block <<<"$RAW") + if [[ -z "$BLOCK" ]]; then + drift "repo '$r' AGENTS.md 缺协议块标记(entry_protocol 已申报——宪法 §11 统一入口)" + PROTO_OK=1 + continue + fi + if [[ "$BLOCK" != "$CANON_BLOCK" ]]; then + drift "repo '$r' 协议块与 template-service 不一致(首处差异: $(diff <<<"$CANON_BLOCK" <<<"$BLOCK" | head -3 | tr '\n' ' ' | cut -c1-160))" + PROTO_OK=1 + fi +done +[[ $PROTO_OK -eq 0 ]] && ok "统一入口协议块一致(真源 template-service × $PROTO_N 个 entry_protocol 仓,逐字节比对)" + # ---------- 18. holdout 隔离断言(DECISION-02:App 安装差异隔离,W1-C4/ADR-0056)---------- # 试卷层 holdout 的读隔离不靠保密(公开仓,ADR-0056/DECISION-02),靠两条: # cloudbrid-agent App 不安装到该仓(agent 的组织级凭据通道物理不可达)+ 泄漏诱饵 diff --git a/scripts/ghcb b/scripts/ghcb index 4a6357f..40a92cb 100644 --- a/scripts/ghcb +++ b/scripts/ghcb @@ -1,19 +1,33 @@ #!/usr/bin/env bash -# ghcb —— cloudbrid-agent 便捷入口(ADR-0044 令牌 + ADR-0051 找活协议) +# ghcb —— cloudbrid-agent 便捷入口(ADR-0044 令牌 + ADR-0051 找活协议 + ADR-0055 front-desk 扩展) # # 用法: # GH_TOKEN=$(ghcb [--refresh]) # 铸 App 单仓安装令牌(ADR-0044,原用法不变) # ghcb next [repo] # 找活:列出 (缺省=origin 所在仓)state:ready 的卡 # ghcb claim [repo] # 认领:在卡 #n 评论 /claim——conductor 校验先到先得并置 # # state:in-progress(ADR-0049 T3);ghcb 不持有状态写权 +# ghcb release [repo] # 释放:评论 /release——conductor 转介 arbiter 删租约 +# # (仅 holder 本人或 owner 可成,ADR-0054 §4) +# ghcb status [repo] # 只读:卡标签态(真相源)+ arbiter 租约持有者/到期 +# ghcb card-meta [repo] # 输出 PR body 应贴的卡元数据行(Card: /#) # -# next/claim 用调用方自己的 gh 凭据(gh auth login);认领合法性单一真源=conductor guard。 +# next/claim/release 用调用方自己的 gh 凭据(gh auth login);认领合法性单一真源=conductor guard +# + arbiter CAS(ADR-0055 转介)。status 读 arbiter 仓 refs/leases/*(只读,无写权要求)。 set -euo pipefail origin_repo() { # 从 origin 远端推断 owner/name;非 git 仓则空 git remote get-url origin 2>/dev/null | sed -E 's#.*github\.com[:/]##; s#\.git$##' || true } +ORG="Cloudbird-Software" # 本 CLI 是组织内部入口(org 段固定,仓段可省略) +LEAS_REPO="$ORG/arbiter" # 租约宿主仓(arbiter,ADR-0054) + +norm_repo() { # 'repo' | 'owner/repo' → 'owner/repo'(org 段缺省补全) + local r="${1:?}" + [[ "$r" == */* ]] || r="$ORG/$r" + printf '%s' "$r" +} + CMD="${1:-}" case "$CMD" in next) @@ -30,10 +44,45 @@ case "$CMD" in # (实测 "/claim"→"D:/development/Git/claim",conductor 白名单不认); # 非 MSYS 环境该 env 为无害空设 MSYS2_ARG_CONV_EXCL='/claim' MSYS_NO_PATHCONV=1 gh issue comment "$N" --repo "$REPO_ARG" --body "/claim" >/dev/null - echo "已评论 /claim(#$N @$REPO_ARG)——conductor 校验先到先得并置 state:in-progress;确认:gh issue view $N -R $REPO_ARG" + echo "已评论 /claim(#$N @$REPO_ARG)——conductor 转介 arbiter CAS 裁决并置 state:in-progress;确认:ghcb status $N" + ;; + release) + # 释放租约(ADR-0055):/release 评论由 conductor 转介 arbiter——非 holder/无租约=deny + N="${2:?用法: ghcb release [repo]}" + REPO_ARG="${3:-$(origin_repo)}" + [[ -n "$REPO_ARG" ]] || { echo "错误:不在 git 仓内且未指定仓" >&2; exit 2; } + MSYS2_ARG_CONV_EXCL='/release' MSYS_NO_PATHCONV=1 gh issue comment "$N" --repo "$REPO_ARG" --body "/release" >/dev/null + echo "已评论 /release(#$N @$REPO_ARG)——conductor 转介 arbiter 释放租约(仅 holder/owner);确认:ghcb status $N" + ;; + status) + # 只读:标签态(宪法 §12 真相源)+ 租约视图(arbiter refs/leases/____) + N="${2:?用法: ghcb status [repo]}" + REPO_ARG="${3:-$(origin_repo)}" + [[ -n "$REPO_ARG" ]] || { echo "错误:不在 git 仓内且未指定仓" >&2; exit 2; } + FULL="$(norm_repo "$REPO_ARG")"; NAME="${FULL##*/}" + gh issue view "$N" --repo "$FULL" --json number,title,state,labels,assignees \ + --jq '"#\(.number) \(.title)\n 状态: \(.state) | 标签: \([.labels[].name] | join(", ")) | assignee: \([.assignees[].login] | join(", "))"' \ + || { echo "查询失败:检查 gh 凭据与仓权限" >&2; exit 2; } + REF="refs/leases/${ORG}__${NAME}__${N}" + if SHA=$(gh api "repos/$LEAS_REPO/git/ref/$REF" --jq '.object.sha' 2>/dev/null); then + MSG=$(gh api "repos/$LEAS_REPO/git/commits/$SHA" --jq '.message' 2>/dev/null || true) + HOLDER=$(grep -o '"holder": *"[^"]*"' <<<"$MSG" | head -1 | sed 's/.*"holder": *"//; s/"$//' || true) + EXP=$(grep -o '"expires_at": *"[^"]*"' <<<"$MSG" | head -1 | sed 's/.*"expires_at": *"//; s/"$//' || true) + echo " 租约: holder=${HOLDER:-?} expires_at=${EXP:-?}(ref $REF)" + else + echo " 租约: 无活跃租约(ref $REF 不存在)" + fi + ;; + card-meta) + # 输出 PR body 必贴的卡元数据行(ADR-0055 决策 1:缺失=后续关卡 exit 3) + N="${2:?用法: ghcb card-meta [repo]}" + REPO_ARG="${3:-$(origin_repo)}" + [[ -n "$REPO_ARG" ]] || { echo "错误:不在 git 仓内且未指定仓" >&2; exit 2; } + echo "Card: $(norm_repo "$REPO_ARG")#$N" + echo "提示:把上面一行原样贴进 PR body(缺失=后续关卡 exit 3)" >&2 ;; *) - export REPO="${1:?用法: GH_TOKEN=$(ghcb [--refresh]) | ghcb next [repo] | ghcb claim [repo]}" + export REPO="${1:?用法: GH_TOKEN=$(ghcb [--refresh]) | ghcb next [repo] | ghcb claim [repo] | ghcb release [repo] | ghcb status [repo] | ghcb card-meta [repo]}" shift || true DIR="$(cd "$(dirname "$0")" && pwd)" exec bash "$DIR/gh-app-token.sh" "$@"