Skip to content

fix(pr): webhook 入站同步更新 PR 列表 + webhook/poll 诊断(#61 #62) - #66

Merged
ghbvf merged 4 commits into
developfrom
Fix/61-62-webhook-ingest-diag
Jun 18, 2026
Merged

ghbvf merged 4 commits into
developfrom
Fix/61-62-webhook-ingest-diag

Conversation

@ghbvf

@ghbvf ghbvf commented Jun 17, 2026

Copy link
Copy Markdown
Owner

Summary

webhook 收到 PR 后同步 upsert 到 prs.json 并 emit prs:updated(即便 autoReview=false 也进列表,只是不 dispatch),修复「GitHub delivery 200 但 App 列表不显示」;并新增 webhook delivery 历史 + poll loop 后端真实状态两类诊断。

Why / 背景

排查 ghbvf/gocell#2300 时发现:webhook 入站路径(handle_webhook → 注入闭包)只做 gate + dispatch,从不 registry::mutate_tracked upsert、从不 emit prs:updated——upsert+emit 这段尾巴只活在 poll 路径的 scheduler.rs::discover_emit_dispatch。所以 polling 暂停/停摆/未到下一轮时,webhook 已收到的 PR 不进左侧列表(#61)。同时缺入站/调度可观测性:delivery 无审计、poll loop 无后端真实状态、跳过原因无法串链路(#62)。

预期结果:webhook 收到 opened/labeled/ready_for_review(open + 触发 label)后目标 PR 立即进列表;Webhook 面板能看最近 delivery 处理结果;PollControls 能看后端真实 running + 最近成功/失败时间。

Refs

Risk / 兼容性

  • 不向后兼容(均 backend-internal,无外部消费方):webhook 注入 seam ProjectDispatcher → WebhookIngestor;payload_to_candidate → parse_delivery;删除 gate_dispatchable/gate_candidates(被 webhook_view 取代,parity 测试已迁移)。
  • 新增 serde camelCase 契约 WebhookDelivery/DeliveryStatus/PollStatus ↔ src/pr/types.ts:切片私有命令返回(同 WebhookStatus/GhStatus),各带 golden 锁 wire 形状(camelCase 在 / snake_case 缺 / enum 字面量 pin)。未触跨切片 model.rs/events.rs 契约。
  • 持久化唯一写 seam mutate_tracked 不变(复用,非另开)。无配置/数据迁移。新增诊断走命令拉取,无新增事件类型。
  • 行为:closed/merged 或触发 label 被移除的 delivery → 更新已跟踪行状态(skip_reason + 刷新 labels)后自然老化 Current→Stale;不为未跟踪 PR 新插入行(update-if-present)。诊断记录不含 secret/token。

Test plan

  • pnpm build 本地通过(vue-tsc 类型检查 + vite build)
  • cargo build --manifest-path src-tauri/Cargo.toml --locked 本地通过
  • cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets --locked -- -D warnings 0 告警
  • cargo fmt --manifest-path src-tauri/Cargo.toml --all -- --check 干净
  • cargo test --manifest-path src-tauri/Cargo.toml --locked 通过(218 lib + 3 集成;新增 parse_delivery/webhook_view/update_present/PollDiag 单测 + 3 个 wire golden)
  • vitest 109/109(含 slice-boundary)

🤖 Generated with Claude Code

ghbvf and others added 2 commits June 18, 2026 03:28
#61: webhook 收到的 PR 现在走 upsert+emit prs:updated(即使 autoReview=off
也进列表),不再只 dispatch。payload_to_candidate 重写为纯函数 parse_delivery
→ ParseResult(Routable/WrongRepo/Malformed),承载 title/labels/url(不再 gh pr
view);新增 registry::update_present(status-only 刷新现有行,不插入)、
commands::webhook_view(复用 should_skip/cooldown_skip 门,对齐 poll 路径)与
AppHandle 绑定的 ingest_webhook(config/ledger fail-closed),删除 gate_dispatchable
/gate_candidates;webhook seam 由 set_dispatcher 改为 set_ingestor(WebhookEvent)。

