Repository navigation
feat(pr): webhook 数据源接收端 + Cloudflare Quick Tunnel 公网代理(#9) - #49
Conversation
本地 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>
按三 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>
🛠 ship review + fixreviewer 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。
完整详表(根因 + 证据 + 建议 + 处置,/fix 读此)F1 [P1·small·安全]
F2 [P1·small·产品]
F3 [P2·small·可靠性]
F4 [P2·small·可靠性]
F5 [P2·small·安全]
F6 [P2·small·测试]
F7 [P2·small·DX]
F8 [P3·small·测试]
F9 [P3·small·安全]
F10 [P3·small·产品]
F11 [P3·small·产品]
F12 [P3·small·安全]
下一步:切 🤖 PR #49 · Generated with Claude Code · branch Feature/9-webhook-cloudflare · worktree worktrees/Feature/9-webhook-cloudflare · session a6d786c6 |
CI 检查结果状态:通过(已通过 2 / 共 2 个检查)
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 |
🔍 pr-review(六维度分级审查)根因簇 3 · Findings 6(P0 0·P1 2·P2 4·P3 0 | small 4·large 2)· 结论 需修复 根因簇
Findings(每条带 file:line,/fix 无损提取)
完整详表(证据 + 建议 + 根因 + 方案种子,/fix 读此)F1 [P1·large·架构/可靠性]
F2 [P1·small·产品/用户体验]
F3 [P2·large·可靠性/生命周期]
F4 [P2·small·可靠性/测试]
F5 [P2·small·可靠性/错误传播]
F6 [P2·small·可靠性/产品]
复杂度汇总:按根因簇 small: 1 / large: 2;按 Finding small: 4 / large: 2 修复分流:small → 🤖 PR #49 · Generated with Claude Code · branch Feature/9-webhook-cloudflare · worktree /Users/shengming/Documents/code/prmonitor/worktrees/review-pr49 · session — |
…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>
🔁 fix(findings triage + fix)Findings 6(已修 small 4 · large 2 · 遗留 0 · OUT_OF_SCOPE 0)· 全部 IN_SCOPE(均在 PR diff,归属 issue #9)
完整详表(修复手段 + 测试锁,下次 fix /
|
CI 检查结果状态:通过(已通过 2 / 共 2 个检查)
提交 🤖 PR #49 · Generated with Claude Code |
🔍 pr-review(--check 验证)Findings 6(已修复 5 · 未修复 0 · 回归 0 · 部分 1)· 结论 需修复 验证表
完整详表(验证证据 + 遗留项)F1 [P1·large·架构/可靠性]
F2 [P1·small·产品/用户体验]
F3 [P2·large·可靠性/生命周期]
F4 [P2·small·可靠性/测试]
F5 [P2·small·可靠性/错误传播]
F6 [P2·small·可靠性/产品]
验证命令:
结论:需修复。F3 仍有部分未闭合:stop/shutdown teardown 未显式 reap cloudflared 子进程。下一步 🤖 PR #49 · Generated with Claude Code · branch Feature/9-webhook-cloudflare · worktree /Users/shengming/Documents/code/prmonitor/worktrees/Feature/9-webhook-cloudflare · session — |
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>
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>
🛠 ship review + fixreviewer 3(架构+测试 / 安全+产品 / 可靠性+DX,聚焦本次 702 行隧道解耦)· Findings 9(已修 8:large 1 + small 7 · 遗留 0 · OUT_OF_SCOPE 1 → #51) 本 ship 在 PR49 分支上把 webhook 接收端与隧道解耦成三模式(quick/command/listener),并一并闭合上轮
安全面整体判定干净(不过 shell、port u16 无注入、只绑 127.0.0.1、HMAC 未动);架构判定 完整详表(根因 + 证据 + 处置)G1 [P1·large·可靠性]
G2/G3 [P2·small·安全+可靠性]
G4 G5 G6 G8 G9 G7(OUT_OF_SCOPE,详见独立 pm:oos):Rust↔TS config wire 契约下游 Soft 开口,既有系统性(每个 config 字段皆然),已建 #51 跟踪 codegen Hard 化。 下一步:切 🤖 PR #49 · Generated with Claude Code · branch Feature/9-webhook-cloudflare · worktree worktrees/Feature/9-webhook-cloudflare · session — |
🚦 Out-of-Scope FindingsOOS Findings 1 条(已从 pm:ship 主评论分离,本评论为无损存档;已建 issue) G7 [P2·small·架构·enforcement]
🤖 PR #49 · Generated with Claude Code · branch Feature/9-webhook-cloudflare · worktree worktrees/Feature/9-webhook-cloudflare · session — |
CI 检查结果状态:通过(已通过 2 / 共 2 个检查)
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 — |
🔍 pr-review(六维度分级审查)根因簇 5 · Findings 9(P0 0·P1 3·P2 6·P3 0 | small 4·large 5)· 结论 需修复 根因簇
Findings(每条带 file:line,/fix 无损提取)
完整详表(证据 + 建议 + 根因 + 方案种子,/fix 读此)F1 [P1·large·可靠性/生命周期]
F2 [P1·small·安全/健壮]
F3 [P2·large·可靠性/生命周期]
F4 [P2·large·可靠性/生命周期]
F5 [P1·large·架构/切片边界]
F6 [P2·small·产品/用户体验]
F7 [P2·small·测试/回归]
F8 [P2·small·安全/健壮]
F9 [P2·large·可维护性/DX]
复杂度汇总:按根因簇 small 2 / large 3;按 Finding small 4 / large 5。 修复分流:small → 🤖 PR #49 · Generated with Claude Code · branch Feature/9-webhook-cloudflare · worktree /Users/shengming/Documents/code/prmonitor/worktrees/Feature/9-webhook-cloudflare · session — |
根因: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>
🔁 fix(findings triage + fix)Findings 9(已修 small 4 · large 5 · 遗留 0 · OUT_OF_SCOPE 0)· 全部 IN_SCOPE(均在 PR diff,归属 issue #9/#50)
完整详表(修复 + commit + enforcement 评级,下次 /pr-review --check 读此)簇 C1 webhook 生命周期(F1/F3/F4) — commit
簇 C2 安全(F2/F8)
簇 C3 前端切片边界(F5/F6) — commit
簇 C5 trait seam(F9) — commit
验证:本地全绿—— 下一步:切 🤖 PR #49 · Generated with Claude Code · branch Feature/9-webhook-cloudflare · worktree worktrees/Feature/9-webhook-cloudflare · session — |
根因: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>
CI 检查结果状态:通过(已通过 2 / 共 2 个检查)
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 — |
🔍 pr-review(--check 验证)Findings 9 · 已修复 9 · 未修复 0 · 回归 0 · 部分 0 · 结论 可合并 验证表
完整详表(验证证据)F1
F2
F3
F4
F5
F6
F7
F8
F9
验证命令
结论:可合并。上一轮 9 个 findings 均已按当前代码实证修复,未发现 🤖 PR #49 · Generated with Claude Code · branch Feature/9-webhook-cloudflare · worktree /Users/shengming/Documents/code/prmonitor/worktrees/Feature/9-webhook-cloudflare · session — |
Summary
本地 axum webhook 接收端(绑
127.0.0.1)+ App 托管的 Cloudflare Quick Tunnel(cloudflared子进程),把 GitHub PR 事件即时推进既有auto_dispatch路径——给 PR 加触发标签即秒级触发 review,无需等轮询间隔。Why / 背景
issue #9「webhook 数据源占位 + 可插拔抽象」的真实交付:
PrSourcetrait /SourceKindseam 早已就位,剩余即本地 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(复用Dispatcherseam 类型 +auto_review_enabledgate)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增netfeature。Cargo.lock已更新。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。discover::should_skip/cooldown_skip同等 gating)。不破坏现有轮询路径。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 warnings0 告警cargo fmt --manifest-path src-tauri/Cargo.toml --all -- --check干净cargo test --manifest-path src-tauri/Cargo.toml --locked通过(155 后端)+pnpm test(93 前端)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+ detachedwaitreaping,镜像 codexkill_and_reap)。内置 review 3 reviewer / 9 findings:8 已修(large 1 + small 7),G7(Rust↔TS config wire Soft 下游开口,系统性)→ #51。Closes #50