feat(ack): add Feishu bug review approval gate

This commit is contained in:
2026-08-03 15:26:02 +08:00
parent 4d078e8258
commit 0954ad542a
15 changed files with 851 additions and 100 deletions
+23 -14
View File
@@ -2,16 +2,16 @@
## 目标 ## 目标
让用户在飞书多维表格中跨设备记录文字和截图,随后由 ACK Coordinator 通过官方 让用户在飞书多维表格中跨设备记录文字和截图,由 ACK Coordinator 在同一记录补全并
`lark-cli` 读取项目配置的 `ACK Ready` 视图,整理为独立、可验收、可幂等追踪的 ACK 反复修订修复逻辑与验收标准。用户明确审核通过后,才将批准版本整理为独立、可验收、
任务。不同项目通过不同 CLI profile 访问各自飞书租户 可幂等追踪的 ACK 任务并启动三角色闭环
## 非目标 ## 非目标
- 不用 Skill 承担人工记录入口。 - 不用 Skill 承担人工记录入口。
- 不抓取公开网页或依赖浏览器登录态。 - 不抓取公开网页或依赖浏览器登录态。
- 不把 App Secret、access token 或飞书用户凭据写入项目。 - 不把 App Secret、access token 或飞书用户凭据写入项目。
- 第一版不反向更新飞书记录,不把飞书状态与 ACK 状态做双向同步。 - 不在审核前把草案写入 `tasks.yaml`,也不把 ACK 执行状态持续双向同步到飞书
- 不把多条互不相关的 Bug 合成一次 Developer 派发。 - 不把多条互不相关的 Bug 合成一次 Developer 派发。
## 项目配置契约 ## 项目配置契约
@@ -22,6 +22,7 @@
| 字段 | 约束 | | 字段 | 约束 |
|------|------| |------|------|
| `provider` | 固定为 `feishu-base` | | `provider` | 固定为 `feishu-base` |
| `workflow` | 审核前协作固定为 `reviewed-writeback-v1`;缺省表示旧只读模式 |
| `profile` | `lark-cli` profile 名称;每次命令显式传入 | | `profile` | `lark-cli` profile 名称;每次命令显式传入 |
| `baseToken` | 飞书 Base token | | `baseToken` | 飞书 Base token |
| `tableId` | 数据表 ID | | `tableId` | 数据表 ID |
@@ -29,8 +30,8 @@
| `fields` | 逻辑字段到飞书字段 ID/名称的映射 | | `fields` | 逻辑字段到飞书字段 ID/名称的映射 |
`fields` 必须映射 `title``actual``expected``stepsToReproduce` `fields` 必须映射 `title``actual``expected``stepsToReproduce`
`acceptance``priority``attachments``updatedAt`字段值只作为单个 argv 传给 `acceptance``priority``attachments``updatedAt`启用审核前协作时还必须映射
`lark-cli`,不经过 shell。 `fixLogic`。字段值只作为单个 argv 传给 `lark-cli`,不经过 shell。
## CLI 契约 ## CLI 契约
@@ -40,6 +41,10 @@
- profile 由项目显式选择;不得执行 `profile use` 或读取 active profile作为回退。 - profile 由项目显式选择;不得执行 `profile use` 或读取 active profile作为回退。
- 子进程使用最小环境,不继承可能覆盖 profile/config/凭据或注入运行时的环境变量。 - 子进程使用最小环境,不继承可能覆盖 profile/config/凭据或注入运行时的环境变量。
- 记录读取使用 `base +record-list`、JSON 输出、指定 Base/table/view 和投影字段。 - 记录读取使用 `base +record-list`、JSON 输出、指定 Base/table/view 和投影字段。
- 草案写回只通过读取器的 `write-draft` 子命令调用 `base +record-upsert`,并且只允许修改
配置映射的 `fixLogic``acceptance`
- 批准后只通过 `import-approved` 生成规范任务字段和可重算的 `approvedPayloadHash`;任务
校验器拒绝审核字段与该 hash 不一致的 reviewed task。
- 记录超过一页时按 offset/limit 继续读取,并设置总页数/记录数上限。 - 记录超过一页时按 offset/limit 继续读取,并设置总页数/记录数上限。
- 附件仅通过 `base +record-download-attachment` 下载到调用者显式提供的临时目录。 - 附件仅通过 `base +record-download-attachment` 下载到调用者显式提供的临时目录。
- 附件数量、单文件大小、批次总大小和子进程文件写入均有硬上限;落盘大小必须与 - 附件数量、单文件大小、批次总大小和子进程文件写入均有硬上限;落盘大小必须与
@@ -53,8 +58,9 @@
- `sourceRef`:对 provider、profile、Base、table、record ID 做域隔离 SHA-256 - `sourceRef`:对 provider、profile、Base、table、record ID 做域隔离 SHA-256
后得到的稳定匿名引用;原始 profile、Base token 与 record ID 不拼入引用文本; 后得到的稳定匿名引用;原始 profile、Base token 与 record ID 不拼入引用文本;
- `recordId``updatedAt` - `recordId``updatedAt`
- title、actual、expected、steps、acceptance、priority - 绑定来源事实、附件元数据和审核字段的 `draftRevision`
- `enrichmentRequired`:缺失但允许 Coordinator 整理的 steps、acceptance、priority - title、actual、expected、steps、可选 fixLogic、acceptance、priority
- `enrichmentRequired`:缺失但允许 Coordinator 整理的 steps、fixLogic、acceptance、priority
- 附件的 name/type/size 与可选本地临时路径;附件 token 只在下载命令内部使用; - 附件的 name/type/size 与可选本地临时路径;附件 token 只在下载命令内部使用;
- 原始字段中无法映射但不影响导入的警告。 - 原始字段中无法映射但不影响导入的警告。
@@ -63,18 +69,21 @@ token 或 CLI 配置文件内容。
## Coordinator 整理规则 ## Coordinator 整理规则
1. 先运行读取器 `check`确认 `lark-cli`项目 profile 和所需只读能力可用。 1. 先运行读取器 `check` 确认 `lark-cli`项目 profile,再单独核对 app 已获得所需读写
scope`check` 不把用户 token 状态当作 app/bot scope 证明。
2. 运行读取器 `plan` 读取 `ACK Ready` 视图并生成 create/refresh/unchanged/drift 2. 运行读取器 `plan` 读取 `ACK Ready` 视图并生成 create/refresh/unchanged/drift
整理动作;需要看截图时使用临时下载目录。 整理动作;需要看截图时使用临时下载目录。
3. 将记录分类为 Bug、已有功能、接受的改进、样式偏好或超范围;只导入确认接受的项 3. 将记录分类并在飞书补全 `fixLogic``acceptance`;用户反馈时继续写回同一记录
4. 每条导入任务保存 `source.kind=feishu-base``source.ref``source.recordId` 4. 用户必须明确批准当前 `draftRevision`;通过前不写 `tasks.yaml`、不派发角色、不修改
`source.updatedAt`,并把截图观察转成文字证据。 应用代码。通过后每条导入任务保存 `source.kind=feishu-base``source.ref`
`source.recordId``source.updatedAt``source.workflow``source.approvedRevision`
`source.approvedPayloadHash`,并把截图观察转成文字证据。
5. 导入前扫描已有任务的 `source.ref`。相同来源不得新建第二条任务。 5. 导入前扫描已有任务的 `source.ref`。相同来源不得新建第二条任务。
6. 来源更新但任务尚为 `open` 时可由 Coordinator刷新描述;任务已派发或进入终态时只报告漂移,由用户决定是否新开任务。 6. 来源更新但任务尚为 `open` 时可由 Coordinator刷新描述;任务已派发或进入终态时只报告漂移,由用户决定是否新开任务。
7. 飞书记录删除、不可访问或 CLI 暂时失败时保留已有 ACK 任务,不反向删除。 7. 飞书记录删除、不可访问或 CLI 暂时失败时保留已有 ACK 任务,不反向删除。
8. title、actual、expected、updatedAt 是不可推断的来源事实,部分缺失时 fail closed 8. title、actual、expected、updatedAt 是不可推断的来源事实,部分缺失时 fail closed
steps、acceptance、priority 缺失时由 Coordinator 根据来源事实和项目上下文补齐, steps、fixLogic、acceptance、priority 缺失时由 Coordinator 根据来源事实和项目上下文
并写入 `evidence.intakeEnrichment`,不要求报告者返回飞书机械补录 补齐并写回飞书。只有批准后的最终版本才导入任务板
9. 没有附件且所有 Bug 内容字段均为空的误建行跳过并输出 `blank_record_skipped`;带部分 9. 没有附件且所有 Bug 内容字段均为空的误建行跳过并输出 `blank_record_skipped`;带部分
来源事实的残缺行不得静默跳过。 来源事实的残缺行不得静默跳过。
+11 -5
View File
@@ -132,11 +132,17 @@ description: >-
worker 启动规则。项目覆盖层优先于通用示例命令。按 scope 推荐相关 `active` worker 启动规则。项目覆盖层优先于通用示例命令。按 scope 推荐相关 `active`
知识,经确认后把固定 revision 的显式 `knowledgeRefs` 写入当前任务上下文; 知识,经确认后把固定 revision 的显式 `knowledgeRefs` 写入当前任务上下文;
不全量注入知识库。 不全量注入知识库。
配置了 `project.bugIntake` 时,先按 `references/feishu-bug-intake.md` 运行 check `project.bugIntake.workflow` 为 `reviewed-writeback-v1` 时,按
再运行 plan 获取标准化记录及 `create` / `refresh` / `unchanged` / `drift` 整理动作。 `references/feishu-bug-intake.md` 把飞书作为审核前的唯一协作区:先运行 check/plan,
`enrichmentRequired` 中的复现步骤、验收标准和优先级由 Coordinator 根据来源事实与 读取用户填写的 BugCoordinator 根据来源事实与项目上下文补全修复逻辑和可观测验收
项目上下文补齐,并在任务证据中标记推断字段,不要求用户回飞书机械补录;标题、实际 标准,只通过安全适配器写回同一飞书记录并回读确认。用户反馈后继续只在飞书修订。
表现和预期结果不可推断,部分缺失时仍停止。整行空白记录按批次 warning 跳过。 用户针对当前 `draftRevision` 明确审核通过前,不创建或刷新 `tasks.yaml` 任务、不启动
worker、不派发 Developer/Test,也不修改应用代码。审核通过后重新读取,要求 revision
与批准值完全一致,才通过 `import-approved` 生成规范 `taskDraft`,原样写入最终版本、
`source.workflow`、`source.approvedRevision` 与 `source.approvedPayloadHash`;校验器重算
payload hash 通过后才进入三角色闭环。未声明 workflow 的旧八字段配置只按
`read-only-v1` 兼容,不得写回;
标题、实际表现和预期结果不可推断;整行空白记录按批次 warning 跳过。
按每条记录的 `sourceRef` 去重:仅 `open` 任务可刷新描述; 按每条记录的 `sourceRef` 去重:仅 `open` 任务可刷新描述;
`dispatched`、`fixed_by_dev`、`retesting`、`failed_retest`、`verified`、`blocked` 和 `dispatched`、`fixed_by_dev`、`retesting`、`failed_retest`、`verified`、`blocked` 和
`leftover` 只报告来源漂移,绝不覆盖;来源消失或读取失败时绝不删除已有任务。 `leftover` 只报告来源漂移,绝不覆盖;来源消失或读取失败时绝不删除已有任务。
+1 -1
View File
@@ -1 +1 @@
0.13.1 0.14.0
+1 -1
View File
@@ -1,6 +1,6 @@
# notes-web Agent 协作协议(示例,项目覆盖层) # notes-web Agent 协作协议(示例,项目覆盖层)
> 本项目基于 ack v0.13.1 > 本项目基于 ack v0.14.0
> 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。 > 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。
> 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。 > 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。
> ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。 > ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
+7 -1
View File
@@ -3,7 +3,7 @@
version: 1 version: 1
updatedAt: "2026-07-06T09:40:00+08:00" updatedAt: "2026-07-06T09:40:00+08:00"
source: "Coordinator (PM) Agent" source: "Coordinator (PM) Agent"
ackVersion: "0.13.1" ackVersion: "0.14.0"
project: project:
name: "notes-web" name: "notes-web"
repoPath: "/home/dev/notes-web" repoPath: "/home/dev/notes-web"
@@ -112,6 +112,12 @@ tasks:
updatedAt: "2026-07-06T09:00:00Z" updatedAt: "2026-07-06T09:00:00Z"
description: > description: >
用户在 /fix 页点击“预览变更”后,确认区不渲染 API 返回的 diff。 用户在 /fix 页点击“预览变更”后,确认区不渲染 API 返回的 diff。
fixLogic: >
修正确认区对预览响应的字段映射,完整渲染四类 diff,同时保持取消操作不触发写入。
acceptanceCriteria:
- "预览后确认区显示 title、修改前值、修改后值和 coverChanged"
- "取消预览不会产生保存请求"
- "原先确认区为空的问题不再出现"
stepsToReproduce: stepsToReproduce:
- "打开 /fix?fileId=1" - "打开 /fix?fileId=1"
- "搜索并选择候选项" - "搜索并选择候选项"
+99 -51
View File
@@ -1,24 +1,37 @@
# 飞书 Base Bug 收件箱 # 飞书 Base Bug 整理与审核
这是可选的只读接入。项目在 `docs/ack/tasks.yaml``project.bugIntake` 声明 飞书 Base 是 Bug 在**审核通过前的唯一协作区**。用户先在飞书记录 Bug 描述,
`provider: feishu-base`、显式 `profile``baseToken``tableId`、ACK Ready 的 `viewId` Coordinator 读取记录和项目现状,补全或修改修复逻辑、验收标准,再写回同一条飞书
和八个字段映射;不要保存 App Secret、access token 或任何 profile 凭据。 记录。用户可以继续在飞书反馈,Coordinator 按反馈反复修订。只有用户明确表示审核
通过后,Coordinator 才把最终版本写入 `docs/ack/tasks.yaml`,随后启动 ACK 三角色闭环。
审核前不创建或刷新 `tasks.yaml` 中的 ACK 任务,不得启动 worker,不得派发
Developer/Test,也不得修改应用代码。飞书字段已填满、记录进入某个 view、用户暂时没有
回复,都不等于审核通过。
## 项目配置
项目在 `docs/ack/tasks.yaml``project.bugIntake` 声明 `provider: feishu-base`
`workflow: reviewed-writeback-v1`、显式 `profile``baseToken``tableId`、用于候选 Bug
`viewId` 和字段映射;不要保存 App Secret、access token 或任何 profile 凭据。
原有八字段配置在未声明 `workflow` 时按 `read-only-v1` 兼容读取,不得写回或进入本节
流程。启用审核前协作必须显式选择 `reviewed-writeback-v1`,并在 `title``actual`
`expected``stepsToReproduce``acceptance``priority``attachments``updatedAt`
之外配置可写文本字段 `fixLogic`。其中 `fixLogic``acceptance` 由 Coordinator 维护;
用户提供的标题、实际表现、预期结果和附件属于来源事实,Coordinator 不得覆盖。
## 一次性安装与 profile 设置 ## 一次性安装与 profile 设置
在账号级可信工具目录安装官方 CLI;ACK 读取器只会搜索账号的 `~/.local/bin`、mise/cargo 在账号级可信工具目录安装官方 CLI;ACK 读取器只会搜索账号的 `~/.local/bin`、mise/cargo
shim 目录和固定系统目录,绝不会采用项目 `PATH` 中的同名文件。它同时识别官方 npm shim 目录和固定系统目录,绝不会采用项目 `PATH` 中的同名文件。它同时识别官方 npm
包生成的 `lark-cli -> @larksuite/cli/scripts/run.js` wrapper,并校验 package 名与 bin 包生成的 `lark-cli -> @larksuite/cli/scripts/run.js` wrapper,并校验 package 名与 bin
映射,然后直接执行该官方包下载的 native binary;这样不依赖 nvm PATH,也不会让 Node 映射,然后直接执行该官方包下载的 native binary
运行时变量进入凭据边界。缺少 native binary 或同名仿冒 wrapper 都会被拒绝:
```bash ```bash
npm install --global --prefix "$HOME/.local" @larksuite/cli@latest npm install --global --prefix "$HOME/.local" @larksuite/cli@latest
``` ```
固定 `--prefix "$HOME/.local"` 是接入契约的一部分,确保 npm wrapper 落在读取器会检查的
账号级可信目录;不要依赖 nvm 或其它由当前 shell 动态注入的 PATH 位置。
随后在受控终端中用 stdin 提供 App Secret,选择 Feishu brand,避免 secret 进入 shell 随后在受控终端中用 stdin 提供 App Secret,选择 Feishu brand,避免 secret 进入 shell
history、进程参数或项目文件: history、进程参数或项目文件:
@@ -28,14 +41,13 @@ printf '%s' "$FEISHU_APP_SECRET" | lark-cli profile add \
--app-secret-stdin --brand feishu --app-secret-stdin --brand feishu
``` ```
随后在 project 配置只填写 `profile: project-feishu`。不要执行 `profile use`,也不要 项目配置只填写 `profile: project-feishu`。不要执行 `profile use`,也不要依赖 active
依赖 active profile每次读取附件下载都由 adapter 显式传 `--profile project-feishu` profile;读取附件下载和写回都必须显式传 `--profile project-feishu`profile 至少需要
为读取记录和下载附件,profile 必须具有 `base:record:read` `base:record:read``base:record:write``docs:document.media:download`
`docs:document.media:download`。请在飞书开发者后台的应用权限中为该 app 授予这两个 不要把 `lark-cli auth check` 当作 app/bot scope 的证明,因为它检查的是当前用户的
scope;不要把 `lark-cli auth check` 当作 app/bot scope 的证明,因为它检查的是当前用户的
stored user token。 stored user token。
## 使用 ## 读取与整理计划
```bash ```bash
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py check docs/ack/tasks.yaml python3 <ack-skill-dir>/scripts/feishu_bug_intake.py check docs/ack/tasks.yaml
@@ -46,48 +58,84 @@ python3 <ack-skill-dir>/scripts/feishu_bug_intake.py plan docs/ack/tasks.yaml \
--output-dir "$tmpdir" --output-dir "$tmpdir"
``` ```
`fetch` 只调用官方 `base +record-list`(限定配置的 view 和字段)和按配置附件字段的 `fetch` 只调用官方 `base +record-list` 和附件下载命令,输出标准化 JSON,并为每条记录
`base +record-download-attachment`。它输出单一 JSON,验证行矩阵、分页和下载路径;CLI、 计算覆盖来源事实、附件元数据和审核字段的 `draftRevision``plan` 只生成
profile、JSON、分页、附件或路径任一异常都会失败且不输出伪成功结果。 `create` / `refresh` / `unchanged` / `drift` 候选动作,不修改飞书或任务板。**审核通过前
不得执行这些候选动作**;它们只用于查重和预览最终导入结果。
`plan` 在同一批标准化记录上读取现有 `tasks`,只输出整理计划而不修改文件:新来源为 标题、实际表现、预期结果和更新时间属于来源事实,任一缺失时读取失败。复现步骤、
`create`,同来源且现有任务为 `open`、来源时间有变化时为 `refresh`,未变化为 修复逻辑、验收标准和优先级属于 Coordinator 可整理字段;缺失时记录与 action 返回
`unchanged`,其它 ACK 状态发生来源变化时为 `drift`。任务板或读取结果出现重复 `enrichmentRequired`。没有附件且所有 Bug 内容字段都为空的误建行会跳过,并在批次
`sourceRef` 会直接失败。标题、实际表现、预期结果和更新时间属于来源事实,任一缺失时 `warnings` 中返回 `blank_record_skipped`
读取失败;复现步骤、验收标准和优先级属于 Coordinator 可整理字段,缺失时记录与 action
会返回 `enrichmentRequired`,不阻断整批。没有附件且所有 Bug 内容字段都为空的误建行会
跳过,并在批次 `warnings` 中返回 `blank_record_skipped`
子进程只收到实际账号 HOME、可信 PATH 和基础 locale;调用者环境中的 子进程只收到实际账号 HOME、可信 PATH 和基础 locale;调用者环境中的
`LARKSUITE_CLI_*``FEISHU_*``NODE_OPTIONS` 等变量不会传入,避免环境凭据或运行时 `LARKSUITE_CLI_*``FEISHU_*``NODE_OPTIONS` 等变量不会传入。每条记录最多 10 个
注入绕过项目 profile。每条记录最多 10 个附件、单批最多 100 个,单个附件最多 附件、单批最多 100 个,单个附件最多 20 MiB、合计最多 200 MiB,整批下载最多 5 分钟;
20 MiB、合计最多 200 MiB,整批附件下载最多 5 分钟,并校验声明大小与落盘大小; 始终使用新的临时目录作为 `--output-dir`
请始终使用新的临时目录作为 `--output-dir`
`profile list` 只作存在性检查,不会输出 profile 内容。官方 record-list JSON 使用 ## 审核前协作循环
`data.fields``data.record_id_list` 与同长度的 `data.data` 行矩阵;`ok: true`
`code: 0` 是唯一可接受的成功 envelope。
## Coordinator 整理 对每条候选 BugCoordinator 按以下顺序工作:
读取结果的每条 `sourceRef` 是稳定且不透明的键,例如 1. 读取用户填写的来源事实、附件和项目代码/测试,确认问题边界;证据不足时把不确定点
`feishu-base:sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef` 明确写成假设,不伪装成用户原文
导入时写入 task 的 2. 整理复现步骤和优先级,并生成:
`source.kind: feishu-base``source.ref``source.recordId``source.updatedAt`;先按 - `fixLogic`:说明根因判断、计划修改的位置与行为、需要保持的不变量,以及回归风险;
`source.ref` 查重。相同来源只更新 `open` 任务;`dispatched``fixed_by_dev` 它是待审核的实现方向,不宣称代码已经修改。
`retesting``failed_retest``verified``blocked``leftover` 任务只告警来源漂移, - `acceptance`:写成可观测、可独立复测的标准,至少覆盖用户可见结果、真实状态或 API
由用户决定是否新建任务;飞书记录消失、不可访问或同步失败时,已有 ACK 任务一律保留 结果,以及原失败不再出现;不扩张用户没有表达的产品范围
3. 把本轮草案保存为不超过 64 KiB 的临时 JSON:只包含非空字符串 `fixLogic` 和非空
字符串数组 `acceptance`。通过安全适配器写回当前飞书记录:
当 action 带 `enrichmentRequired` 时,由 Coordinator 补齐后再写入任务板,不要求报告者 ```bash
返回飞书机械补字段: python3 <ack-skill-dir>/scripts/feishu_bug_intake.py write-draft \
docs/ack/tasks.yaml --record-id <record-id> \
--expected-source-ref <sourceRef> \
--expected-draft-revision <draftRevision> --input <draft.json>
```
- `steps`:根据标题、实际表现、附件和项目现状整理 2 至 5 个可复现步骤;证据不足时明确 `write-draft` 会在写前确认 record 属于配置 view,且 `sourceRef`、`draftRevision` 与调用者
写成待 Developer 首轮确认的复现假设,不能把假设伪装成来源原文。 看到的版本一致;它只允许覆盖配置映射的 `fixLogic` 和 `acceptance`,拒绝 symlink、超限
- `acceptance`:把预期结果改写成可观测信号,至少覆盖用户可见结果、真实状态或 API 结果, 或多余字段输入,并复用读取器的可信 CLI、显式 profile、最小环境和成功 envelope 校验。
以及原失败不再出现;不扩张飞书记录没有表达的产品范围。 不得绕过适配器直接调用 PATH 中的 CLI,也不得改标题、实际表现、预期结果、附件或其它
- `priority`:主流程阻断、数据损坏/丢失或安全风险定为 P0;普通功能错误默认 P1;纯样式或 用户字段。
低频体验问题可定为 P2。无法判断时使用 P1,并保留判断依据。 4. `write-draft` 会回读同一记录、确认落盘值一致并返回当前 `draftRevision`。把这个 revision
连同记录交给用户审核;不能只展示没有 revision 的自由文本。
5. 用户提出意见时,重新读取最新记录和反馈,修订后再次写回、回读、等待审核;不要在
本地或聊天中维护一份与飞书分叉的“最终草案”。
在任务 `evidence.intakeEnrichment` 中记录哪些字段由 Coordinator 推断及依据。Developer 任一写回、回读或字段校验失败时停止本轮并报告,不能转入任务板或三角色处理。
必须先复现或用失败测试确认推断步骤;Test 仍按任务中的可观测验收信号独立复测。标题、
实际表现和预期结果不得由 Coordinator 补造,部分缺失时继续 fail closed。 ## 审核门禁与导入
只有用户针对当前飞书记录的明确 `draftRevision` 表示“审核通过”“可以执行”或等价授权,
才解除门禁。授权必须发生在最后一次草案写回和回读之后;之后若用户字段、附件、
`fixLogic` 或 `acceptance` 再次变化,revision 会变化,原授权失效,必须重新审核。
审核通过后,Coordinator 才执行以下动作:
1. 通过适配器重新读取并生成规范任务草案;命令会要求当前 `sourceRef` 和
`draftRevision` 精确等于用户批准值:
```bash
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py import-approved \
docs/ack/tasks.yaml --record-id <record-id> \
--expected-source-ref <approved-sourceRef> \
--expected-draft-revision <approved-draftRevision>
```
不一致时停止并重新审核。输出的 `taskDraft` 包含批准后的规范字段以及
`source.workflow`、`source.approvedRevision`、`source.approvedPayloadHash`。
2. 按 `source.ref` 查重,把 `taskDraft` 原样合并到新的 ACK task,只补任务 ID、状态、
dispatch 等 ACK 运行字段;不得手工改写 `fixLogic`、`acceptanceCriteria` 或其它审核字段。
可在 `evidence.intakeEnrichment` 标明 Coordinator 推断依据,但它不替代规范字段。
3. 运行 `validate_tasks.py`。校验器会重算 `approvedPayloadHash`;只有写入和校验都成功,
任务才可作为已确认的 `open` 任务
进入 ACK 闭环。
4. 按 Coordinator → Developer → Test 的标准流程派发、实现和独立复测。
相同来源只更新尚未派发的 `open` 任务;`dispatched`、`fixed_by_dev`、`retesting`、
`failed_retest`、`verified`、`blocked` 和 `leftover` 任务只告警来源漂移,由用户决定是否
新建任务。项目启用 reviewed workflow 后,历史 `verified` / `leftover` 只读来源继续保留;
其它仍可执行的 legacy 飞书任务必须先按当前流程重新整理和审核,不能通过省略或降级
`source.workflow` 绕过门禁。飞书记录消失、不可访问或同步失败时,已有 ACK 任务一律保留。
+50
View File
@@ -0,0 +1,50 @@
"""Canonical reviewed Bug task payload shared by intake and validation."""
from __future__ import annotations
import hashlib
import json
import re
from typing import Any
PAYLOAD_FIELDS = (
"title",
"description",
"priority",
"actual",
"expected",
"stepsToReproduce",
"fixLogic",
"acceptanceCriteria",
)
NUMBERED_ITEM = re.compile(r"(?:^|\s)([1-9][0-9]*)\.\s+")
def review_items(value: str) -> list[str]:
"""Recover line or numbered-list review text as stable non-empty items."""
lines = [line.strip(" \t-*•") for line in value.splitlines() if line.strip()]
if len(lines) > 1:
return lines
text = value.strip()
matches = list(NUMBERED_ITEM.finditer(text))
if matches:
items: list[str] = []
for index, match in enumerate(matches):
start = match.end()
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
item = text[start:end].strip()
if item:
items.append(item)
if items:
return items
return [text] if text else []
def approval_payload_hash(task: dict[str, Any]) -> str:
"""Hash the exact reviewed fields that Developer and Test consume."""
payload = {field: task.get(field) for field in PAYLOAD_FIELDS}
encoded = json.dumps(
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"),
).encode("utf-8")
return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
+270 -18
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Read an ACK-ready Feishu Base view through the official lark-cli. """Read and review an ACK-ready Feishu Base view through the official lark-cli.
This is deliberately a small, non-mutating adapter. It never reads the The only mutation is a bounded draft write to configured Coordinator fields.
active profile and emits one JSON document only on success. The adapter never reads the active profile and emits one JSON document only
on success.
""" """
from __future__ import annotations from __future__ import annotations
@@ -23,12 +24,14 @@ import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from approval_payload import approval_payload_hash, review_items
from yaml_subset import DuplicateKeyError, YamlSubsetError, load_json_unique, load_yaml_subset, make_unique_pyyaml_loader from yaml_subset import DuplicateKeyError, YamlSubsetError, load_json_unique, load_yaml_subset, make_unique_pyyaml_loader
REQUIRED_FIELDS = ("title", "actual", "expected", "stepsToReproduce", "acceptance", "priority", "attachments", "updatedAt") REQUIRED_FIELDS = ("title", "actual", "expected", "stepsToReproduce", "acceptance", "priority", "attachments", "updatedAt")
OPTIONAL_FIELDS = ("fixLogic",)
SOURCE_FACT_FIELDS = ("title", "actual", "expected", "updatedAt") SOURCE_FACT_FIELDS = ("title", "actual", "expected", "updatedAt")
COORDINATOR_FIELDS = ("steps", "acceptance", "priority") COORDINATOR_FIELDS = ("steps", "acceptance", "priority")
BUG_CONTENT_FIELDS = ("title", "actual", "expected", *COORDINATOR_FIELDS) BUG_CONTENT_FIELDS = ("title", "actual", "expected", "fixLogic", *COORDINATOR_FIELDS)
MAX_PAGES = 100 MAX_PAGES = 100
MAX_RECORDS = 10_000 MAX_RECORDS = 10_000
PAGE_SIZE = 100 PAGE_SIZE = 100
@@ -39,10 +42,13 @@ MAX_TOTAL_ATTACHMENTS = 100
MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
MAX_TOTAL_ATTACHMENT_BYTES = 200 * 1024 * 1024 MAX_TOTAL_ATTACHMENT_BYTES = 200 * 1024 * 1024
MAX_ATTACHMENT_BATCH_SECONDS = 300 MAX_ATTACHMENT_BATCH_SECONDS = 300
MAX_DRAFT_BYTES = 64 * 1024
SAFE_VALUE = re.compile(r"^[^\s\x00-\x1f]{1,256}$") SAFE_VALUE = re.compile(r"^[^\s\x00-\x1f]{1,256}$")
PROFILE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") PROFILE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
RECORD_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$") RECORD_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$")
SOURCE_REF = re.compile(r"^feishu-base:sha256:[0-9a-f]{64}$") SOURCE_REF = re.compile(r"^feishu-base:sha256:[0-9a-f]{64}$")
DRAFT_REVISION = re.compile(r"^sha256:[0-9a-f]{64}$")
WORKFLOWS = {"read-only-v1", "reviewed-writeback-v1"}
class IntakeError(Exception): class IntakeError(Exception):
@@ -182,6 +188,60 @@ def load_board(path: Path) -> dict[str, Any]:
return value return value
def load_draft(path: Path) -> dict[str, Any]:
"""Load one bounded, regular JSON file with the two writable draft fields."""
descriptor: int | None = None
try:
before = path.lstat()
if not stat.S_ISREG(before.st_mode) or path.is_symlink():
raise IntakeError("draft input must be a regular file")
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
descriptor = os.open(path, flags)
metadata = os.fstat(descriptor)
if (
not stat.S_ISREG(metadata.st_mode)
or (before.st_dev, before.st_ino) != (metadata.st_dev, metadata.st_ino)
):
raise IntakeError("draft input changed while opening")
if metadata.st_size <= 0 or metadata.st_size > MAX_DRAFT_BYTES:
raise IntakeError("draft input size is invalid")
chunks: list[bytes] = []
total = 0
while total <= MAX_DRAFT_BYTES:
chunk = os.read(descriptor, min(64 * 1024, MAX_DRAFT_BYTES + 1 - total))
if not chunk:
break
chunks.append(chunk)
total += len(chunk)
content = b"".join(chunks)
if len(content) != metadata.st_size:
raise IntakeError("draft input changed while reading")
value = load_json_unique(content.decode("utf-8"))
except IntakeError:
raise
except (OSError, UnicodeError, json.JSONDecodeError, DuplicateKeyError) as exc:
raise IntakeError("cannot read draft input") from exc
finally:
if descriptor is not None:
os.close(descriptor)
if not isinstance(value, dict) or set(value) != {"fixLogic", "acceptance"}:
raise IntakeError("draft input must contain exactly fixLogic and acceptance")
fix_logic = value["fixLogic"]
acceptance = value["acceptance"]
if not isinstance(fix_logic, str) or not fix_logic.strip():
raise IntakeError("draft fixLogic must be a non-empty string")
if (
not isinstance(acceptance, list)
or not acceptance
or any(not isinstance(item, str) or not item.strip() for item in acceptance)
):
raise IntakeError("draft acceptance must be a non-empty string list")
return {
"fixLogic": fix_logic.strip(),
"acceptance": [item.strip() for item in acceptance],
}
def config_from_board(board: dict[str, Any]) -> dict[str, Any]: def config_from_board(board: dict[str, Any]) -> dict[str, Any]:
project = board.get("project") project = board.get("project")
if not isinstance(project, dict) or "bugIntake" not in project: if not isinstance(project, dict) or "bugIntake" not in project:
@@ -189,12 +249,15 @@ def config_from_board(board: dict[str, Any]) -> dict[str, Any]:
config = project["bugIntake"] config = project["bugIntake"]
if not isinstance(config, dict): if not isinstance(config, dict):
raise IntakeError("project.bugIntake must be an object") raise IntakeError("project.bugIntake must be an object")
allowed = {"provider", "profile", "baseToken", "tableId", "viewId", "fields"} allowed = {"provider", "workflow", "profile", "baseToken", "tableId", "viewId", "fields"}
unknown = sorted(set(config) - allowed) unknown = sorted(set(config) - allowed)
if unknown: if unknown:
raise IntakeError("bugIntake has unknown fields") raise IntakeError("bugIntake has unknown fields")
if config.get("provider") != "feishu-base": if config.get("provider") != "feishu-base":
raise IntakeError("bugIntake.provider must be feishu-base") raise IntakeError("bugIntake.provider must be feishu-base")
workflow = config.get("workflow", "read-only-v1")
if workflow not in WORKFLOWS:
raise IntakeError("bugIntake.workflow is invalid")
profile = config.get("profile") profile = config.get("profile")
if not isinstance(profile, str) or not PROFILE.fullmatch(profile): if not isinstance(profile, str) or not PROFILE.fullmatch(profile):
raise IntakeError("bugIntake.profile is invalid") raise IntakeError("bugIntake.profile is invalid")
@@ -203,12 +266,19 @@ def config_from_board(board: dict[str, Any]) -> dict[str, Any]:
if not isinstance(value, str) or not SAFE_VALUE.fullmatch(value): if not isinstance(value, str) or not SAFE_VALUE.fullmatch(value):
raise IntakeError(f"bugIntake.{key} is invalid") raise IntakeError(f"bugIntake.{key} is invalid")
fields = config.get("fields") fields = config.get("fields")
if not isinstance(fields, dict) or set(fields) != set(REQUIRED_FIELDS): supported = set(REQUIRED_FIELDS) | set(OPTIONAL_FIELDS)
raise IntakeError("bugIntake.fields must map exactly the required logical fields") if (
not isinstance(fields, dict)
or not set(REQUIRED_FIELDS).issubset(fields)
or not set(fields).issubset(supported)
):
raise IntakeError("bugIntake.fields must map all required and only supported logical fields")
if any(not isinstance(value, str) or not SAFE_VALUE.fullmatch(value) for value in fields.values()): if any(not isinstance(value, str) or not SAFE_VALUE.fullmatch(value) for value in fields.values()):
raise IntakeError("bugIntake.fields values are invalid") raise IntakeError("bugIntake.fields values are invalid")
if len(set(fields.values())) != len(fields): if len(set(fields.values())) != len(fields):
raise IntakeError("bugIntake.fields values must be unique") raise IntakeError("bugIntake.fields values must be unique")
if workflow == "reviewed-writeback-v1" and "fixLogic" not in fields:
raise IntakeError("reviewed writeback requires bugIntake.fields.fixLogic")
return config return config
@@ -381,7 +451,11 @@ def matrix_from_response(response: dict[str, Any], field_ids: list[str]) -> tupl
def fetch_pages(config: dict[str, Any]) -> list[tuple[str, list[Any]]]: def fetch_pages(config: dict[str, Any]) -> list[tuple[str, list[Any]]]:
field_ids = [config["fields"][logical] for logical in REQUIRED_FIELDS] logical_fields = [
logical for logical in (*REQUIRED_FIELDS, *OPTIONAL_FIELDS)
if logical in config["fields"]
]
field_ids = [config["fields"][logical] for logical in logical_fields]
all_rows: list[tuple[str, list[Any]]] = [] all_rows: list[tuple[str, list[Any]]] = []
offset = 0 offset = 0
for _ in range(MAX_PAGES): for _ in range(MAX_PAGES):
@@ -444,6 +518,37 @@ def source_ref(config: dict[str, Any], record_id: str) -> str:
return f"feishu-base:sha256:{hashlib.sha256(identity.encode('utf-8')).hexdigest()}" return f"feishu-base:sha256:{hashlib.sha256(identity.encode('utf-8')).hexdigest()}"
def draft_revision(
record: dict[str, Any], attachment_tokens: list[str] | None = None,
) -> str:
"""Bind approval to the normalized source facts and review-controlled fields."""
tokens = attachment_tokens or []
if len(tokens) != len(record["attachments"]):
raise IntakeError("draft revision attachment identity is incomplete")
stable = {
"sourceRef": record["sourceRef"],
"updatedAt": record["updatedAt"],
"title": record["title"],
"actual": record["actual"],
"expected": record["expected"],
"steps": record["steps"],
"fixLogic": record["fixLogic"],
"acceptance": record["acceptance"],
"priority": record["priority"],
"attachments": [
{
**{key: attachment.get(key) for key in ("name", "type", "size")},
"tokenDigest": f"sha256:{hashlib.sha256(('ack-feishu-attachment-v1\x1f' + token).encode('utf-8')).hexdigest()}",
}
for attachment, token in zip(record["attachments"], tokens)
],
}
encoded = json.dumps(
stable, ensure_ascii=False, sort_keys=True, separators=(",", ":"),
).encode("utf-8")
return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
def fetch(config: dict[str, Any], output_dir: Path | None) -> dict[str, Any]: def fetch(config: dict[str, Any], output_dir: Path | None) -> dict[str, Any]:
profile_check(config) profile_check(config)
prepared: list[tuple[dict[str, Any], list[tuple[dict[str, Any], str]]]] = [] prepared: list[tuple[dict[str, Any], list[tuple[dict[str, Any], str]]]] = []
@@ -451,7 +556,11 @@ def fetch(config: dict[str, Any], output_dir: Path | None) -> dict[str, Any]:
total_attachments = 0 total_attachments = 0
total_attachment_bytes = 0 total_attachment_bytes = 0
for record_id, row in fetch_pages(config): for record_id, row in fetch_pages(config):
cells = dict(zip(REQUIRED_FIELDS, row)) logical_fields = [
logical for logical in (*REQUIRED_FIELDS, *OPTIONAL_FIELDS)
if logical in config["fields"]
]
cells = dict(zip(logical_fields, row))
attachment_data = attachment_items(cells["attachments"]) attachment_data = attachment_items(cells["attachments"])
total_attachments += len(attachment_data) total_attachments += len(attachment_data)
total_attachment_bytes += sum(metadata["size"] for metadata, _ in attachment_data) total_attachment_bytes += sum(metadata["size"] for metadata, _ in attachment_data)
@@ -459,16 +568,22 @@ def fetch(config: dict[str, Any], output_dir: Path | None) -> dict[str, Any]:
raise IntakeError("batch exceeded the attachment count limit") raise IntakeError("batch exceeded the attachment count limit")
if total_attachment_bytes > MAX_TOTAL_ATTACHMENT_BYTES: if total_attachment_bytes > MAX_TOTAL_ATTACHMENT_BYTES:
raise IntakeError("batch exceeded the attachment byte limit") raise IntakeError("batch exceeded the attachment byte limit")
record = {"sourceRef": source_ref(config, record_id), "recordId": record_id, "updatedAt": text(cells["updatedAt"]), "title": text(cells["title"]), "actual": text(cells["actual"]), "expected": text(cells["expected"]), "steps": text(cells["stepsToReproduce"]), "acceptance": text(cells["acceptance"]), "priority": text(cells["priority"]), "attachments": [metadata for metadata, _ in attachment_data], "warnings": []} record = {"sourceRef": source_ref(config, record_id), "recordId": record_id, "updatedAt": text(cells["updatedAt"]), "title": text(cells["title"]), "actual": text(cells["actual"]), "expected": text(cells["expected"]), "steps": text(cells["stepsToReproduce"]), "fixLogic": text(cells.get("fixLogic")), "acceptance": text(cells["acceptance"]), "priority": text(cells["priority"]), "attachments": [metadata for metadata, _ in attachment_data], "warnings": []}
if not attachment_data and not any(record[field] for field in BUG_CONTENT_FIELDS): if not attachment_data and not any(record[field] for field in BUG_CONTENT_FIELDS):
batch_warnings.append({"recordId": record_id, "code": "blank_record_skipped"}) batch_warnings.append({"recordId": record_id, "code": "blank_record_skipped"})
continue continue
for field in SOURCE_FACT_FIELDS: for field in SOURCE_FACT_FIELDS:
if not record[field]: if not record[field]:
raise IntakeError(f"record {field} must not be empty") raise IntakeError(f"record {field} must not be empty")
enrichment_fields = list(COORDINATOR_FIELDS)
if "fixLogic" in config["fields"]:
enrichment_fields.append("fixLogic")
record["enrichmentRequired"] = [ record["enrichmentRequired"] = [
field for field in COORDINATOR_FIELDS if not record[field] field for field in enrichment_fields if not record[field]
] ]
record["draftRevision"] = draft_revision(
record, [token for _, token in attachment_data],
)
prepared.append((record, attachment_data)) prepared.append((record, attachment_data))
download_root: Path | None = None download_root: Path | None = None
if output_dir is not None: if output_dir is not None:
@@ -493,7 +608,117 @@ def fetch(config: dict[str, Any], output_dir: Path | None) -> dict[str, Any]:
attachment["size"], min(60, remaining), attachment["size"], min(60, remaining),
) )
records.append(record) records.append(record)
return {"provider": "feishu-base", "profile": config["profile"], "tableId": config["tableId"], "viewId": config["viewId"], "records": records, "warnings": batch_warnings} return {"provider": "feishu-base", "workflow": config.get("workflow", "read-only-v1"), "profile": config["profile"], "tableId": config["tableId"], "viewId": config["viewId"], "records": records, "warnings": batch_warnings}
def review_record(
config: dict[str, Any], record_id: str, expected_source_ref: str,
expected_revision: str,
) -> dict[str, Any]:
"""Resolve one record inside the configured view and bind its reviewed version."""
if RECORD_ID.fullmatch(record_id) is None:
raise IntakeError("review record id is invalid")
if SOURCE_REF.fullmatch(expected_source_ref) is None:
raise IntakeError("expected source reference is invalid")
if DRAFT_REVISION.fullmatch(expected_revision) is None:
raise IntakeError("expected draft revision is invalid")
matching = [
record for record in fetch(config, None)["records"]
if record["recordId"] == record_id
]
if len(matching) != 1:
raise IntakeError("configured review view did not contain exactly one record")
if (
matching[0]["sourceRef"] != expected_source_ref
or matching[0]["draftRevision"] != expected_revision
):
raise IntakeError("review record changed before the requested operation")
return matching[0]
def write_draft(
config: dict[str, Any], record_id: str, expected_source_ref: str,
expected_revision: str, draft_path: Path,
) -> dict[str, Any]:
"""Overwrite only the configured fix logic and acceptance cells."""
if config.get("workflow", "read-only-v1") != "reviewed-writeback-v1":
raise IntakeError("draft writeback requires reviewed-writeback-v1 workflow")
if "fixLogic" not in config["fields"]:
raise IntakeError("bugIntake.fields.fixLogic is required for draft writeback")
review_record(config, record_id, expected_source_ref, expected_revision)
draft = load_draft(draft_path)
patch = {
config["fields"]["fixLogic"]: draft["fixLogic"],
config["fields"]["acceptance"]: "\n".join(
f"{index}. {item}" for index, item in enumerate(draft["acceptance"], start=1)
),
}
profile_check(config)
run_cli([
"base", "+record-upsert", "--profile", config["profile"],
"--base-token", config["baseToken"], "--table-id", config["tableId"],
"--record-id", record_id, "--json",
json.dumps(patch, ensure_ascii=False, separators=(",", ":")),
"--format", "json",
])
matching = [record for record in fetch(config, None)["records"] if record["recordId"] == record_id]
if len(matching) != 1 or matching[0]["sourceRef"] != expected_source_ref:
raise IntakeError("draft writeback readback did not find exactly one record")
expected_acceptance = text(patch[config["fields"]["acceptance"]])
if (
matching[0]["fixLogic"] != text(draft["fixLogic"])
or matching[0]["acceptance"] != expected_acceptance
):
raise IntakeError("draft writeback readback did not match the submitted draft")
return {
"provider": "feishu-base",
"recordId": record_id,
"written": ["fixLogic", "acceptance"],
"draftRevision": matching[0]["draftRevision"],
"ok": True,
}
def import_approved(
config: dict[str, Any], record_id: str, expected_source_ref: str,
expected_revision: str,
) -> dict[str, Any]:
"""Emit the canonical task payload for one explicitly approved draft revision."""
if config.get("workflow", "read-only-v1") != "reviewed-writeback-v1":
raise IntakeError("approved import requires reviewed-writeback-v1 workflow")
record = review_record(
config, record_id, expected_source_ref, expected_revision,
)
steps = review_items(record["steps"])
acceptance = review_items(record["acceptance"])
if not record["priority"] or not steps or not record["fixLogic"] or not acceptance:
raise IntakeError("approved record is missing prepared review fields")
task_draft: dict[str, Any] = {
"title": record["title"],
"priority": record["priority"],
"description": record["title"],
"actual": record["actual"],
"expected": record["expected"],
"stepsToReproduce": steps,
"fixLogic": record["fixLogic"],
"acceptanceCriteria": acceptance,
"source": {
"kind": "feishu-base",
"workflow": "reviewed-writeback-v1",
"ref": record["sourceRef"],
"recordId": record["recordId"],
"updatedAt": record["updatedAt"],
"approvedRevision": record["draftRevision"],
},
}
task_draft["source"]["approvedPayloadHash"] = approval_payload_hash(task_draft)
return {
"provider": "feishu-base",
"recordId": record_id,
"draftRevision": record["draftRevision"],
"taskDraft": task_draft,
"ok": True,
}
def plan_actions(board: dict[str, Any], records: list[dict[str, Any]]) -> list[dict[str, Any]]: def plan_actions(board: dict[str, Any], records: list[dict[str, Any]]) -> list[dict[str, Any]]:
@@ -529,10 +754,12 @@ def plan_actions(board: dict[str, Any], records: list[dict[str, Any]]) -> list[d
ref = record.get("sourceRef") ref = record.get("sourceRef")
record_id = record.get("recordId") record_id = record.get("recordId")
updated_at = record.get("updatedAt") updated_at = record.get("updatedAt")
revision = record.get("draftRevision")
if ( if (
not isinstance(ref, str) or SOURCE_REF.fullmatch(ref) is None not isinstance(ref, str) or SOURCE_REF.fullmatch(ref) is None
or not isinstance(record_id, str) or RECORD_ID.fullmatch(record_id) is None or not isinstance(record_id, str) or RECORD_ID.fullmatch(record_id) is None
or not isinstance(updated_at, str) or not updated_at or not isinstance(updated_at, str) or not updated_at
or not isinstance(revision, str) or DRAFT_REVISION.fullmatch(revision) is None
): ):
raise IntakeError("normalized Feishu record identity is invalid") raise IntakeError("normalized Feishu record identity is invalid")
if ref in seen_records: if ref in seen_records:
@@ -540,14 +767,22 @@ def plan_actions(board: dict[str, Any], records: list[dict[str, Any]]) -> list[d
seen_records.add(ref) seen_records.add(ref)
task = existing.get(ref) task = existing.get(ref)
if task is None: if task is None:
planned_action: dict[str, Any] = {"sourceRef": ref, "recordId": record_id, "action": "create"} planned_action: dict[str, Any] = {"sourceRef": ref, "recordId": record_id, "draftRevision": record["draftRevision"], "action": "create"}
enrichment_required = record.get("enrichmentRequired") enrichment_required = record.get("enrichmentRequired")
if enrichment_required: if enrichment_required:
planned_action["enrichmentRequired"] = enrichment_required planned_action["enrichmentRequired"] = enrichment_required
actions.append(planned_action) actions.append(planned_action)
continue continue
source = task["source"] source = task["source"]
if source["updatedAt"] == updated_at: approved_revision = source.get("approvedRevision")
if isinstance(approved_revision, str):
if approved_revision == revision:
action_name = "unchanged"
elif task["status"] == "open":
action_name = "refresh"
else:
action_name = "drift"
elif source["updatedAt"] == updated_at:
action_name = "unchanged" action_name = "unchanged"
elif task["status"] == "open": elif task["status"] == "open":
action_name = "refresh" action_name = "refresh"
@@ -556,6 +791,7 @@ def plan_actions(board: dict[str, Any], records: list[dict[str, Any]]) -> list[d
planned_action = { planned_action = {
"sourceRef": ref, "sourceRef": ref,
"recordId": record_id, "recordId": record_id,
"draftRevision": record["draftRevision"],
"taskId": task["id"], "taskId": task["id"],
"status": task["status"], "status": task["status"],
"action": action_name, "action": action_name,
@@ -568,25 +804,41 @@ def plan_actions(board: dict[str, Any], records: list[dict[str, Any]]) -> list[d
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Read a configured Feishu Base bug intake") parser = argparse.ArgumentParser(description="Read and review a configured Feishu Base bug intake")
sub = parser.add_subparsers(dest="command", required=True) sub = parser.add_subparsers(dest="command", required=True)
for name in ("check", "fetch", "plan"): for name in ("check", "fetch", "plan", "write-draft", "import-approved"):
command = sub.add_parser(name) command = sub.add_parser(name)
command.add_argument("tasks", type=Path, help="ACK tasks.yaml or JSON board") command.add_argument("tasks", type=Path, help="ACK tasks.yaml or JSON board")
if name in {"fetch", "plan"}: if name in {"fetch", "plan"}:
command.add_argument("--output-dir", type=Path, help="explicit directory for downloaded attachments") command.add_argument("--output-dir", type=Path, help="explicit directory for downloaded attachments")
if name in {"write-draft", "import-approved"}:
command.add_argument("--record-id", required=True, help="existing Feishu Base record id")
command.add_argument("--expected-source-ref", required=True, help="sourceRef returned by fetch")
command.add_argument("--expected-draft-revision", required=True, help="draftRevision returned by fetch")
if name == "write-draft":
command.add_argument("--input", type=Path, required=True, help="bounded JSON draft file")
args = parser.parse_args(argv) args = parser.parse_args(argv)
try: try:
board = load_board(args.tasks) board = load_board(args.tasks)
config = config_from_board(board) config = config_from_board(board)
if args.command == "check": if args.command == "check":
profile_check(config) profile_check(config)
output = {"provider": "feishu-base", "profile": config["profile"], "ok": True} output = {"provider": "feishu-base", "workflow": config.get("workflow", "read-only-v1"), "profile": config["profile"], "ok": True}
elif args.command == "fetch": elif args.command == "fetch":
output = fetch(config, args.output_dir) output = fetch(config, args.output_dir)
else: elif args.command == "plan":
output = fetch(config, args.output_dir) output = fetch(config, args.output_dir)
output["actions"] = plan_actions(board, output["records"]) output["actions"] = plan_actions(board, output["records"])
elif args.command == "write-draft":
output = write_draft(
config, args.record_id, args.expected_source_ref,
args.expected_draft_revision, args.input,
)
else:
output = import_approved(
config, args.record_id, args.expected_source_ref,
args.expected_draft_revision,
)
except IntakeError as exc: except IntakeError as exc:
sys.stderr.write(f"Feishu bug intake failed: {exc}\n") sys.stderr.write(f"Feishu bug intake failed: {exc}\n")
return 1 return 1
+69 -4
View File
@@ -24,6 +24,7 @@ import re
import sys import sys
from pathlib import Path from pathlib import Path
from approval_payload import approval_payload_hash
from yaml_subset import ( from yaml_subset import (
DuplicateKeyError, DuplicateKeyError,
YamlSubsetError, YamlSubsetError,
@@ -117,8 +118,13 @@ FEISHU_REQUIRED_FIELDS = {
"title", "actual", "expected", "stepsToReproduce", "acceptance", "priority", "title", "actual", "expected", "stepsToReproduce", "acceptance", "priority",
"attachments", "updatedAt", "attachments", "updatedAt",
} }
FEISHU_CONFIG_FIELDS = {"provider", "profile", "baseToken", "tableId", "viewId", "fields"} FEISHU_OPTIONAL_FIELDS = {"fixLogic"}
FEISHU_SOURCE_FIELDS = {"kind", "ref", "recordId", "updatedAt"} FEISHU_CONFIG_FIELDS = {"provider", "workflow", "profile", "baseToken", "tableId", "viewId", "fields"}
FEISHU_SOURCE_FIELDS = {
"kind", "workflow", "ref", "recordId", "updatedAt", "approvedRevision",
"approvedPayloadHash",
}
FEISHU_WORKFLOWS = {"read-only-v1", "reviewed-writeback-v1"}
FEISHU_PROFILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") FEISHU_PROFILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
FEISHU_SOURCE_REF_RE = re.compile(r"^feishu-base:sha256:[0-9a-f]{64}$") FEISHU_SOURCE_REF_RE = re.compile(r"^feishu-base:sha256:[0-9a-f]{64}$")
FEISHU_RECORD_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$") FEISHU_RECORD_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$")
@@ -598,6 +604,7 @@ def validate_with_schema(data: dict, schema_path: Path) -> list[str]:
def validate_builtin(data: dict) -> list[str]: def validate_builtin(data: dict) -> list[str]:
errors: list[str] = [] errors: list[str] = []
project_intake_workflow = "read-only-v1"
def validate_string_fields( def validate_string_fields(
value: dict, value: dict,
@@ -671,6 +678,11 @@ def validate_builtin(data: dict) -> list[str]:
reject_unknown_fields(intake, FEISHU_CONFIG_FIELDS, "project.bugIntake", errors) reject_unknown_fields(intake, FEISHU_CONFIG_FIELDS, "project.bugIntake", errors)
if intake.get("provider") != "feishu-base": if intake.get("provider") != "feishu-base":
errors.append("project.bugIntake.provider 必须是 feishu-base") errors.append("project.bugIntake.provider 必须是 feishu-base")
workflow = intake.get("workflow", "read-only-v1")
if workflow in FEISHU_WORKFLOWS:
project_intake_workflow = workflow
if workflow not in FEISHU_WORKFLOWS:
errors.append("project.bugIntake.workflow 非法")
profile = intake.get("profile") profile = intake.get("profile")
if not isinstance(profile, str) or FEISHU_PROFILE_RE.fullmatch(profile) is None: if not isinstance(profile, str) or FEISHU_PROFILE_RE.fullmatch(profile) is None:
errors.append("project.bugIntake.profile 非法") errors.append("project.bugIntake.profile 非法")
@@ -679,12 +691,18 @@ def validate_builtin(data: dict) -> list[str]:
if not isinstance(value, str) or not value.strip() or any(char.isspace() for char in value): if not isinstance(value, str) or not value.strip() or any(char.isspace() for char in value):
errors.append(f"project.bugIntake.{key} 必须是无空白非空字符串") errors.append(f"project.bugIntake.{key} 必须是无空白非空字符串")
fields = intake.get("fields") fields = intake.get("fields")
if not isinstance(fields, dict) or set(fields) != FEISHU_REQUIRED_FIELDS: if (
not isinstance(fields, dict)
or not FEISHU_REQUIRED_FIELDS.issubset(fields)
or not set(fields).issubset(FEISHU_REQUIRED_FIELDS | FEISHU_OPTIONAL_FIELDS)
):
errors.append("project.bugIntake.fields 必须且只能映射所需逻辑字段") errors.append("project.bugIntake.fields 必须且只能映射所需逻辑字段")
elif any(not isinstance(v, str) or not v.strip() or any(c.isspace() for c in v) for v in fields.values()): elif any(not isinstance(v, str) or not v.strip() or any(c.isspace() for c in v) for v in fields.values()):
errors.append("project.bugIntake.fields 字段值必须是无空白非空字符串") errors.append("project.bugIntake.fields 字段值必须是无空白非空字符串")
elif len(set(fields.values())) != len(fields): elif len(set(fields.values())) != len(fields):
errors.append("project.bugIntake.fields 字段值不能重复") errors.append("project.bugIntake.fields 字段值不能重复")
elif workflow == "reviewed-writeback-v1" and "fixLogic" not in fields:
errors.append("reviewed-writeback-v1 必须映射 project.bugIntake.fields.fixLogic")
if ( if (
"knowledgeFile" in project "knowledgeFile" in project
and project.get("knowledgeFile") != "docs/ack/knowledge.yaml" and project.get("knowledgeFile") != "docs/ack/knowledge.yaml"
@@ -776,6 +794,7 @@ def validate_builtin(data: dict) -> list[str]:
"assignee", "assignee",
"component", "component",
"description", "description",
"fixLogic",
"expected", "expected",
"actual", "actual",
}, },
@@ -783,7 +802,7 @@ def validate_builtin(data: dict) -> list[str]:
) )
validate_string_lists( validate_string_lists(
task, task,
{"specRefs", "testRefs", "stepsToReproduce"}, {"specRefs", "testRefs", "stepsToReproduce", "acceptanceCriteria"},
where, where,
) )
validate_object_fields(task, {"evidence", "verification"}, where) validate_object_fields(task, {"evidence", "verification"}, where)
@@ -806,6 +825,52 @@ def validate_builtin(data: dict) -> list[str]:
errors.append(f"{where}.source.recordId: 必须是合法飞书记录 ID") errors.append(f"{where}.source.recordId: 必须是合法飞书记录 ID")
if not _nonempty_string(source.get("updatedAt")): if not _nonempty_string(source.get("updatedAt")):
errors.append(f"{where}.source.updatedAt: 必须是非空字符串") errors.append(f"{where}.source.updatedAt: 必须是非空字符串")
source_workflow = source.get("workflow", "read-only-v1")
if source_workflow not in FEISHU_WORKFLOWS:
errors.append(f"{where}.source.workflow: 非法")
if (
project_intake_workflow == "reviewed-writeback-v1"
and source_workflow != "reviewed-writeback-v1"
and status not in {"verified", "leftover"}
):
errors.append(
f"{where}.source.workflow: reviewed 项目的可执行飞书任务必须先迁移审核"
)
approved_revision = source.get("approvedRevision")
stored_payload_hash = source.get("approvedPayloadHash")
if source_workflow == "reviewed-writeback-v1" and approved_revision is None:
errors.append(f"{where}.source.approvedRevision: reviewed workflow 必填")
elif approved_revision is not None and (
not isinstance(approved_revision, str)
or re.fullmatch(r"sha256:[0-9a-f]{64}", approved_revision) is None
):
errors.append(f"{where}.source.approvedRevision: 必须是 sha256 revision")
if source_workflow == "reviewed-writeback-v1":
if (
not isinstance(stored_payload_hash, str)
or re.fullmatch(r"sha256:[0-9a-f]{64}", stored_payload_hash) is None
):
errors.append(f"{where}.source.approvedPayloadHash: reviewed workflow 必填")
required_strings = ("title", "priority", "actual", "expected", "fixLogic")
for field in required_strings:
if not _nonempty_string(task.get(field)):
errors.append(f"{where}.{field}: reviewed workflow 必须是非空字符串")
for field in ("stepsToReproduce", "acceptanceCriteria"):
items = task.get(field)
if (
not isinstance(items, list)
or not items
or any(not _nonempty_string(item) for item in items)
):
errors.append(f"{where}.{field}: reviewed workflow 必须是非空字符串列表")
if (
isinstance(stored_payload_hash, str)
and re.fullmatch(r"sha256:[0-9a-f]{64}", stored_payload_hash)
and stored_payload_hash != approval_payload_hash(task)
):
errors.append(f"{where}.source.approvedPayloadHash: 与任务审核字段不匹配")
elif stored_payload_hash is not None:
errors.append(f"{where}.source.approvedPayloadHash: 只允许 reviewed workflow")
validate_knowledge_fields(task, where, status, errors) validate_knowledge_fields(task, where, status, errors)
+44 -3
View File
@@ -171,6 +171,10 @@
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"provider": { "const": "feishu-base" }, "provider": { "const": "feishu-base" },
"workflow": {
"type": "string",
"enum": ["read-only-v1", "reviewed-writeback-v1"]
},
"profile": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" }, "profile": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" },
"baseToken": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, "baseToken": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
"tableId": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, "tableId": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
@@ -184,13 +188,27 @@
"actual": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, "actual": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
"expected": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, "expected": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
"stepsToReproduce": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, "stepsToReproduce": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
"fixLogic": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
"acceptance": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, "acceptance": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
"priority": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, "priority": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
"attachments": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, "attachments": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
"updatedAt": { "type": "string", "minLength": 1, "pattern": "^\\S+$" } "updatedAt": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }
} }
} }
} },
"allOf": [
{
"if": {
"properties": { "workflow": { "const": "reviewed-writeback-v1" } },
"required": ["workflow"]
},
"then": {
"properties": {
"fields": { "required": ["fixLogic"] }
}
}
}
]
}, },
"feishuTaskSource": { "feishuTaskSource": {
"type": "object", "type": "object",
@@ -198,6 +216,10 @@
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"kind": { "const": "feishu-base" }, "kind": { "const": "feishu-base" },
"workflow": {
"type": "string",
"enum": ["read-only-v1", "reviewed-writeback-v1"]
},
"ref": { "ref": {
"type": "string", "type": "string",
"pattern": "^feishu-base:sha256:[0-9a-f]{64}$" "pattern": "^feishu-base:sha256:[0-9a-f]{64}$"
@@ -206,8 +228,21 @@
"type": "string", "type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$" "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$"
}, },
"updatedAt": { "type": "string", "minLength": 1, "pattern": "\\S" } "updatedAt": { "type": "string", "minLength": 1, "pattern": "\\S" },
} "approvedRevision": { "$ref": "#/definitions/sha256" },
"approvedPayloadHash": { "$ref": "#/definitions/sha256" }
},
"allOf": [
{
"if": {
"properties": { "workflow": { "const": "reviewed-writeback-v1" } },
"required": ["workflow"]
},
"then": {
"required": ["approvedRevision", "approvedPayloadHash"]
}
}
]
}, },
"profileId": { "profileId": {
"type": "string", "type": "string",
@@ -996,6 +1031,12 @@
} }
}, },
"description": { "type": "string" }, "description": { "type": "string" },
"fixLogic": { "type": "string", "minLength": 1, "pattern": "\\S" },
"acceptanceCriteria": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "minLength": 1, "pattern": "\\S" }
},
"stepsToReproduce": { "type": "array", "items": { "type": "string" } }, "stepsToReproduce": { "type": "array", "items": { "type": "string" } },
"expected": { "type": "string" }, "expected": { "type": "string" },
"actual": { "type": "string" }, "actual": { "type": "string" },
+13
View File
@@ -14,6 +14,7 @@ project:
# 可选:飞书 Base Bug 收件箱。只保存 profile 名和资源 ID,绝不保存 App Secret。 # 可选:飞书 Base Bug 收件箱。只保存 profile 名和资源 ID,绝不保存 App Secret。
# bugIntake: # bugIntake:
# provider: "feishu-base" # provider: "feishu-base"
# workflow: "reviewed-writeback-v1"
# profile: "project-feishu" # profile: "project-feishu"
# baseToken: "<base_token>" # baseToken: "<base_token>"
# tableId: "<table_id>" # tableId: "<table_id>"
@@ -23,6 +24,7 @@ project:
# actual: "<field_id>" # actual: "<field_id>"
# expected: "<field_id>" # expected: "<field_id>"
# stepsToReproduce: "<field_id>" # stepsToReproduce: "<field_id>"
# fixLogic: "<field_id>" # 启用飞书审核前协作时必填
# acceptance: "<field_id>" # acceptance: "<field_id>"
# priority: "<field_id>" # priority: "<field_id>"
# attachments: "<field_id>" # attachments: "<field_id>"
@@ -114,13 +116,24 @@ tasks:
# 从飞书导入时由 Coordinator 写入;source.ref 是幂等键。 # 从飞书导入时由 Coordinator 写入;source.ref 是幂等键。
# source: # source:
# kind: "feishu-base" # kind: "feishu-base"
# workflow: "reviewed-writeback-v1"
# ref: "feishu-base:sha256:<64-lowercase-hex>" # ref: "feishu-base:sha256:<64-lowercase-hex>"
# recordId: "<record-id>" # recordId: "<record-id>"
# updatedAt: "<source-updated-at>" # updatedAt: "<source-updated-at>"
# approvedRevision: "sha256:<approved-draft-digest>"
# approvedPayloadHash: "sha256:<canonical-task-payload-digest>"
description: > description: >
<What is wrong, in user-visible terms.> <What is wrong, in user-visible terms.>
# 飞书审核通过后写入批准版本;审核前不创建这条 ACK 任务。
fixLogic: >
<Approved repair logic, affected behavior, invariants, and regression risks.>
acceptanceCriteria:
- "<observable acceptance signal 1>"
- "<observable acceptance signal 2>"
stepsToReproduce: stepsToReproduce:
- "<step 1>" - "<step 1>"
- "<step 2>" - "<step 2>"
+8
View File
@@ -236,6 +236,10 @@ tasks: []
self.assertIn("--brand feishu", content) self.assertIn("--brand feishu", content)
self.assertTrue("scope" in content) self.assertTrue("scope" in content)
self.assertIn("不要把 `lark-cli auth check`", content) self.assertIn("不要把 `lark-cli auth check`", content)
self.assertIn("审核通过前的唯一协作区", content)
self.assertIn("不创建或刷新 `tasks.yaml` 中的 ACK 任务", content)
self.assertIn("base:record:write", content)
self.assertIn("fixLogic", content)
self.assertNotIn("--lark-cli", content) self.assertNotIn("--lark-cli", content)
self.assertNotIn("--executable", content) self.assertNotIn("--executable", content)
@@ -248,8 +252,12 @@ tasks: []
self.assertEqual(empty_plan["actions"], [{ self.assertEqual(empty_plan["actions"], [{
"sourceRef": first, "sourceRef": first,
"recordId": "recBug1", "recordId": "recBug1",
"draftRevision": empty_plan["records"][0]["draftRevision"],
"action": "create", "action": "create",
}]) }])
self.assertRegex(
empty_plan["actions"][0]["draftRevision"], r"^sha256:[0-9a-f]{64}$",
)
base = self.board.read_text(encoding="utf-8") base = self.board.read_text(encoding="utf-8")
+165 -1
View File
@@ -53,9 +53,13 @@ class FeishuBugIntakeUnitTests(unittest.TestCase):
" if '--format' in args: raise SystemExit(8)\n" " if '--format' in args: raise SystemExit(8)\n"
" print(json.dumps([{'name':'tenant-a','appId':'cli_a','brand':'feishu','active':True},{'name':'tenant-b','appId':'cli_b','brand':'feishu','active':False}]))\n" " print(json.dumps([{'name':'tenant-a','appId':'cli_a','brand':'feishu','active':True},{'name':'tenant-b','appId':'cli_b','brand':'feishu','active':False}]))\n"
"elif args[:2] == ['base', '+record-list']:\n" "elif args[:2] == ['base', '+record-list']:\n"
" print(json.dumps({'code': 0, 'data': {'fields': ['fldTitle','fldActual','fldExpected','fldSteps','fldAcceptance','fldPriority','fldAttachments','fldUpdated'], 'record_id_list': ['recA'], 'data': [[' Bug\\n title ', ' actual ', 'expected', ['one', 'two'], 'accept', 'P1', [{'file_token':'fileA','name':'shot.png','type':'image/png','size':3}], '2026-08-01']]}}))\n" " fields=['fldTitle','fldActual','fldExpected','fldSteps','fldAcceptance','fldPriority','fldAttachments','fldUpdated']; row=[' Bug\\n title ',' actual ','expected',['one','two'],'1. save succeeds\\n2. the crash no longer occurs','P1',[{'file_token':'fileA','name':'shot.png','type':'image/png','size':3}],'2026-08-01']\n"
" if 'fldFixLogic' in args: fields.append('fldFixLogic'); row.append('change parser without widening input')\n"
" print(json.dumps({'code': 0, 'data': {'fields': fields, 'record_id_list': ['recA'], 'data': [row]}}))\n"
"elif args[:2] == ['base', '+record-download-attachment']:\n" "elif args[:2] == ['base', '+record-download-attachment']:\n"
" out = pathlib.Path(args[args.index('--output') + 1]); out.mkdir(parents=True, exist_ok=True); (out / 'shot.png').write_bytes(b'png'); print(json.dumps({'ok': True, 'data': {}}))\n" " out = pathlib.Path(args[args.index('--output') + 1]); out.mkdir(parents=True, exist_ok=True); (out / 'shot.png').write_bytes(b'png'); print(json.dumps({'ok': True, 'data': {}}))\n"
"elif args[:2] == ['base', '+record-upsert']:\n"
" print(json.dumps({'code': 0, 'data': {}}))\n"
"else: raise SystemExit(2)\n", "else: raise SystemExit(2)\n",
encoding="utf-8", encoding="utf-8",
) )
@@ -210,6 +214,166 @@ class FeishuBugIntakeUnitTests(unittest.TestCase):
} }
self.assertEqual(feishu_bug_intake.matrix_from_response(response, fields), (["recA"], [["Bug"]])) self.assertEqual(feishu_bug_intake.matrix_from_response(response, fields), (["recA"], [["Bug"]]))
def test_optional_fix_logic_field_is_normalized_for_preapproval_review(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
board_path = Path(temp_dir) / "tasks.yaml"
board_path.write_text(
BOARD.replace(
" profile: tenant-b\n",
" workflow: reviewed-writeback-v1\n profile: tenant-b\n",
).replace(
" acceptance: fldAcceptance\n",
" acceptance: fldAcceptance\n fixLogic: fldFixLogic\n",
),
encoding="utf-8",
)
config = feishu_bug_intake.config_from_board(
feishu_bug_intake.load_board(board_path)
)
row = [
"Bug", "actual", "expected", "steps", "acceptance", "P1", [],
"2026-08-01T12:00:00Z", "change parser without widening input",
]
with mock.patch.object(feishu_bug_intake, "profile_check"), mock.patch.object(
feishu_bug_intake, "fetch_pages", return_value=[("recA", row)]
):
payload = feishu_bug_intake.fetch(config, None)
record = payload["records"][0]
self.assertEqual(record["fixLogic"], "change parser without widening input")
self.assertNotIn("fixLogic", record["enrichmentRequired"])
def test_write_draft_uses_trusted_cli_and_only_configured_review_fields(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
temp = Path(temp_dir)
board_path = temp / "tasks.yaml"
board_path.write_text(
BOARD.replace(
" profile: tenant-b\n",
" workflow: reviewed-writeback-v1\n profile: tenant-b\n",
).replace(
" acceptance: fldAcceptance\n",
" acceptance: fldAcceptance\n fixLogic: fldFixLogic\n",
),
encoding="utf-8",
)
draft_path = temp / "draft.json"
draft_path.write_text(json.dumps({
"fixLogic": "change parser without widening input",
"acceptance": ["save succeeds", "the crash no longer occurs"],
}), encoding="utf-8")
fake, log_path = self.make_fake_cli(temp)
fetched_code, fetched_output, fetched_error = self.invoke([
"fetch", str(board_path),
], fake)
self.assertEqual(fetched_code, 0, fetched_error)
fetched_record = json.loads(fetched_output)["records"][0]
code, output, error = self.invoke([
"write-draft", str(board_path), "--record-id", "recA",
"--expected-source-ref", fetched_record["sourceRef"],
"--expected-draft-revision", fetched_record["draftRevision"],
"--input", str(draft_path),
], fake)
self.assertEqual(code, 0, error)
self.assertEqual(json.loads(output)["written"], ["fixLogic", "acceptance"])
self.assertRegex(json.loads(output)["draftRevision"], r"^sha256:[0-9a-f]{64}$")
calls = [json.loads(line) for line in log_path.read_text(encoding="utf-8").splitlines()]
write_call = next(call for call in calls if call[:2] == ["base", "+record-upsert"])
self.assertEqual(write_call[write_call.index("--profile") + 1], "tenant-b")
patch = json.loads(write_call[write_call.index("--json") + 1])
self.assertEqual(set(patch), {"fldFixLogic", "fldAcceptance"})
self.assertIn("1. save succeeds", patch["fldAcceptance"])
imported_code, imported_output, imported_error = self.invoke([
"import-approved", str(board_path), "--record-id", "recA",
"--expected-source-ref", fetched_record["sourceRef"],
"--expected-draft-revision", fetched_record["draftRevision"],
], fake)
self.assertEqual(imported_code, 0, imported_error)
task_draft = json.loads(imported_output)["taskDraft"]
self.assertEqual(task_draft["source"]["workflow"], "reviewed-writeback-v1")
self.assertEqual(
task_draft["acceptanceCriteria"],
["save succeeds", "the crash no longer occurs"],
)
self.assertRegex(
task_draft["source"]["approvedPayloadHash"], r"^sha256:[0-9a-f]{64}$",
)
def test_write_draft_rejects_stale_revision_before_mutation(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
temp = Path(temp_dir)
board_path = temp / "tasks.yaml"
board_path.write_text(
BOARD.replace(
" profile: tenant-b\n",
" workflow: reviewed-writeback-v1\n profile: tenant-b\n",
).replace(
" acceptance: fldAcceptance\n",
" acceptance: fldAcceptance\n fixLogic: fldFixLogic\n",
),
encoding="utf-8",
)
draft_path = temp / "draft.json"
draft_path.write_text(json.dumps({
"fixLogic": "fix",
"acceptance": ["pass"],
}), encoding="utf-8")
fake, log_path = self.make_fake_cli(temp)
fetched = json.loads(self.invoke(["fetch", str(board_path)], fake)[1])["records"][0]
code, _, _ = self.invoke([
"write-draft", str(board_path), "--record-id", "recA",
"--expected-source-ref", fetched["sourceRef"],
"--expected-draft-revision", "sha256:" + "0" * 64,
"--input", str(draft_path),
], fake)
self.assertNotEqual(code, 0)
calls = [json.loads(line) for line in log_path.read_text(encoding="utf-8").splitlines()]
self.assertFalse(any(call[:2] == ["base", "+record-upsert"] for call in calls))
def test_write_draft_rejects_symlink_and_unknown_fields(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
temp = Path(temp_dir)
target = temp / "draft.json"
target.write_text(json.dumps({
"fixLogic": "fix",
"acceptance": ["pass"],
"unexpected": "do not write",
}), encoding="utf-8")
with self.assertRaises(feishu_bug_intake.IntakeError):
feishu_bug_intake.load_draft(target)
target.write_text(json.dumps({
"fixLogic": "fix",
"acceptance": ["pass"],
}), encoding="utf-8")
linked = temp / "linked.json"
linked.symlink_to(target)
with self.assertRaises(feishu_bug_intake.IntakeError):
feishu_bug_intake.load_draft(linked)
def test_draft_revision_binds_attachment_identity(self) -> None:
record = {
"sourceRef": "feishu-base:sha256:" + "a" * 64,
"updatedAt": "2026-08-03T12:00:00+08:00",
"title": "Bug",
"actual": "crash",
"expected": "save",
"steps": "open then save",
"fixLogic": "fix parser",
"acceptance": "save succeeds",
"priority": "P1",
"attachments": [{"name": "same.png", "type": "image/png", "size": 4}],
}
first = feishu_bug_intake.draft_revision(record, ["fileTokenA"])
second = feishu_bug_intake.draft_revision(record, ["fileTokenB"])
self.assertNotEqual(first, second)
def test_attachments_and_required_text_are_resource_bounded_and_strict(self) -> None: def test_attachments_and_required_text_are_resource_bounded_and_strict(self) -> None:
with self.assertRaises(feishu_bug_intake.IntakeError): with self.assertRaises(feishu_bug_intake.IntakeError):
feishu_bug_intake.attachment_items([{ feishu_bug_intake.attachment_items([{
+1 -1
View File
@@ -67,7 +67,7 @@ class AckSkillContentTests(unittest.TestCase):
): ):
self.assertTrue((ack_dir / relative_path).is_file(), relative_path) self.assertTrue((ack_dir / relative_path).is_file(), relative_path)
version = (ack_dir / "VERSION").read_text(encoding="utf-8").strip() version = (ack_dir / "VERSION").read_text(encoding="utf-8").strip()
self.assertEqual(version, "0.13.1") self.assertEqual(version, "0.14.0")
self.assertIn( self.assertIn(
f'ackVersion: "{version}"', f'ackVersion: "{version}"',
(ack_dir / "examples" / "tasks.example.yaml").read_text(encoding="utf-8"), (ack_dir / "examples" / "tasks.example.yaml").read_text(encoding="utf-8"),
+89
View File
@@ -16,6 +16,8 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
VALIDATOR = REPO_ROOT / "skills" / "ack" / "scripts" / "validate_tasks.py" VALIDATOR = REPO_ROOT / "skills" / "ack" / "scripts" / "validate_tasks.py"
EXAMPLE = REPO_ROOT / "skills" / "ack" / "examples" / "tasks.example.yaml" EXAMPLE = REPO_ROOT / "skills" / "ack" / "examples" / "tasks.example.yaml"
SCHEMA = REPO_ROOT / "skills" / "ack" / "templates" / "tasks.schema.json" SCHEMA = REPO_ROOT / "skills" / "ack" / "templates" / "tasks.schema.json"
sys.path.insert(0, str(VALIDATOR.parent))
from approval_payload import approval_payload_hash # noqa: E402
def valid_knowledge_board() -> dict: def valid_knowledge_board() -> dict:
@@ -148,6 +150,93 @@ class AckTaskValidationTests(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("任务板校验通过", result.stdout) self.assertIn("任务板校验通过", result.stdout)
def test_approved_feishu_plan_fields_are_validated(self) -> None:
board = valid_knowledge_board()
board["tasks"][0]["fixLogic"] = "change the parser and preserve legacy input"
board["tasks"][0]["acceptanceCriteria"] = [
"the submitted value is saved",
"the original crash no longer occurs",
]
self.assert_board_accepted_in_all_modes(board)
invalid = copy.deepcopy(board)
invalid["tasks"][0]["acceptanceCriteria"] = ["valid", 7]
self.assert_board_rejected_in_all_modes(invalid, "列表项必须是字符串")
def test_reviewed_feishu_workflow_requires_fix_logic_and_approval_revision(self) -> None:
fields = {
"title": "fTitle",
"actual": "fActual",
"expected": "fExpected",
"stepsToReproduce": "fSteps",
"acceptance": "fAcceptance",
"priority": "fPriority",
"attachments": "fAttachments",
"updatedAt": "fUpdated",
}
board = valid_knowledge_board()
board["project"]["bugIntake"] = {
"provider": "feishu-base",
"workflow": "reviewed-writeback-v1",
"profile": "tenant-b",
"baseToken": "baseToken",
"tableId": "tblBugs",
"viewId": "vewReview",
"fields": fields,
}
self.assert_board_rejected_in_all_modes(
board, "reviewed-writeback-v1 必须映射",
)
board["project"]["bugIntake"]["fields"]["fixLogic"] = "fFixLogic"
board["tasks"][0].update({
"priority": "P1",
"actual": "save crashes",
"expected": "save succeeds",
"stepsToReproduce": ["open", "save"],
"fixLogic": "preserve input while fixing the parser",
"acceptanceCriteria": ["save succeeds", "the crash no longer occurs"],
})
board["tasks"][0]["source"] = {
"kind": "feishu-base",
"workflow": "reviewed-writeback-v1",
"ref": "feishu-base:sha256:" + "a" * 64,
"recordId": "recA",
"updatedAt": "2026-08-03T12:00:00+08:00",
"approvedRevision": "sha256:" + "b" * 64,
}
board["tasks"][0]["source"]["approvedPayloadHash"] = approval_payload_hash(
board["tasks"][0]
)
self.assert_board_accepted_in_all_modes(board)
board["tasks"][0]["source"]["approvedRevision"] = "latest"
self.assert_board_rejected_in_all_modes(board, "必须是 sha256 revision")
board["tasks"][0]["source"]["approvedRevision"] = "sha256:" + "b" * 64
board["tasks"][0]["fixLogic"] = "silently changed after approval"
self.assert_board_rejected_in_all_modes(board, "与任务审核字段不匹配")
board["tasks"][0]["fixLogic"] = "preserve input while fixing the parser"
board["tasks"][0]["source"]["approvedPayloadHash"] = approval_payload_hash(
board["tasks"][0]
)
board["tasks"][0]["description"] = "modify unrelated modules"
self.assert_board_rejected_in_all_modes(board, "与任务审核字段不匹配")
legacy = copy.deepcopy(board)
legacy["tasks"][0]["description"] = "validate knowledge fields"
legacy["tasks"][0]["source"] = {
"kind": "feishu-base",
"ref": "feishu-base:sha256:" + "c" * 64,
"recordId": "recLegacy",
"updatedAt": "2026-08-01T12:00:00+08:00",
}
self.assert_board_rejected_in_all_modes(legacy, "必须先迁移审核")
legacy["tasks"][0]["status"] = "verified"
self.assert_board_accepted_in_all_modes(legacy)
def test_legacy_sources_remain_open_while_feishu_sources_are_strict(self) -> None: def test_legacy_sources_remain_open_while_feishu_sources_are_strict(self) -> None:
for legacy_source in ( for legacy_source in (
"manual", "manual",