feat(ack): add Feishu bug review approval gate
This commit is contained in:
@@ -2,16 +2,16 @@
|
||||
|
||||
## 目标
|
||||
|
||||
让用户在飞书多维表格中跨设备记录文字和截图,随后由 ACK Coordinator 通过官方
|
||||
`lark-cli` 读取项目配置的 `ACK Ready` 视图,整理为独立、可验收、可幂等追踪的 ACK
|
||||
任务。不同项目通过不同 CLI profile 访问各自飞书租户。
|
||||
让用户在飞书多维表格中跨设备记录文字和截图,由 ACK Coordinator 在同一记录补全并
|
||||
反复修订修复逻辑与验收标准。用户明确审核通过后,才将批准版本整理为独立、可验收、
|
||||
可幂等追踪的 ACK 任务并启动三角色闭环。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不用 Skill 承担人工记录入口。
|
||||
- 不抓取公开网页或依赖浏览器登录态。
|
||||
- 不把 App Secret、access token 或飞书用户凭据写入项目。
|
||||
- 第一版不反向更新飞书记录,不把飞书状态与 ACK 状态做双向同步。
|
||||
- 不在审核前把草案写入 `tasks.yaml`,也不把 ACK 执行状态持续双向同步到飞书。
|
||||
- 不把多条互不相关的 Bug 合成一次 Developer 派发。
|
||||
|
||||
## 项目配置契约
|
||||
@@ -22,6 +22,7 @@
|
||||
| 字段 | 约束 |
|
||||
|------|------|
|
||||
| `provider` | 固定为 `feishu-base` |
|
||||
| `workflow` | 审核前协作固定为 `reviewed-writeback-v1`;缺省表示旧只读模式 |
|
||||
| `profile` | `lark-cli` profile 名称;每次命令显式传入 |
|
||||
| `baseToken` | 飞书 Base token |
|
||||
| `tableId` | 数据表 ID |
|
||||
@@ -29,8 +30,8 @@
|
||||
| `fields` | 逻辑字段到飞书字段 ID/名称的映射 |
|
||||
|
||||
`fields` 必须映射 `title`、`actual`、`expected`、`stepsToReproduce`、
|
||||
`acceptance`、`priority`、`attachments`、`updatedAt`。字段值只作为单个 argv 传给
|
||||
`lark-cli`,不经过 shell。
|
||||
`acceptance`、`priority`、`attachments`、`updatedAt`。启用审核前协作时还必须映射
|
||||
`fixLogic`。字段值只作为单个 argv 传给 `lark-cli`,不经过 shell。
|
||||
|
||||
## CLI 契约
|
||||
|
||||
@@ -40,6 +41,10 @@
|
||||
- profile 由项目显式选择;不得执行 `profile use` 或读取 active profile作为回退。
|
||||
- 子进程使用最小环境,不继承可能覆盖 profile/config/凭据或注入运行时的环境变量。
|
||||
- 记录读取使用 `base +record-list`、JSON 输出、指定 Base/table/view 和投影字段。
|
||||
- 草案写回只通过读取器的 `write-draft` 子命令调用 `base +record-upsert`,并且只允许修改
|
||||
配置映射的 `fixLogic` 与 `acceptance`。
|
||||
- 批准后只通过 `import-approved` 生成规范任务字段和可重算的 `approvedPayloadHash`;任务
|
||||
校验器拒绝审核字段与该 hash 不一致的 reviewed task。
|
||||
- 记录超过一页时按 offset/limit 继续读取,并设置总页数/记录数上限。
|
||||
- 附件仅通过 `base +record-download-attachment` 下载到调用者显式提供的临时目录。
|
||||
- 附件数量、单文件大小、批次总大小和子进程文件写入均有硬上限;落盘大小必须与
|
||||
@@ -53,8 +58,9 @@
|
||||
- `sourceRef`:对 provider、profile、Base、table、record ID 做域隔离 SHA-256
|
||||
后得到的稳定匿名引用;原始 profile、Base token 与 record ID 不拼入引用文本;
|
||||
- `recordId` 与 `updatedAt`;
|
||||
- title、actual、expected、steps、acceptance、priority;
|
||||
- `enrichmentRequired`:缺失但允许 Coordinator 整理的 steps、acceptance、priority;
|
||||
- 绑定来源事实、附件元数据和审核字段的 `draftRevision`;
|
||||
- title、actual、expected、steps、可选 fixLogic、acceptance、priority;
|
||||
- `enrichmentRequired`:缺失但允许 Coordinator 整理的 steps、fixLogic、acceptance、priority;
|
||||
- 附件的 name/type/size 与可选本地临时路径;附件 token 只在下载命令内部使用;
|
||||
- 原始字段中无法映射但不影响导入的警告。
|
||||
|
||||
@@ -63,18 +69,21 @@ token 或 CLI 配置文件内容。
|
||||
|
||||
## 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
|
||||
整理动作;需要看截图时使用临时下载目录。
|
||||
3. 将记录分类为 Bug、已有功能、接受的改进、样式偏好或超范围;只导入确认接受的项。
|
||||
4. 每条导入任务保存 `source.kind=feishu-base`、`source.ref`、`source.recordId`、
|
||||
`source.updatedAt`,并把截图观察转成文字证据。
|
||||
3. 将记录分类并在飞书补全 `fixLogic` 与 `acceptance`;用户反馈时继续写回同一记录。
|
||||
4. 用户必须明确批准当前 `draftRevision`;通过前不写 `tasks.yaml`、不派发角色、不修改
|
||||
应用代码。通过后每条导入任务保存 `source.kind=feishu-base`、`source.ref`、
|
||||
`source.recordId`、`source.updatedAt`、`source.workflow`、`source.approvedRevision`、
|
||||
`source.approvedPayloadHash`,并把截图观察转成文字证据。
|
||||
5. 导入前扫描已有任务的 `source.ref`。相同来源不得新建第二条任务。
|
||||
6. 来源更新但任务尚为 `open` 时可由 Coordinator刷新描述;任务已派发或进入终态时只报告漂移,由用户决定是否新开任务。
|
||||
7. 飞书记录删除、不可访问或 CLI 暂时失败时保留已有 ACK 任务,不反向删除。
|
||||
8. title、actual、expected、updatedAt 是不可推断的来源事实,部分缺失时 fail closed;
|
||||
steps、acceptance、priority 缺失时由 Coordinator 根据来源事实和项目上下文补齐,
|
||||
并写入 `evidence.intakeEnrichment`,不要求报告者返回飞书机械补录。
|
||||
steps、fixLogic、acceptance、priority 缺失时由 Coordinator 根据来源事实和项目上下文
|
||||
补齐并写回飞书。只有批准后的最终版本才导入任务板。
|
||||
9. 没有附件且所有 Bug 内容字段均为空的误建行跳过并输出 `blank_record_skipped`;带部分
|
||||
来源事实的残缺行不得静默跳过。
|
||||
|
||||
|
||||
+11
-5
@@ -132,11 +132,17 @@ description: >-
|
||||
worker 启动规则。项目覆盖层优先于通用示例命令。按 scope 推荐相关 `active`
|
||||
知识,经确认后把固定 revision 的显式 `knowledgeRefs` 写入当前任务上下文;
|
||||
不全量注入知识库。
|
||||
配置了 `project.bugIntake` 时,先按 `references/feishu-bug-intake.md` 运行 check,
|
||||
再运行 plan 获取标准化记录及 `create` / `refresh` / `unchanged` / `drift` 整理动作。
|
||||
`enrichmentRequired` 中的复现步骤、验收标准和优先级由 Coordinator 根据来源事实与
|
||||
项目上下文补齐,并在任务证据中标记推断字段,不要求用户回飞书机械补录;标题、实际
|
||||
表现和预期结果不可推断,部分缺失时仍停止。整行空白记录按批次 warning 跳过。
|
||||
`project.bugIntake.workflow` 为 `reviewed-writeback-v1` 时,按
|
||||
`references/feishu-bug-intake.md` 把飞书作为审核前的唯一协作区:先运行 check/plan,
|
||||
读取用户填写的 Bug;Coordinator 根据来源事实与项目上下文补全修复逻辑和可观测验收
|
||||
标准,只通过安全适配器写回同一飞书记录并回读确认。用户反馈后继续只在飞书修订。
|
||||
用户针对当前 `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` 任务可刷新描述;
|
||||
`dispatched`、`fixed_by_dev`、`retesting`、`failed_retest`、`verified`、`blocked` 和
|
||||
`leftover` 只报告来源漂移,绝不覆盖;来源消失或读取失败时绝不删除已有任务。
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.13.1
|
||||
0.14.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# notes-web Agent 协作协议(示例,项目覆盖层)
|
||||
|
||||
> 本项目基于 ack v0.13.1。
|
||||
> 本项目基于 ack v0.14.0。
|
||||
> 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。
|
||||
> 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。
|
||||
> ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
version: 1
|
||||
updatedAt: "2026-07-06T09:40:00+08:00"
|
||||
source: "Coordinator (PM) Agent"
|
||||
ackVersion: "0.13.1"
|
||||
ackVersion: "0.14.0"
|
||||
project:
|
||||
name: "notes-web"
|
||||
repoPath: "/home/dev/notes-web"
|
||||
@@ -112,6 +112,12 @@ tasks:
|
||||
updatedAt: "2026-07-06T09:00:00Z"
|
||||
description: >
|
||||
用户在 /fix 页点击“预览变更”后,确认区不渲染 API 返回的 diff。
|
||||
fixLogic: >
|
||||
修正确认区对预览响应的字段映射,完整渲染四类 diff,同时保持取消操作不触发写入。
|
||||
acceptanceCriteria:
|
||||
- "预览后确认区显示 title、修改前值、修改后值和 coverChanged"
|
||||
- "取消预览不会产生保存请求"
|
||||
- "原先确认区为空的问题不再出现"
|
||||
stepsToReproduce:
|
||||
- "打开 /fix?fileId=1"
|
||||
- "搜索并选择候选项"
|
||||
|
||||
@@ -1,24 +1,37 @@
|
||||
# 飞书 Base Bug 收件箱
|
||||
# 飞书 Base Bug 整理与审核
|
||||
|
||||
这是可选的只读接入。项目在 `docs/ack/tasks.yaml` 的 `project.bugIntake` 声明
|
||||
`provider: feishu-base`、显式 `profile`、`baseToken`、`tableId`、ACK Ready 的 `viewId`
|
||||
和八个字段映射;不要保存 App Secret、access token 或任何 profile 凭据。
|
||||
飞书 Base 是 Bug 在**审核通过前的唯一协作区**。用户先在飞书记录 Bug 描述,
|
||||
Coordinator 读取记录和项目现状,补全或修改修复逻辑、验收标准,再写回同一条飞书
|
||||
记录。用户可以继续在飞书反馈,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 设置
|
||||
|
||||
在账号级可信工具目录安装官方 CLI;ACK 读取器只会搜索账号的 `~/.local/bin`、mise/cargo
|
||||
shim 目录和固定系统目录,绝不会采用项目 `PATH` 中的同名文件。它同时识别官方 npm
|
||||
包生成的 `lark-cli -> @larksuite/cli/scripts/run.js` wrapper,并校验 package 名与 bin
|
||||
映射,然后直接执行该官方包下载的 native binary;这样不依赖 nvm PATH,也不会让 Node
|
||||
运行时变量进入凭据边界。缺少 native binary 或同名仿冒 wrapper 都会被拒绝:
|
||||
映射,然后直接执行该官方包下载的 native binary:
|
||||
|
||||
```bash
|
||||
npm install --global --prefix "$HOME/.local" @larksuite/cli@latest
|
||||
```
|
||||
|
||||
固定 `--prefix "$HOME/.local"` 是接入契约的一部分,确保 npm wrapper 落在读取器会检查的
|
||||
账号级可信目录;不要依赖 nvm 或其它由当前 shell 动态注入的 PATH 位置。
|
||||
|
||||
随后在受控终端中用 stdin 提供 App Secret,选择 Feishu brand,避免 secret 进入 shell
|
||||
history、进程参数或项目文件:
|
||||
|
||||
@@ -28,14 +41,13 @@ printf '%s' "$FEISHU_APP_SECRET" | lark-cli profile add \
|
||||
--app-secret-stdin --brand feishu
|
||||
```
|
||||
|
||||
随后在 project 配置里只填写 `profile: project-feishu`。不要执行 `profile use`,也不要
|
||||
依赖 active profile;每次读取和附件下载都由 adapter 显式传 `--profile project-feishu`。
|
||||
为读取记录和下载附件,profile 必须具有 `base:record:read` 和
|
||||
`docs:document.media:download`。请在飞书开发者后台的应用权限中为该 app 授予这两个
|
||||
scope;不要把 `lark-cli auth check` 当作 app/bot scope 的证明,因为它检查的是当前用户的
|
||||
项目配置只填写 `profile: project-feishu`。不要执行 `profile use`,也不要依赖 active
|
||||
profile;读取、附件下载和写回都必须显式传 `--profile project-feishu`。profile 至少需要
|
||||
`base:record:read`、`base:record:write` 和 `docs:document.media:download`。
|
||||
不要把 `lark-cli auth check` 当作 app/bot scope 的证明,因为它检查的是当前用户的
|
||||
stored user token。
|
||||
|
||||
## 使用
|
||||
## 读取与整理计划
|
||||
|
||||
```bash
|
||||
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"
|
||||
```
|
||||
|
||||
`fetch` 只调用官方 `base +record-list`(限定配置的 view 和字段)和按配置附件字段的
|
||||
`base +record-download-attachment`。它输出单一 JSON,验证行矩阵、分页和下载路径;CLI、
|
||||
profile、JSON、分页、附件或路径任一异常都会失败且不输出伪成功结果。
|
||||
`fetch` 只调用官方 `base +record-list` 和附件下载命令,输出标准化 JSON,并为每条记录
|
||||
计算覆盖来源事实、附件元数据和审核字段的 `draftRevision`。`plan` 只生成
|
||||
`create` / `refresh` / `unchanged` / `drift` 候选动作,不修改飞书或任务板。**审核通过前
|
||||
不得执行这些候选动作**;它们只用于查重和预览最终导入结果。
|
||||
|
||||
`plan` 在同一批标准化记录上读取现有 `tasks`,只输出整理计划而不修改文件:新来源为
|
||||
`create`,同来源且现有任务为 `open`、来源时间有变化时为 `refresh`,未变化为
|
||||
`unchanged`,其它 ACK 状态发生来源变化时为 `drift`。任务板或读取结果出现重复
|
||||
`sourceRef` 会直接失败。标题、实际表现、预期结果和更新时间属于来源事实,任一缺失时
|
||||
读取失败;复现步骤、验收标准和优先级属于 Coordinator 可整理字段,缺失时记录与 action
|
||||
会返回 `enrichmentRequired`,不阻断整批。没有附件且所有 Bug 内容字段都为空的误建行会
|
||||
跳过,并在批次 `warnings` 中返回 `blank_record_skipped`。
|
||||
标题、实际表现、预期结果和更新时间属于来源事实,任一缺失时读取失败。复现步骤、
|
||||
修复逻辑、验收标准和优先级属于 Coordinator 可整理字段;缺失时记录与 action 返回
|
||||
`enrichmentRequired`。没有附件且所有 Bug 内容字段都为空的误建行会跳过,并在批次
|
||||
`warnings` 中返回 `blank_record_skipped`。
|
||||
|
||||
子进程只收到实际账号 HOME、可信 PATH 和基础 locale;调用者环境中的
|
||||
`LARKSUITE_CLI_*`、`FEISHU_*`、`NODE_OPTIONS` 等变量不会传入,避免环境凭据或运行时
|
||||
注入绕过项目 profile。每条记录最多 10 个附件、单批最多 100 个,单个附件最多
|
||||
20 MiB、合计最多 200 MiB,整批附件下载最多 5 分钟,并校验声明大小与落盘大小;
|
||||
请始终使用新的临时目录作为 `--output-dir`。
|
||||
`LARKSUITE_CLI_*`、`FEISHU_*`、`NODE_OPTIONS` 等变量不会传入。每条记录最多 10 个
|
||||
附件、单批最多 100 个,单个附件最多 20 MiB、合计最多 200 MiB,整批下载最多 5 分钟;
|
||||
始终使用新的临时目录作为 `--output-dir`。
|
||||
|
||||
`profile list` 只作存在性检查,不会输出 profile 内容。官方 record-list JSON 使用
|
||||
`data.fields`、`data.record_id_list` 与同长度的 `data.data` 行矩阵;`ok: true` 或
|
||||
`code: 0` 是唯一可接受的成功 envelope。
|
||||
## 审核前协作循环
|
||||
|
||||
## Coordinator 整理
|
||||
对每条候选 Bug,Coordinator 按以下顺序工作:
|
||||
|
||||
读取结果的每条 `sourceRef` 是稳定且不透明的键,例如
|
||||
`feishu-base:sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef`。
|
||||
导入时写入 task 的
|
||||
`source.kind: feishu-base`、`source.ref`、`source.recordId` 与 `source.updatedAt`;先按
|
||||
`source.ref` 查重。相同来源只更新 `open` 任务;`dispatched`、`fixed_by_dev`、
|
||||
`retesting`、`failed_retest`、`verified`、`blocked` 和 `leftover` 任务只告警来源漂移,
|
||||
由用户决定是否新建任务;飞书记录消失、不可访问或同步失败时,已有 ACK 任务一律保留。
|
||||
1. 读取用户填写的来源事实、附件和项目代码/测试,确认问题边界;证据不足时把不确定点
|
||||
明确写成假设,不伪装成用户原文。
|
||||
2. 整理复现步骤和优先级,并生成:
|
||||
- `fixLogic`:说明根因判断、计划修改的位置与行为、需要保持的不变量,以及回归风险;
|
||||
它是待审核的实现方向,不宣称代码已经修改。
|
||||
- `acceptance`:写成可观测、可独立复测的标准,至少覆盖用户可见结果、真实状态或 API
|
||||
结果,以及原失败不再出现;不扩张用户没有表达的产品范围。
|
||||
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 个可复现步骤;证据不足时明确
|
||||
写成待 Developer 首轮确认的复现假设,不能把假设伪装成来源原文。
|
||||
- `acceptance`:把预期结果改写成可观测信号,至少覆盖用户可见结果、真实状态或 API 结果,
|
||||
以及原失败不再出现;不扩张飞书记录没有表达的产品范围。
|
||||
- `priority`:主流程阻断、数据损坏/丢失或安全风险定为 P0;普通功能错误默认 P1;纯样式或
|
||||
低频体验问题可定为 P2。无法判断时使用 P1,并保留判断依据。
|
||||
`write-draft` 会在写前确认 record 属于配置 view,且 `sourceRef`、`draftRevision` 与调用者
|
||||
看到的版本一致;它只允许覆盖配置映射的 `fixLogic` 和 `acceptance`,拒绝 symlink、超限
|
||||
或多余字段输入,并复用读取器的可信 CLI、显式 profile、最小环境和成功 envelope 校验。
|
||||
不得绕过适配器直接调用 PATH 中的 CLI,也不得改标题、实际表现、预期结果、附件或其它
|
||||
用户字段。
|
||||
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 任务一律保留。
|
||||
|
||||
@@ -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()}"
|
||||
@@ -1,8 +1,9 @@
|
||||
#!/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
|
||||
active profile and emits one JSON document only on success.
|
||||
The only mutation is a bounded draft write to configured Coordinator fields.
|
||||
The adapter never reads the active profile and emits one JSON document only
|
||||
on success.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -23,12 +24,14 @@ import time
|
||||
from pathlib import Path
|
||||
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
|
||||
|
||||
REQUIRED_FIELDS = ("title", "actual", "expected", "stepsToReproduce", "acceptance", "priority", "attachments", "updatedAt")
|
||||
OPTIONAL_FIELDS = ("fixLogic",)
|
||||
SOURCE_FACT_FIELDS = ("title", "actual", "expected", "updatedAt")
|
||||
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_RECORDS = 10_000
|
||||
PAGE_SIZE = 100
|
||||
@@ -39,10 +42,13 @@ MAX_TOTAL_ATTACHMENTS = 100
|
||||
MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
||||
MAX_TOTAL_ATTACHMENT_BYTES = 200 * 1024 * 1024
|
||||
MAX_ATTACHMENT_BATCH_SECONDS = 300
|
||||
MAX_DRAFT_BYTES = 64 * 1024
|
||||
SAFE_VALUE = re.compile(r"^[^\s\x00-\x1f]{1,256}$")
|
||||
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}$")
|
||||
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):
|
||||
@@ -182,6 +188,60 @@ def load_board(path: Path) -> dict[str, Any]:
|
||||
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]:
|
||||
project = board.get("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"]
|
||||
if not isinstance(config, dict):
|
||||
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)
|
||||
if unknown:
|
||||
raise IntakeError("bugIntake has unknown fields")
|
||||
if config.get("provider") != "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")
|
||||
if not isinstance(profile, str) or not PROFILE.fullmatch(profile):
|
||||
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):
|
||||
raise IntakeError(f"bugIntake.{key} is invalid")
|
||||
fields = config.get("fields")
|
||||
if not isinstance(fields, dict) or set(fields) != set(REQUIRED_FIELDS):
|
||||
raise IntakeError("bugIntake.fields must map exactly the required logical fields")
|
||||
supported = set(REQUIRED_FIELDS) | set(OPTIONAL_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()):
|
||||
raise IntakeError("bugIntake.fields values are invalid")
|
||||
if len(set(fields.values())) != len(fields):
|
||||
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
|
||||
|
||||
|
||||
@@ -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]]]:
|
||||
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]]] = []
|
||||
offset = 0
|
||||
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()}"
|
||||
|
||||
|
||||
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]:
|
||||
profile_check(config)
|
||||
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_attachment_bytes = 0
|
||||
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"])
|
||||
total_attachments += len(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")
|
||||
if total_attachment_bytes > MAX_TOTAL_ATTACHMENT_BYTES:
|
||||
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):
|
||||
batch_warnings.append({"recordId": record_id, "code": "blank_record_skipped"})
|
||||
continue
|
||||
for field in SOURCE_FACT_FIELDS:
|
||||
if not record[field]:
|
||||
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"] = [
|
||||
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))
|
||||
download_root: Path | None = 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),
|
||||
)
|
||||
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]]:
|
||||
@@ -529,10 +754,12 @@ def plan_actions(board: dict[str, Any], records: list[dict[str, Any]]) -> list[d
|
||||
ref = record.get("sourceRef")
|
||||
record_id = record.get("recordId")
|
||||
updated_at = record.get("updatedAt")
|
||||
revision = record.get("draftRevision")
|
||||
if (
|
||||
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(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")
|
||||
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)
|
||||
task = existing.get(ref)
|
||||
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")
|
||||
if enrichment_required:
|
||||
planned_action["enrichmentRequired"] = enrichment_required
|
||||
actions.append(planned_action)
|
||||
continue
|
||||
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"
|
||||
elif task["status"] == "open":
|
||||
action_name = "refresh"
|
||||
@@ -556,6 +791,7 @@ def plan_actions(board: dict[str, Any], records: list[dict[str, Any]]) -> list[d
|
||||
planned_action = {
|
||||
"sourceRef": ref,
|
||||
"recordId": record_id,
|
||||
"draftRevision": record["draftRevision"],
|
||||
"taskId": task["id"],
|
||||
"status": task["status"],
|
||||
"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:
|
||||
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)
|
||||
for name in ("check", "fetch", "plan"):
|
||||
for name in ("check", "fetch", "plan", "write-draft", "import-approved"):
|
||||
command = sub.add_parser(name)
|
||||
command.add_argument("tasks", type=Path, help="ACK tasks.yaml or JSON board")
|
||||
if name in {"fetch", "plan"}:
|
||||
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)
|
||||
try:
|
||||
board = load_board(args.tasks)
|
||||
config = config_from_board(board)
|
||||
if args.command == "check":
|
||||
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":
|
||||
output = fetch(config, args.output_dir)
|
||||
else:
|
||||
elif args.command == "plan":
|
||||
output = fetch(config, args.output_dir)
|
||||
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:
|
||||
sys.stderr.write(f"Feishu bug intake failed: {exc}\n")
|
||||
return 1
|
||||
|
||||
@@ -24,6 +24,7 @@ import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from approval_payload import approval_payload_hash
|
||||
from yaml_subset import (
|
||||
DuplicateKeyError,
|
||||
YamlSubsetError,
|
||||
@@ -117,8 +118,13 @@ FEISHU_REQUIRED_FIELDS = {
|
||||
"title", "actual", "expected", "stepsToReproduce", "acceptance", "priority",
|
||||
"attachments", "updatedAt",
|
||||
}
|
||||
FEISHU_CONFIG_FIELDS = {"provider", "profile", "baseToken", "tableId", "viewId", "fields"}
|
||||
FEISHU_SOURCE_FIELDS = {"kind", "ref", "recordId", "updatedAt"}
|
||||
FEISHU_OPTIONAL_FIELDS = {"fixLogic"}
|
||||
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_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}$")
|
||||
@@ -598,6 +604,7 @@ def validate_with_schema(data: dict, schema_path: Path) -> list[str]:
|
||||
|
||||
def validate_builtin(data: dict) -> list[str]:
|
||||
errors: list[str] = []
|
||||
project_intake_workflow = "read-only-v1"
|
||||
|
||||
def validate_string_fields(
|
||||
value: dict,
|
||||
@@ -671,6 +678,11 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
reject_unknown_fields(intake, FEISHU_CONFIG_FIELDS, "project.bugIntake", errors)
|
||||
if intake.get("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")
|
||||
if not isinstance(profile, str) or FEISHU_PROFILE_RE.fullmatch(profile) is None:
|
||||
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):
|
||||
errors.append(f"project.bugIntake.{key} 必须是无空白非空字符串")
|
||||
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 必须且只能映射所需逻辑字段")
|
||||
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 字段值必须是无空白非空字符串")
|
||||
elif len(set(fields.values())) != len(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 (
|
||||
"knowledgeFile" in project
|
||||
and project.get("knowledgeFile") != "docs/ack/knowledge.yaml"
|
||||
@@ -776,6 +794,7 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
"assignee",
|
||||
"component",
|
||||
"description",
|
||||
"fixLogic",
|
||||
"expected",
|
||||
"actual",
|
||||
},
|
||||
@@ -783,7 +802,7 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
)
|
||||
validate_string_lists(
|
||||
task,
|
||||
{"specRefs", "testRefs", "stepsToReproduce"},
|
||||
{"specRefs", "testRefs", "stepsToReproduce", "acceptanceCriteria"},
|
||||
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")
|
||||
if not _nonempty_string(source.get("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)
|
||||
|
||||
|
||||
@@ -171,6 +171,10 @@
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"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}$" },
|
||||
"baseToken": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||
"tableId": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||
@@ -184,13 +188,27 @@
|
||||
"actual": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||
"expected": { "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+$" },
|
||||
"priority": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||
"attachments": { "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": {
|
||||
"type": "object",
|
||||
@@ -198,6 +216,10 @@
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"kind": { "const": "feishu-base" },
|
||||
"workflow": {
|
||||
"type": "string",
|
||||
"enum": ["read-only-v1", "reviewed-writeback-v1"]
|
||||
},
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"pattern": "^feishu-base:sha256:[0-9a-f]{64}$"
|
||||
@@ -206,8 +228,21 @@
|
||||
"type": "string",
|
||||
"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": {
|
||||
"type": "string",
|
||||
@@ -996,6 +1031,12 @@
|
||||
}
|
||||
},
|
||||
"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" } },
|
||||
"expected": { "type": "string" },
|
||||
"actual": { "type": "string" },
|
||||
|
||||
@@ -14,6 +14,7 @@ project:
|
||||
# 可选:飞书 Base Bug 收件箱。只保存 profile 名和资源 ID,绝不保存 App Secret。
|
||||
# bugIntake:
|
||||
# provider: "feishu-base"
|
||||
# workflow: "reviewed-writeback-v1"
|
||||
# profile: "project-feishu"
|
||||
# baseToken: "<base_token>"
|
||||
# tableId: "<table_id>"
|
||||
@@ -23,6 +24,7 @@ project:
|
||||
# actual: "<field_id>"
|
||||
# expected: "<field_id>"
|
||||
# stepsToReproduce: "<field_id>"
|
||||
# fixLogic: "<field_id>" # 启用飞书审核前协作时必填
|
||||
# acceptance: "<field_id>"
|
||||
# priority: "<field_id>"
|
||||
# attachments: "<field_id>"
|
||||
@@ -114,13 +116,24 @@ tasks:
|
||||
# 从飞书导入时由 Coordinator 写入;source.ref 是幂等键。
|
||||
# source:
|
||||
# kind: "feishu-base"
|
||||
# workflow: "reviewed-writeback-v1"
|
||||
# ref: "feishu-base:sha256:<64-lowercase-hex>"
|
||||
# recordId: "<record-id>"
|
||||
# updatedAt: "<source-updated-at>"
|
||||
# approvedRevision: "sha256:<approved-draft-digest>"
|
||||
# approvedPayloadHash: "sha256:<canonical-task-payload-digest>"
|
||||
|
||||
description: >
|
||||
<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:
|
||||
- "<step 1>"
|
||||
- "<step 2>"
|
||||
|
||||
@@ -236,6 +236,10 @@ tasks: []
|
||||
self.assertIn("--brand feishu", content)
|
||||
self.assertTrue("scope" in 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("--executable", content)
|
||||
|
||||
@@ -248,8 +252,12 @@ tasks: []
|
||||
self.assertEqual(empty_plan["actions"], [{
|
||||
"sourceRef": first,
|
||||
"recordId": "recBug1",
|
||||
"draftRevision": empty_plan["records"][0]["draftRevision"],
|
||||
"action": "create",
|
||||
}])
|
||||
self.assertRegex(
|
||||
empty_plan["actions"][0]["draftRevision"], r"^sha256:[0-9a-f]{64}$",
|
||||
)
|
||||
|
||||
base = self.board.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@@ -53,9 +53,13 @@ class FeishuBugIntakeUnitTests(unittest.TestCase):
|
||||
" 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"
|
||||
"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"
|
||||
" 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",
|
||||
encoding="utf-8",
|
||||
)
|
||||
@@ -210,6 +214,166 @@ class FeishuBugIntakeUnitTests(unittest.TestCase):
|
||||
}
|
||||
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:
|
||||
with self.assertRaises(feishu_bug_intake.IntakeError):
|
||||
feishu_bug_intake.attachment_items([{
|
||||
|
||||
@@ -67,7 +67,7 @@ class AckSkillContentTests(unittest.TestCase):
|
||||
):
|
||||
self.assertTrue((ack_dir / relative_path).is_file(), relative_path)
|
||||
version = (ack_dir / "VERSION").read_text(encoding="utf-8").strip()
|
||||
self.assertEqual(version, "0.13.1")
|
||||
self.assertEqual(version, "0.14.0")
|
||||
self.assertIn(
|
||||
f'ackVersion: "{version}"',
|
||||
(ack_dir / "examples" / "tasks.example.yaml").read_text(encoding="utf-8"),
|
||||
|
||||
@@ -16,6 +16,8 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
VALIDATOR = REPO_ROOT / "skills" / "ack" / "scripts" / "validate_tasks.py"
|
||||
EXAMPLE = REPO_ROOT / "skills" / "ack" / "examples" / "tasks.example.yaml"
|
||||
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:
|
||||
@@ -148,6 +150,93 @@ class AckTaskValidationTests(unittest.TestCase):
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
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:
|
||||
for legacy_source in (
|
||||
"manual",
|
||||
|
||||
Reference in New Issue
Block a user