diff --git a/.github/workflows/butler-ledger.yml b/.github/workflows/butler-ledger.yml index 0bb45e2..c651d8d 100644 --- a/.github/workflows/butler-ledger.yml +++ b/.github/workflows/butler-ledger.yml @@ -1,8 +1,9 @@ name: butler-ledger # 管家账本刷新——唤醒矩阵行 2(宪法 §11 行 2 / §12 投影;ADR-0057,W1-C5 .github#168) -# 每 15min 调用 W1-C3 的投影脚本:governance/board-sync.py(label→Project 板)与 -# governance/dashboard-update.py(dashboard 账本 issue 刷新)。**两脚本由 C3 卡并行 -# 开发、本卡时点尚未落盘**——用 [ -f ] 守卫:存在才跑;不存在输出 +# 每 15min 调用投影脚本:governance/board-sync.py(label→Project 板)、 +# governance/dashboard-update.py(dashboard 账本 issue 刷新)与 +# governance/feishu-sync.py(W3-F1 飞书多维表格出站投影,fail-open AC-7b)。 +# 前两者由 C3 卡并行开发、本卡时点尚未落盘——用 [ -f ] 守卫:存在才跑;不存在输出 # skipped 审计行且保持绿(守卫原因:账本刷新骨架先行——cron 节奏与审计形态先定型, # 投影脚本随后合入即自动生效,两卡解耦不互相阻塞)。 # 骨架期本卡自带轻量记账:每次运行无条件追加一条 dashboard 备注行(v1 仅审计日志, @@ -31,6 +32,10 @@ jobs: - name: 投影脚本守卫调用 + 轻量记账 env: GH_TOKEN: ${{ secrets.GOVERNANCE_TOKEN }} # C3 投影脚本的跨仓/GraphQL 读 + # W3-F1 飞书投影凭据(INV-04:org secrets 注入,缺省=not-provisioned skipped 绿) + FEISHU_APP_ID: ${{ secrets.FEISHU_APP_ID }} + FEISHU_APP_SECRET: ${{ secrets.FEISHU_APP_SECRET }} + FEISHU_BITABLE_APP_TOKEN: ${{ secrets.FEISHU_BITABLE_APP_TOKEN }} BUTLER_TRIGGER: ${{ github.event_name }} run: | set -uo pipefail @@ -64,5 +69,17 @@ jobs: echo "OK governance/dashboard-update.py 不存在(W1-C3 未合并)——skipped" audit_emit ledger-refresh "$TRIGGER" ok '{"skipped":"dashboard-scripts-not-landed(W1-C3)","dashboard_update":"absent"}' fi + # --- W3-F1 投影脚本三:feishu-sync.py(fail-open,AC-7b) --- + # 出站投影(§12 第四投影)——与 board-sync/dashboard 的 fail-closed 不同: + # 飞书面故障只降级不判红(exit≠0 → ::warning:: + 继续其余账本刷新—— + # 投影面故障不得阻塞其余投影与判定链)。审计由脚本自身经 butler-audit.sh + # 发射(AUDIT 行 + 影子随 runner 销毁=丢弃层,同 butler 哲学),本层不重复记账 + if [[ -f governance/feishu-sync.py ]]; then + if ! python3 governance/feishu-sync.py; then + echo "::warning::feishu-sync.py 失败(fail-open,AC-7b——降级回 GitHub dashboard,不阻塞其余投影)" + fi + else + echo "OK governance/feishu-sync.py 不存在(W3-F1 未合并)——skipped" + fi # --- 本卡自有轻量记账(无条件):dashboard 备注行 v1=审计日志形态 --- audit_emit ledger-refresh "$TRIGGER" ok '{"ledger":"append","note_row":{"ts":"'"$(date -u +%FT%TZ)"'","run_id":"'"${GITHUB_RUN_ID:-local}"'","kind":"dashboard-remark-v1","sli_keys_reserved":["auto_merge_rate","check_latency","revert_count"]}}' diff --git a/.github/workflows/feishu-drill.yml b/.github/workflows/feishu-drill.yml new file mode 100644 index 0000000..c93468c --- /dev/null +++ b/.github/workflows/feishu-drill.yml @@ -0,0 +1,82 @@ +name: feishu-drill +# 飞书投影演练面(宪法 §12 第四投影 / IR-0006 W3-F1 / 卡 #416 / ADR-0103 决策 7) +# 手动 dispatch 驱动(无 cron——15min 日常节奏归 butler-ledger.yml 守卫调用, +# 本面只做演练与复盘:sync 常规收敛 / verify 只读对账(不收敛=红,INV-05 演练)/ +# rebuild 整表删除单轮重建(BEH-06 演练语义)。影子事件 relink 持久化到 +# feishu-ledger 分支=evidence-query 第 6 源(append-only 台账,双侧验链防覆盖 +# 掩盖篡改,同 butler-ledger 哲学);BUTLER_CARD 绑 #416——演练可追溯锚点。 +on: + workflow_dispatch: + inputs: + mode: + description: "演练模式:sync=常规同步 | verify=只读对账(漂移=红) | rebuild=删表重建" + required: true + default: "sync" + type: choice + options: [sync, verify, rebuild] + +permissions: {} + +concurrency: + group: feishu-drill # 串行化:feishu-ledger append-only 不容忍并发追加 + cancel-in-progress: false + +jobs: + drill: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read # 读 governance/ 脚本 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: 投影演练(mode→feishu-sync 参数映射;verify 漂移=红) + env: + GH_TOKEN: ${{ secrets.GOVERNANCE_TOKEN }} # label 真源读(缺=exit 2 fail-closed) + FEISHU_APP_ID: ${{ secrets.FEISHU_APP_ID }} + FEISHU_APP_SECRET: ${{ secrets.FEISHU_APP_SECRET }} + FEISHU_BITABLE_APP_TOKEN: ${{ secrets.FEISHU_BITABLE_APP_TOKEN }} + BUTLER_TRIGGER: drill + BUTLER_CARD: Cloudbird-Software/.github#416 + BUTLER_TENANT: cloudbird-internal + MODE: ${{ inputs.mode }} + run: | + set -euo pipefail + ARGS=() + case "$MODE" in + sync) ARGS=() ;; # 常规收敛(默认路径=日常 15min 同语义) + verify) ARGS=(--verify) ;; # 只读对账:未收敛 exit 3=红(fail-closed) + rebuild) ARGS=(--drop) ;; # 删表+单轮重建(BEH-06 演练) + esac + python3 governance/feishu-sync.py "${ARGS[@]}" + - name: 影子账本落盘(feishu-ledger 分支,IR-0006 W3-F1 / BEH-03) + # feishu 源影子持久化:本地 shadow-evidence.jsonl relink 续接 feishu-ledger + # 基链(双侧验链,防覆盖掩盖篡改)后写回。always():verify 红(exit 3)时 + # 审计事件同样必须落账(append-only 纪律优先于本轮结论)。 + if: ${{ always() }} + env: + DRILL_TOKEN: ${{ secrets.GOVERNANCE_TOKEN }} + run: | + set -euo pipefail + SHADOW="governance/feishu/shadow-evidence.jsonl" + if [[ ! -s "$SHADOW" ]]; then + echo "OK 本轮无影子记录(audit_emit 未触发或 skipped)——跳过(幂等)" + exit 0 + fi + git config --global user.name feishu-drill-bot && git config --global user.email feishu-bot@users.noreply.github.com + if ! git clone --depth 1 "https://x-access-token:${DRILL_TOKEN}@github.com/Cloudbird-Software/.github.git" ledger -b feishu-ledger 2>/dev/null; then + git clone --depth 1 "https://x-access-token:${DRILL_TOKEN}@github.com/Cloudbird-Software/.github.git" ledger + git -C ledger checkout -b feishu-ledger + fi + BASE="ledger/$SHADOW" + [[ -f "$BASE" ]] || : > "$BASE" + python3 governance/evidence_shadow.py relink --base "$BASE" --local "$SHADOW" --out merged.jsonl + python3 governance/evidence_shadow.py verify --file merged.jsonl + mkdir -p "ledger/governance/feishu" + cp merged.jsonl "$BASE" + git -C ledger add "$SHADOW" + git -C ledger diff --cached --quiet && { echo "OK 影子无新增——不提交(幂等)"; exit 0; } + git -C ledger commit -m "feishu: 影子账本追加(IR-0006 W3-F1 演练,链验通过)" + for i in 1 2 3; do git -C ledger push "https://x-access-token:${DRILL_TOKEN}@github.com/Cloudbird-Software/.github.git" HEAD:refs/heads/feishu-ledger && break + git -C ledger pull --rebase "https://x-access-token:${DRILL_TOKEN}@github.com/Cloudbird-Software/.github.git" feishu-ledger || true; sleep 5; done diff --git a/.gitignore b/.gitignore index eb9685c..0bfc2f8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,8 @@ __pycache__/ .ciw/ env-defs/ .scratch/ + +# 本地运行 butler/feishu 审计产生的影子账本(持久化真源在 *-ledger 分支, +# 工作树内的副本是 ephemeral 运行产物——不入库) +governance/butler/shadow-evidence.jsonl +governance/feishu/shadow-evidence.jsonl diff --git a/governance/butler-audit.sh b/governance/butler-audit.sh index 7f4c06e..f8eeaf6 100644 --- a/governance/butler-audit.sh +++ b/governance/butler-audit.sh @@ -128,7 +128,9 @@ _shadow_emit() { here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" shadow="${BUTLER_SHADOW_FILE:-$here/butler/shadow-evidence.jsonl}" evf="$(mktemp)" - "$_BUTLER_PY" - "$evf" "$butler" "$outcome" <<'PYEOF' || ev_rc=$? + # BUTLER_SHADOW_PAYLOAD(可选,feishu-sync):动作明细进影子 payload(≤4KB, + # evidence_shadow append 执法 INV-06 超限拒写);缺省=无 payload(原行为不变) + "$_BUTLER_PY" - "$evf" "$butler" "$outcome" "${BUTLER_SHADOW_PAYLOAD:-}" <<'PYEOF' || ev_rc=$? import datetime, json, sys ev = { "ts": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), @@ -137,6 +139,8 @@ ev = { "tenant": __import__("os").environ.get("BUTLER_TENANT", "cloudbird-internal")}, "actor": {"identity": sys.argv[2], "role": "bot", "model": None}, } +if len(sys.argv) > 4 and sys.argv[4]: + ev["payload"] = sys.argv[4] open(sys.argv[1], "w", encoding="utf-8").write(json.dumps(ev, ensure_ascii=False)) PYEOF if [[ $ev_rc -ne 0 ]]; then diff --git a/governance/evidence-query.sh b/governance/evidence-query.sh index 3693dfd..b420612 100644 --- a/governance/evidence-query.sh +++ b/governance/evidence-query.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# evidence-query.sh —— 四源统一证据查询(IR-0006 W1-B2 / BEH-03 / ADR-0103,AC-4a) +# evidence-query.sh —— 六源统一证据查询(IR-0006 W1-B2/W3-F1 / BEH-03 / ADR-0103,AC-4a) # # 一条命令跨源拉取 schema v1 影子账本、逐源验链(fail-closed:链断=红)、 # 按时间归并输出统一 JSONL(stdout)+ 分源统计(stderr): @@ -11,6 +11,10 @@ # 源 5 tickets Cloudbird-Software/cnb-bridge @ tickets-ledger tickets.jsonl # (W2-C1 内网调度器短票据 grant/revoke——AC-5b 统一账本;Go 发射器产出, # 链形态与 evidence_shadow.py 逐字节兼容,金向量锚定) +# 源 6 feishu Cloudbird-Software/.github @ feishu-ledger governance/feishu/shadow-evidence.jsonl +# (W3-F1 飞书多维表格投影同步/对账/重建演练事件——payload 带每轮 api_calls +# 计数=AC-7a 调用账本可查询锚点;日常 15min 轮影子随 runner 销毁=丢弃层, +# 本源只含 drill 持久化轮) # # 用法: # bash governance/evidence-query.sh [--card owner/repo#n] [--json] # --json=汇总行也走 stdout @@ -74,21 +78,23 @@ DRILL_OK=0; fetch_file "Cloudbird-Software/.github" "drill-ledger" "governance/d BUTLER_OK=0; fetch_file "Cloudbird-Software/.github" "butler-ledger" "governance/butler/shadow-evidence.jsonl" "$TMP/butler.jsonl" && BUTLER_OK=1 || [[ $? -eq 1 ]] || exit 2 ELEV_OK=0; fetch_file "Cloudbird-Software/.github" "elevation-ledger" "governance/elevation/shadow-evidence.jsonl" "$TMP/elev.jsonl" && ELEV_OK=1 || [[ $? -eq 1 ]] || exit 2 TICKET_OK=0; fetch_file "Cloudbird-Software/cnb-bridge" "tickets-ledger" "tickets.jsonl" "$TMP/tickets.jsonl" && TICKET_OK=1 || [[ $? -eq 1 ]] || exit 2 +FEISHU_OK=0; fetch_file "Cloudbird-Software/.github" "feishu-ledger" "governance/feishu/shadow-evidence.jsonl" "$TMP/feishu.jsonl" && FEISHU_OK=1 || [[ $? -eq 1 ]] || exit 2 # ---- 逐源验链 + 归并输出(链断=exit 3:不可信数据不出结果) ---- -export CARD_FILTER JSON_ONLY DRILL_OK BUTLER_OK ELEV_OK TICKET_OK -python3 - "$DIR/evidence_shadow.py" "$SRC_METER" "$TMP/drill.jsonl" "$TMP/butler.jsonl" "$TMP/elev.jsonl" "$TMP/tickets.jsonl" "$TMP" <<'PYEOF' +export CARD_FILTER JSON_ONLY DRILL_OK BUTLER_OK ELEV_OK TICKET_OK FEISHU_OK +python3 - "$DIR/evidence_shadow.py" "$SRC_METER" "$TMP/drill.jsonl" "$TMP/butler.jsonl" "$TMP/elev.jsonl" "$TMP/tickets.jsonl" "$TMP/feishu.jsonl" "$TMP" <<'PYEOF' import glob, json, os, sys sys.path.insert(0, os.path.dirname(os.path.abspath(sys.argv[1]))) import evidence_shadow # noqa: E402 验链与 CI-Workflows 侧同源语义 -metering_dir, drill_f, butler_f, elev_f, tickets_f, tmp = sys.argv[2:8] +metering_dir, drill_f, butler_f, elev_f, tickets_f, feishu_f, tmp = sys.argv[2:9] sources = {"metering": sorted(glob.glob(os.path.join(metering_dir, "shadow-evidence-*.jsonl"))), "drill": [drill_f] if os.environ.get("DRILL_OK") == "1" else [], "butler": [butler_f] if os.environ.get("BUTLER_OK") == "1" else [], "elevation": [elev_f] if os.environ.get("ELEV_OK") == "1" else [], - "tickets": [tickets_f] if os.environ.get("TICKET_OK") == "1" else []} + "tickets": [tickets_f] if os.environ.get("TICKET_OK") == "1" else [], + "feishu": [feishu_f] if os.environ.get("FEISHU_OK") == "1" else []} errs, recs = [], [] for src, files in sources.items(): for f in files: @@ -111,7 +117,7 @@ for r in out: summary = { "total": len(out), - "by_source": {s: sum(1 for r in out if r["source"] == s) for s in ("metering", "drill", "butler", "elevation", "tickets")}, + "by_source": {s: sum(1 for r in out if r["source"] == s) for s in ("metering", "drill", "butler", "elevation", "tickets", "feishu")}, "by_tenant": {}, "by_card_top": {}, } diff --git a/governance/feishu-sync.py b/governance/feishu-sync.py new file mode 100644 index 0000000..8b4179e --- /dev/null +++ b/governance/feishu-sync.py @@ -0,0 +1,378 @@ +#!/usr/bin/env python3 +"""feishu-sync.py —— 飞书多维表格投影同步器(IR-0006 W3-F1 / 卡 #416 / ADR-0103 决策 7) + +宪法 §12 第四投影(outbound-only):飞书多维表格 = factory-floor 的物化视图, +**label 唯一真源(INV-05)**——人工在表格上的修改被下一轮投影纠正(以 label 为 +准并告警);整表删除后单轮同步内重建(find_or_create_table 幂等)。 + +- 数据面与 board-sync.py 完全同源:复用 scan_cards/enrich_cards(label 真源扫描, + IFACE-04 字段=卡 ID/State/仓/认领者/卡号/停留天数/AC 进度/关卡状态/谓词状态) +- 节奏(BEH-06):butler-ledger.yml 每 15min 守卫调用(fail-open——投影面故障 + 不阻塞其余投影与判定链,AC-7b);feishu-drill.yml 演习面 +- 飞书 API 调用账本(AC-7a):每轮运行按端点计数进 AUDIT 行(Actions 运行日志= + 带 run_id 的不可变第三方台账,ADR-0057 决策 2);影子事件按 schema v1 落 + governance/feishu/shadow-evidence.jsonl(drill relink 持久化到 feishu-ledger + 分支=evidence-query 第 6 源;15min 轮影子随 runner 销毁=丢弃层,同 butler 哲学) +- 凭据纪律(INV-04):app_id/secret/app_token 只从环境(org secrets)注入, + agent/PM 上下文零凭据;缺省=投影未开通——skipped 绿(过渡期合法,同 + board-sync 落地守卫模式) + +用法: + python3 governance/feishu-sync.py # 同步(默认) + python3 governance/feishu-sync.py --verify # 只读对账:有漂移 exit 3(fail-closed) + python3 governance/feishu-sync.py --dry-run # 只读计划(打印,不写不判红) + python3 governance/feishu-sync.py --drop # 先删表再同步(drop & rebuild 单轮重建) +env: + GH_TOKEN GitHub 读(label 真源;缺=exit 2 fail-closed) + FEISHU_APP_ID / FEISHU_APP_SECRET / FEISHU_BITABLE_APP_TOKEN org secrets + FEISHU_SYNC_DISABLED=1 停用投影(降级回 GitHub dashboard,AC-7b) + FEISHU_TABLE_NAME 数据表名(缺省 factory-floor) + BUTLER_CARD / BUTLER_TENANT 影子事件 subject(drill 绑卡号;日常=哨兵 #0) + GH_API_BASE / FEISHU_API_BASE API base 覆盖(测试桩专用;缺省公网端点) +退出码: 0=同步成功/skipped | 2=基础设施故障(GitHub 读/飞书 API/审计) | 3=--verify 漂移 +""" +import collections +import importlib.util +import json +import os +import subprocess +import sys +import time +import urllib.error +import urllib.request + +DIR = os.path.dirname(os.path.abspath(__file__)) + + +def _load(name, path): + """连字符脚本名的模块加载(board-sync.py 不可直接 import——同目录约定)。""" + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +board_sync = _load("board_sync", os.path.join(DIR, "board-sync.py")) # label 真源扫描——与 org Project 投影完全同源 +FEISHU_API = os.environ.get("FEISHU_API_BASE", "https://open.feishu.cn").rstrip("/") +TABLE_NAME = os.environ.get("FEISHU_TABLE_NAME", "factory-floor") +APP_ID = os.environ.get("FEISHU_APP_ID", "") +APP_SECRET = os.environ.get("FEISHU_APP_SECRET", "") +APP_TOKEN = os.environ.get("FEISHU_BITABLE_APP_TOKEN", "") +TOKEN = board_sync.TOKEN # board_sync 在 import 时已读 GH_TOKEN/GOVERNANCE_TOKEN +TRIGGER = os.environ.get("BUTLER_TRIGGER") or "manual" +DRY_RUN = "--dry-run" in sys.argv +VERIFY = "--verify" in sys.argv +DROP = "--drop" in sys.argv +# 测试注入面:board_sync 的 GitHub base 可重定向(生产缺省公网 api.github.com) +board_sync.GH_API = os.environ.get("GH_API_BASE", board_sync.GH_API).rstrip("/") + +# 飞书多维表格字段类型(创建表用):1=文本 2=数字 3=单选 +_TEXT, _NUM, _SELECT = 1, 2, 3 + +# IFACE-04 schema——与 factory-floor 板字段同源(board-sync.py FIELD_SPEC); +# 飞书侧多一列「卡 ID」(repo#n 唯一键:记录对账锚点,无 GitHub content 直链) +FIELD_SPEC = [ + ("卡 ID", _TEXT), ("State", _SELECT), ("仓", _TEXT), ("认领者", _TEXT), + ("卡号", _NUM), ("停留天数", _NUM), ("AC 进度", _TEXT), + ("关卡状态", _TEXT), ("谓词状态", _TEXT), +] +# 漂移报警面=人工可在表格上改的字段(INV-05:人工改动将被纠正+告警); +# 停留天数/卡号/卡 ID/仓=每轮派生刷新(日增/重建),进报警面=每天误报淹没信号 +# ——与 board-sync BOARD_DRIFT_FIELDS 同口径 +FEISHU_DRIFT_FIELDS = ("State", "认领者", "AC 进度", "关卡状态", "谓词状态") + + +class Infra(Exception): + """API/数据面故障——exit 2(fail-closed:投影失明不得伪装成功)。""" + + +# @w3f1-pure-begin —— 投影纯函数区(governance/tests/test-feishu-sync.sh 离线单测直查本模块) + +def want_fields(card): + """卡(board-sync scan/enrich 产物)→ 飞书行字段期望值(label/派生真源)。""" + return {"卡 ID": f"{card['repo']}#{card['number']}", "State": card["state"], + "仓": card["repo"], "认领者": card["assignee"] or "", + "卡号": card["number"], "停留天数": card["days_idle"], + "AC 进度": card["ac_progress"], "关卡状态": card["gate_status"], + "谓词状态": card["predicate_status"]} + + +def _eq(have, want): + """空值与未设等价(飞书空文本不落值=键缺失/null);数字 416==416.0 等价。""" + if have is None: + return want in ("", None) + return have == want + + +def plan_sync(cards, records, state_options): + """label 真源卡列表 × 飞书现有行 → 同步计划(纯函数,离线可测)。 + + cards = board_sync scan/enrich 产物列表 + records = {卡 ID: {"record_id", "fields"}}(list_records 产物) + state_options = State 单选选项名集合(expected-state 全集) + 返回 {to_create, to_update, to_delete, drift_alarms, unknown_states}: + - to_create: [{"fields": 全字段}](飞书无对应行) + - to_update: [{"record_id", "fields": 差异字段}](含派生刷新与人工漂移纠正) + - to_delete: [record_id](行对应的卡已 closed/消失——factory-floor=活跃面) + - drift_alarms: [(卡 ID, 字段名, 飞书值, 期望值)](仅报警面字段且旧值非空—— + 人工改动被纠正的告警,INV-05) + - unknown_states: [(卡 ID, state)](label 态不在 expected-state 全集——State + 写不进单选,跳过 State 字段照常同步其余,报警留观) + """ + by_id = {} + for c in cards: + by_id[f"{c['repo']}#{c['number']}"] = c + to_create, to_update, to_delete, drift_alarms, unknown_states = [], [], [], [], [] + for cid, card in by_id.items(): + want = want_fields(card) + if card["state"] not in state_options: + unknown_states.append((cid, card["state"])) + want = {k: v for k, v in want.items() if k != "State"} + rec = records.get(cid) + if rec is None: + to_create.append({"fields": want}) + continue + have = rec.get("fields") or {} + for f in FEISHU_DRIFT_FIELDS: # 报警面:人工改动(旧值非空且≠期望) + if f not in want: # unknown-state 已剔除 State + continue + if have.get(f) not in (None, "") and not _eq(have.get(f), want[f]): + drift_alarms.append((cid, f, have.get(f), want[f])) + diff = {k: v for k, v in want.items() if not _eq(have.get(k), v)} + if diff: + to_update.append({"record_id": rec["record_id"], "fields": diff}) + for cid, rec in records.items(): + if cid not in by_id: + to_delete.append(rec["record_id"]) + return {"to_create": to_create, "to_update": to_update, "to_delete": to_delete, + "drift_alarms": drift_alarms, "unknown_states": unknown_states} +# @w3f1-pure-end + + +class Feishu: + """飞书开放平台客户端(零依赖 urllib;调用计数=AC-7a API 调用账本运行时面)。 + + 响应包络 {"code":0,"msg","data"}——code!=0 或 HTTP 非 2xx 一律 Infra + (fail-closed);调用按端点归一计数(app_token/table_id 段抹平)。 + """ + + def __init__(self, base, app_id, app_secret, app_token): + self.base, self.app_id, self.app_secret, self.app_token = \ + base.rstrip("/"), app_id, app_secret, app_token + self.calls = collections.Counter() + self._tok, self._tok_exp = None, 0.0 + + def _endpoint(self, method, path): + p = path.split("?")[0] + for seg in (self.app_token,): + p = p.replace(f"/{seg}/", "/{app}/") + p = "/".join("{tbl}" if s.startswith("tbl") else s for s in p.split("/")) + return f"{method} {p}" + + def _http(self, method, path, body=None, auth=True, raw=False): + headers = {"Content-Type": "application/json"} + if auth: + headers["Authorization"] = f"Bearer {self._token()}" + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(self.base + path, data=data, method=method, + headers=headers) + self.calls[self._endpoint(method, path)] += 1 + try: + with urllib.request.urlopen(req, timeout=30) as r: + payload = json.loads(r.read().decode() or "{}") + except urllib.error.HTTPError as e: + raw_txt = e.read().decode()[:200] + raise Infra(f"飞书 {method} {path} HTTP {e.code}: {raw_txt}") from e + except Exception as e: + raise Infra(f"飞书 {method} {path} 传输失败: {e}") from e + if payload.get("code") not in (0, None): + raise Infra(f"飞书 {method} {path} code={payload.get('code')}: " + f"{str(payload.get('msg'))[:160]}") + if raw: # token 端点响应在顶层(无 data 包裹——飞书 API 特例) + return payload + return payload.get("data") or {} + + def _token(self): + if self._tok and time.time() < self._tok_exp - 120: + return self._tok + payload = self._http("POST", "/open-apis/auth/v3/tenant_access_token/internal", + {"app_id": self.app_id, "app_secret": self.app_secret}, + auth=False, raw=True) + tok = payload.get("tenant_access_token") + if not tok: + raise Infra("tenant_access_token 缺失(app_id/secret 无效?)") + self._tok, self._tok_exp = tok, time.time() + int(payload.get("expire") or 7200) + return self._tok + + def _tables_path(self): + return f"/open-apis/bitable/v1/apps/{self.app_token}/tables" + + def _records_path(self, table_id): + return f"{self._tables_path()}/{table_id}/records" + + def find_or_create_table(self, name, state_names): + """按表名找数据表;缺则创建(IFACE-04 字段+State 单选全集)——幂等, + 整表删除后单轮同步内重建的机制基础(BEH-06)。""" + cur = None + while True: + q = f"?page_size=100" + (f"&page_token={cur}" if cur else "") + data = self._http("GET", self._tables_path() + q) + for it in data.get("items") or []: + if it.get("name") == name: + return it["table_id"] + if not data.get("has_more"): + break + cur = data.get("page_token") + fields = [] + for fname, ftype in FIELD_SPEC: + f = {"field_name": fname, "type": ftype} + if ftype == _SELECT: + f["property"] = {"options": [{"name": n} for n in state_names]} + fields.append(f) + data = self._http("POST", self._tables_path(), + {"table": {"name": name, "fields": fields}}) + tid = (data.get("table") or {}).get("table_id") or data.get("table_id") + if not tid: + raise Infra(f"建表响应缺 table_id: {json.dumps(data)[:200]}") + print(f"OK 数据表「{name}」不存在——已重建(table_id={tid},单轮重建语义)") + return tid + + def delete_table(self, table_id): + """删除数据表(--drop 演练路径:drop & rebuild 保真度实测)。""" + self._http("DELETE", f"{self._tables_path()}/{table_id}") + + def list_records(self, table_id): + """全表行 → {卡 ID: {record_id, fields}}(分页拉全)。""" + out, cur = {}, None + while True: + q = "?page_size=500" + (f"&page_token={cur}" if cur else "") + data = self._http("GET", self._records_path(table_id) + q) + for it in data.get("items") or []: + fields = it.get("fields") or {} + cid = fields.get("卡 ID") + if isinstance(cid, str) and cid: + out[cid] = {"record_id": it["record_id"], "fields": fields} + if not data.get("has_more"): + return out + cur = data.get("page_token") + + def batch_create(self, table_id, records): + for i in range(0, len(records), 500): + self._http("POST", self._records_path(table_id) + "/batch_create", + {"records": [self._sanitize(r) for r in records[i:i + 500]]}) + + def batch_update(self, table_id, records): + for i in range(0, len(records), 500): + self._http("PUT", self._records_path(table_id) + "/batch_update", + {"records": [self._sanitize(r) for r in records[i:i + 500]]}) + + @staticmethod + def _sanitize(rec): + """空串→None(飞书空文本须以 null 清空——空字符串写入部分字段会 4xx)。""" + rec = dict(rec) + rec["fields"] = {k: (None if v == "" else v) for k, v in rec["fields"].items()} + return rec + + def batch_delete(self, table_id, record_ids): + for i in range(0, len(record_ids), 500): + self._http("DELETE", self._records_path(table_id) + "/batch_delete", + {"records": record_ids[i:i + 500]}) + + +def audit(outcome, actions): + """审计代发(butler-audit.sh CLI:AUDIT 行 + step summary + schema v1 影子)。 + + 影子文件定向 governance/feishu/shadow-evidence.jsonl(BUTLER_SHADOW_FILE 可 + 覆盖——测试注入面;与 butler 源分离);drill 用 BUTLER_CARD 绑卡号、 + BUTLER_TENANT 定租户;stats 经 BUTLER_SHADOW_PAYLOAD 进影子 payload + (≤4KB,超限降级只记结论——AUDIT 行才是全量账本真源)。 + 审计链失败=exit 2(fail-visible——审计完整性优先于投影面 fail-open)。 + """ + shadow = os.environ.get("BUTLER_SHADOW_FILE") \ + or os.path.join(DIR, "feishu", "shadow-evidence.jsonl") + os.makedirs(os.path.dirname(shadow), exist_ok=True) + payload = json.dumps(actions, ensure_ascii=False) + if len(payload.encode("utf-8")) > 4096: # INV-06:超限拒写——降级只记结论 + payload = json.dumps({k: v for k, v in actions.items() if k != "api_calls"}, + ensure_ascii=False) + if len(payload.encode("utf-8")) > 4096: + payload = "" + env = {**os.environ, "BUTLER_SHADOW_FILE": shadow, + "BUTLER_SHADOW_PAYLOAD": payload} + r = subprocess.run(["bash", os.path.join(DIR, "butler-audit.sh"), + "feishu-sync", TRIGGER, outcome, + json.dumps(actions, ensure_ascii=False)], + env=env, check=False) + return r.returncode + + +def main(): + # ---- 停用守卫(AC-7b:投影可整体停用,降级回 GitHub dashboard) ---- + if os.environ.get("FEISHU_SYNC_DISABLED") == "1": + print("OK FEISHU_SYNC_DISABLED=1——投影停用(降级回 GitHub dashboard,AC-7b)") + return audit("ok", {"skipped": "disabled"}) + # ---- 凭据守卫(过渡期:secrets 未配=投影未开通,skipped 绿) ---- + if not (APP_ID and APP_SECRET and APP_TOKEN): + print("OK 飞书凭据未配置(FEISHU_APP_ID/SECRET/BITABLE_APP_TOKEN 缺席)" + "——投影未开通,skipped(过渡期合法)") + return audit("ok", {"skipped": "not-provisioned"}) + if not TOKEN: + print("FATAL 需要环境变量 GH_TOKEN(label 真源读——投影不得无真源运行)", + file=sys.stderr) + return 2 + stats = {"cards": 0, "added": 0, "updated": 0, "deleted": 0, + "drift_alarms": 0, "unknown_states": 0} + try: + states = {s["name"] for s in board_sync.load_states()} + repos = board_sync.active_repos() + cards = board_sync.enrich_cards(board_sync.scan_cards(repos), repos) + stats["cards"] = len(cards) + fs = Feishu(FEISHU_API, APP_ID, APP_SECRET, APP_TOKEN) + tid = fs.find_or_create_table(TABLE_NAME, sorted(states)) + if DROP: + fs.delete_table(tid) + print("OK --drop:数据表已删——本轮单轮重建(BEH-06 演练语义)") + tid = fs.find_or_create_table(TABLE_NAME, sorted(states)) + plan = plan_sync(cards, fs.list_records(tid), states) + stats["added"] = len(plan["to_create"]) + stats["updated"] = len(plan["to_update"]) + stats["deleted"] = len(plan["to_delete"]) + stats["drift_alarms"] = len(plan["drift_alarms"]) + stats["unknown_states"] = len(plan["unknown_states"]) + for cid, state in plan["unknown_states"]: + print(f"WARN unknown-state {cid}: label 态 {state} 不在 expected-state 全集" + f"——State 单选无对应选项,跳过 State 写入(报警留观,同 board-sync)") + for cid, f, have, want in plan["drift_alarms"]: + print(f"WARN feishu-drift {cid}: {f} 表={have!r} label 期望={want!r}" + f"(人工改动将被纠正,INV-05——label 唯一真源)") + if DRY_RUN: + print(f"[dry-run] create={stats['added']} update={stats['updated']} " + f"delete={stats['deleted']} drift={stats['drift_alarms']}") + elif VERIFY: + # --verify=只读对账:不执行任何写;有差异=未收敛=exit 3(fail-closed) + if plan["to_create"] or plan["to_update"] or plan["to_delete"]: + print(f"FATAL --verify:投影未收敛(create={stats['added']} " + f"update={stats['updated']} delete={stats['deleted']}" + f"——与 label 态不一致=红,fail-closed)", file=sys.stderr) + stats["api_calls"] = dict(sorted(fs.calls.items())) + stats["table_id"] = tid + rc = audit("infra-fail", {**stats, "verify": "not-converged"}) + return rc if rc else 3 + else: + if plan["to_create"]: + fs.batch_create(tid, plan["to_create"]) + if plan["to_update"]: + fs.batch_update(tid, plan["to_update"]) + if plan["to_delete"]: + fs.batch_delete(tid, plan["to_delete"]) + stats["api_calls"] = dict(sorted(fs.calls.items())) + stats["table_id"] = tid + except Infra as e: + print(f"FATAL {e}", file=sys.stderr) + rc = audit("infra-fail", {**stats, "error": str(e)[:300]}) + return rc if rc else 2 + return audit("ok", stats) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/governance/tests/test-evidence-shadow.sh b/governance/tests/test-evidence-shadow.sh index 0e717fb..6ee5c4f 100644 --- a/governance/tests/test-evidence-shadow.sh +++ b/governance/tests/test-evidence-shadow.sh @@ -168,13 +168,25 @@ python3 "$DIR/governance/evidence_shadow.py" append --file "$TICKETS" --event-fi && python3 "$DIR/governance/evidence_shadow.py" verify --file "$TICKETS" >/dev/null \ && pass "tickets 影子验链绿(grant/revoke 双事件)" || fail "tickets 影子链断" -# ---- evidence-query 五源统一查询(AC-4a:归并 + 验链 + fail-closed) ---- +# ---- feishu 投影同步事件(W3-F1 / AC-7a:evidence-query 第 6 源,payload 带 api_calls) ---- +FEISHU="$TMP/feishu.jsonl" +cat >"$TMP/fs1.json" <<'EOF' +{"ts": "2026-08-29T00:05:00Z", "kind": "gate", "action": "butler-feishu-sync", "verdict": "ok", + "subject": {"card": "Cloudbird-Software/.github#416", "tenant": "cloudbird-internal"}, + "actor": {"identity": "feishu-sync", "role": "bot"}, + "payload": "{\"cards\": 2, \"drift_alarms\": 0, \"api_calls\": {\"GET /open-apis/bitable/v1/apps/{app}/tables\": 1}}"} +EOF +python3 "$DIR/governance/evidence_shadow.py" append --file "$FEISHU" --event-file "$TMP/fs1.json" >/dev/null \ + && python3 "$DIR/governance/evidence_shadow.py" verify --file "$FEISHU" >/dev/null \ + && pass "feishu 影子验链绿(投影同步事件+api_calls payload)" || fail "feishu 影子链断" + +# ---- evidence-query 六源统一查询(AC-4a:归并 + 验链 + fail-closed) ---- GHSTUB="$TMP/gh-stub" mkdir -p "$TMP/fixtures" -# fixture:metering=周分片列表($SHADOW);drill/butler/elevation=各自影子(独立成链);tickets=短票据链 -python3 - "$SHADOW" "$DSHADOW" "$BUTLER_SHADOW" "$ESHADOW" "$TICKETS" "$TMP/fixtures" <<'PYEOF' +# fixture:metering=周分片列表($SHADOW);drill/butler/elevation/feishu=各自影子(独立成链);tickets=短票据链 +python3 - "$SHADOW" "$DSHADOW" "$BUTLER_SHADOW" "$ESHADOW" "$TICKETS" "$FEISHU" "$TMP/fixtures" <<'PYEOF' import base64, json, sys -shadow, dshadow, bshadow, eshadow, tickets, fixdir = sys.argv[1:7] +shadow, dshadow, bshadow, eshadow, tickets, feishu, fixdir = sys.argv[1:8] b64 = lambda p: base64.b64encode(open(p, "rb").read()).decode() json.dump([{"type": "file", "name": "shadow-evidence-2026-W35.jsonl", "content": b64(shadow)}], open(f"{fixdir}/metering-list.json", "w")) @@ -182,6 +194,7 @@ json.dump({"type": "file", "content": b64(dshadow)}, open(f"{fixdir}/drill.json" json.dump({"type": "file", "content": b64(bshadow)}, open(f"{fixdir}/butler.json", "w")) json.dump({"type": "file", "content": b64(eshadow)}, open(f"{fixdir}/elev.json", "w")) json.dump({"type": "file", "content": b64(tickets)}, open(f"{fixdir}/tickets.json", "w")) +json.dump({"type": "file", "content": b64(feishu)}, open(f"{fixdir}/feishu.json", "w")) PYEOF cat >"$GHSTUB" <<'STUBEOF' #!/usr/bin/env bash @@ -213,6 +226,11 @@ case "$url" in echo "gh: No commit found for the ref tickets-ledger (HTTP 404)" >&2; exit 1 fi cat "$F/tickets.json" ;; + "repos/Cloudbird-Software/.github/contents/governance/feishu/shadow-evidence.jsonl?ref=feishu-ledger") + if [[ "${GH_STUB_FEISHU_MISSING:-}" == "1" ]]; then + echo "gh: No commit found for the ref feishu-ledger (HTTP 404)" >&2; exit 1 + fi + cat "$F/feishu.json" ;; *) echo "gh-stub: 意外 URL $url" >&2; exit 1 ;; esac STUBEOF @@ -221,10 +239,11 @@ nlines() { grep -c . <<<"$1" || true; } # 非空行计数(wc 对空 herestrin QOUT=$(GH="$GHSTUB" GH_STUB_FIXTURES="$TMP/fixtures" GH_TOKEN=stub \ bash "$DIR/governance/evidence-query.sh" 2>"$TMP/q.err"); QRC=$? N=$(nlines "$QOUT") -[[ $QRC -eq 0 && "$N" -eq 8 ]] && pass "统一查询归并五源(8 条,rc=0)" || fail "统一查询(rc=$QRC 行=$N)" +[[ $QRC -eq 0 && "$N" -eq 9 ]] && pass "统一查询归并六源(9 条,rc=0)" || fail "统一查询(rc=$QRC 行=$N)" grep -q '"source":"metering"' <<<"$QOUT" && grep -q '"source":"drill"' <<<"$QOUT" && grep -q '"source":"butler"' <<<"$QOUT" \ && grep -q '"source":"elevation"' <<<"$QOUT" && grep -q '"source":"tickets"' <<<"$QOUT" \ - && pass "五源标记齐全(source 字段)" || fail "source 标记缺失" + && grep -q '"source":"feishu"' <<<"$QOUT" \ + && pass "六源标记齐全(source 字段)" || fail "source 标记缺失" grep -q '^SUMMARY ' "$TMP/q.err" && grep -Eq '"metering": ?2' "$TMP/q.err" \ && pass "分源统计(stderr SUMMARY)" || fail "SUMMARY 统计缺失" # --card 过滤:metering 影子绑 #407,drill/butler 哨兵 #0,elevation 绑 #415 @@ -245,33 +264,45 @@ TQN=$(nlines "$TQOUT") [[ $TQRC -eq 0 && "$TQN" -eq 2 ]] && grep -q '"source":"tickets"' <<<"$TQOUT" \ && grep -q '"ticket.grant"' <<<"$TQOUT" && grep -q '"ticket.revoke"' <<<"$TQOUT" \ && pass "AC-5b tickets 短票据事件 subject 可查询(#412 → 2 条)" || fail "tickets 查询(rc=$TQRC 行=$TQN)" +# AC-7a:feishu 投影同步事件按 subject 可查询(#416 → 1 条,payload 带 api_calls) +FQOUT=$(GH="$GHSTUB" GH_STUB_FIXTURES="$TMP/fixtures" GH_TOKEN=stub \ + bash "$DIR/governance/evidence-query.sh" --card Cloudbird-Software/.github#416 2>/dev/null); FQRC=$? +FQN=$(nlines "$FQOUT") +[[ $FQRC -eq 0 && "$FQN" -eq 1 ]] && grep -q '"source":"feishu"' <<<"$FQOUT" \ + && grep -q '"butler-feishu-sync"' <<<"$FQOUT" && grep -q 'api_calls' <<<"$FQOUT" \ + && pass "AC-7a feishu 同步事件 subject 可查询(#416 → 1 条,payload 带 api_calls)" || fail "feishu 查询(rc=$FQRC 行=$FQN)" # 源缺席(404):过渡期合法,非红(须在篡改 fixture 生成前跑——桩对篡改片优先回放) MOUT=$(GH="$GHSTUB" GH_STUB_FIXTURES="$TMP/fixtures" GH_TOKEN=stub GH_STUB_BUTLER_MISSING=1 \ bash "$DIR/governance/evidence-query.sh" 2>"$TMP/m.err"); MRC=$? MN=$(nlines "$MOUT") -[[ $MRC -eq 0 && "$MN" -eq 7 ]] && pass "butler 源缺席(404)→ 跳过非红(7 条)" || fail "源缺席误红(rc=$MRC 行=$MN)" +[[ $MRC -eq 0 && "$MN" -eq 8 ]] && pass "butler 源缺席(404)→ 跳过非红(8 条)" || fail "源缺席误红(rc=$MRC 行=$MN)" # 源缺席(404 第二报文形态——账本分支未建 "No ref found"):同跳过非红 # (2026-08-29 波次通道实测回归:该形态曾被误判"非 404"→ INFRA exit 2) ROUT=$(GH="$GHSTUB" GH_STUB_FIXTURES="$TMP/fixtures" GH_TOKEN=stub GH_STUB_BUTLER_MISSING=2 \ bash "$DIR/governance/evidence-query.sh" 2>"$TMP/r.err"); RRC=$? RN=$(nlines "$ROUT") -[[ $RRC -eq 0 && "$RN" -eq 7 ]] && pass "源缺席(No ref found 分支未建)→ 跳过非红(7 条)" || fail "分支未建误红(rc=$RRC 行=$RN)" +[[ $RRC -eq 0 && "$RN" -eq 8 ]] && pass "源缺席(No ref found 分支未建)→ 跳过非红(8 条)" || fail "分支未建误红(rc=$RRC 行=$RN)" # 源缺席(404 第三报文形态——分支缺失 "No commit found for the ref",本地实测 # 真实报文):按 HTTP 404 状态码判定后同跳过非红 COUT2=$(GH="$GHSTUB" GH_STUB_FIXTURES="$TMP/fixtures" GH_TOKEN=stub GH_STUB_BUTLER_MISSING=3 \ bash "$DIR/governance/evidence-query.sh" 2>"$TMP/c2.err"); C2RC=$? C2N=$(nlines "$COUT2") -[[ $C2RC -eq 0 && "$C2N" -eq 7 ]] && pass "源缺席(No commit found for the ref)→ 跳过非红(7 条)" || fail "分支缺失误红(rc=$C2RC 行=$C2N)" +[[ $C2RC -eq 0 && "$C2N" -eq 8 ]] && pass "源缺席(No commit found for the ref)→ 跳过非红(8 条)" || fail "分支缺失误红(rc=$C2RC 行=$C2N)" # 源缺席(elevation 第 4 源分支未建——"No commit found"):同跳过非红 EOUT2=$(GH="$GHSTUB" GH_STUB_FIXTURES="$TMP/fixtures" GH_TOKEN=stub GH_STUB_ELEV_MISSING=1 \ bash "$DIR/governance/evidence-query.sh" 2>"$TMP/e2.err"); E2RC=$? E2N=$(nlines "$EOUT2") -[[ $E2RC -eq 0 && "$E2N" -eq 7 ]] && pass "elevation 源缺席(分支未建)→ 跳过非红(7 条)" || fail "elevation 缺席误红(rc=$E2RC 行=$E2N)" +[[ $E2RC -eq 0 && "$E2N" -eq 8 ]] && pass "elevation 源缺席(分支未建)→ 跳过非红(8 条)" || fail "elevation 缺席误红(rc=$E2RC 行=$E2N)" # tickets 第 5 源缺席(分支未建):同跳过非红(REMOVAL 语义:源消失≠链断) TKOUT=$(GH="$GHSTUB" GH_STUB_FIXTURES="$TMP/fixtures" GH_TOKEN=stub GH_STUB_TICKETS_MISSING=1 \ bash "$DIR/governance/evidence-query.sh" 2>"$TMP/tk.err"); TKRC=$? TKQN=$(nlines "$TKOUT") -[[ $TKRC -eq 0 && "$TKQN" -eq 6 ]] && pass "tickets 源缺席(分支未建)→ 跳过非红(6 条)" || fail "tickets 缺席误红(rc=$TKRC 行=$TKQN)" +[[ $TKRC -eq 0 && "$TKQN" -eq 7 ]] && pass "tickets 源缺席(分支未建)→ 跳过非红(7 条)" || fail "tickets 缺席误红(rc=$TKRC 行=$TKQN)" +# feishu 第 6 源缺席(分支未建——投影未开通的过渡期常态):同跳过非红 +FOUT2=$(GH="$GHSTUB" GH_STUB_FIXTURES="$TMP/fixtures" GH_TOKEN=stub GH_STUB_FEISHU_MISSING=1 \ + bash "$DIR/governance/evidence-query.sh" 2>"$TMP/f2.err"); F2RC=$? +F2N=$(nlines "$FOUT2") +[[ $F2RC -eq 0 && "$F2N" -eq 8 ]] && pass "feishu 源缺席(分支未建=投影未开通过渡期)→ 跳过非红(8 条)" || fail "feishu 缺席误红(rc=$F2RC 行=$F2N)" # 负向:任一源链断 → exit 3 且 stdout 零输出(不可信数据不出结果) python3 - "$TMP/fixtures" <<'PYEOF' import base64, json, sys diff --git a/governance/tests/test-feishu-sync.sh b/governance/tests/test-feishu-sync.sh new file mode 100644 index 0000000..a90a2f2 --- /dev/null +++ b/governance/tests/test-feishu-sync.sh @@ -0,0 +1,300 @@ +#!/usr/bin/env bash +# test-feishu-sync.sh —— 飞书多维表格投影同步器自测(IR-0006 W3-F1 / 卡 #416) +# +# 覆盖(卡 AC 对应,全部离线——飞书测试用桩注入,spec T-08:无网络依赖断言): +# AC-7a plan_sync 纯函数:建行/漂移纠正(INV-05 报警面)/孤儿删除/派生刷新不报警/ +# 空值等价/unknown-state 跳过 State +# AC-7a 桩 e2e:GitHub 桩(label 真源)+ 飞书桩 → 同步 → batch_create 落正确字段; +# 二轮人工改 State → 纠正(batch_update)+ WARN + 漂移入审计;孤儿行删除; +# AUDIT 行 + schema v1 影子落盘(链验绿)+ api_calls 计数 +# AC-7b fail-open 面:--verify 只读(不收敛=exit 3 且零写);--drop 单轮重建; +# 停用/未配置=skipped 绿;飞书 API 故障=exit 2(fail-closed 方向); +# GH_TOKEN 缺失=exit 2(label 真源不可缺席) +# 用法: bash governance/tests/test-feishu-sync.sh(gate.yml 自动纳入) +set -uo pipefail +DIR="$(cd "$(dirname "$0")/../.." && pwd)" +FAILS=0 +pass() { echo "PASS $1"; } +fail() { echo "FAIL $1"; FAILS=$((FAILS+1)); } + +TMP=$(mktemp -d) +trap '[[ -n "${STUB_PID:-}" ]] && kill "$STUB_PID" 2>/dev/null; rm -rf "$TMP"' EXIT + +FSYNC="python3 $DIR/governance/feishu-sync.py" + +# ---- 纯函数区(plan_sync:IFACE-04 字段期望 + INV-05 漂移语义) ---- +python3 - "$DIR" <<'PYEOF' && pass "plan_sync 纯函数五断言(建行/纠正/删除/派生/空值)" || fail "plan_sync 纯函数" +import importlib.util, sys +spec = importlib.util.spec_from_file_location("feishu_sync", f"{sys.argv[1]}/governance/feishu-sync.py") +fs = importlib.util.module_from_spec(spec); spec.loader.exec_module(fs) +cards = [ + {"repo": ".github", "number": 416, "title": "t", "state": "in-progress", + "assignee": "randypanding", "days_idle": 2, "ac_progress": "1/2", + "gate_status": "绿", "predicate_status": "pending"}, + {"repo": ".github", "number": 417, "title": "t2", "state": "ready", + "assignee": "", "days_idle": 0, "ac_progress": "", + "gate_status": "无 PR", "predicate_status": "pending"}, +] +opts = {"in-progress", "ready", "done"} +# 1) 空表 → 全量建行;卡 ID 唯一键 +p = fs.plan_sync(cards, {}, opts) +assert len(p["to_create"]) == 2 and not p["to_update"] and not p["drift_alarms"], p +assert p["to_create"][0]["fields"]["卡 ID"] == ".github#416" +assert p["to_create"][0]["fields"]["State"] == "in-progress" +# 2) 人工改 State=done(label=in-progress)→ 纠正 + 报警(INV-05) +recs = {".github#416": {"record_id": "rec1", "fields": {**fs.want_fields(cards[0]), "State": "done"}}} +p = fs.plan_sync(cards, recs, opts) +assert p["to_update"] == [{"record_id": "rec1", "fields": {"State": "in-progress"}}], p +assert p["drift_alarms"] == [(".github#416", "State", "done", "in-progress")], p +# 3) 孤儿行(卡已 closed)→ 删除;停留天数派生刷新不进报警面 +recs = {".github#416": {"record_id": "rec1", "fields": fs.want_fields(cards[0])}, + ".github#417": {"record_id": "rec2", "fields": fs.want_fields(cards[1]) | {"停留天数": 5}}, + ".github#999": {"record_id": "rec9", "fields": {"卡 ID": ".github#999", "停留天数": 99}}} +p = fs.plan_sync(cards, recs, opts) +assert p["to_delete"] == ["rec9"], p +assert [u for u in p["to_update"] if u["record_id"] == "rec2"] == [{"record_id": "rec2", "fields": {"停留天数": 0}}], p +assert p["to_create"] == [] and p["drift_alarms"] == [], p +# 4) 空值等价:记录 None ≈ 期望 ""(认领者/AC 进度空卡不误报) +recs2 = {".github#417": {"record_id": "rec2", "fields": fs.want_fields(cards[1]) | {"认领者": None, "AC 进度": None}}} +p = fs.plan_sync([cards[1]], recs2, opts) +assert p["to_update"] == [] and p["drift_alarms"] == [], p +# 5) unknown state:State 剔除出 want,其余照常 +c3 = dict(cards[0], number=418, state="weird") +p = fs.plan_sync([c3], {}, {"in-progress"}) +assert "State" not in p["to_create"][0]["fields"] and p["unknown_states"] == [(".github#418", "weird")], p +print("OK") +PYEOF + +# ---- 双桩 e2e:GitHub 桩(label 真源)+ 飞书桩(投影面) ---- +mkdir -p "$TMP/ctrl" +CAP="$TMP/captured.jsonl"; : > "$CAP" +cat > "$TMP/stub.py" <<'STUBEOF' +#!/usr/bin/env python3 +# e2e 桩:单服务器双面(/repos/=GitHub fixture;/open-apis/=飞书带状态投影面) +import json, os, sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +TMP = sys.argv[1]; CTRL = os.path.join(TMP, "ctrl") +CAP = os.path.join(TMP, "captured.jsonl") +ERR = os.path.join(TMP, "feishu-error") +GH_ISSUES = [ + {"node_id": "n416", "number": 416, "title": "W3-F1 桩卡", "state": "open", + "labels": [{"name": "type:card"}, {"name": "state:in-progress"}], + "assignees": [{"login": "randypanding"}], + "html_url": "https://github.com/Cloudbird-Software/.github/issues/416", + "updated_at": "2026-08-29T00:00:00Z", "body": "- [x] AC-7a\n- [ ] AC-7b"}, + {"node_id": "n417", "number": 417, "title": "W3-F2 桩卡", "state": "open", + "labels": [{"name": "type:card"}, {"name": "state:ready"}], + "assignees": [], "html_url": "https://github.com/Cloudbird-Software/.github/issues/417", + "updated_at": "2026-08-29T00:00:00Z", "body": ""}, +] +GH_PRS = [{"number": 90, "body": "Card: Cloudbird-Software/.github#416", "head": {"sha": "abc123"}}] +GH_CHECKS = {"check_runs": [{"name": "gate", "status": "completed", "conclusion": "success"}, + {"name": "adversary", "status": "completed", "conclusion": "success"}]} +def rd(name, default): + p = os.path.join(CTRL, name) + return json.load(open(p, encoding="utf-8")) if os.path.isfile(p) else default +def wr(name, obj): + json.dump(obj, open(os.path.join(CTRL, name), "w", encoding="utf-8"), ensure_ascii=False) +def recs_file(tid): return f"records-{tid}.json" +class H(BaseHTTPRequestHandler): + def log_message(self, *a): pass + def _send(self, obj): + b = json.dumps(obj).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(b))) + self.end_headers(); self.wfile.write(b) + def _cap(self, method, body): + open(CAP, "a", encoding="utf-8").write( + json.dumps({"m": method, "p": self.path, "b": body}, ensure_ascii=False) + "\n") + def _body(self): + n = int(self.headers.get("Content-Length") or 0) + return json.loads(self.rfile.read(n) or b"{}") if n else {} + def do_GET(self): + p = self.path + if p.startswith("/open-apis/"): + if os.path.isfile(ERR): + self._send({"code": 99999, "msg": "stub-injected-error"}); return + if "/records" in p: + tid = p.split("/tables/")[1].split("/")[0] + self._send({"code": 0, "data": {"items": rd(recs_file(tid), []), "has_more": False}}); return + self._send({"code": 0, "data": {"items": rd("tables.json", []), "has_more": False}}); return + if "/commits/" in p and "/check-runs" in p: + self._send(GH_CHECKS); return + if "/issues" in p: + self._send(GH_ISSUES if "/Cloudbird-Software/.github/" in p else []); return + if "/pulls" in p: + self._send(GH_PRS if "/Cloudbird-Software/.github/" in p else []); return + self._send([]) + def do_POST(self): + p, body = self.path, self._body() + if p.startswith("/open-apis/auth/"): + self._send({"code": 0, "tenant_access_token": "t-stub", "expire": 7200}); return + if os.path.isfile(ERR): + self._send({"code": 99999, "msg": "stub-injected-error"}); return + self._cap("POST", body) + if p.endswith("/tables"): + tables = rd("tables.json", []) + tid = f"tbl{len(tables) + 1}" + tables.append({"table_id": tid, "name": (body.get("table") or {}).get("name")}) + wr("tables.json", tables) + self._send({"code": 0, "data": {"table_id": tid}}); return + if p.endswith("/batch_create"): + tid = p.split("/tables/")[1].split("/")[0] + recs = rd(recs_file(tid), []) + for r in body["records"]: + recs.append({"record_id": f"rec{len(recs) + 1}", "fields": r["fields"]}) + wr(recs_file(tid), recs) + self._send({"code": 0, "data": {}}); return + self._send({"code": 0, "data": {}}) + def do_PUT(self): + p, body = self.path, self._body() + if os.path.isfile(ERR): + self._send({"code": 99999, "msg": "stub-injected-error"}); return + self._cap("PUT", body) + if p.endswith("/batch_update"): + tid = p.split("/tables/")[1].split("/")[0] + recs = rd(recs_file(tid), []) + for r in body["records"]: + for e in recs: + if e["record_id"] == r["record_id"]: + e["fields"].update(r["fields"]) + wr(recs_file(tid), recs) + self._send({"code": 0, "data": {}}) + def do_DELETE(self): + p, body = self.path, self._body() + if os.path.isfile(ERR): + self._send({"code": 99999, "msg": "stub-injected-error"}); return + self._cap("DELETE", body) + if "/records/batch_delete" in p: + tid = p.split("/tables/")[1].split("/")[0] + recs = [e for e in rd(recs_file(tid), []) if e["record_id"] not in body["records"]] + wr(recs_file(tid), recs) + else: # 删表 + tid = p.split("/tables/")[1].split("/")[0] + tables = [t for t in rd("tables.json", []) if t["table_id"] != tid] + wr("tables.json", tables) + self._send({"code": 0, "data": {}}) +port_file = os.path.join(TMP, "port") +srv = ThreadingHTTPServer(("127.0.0.1", 0), H) +open(port_file, "w").write(str(srv.server_address[1])) +srv.serve_forever() +STUBEOF +python3 "$TMP/stub.py" "$TMP" & STUB_PID=$! +for _ in $(seq 1 50); do [[ -s "$TMP/port" ]] && break; sleep 0.1; done +PORT=$(cat "$TMP/port") +BASE="http://127.0.0.1:$PORT" +FSHADOW="$TMP/feishu/shadow-evidence.jsonl" +run_sync() { # run_sync [额外参数...] → stdout/stderr 落 $TMP/run.out + BUTLER_SHADOW_FILE="$FSHADOW" BUTLER_TENANT=cloudbird-internal \ + GH_TOKEN=stub GH_API_BASE="$BASE" FEISHU_API_BASE="$BASE" \ + FEISHU_APP_ID=ci_app FEISHU_APP_SECRET=ci_secret FEISHU_BITABLE_APP_TOKEN=appSTUB \ + $FSYNC "$@" >"$TMP/run.out" 2>"$TMP/run.err" +} + +# e2e-1 首轮:空表 → 建表 + 两行(batch_create 字段断言)+ 审计/影子 +run_sync; RC=$? +[[ $RC -eq 0 ]] && pass "e2e 首轮同步 exit 0" || fail "e2e 首轮 exit=$RC $(tail -3 "$TMP/run.err")" +C1=$(grep -c '"m": "POST", "p": "/open-apis/bitable/v1/apps/appSTUB/tables"' "$CAP") +[[ $C1 -eq 1 ]] && pass "e2e 表幂等创建(1 次 POST /tables)" || fail "e2e 建表次数=$C1" +python3 - "$CAP" <<'PYEOF' && pass "e2e batch_create 两行·卡 ID/关卡状态/AC 进度字段断言" || fail "e2e batch_create 字段" +import json, sys +caps = [json.loads(l) for l in open(sys.argv[1], encoding="utf-8")] +creates = [c for c in caps if c["p"].endswith("/records/batch_create")] +assert len(creates) == 1 and len(creates[0]["b"]["records"]) == 2, creates +f = {r["fields"]["卡 ID"]: r["fields"] for r in creates[0]["b"]["records"]} +assert f[".github#416"]["State"] == "in-progress" and f[".github#416"]["关卡状态"] == "绿" +assert f[".github#416"]["AC 进度"] == "1/2" and f[".github#416"]["认领者"] == "randypanding" +assert f[".github#417"]["State"] == "ready" and f[".github#417"]["关卡状态"] == "无 PR" +print("OK") +PYEOF +grep -q '^AUDIT | butler=feishu-sync' "$TMP/run.out" && pass "e2e AUDIT 行(butler=feishu-sync)" || fail "AUDIT 行缺失" +python3 - "$DIR" "$FSHADOW" <<'PYEOF' && pass "e2e schema v1 影子落盘+验链绿+api_calls 计数" || fail "e2e 影子/验链" +import json, os, sys +sys.path.insert(0, f"{sys.argv[1]}/governance") +import evidence_shadow +lines = evidence_shadow.read_lines(sys.argv[2]) +assert len(lines) == 1, lines +rec = json.loads(lines[0]) +assert rec["action"] == "butler-feishu-sync" and rec["verdict"] == "ok", rec +payload = json.loads(rec["payload"]) +assert payload["api_calls"]["GET /open-apis/bitable/v1/apps/{app}/tables"] >= 1, payload +assert payload["api_calls"]["POST /open-apis/bitable/v1/apps/{app}/tables/{tbl}/records/batch_create"] == 1, payload +assert rec["subject"]["tenant"] == "cloudbird-internal", rec +assert not evidence_shadow.verify_file(sys.argv[2]), "链断" +print("OK") +PYEOF + +# e2e-2 二轮:人工改 State(.github#416 → done)+ 孤儿行注入 → 纠正+删除+报警 +python3 - "$TMP/ctrl/records-tbl1.json" <<'PYEOF' +import json, sys +recs = json.load(open(sys.argv[1], encoding="utf-8")) +for r in recs: # 人工违规:#416 State 改 done、#417 认领者乱填 + if r["fields"].get("卡 ID") == ".github#416": r["fields"]["State"] = "done" + if r["fields"].get("卡 ID") == ".github#417": r["fields"]["认领者"] = "human-typo" +recs.append({"record_id": "rec9", "fields": {"卡 ID": ".github#999", "停留天数": 99}}) +json.dump(recs, open(sys.argv[1], "w", encoding="utf-8"), ensure_ascii=False) +PYEOF +: > "$CAP" +run_sync; RC=$? +[[ $RC -eq 0 ]] && pass "e2e 二轮纠正 exit 0" || fail "e2e 二轮 exit=$RC" +grep -q "WARN feishu-drift .github#416: State 表='done'" "$TMP/run.out" \ + && grep -q 'WARN feishu-drift .github#417: 认领者' "$TMP/run.out" \ + && pass "INV-05 漂移告警(State+认领者人工改动)" || fail "漂移告警缺失" +python3 - "$CAP" <<'PYEOF' && pass "e2e 纠正写回(batch_update State/认领者 + batch_delete 孤儿)" || fail "e2e 纠正写" +import json, sys +caps = [json.loads(l) for l in open(sys.argv[1], encoding="utf-8")] +upd = next((c for c in caps if c["p"].endswith("/records/batch_update")), None) +dele = next((c for c in caps if c["p"].endswith("/records/batch_delete")), None) +assert upd and dele, caps +byrec = {r["record_id"]: r["fields"] for r in upd["b"]["records"]} +# rec1=#416(State 纠正)、rec2=#417(认领者纠正为空——空值归一不落写时 diff 为 "",仍需下发) +assert byrec.get("rec1", {}).get("State") == "in-progress", upd +assert "认领者" in byrec.get("rec2", {}), upd +assert dele["b"]["records"] == ["rec9"], dele +print("OK") +PYEOF + +# e2e-3 --verify 只读对账:漂移态 → exit 3 且零写(fail-closed:不收敛=红) +: > "$CAP" +python3 - "$TMP/ctrl/records-tbl1.json" <<'PYEOF' +import json, sys +recs = json.load(open(sys.argv[1], encoding="utf-8")) +for r in recs: + if r["fields"].get("卡 ID") == ".github#416": r["fields"]["State"] = "done" +json.dump(recs, open(sys.argv[1], "w", encoding="utf-8"), ensure_ascii=False) +PYEOF +run_sync --verify; RC=$? +NOWRITES=$(grep -cE '"m": "(POST|PUT|DELETE)"' "$CAP") +[[ $RC -eq 3 && "$NOWRITES" -eq 0 ]] && pass "AC-7b --verify 只读:未收敛=exit 3 且零写" || fail "--verify(rc=$RC 写=$NOWRITES)" + +# e2e-4 收敛态 --verify → 0 +run_sync >/dev/null 2>&1 +run_sync --verify; RC=$? +[[ $RC -eq 0 ]] && pass "--verify 收敛态 exit 0" || fail "--verify 收敛 rc=$RC" + +# e2e-5 --drop:删表后同轮重建(BEH-06 单轮重建语义) +: > "$CAP" +run_sync --drop; RC=$? +DROPS=$(grep -c '"m": "DELETE", "p": "/open-apis/bitable/v1/apps/appSTUB/tables/tbl' "$CAP") +[[ $RC -eq 0 && "$DROPS" -ge 1 ]] && pass "--drop 删表+单轮重建 exit 0" || fail "--drop(rc=$RC drops=$DROPS)" +run_sync --verify; RC=$? +[[ $RC -eq 0 ]] && pass "--drop 重建后 --verify 收敛(字段一致)" || fail "--drop 重建后未收敛 rc=$RC" + +# ---- 负向/守卫 ---- +out=$(FEISHU_SYNC_DISABLED=1 GH_TOKEN=x FEISHU_APP_ID=a FEISHU_APP_SECRET=s FEISHU_BITABLE_APP_TOKEN=t \ + BUTLER_SHADOW_FILE="$FSHADOW" $FSYNC 2>&1); RC=$? +[[ $RC -eq 0 && "$out" == *投影停用* ]] && pass "AC-7b 停用守卫=skipped 绿" || fail "停用守卫 rc=$RC" +out=$(GH_TOKEN=x BUTLER_SHADOW_FILE="$FSHADOW" $FSYNC 2>&1); RC=$? +[[ $RC -eq 0 && "$out" == *未配置* ]] && pass "凭据缺席=skipped 绿(过渡期)" || fail "凭据守卫 rc=$RC" +GH_TOKEN= GH_API_BASE="$BASE" FEISHU_API_BASE="$BASE" FEISHU_APP_ID=a FEISHU_APP_SECRET=s \ + FEISHU_BITABLE_APP_TOKEN=t BUTLER_SHADOW_FILE="$FSHADOW" $FSYNC >/dev/null 2>&1 +[[ $? -eq 2 ]] && pass "GH_TOKEN 缺失=exit 2(label 真源不可缺席)" || fail "GH_TOKEN 守卫" +touch "$TMP/feishu-error" +run_sync >/dev/null 2>&1 +[[ $? -eq 2 ]] && pass "飞书 API 故障=exit 2(fail-closed 方向)" || fail "API 故障未红" +rm -f "$TMP/feishu-error" + +echo "----------------------------------------" +if [[ $FAILS -eq 0 ]]; then echo "test-feishu-sync: PASS"; exit 0; fi +echo "test-feishu-sync: $FAILS 处失败"; exit 1