#62: 无新事件类型,命令拉取。webhook.rs 新增 DeliveryStatus/WebhookDelivery
+ 50 条环形缓冲(每请求恰好记一条);scheduler.rs 新增 PollDiag + PollStatus,
discover_emit_dispatch 记录 started/discovered/persist/error;新增命令
webhook_deliveries / poll_status。所有新 wire 类型配 camelCase golden 测试
(Medium 载体,ai-robust.md)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
镜像后端两个诊断 command 的 wire 形状并在 UI 展示:
- types.ts: 新增 DeliveryStatus 联合 / WebhookDelivery / PollStatus(切片私有,serde camelCase)
- api.ts: webhookDeliveries() / pollStatus(projectId) invoke 包装
- usePrStore.ts: 按项目 pollStatus 分区 + pollStatusActive getter + refreshPollStatus action;subscribe 两分支末尾、init、toggle 后刷新
- PollControls.vue: 轮询循环运行/暂停指示、最近成功时间、失败/停滞告警、间隔提示 + ~10s 兜底刷新
- WebhookPanel.vue: 最近 deliveries 列表(最近优先),状态徽章 + assertNever 穷尽,挂载/启停/手动刷新 + ~5s 兜底刷新

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ghbvf ghbvf added the pr-status/in-progress ship 实施 + 内置 review/fix 中 label Jun 17, 2026
…测试补全(#61 #62)

六维内置 review 的 small findings 自动修:
- 后端:IngestIntent::StatusOnly 改携带 sealed StatusOnlyKind(type-locked,
  消除 parse_delivery↔ingest_webhook 的字符串比较耦合,Soft→Hard);webhook
  mutate_tracked 失败改 emit PrEvent::Error(与 poll 路径对称,不再静默吞);
  final_status 单点决策 + WriteKind 提到模块级;fork 检测改 eq_ignore_ascii_case;
  make_dispatcher 去重;record_delivery 委托 record_into;过期模块/类型 doc 订正;
  补 poll_status golden 4 个 snake_case 缺席断言 + parse_delivery Malformed/labels-缺失/
  WebhookDelivery all-None null 序列化测试。
- 前端:PollControls stalled 首轮误报修复(用 lastStartedEpoch)+ interval 友好格式化;
  WebhookPanel 独立 deliveryError/deliveryLoading + 时间戳含日期 + listUpdated 无 message
  时给固定提示;轮询间隔魔法数常量化。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ghbvf

ghbvf commented Jun 17, 2026

Copy link
Copy Markdown
Owner Author

🛠 ship review + fix

reviewer 6(diff 2176 行 → 顶档六维并行)· Findings 24(已修 small 20 · 遗留 large 0 · OUT_OF_SCOPE/declined 4)· 0 P0/P1

可靠性维度确认核心设计健全:registry::mutate_tracked 单一写 seam 复用(锁同步持有、无跨 .await)、push 路径 ledger 去重幂等成立、detached spawn 不丢不重、ring FIFO 正确、update_present miss 无幻写、错误经 AppError/AppResult。

  • F1 [P2·small·架构/安全/DX/可靠] src-tauri/src/pr/commands.rs:444 — StatusOnly NotOpen↔NoTriggerLabel 靠跨函数中文字符串比较区分(4 reviewer 命中;ai-robust Soft 违反最低 Medium)→ ✅ 已修(sealed StatusOnlyKind 单源 reason+status,type-locked → Hard)
  • F2 [P2·small·架构/安全/可靠] src-tauri/src/pr/commands.rs:507 — webhook mutate_tracked 失败 Err(_) => {} 静默吞,与 poll 路径不对称 → ✅ 已修(emit PrEvent::Error,dispatch 仍独立,与 poll 对称)
  • F3 [P2·small·可靠] src-tauri/src/pr/commands.rs:386 — final_status 提前赋 Dispatched 再覆盖 + async fn 内局部 WriteKind → ✅ 已修(单点决策 + 提模块级)
  • F4 [P2·small·DX] src-tauri/src/pr/webhook.rs:18 — 模块头 doc 仍说 handler 映射 (project_id, Candidate) → ProjectDispatcher(已换 WebhookIngestor)→ ✅ 已修
  • F5 [P2·small·测试] src-tauri/src/pr/scheduler.rs:1028 — poll_status golden 仅断言 3/7 多词字段的 snake_case 缺席(Medium 载体不闭环)→ ✅ 已修(补 4 个)
  • F6 [P2·small·测试] src-tauri/src/pr/webhook.rs:1577 — parse_delivery 4 个 Malformed 返回点仅测 2 个 → ✅ 已修(补 number / head.ref 缺失)
  • F7 [P2·small·测试] src-tauri/src/pr/commands.rs:504 — ingest_webhook 的 ListUpdated(autoReview off 仍入列表,[bug] webhook 收到 PR 后未更新 PR 列表快照 #61 核心)无覆盖说明 → ✅ 已修(加 AppHandle-bound 不可单测、依赖集成/手验 说明)
  • F8 [P2·small·产品] src/pr/PollControls.vue:44 — stalled 在 lastSuccessEpoch==null 即报,首轮未完成时误报 → ✅ 已修(引入 lastStartedEpoch 阈值)
  • F9 [P2·small·产品] src/pr/WebhookPanel.vue:127 — loadDeliveries 与 start/stop/refresh 共用同一 error ref,竞态互相覆盖 → ✅ 已修(独立 deliveryError)
  • F10 [P2·small·产品] src/pr/WebhookPanel.vue:175 — delivery 时间仅 toLocaleTimeString 跨午夜歧义 → ✅ 已修(含日期)
  • F11–F20 [P3·small·DX/安全/测试/产品] fork 检测大小写敏感(webhook.rs:1093→eq_ignore_ascii_case) · make_dispatcher 去重(lib.rs) · WebhookIngestor doc 订正 · record_delivery 委托 record_into · kind 推断冗余 else 化简 · discover.rs 模块头标 live_gate_skip (reserved) · WebhookDelivery all-None null 序列化测试 · labels 缺失 fallback 测试 · deliveryLoading 态(空/加载区分+防并发点击) · interval 友好格式化 + 轮询间隔魔法数常量化 → ✅ 全修

OUT_OF_SCOPE / declined(不阻塞;附理由)

  • O1 [安全] webhook payload 字段长度/格式上限(head_sha/head_ref/labels/action)→ 🚦 DECLINE:端点 HMAC 验证、数据源是 GitHub、body 已 1 MiB 上限;加上限属推测性防御,违「优雅简洁 / 不预设未来需求」。
  • O2 [安全/可靠] webhookPublicUrl https 校验、status() 嵌套锁序注释、drain_scanning_url TOCTOU → 🚦 OOS:均为本 PR 未改动的既有代码,且后两者当前实现安全。
  • O3 [架构] 从 Rust serde 类型 codegen src/pr/types.ts + git diff --exit-code Hard 化 → 🚦 OOS:全仓所有 slice-private 类型同此 Soft 下游模式,非本 PR 引入;ai-robust.md 已记此 future Hard 路径。
完整详表(根因 + 证据 + 处置;/fix 读此)

F1 [P2·small·架构/安全/DX/可靠] src-tauri/src/pr/commands.rs:444

  • 证据:let st = if reason == "PR 已关闭或合并" { NotOpen } else { NoTriggerLabel },而 parse_delivery(webhook.rs)生产同一字面量——两函数靠中文字符串隐式协议,改名即静默错分类。BOTH_TRIGGER_LABELS_REASON 已有共享常量先例未跟进。
  • enforcement:现状 Soft(手维护字面量,无机器校验,违 ai-robust 最低 Medium)。
  • 处置:✅ 已修——新增 sealed enum StatusOnlyKind { ClosedOrMerged, TriggerLabelRemoved },reason()/delivery_status() 单源;IngestIntent::StatusOnly { kind } 携带枚举;ingest_webhook 直接 kind.delivery_status()/kind.reason(),无字符串比较 → Hard(type-locked,改动即编译错)。

F2 [P2·small·架构/安全/可靠] src-tauri/src/pr/commands.rs:507

  • 证据:Err(_) => {}——store 写失败完全静默;poll 路径 discover_emit_dispatch/persist_event 则 emit PrEvent::Error。用户可能看到 delivery=Dispatched 但列表未更新且无任何错误提示。
  • 处置:✅ 已修——Err(e) 改 emit PrEvent::Error{project_id, "PR 列表持久化失败:…"}(项目级错误 banner,与 poll 对称);dispatch 与持久化独立、仍按 gate 运行(同 poll 既有契约)。

F3 [P2·small·可靠] src-tauri/src/pr/commands.rs:386

  • 证据:status 在 autoReview 判定前预设 Dispatched,后被覆盖 ListUpdated;enum WriteKind 定义于 async fn 体内。
  • 处置:✅ 已修——final_status 在 persist+dispatch 决策后单点设定;WriteKind 提到模块级。

F4 [P2·small·DX] src-tauri/src/pr/webhook.rs:18-23 — 模块头描述 handler 映射 (project_id, Candidate) 交 ProjectDispatcher(已换 WebhookEvent/WebhookIngestor)。✅ 订正(含 F13:WebhookIngestor doc 去除"replacing old"、修正不准确的"mirroring")。

F5 [P2·small·测试] src-tauri/src/pr/scheduler.rs:1028 — poll_status_wire_shape_is_camel_case 漏 last_success_epoch/last_error_epoch/last_error_message/last_persist_epoch 的 snake_case 缺席断言。✅ 补齐 4 条(Medium 载体闭环)。

F6 [P2·small·测试] src-tauri/src/pr/webhook.rs:1577 — parse_delivery 缺 number / head.ref 缺失 → Malformed 的子用例。✅ 补。

F7 [P2·small·测试] src-tauri/src/pr/commands.rs:504 — ListUpdated(#61 核心:autoReview off 仍入列表)路径 AppHandle-bound 不可纯单测。✅ 加说明注释(依赖集成/手验)。

F8 [P2·small·产品] src/pr/PollControls.vue:44 — stalled 首轮(lastSuccessEpoch==null 且首 cycle 在途)误报。✅ 改:lastStartedEpoch==null→不报;否则 now-started > max(intervalSecs,60) 才报。

F9 [P2·small·产品] src/pr/WebhookPanel.vue:127 — loadDeliveries 与主面板共享 error ref,run() 起始 error=null 与 delivery 错误互相覆盖(onMounted 并发)。✅ 独立 deliveryError。

F10 [P2·small·产品] src/pr/WebhookPanel.vue:175 — deliveryTime 仅时间无日期。✅ toLocaleString 含 month/day。

F11–F20(P3·small):

  • 安全:webhook.rs:1093 fork 检测 h != b → !h.eq_ignore_ascii_case(&b)(与路由匹配一致)。
  • 架构:lib.rs 两处相同 dispatcher 闭包 → 抽 make_dispatcher。
  • DX:record_delivery 委托 record_into(去重);commands.rs:435 kind 推断冗余 else 化简为 if check {check} else {review};discover.rs:1 模块头标 live_gate_skip (reserved)。
  • 测试:webhook.rs 加 all-None WebhookDelivery → JSON null 断言;加 labels=null/缺失 → TriggerLabelRemoved 子用例。
  • 产品:WebhookPanel 加 deliveryLoading(区分加载/空态 + 刷新按钮防并发);listUpdated 且 message 为空时给固定提示「autoReview 关闭:已入列表,未派发 review」;PollControls interval formatInterval("2 分钟");两处轮询间隔(5s/10s)常量化。

O1 [安全] head_sha/head_ref/labels/action 无长度上限 → 🚦 DECLINE(HMAC 验证 + GitHub 来源 + body 1 MiB 上限;推测性防御违优雅简洁/不预设未来)。
O2 [安全/可靠] configured_url 无 https 校验、status() 锁序、drain_scanning_url TOCTOU → 🚦 OOS(本 PR 未改的既有代码;后两者当前安全)。
O3 [架构] TS 镜像 codegen Hard 化 → 🚦 OOS(全仓既有 Soft 下游模式,ai-robust 已记 future Hard 路径)。

下一步:切 pr-status/needs-review-again(待再审:codex / /pr-review #66;有需改再 /fix #66)。


🤖 PR #66 · Generated with Claude Code · branch Fix/61-62-webhook-ingest-diag · worktree worktrees/Fix/61-62-webhook-ingest-diag

@ghbvf ghbvf added pr-status/needs-review-again ship 交接后首审一次(review changes-requested 后转 needs-fix) and removed pr-status/in-progress ship 实施 + 内置 review/fix 中 labels Jun 17, 2026
@ghbvf

ghbvf commented Jun 17, 2026

Copy link
Copy Markdown
Owner Author

🟢 CI 收敛

提交 8bbf1eb(内置 review 修复后)CI 全绿,1 轮收敛,无回修:

  • Frontend (vue-tsc + build) — ✅ pass (18s)
  • Rust (fmt + clippy + build) — ✅ pass (1m29s)

run: https://github.com/ghbvf/prmonitor/actions/runs/27715715398

本地另跑(CI 未含):cargo test 219 lib + 3 集成 通过、vitest 109 通过(webhook 的 6 个绑 TCP 测试需本地非沙箱环境)。


🤖 PR #66 · Generated with Claude Code

@ghbvf

ghbvf commented Jun 17, 2026

Copy link
Copy Markdown
Owner Author

🔍 pr-review(六维度分级审查)

根因簇 5 · Findings 8(P0 0·P1 1·P2 4·P3 3 | small 5·large 3)· 结论 需修复

根因簇

  • C1 webhook delivery 被异步 detached 处理,缺少按 PR/项目的顺序与 live-state 复核,且把“已排队”当成终态(维度 可靠性/生命周期、产品/用户体验;系统性 Grep 2 处)→ F1,F2
  • C2 webhook payload 把缺失/非法 labels 归一成空数组,坏 payload 会变成合法状态更新(维度 安全/健壮;系统性 Grep 1 处)→ F3
  • C3 前端新增后端 pollStatus 后,仍保留 optimistic polling 作为控制源,两个状态源会冲突(维度 产品/用户体验、测试/回归;系统性 Grep 7 处)→ F4,F5,F8
  • C4 Webhook 诊断文案未按 kind/真实语义生成,check 和 queued dispatch 会被误读(维度 产品/用户体验;系统性 Grep 1 处)→ F6
  • C5 核心 AppHandle-bound ingest 副作用路径没有自动化覆盖,PR 的主修复链只测了分段纯函数(维度 测试/回归、可维护性/DX;系统性 Grep 0 个直接测试覆盖点)→ F7

Findings(每条带 file:line,/fix 无损提取)

  • F1 [P1·large·可靠性/生命周期] src-tauri/src/pr/webhook.rs:946 — routable delivery detached spawn,旧事件可能覆盖新状态并过期派发 → 簇 C1
  • F2 [P2·large·可靠性/生命周期] src-tauri/src/pr/commands.rs:527 — 只提交异步 dispatch 就记录 Dispatched,真实启动失败会误报 → 簇 C1
  • F3 [P2·small·安全/健壮] src-tauri/src/pr/webhook.rs:1100 — labels 缺失/null/非数组被当成触发 label 已移除 → 簇 C2
  • F4 [P2·small·产品/用户体验] src/pr/PollControls.vue:109 — 按钮仍读 optimistic pollingActive,可能和后端 running 冲突 → 簇 C3
  • F7 [P2·large·测试/回归] src-tauri/src/pr/commands.rs:333 — ingest_webhook 的 upsert/emit/dispatch/diagnostic 组合未被自动化覆盖 → 簇 C5
  • F5 [P3·small·产品/用户体验] src/pr/usePrStore.ts:203 — 切项目后不立即刷新 pollStatus,诊断短时间为空或旧 → 簇 C3
  • F6 [P3·small·产品/用户体验] src/pr/WebhookPanel.vue:205 — listUpdated 默认文案写死 review,check 场景显示错误 → 簇 C4
  • F8 [P3·small·测试/回归] src/pr/usePrStore.test.ts:15 — pollStatus mock/断言缺失,刷新链路回归不会红 → 簇 C3
完整详表(证据 + 建议 + 根因 + 方案种子,/fix 读此)

F1 [P1·large·可靠性/生命周期] src-tauri/src/pr/webhook.rs:946(→ C1)

  • 证据:drop(spawn(ingestor(*ev)));
  • 问题:handler 对每个 routable delivery 直接 detached spawn,后续 ingest_webhook 各自竞争 config/ledger 读取、mutate_tracked 写入、emit 和 dispatch。mutate_tracked 只保证单次写是串行的,不保证同一 PR 的 webhook delivery 按接收顺序落库;如果 labeled 与 unlabeled/closed 快速到达,旧 labeled 任务后完成时可以覆盖新状态并触发过期 dispatch。
  • 建议:按 project/PR 串行化 webhook ingest,或给 delivery 携带单调序号并在写入前拒绝旧事件;dispatch 前再 live-revalidate 当前 PR 仍 open 且目标 trigger label 仍存在。
  • 三级方案种子:最小 按 project/PR 加队列并复核当前状态 / 彻底 delivery 序号 + 写入单调校验 + dispatch 前 gh live gate / 重构 将 webhook ingest 建成显式状态机。

F2 [P2·large·可靠性/生命周期] src-tauri/src/pr/commands.rs:527(→ C1)

  • 证据:drop(tauri::async_runtime::spawn(dispatcher(...))); DeliveryStatus::Dispatched
  • 问题:这里记录的是“已提交异步派发任务”,但下游 run_auto_dispatch 仍可能因为 config 校验失败、codex stopped、session dedup 或 start error 而没有真正启动 review。诊断面板会把 queued/skipped/failed 误报成 Dispatched。
  • 建议:不要在 spawn 点记录终态;要么让 dispatcher 返回 started/skipped/failed outcome 后记录,要么新增 dispatchQueued 状态并由实际 start 结果更新诊断。
  • 三级方案种子:最小 将文案/状态改为 queued / 彻底 ProjectDispatcher 返回结果枚举 / 重构 统一 auto-dispatch outcome 事件和 webhook delivery 诊断。

F3 [P2·small·安全/健壮] src-tauri/src/pr/webhook.rs:1100(→ C2)

  • 证据:pr.get("labels").and_then(Value::as_array)...unwrap_or_default();随后 (false, false) 映射为 StatusOnlyKind::TriggerLabelRemoved。
  • 问题:labels 缺失、null 或非数组时会被当成空 label 集,进而对已跟踪 PR 落库并 emit “触发 label 已移除”。这把 malformed payload 转成了有效状态更新。
  • 建议:labels 字段缺失或非数组应返回 ParseResult::Malformed/BadPayload;只有明确的空数组才表示无触发 label。同步修改当前锁定 null/absent fallback 的测试。

F4 [P2·small·产品/用户体验] src/pr/PollControls.vue:109(→ C3)

  • 证据:{{ store.pollingActive ? "暂停轮询" : "恢复轮询" }},同时诊断区读取 poll.running。
  • 问题:当后端 poll_status 返回 running: false,诊断区显示“已暂停”,按钮仍可能因 optimistic pollingActive 显示“暂停轮询”;第一次点击只会调用 stopPolling,用户不能直接恢复。
  • 建议:在 pollStatus 可用时以 poll.running 作为按钮文案/行为来源,或在 refreshPollStatus 成功后同步 polling[id];ProjectSwitcher 的状态点也要同源。

F5 [P3·small·产品/用户体验] src/pr/usePrStore.ts:203(→ C3)

  • 证据:switchTo 只 setActive、清 hasNewPr、loadSnapshot,没有刷新 pollStatus。
  • 问题:切换项目后 PollControls 只能等 10 秒定时器或下一次事件,短时间没有该项目的真实轮询状态。
  • 建议:switchTo(id) 成功加载 snapshot 后立即 void this.refreshPollStatus(id),或在 PollControls watch activeProjectId 后 immediate 刷新。

F6 [P3·small·产品/用户体验] src/pr/WebhookPanel.vue:205(→ C4)

  • 证据:if (d.status === "listUpdated") return "autoReview 关闭:已入列表,未派发 review";
  • 问题:后端 delivery 带有 kind: "review" | "check";check label 触发且 autoReview 关闭时,UI 会错误显示“未派发 review”。
  • 建议:使用 d.kind 生成文案,或改成不区分模式的“未派发自动任务”。

F7 [P2·large·测试/回归] src-tauri/src/pr/commands.rs:333(→ C5)

  • 证据:pub(crate) async fn ingest_webhook<R: tauri::Runtime>(...) 承载 upsert、emit、dispatch、delivery 记录;Grep 未发现直接测试覆盖该函数,代码注释还写明 autoReview-off → ListUpdated “verified via integration / manual verify, NOT a unit test”。
  • 问题:PR 的核心修复路径没有自动化覆盖;现有测试只覆盖 parse_delivery、webhook_view、update_present 等分段纯函数,没验证 AppHandle-bound 组合路径会正确 upsert、emit、记录 ListUpdated/Dispatched、处理 status-only hit/miss 和 persist error。
  • 建议:增加 Tauri harness 或抽出纯 ingest decision seam,覆盖 clean+autoReview false/true、closed/merged update-present hit/miss、label removed hit/miss、persist error/event、delivery status 记录。
  • 三级方案种子:最小 抽纯 decision seam 并测结果 / 彻底 Tauri test harness 验证 emit 与 delivery ring / 重构 拆分 decision/persist/dispatch/diagnostic 四段。

F8 [P3·small·测试/回归] src/pr/usePrStore.test.ts:15(→ C3)

  • 证据:vi.mock("./api", () => ({ ... })) 没有导出 pollStatus;Grep 无 refreshPollStatus 断言。
  • 问题:新增 poll 状态刷新链路没有 vitest 断言;refreshPollStatus 捕获并吞掉异常,mock 缺失也不红,API adapter 参数、成功写入、失败保留旧值都可能回归。
  • 建议:补 pollStatus mock,并断言 subscribe() 事件、init()、toggle()、switchTo() 使用正确 projectId 刷新;单测成功写入和失败保留旧值。

复杂度汇总:按根因簇 small: 3 / large: 2;按 Finding small: 5 / large: 3

修复分流:small → /fix #66;large → 需人工决策(方案种子见详表)。C1/C5 ← issue #61/#62;C2/C3/C4 ← issue #61/#62
结论:需修复;webhook delivery 可乱序覆盖并过期派发是 P1 风险,且 payload 校验和 poll 状态一致性会产生可见错误。


🤖 PR #66 · Generated with Claude Code · branch Fix/61-62-webhook-ingest-diag · worktree /Users/shengming/Documents/code/prmonitor/worktrees/Fix/61-62-webhook-ingest-diag · session —

@ghbvf ghbvf added pr-review/changes-requested review 提出需改项 pr-status/needs-fix review 出 changes-requested,待 /fix and removed pr-status/needs-review-again ship 交接后首审一次(review changes-requested 后转 needs-fix) labels Jun 17, 2026
…on seam + poll 状态一致性(#61 #62)

根因簇 C2/C5/C3/C4 的 small findings:
- F3[安全] parse_delivery:labels 缺失/null/非数组 → ParseResult::Malformed
  (仅显式 [] 表示无触发 label),坏 payload 不再被当成有效状态更新;
  改正上一轮误锁 null/absent→TriggerLabelRemoved 的测试。
- F7[测试] 抽出纯 decide_ingest seam(intent/params/ledger/autoReview → view/
  WriteKind/dispatchable/status/message),ingest_webhook 退化为薄 IO 壳;
  补全 7 分支单测(clean×autoReview / gated draft+cooldown / conflict /
  StatusOnly closed+label-removed),移除"未单测"注释。
- F4[产品] refreshPollStatus 成功后同步 polling[id]=running,按钮以后端为源。
- F5[产品] switchTo 切项目后立即刷新 pollStatus。
- F6[产品] listUpdated 文案改 mode-agnostic「未派发自动任务」(check 不再误显 review)。
- F8[测试] usePrStore.test 补 pollStatus mock + refreshPollStatus 全链路断言。

遗留(large·并发/生命周期,需人工决策,见 pm:fix):
F1[P1] webhook delivery detached spawn 乱序覆盖+过期派发;F2 spawn 即记 Dispatched 误报。

Refs: PR #66 pm:pr-review F3-F8(Discovered via /fix #66)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ghbvf

ghbvf commented Jun 17, 2026

Copy link
Copy Markdown
Owner Author

🔁 fix(findings triage + fix)

Findings 8(已修 small 6 · 遗留 large 2 · OUT_OF_SCOPE 0)· 来源 pm:pr-review(5 簇)

全部 IN_SCOPE(涉及文件均在本分支 diff 内)。small 自动修;C1 的 2 条 large(并发/生命周期语义)按 fix 章程「并发语义变更不自动执行」遗留 + surface。

  • F3 [P2·small·安全] src-tauri/src/pr/webhook.rs:1110 — labels 缺失/null/非数组被 unwrap_or_default() 当成空集→「触发 label 已移除」,坏 payload 变有效状态更新 → ✅ 已修(→ ParseResult::Malformed,仅显式 [] 表无触发 label;改正上一轮误锁的测试)
  • F7 [P2→small·测试] src-tauri/src/pr/commands.rs:333 — ingest_webhook 组合路径无自动化覆盖(仅测分段纯函数)→ ✅ 已修(抽纯 decide_ingest seam,7 分支单测;重分级 small:单文件、无 trait/并发契约变更)
  • F4 [P2·small·产品] src/pr/PollControls.vue:109 — 按钮读 optimistic pollingActive,与后端 running 冲突 → ✅ 已修(refreshPollStatus 成功后同步 polling[id]=running,后端为源)
  • F5 [P3·small·产品] src/pr/usePrStore.ts:203 — switchTo 不刷新 pollStatus → ✅ 已修(切项目后立即 refreshPollStatus)
  • F6 [P3·small·产品] src/pr/WebhookPanel.vue:205 — listUpdated 文案写死 review,check 误显 → ✅ 已修(改 mode-agnostic「未派发自动任务」)
  • F8 [P3·small·测试] src/pr/usePrStore.test.ts:15 — pollStatus mock/断言缺失 → ✅ 已修(补 mock + subscribe/init/toggle/switchTo 的 projectId 断言 + 成功写入/失败保留旧值)
  • F1 [P1·large·可靠性/生命周期] src-tauri/src/pr/webhook.rs:946 — routable delivery detached spawn,同一 PR 的 delivery 不保证按序落库,旧 labeled 后完成可覆盖新状态 + 过期派发 → ⏸ 遗留(需人工决策)
  • F2 [P2·large·可靠性/生命周期] src-tauri/src/pr/commands.rs:527 — spawn 即记 Dispatched,下游 run_auto_dispatch 仍可能 config/codex/dedup 跳过 → ⏸ 遗留(需人工决策)
完整详表(triage 依据 + 证据 + 处置 + 遗留方案种子,下次 fix 读此)

F3 [P2·small·安全] src-tauri/src/pr/webhook.rs:1110(IN_SCOPE)

  • 证据:pr.get("labels").and_then(Value::as_array)...unwrap_or_default(),随后 (false,false) → StatusOnlyKind::TriggerLabelRemoved。
  • 处置:✅ 已修——改 let Some(labels_arr) = pr.get("labels").and_then(Value::as_array) else { return ParseResult::Malformed };,与 number/head.sha/head.ref 同列(不分 open/closed)。显式 [] 仍合法(open→TriggerLabelRemoved / closed→ClosedOrMerged)。反转上一轮 FIX 15 误锁的 null/absent→TriggerLabelRemoved 测试 → 现断言 Malformed,并补非数组用例 + 显式 [] open/closed 正例。commit bce2e71。

F7 [P2→small·测试] src-tauri/src/pr/commands.rs:333(IN_SCOPE)

  • 证据:ingest_webhook 承载 upsert/emit/dispatch/diagnostic,Grep 无直接测试;注释自承「verified via integration / NOT a unit test」。
  • 复杂度复核:单文件、不改 trait/跨切片契约/并发语义 → 实为 small(reviewer 原判 large 偏高)。
  • 处置:✅ 已修——抽纯 fn decide_ingest(intent, number, title, labels, url, &params, &ledger, now, auto_review) -> IngestDecision{view,write,dispatchable,status,message}(无 AppHandle);ingest_webhook 退化为薄 IO 壳(load project→params+auto_review、load ledger、mutate_tracked、emit、spawn dispatch、record_delivery)。新增 7 分支单测:clean×autoReview(t→Dispatched/f→ListUpdated [bug] webhook 收到 PR 后未更新 PR 列表快照 #61 核心) / draft→Gated / cooldown→Gated / conflict→Gated(BOTH) / StatusOnly closed→NotOpen+UpdatePresent / label-removed→NoTriggerLabel+UpdatePresent。移除「未单测」注释。commit bce2e71。

F4 PollControls.vue:109 ✅ — refreshPollStatus 成功后 this.polling[id]=status.running,pollingActive+ProjectSwitcher 状态点随后端真值;按钮无需改。
F5 usePrStore.ts:203 ✅ — switchTo 加载 snapshot 后 void this.refreshPollStatus(id)。
F6 WebhookPanel.vue:205 ✅ — listUpdated+无 message → 固定文案「autoReview 关闭:已入列表,未派发自动任务」(不再写死 review)。
F8 usePrStore.test.ts ✅ — ./api mock 加 pollStatus;断言 subscribe(p1/p2)/init/toggle/switchTo 用正确 projectId 刷新;成功写 pollStatus[id]+同步 polling[id]、失败保留旧值。vitest 112 通过(+3)。

F1 [P1·large·可靠性/生命周期] src-tauri/src/pr/webhook.rs:946(IN_SCOPE,遗留)

  • 证据:drop(spawn(ingestor(*ev))); —— 每个 routable delivery detached spawn,mutate_tracked 只保证单次写串行,不保证同一 PR 的 delivery 按接收顺序落库;labeled 与 unlabeled/closed 快速到达时,旧 labeled 任务后完成可覆盖新状态并触发过期 dispatch。
  • 三级方案种子:最小 按 project/PR 加串行队列 + 写入前复核当前状态;彻底 delivery 携单调序号 + 写入单调校验 + dispatch 前 gh live-revalidate(open 且目标 trigger label 仍在);重构 webhook ingest 建成显式状态机。
  • 遗留原因:并发/生命周期语义变更,章程禁止无监督自动执行;窗口:本/下迭代(P1,建议优先)。

F2 [P2·large·可靠性/生命周期] src-tauri/src/pr/commands.rs:527(IN_SCOPE,遗留)

  • 证据:drop(spawn(dispatcher(...))); status=Dispatched —— 记录的是「已提交异步派发」,但 run_auto_dispatch 仍可能因 config 校验失败 / codex stopped / session dedup / start error 未真正启动,诊断面板把 queued/skipped/failed 误报成 Dispatched。
  • 三级方案种子:最小 文案/状态改 queued(新增 dispatchQueued 语义);彻底 ProjectDispatcher 返回 started/skipped/failed outcome 枚举后据实记录;重构 统一 auto-dispatch outcome 事件与 delivery 诊断。
  • 遗留原因:彻底解触及 ProjectDispatcher seam(poll 路径共用)→ 跨调用方契约变更,需人工决策;当前 ReviewEvent::DispatchError 已部分透传启动失败到 review banner。

下一步:切 pr-status/needs-check-fix,待 /pr-review #66 --check 验证本轮修复(fix 不自证完成)。F1/F2 两条 large 遗留需人工裁定方案档位(最小/彻底/重构)后再开工。


🤖 PR #66 · Generated with Claude Code · branch Fix/61-62-webhook-ingest-diag · worktree worktrees/Fix/61-62-webhook-ingest-diag

@ghbvf ghbvf added pr-status/needs-check-fix /fix 已修,待 /pr-review --check 验证 and removed pr-status/needs-fix review 出 changes-requested,待 /fix labels Jun 17, 2026
@ghbvf

ghbvf commented Jun 17, 2026

Copy link
Copy Markdown
Owner Author

🟢 CI 收敛(fix 轮)

提交 bce2e71(pr-review F3–F8 修复后)CI 全绿,1 轮收敛,无回修:

  • Frontend (vue-tsc + build) — ✅ pass (21s)
  • Rust (fmt + clippy + build) — ✅ pass (2m31s)

run: https://github.com/ghbvf/prmonitor/actions/runs/27718713102

本地另跑(CI 未含):cargo test 226 lib + 3 集成 通过、vitest 112 通过(含新增 decide_ingest 7 分支单测 + pollStatus 刷新链路断言;webhook 的 6 个绑 TCP 测试需本地非沙箱环境)。


🤖 PR #66 · Generated with Claude Code

@ghbvf

ghbvf commented Jun 18, 2026

Copy link
Copy Markdown
Owner Author

🔍 pr-review --check(修复验证)

验证对象 8 条上一轮 findings · 已修复 6 · 未修复 2 · 回归 0 · 部分 0 · 结论 需继续修复

验证表

  • F1 [原 P1·large·可靠性/生命周期] src-tauri/src/pr/webhook.rs:946 — ❌ 未修:当前仍是 drop(spawn(ingestor(*ev))),没有按 project/PR 串行队列、delivery 序号或 dispatch 前 live-state 复核。
  • F2 [原 P2·large·可靠性/生命周期] src-tauri/src/pr/commands.rs:527 — ❌ 未修:当前仍在 autoReview-on 决策中记录 DeliveryStatus::Dispatched,后续 dispatcher 仍是 detached spawn,未等待/回传 started/skipped/failed outcome。
  • F3 [原 P2·small·安全/健壮] src-tauri/src/pr/webhook.rs:1100 — ✅ 已修:labels 现在必须是数组,否则 ParseResult::Malformed;测试锁定 null/非数组/缺失均不再转成 TriggerLabelRemoved。
  • F4 [原 P2·small·产品/用户体验] src/pr/PollControls.vue:109 — ✅ 已修:refreshPollStatus 成功后把 this.polling[id] = status.running,按钮读取的 pollingActive 会同步后端真实 running。
  • F7 [原 P2·large·测试/回归] src-tauri/src/pr/commands.rs:333 — ✅ 已修:抽出纯 decide_ingest seam,7 个分支测试覆盖 clean/on、clean/off、gated、cooldown、conflict、closed、label removed。
  • F5 [原 P3·small·产品/用户体验] src/pr/usePrStore.ts:203 — ✅ 已修:switchTo 加载 snapshot 后立即 refreshPollStatus(id)。
  • F6 [原 P3·small·产品/用户体验] src/pr/WebhookPanel.vue:205 — ✅ 已修:listUpdated 默认文案改为「未派发自动任务」,不再写死 review。
  • F8 [原 P3·small·测试/回归] src/pr/usePrStore.test.ts:15 — ✅ 已修:pollStatus mock 和刷新链路断言已补,覆盖 subscribe/init/toggle/switchTo/成功写入/失败保留旧值。
验证证据

F1 src-tauri/src/pr/webhook.rs:946 → ❌

  • 当前代码仍为 let ingestor = ctx.ingestor.clone(); drop(spawn(ingestor(*ev)));。
  • Grep 未发现按 project/PR 的 webhook ingest queue、delivery 单调序号或 dispatch 前 live-state 复核。mutate_tracked 仍只保护单次写,不保证同一 PR delivery 接收顺序。

F2 src-tauri/src/pr/commands.rs:527 → ❌

  • 当前 decide_ingest 在 clean + autoReview on 时仍返回 DeliveryStatus::Dispatched,shell 随后 drop(tauri::async_runtime::spawn(dispatcher(...)))。
  • 下游 run_auto_dispatch 仍可能因 project_validated、state.codex.is_stopped()、StartReviewOutcome::Deduped 或 start error 跳过/失败;状态未改成 queued,也没有 dispatcher outcome 回传。

F3 src-tauri/src/pr/webhook.rs:1100 → ✅

  • 当前 let Some(labels_arr) = pr.get("labels").and_then(Value::as_array) else { return ParseResult::Malformed; };。
  • parse_delivery_no_trigger_label_is_status_only 测试覆盖 null、非数组、缺失均为 Malformed,显式 no-trigger labels 才是 StatusOnly。

F4 src/pr/PollControls.vue:109 / src/pr/usePrStore.ts:276 → ✅

  • PollControls 仍读 pollingActive,但 refreshPollStatus 成功后执行 this.polling[id] = status.running,ProjectSwitcher 的 pollingFor 也随之后端真值同步。
  • usePrStore.test.ts 覆盖 backend running=false 时 pollingFor("p1") 变 false。

F7 src-tauri/src/pr/commands.rs:333 → ✅

  • 新增纯 decide_ingest seam;cargo test ... decide_ingest 通过 7 个分支测试。
  • 该 seam 覆盖 autoReview on/off、static gated、cooldown gated、conflict、closed status-only、trigger-label-removed status-only。

F5 src/pr/usePrStore.ts:203 → ✅

  • switchTo(id) 现在 await this.loadSnapshot(id); void this.refreshPollStatus(id);。
  • usePrStore.test.ts 断言 switchTo("p2") 会调用 pollStatus("p2")。

F6 src/pr/WebhookPanel.vue:205 → ✅

  • deliveryDetail 对 listUpdated 返回「autoReview 关闭:已入列表,未派发自动任务」,不再 hardcode review。

F8 src/pr/usePrStore.test.ts:15 → ✅

  • ./api mock 已补 pollStatus;测试覆盖 subscribe/init/toggle/switchTo 触发刷新、成功写入+同步 polling、失败保留旧状态。

验证命令

  • cargo test --manifest-path src-tauri/Cargo.toml --locked decide_ingest -- --nocapture ✅
  • cargo test --manifest-path src-tauri/Cargo.toml --locked parse_delivery_no_trigger_label -- --nocapture ✅
  • pnpm vitest run src/pr/usePrStore.test.ts ✅

结论:仍需修复。F1/F2 两个 large 生命周期问题按 fix 留痕遗留,需人工选择方案后继续 /fix #66。


🤖 PR #66 · Generated with Claude Code · branch Fix/61-62-webhook-ingest-diag · worktree /Users/shengming/Documents/code/prmonitor/worktrees/Fix/61-62-webhook-ingest-diag · session —

@ghbvf ghbvf added pr-status/needs-fix review 出 changes-requested,待 /fix and removed pr-status/needs-check-fix /fix 已修,待 /pr-review --check 验证 labels Jun 18, 2026
@ghbvf

ghbvf commented Jun 18, 2026

Copy link
Copy Markdown
Owner Author

🔍 pr-review --check(defer 确认)

F1/F2 已由人工确认 deferred,不再阻塞 PR #66 本轮合并判断。

本轮状态

  • F3/F4/F5/F6/F7/F8:上一轮 --check 已验证 ✅ 已修复
  • F1/F2:🚦 deferred(large 生命周期/并发语义项,后续单独决策处理)
  • 回归:0

结论:通过;本轮阻塞项清零,切 pr-status/ready + pr-review/approved。


🤖 PR #66 · Generated with Claude Code · branch Fix/61-62-webhook-ingest-diag · worktree /Users/shengming/Documents/code/prmonitor/worktrees/Fix/61-62-webhook-ingest-diag · session —

@ghbvf ghbvf added pr-status/ready --check 验证全修复,可合并 pr-review/approved review 无需改 and removed pr-status/needs-fix review 出 changes-requested,待 /fix pr-review/changes-requested review 提出需改项 labels Jun 18, 2026
@ghbvf
ghbvf merged commit ead7df3 into develop Jun 18, 2026
2 checks passed
@ghbvf
ghbvf deleted the Fix/61-62-webhook-ingest-diag branch July 4, 2026 16:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-review/approved review 无需改 pr-status/ready --check 验证全修复,可合并

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] 增加 webhook delivery 与 poll loop 诊断日志 [bug] webhook 收到 PR 后未更新 PR 列表快照

1 participant