Skip to content

feat(pr): webhook 数据源接收端 + Cloudflare Quick Tunnel 公网代理(#9) - #49

Merged
ghbvf merged 14 commits into
developfrom
Feature/9-webhook-cloudflare
Jun 17, 2026
Merged

ghbvf merged 14 commits into
developfrom
Feature/9-webhook-cloudflare

Conversation

@ghbvf

@ghbvf ghbvf commented Jun 16, 2026 •

Copy link
Copy Markdown
Owner

Summary

本地 axum webhook 接收端(绑 127.0.0.1)+ App 托管的 Cloudflare Quick Tunnel(cloudflared 子进程),把 GitHub PR 事件即时推进既有 auto_dispatch 路径——给 PR 加触发标签即秒级触发 review,无需等轮询间隔。

Why / 背景

issue #9「webhook 数据源占位 + 可插拔抽象」的真实交付:PrSource trait / SourceKind seam 早已就位,剩余即本地 HTTP 接收端。用户追加方向「本地启动服务,然后通过 cloudflare 代理公网」→ 升级为功能完整的接收端 + 免账号 Quick Tunnel:轮询有间隔延迟,webhook push 即时触发,二者并存(webhook 不替代、不影响轮询路径)。

webhook 是 push 形态,与 PrSource::discover()(pull)不同 → 不实现该 trait,而是按 dispatch.rs 既有架构注释「未来 webhook trigger 用 push event 产出的 candidates 调用同一个 auto_dispatch」落地,零派发重复。

Refs

Closes #9

  • ref: src-tauri/src/dispatch.rs(webhook→auto_dispatch 架构注释,本 PR 兑现)
  • ref: src-tauri/src/pr/scheduler.rs(复用 Dispatcher seam 类型 + auto_review_enabled gate)
  • ref: src-tauri/src/pr/gh.rs(GhStatus 切片私有 wire 类型 + golden 模式,WebhookStatus 仿之)
  • ref: src-tauri/src/review/engines/codex/manager.rs(子进程 kill_on_drop + AppState manager + Exit shutdown 生命周期模式,cloudflared 照搬)

Risk / 兼容性

  • 新增依赖:axum(default-features off,仅 tokio+http1)、hmac/sha2/hex;tokio 增 net feature。Cargo.lock 已更新。
  • 新增 config 字段 webhookEnabled/webhookPort/webhookSecret/cloudflaredBin:serde camelCase golden 测试已扩;src/config/types.ts 镜像已同步(含 SettingsView/OnboardingWizard draft + 测试 literal)。#[serde(default)] 前向兼容——旧持久化 config 自动补默认。
  • 不改跨切片契约(model.rs 的 Candidate/PullRequestView 未动)。WebhookStatus 为 pr 切片私有 wire 类型(src/pr/types.ts,非 src/types.ts),同 GhStatus。
  • additive:webhook 与轮询并存,去重靠既有 registry 在途 guard + ledger cooldown(webhook 候选经 discover::should_skip/cooldown_skip 同等 gating)。不破坏现有轮询路径。
  • 治理:App 不自动注册 GitHub webhook(守 gh 只读红线——用户手动把公网 URL + secret 粘进仓库 webhook 设置);本地仅绑 127.0.0.1;公网端点强制 HMAC 验签。dispatch.rs::app_code_uses_no_gh_write_subcommands 仍通过。
  • 运行时需 cloudflared(未装时友好降级 + brew install cloudflared 提示)。

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 通过(155 后端)+ pnpm test(93 前端)
  • 手动 e2e(需 brew install cloudflared):启动隧道 → 公网 URL 粘进 GitHub webhook → 加 review 标签 → 观察秒级起 review

🤖 Generated with Claude Code


隧道解耦(#50,后续 commit)

接收端与隧道解耦为三模式(webhookTunnelMode):quick(默认,零配置 Quick Tunnel)/ command(App 拉起自定义隧道命令,{port} 占位,固定 URL)/ listener(仅监听,隧道完全外置)。接收端只需 port + secret,App 不持隧道凭证、不碰域名/登录(守与 gh 只读同源的治理红线)。新增 webhookTunnelCommand / webhookPublicUrl 配置(serde camelCase golden 已扩,TS 镜像同步,as const 派生 union 单源)。

并闭合上轮 --check 遗留的 P1 F3(teardown 显式 start_kill + detached wait reaping,镜像 codex kill_and_reap)。内置 review 3 reviewer / 9 findings:8 已修(large 1 + small 7),G7(Rust↔TS config wire Soft 下游开口,系统性)→ #51。

Closes #50

本地 axum 接收端(绑 127.0.0.1)+ App 托管的 cloudflared Quick Tunnel,把
GitHub PR 事件即时推送进既有 auto_dispatch 路径——加触发标签秒级触发 review,
不必等轮询间隔。

- pr/webhook.rs:POST /webhook,HMAC-SHA256(X-Hub-Signature-256)常量时间验签 →
  payload_to_candidate(label→kind + conflict 跳过,纯函数)→ 注入的 Dispatcher。
  WebhookManager 持有 server task + cloudflared 子进程(kill_on_drop)+ 公网 URL;
  start/stop/status/shutdown。cloudflared 未装时友好降级(status + brew 提示)。
- 复用而非重复:派发走 lib.rs run_auto_dispatch;派发闭包复用 scheduler 的
  Dispatcher 类型 + auto_review_enabled gate + discover::should_skip/cooldown_skip
  做轮询路径同等 gating(跨仓/草稿/作者/冷却)。handler 不持 AppHandle,gate 在
  composition root 闭包内。
- config 增 webhookEnabled/webhookPort/webhookSecret/cloudflaredBin(serde camelCase
  golden + validate:启用时 secret 必填、port>0,消息保留字段前缀路由契约)。
- 前端:config 镜像 + Webhook 设置分组(仅 Settings,不入首启)+ WebhookPanel
  (启停 + 公网 URL 复制 + 粘贴指引)。WebhookStatus 为 pr 切片私有 wire 类型
  (src/pr/types.ts,仿 GhStatus)。
- 治理:App 不自动注册 GitHub webhook(守 gh 只读红线,用户手动粘贴 URL+secret);
  本地仅绑 127.0.0.1;公网端点强制验签。app_code_uses_no_gh_write_subcommands 通过。

Closes #9

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 16, 2026
按三 reviewer(架构+测试 / 安全+产品 / 可靠性+DX)findings 修小问题,全 small:

- [P2 可靠性] spawn_quick_tunnel 失败时显式 abort 已起的 axum server_task,避免泄漏
  已 bind 的端口(runtime 未记录 → 后续 stop_inner 无法回收)。
- [P2 可靠性] stderr drain task 的 JoinHandle 存入 WebhookRuntime,stop_inner 一并
  abort(生命周期对称,不依赖 child-kill→EOF 链终止)。
- [P2 安全] 公网 axum 端点加 DefaultBodyLimit::max(1 MiB),限制验签前缓冲的请求体。
- [P1 安全] webhookSecret 改 type=password + 显示/隐藏切换(FieldDef.secret 标志),
  避免截图/屏幕分享泄露 HMAC 密钥。
- [P1 产品] WebhookPanel 读已保存 webhookEnabled gate 启动按钮(未启用即禁用 + 明确
  提示),把后端「请先启用」错误前移;按钮文案区分启动/重启;URL 未解析时加「重新查询状态」。
- [P2/P3 DX] 修正 WebhookRuntime drop 语义注释;补 errorToStep webhook 字段「设置专属、
  有意不路由」注释 + 测试锁定(闭合 Medium routing carrier);补 gate_dispatchable 覆盖
  说明 + 验签空头/无 shell exec 注释。

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

ghbvf commented Jun 16, 2026

Copy link
Copy Markdown
Owner Author

🛠 ship review + fix

reviewer 3(按 diff 1210 行分档:A 架构+测试 / B 安全+产品 / C 可靠性+DX) · Findings 14(已修 small 12 · 遗留 large 0 · OUT_OF_SCOPE 0 · 主动 defer P3 2)

安全核查全部 PASS:HMAC 验签在 parse 前、常量时间比较、空 secret fail-closed、仅绑 127.0.0.1、secret 不落日志、GitHub write-free(不自动注册 webhook)、fork fail-safe、子进程无 shell 注入、kill_on_drop + Exit shutdown。

  • F1 [P1·small·安全] src/config/fields.ts:86 ConfigField.vue — webhookSecret 以 type=text 明文渲染 → ✅ 已修(改 password + 显示/隐藏切换)
  • F2 [P1·small·产品] src/pr/WebhookPanel.vue — 面板未校验已保存的 webhookEnabled,未启用点启动只回后端错误 → ✅ 已修(读 store 已存值,未启用即禁用按钮 + 明确提示)
  • F3 [P2·small·可靠性] src-tauri/src/pr/webhook.rs:157 — spawn_quick_tunnel 失败时已起的 axum server_task 泄漏(端口占用、无法回收)→ ✅ 已修(失败显式 abort server_task)
  • F4 [P2·small·可靠性] src-tauri/src/pr/webhook.rs:433 — stderr drain task JoinHandle 被丢弃,无法主动 abort → ✅ 已修(存入 WebhookRuntime,stop_inner 一并 abort)
  • F5 [P2·small·安全] src-tauri/src/pr/webhook.rs:155 — 公网端点无请求体大小上限 → ✅ 已修(DefaultBodyLimit::max(1 MiB))
  • F6 [P2·small·测试] src/config/fields.ts:220 — 新增 webhookSecret/webhookPort validate 前缀未被 errorToStep 消费(Medium routing carrier 缺口)→ ✅ 已修(webhook 系设置专属、有意不路由 → 加注释 + 测试锁定)
  • F7 [P2·small·DX] src-tauri/src/pr/webhook.rs:90 — WebhookRuntime「Dropping tears both down」注释对 server_task 误导 → ✅ 已修(更正注释)
  • F8 [P3·small·测试] src-tauri/src/pr/commands.rs:223 — gate_dispatchable 无直测 → ✅ 已修(注释说明:核心 gate 经 discover should_skip/cooldown_skip 测试覆盖,AppHandle 分支为运行时路径)
  • F9 [P3·small·安全] webhook.rs 验签空 header / cloudflared 无 shell 注入 → ✅ 已修(补 fail-closed / 直接 exec 注释)
  • F10 [P3·small·产品] WebhookPanel 启动/重启文案不分、URL 超时未解析无重试 → ✅ 已修(区分「启动/重启」文案 + 加「重新查询状态」按钮)
  • F11 [P3·small·产品] WebhookPanel.vue 初始 status=null 期无 loading 态 → 🟡 defer(busy 期间按钮已 disabled,无误点风险;纯加载骨架价值低)
  • F12 [P3·small·安全] webhook.rs:438 extract_trycloudflare_url 用 contains 子串匹配 → 🟡 defer(威胁模型下用户已控 cloudflared_bin 即有本地执行能力,正则收紧收益甚微)
完整详表(根因 + 证据 + 建议 + 处置,/fix 读此)

F1 [P1·small·安全] src/config/fields.ts:86 / src/config/ConfigField.vue

  • 证据:webhookSecret FieldDef kind: "text" → 明文输入框,HMAC 密钥在设置界面可见。
  • 根因:代码 通用 ConfigField 无凭据掩码 / 架构 1 处字段定义 / 历史 无 secret 类字段先例。
  • 建议:FieldDef 加 secret?: boolean,ConfigField 据此渲染 type=password + 显示/隐藏切换。
  • 处置:✅ 已修(commit 41c5758)。

F2 [P1·small·产品] src/pr/WebhookPanel.vue

  • 证据:面板无 webhookEnabled 检查,start_webhook 后端 if !cfg.webhook_enabled { return Err } 仅以错误文字呈现。
  • 建议:读 useConfigStore().config.webhookEnabled(已保存值,契合「先保存再启动」),未启用即 :disabled + 明确提示。
  • 处置:✅ 已修。

F3 [P2·small·可靠性] src-tauri/src/pr/webhook.rs start()

  • 证据:let server_task = spawn(...) 后 spawn_quick_tunnel(...).await?,? 提前返回时 server_task detach、端口占用、runtime 未记录 → 后续 stop_inner 无法 abort。
  • 建议:tunnel spawn 失败时先 server_task.abort() 再返回 Err。
  • 处置:✅ 已修。

F4 [P2·small·可靠性] src-tauri/src/pr/webhook.rs spawn_quick_tunnel/stop_inner

  • 证据:drain task spawn(...) 句柄被丢弃,仅靠 child-kill→pipe-EOF 终止,不对称。
  • 建议:返回 drain JoinHandle 存入 WebhookRuntime,stop_inner 显式 abort。
  • 处置:✅ 已修。

F5 [P2·small·安全] src-tauri/src/pr/webhook.rs router

  • 证据:axum 默认无 body 上限,公网端点验签前先 buffer 整个 body。
  • 建议:.layer(DefaultBodyLimit::max(1024*1024))(GitHub payload 通常 < 25 KiB)。
  • 处置:✅ 已修。

F6 [P2·small·测试] src/config/fields.ts errorToStep

  • 证据:validate() 新增 webhookSecret/webhookPort 前缀消息,errorToStep 无分支 → null(→ done)。
  • 分析:webhook 系设置专属(无 onboarding step),webhookEnabled 默认 false → wizard 保存不触发其 validate 错误;SettingsView 直接展示后端错误(不走 errorToStep)。故 null 是设计,不是 bug。
  • 建议:注释说明「有意不路由」+ 测试断言 webhook 消息 → null,锁定意图、闭合 Medium carrier。
  • 处置:✅ 已修。

F7 [P2·small·DX] src-tauri/src/pr/webhook.rs:90 WebhookRuntime 注释

  • 证据:注释「Dropping it tears both down」,但 server_task 是 stop_inner 显式 abort(无 Drop impl)。
  • 处置:✅ 已修(注释更正为 stop_inner 显式 abort,_tunnel 走 kill_on_drop)。

F8 [P3·small·测试] src-tauri/src/pr/commands.rs:223 gate_dispatchable

  • 证据:无直测;核心谓词 should_skip/cooldown_skip 在 discover.rs 有充分测试;AppHandle 绑定的 load/fail-closed 分支无单测(AppHandle 不可在普通测试构造)。
  • 处置:✅ 已修(注释声明覆盖来源 + 运行时分支)。

F9 [P3·small·安全] webhook.rs 验签空 header / cloudflared exec

  • 证据:缺失/非 UTF-8 header → "",由 strip_prefix 门 fail-closed;Command::new(bin) 直接 exec 不经 shell。
  • 处置:✅ 已修(补注释使安全属性显式)。

F10 [P3·small·产品] WebhookPanel.vue

  • 证据:running 时按钮仍显示「启动」(实为 stop_inner 重启);running 但 publicUrl=None 无重试入口。
  • 处置:✅ 已修(running 显示「重启 Webhook 隧道」;running && !publicUrl 显示「重新查询状态」按钮调 webhookStatus)。

F11 [P3·small·产品] WebhookPanel.vue 初始加载态

  • 证据:onMounted 调 webhookStatus 期间 status=null,无 loading 骨架。
  • 处置:🟡 defer——该期间 busy=true 按钮已 disabled,无误点;加载骨架价值低,不立项。

F12 [P3·small·安全] webhook.rs:438 extract_trycloudflare_url

  • 证据:tok.starts_with("https://") && tok.contains(".trycloudflare.com") 子串匹配,可被 x.trycloudflare.com.evil 之类骗过。
  • 处置:🟡 defer——URL 仅展示给用户、不入任何后端敏感操作;且能控 cloudflared_bin 的用户已有本地代码执行能力,正则收紧收益甚微。

下一步:切 pr-status/needs-review-again(待再审:codex / /pr-review #49;有需改再 /fix #49)。F11/F12 为主动 defer 的 P3,未建 issue(价值低、无红线)。


🤖 PR #49 · Generated with Claude Code · branch Feature/9-webhook-cloudflare · worktree worktrees/Feature/9-webhook-cloudflare · session a6d786c6

@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 16, 2026
@ghbvf

ghbvf commented Jun 16, 2026

Copy link
Copy Markdown
Owner Author

CI 检查结果

状态:通过(已通过 2 / 共 2 个检查)

  • Frontend (vue-tsc + build) — pass (20s)
  • Rust (fmt + clippy + build) — pass (1m34s)

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


🤖 PR #49 · Generated with Claude Code · branch Feature/9-webhook-cloudflare · worktree worktrees/Feature/9-webhook-cloudflare · session a6d786c6

@ghbvf

ghbvf commented Jun 17, 2026

Copy link
Copy Markdown
Owner Author

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

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

根因簇

  • C1 webhook 派发没有一个原子、同源的幂等/有效性边界(维度 架构/可靠性/测试;系统性 Grep 5 处)→ F1,F4,F6
  • C2 对外 webhook URL 契约分裂,后端只接 /webhook,前端展示根 URL(维度 产品;系统性 Grep 4 处)→ F2
  • C3 cloudflared 运行态只由 runtime: Some 推断,未闭合子进程生命周期(维度 可靠性/安全;系统性 Grep 4 处)→ F3,F5

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

  • F1 [P1·large·架构/可靠性] src-tauri/src/pr/webhook.rs:292 — 并发 webhook 可能绕过快照 guard 重复启动 review → 簇 C1
  • F2 [P1·small·产品/用户体验] src/pr/WebhookPanel.vue:116 — UI 指引粘贴根 URL,但后端只接 /webhook → 簇 C2
  • F3 [P2·large·可靠性/生命周期] src-tauri/src/pr/webhook.rs:203 — cloudflared 退出不被 wait/探测,状态可能假 running → 簇 C3
  • F4 [P2·small·可靠性/测试] src-tauri/src/pr/commands.rs:242 — ledger 读取失败时 fail-open,绕过去重/cooldown → 簇 C1
  • F5 [P2·small·可靠性/错误传播] src-tauri/src/pr/commands.rs:262 — start_webhook 未复用 load_validated,端口校验可被手改配置绕过 → 簇 C3
  • F6 [P2·small·可靠性/产品] src-tauri/src/pr/webhook.rs:331 — webhook parser 不检查 PR state/action,closed PR 也可能派发 → 簇 C1
完整详表(证据 + 建议 + 根因 + 方案种子,/fix 读此)

F1 [P1·large·架构/可靠性] src-tauri/src/pr/webhook.rs:292(→ C1)

  • 证据:drop(spawn(dispatcher(vec![candidate]))); detached 每个请求;src-tauri/src/lib.rs:173 只在进入 run_auto_dispatch 时取 state.sessions.active_pairs() 快照;src-tauri/src/dispatch.rs:59 用该快照过滤;src-tauri/src/review/session.rs:201 要等 thread/start 成功后才插入 Starting session。
  • 根因:webhook/redelivery 是并发 push 源,但幂等边界仍是非原子快照,不是 {pr,kind} reservation。
  • 三级方案种子:最小 按 {pr,kind} 串行化 webhook dispatcher / 彻底 review registry 提供 try_reserve_pair + release / 重构 scheduler 与 webhook 共用单一 dispatch queue。

F2 [P1·small·产品/用户体验] src/pr/WebhookPanel.vue:116(→ C2)

  • 证据:前端展示复制 status.publicUrl,文案写「Payload URL 填此 URL」;后端唯一路由是 src-tauri/src/pr/webhook.rs:156 的 .route("/webhook", post(handle_webhook))。
  • 建议:前端展示/复制 ${publicUrl}/webhook,或后端返回专门的 payloadUrl 字段;补 UI/单测锁住该路径。

F3 [P2·large·可靠性/生命周期] src-tauri/src/pr/webhook.rs:203(→ C3)

  • 证据:stop_inner 只 abort server/drain 后依赖 _tunnel drop;status 在 src-tauri/src/pr/webhook.rs:223 只看 Some(rt) 即 running: true;spawn_quick_tunnel 在 URL scan EOF/timeout 后仍 Ok((child, drain_task, url))。
  • 三级方案种子:最小 启动后 try_wait/EOF 检测并返回错误 / 彻底 增加 kill-and-reap 与 status 自愈 / 重构 抽共享子进程 runtime handle,复用 codex 的 kill+wait 模式。

F4 [P2·small·可靠性/测试] src-tauri/src/pr/commands.rs:242(→ C1)

  • 证据:webhook gate 用 Ledger::load(app).unwrap_or_default();轮询 discovery 路径 src-tauri/src/pr/commands.rs:76 是 Ledger::load(app)?,失败不产生 dispatchable。
  • 建议:ledger 读取失败时 fail-closed 返回空候选,并通过 DispatchError 或日志暴露;抽可测 helper 覆盖 Err path。

F5 [P2·small·可靠性/错误传播] src-tauri/src/pr/commands.rs:262(→ C3)

  • 证据:start_webhook 调 config_service::load(&app)? 后只检查 enabled/secret;src-tauri/src/config/model.rs:175 已有 webhook_port == 0 校验,但这里没有复用。
  • 建议:start_webhook 改为 load_validated,或至少复用同一 port 校验,避免手改持久化配置绑定随机端口 0。

F6 [P2·small·可靠性/产品] src-tauri/src/pr/webhook.rs:331(→ C1)

  • 证据:payload_to_candidate 进入 pull_request 后直接读 labels 并生成 candidate;轮询路径 src-tauri/src/pr/gh.rs:171 明确 --state open。
  • 建议:parser 至少要求 pull_request.state == "open",并明确允许的 action 集合(如 labeled/synchronize/reopened),补 closed/merged payload 测试。

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

修复分流:small → /fix #49;large → 需人工决策(方案种子见详表)。PR body 含 Closes #9,以上 webhook 交付问题归属 issue #9。
结论:需修复。Payload URL 指引会让用户配置后完全收不到 GitHub delivery,且 webhook 并发幂等和 cloudflared 生命周期还没有达到自动派发路径需要的可靠性边界。


🤖 PR #49 · Generated with Claude Code · branch Feature/9-webhook-cloudflare · worktree /Users/shengming/Documents/code/prmonitor/worktrees/review-pr49 · 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
ghbvf and others added 3 commits June 17, 2026 10:30
…F1)

根因:dedup 是 snapshot-then-act 非原子——auto_dispatch 取 active_pairs() 快照后才在
thread/start 成功时插入 Starting,两个同 {pr,kind} 的并发 webhook 派发可都取到空快照→双 review。

修复:SessionRegistry 引入同锁的 {pr,kind} 预留(try_reserve_pair test-and-set /
release_pair / promote_reservation 无窗口交接 + RAII ReservationGuard 防泄漏),active_pairs
并集 reserved,把幂等边界从快照升级为原子预留;轮询路径同享该 seam(顺带闭合 overlapping-cycle)。
ReviewEngine::start 改返回 AppResult<Option<SessionId>>:Ok(None)=已去重,dispatch 既不记账也不
报失败,手动命令提示「已在进行中」——类型化去重≠失败(漏改 callsite 不编译 = Hard 锁)。

复杂度:large(改并发语义 + ReviewEngine trait seam)。
测试锁(Medium):try_reserve_pair 原子 test-and-set、promote 无窗口交接、32 并发恰一胜出、
dispatch FakeEngine 锁 Ok(None) 臂。

Refs #49
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…er fail-closed + start_webhook 校验复用(#49 F2/F3/F4/F5/F6)

- F3(large·子进程生命周期):spawn_quick_tunnel 在 URL 未解析且 child 已退出时 fail-fast 返回
  Err(区分「仍在建立」与「已死」);status() 经 try_wait 探活,子进程已退出则自愈(take +
  WebhookRuntime::teardown,报 not-running),镜像 codex kill_and_reap;_tunnel→tunnel。
- F2(后端·small):WebhookStatus 增 payload_url,经 WEBHOOK_PATH 常量从 public_url 单源派生
  (唯一构造器 new(),route 复用同常量;golden 测试锁 payloadUrl ends_with /webhook)。
- F4(small):gate_dispatchable 的 Ledger::load 由 unwrap_or_default(fail-open)改 fail-closed
  (与上方 config load / 轮询 Ledger::load? 对称),抽 gate_candidates 纯函数并单测。
- F5(small):start_webhook 由 load 改 load_validated,复用 webhook_port!=0 / secret 校验。
- F6(small):payload_to_candidate 要求 pull_request.state==open(与轮询 --state open parity),
  closed/merged PR 不再派发。

测试锁(Medium):status 自愈(true bin)、spawn fail-fast(false bin)、gate_candidates、closed payload。

Refs #49
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
后端单源派生的 payloadUrl 镜像进 WebhookStatus(types.ts);面板展示/复制 status.payloadUrl
而非 publicUrl,文案明确「填此完整 URL(已含 /webhook 路径)」——此前指引粘贴隧道根 URL 会让
GitHub delivery 全部 404(后端唯一路由是 /webhook)。

复杂度:small。Refs #49
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 6(已修 small 4 · large 2 · 遗留 0 · OUT_OF_SCOPE 0)· 全部 IN_SCOPE(均在 PR diff,归属 issue #9)

  • F1 [P1·large·架构/可靠性] src-tauri/src/review/session.rs — 并发 webhook 绕过快照 guard 重复 review → ✅ 已修(b2fa1af)
  • F2 [P1·small·产品/UX] src/pr/WebhookPanel.vue + src-tauri/src/pr/webhook.rs — Payload URL 缺 /webhook 后缀,GitHub delivery 全 404 → ✅ 已修(8879361/f4dacca)
  • F3 [P2·large·可靠性/生命周期] src-tauri/src/pr/webhook.rs — cloudflared 退出不被探测,状态假 running → ✅ 已修(8879361)
  • F4 [P2·small·可靠性] src-tauri/src/pr/commands.rs — ledger 读取失败 fail-open,绕过去重/cooldown → ✅ 已修(8879361)
  • F5 [P2·small·可靠性] src-tauri/src/pr/commands.rs — start_webhook 未复用 load_validated,端口校验可绕过 → ✅ 已修(8879361)
  • F6 [P2·small·可靠性/产品] src-tauri/src/pr/webhook.rs — parser 不检查 PR state,closed PR 也派发 → ✅ 已修(8879361)
完整详表(修复手段 + 测试锁,下次 fix / --check 读此)

F1 [P1·large] 并发幂等(→ 簇 C1)

  • 根因:dedup 是 snapshot-then-act 非原子——auto_dispatch 取 active_pairs() 快照后才在 thread/start 成功时插入 Starting,两个同 {pr,kind} 并发派发可都取到空快照→双 review。
  • 修复:SessionRegistry 引入同锁 RegistryState{sessions, reserved} 的 {pr,kind} 预留——try_reserve_pair(test-and-set)/ release_pair / promote_reservation(reserved→Starting 一临界区无窗口交接)+ RAII ReservationGuard(任何 ?/panic 不漏预留);active_pairs 并集 reserved。幂等边界从快照升级为原子预留,dispatchable_after_guard 降为廉价首过滤。轮询路径同享该 seam(顺带闭合 overlapping-cycle 竞态)。
  • B1:ReviewEngine::start → AppResult<Option<SessionId>>,Ok(None)=已去重(类型化,编译器逐 callsite 走查);dispatch.rs 去重既不记账也不报失败,手动 start_review 命令返回「已在进行中」。
  • 测试锁(Medium):try_reserve_pair_is_atomic_test_and_set、promote_reservation_hands_off_without_a_gap、concurrent_reservations_admit_exactly_one(32 并发恰 1 胜出)、dispatch::auto_dispatch_records_started_skips_deduped_reports_failed(FakeEngine 锁 Ok(None) 臂)。

F2 [P1·small] Payload URL 单源(→ 簇 C2)

  • 根因:前端展示/复制 publicUrl(隧道根),后端唯一路由 /webhook → GitHub POST 根 URL 404。
  • 修复:后端单源——const WEBHOOK_PATH 同时供 .route() 与派生;WebhookStatus 增 payload_url,唯一构造器 WebhookStatus::new 从 public_url 派生(不可漂移);TS 镜像 + 前端展示/复制 payloadUrl,文案改「填此完整 URL(含 /webhook)」。
  • 测试锁(Medium):webhook_status_wire_shape_is_camel_case 断言 payloadUrl 存在且 ends_with("/webhook")(路由改名忘改派发即失败)。

F3 [P2·large] cloudflared 生命周期(→ 簇 C3)

  • 根因:spawn_quick_tunnel 在 URL scan timeout/EOF 后仍 Ok((child, _, None))(子进程可能已死);status() 只看 Some(rt) 即 running:true。
  • 修复:spawn_quick_tunnel URL 未解析且 child.try_wait() 已退出 → fail-fast Err;status() 锁内 try_wait 探活,已退出则自愈(take + WebhookRuntime::teardown,报 not-running「隧道已退出…请重新启动」);镜像 codex kill_and_reap;_tunnel→tunnel。
  • 测试锁(Medium·CI-safe):status_self_heals_when_tunnel_child_exited(true bin)、spawn_quick_tunnel_errs_when_child_exits_without_url(false bin)。

F4 [P2·small] ledger fail-closed(→ 簇 C1)

  • 根因:gate_dispatchable 用 Ledger::load(app).unwrap_or_default()——读失败退化空 ledger,cooldown/dedup 全失效;轮询路径是 Ledger::load(app)?(fail-closed)。
  • 修复:改 let Ok(ledger) = … else { return Vec::new() }(与上方 config load / 轮询对称 fail-closed);抽纯函数 gate_candidates 并单测。
  • 测试锁(Medium):gate_candidates_drops_dispatched_and_cooldown_but_keeps_clean。

F5 [P2·small] start_webhook 校验复用(→ 簇 C3)

  • 根因:start_webhook 用 load(无校验)+ 手检,漏 webhook_port==0 等核心校验,手改配置可绑端口 0。
  • 修复:改 load_validated(复用 validate:enabled 时 secret 非空 + port≠0,与 start_polling/start_review 一致);保留 webhook_enabled 检查,删冗余 secret 手检。

F6 [P2·small] parser state==open(→ 簇 C1)

  • 根因:payload_to_candidate 只读 labels,从不检查 pull_request.state;轮询 --state open。closed/merged PR 带触发标签仍产 candidate。
  • 修复:要求 pull_request.state == "open"(与轮询 parity,单一正确性来源;不加 action 白名单以免偏离 parity——ledger 已按 head_sha 去重同提交重复事件)。
  • 测试锁(Medium):payload_to_candidate_skips_non_open_pr(closed/merged/缺 state → None)。

验证:cargo build --locked + cargo test --locked(164 通过)+ cargo clippy --all-targets --locked -- -D warnings(0 告警)+ cargo fmt --check(干净)+ pnpm build + pnpm test(94 通过)全绿。手动 e2e(需 brew install cloudflared)非阻塞。

下一步:切 pr-status/needs-check-fix,待 /pr-review #49 --check 验证(fix 不自证完成)。


🤖 PR #49 · Generated with Claude Code · branch Feature/9-webhook-cloudflare · worktree worktrees/Feature/9-webhook-cloudflare · session —

@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 检查结果

状态:通过(已通过 2 / 共 2 个检查)

  • Frontend (vue-tsc + build) — pass (14s)
  • Rust (fmt + clippy + build) — pass (1m51s)

提交 f4dacca(#49 F1–F6 修复)。Run: https://github.com/ghbvf/prmonitor/actions/runs/27661829922


🤖 PR #49 · Generated with Claude Code

@ghbvf

ghbvf commented Jun 17, 2026

Copy link
Copy Markdown
Owner Author

🔍 pr-review(--check 验证)

Findings 6(已修复 5 · 未修复 0 · 回归 0 · 部分 1)· 结论 需修复

验证表

  • F1 [P1·large·架构/可靠性] src-tauri/src/pr/webhook.rs:292 → ✅ 已修复 — {pr,kind} reservation 已在 await 前 test-and-set,Ok(None) 表达去重。
  • F2 [P1·small·产品/用户体验] src/pr/WebhookPanel.vue:116 → ✅ 已修复 — 前端展示/复制 payloadUrl,后端单源 WEBHOOK_PATH 派生 /webhook。
  • F3 [P2·large·可靠性/生命周期] src-tauri/src/pr/webhook.rs:203 → 🔧 部分 — dead tunnel 误报 running 已修,但 stop/shutdown 仍无显式 wait() reaping。
  • F4 [P2·small·可靠性/测试] src-tauri/src/pr/commands.rs:242 → ✅ 已修复 — ledger 读取失败已 fail-closed,gate_candidates 测试覆盖 dispatched/cooldown。
  • F5 [P2·small·可靠性/错误传播] src-tauri/src/pr/commands.rs:262 → ✅ 已修复 — start_webhook 已走 load_validated。
  • F6 [P2·small·可靠性/产品] src-tauri/src/pr/webhook.rs:331 → ✅ 已修复 — parser 要求 pull_request.state == "open",closed/missing state fail closed。
完整详表(验证证据 + 遗留项)

F1 [P1·large·架构/可靠性] src-tauri/src/pr/webhook.rs:292 → ✅

  • 证据:src-tauri/src/review/session.rs:301 在任何 .await 前调用 registry.try_reserve_pair(pr_number, kind);src-tauri/src/review/session.rs:333 用 promote_reservation 在同一锁内把 reserved 交接成 Starting;src-tauri/src/dispatch.rs:77-84 区分 Ok(Some) 与 Ok(None),dedup 不记账也不报失败。
  • 测试:concurrent_reservations_admit_exactly_one、auto_dispatch_records_started_skips_deduped_reports_failed 通过。

F2 [P1·small·产品/用户体验] src/pr/WebhookPanel.vue:116 → ✅

  • 证据:src-tauri/src/pr/webhook.rs:60 定义 WEBHOOK_PATH = "/webhook",WebhookStatus::new 派生 payload_url;src/pr/WebhookPanel.vue:64,111,117 复制/展示/说明均使用 payloadUrl。
  • 测试:webhook_status_wire_shape_is_camel_case 断言 payloadUrl == "https://x.trycloudflare.com/webhook"。

F3 [P2·large·可靠性/生命周期] src-tauri/src/pr/webhook.rs:203 → 🔧 部分

  • 已修证据:status() 在 src-tauri/src/pr/webhook.rs:271 用 try_wait 探测 dead tunnel,退出后 take runtime 并返回 not-running;spawn_quick_tunnel 在 src-tauri/src/pr/webhook.rs:510-515 对 URL 未解析且 child 已退出 fail-fast。
  • 遗留证据:WebhookRuntime::teardown 在 src-tauri/src/pr/webhook.rs:142-145 仍只是 abort tasks 后 drop Child 依赖 kill_on_drop;同仓 codex 的完整模式在 src-tauri/src/review/engines/codex/process.rs:153-158 是 start_kill() 后 detached wait().await。原 finding 的显式 reaping 部分未闭合。
  • 建议:给 WebhookRuntime 增加 kill_and_reap/teardown 中 start_kill() + detached wait().await,并补 stop/shutdown reaping 测试。

F4 [P2·small·可靠性/测试] src-tauri/src/pr/commands.rs:242 → ✅

  • 证据:src-tauri/src/pr/commands.rs:245-247 对 Ledger::load(app) 失败返回空候选;gate_candidates 抽成纯 helper。
  • 测试:gate_candidates_drops_dispatched_and_cooldown_but_keeps_clean 通过。

F5 [P2·small·可靠性/错误传播] src-tauri/src/pr/commands.rs:262 → ✅

  • 证据:start_webhook 现在 src-tauri/src/pr/commands.rs:287 调 config_service::load_validated(&app)?,复用 webhookSecret/webhookPort 校验。

F6 [P2·small·可靠性/产品] src-tauri/src/pr/webhook.rs:331 → ✅

  • 证据:src-tauri/src/pr/webhook.rs:390-397 要求 pull_request.state == "open",否则不产 candidate。
  • 测试:payload_to_candidate_skips_non_open_pr 通过。

验证命令:

  • cargo test --manifest-path src-tauri/Cargo.toml --locked webhook -- --nocapture
  • cargo test --manifest-path src-tauri/Cargo.toml --locked concurrent_reservations_admit_exactly_one -- --nocapture
  • cargo test --manifest-path src-tauri/Cargo.toml --locked auto_dispatch_records_started_skips_deduped_reports_failed -- --nocapture
  • cargo test --manifest-path src-tauri/Cargo.toml --locked gate_candidates_drops_dispatched_and_cooldown_but_keeps_clean -- --nocapture

结论:需修复。F3 仍有部分未闭合:stop/shutdown teardown 未显式 reap cloudflared 子进程。下一步 /fix #49。


🤖 PR #49 · Generated with Claude Code · branch Feature/9-webhook-cloudflare · worktree /Users/shengming/Documents/code/prmonitor/worktrees/Feature/9-webhook-cloudflare · 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 17, 2026
ghbvf and others added 2 commits June 17, 2026 15:20
webhookTunnelMode/webhookTunnelCommand/webhookPublicUrl 接进 fields 单源;
WebhookPanel 按 store.config 模式调整文案,cloudflared 警告仅 quick 模式。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
接收端只管 bind/验签/派发;隧道如何暴露公网变可配(WebhookTunnelMode)。
command 模式 App spawn 自定义隧道命令({port} 占位、直接 exec 无 shell);
listener 模式只监听、不 spawn,隧道完全外置。public_url 非 quick 模式来自配置。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ghbvf and others added 2 commits June 17, 2026 15:38
payloadUrl 缺失提示按 mode 分流(非 quick 指引填 publicUrl);fields hint 补
command/listener 需填公网 URL;startLabel switch 加 assertNever 穷尽;
WebhookTunnelMode union 由 as const 数组派生,消除与 fields.options 双写。

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

teardown 对 Option<Child> 显式 start_kill+detached wait(镜像 codex kill_and_reap),
闭合遗留 F3 僵尸风险;WebhookRuntime 存 mode,status() 非 quick 模式跳过 cloudflared
探针 + mode 正确文案。补 listener 空 public_url 测试。

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 3(架构+测试 / 安全+产品 / 可靠性+DX,聚焦本次 702 行隧道解耦)· Findings 9(已修 8:large 1 + small 7 · 遗留 0 · OUT_OF_SCOPE 1 → #51)

本 ship 在 PR49 分支上把 webhook 接收端与隧道解耦成三模式(quick/command/listener),并一并闭合上轮 --check 遗留的 P1 F3(teardown reaping)。

  • G1 [P1·large·可靠性] src-tauri/src/pr/webhook.rs:179 — teardown 仍靠 kill_on_drop、无显式 reaping(遗留 F3,僵尸风险)→ ✅ 已修(12359b3,显式 start_kill+detached wait,镜像 codex kill_and_reap)
  • G2 [P2·small·安全] src-tauri/src/pr/webhook.rs:391 — crashed 消息硬写 "cloudflared",command 模式误导 → ✅ 已修(12359b3,按 mode 区分文案)
  • G3 [P2·small·可靠性] src-tauri/src/pr/webhook.rs:384 — status() 对 command/listener 仍跑 5s cloudflared 探针 + nag 安装 → ✅ 已修(12359b3,仅 quick 探针)
  • G4 [P2·small·产品] src/pr/WebhookPanel.vue:152 — payloadUrl=null 提示"cloudflared 建立中",非 quick 应指引填 publicUrl → ✅ 已修(712ce6e,按 mode 分流)
  • G5 [P2·small·产品] src/config/fields.ts:106 — mode hint 没提示 command/listener 需填 publicUrl(易陷哑状态)→ ✅ 已修(712ce6e,含 webhookPort hint 修准)
  • G6 [P2·small·测试] src-tauri/src/pr/webhook.rs — listener 模式空 public_url 路径无测试 → ✅ 已修(12359b3,新增测试)
  • G7 [P2·small·架构·enforcement] src/config/types.ts:10 — WebhookTunnelMode TS union vs Rust enum = Soft 下游开口(serde funnel 非闭环)→ 🚦 OUT_OF_SCOPE(既有系统性,已建 config Rust↔TS wire 契约 Hard 化:codegen 派生 src/config/types.ts(关闭 serde funnel 下游开口) #51;详见 pm:oos)
  • G8 [P3·small·维护] src/pr/WebhookPanel.vue:44 — startLabel switch 缺 assertNever 穷尽 → ✅ 已修(712ce6e,对齐 charter Medium)
  • G9 [P3·small·DX] src/config/types.ts — mode union 与 fields.options 双写 → ✅ 已修(712ce6e,as const 派生单源,Hard 化)

安全面整体判定干净(不过 shell、port u16 无注入、只绑 127.0.0.1、HMAC 未动);架构判定 WebhookTunnelMode 归 model.rs 正确(对齐 SourceKind/EngineKind 先例)。三 reviewer 对 status 模式感知问题独立三方命中。

完整详表(根因 + 证据 + 处置)

G1 [P1·large·可靠性] src-tauri/src/pr/webhook.rs:179(闭合遗留 F3)

  • 根因:解耦把 tunnel 改为 Option<Child> 重写了 teardown,但 quick/command 模式有子进程时仍只在 drop 时靠 kill_on_drop 触发 SIGKILL,wait() 从未调用 → 子进程变僵尸直到父进程退出,stop/restart/crash 场景滞留。
  • 证据:原 teardown self.tunnel dropped here → kill_on_drop;对标完整模式 src-tauri/src/review/engines/codex/process.rs:153 kill_and_reap(start_kill + detached wait)。
  • 处置:✅ 已修 12359b3 — teardown 对 Some(child) 显式 start_kill() + spawn(async move { child.wait().await });新增 stop_kills_and_reaps_tunnel_child 测试(起 sleep 300 子进程→stop→kill -0 轮询证实回收)。

G2/G3 [P2·small·安全+可靠性] src-tauri/src/pr/webhook.rs:384,391

  • 根因:WebhookRuntime 不存 mode → status() 对所有模式无差别跑 cloudflared 探针、crashed/not-running 消息硬写 cloudflared。
  • 处置:✅ 12359b3 — Runtime 加 mode: WebhookTunnelMode;status() 加 configured_mode 参(运行用 runtime.mode、停止用 configured_mode),仅 Quick 跑探针 + brew nag,crashed 文案按 mode 区分;commands.rs 两处 call-site 传 cfg.webhook_tunnel_mode;新增 status_stopped_non_quick_skips_probe_and_neutral_message。

G4 src/pr/WebhookPanel.vue:152:✅ 712ce6e — payloadUrl 缺失提示按 mode 分流(quick→cloudflared 建立中;command/listener→指引填 webhookPublicUrl 并重启)。

G5 src/config/fields.ts:106:✅ 712ce6e — webhookTunnelMode hint 补 "command/listener 需同填公网 URL";webhookPort hint "仅 cloudflared 本地可达"→"仅经隧道公网可达"。

G6 src-tauri/src/pr/webhook.rs:✅ 12359b3 — 新增 listener_mode_empty_public_url_reports_none(空 publicUrl → running:true, public_url:None, payload_url:None)。

G8 src/pr/WebhookPanel.vue:44:✅ 712ce6e — startLabel switch 显式 case "quick" + default: assertNever(mode)(assertNever 落 src/types.ts 共享位),编译期穷尽。

G9 src/config/types.ts:✅ 712ce6e — WEBHOOK_TUNNEL_MODES as const 单源派生 union,fields.options 复用之,消除双写(Hard 化 TS 内 options↔union)。

G7(OUT_OF_SCOPE,详见独立 pm:oos):Rust↔TS config wire 契约下游 Soft 开口,既有系统性(每个 config 字段皆然),已建 #51 跟踪 codegen Hard 化。

下一步:切 pr-status/needs-review-again(待再审:codex / /pr-review #49;有需改再 /fix #49)。本轮已闭合 PR49 唯一遗留阻塞 F3 + 解耦 reviewer 新增 8 项。


🤖 PR #49 · Generated with Claude Code · branch Feature/9-webhook-cloudflare · worktree worktrees/Feature/9-webhook-cloudflare · session —

@ghbvf

ghbvf commented Jun 17, 2026

Copy link
Copy Markdown
Owner Author

🚦 Out-of-Scope Findings

OOS Findings 1 条(已从 pm:ship 主评论分离,本评论为无损存档;已建 issue)

G7 [P2·small·架构·enforcement] src/config/types.ts:10(🚦 OUT_OF_SCOPE)


🤖 PR #49 · Generated with Claude Code · branch Feature/9-webhook-cloudflare · worktree worktrees/Feature/9-webhook-cloudflare · session —

@ghbvf ghbvf removed the pr-status/needs-fix review 出 changes-requested,待 /fix label Jun 17, 2026
@ghbvf ghbvf added the pr-status/needs-review-again ship 交接后首审一次(review changes-requested 后转 needs-fix) label Jun 17, 2026
@ghbvf

ghbvf commented Jun 17, 2026

Copy link
Copy Markdown
Owner Author

CI 检查结果

状态:通过(已通过 2 / 共 2 个检查)

  • Frontend (vue-tsc + build) — pass 21s
  • Rust (fmt + clippy + build) — pass 1m45s

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


🤖 PR #49 · Generated with Claude Code · branch Feature/9-webhook-cloudflare · worktree worktrees/Feature/9-webhook-cloudflare · session —

@ghbvf

ghbvf commented Jun 17, 2026

Copy link
Copy Markdown
Owner Author

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

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

根因簇

  • C1 Webhook runtime 只有“已登记 Running”,缺少可取消/可等待的 Starting 生命周期(维度 可靠性/生命周期、产品;系统性 Grep 多处:runtime/stop_inner/teardown/drain_task/status)→ F1,F3,F4
  • C2 公网 webhook 验证只证明“知道 secret”,没有把事件归属绑定到当前配置仓库(维度 安全/健壮;系统性 Grep:WebhookCtx/payload_to_candidate 未携带 repo)→ F2,F8
  • C3 前端 config 与 pr slice 互相 import,控制面板读取已保存 store 而不是同屏 draft(维度 架构/切片边界、产品/用户体验;系统性 Grep 2 处横向 import)→ F5,F6
  • C4 Payload URL 拼接缺少 URL 归一化(维度 测试/回归、产品;系统性 Grep:payloadUrl 单点拼接且缺尾斜杠测试)→ F7
  • C5 ReviewEngine trait seam 用 Option 表达 dedup outcome,语义靠注释(维度 可维护性/DX、架构 enforcement;系统性 Grep 多处 Ok(None) callsite/test)→ F9

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

  • F1 [P1·large·可靠性/生命周期] src-tauri/src/pr/webhook.rs:312 — 启动中 runtime 尚未登记,stop/shutdown 无法取消已 bind/spawn 的接收端和隧道 → 簇 C1
  • F2 [P1·small·安全/健壮] src-tauri/src/pr/webhook.rs:493 — HMAC 通过后未校验 payload 仓库归属,误配/复用 secret 可跨仓触发当前仓库 review → 簇 C2
  • F5 [P1·large·架构/切片边界] src/pr/WebhookPanel.vue:11 — pr 组件直接 import config store,且 config 页面反向挂载 pr 组件,形成横向依赖 → 簇 C3
  • F3 [P2·large·可靠性/生命周期] src-tauri/src/pr/webhook.rs:237 — restart 同步 abort 后立刻 re-bind,同端口释放未等待,可能偶发 address in use → 簇 C1
  • F4 [P2·large·可靠性/生命周期] src-tauri/src/pr/webhook.rs:670 — Quick Tunnel 超时后 drain 丢弃后续 URL,刷新状态永远拿不到迟到的公网地址 → 簇 C1
  • F6 [P2·small·产品/用户体验] src/pr/WebhookPanel.vue:24 — 启动按钮只看已保存 config,同屏 draft 未保存时会按旧端口/secret/mode 启动 → 簇 C3
  • F7 [P2·small·测试/回归] src-tauri/src/pr/webhook.rs:107 — 手填公网 URL 若带尾斜杠会生成 //webhook,与服务端 /webhook 路由不一致 → 簇 C4
  • F8 [P2·small·安全/健壮] src-tauri/src/config/model.rs:185 — webhook secret 只校验非空,低熵值也可暴露公网端点 → 簇 C2
  • F9 [P2·large·可维护性/DX] src-tauri/src/review/engine.rs:21 — Ok(None) 表示 dedup 的 trait seam 是 Soft 语义约定,新增 engine 可误用 → 簇 C5
完整详表(证据 + 建议 + 根因 + 方案种子,/fix 读此)

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

  • 证据:*self.runtime.lock().unwrap() = Some(WebhookRuntime { ... }) 发生在 bind、server_task spawn、Quick Tunnel URL await 之后;stop_inner 只能 take 已登记 runtime。
  • 建议:引入 Starting/Running 生命周期或 provisional runtime;stop/shutdown 必须能取消启动中的 server/child,并阻止启动完成后重新写回 running。
  • 三级方案种子:最小 登记启动中句柄并加取消标记 / 彻底 async 状态机 + teardown await / 重构 receiver 与 tunnel supervisor 分层。

F2 [P1·small·安全/健壮] src-tauri/src/pr/webhook.rs:493(→ C2)

  • 证据:payload_to_candidate(&payload, &ctx.review_label, &ctx.check_label) 没有接收配置 repo;函数内只用 full_name 判断 fork,不校验事件仓库等于当前配置仓库。
  • 建议:把 cfg.repo 放入 WebhookCtx,验签后校验 repository.full_name 和/或 pull_request.base.repo.full_name 必须等于配置仓库;缺失/不匹配 fail closed,并补测试。

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

  • 证据:self.stop_inner(); 后紧接 TcpListener::bind(("127.0.0.1", port)).await;teardown 只 abort() server task,没有等待 listener drop。
  • 建议:命令 restart 走 async teardown,await server task 结束或 graceful shutdown 完成后再 bind;退出事件可保留 best-effort sync path。
  • 三级方案种子:最小 stop 后 await JoinHandle / 彻底 shutdown token + await closed / 重构独立 receiver supervisor。

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

  • 证据:URL timeout 后 url = None,随后 drain task while let Ok(Some(_)) = lines.next_line().await {} 丢弃全部后续 stderr;status() 只 clone rt.public_url。
  • 建议:drain 继续扫描 URL 并写共享状态,或将 URL scanner 作为 runtime 后台任务,status 读取最新值。
  • 三级方案种子:最小 drain 中扫描并更新 Arc<Mutex<Option>> / 彻底 tunnel 状态机 / 重构 tunnel driver 抽象。

F5 [P1·large·架构/切片边界] src/pr/WebhookPanel.vue:11(→ C3)

  • 证据:import { useConfigStore } from "../config/useConfigStore";,同时 src/config/SettingsView.vue:12 import ../pr/WebhookPanel.vue;rg 显示这是目前唯一前端 slice-to-slice 依赖。
  • 建议:把跨 slice 组装上移到 composition 层,或让 SettingsView 通过 props 给控制组件传 saved config/dirty/saving,不让 pr slice 读 config 内部 store。
  • Enforcement:现状 Soft(注释/人工 review);建议 Medium:ESLint import-boundary/no-restricted-imports 禁止 src/{config,pr,review} 横向 import,composition root allowlist 例外。
  • 三级方案种子:最小 props 解耦 / 彻底 控制面移到 composition 层 / 重构 加 lint enforcement。

F6 [P2·small·产品/用户体验] src/pr/WebhookPanel.vue:24(→ C3)

  • 证据:enabled = computed(() => store.config?.webhookEnabled === true) 只看已保存 store;同屏 SettingsView 编辑的是本地 draft,保存前 start_webhook 仍用旧持久化配置。
  • 建议:传入 dirty/saving 状态;有未保存 webhook 相关改动或保存中时禁用启动/重启,并显示“先保存当前 Webhook 配置”。

F7 [P2·small·测试/回归] src-tauri/src/pr/webhook.rs:107(→ C4)

  • 证据:format!("{u}{WEBHOOK_PATH}") 对 https://host/ 生成 https://host//webhook;当前测试只覆盖无尾斜杠。
  • 建议:生成前 trim_end_matches('/') 归一化,并补 https://example.com/ → https://example.com/webhook 的 command/listener 覆盖。

F8 [P2·small·安全/健壮] src-tauri/src/config/model.rs:185(→ C2)

  • 证据:启用 webhook 时只检查 webhook_secret.trim().is_empty();"a" 这类低熵值可启动公网端点。
  • 建议:增加最小长度/强度门槛,例如 trim 后至少 20/32 字节;start 时使用 trim 后 secret,并补低熵拒绝测试。

F9 [P2·large·可维护性/DX] src-tauri/src/review/engine.rs:21(→ C5)

  • 证据:async fn start(...) -> AppResult<Option<SessionId>>,Ok(None) 的 dedup 语义由注释约定;dispatch/manual command/codex impl/test 都要记住该约定。
  • 建议:改为显式 StartReviewOutcome { Started(SessionId), Deduped },让新增 engine 和 callsite 通过类型系统表达分支。
  • Enforcement:现状 Soft;建议 Hard,使用 Rust enum 让误用不可表达。
  • 三级方案种子:最小 enum outcome / 彻底 kind newtype + outcome enum / 重构 review command/outcome 全强类型。

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

修复分流:small → /fix #49(C2/F2,F8;C4/F7)← issue #9/#50;large → 需人工决策(C1/C3/C5,方案种子见详表)← issue #9/#50。
结论:需修复;存在 P1 生命周期竞态、安全归属缺口和架构边界回归,当前不建议合并。


🤖 PR #49 · Generated with Claude Code · branch Feature/9-webhook-cloudflare · worktree /Users/shengming/Documents/code/prmonitor/worktrees/Feature/9-webhook-cloudflare · session —

@ghbvf ghbvf added 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
ghbvf and others added 4 commits June 17, 2026 21:52
根因:dedup 结果用 Ok(None) 表达,语义仅靠注释约定(Soft)——新增 engine/调用方可能把 None 误当失败。Option<SessionId> → StartReviewOutcome{Started(SessionId),Deduped},dedup 分支由类型系统表达(Hard:违反不可表达 + 穷尽 match)。

复杂度:large(trait seam 签名变更)。改 engine.rs/codex engine/session.rs/dispatch.rs/review commands + 测试;review 切片内、不跨 model.rs 契约,零行为变更。

Refs #49

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
根因:F1 runtime 仅在 bind+spawn+隧道URL解析后登记,启动期 stop 取消不掉 → stop 改 async 取 start_lock 序列化(被 stop 必兑现)。F3 重启 sync abort 后立即 rebind 撞 address-in-use → teardown 改 graceful shutdown + await server task,bind 加有界重试。F4 隧道URL超时后 drain 丢弃后续行、迟到URL永丢 → public_url 改 Arc<Mutex>,drain 持续扫描写回,status 读最新。F2 验签后未校验事件仓库归属(误配/复用 secret 可跨仓触发)→ WebhookCtx 带 repo,payload_to_candidate fail-closed 校验 repository.full_name==cfg.repo。F7 payload URL 拼接尾斜杠 → //webhook → trim_end_matches('/')。

复杂度:large(并发/进程生命周期重构,对标 codex kill_and_reap + tokio/axum graceful-shutdown)。新增 F2/F3/F4/F7 回归测试(drain 扫描泛型化以可单测)。

Refs #49

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
根因:启用 webhook 时 secret 仅校验非空,低熵值(如单字符)也可暴露公网 HMAC 端点。增 trim 后 ≥16 字符门槛(错误信息保留 webhookSecret 路由前缀,errorToStep 契约不破);start 用 trim 后 secret。复杂度:small。

Refs #49

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
根因:F5 pr/WebhookPanel 直接 import config 的 useConfigStore,config/SettingsView 反向 import WebhookPanel = 双向跨切片依赖(当前唯一 FE 横向边)。composition lift:SettingsView 暴露 webhook scoped slot,App.vue(composition root)填槽并传 savedConfig/draft/saving;WebhookPanel 去 store、改收 props(仅余 type-only AppConfig)。F6 启动按钮仅看已存 config → enabled 增 !dirty && !saving 门,未保存草稿/保存中禁用并提示先保存。

enforcement:新增 vitest slice-boundary 源码扫描测试(Medium)——禁止切片间 value import,允许 type-only + 仅 App.vue/shared 跨切片,附 detector 自测防误判。项目无 ESLint,故用既有 vitest 载体、零新依赖(import.meta.glob ?raw)。复杂度:large(架构/切片边界)。

Refs #49

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 9(已修 small 4 · large 5 · 遗留 0 · OUT_OF_SCOPE 0)· 全部 IN_SCOPE(均在 PR diff,归属 issue #9/#50)

  • F1 [P1·large·可靠性/生命周期] src-tauri/src/pr/webhook.rs — 启动中 runtime 未登记,stop/shutdown 取消不掉在途接收端/隧道 → ✅ 已修
  • F2 [P1·small·安全/健壮] src-tauri/src/pr/webhook.rs — HMAC 通过后未校验事件仓库归属,误配/复用 secret 可跨仓触发 → ✅ 已修
  • F5 [P1·large·架构/切片边界] src/pr/WebhookPanel.vue — pr↔config 双向 import(当前唯一 FE 横向边)→ ✅ 已修
  • F3 [P2·large·可靠性/生命周期] webhook.rs — restart 同步 abort 后立即 rebind,偶发 address-in-use → ✅ 已修
  • F4 [P2·large·可靠性/生命周期] webhook.rs — Quick Tunnel 超时后 drain 丢弃迟到公网 URL → ✅ 已修
  • F6 [P2·small·产品/UX] WebhookPanel.vue — 启动按钮只看已存 config,未保存草稿/保存中被忽略 → ✅ 已修
  • F7 [P2·small·测试/回归] webhook.rs — 手填公网 URL 尾斜杠生成 //webhook → ✅ 已修
  • F8 [P2·small·安全/健壮] config/model.rs — webhook secret 仅校验非空,低熵值可暴露公网端点 → ✅ 已修
  • F9 [P2·large·可维护性/DX] review/engine.rs — Ok(None)=dedup 的 Soft 语义约定 → ✅ 已修
完整详表(修复 + commit + enforcement 评级,下次 /pr-review --check 读此)

簇 C1 webhook 生命周期(F1/F3/F4) — commit fe2384e

  • F1:runtime 仅在 bind + server spawn + 隧道 URL 解析之后登记,启动窗口内 stop/shutdown take 到 None → 用户的 stop 被静默丢弃。修复:stop() 改 async 并取 start_lock,与 start 互斥序列化——启动中发起的 stop 等 start 完成后必把刚登记的 runtime 拆掉(兑现而非丢弃);shutdown()(app-exit,sync)保留 best-effort。
  • F3:restart 走同步 abort 后立即 TcpListener::bind,端口未必释放 → 偶发「address in use」。修复:teardown 改 graceful shutdown(oneshot 信号)+ await server task(对标 tokio/axum 惯例),并对 bind 加有界重试(10×20ms)兜住 OS 释放延迟。
  • F4:Quick Tunnel URL 超时后 url=None、drain task 丢弃后续 stderr,迟到 URL 永丢。修复:public_url 改共享 Arc<Mutex<Option<String>>>,drain 持续扫描并写回,status 读最新值。扫描循环泛型化(AsyncBufRead)以可单测。
  • 测试:restart_on_same_port_rebinds_after_stop(block_on 同 runtime,确定性)、drain_scanning_url_captures_late_url / _keeps_first_resolved_url、既有 self-heal/reap 测试全绿。

簇 C2 安全(F2/F8)

  • F2(commit fe2384e,Medium:runtime guard fail-closed + 测试锁):WebhookCtx 带 repo,start_webhook 传 cfg.repo;payload_to_candidate 验签后校验 repository.full_name(回退 base.repo.full_name)== cfg.repo(大小写不敏感),缺失/不匹配 → 无 candidate。测试 payload_to_candidate_requires_matching_repo(他仓 payload + 合法 HMAC → None)。
  • F8(commit 3c71653,Medium:runtime 校验 + 测试锁):启用 webhook 时 secret 增 trim 后 ≥16 字符门槛(WEBHOOK_SECRET_MIN_LEN),保留 webhookSecret 路由前缀。测试断言短 secret 被拒。

簇 C3 前端切片边界(F5/F6) — commit 45f5c77

  • F5(Medium enforcement:新增 src/slice-boundary.test.ts 源码扫描):composition lift —— SettingsView 暴露 webhook scoped slot(不再 import pr),App.vue(composition root)填槽并传 savedConfig/draft/saving;WebhookPanel 去 useConfigStore、改收 props(仅余 type-only AppConfig)。双向边全消。
    • 载体说明:项目无 ESLint,故用既有 vitest 载体(零新依赖,import.meta.glob ?raw)断言切片间无 value 横向 import(允许 type-only + 仅 App.vue/shared 跨切片),附 detector 自测防误判。
  • F6:enabled 增 !webhookDirty && !saving 门,未保存 webhook 草稿/保存中禁用启动并提示先保存。

簇 C5 trait seam(F9) — commit d1e3679,Hard(违反不可表达)

  • ReviewEngine::start 返回 AppResult<Option<SessionId>> → AppResult<StartReviewOutcome{Started(SessionId),Deduped}>,dedup 由类型系统命名、穷尽 match 强制;改 engine/codex/session/dispatch/commands + 测试,review 切片内、不跨 model.rs 契约,零行为变更。

验证:本地全绿——cargo test 179 + cargo clippy -D warnings + cargo fmt --check;pnpm build(vue-tsc)+ pnpm test 97(含新 slice-boundary 3 测试)。冲突预检 MERGEABLE。

下一步:切 pr-status/needs-check-fix,待 /pr-review #49 --check 验证(fix 不自证完成)。


🤖 PR #49 · Generated with Claude Code · branch Feature/9-webhook-cloudflare · worktree worktrees/Feature/9-webhook-cloudflare · session —

@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
根因:command_mode_self_heals_when_child_exits 用单独 `true`+wait 作为「tunnel 子进程已退出」的时序代理,再单次探一次 status;status 的 try_wait 非阻塞,首探可能仍早于子进程退出 → CI 偶发 running 仍为 true。改为对 status() 有界轮询(≤200×25ms)直到自愈观测到退出,子进程必退故确定性收敛。仅测试改动,无生产逻辑变更。

Refs #49

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

ghbvf commented Jun 17, 2026

Copy link
Copy Markdown
Owner Author

CI 检查结果

状态:通过(已通过 2 / 共 2 个检查)

  • Frontend (vue-tsc + build) — pass (19s)
  • Rust (fmt + clippy + build) — pass (1m32s)

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


🤖 PR #49 · Generated with Claude Code · branch Feature/9-webhook-cloudflare · worktree worktrees/Feature/9-webhook-cloudflare · session —

@ghbvf

ghbvf commented Jun 17, 2026

Copy link
Copy Markdown
Owner Author

🔍 pr-review(--check 验证)

Findings 9 · 已修复 9 · 未修复 0 · 回归 0 · 部分 0 · 结论 可合并

验证表

  • F1 [P1·large·可靠性/生命周期] src-tauri/src/pr/webhook.rs:312 — ✅ 已修复:start 持有 start_lock,restart 先 teardown_awaiting().await,stop() 也等待同一锁后拆掉刚启动的 runtime。
  • F2 [P1·small·安全/健壮] src-tauri/src/pr/webhook.rs:493 — ✅ 已修复:WebhookCtx 带 repo,payload_to_candidate 校验 repository.full_name / base.repo.full_name 与配置仓库一致。
  • F5 [P1·large·架构/切片边界] src/pr/WebhookPanel.vue:11 — ✅ 已修复:WebhookPanel 不再 value import config store;SettingsView 改 scoped slot,App.vue composition root 负责装配。
  • F3 [P2·large·可靠性/生命周期] src-tauri/src/pr/webhook.rs:237 — ✅ 已修复:teardown 使用 graceful shutdown + await server task,bind 增加有界重试,重启同端口有测试覆盖。
  • F4 [P2·large·可靠性/生命周期] src-tauri/src/pr/webhook.rs:670 — ✅ 已修复:public_url 改共享状态,drain task 继续扫描迟到 URL,status() 读取 live URL。
  • F6 [P2·small·产品/用户体验] src/pr/WebhookPanel.vue:24 — ✅ 已修复:面板通过 props 比对 saved config 与 draft,dirty/saving 时禁用启动并提示先保存。
  • F7 [P2·small·测试/回归] src-tauri/src/pr/webhook.rs:107 — ✅ 已修复:payload URL 生成时 trim_end_matches('/'),补尾斜杠测试。
  • F8 [P2·small·安全/健壮] src-tauri/src/config/model.rs:185 — ✅ 已修复:启用 webhook 时 secret trim 后至少 WEBHOOK_SECRET_MIN_LEN,短 secret 测试覆盖。
  • F9 [P2·large·可维护性/DX] src-tauri/src/review/engine.rs:21 — ✅ 已修复:StartReviewOutcome::{Started,Deduped} 替换 Option<SessionId>,callsite 穷尽 match。
完整详表(验证证据)

F1 src-tauri/src/pr/webhook.rs:298 / src-tauri/src/pr/webhook.rs:476

  • 证据:start 全程持有 start_lock,先 take prior runtime 并 teardown_awaiting().await;stop() 同样拿 start_lock,等待在途 start 完成后 teardown,stop 不再丢失。

F2 src-tauri/src/pr/webhook.rs:632 / src-tauri/src/pr/webhook.rs:689

  • 证据:handler 调 payload_to_candidate(&payload, &ctx.repo, ...);parser 读取 top-level repository.full_name,回退 base.repo.full_name,不匹配或缺失直接 None。

F3 src-tauri/src/pr/webhook.rs:245 / src-tauri/src/pr/webhook.rs:338

  • 证据:teardown_awaiting 发送 graceful shutdown 并 await server_task;bind 用 BIND_RETRIES + BIND_RETRY_DELAY;测试 restart_on_same_port_rebinds_after_stop 覆盖。

F4 src-tauri/src/pr/webhook.rs:200 / src-tauri/src/pr/webhook.rs:528 / src-tauri/src/pr/webhook.rs:917

  • 证据:runtime 保存 Arc<Mutex<Option<String>>> public_url;status 读取 live shared URL;drain_scanning_url 持续扫描并写回迟到 URL;测试 drain_scanning_url_captures_late_url 覆盖。

F5 src/config/SettingsView.vue:158 / src/App.vue:164 / src/slice-boundary.test.ts:113

  • 证据:SettingsView 暴露 webhook scoped slot,不 import pr;App.vue 填槽并挂载 WebhookPanel;slice-boundary 测试断言 slice 之间无 runtime value import。

F6 src/pr/WebhookPanel.vue:38 / src/pr/WebhookPanel.vue:59

  • 证据:webhookDirty 比对 saved config 与 live draft 的 webhook 字段;enabled 要求 saved enabled 且非 dirty 且非 saving。

F7 src-tauri/src/pr/webhook.rs:126

  • 证据:format!("{}{WEBHOOK_PATH}", u.trim_end_matches('/'));测试 webhook_status_payload_url_trims_trailing_slash 通过。

F8 src-tauri/src/config/model.rs:190

  • 证据:启用 webhook 后用 trim 后 secret 检查空值和 chars().count() < WEBHOOK_SECRET_MIN_LEN;validate_webhook_fields_only_when_enabled 覆盖短 secret 拒绝。

F9 src-tauri/src/review/engine.rs:27 / src-tauri/src/dispatch.rs:79

  • 证据:trait 返回 StartReviewOutcome;dispatch/manual command/codex impl 均显式 match Started/Deduped,不再有 AppResult<Option<SessionId>>。

验证命令

  • cargo test --manifest-path src-tauri/Cargo.toml --locked → 179 unit tests + 3 integration tests passed,1 ignored。
  • pnpm test -- --run slice-boundary → 97 tests passed(含 src/slice-boundary.test.ts)。

结论:可合并。上一轮 9 个 findings 均已按当前代码实证修复,未发现 /fix 引入的回归。


🤖 PR #49 · Generated with Claude Code · branch Feature/9-webhook-cloudflare · worktree /Users/shengming/Documents/code/prmonitor/worktrees/Feature/9-webhook-cloudflare · session —

@ghbvf ghbvf added pr-status/ready --check 验证全修复,可合并 pr-review/approved review 无需改 and removed pr-status/needs-check-fix /fix 已修,待 /pr-review --check 验证 pr-review/changes-requested review 提出需改项 labels Jun 17, 2026
@ghbvf
ghbvf merged commit 7df2780 into develop Jun 17, 2026
2 checks passed
@ghbvf
ghbvf deleted the Feature/9-webhook-cloudflare 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

1 participant