From fa85b911fcf38aeea33019aaed1db05306b20f30 Mon Sep 17 00:00:00 2001
From: ghbvf <104540935+ghbvf@users.noreply.github.com>
Date: Thu, 18 Jun 2026 03:28:18 +0800
Subject: [PATCH 1/4] =?UTF-8?q?fix(pr):=20webhook=20=E5=85=A5=E7=AB=99?=
=?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=9B=B4=E6=96=B0=20PR=20=E5=88=97=E8=A1=A8?=
=?UTF-8?q?=E5=BF=AB=E7=85=A7=20+=20webhook/poll=20=E8=AF=8A=E6=96=AD?=
=?UTF-8?q?=EF=BC=88#61=20#62=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
#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)
---
src-tauri/src/lib.rs | 43 +-
src-tauri/src/pr/commands.rs | 417 +++++++++++++--
src-tauri/src/pr/ledger.rs | 2 +-
src-tauri/src/pr/registry.rs | 70 +++
src-tauri/src/pr/scheduler.rs | 311 ++++++++++-
src-tauri/src/pr/webhook.rs | 947 ++++++++++++++++++++++++++--------
6 files changed, 1487 insertions(+), 303 deletions(-)
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 717a442..4f4e62d 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -54,28 +54,31 @@ pub fn run() {
Box::pin(run_auto_dispatch(app, project_id, cands))
}
}));
- // Install the WEBHOOK trigger's dispatch hook (#9). The webhook is a
- // second auto-trigger source: its axum handler maps a push payload to a
- // `Candidate` and hands it here. Unlike the scheduler (whose candidates
- // are pre-gated by `build_view`), webhook candidates arrive raw, so this
- // closure applies the parity gates the composition root owns — the same
- // `autoReview` gate the scheduler applies at its call site, then the
- // static/cooldown gates (`gate_dispatchable`) — before reusing the very
- // same `run_auto_dispatch`. Keeping the gates here (not in the handler)
- // is what lets `pr::webhook` stay runtime-agnostic (never names AppHandle).
- state.webhook.set_dispatcher(Arc::new({
+ // Install the WEBHOOK trigger's ingest hook (#9 / #61). The webhook is a
+ // second auto-trigger source: its axum handler parses + routes a push payload
+ // into a `WebhookEvent` and hands it here. The ingest (`pr::commands::ingest_webhook`)
+ // upserts the persisted PR list + emits `prs:updated` (so webhook PRs enter the
+ // list even when autoReview is OFF — the #61 fix), applies that project's
+ // static/cooldown gates (`webhook_view`) + the SAME per-project `autoReview`
+ // gate the scheduler uses, and dispatches the clean candidate by reusing the
+ // very same `run_auto_dispatch` (captured + cloned below, exactly as the
+ // scheduler's dispatcher). Keeping all this in the ingest (not the handler) is
+ // what lets `pr::webhook` stay runtime-agnostic (never names AppHandle); the
+ // ingest also records the terminal delivery diagnostic (#62).
+ let webhook_dispatcher: pr::scheduler::ProjectDispatcher = Arc::new({
let app = app.handle().clone();
- move |project_id: String, cands| {
+ move |project_id, cands| {
+ let app = app.clone();
+ Box::pin(run_auto_dispatch(app, project_id, cands))
+ }
+ });
+ state.webhook.set_ingestor(Arc::new({
+ let app = app.handle().clone();
+ move |ev| {
let app = app.clone();
+ let dispatcher = webhook_dispatcher.clone();
Box::pin(async move {
- // The webhook handler already routed by repo to the owning
- // project (#35); apply that project's autoReview + static/cooldown
- // gates before reusing the same per-project run_auto_dispatch.
- if !pr::scheduler::auto_review_enabled(&app, &project_id) {
- return;
- }
- let gated = pr::commands::gate_dispatchable(&app, &project_id, cands);
- run_auto_dispatch(app, project_id, gated).await;
+ pr::commands::ingest_webhook(&app, &dispatcher, ev).await;
})
}
}));
@@ -104,6 +107,8 @@ pub fn run() {
pr::commands::start_webhook,
pr::commands::stop_webhook,
pr::commands::webhook_status,
+ pr::commands::webhook_deliveries,
+ pr::commands::poll_status,
review::commands::get_codex_status,
review::commands::start_codex,
review::commands::stop_codex,
diff --git a/src-tauri/src/pr/commands.rs b/src-tauri/src/pr/commands.rs
index 33b475d..cb499b8 100644
--- a/src-tauri/src/pr/commands.rs
+++ b/src-tauri/src/pr/commands.rs
@@ -9,7 +9,8 @@ use crate::model::{Candidate, PullRequestView};
use super::discover::{self, MonitorParams};
use super::gh::{gh_auth_status, GhRow, GhStatus, GithubCli};
use super::ledger::{now_epoch, Ledger};
-use super::webhook::WebhookStatus;
+use super::scheduler::{PollStatus, ProjectDispatcher};
+use super::webhook::{DeliveryStatus, IngestIntent, WebhookDelivery, WebhookEvent, WebhookStatus};
/// Annotates one discovered row for the PR list and surfaces its dispatchable
/// [`Candidate`] when nothing gates it. Conflict (both trigger labels) skips
@@ -257,36 +258,105 @@ pub fn set_pr_archived(
Ok(())
}
-/// Apply the SAME static + cooldown gates the poll path applies (via `build_view`)
-/// to `project_id`'s webhook-sourced candidates (#35), so a push trigger has dispatch
-/// parity with that project's scheduler: no draft / fork / disallowed-author /
-/// within-cooldown PR slips through just because it arrived by webhook. Resolves THAT
-/// project's config (authors / cooldown) and loads ITS ledger partition, then filters
-/// each candidate through [`discover::should_skip`] and [`discover::cooldown_skip`].
+/// Annotate a webhook-sourced candidate into a [`PullRequestView`] + dispatch decision
+/// (#61), applying the SAME static + cooldown gates the poll path applies via
+/// [`build_view`] — minus the conflict branch (the caller handles conflict / StatusOnly
+/// upstream from the parsed [`IngestIntent`], so this only ever sees a single-label
+/// candidate). The push-path counterpart to `build_view`: `skip_reason = should_skip(..)
+/// .or_else(|| cooldown_skip(..))`; `dispatchable = skip_reason.is_none().then(..)`, so a
+/// clean candidate dispatches and a gated one (draft / fork / disallowed-author /
+/// within-cooldown) becomes a skipped row with NO dispatch — dispatch parity with that
+/// project's scheduler, so nothing slips through just because it arrived by webhook.
///
-/// BOTH reads fail CLOSED: an unresolvable project / unreadable config OR an
-/// unreadable ledger returns an empty Vec (dispatch nothing), same spirit as
-/// [`super::scheduler::auto_review_enabled`]. The ledger is the dedup/cooldown source
-/// of truth — degrading it to an empty ledger (the prior `unwrap_or_default()`) would
-/// pass EVERY cooldown/dedup gate and re-review storm, so an unprovable "not a recent
+/// The view's title / labels / url come from the webhook payload (passed in by the
+/// caller), so the list row is built without a `gh pr view` round trip.
+///
+/// Pure (no `AppHandle`) so the gate composition is unit-tested without a Tauri handle —
+/// it replaces the deleted `gate_candidates` parity test (it asserts BOTH the static and
+/// the cooldown gate apply; the predicates themselves are tested at their source in
+/// `discover.rs`).
+fn webhook_view(
+ cand: Candidate,
+ title: String,
+ labels: Vec,
+ url: String,
+ params: &MonitorParams,
+ ledger: &Ledger,
+ now: u64,
+) -> (PullRequestView, Option) {
+ let skip_reason = discover::should_skip(&cand, params, ledger)
+ .or_else(|| discover::cooldown_skip(&cand, params, ledger, now));
+ // Clone for dispatch only when it passes every static + cooldown gate; a gated row
+ // contributes a view but no dispatch candidate (parity with `build_view`).
+ let dispatchable = skip_reason.is_none().then(|| cand.clone());
+ let view = PullRequestView {
+ number: cand.number,
+ title,
+ labels,
+ url,
+ kind: cand.kind,
+ skip_reason,
+ };
+ (view, dispatchable)
+}
+
+/// The AppHandle-bound webhook ingest (#61): the body of the [`WebhookIngestor`] the
+/// composition root installs. Takes ONE parsed, routed [`WebhookEvent`] and (a) upserts /
+/// updates the persisted PR list row, (b) emits `prs:updated` so the list reflects the
+/// push WITHOUT waiting for the next poll round (the #61 fix — webhook PRs now enter the
+/// list even when autoReview is off), and (c) dispatches the gated-clean candidate iff
+/// that project's autoReview is on. Records EXACTLY ONE delivery diagnostic at the end
+/// (#62) — the handler records the early-exit classifications, this records the routable
+/// terminal status.
+///
+/// **Fail-closed (parity with the deleted `gate_dispatchable`'s fail-closed reads):** an
+/// unresolvable project / unreadable config OR an unreadable ledger records a `Gated`
+/// delivery with the error message and RETURNS without upsert/emit/dispatch. The ledger
+/// is the dedup/cooldown source of truth — degrading it to an empty ledger would pass
+/// EVERY cooldown/dedup gate and re-review storm, so an unprovable "not a recent
/// duplicate" must fail closed, matching the poll path (`discover` uses
/// `Ledger::load(app, project_id)?`).
///
-/// Called by the composition root's webhook dispatcher closure (`lib.rs`) with the
-/// `project_id` the route matched; the conflict (both-labels) gate already dropped in
-/// `webhook::payload_to_candidate`.
-///
-/// Coverage: the pure predicate composition is unit-tested via [`gate_candidates`];
-/// the predicates themselves at their source (`discover::should_skip` /
-/// `cooldown_skip`). The `AppHandle`-bound load branches run in the live app (a Tauri
-/// `AppHandle` isn't constructible in a plain test).
-pub(crate) fn gate_dispatchable(
+/// Reuses the registry's single serialized write seam ([`registry::mutate_tracked`]) for
+/// the upsert + emit (so this can't interleave with a poll-cycle upsert / `set_pr_archived`
+/// and lose a write) and the scheduler's per-project `auto_review_enabled` gate for the
+/// dispatch decision — the SAME primitives both auto-trigger paths share.
+pub(crate) async fn ingest_webhook(
app: &tauri::AppHandle,
- project_id: &str,
- candidates: Vec,
-) -> Vec {
- let Ok(project) = config_service::project(app, project_id) else {
- return Vec::new();
+ dispatcher: &ProjectDispatcher,
+ ev: WebhookEvent,
+) {
+ let WebhookEvent {
+ project_id,
+ action,
+ repo,
+ number,
+ title,
+ labels,
+ url,
+ intent,
+ } = ev;
+
+ // Resolve config + ledger, failing closed on either error (see the fn doc). On a
+ // failure we record a `Gated` delivery with the error and return without touching the
+ // list — fail-closed parity with the deleted `gate_dispatchable`.
+ let record_failclosed = |app: &tauri::AppHandle, msg: String| {
+ record_webhook_delivery(
+ app,
+ &repo,
+ &action,
+ number,
+ None,
+ DeliveryStatus::Gated,
+ Some(msg),
+ );
+ };
+ let project = match config_service::project(app, &project_id) {
+ Ok(p) => p,
+ Err(e) => {
+ record_failclosed(app, format!("项目配置不可读:{}", e.message));
+ return;
+ }
};
let params = MonitorParams {
repo: project.repo,
@@ -295,30 +365,197 @@ pub(crate) fn gate_dispatchable(
authors: project.authors,
pr_cooldown_seconds: project.pr_cooldown_seconds,
};
- let Ok(ledger) = Ledger::load(app, project_id) else {
- return Vec::new();
+ let ledger = match Ledger::load(app, &project_id) {
+ Ok(l) => l,
+ Err(e) => {
+ record_failclosed(app, format!("ledger 不可读:{}", e.message));
+ return;
+ }
};
- gate_candidates(candidates, ¶ms, &ledger, now_epoch())
+ let now = now_epoch();
+
+ // Build the list-row view + dispatch decision + terminal delivery status from the
+ // parsed intent. `upsert` is the insert-or-update Track path; `update_present` is the
+ // status-only path (refresh an EXISTING row, never insert). The `kind` for a row that
+ // has no candidate falls back to a sensible non-empty string.
+ enum WriteKind {
+ Upsert,
+ UpdatePresent,
+ }
+ let (view, dispatchable, status, message): (
+ PullRequestView,
+ Option,
+ DeliveryStatus,
+ Option,
+ );
+ let write_kind: WriteKind;
+
+ match intent {
+ IngestIntent::Track {
+ candidate: Some(cand),
+ ..
+ } => {
+ let (v, d) = webhook_view(cand, title, labels, url, ¶ms, &ledger, now);
+ // A single trigger label. If the gate passed (skip_reason None) the candidate
+ // is dispatchable — the autoReview gate below decides Dispatched vs ListUpdated;
+ // a gated one (draft/fork/author/cooldown) is a `Gated` row carrying the reason.
+ let (st, msg) = match (&d, &v.skip_reason) {
+ (Some(_), _) => (DeliveryStatus::Dispatched, None),
+ (None, reason) => (DeliveryStatus::Gated, reason.clone()),
+ };
+ view = v;
+ dispatchable = d;
+ status = st;
+ message = msg;
+ write_kind = WriteKind::Upsert;
+ }
+ IngestIntent::Track {
+ candidate: None,
+ conflict: _,
+ } => {
+ // Both trigger labels (conflict): a skipped row, never dispatched. Kind
+ // "review" for the view (the parse picked review for the conflict view).
+ view = PullRequestView {
+ number,
+ title,
+ labels,
+ url,
+ kind: "review".to_string(),
+ skip_reason: Some(discover::BOTH_TRIGGER_LABELS_REASON.to_string()),
+ };
+ dispatchable = None;
+ status = DeliveryStatus::Gated;
+ message = Some(discover::BOTH_TRIGGER_LABELS_REASON.to_string());
+ write_kind = WriteKind::Upsert;
+ }
+ IngestIntent::StatusOnly { reason } => {
+ // Closed/merged or trigger-label-removed: refresh an existing row's status,
+ // never insert, never dispatch. `kind` from the current labels (review/check)
+ // or "review" as a sensible default.
+ let kind = if labels.iter().any(|l| l == ¶ms.review_label) {
+ "review"
+ } else if labels.iter().any(|l| l == ¶ms.check_label) {
+ "check"
+ } else {
+ "review"
+ };
+ // The terminal delivery status distinguishes a closed PR (NotOpen) from a
+ // trigger-label-removed one (NoTriggerLabel) by the reason `parse_delivery` set.
+ let st = if reason == "PR 已关闭或合并" {
+ DeliveryStatus::NotOpen
+ } else {
+ DeliveryStatus::NoTriggerLabel
+ };
+ view = PullRequestView {
+ number,
+ title,
+ labels,
+ url,
+ kind: kind.to_string(),
+ skip_reason: Some(reason.clone()),
+ };
+ dispatchable = None;
+ status = st;
+ message = Some(reason);
+ write_kind = WriteKind::UpdatePresent;
+ }
+ }
+
+ // Persist + emit through the single serialized write seam. The Upsert path always
+ // persists + emits (an upsert always changes the set); the UpdatePresent path persists
+ // + emits ONLY when the row existed (mirrors `set_pr_archived`'s no-op skip), so a
+ // status-only event for an untracked PR is a benign no-op.
+ let emitted = super::registry::mutate_tracked(app, &project_id, |tracked| match write_kind {
+ WriteKind::Upsert => {
+ tracked.upsert(std::slice::from_ref(&view), now);
+ (
+ true,
+ Some(super::registry::project_snapshot(tracked, app, &project_id)),
+ )
+ }
+ WriteKind::UpdatePresent => {
+ if tracked.update_present(&view, now) {
+ (
+ true,
+ Some(super::registry::project_snapshot(tracked, app, &project_id)),
+ )
+ } else {
+ (false, None) // untracked PR — nothing changed, skip persist + emit.
+ }
+ }
+ });
+ match emitted {
+ Ok(Some(list)) => {
+ let _ = app.emit(
+ crate::events::PRS_UPDATED_EVENT,
+ &crate::events::PrEvent::Updated {
+ project_id: project_id.clone(),
+ prs: list,
+ },
+ );
+ }
+ // Persist no-op (untracked status-only PR) — nothing to emit.
+ Ok(None) => {}
+ // A store failure leaves the list unchanged; the delivery diagnostic below still
+ // records the (would-be) terminal status so the panel surfaces the event.
+ Err(_) => {}
+ }
+
+ // Dispatch the gated-clean candidate iff autoReview is on (the SAME per-project gate
+ // the scheduler applies at its call site). Detached spawn, mirroring the scheduler's
+ // detached dispatch (a stop must not cancel a start in flight).
+ let mut final_status = status;
+ if let Some(cand) = dispatchable {
+ if super::scheduler::auto_review_enabled(app, &project_id) {
+ drop(tauri::async_runtime::spawn(dispatcher(
+ project_id.clone(),
+ vec![cand],
+ )));
+ final_status = DeliveryStatus::Dispatched;
+ } else {
+ // A clean candidate but autoReview off: the list was updated, no dispatch —
+ // by design (#61: webhook PRs enter the list even with autoReview off).
+ final_status = DeliveryStatus::ListUpdated;
+ }
+ }
+
+ // Record the single terminal delivery diagnostic (#62) for this routable event.
+ record_webhook_delivery(
+ app,
+ &repo,
+ &action,
+ number,
+ Some(view.kind),
+ final_status,
+ message,
+ );
}
-/// Drop candidates a fresh `should_skip` / `cooldown_skip` rejects against `ledger`.
-/// Split from [`gate_dispatchable`] so the predicate composition is unit-testable
-/// without an `AppHandle` — locking that the webhook gate applies BOTH the static and
-/// the cooldown gate (the predicates themselves are tested at their source in
-/// `discover.rs`).
-fn gate_candidates(
- candidates: Vec,
- params: &MonitorParams,
- ledger: &Ledger,
- now: u64,
-) -> Vec {
- candidates
- .into_iter()
- .filter(|c| {
- discover::should_skip(c, params, ledger).is_none()
- && discover::cooldown_skip(c, params, ledger, now).is_none()
- })
- .collect()
+/// Record one webhook-delivery diagnostic into the manager's ring (#62) via `AppState`.
+/// Helper so `ingest_webhook`'s several record sites (fail-closed + terminal) stay one
+/// liners and never leak the secret/token into the diagnostic.
+fn record_webhook_delivery(
+ app: &tauri::AppHandle,
+ repo: &str,
+ action: &Option,
+ number: u64,
+ kind: Option,
+ status: DeliveryStatus,
+ message: Option,
+) {
+ use tauri::Manager;
+ app.state::()
+ .webhook
+ .record_delivery(WebhookDelivery {
+ received_at_epoch: now_epoch(),
+ event: "pull_request".to_string(),
+ action: action.clone(),
+ repo: Some(repo.to_string()),
+ pr_number: Some(number),
+ kind,
+ status,
+ message,
+ });
}
/// Starts the webhook receiver + Cloudflare Quick Tunnel. Requires `webhook_enabled`
@@ -409,6 +646,29 @@ pub async fn webhook_status(
.await)
}
+/// Snapshot of the webhook-delivery diagnostics ring (#62) for the settings panel —
+/// the recent window of "did GitHub reach us, and what did we do with each delivery".
+/// Oldest→newest; capped at the manager's ring size. Never carries the secret/token.
+#[tauri::command]
+pub async fn webhook_deliveries(
+ state: tauri::State<'_, crate::state::AppState>,
+) -> AppResult> {
+ Ok(state.webhook.deliveries_snapshot())
+}
+
+/// Reports `project_id`'s poll-loop status (#62) for the settings panel: whether the
+/// loop is running, its resolved interval, and the last cycle's diagnostics (started /
+/// success / error / persist epochs + discovered count). Pulled on demand — NO new event
+/// type, so the `events.rs` union stays untouched.
+#[tauri::command]
+pub async fn poll_status(
+ app: tauri::AppHandle,
+ state: tauri::State<'_, crate::state::AppState>,
+ project_id: &str,
+) -> AppResult {
+ Ok(state.scheduler.poll_status(&app, project_id))
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -505,8 +765,13 @@ mod tests {
assert!(cand.is_none());
}
+ // Migrated from the deleted `gate_candidates` parity test: `webhook_view` (#61) must
+ // apply BOTH the static (already-dispatched) and the cooldown gate to a webhook-sourced
+ // candidate — a clean one dispatches (Some), a gated one is a skipped row (None) — so a
+ // push trigger has dispatch parity with the poll path. The predicates themselves are
+ // tested at their source in `discover.rs`.
#[test]
- fn gate_candidates_drops_dispatched_and_cooldown_but_keeps_clean() {
+ fn webhook_view_applies_both_static_and_cooldown_gates() {
use crate::pr::ledger::{dispatch_key, DispatchEvent};
use std::collections::HashSet;
@@ -527,10 +792,56 @@ mod tests {
}],
};
- // Locks that the webhook gate applies BOTH the static (dispatched) and the
- // cooldown gate — only the clean candidate survives.
- let kept = gate_candidates(vec![clean, dispatched, cooled], ¶ms(), &ledger, 1_500);
- assert_eq!(kept.len(), 1);
- assert_eq!(kept[0].number, 1);
+ let meta = |n: u64| {
+ (
+ format!("PR {n}"),
+ vec!["review-label".to_string()],
+ format!("https://x/{n}"),
+ )
+ };
+
+ // Clean candidate → no skip_reason → dispatchable Some.
+ let (v1, d1) = {
+ let (t, l, u) = meta(1);
+ webhook_view(clean, t, l, u, ¶ms(), &ledger, 1_500)
+ };
+ assert_eq!(v1.number, 1);
+ assert_eq!(v1.title, "PR 1");
+ assert_eq!(v1.skip_reason, None);
+ assert!(d1.is_some(), "a clean candidate is dispatchable");
+
+ // Already-dispatched (static gate) → skip_reason Some → dispatchable None.
+ let (v2, d2) = {
+ let (t, l, u) = meta(2);
+ webhook_view(dispatched, t, l, u, ¶ms(), &ledger, 1_500)
+ };
+ assert!(
+ v2.skip_reason
+ .as_deref()
+ .is_some_and(|r| r.contains("already dispatched")),
+ "static gate fires: {:?}",
+ v2.skip_reason
+ );
+ assert!(
+ d2.is_none(),
+ "a statically-gated candidate is not dispatchable"
+ );
+
+ // Within cooldown (cooldown gate) → skip_reason Some → dispatchable None.
+ let (v3, d3) = {
+ let (t, l, u) = meta(3);
+ webhook_view(cooled, t, l, u, ¶ms(), &ledger, 1_500)
+ };
+ assert!(
+ v3.skip_reason
+ .as_deref()
+ .is_some_and(|r| r.contains("within cooldown")),
+ "cooldown gate fires: {:?}",
+ v3.skip_reason
+ );
+ assert!(
+ d3.is_none(),
+ "a cooldown-gated candidate is not dispatchable"
+ );
}
}
diff --git a/src-tauri/src/pr/ledger.rs b/src-tauri/src/pr/ledger.rs
index e98feb3..6bb9af5 100644
--- a/src-tauri/src/pr/ledger.rs
+++ b/src-tauri/src/pr/ledger.rs
@@ -140,7 +140,7 @@ impl Ledger {
/// `has_dispatched` / cooldown check for one project never sees another's records.
///
/// **Lock-free read (intentional).** The discovery path (`commands::discover`) and
- /// the webhook gate (`commands::gate_dispatchable`) call this OUTSIDE
+ /// the webhook ingest (`commands::ingest_webhook` → `webhook_view`) call this OUTSIDE
/// [`LEDGER_WRITE_LOCK`]; a load is a single whole-value store read (no torn read)
/// and a stale-by-one-round snapshot is acceptable because it only gates an
/// OPTIMIZATION — the real double-dispatch backstop is the session registry's
diff --git a/src-tauri/src/pr/registry.rs b/src-tauri/src/pr/registry.rs
index d331652..164aacd 100644
--- a/src-tauri/src/pr/registry.rs
+++ b/src-tauri/src/pr/registry.rs
@@ -193,6 +193,32 @@ impl TrackedPrs {
false
}
}
+
+ /// Refreshes an EXISTING tracked row's display fields (title / labels / url / kind /
+ /// skip_reason) and bumps its `last_seen_epoch` to `now`, returning `true`. Returns
+ /// `false` and inserts NOTHING when no row with `view.number` exists.
+ ///
+ /// The status-only counterpart to [`Self::upsert`] (#61): a webhook event that should
+ /// update an ALREADY-TRACKED PR's status (a closed/merged PR, or one whose trigger
+ /// label was removed) without conjuring a brand-new row for a PR the poll path never
+ /// surfaced. `first_seen_epoch` / `archived` are preserved (same as the upsert hit
+ /// path). The caller skips persist + re-emit when this returns `false` (mirroring
+ /// `set_archived`'s unknown-number no-op), so a status-only event for an untracked PR
+ /// is a benign no-op rather than a phantom insert.
+ pub fn update_present(&mut self, view: &PullRequestView, now: u64) -> bool {
+ if let Some(existing) = self.prs.iter_mut().find(|p| p.number == view.number) {
+ existing.title = view.title.clone();
+ existing.labels = view.labels.clone();
+ existing.url = view.url.clone();
+ existing.kind = view.kind.clone();
+ existing.skip_reason = view.skip_reason.clone();
+ existing.last_seen_epoch = now;
+ // first_seen_epoch and archived are preserved (parity with the upsert hit).
+ true
+ } else {
+ false
+ }
+ }
}
/// The single serialized read-modify-write seam for the persisted set (F1). Holds
@@ -415,6 +441,50 @@ mod tests {
assert_eq!(t.prs.len(), 1);
}
+ #[test]
+ fn update_present_hit_refreshes_fields_and_bumps_last_seen() {
+ // #61 status-only path: an existing row is refreshed + its presence clock bumped,
+ // preserving first_seen_epoch + archived (parity with the upsert hit path).
+ let mut t = TrackedPrs::default();
+ t.upsert(&[view(1, "old title")], 1_000);
+ t.set_archived(1, true);
+
+ let mut refreshed = view(1, "new title");
+ refreshed.skip_reason = Some("PR 已关闭或合并".to_string());
+ refreshed.labels = vec!["closed-now".to_string()];
+ assert!(
+ t.update_present(&refreshed, 2_000),
+ "an existing row updates and returns true"
+ );
+
+ assert_eq!(t.prs.len(), 1, "update_present must not insert on a hit");
+ let pr = &t.prs[0];
+ assert_eq!(pr.title, "new title", "display fields refresh");
+ assert_eq!(pr.labels, vec!["closed-now".to_string()]);
+ assert_eq!(pr.skip_reason.as_deref(), Some("PR 已关闭或合并"));
+ assert_eq!(pr.first_seen_epoch, 1_000, "first_seen_epoch preserved");
+ assert_eq!(pr.last_seen_epoch, 2_000, "last_seen_epoch bumped");
+ assert!(
+ pr.archived,
+ "archived preserved across a status-only update"
+ );
+ }
+
+ #[test]
+ fn update_present_miss_returns_false_and_does_not_insert() {
+ // A status-only event for a PR the poll path never surfaced is a benign no-op:
+ // no row exists, so nothing is inserted and the caller skips persist + emit.
+ let mut t = TrackedPrs::default();
+ t.upsert(&[view(1, "PR one")], 1_000);
+
+ assert!(
+ !t.update_present(&view(999, "ghost"), 2_000),
+ "an unknown number returns false"
+ );
+ assert_eq!(t.prs.len(), 1, "no insert on a miss");
+ assert!(t.prs.iter().all(|p| p.number != 999));
+ }
+
#[test]
fn to_view_list_current_within_grace_and_stale_beyond() {
let t = TrackedPrs {
diff --git a/src-tauri/src/pr/scheduler.rs b/src-tauri/src/pr/scheduler.rs
index 9fbe20c..c81d5c9 100644
--- a/src-tauri/src/pr/scheduler.rs
+++ b/src-tauri/src/pr/scheduler.rs
@@ -42,8 +42,9 @@ use std::pin::Pin;
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Duration;
+use serde::Serialize;
use tauri::async_runtime::JoinHandle;
-use tauri::Emitter; // for app.emit
+use tauri::{AppHandle, Emitter}; // Emitter for app.emit
use tokio::sync::Notify;
use tokio::time::MissedTickBehavior;
@@ -54,6 +55,83 @@ use crate::model::{Candidate, TrackedPrView};
use super::registry;
+/// Internal poll-loop diagnostics (#62): timestamps + counters the panel reads to
+/// answer "is the loop alive, when did it last run, and what happened". Pure data with
+/// pure mutators (unit-tested below) — the `Scheduler` owns one behind an `Arc`
+/// and the cycle updates it; `SchedulerSet::poll_status` snapshots it into the wire
+/// [`PollStatus`]. NO new event type: the frontend pulls this via the `poll_status`
+/// command, keeping the `events.rs` union untouched. `pub(crate)` only so the
+/// `pub(crate)` [`Scheduler::poll_diag`] accessor's return type is visibility-consistent;
+/// it is not a public surface — the public wire type is [`PollStatus`].
+#[derive(Debug, Clone, Default)]
+pub(crate) struct PollDiag {
+ /// Epoch of the most recent cycle entry (a tick / wake / reconfigure fired a cycle).
+ last_started_epoch: Option,
+ /// Epoch of the most recent successful discovery (discover returned `Ok`).
+ last_success_epoch: Option,
+ /// Epoch of the most recent discovery error.
+ last_error_epoch: Option,
+ /// The most recent discovery error message (kept until the next error overwrites it).
+ last_error_message: Option,
+ /// Epoch of the most recent SUCCESSFUL persist of the round's list.
+ last_persist_epoch: Option,
+ /// PR count discovered in the most recent successful cycle.
+ last_discovered_count: Option,
+}
+
+impl PollDiag {
+ /// A cycle started (entered the body). Bumps `last_started_epoch`.
+ fn mark_started(&mut self, now: u64) {
+ self.last_started_epoch = Some(now);
+ }
+
+ /// Discovery succeeded with `count` rows. Records the success epoch + count.
+ fn mark_discovered(&mut self, count: u64, now: u64) {
+ self.last_success_epoch = Some(now);
+ self.last_discovered_count = Some(count);
+ }
+
+ /// The round's list persisted successfully. Records the persist epoch.
+ fn mark_persist(&mut self, now: u64) {
+ self.last_persist_epoch = Some(now);
+ }
+
+ /// Discovery (or persist) failed. Records the error epoch + message.
+ fn mark_error(&mut self, msg: String, now: u64) {
+ self.last_error_epoch = Some(now);
+ self.last_error_message = Some(msg);
+ }
+}
+
+/// Poll-loop status reported to the settings panel (#62), pulled via the `poll_status`
+/// command (NOT a new event type — the `events.rs` union stays untouched). `running` +
+/// `interval_secs` describe the loop; the rest mirror [`PollDiag`]'s last-cycle fields.
+///
+/// camelCase wire type mirrored in `src/pr/types.ts` (Medium carrier per
+/// `.claude/rules/prmonitor/ai-robust.md`; a `poll_status_wire_shape_*` golden test pins
+/// the key shape so a rename can't silently drift the TS mirror). pr-slice-private (not a
+/// cross-slice contract), same placement as [`super::webhook::WebhookStatus`].
+#[derive(Debug, Clone, Serialize, Default)]
+#[serde(rename_all = "camelCase")]
+pub struct PollStatus {
+ /// Whether this project's poll loop is currently running.
+ pub running: bool,
+ /// The resolved poll period (secs) — that project's `poll_interval_secs`, clamped.
+ pub interval_secs: u64,
+ /// Epoch of the most recent cycle entry.
+ pub last_started_epoch: Option,
+ /// Epoch of the most recent successful discovery.
+ pub last_success_epoch: Option,
+ /// Epoch of the most recent discovery error.
+ pub last_error_epoch: Option,
+ /// The most recent discovery error message.
+ pub last_error_message: Option,
+ /// Epoch of the most recent successful persist.
+ pub last_persist_epoch: Option,
+ /// PR count discovered in the most recent successful cycle.
+ pub last_discovered_count: Option,
+}
+
/// Abstract per-cycle dispatch hook: consumes a cycle's `project_id` plus its
/// dispatchable [`Candidate`]s and drives them to completion (in practice:
/// auto-start their reviews concurrently). The leading `project_id` (#35) is the
@@ -85,6 +163,11 @@ pub struct Scheduler {
/// `#[derive(Default)]` still holds) — a `None` dispatcher means a cycle discovers
/// + emits but starts no reviews (the pre-#8 behavior).
dispatcher: StdMutex
最近成功:{{ lastSuccessText }}
运行中但长时间未成功,请检查认证 / 网络。
diff --git a/src/pr/WebhookPanel.vue b/src/pr/WebhookPanel.vue
index 9ac837b..a42727e 100644
--- a/src/pr/WebhookPanel.vue
+++ b/src/pr/WebhookPanel.vue
@@ -39,6 +39,13 @@ const copied = ref(false);
// Recent webhook deliveries (#62) — the receiver's diagnostic ring (oldest→newest);
// reversed for most-recent-first display. Self-contained local ref, no Pinia.
const deliveries = ref([]);
+// Delivery fetch state, kept SEPARATE from the panel-wide `error`: `run()` resets
+// `error` to null on every start/stop/refresh, and these fire concurrently in
+// onMounted, so sharing one ref clobbers it. `deliveryLoading` also drives the
+// loading-vs-empty distinction and disables the refresh button while a fetch is in
+// flight (prevents overlapping refreshes).
+const deliveryError = ref(null);
+const deliveryLoading = ref(false);
// Are there unsaved webhook-field edits? `start_webhook` reads the PERSISTED config,
// so any draft change that hasn't been saved would NOT take effect — gating start on
@@ -122,13 +129,18 @@ async function run(fn: () => Promise) {
}
// Pull the delivery diagnostics ring (#62). Tolerates a rejected command via the
-// shared `error` ref + `toMessage` pattern — a failed fetch must not crash the panel
-// nor blank the tunnel controls.
+// dedicated `deliveryError` ref + `toMessage` pattern — a failed fetch must not crash
+// the panel nor blank the tunnel controls, and must NOT clobber the panel-wide
+// `error` (which `run()` owns). `deliveryLoading` is toggled via try/finally.
async function loadDeliveries() {
+ deliveryLoading.value = true;
+ deliveryError.value = null;
try {
deliveries.value = await webhookDeliveries();
} catch (e) {
- error.value = toMessage(e);
+ deliveryError.value = toMessage(e);
+ } finally {
+ deliveryLoading.value = false;
}
}
@@ -172,18 +184,38 @@ function statusTone(s: DeliveryStatus): "ok" | "warn" | "danger" {
}
}
+// Include the date: the 50-cap ring can span midnight, and a time-only stamp makes
+// cross-day entries ambiguous.
function deliveryTime(epochSecs: number): string {
- return new Date(epochSecs * 1000).toLocaleTimeString();
+ return new Date(epochSecs * 1000).toLocaleString(undefined, {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
}
+// Per-delivery explanatory line: prefer the backend message; otherwise, for a
+// listUpdated delivery with no message, explain the #61 core scenario (autoReview off
+// → enqueued but not dispatched) so the green status isn't reasonless. Empty string =
+// nothing to show.
+function deliveryDetail(d: WebhookDelivery): string {
+ if (d.message) return d.message;
+ if (d.status === "listUpdated") return "autoReview 关闭:已入列表,未派发 review";
+ return "";
+}
+
+// Light backstop refresh cadence while the receiver is up: new deliveries arrive
+// server-side with no push channel, so poll the ring on this interval.
+const DELIVERY_REFRESH_MS = 5_000;
+
onMounted(() => {
run(webhookStatus);
loadDeliveries();
- // Light backstop refresh while the receiver is up: new deliveries arrive
- // server-side with no push channel, so poll the ring every ~5s. Cleared on unmount.
deliveryTimer = setInterval(() => {
if (status.value?.running) loadDeliveries();
- }, 5_000);
+ }, DELIVERY_REFRESH_MS);
});
let deliveryTimer: ReturnType | null = null;
@@ -301,13 +333,15 @@ async function copyUrl() {
-
From bce2e71f62b61111aa3272da28fb232082a83189 Mon Sep 17 00:00:00 2001
From: ghbvf <104540935+ghbvf@users.noreply.github.com>
Date: Thu, 18 Jun 2026 04:46:35 +0800
Subject: [PATCH 4/4] =?UTF-8?q?fix(pr):=20pr-review=20findings=20=E4=BF=AE?=
=?UTF-8?q?=E5=A4=8D=E2=80=94=E2=80=94malformed=20labels=20=E6=8B=92?=
=?UTF-8?q?=E6=94=B6=20+=20ingest=20=E7=BA=AF=20decision=20seam=20+=20poll?=
=?UTF-8?q?=20=E7=8A=B6=E6=80=81=E4=B8=80=E8=87=B4=E6=80=A7=EF=BC=88#61=20?=
=?UTF-8?q?#62=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
根因簇 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)
---
src-tauri/src/pr/commands.rs | 544 ++++++++++++++++++++++++++++-------
src-tauri/src/pr/webhook.rs | 88 ++++--
src/pr/WebhookPanel.vue | 6 +-
src/pr/usePrStore.test.ts | 92 ++++++
src/pr/usePrStore.ts | 13 +-
5 files changed, 609 insertions(+), 134 deletions(-)
diff --git a/src-tauri/src/pr/commands.rs b/src-tauri/src/pr/commands.rs
index 4363520..eef9a1f 100644
--- a/src-tauri/src/pr/commands.rs
+++ b/src-tauri/src/pr/commands.rs
@@ -309,8 +309,163 @@ fn webhook_view(
(view, dispatchable)
}
+/// The full ingest decision for ONE routed [`WebhookEvent`], computed PURELY from plain
+/// data by [`decide_ingest`] (no `AppHandle`) so EVERY branch is unit-tested. The
+/// AppHandle-bound [`ingest_webhook`] becomes a thin shell that just performs the IO this
+/// describes: `write` the row through the serialized seam, emit, spawn the dispatch when
+/// `dispatchable` is `Some` AND autoReview is on, and record the delivery with `status` /
+/// `message`.
+struct IngestDecision {
+ /// The list-row view to persist (Upsert) or refresh (UpdatePresent).
+ view: PullRequestView,
+ /// Which registry write to perform for `view`.
+ write: WriteKind,
+ /// The candidate to auto-dispatch when autoReview is on; `None` = nothing to dispatch
+ /// (gated / conflict / status-only). Whether a `Some` is actually spawned is the
+ /// shell's call (it depends on autoReview), but the TERMINAL `status` below already
+ /// reflects the autoReview gate, so the shell never re-decides the status.
+ dispatchable: Option,
+ /// The single terminal delivery diagnostic status (#62) — finalized HERE from the
+ /// dispatch decision + autoReview, so the shell records it verbatim.
+ status: DeliveryStatus,
+ /// The delivery diagnostic's human-readable note (skip reason / `None` for a clean
+ /// dispatch).
+ message: Option,
+}
+
+/// PURE webhook-ingest decision (#61/#62): maps a parsed [`IngestIntent`] + the project's
+/// gating inputs to the FULL [`IngestDecision`] (view + write + dispatch + terminal
+/// delivery status + message), WITHOUT any `AppHandle` so every branch is unit-tested.
+/// Extracted from the old inline `ingest_webhook` body so the decision path — including the
+/// #61 core "autoReview off still LISTS the PR" — has automated coverage rather than only a
+/// "verified via integration / manual verify" note. Behavior-preserving: the IO shell
+/// ([`ingest_webhook`]) feeds it the same data the inline match consumed and acts on its
+/// output verbatim.
+///
+/// `auto_review` is the project's resolved autoReview flag (the shell reads it ONCE off the
+/// loaded project — the SAME per-project gate `scheduler::auto_review_enabled` resolves —
+/// and passes it here so the dispatch decision and the terminal status agree on one value).
+///
+/// Branch semantics (each preserved from the inline body):
+/// - `Track { candidate: Some(cand), .. }`: run the SAME static + cooldown gates as the
+/// poll path (via [`webhook_view`]). `write = Upsert`. Gated (skip_reason `Some`) →
+/// `dispatchable = None`, `status = Gated`, `message = skip_reason`. Clean (skip_reason
+/// `None`) → `dispatchable = Some(cand)`; `status = if auto_review { Dispatched } else
+/// { ListUpdated }` (#61: autoReview off still lists), `message = None`.
+/// - `Track { candidate: None, conflict: .. }`: both trigger labels → a skipped "review"
+/// row with the conflict reason; `write = Upsert`; no dispatch; `status = Gated`.
+/// - `StatusOnly { kind }`: refresh an EXISTING row's status (`write = UpdatePresent`),
+/// never insert / dispatch. Reason text + terminal status BOTH come from the type-locked
+/// [`StatusOnlyKind`] (no string compare — see ai-robust.md).
+// The flat plain-data arg list (the row metadata + the gating inputs) is deliberate: this
+// is a PURE decision seam whose whole point is to be callable from a `#[test]` with no
+// `AppHandle`, so it takes exactly the data the IO shell already holds rather than an
+// AppHandle-bound bundle. Bundling into a struct would just move the arg count around and
+// add a single-use type — same as `review::session::start_review`'s allow.
+#[allow(clippy::too_many_arguments)]
+fn decide_ingest(
+ intent: IngestIntent,
+ number: u64,
+ title: String,
+ labels: Vec,
+ url: String,
+ params: &MonitorParams,
+ ledger: &Ledger,
+ now: u64,
+ auto_review: bool,
+) -> IngestDecision {
+ match intent {
+ IngestIntent::Track {
+ candidate: Some(cand),
+ ..
+ } => {
+ let (view, dispatchable) = webhook_view(cand, title, labels, url, params, ledger, now);
+ // A single trigger label. A gated one (draft/fork/author/cooldown) is a `Gated`
+ // row carrying the reason. A clean (dispatchable) one's terminal status is
+ // decided HERE by the autoReview flag — Dispatched (on) vs ListUpdated (off, the
+ // #61 core "PR enters the list even with autoReview off"); the shell only acts on
+ // `dispatchable` + `auto_review`, it never re-derives the status.
+ match (dispatchable, &view.skip_reason) {
+ (Some(cand), _) => IngestDecision {
+ status: if auto_review {
+ DeliveryStatus::Dispatched
+ } else {
+ DeliveryStatus::ListUpdated
+ },
+ message: None,
+ dispatchable: Some(cand),
+ write: WriteKind::Upsert,
+ view,
+ },
+ (None, reason) => IngestDecision {
+ status: DeliveryStatus::Gated,
+ message: reason.clone(),
+ dispatchable: None,
+ write: WriteKind::Upsert,
+ view,
+ },
+ }
+ }
+ IngestIntent::Track {
+ candidate: None,
+ conflict: _,
+ } => {
+ // Both trigger labels (conflict): a skipped row, never dispatched. Kind "review"
+ // for the view (the parse picked review for the conflict view).
+ let view = PullRequestView {
+ number,
+ title,
+ labels,
+ url,
+ kind: "review".to_string(),
+ skip_reason: Some(discover::BOTH_TRIGGER_LABELS_REASON.to_string()),
+ };
+ IngestDecision {
+ view,
+ write: WriteKind::Upsert,
+ dispatchable: None,
+ status: DeliveryStatus::Gated,
+ message: Some(discover::BOTH_TRIGGER_LABELS_REASON.to_string()),
+ }
+ }
+ IngestIntent::StatusOnly { kind: status_kind } => {
+ // Closed/merged or trigger-label-removed: refresh an existing row's status,
+ // never insert, never dispatch. `kind` from the current labels (check vs the
+ // review default). The reason text + terminal delivery status both come from the
+ // type-locked `StatusOnlyKind` (no string compare — see FIX 1 / ai-robust.md).
+ let kind = if labels.iter().any(|l| l == ¶ms.check_label) {
+ "check"
+ } else {
+ "review"
+ };
+ let reason = status_kind.reason().to_string();
+ let view = PullRequestView {
+ number,
+ title,
+ labels,
+ url,
+ kind: kind.to_string(),
+ skip_reason: Some(reason.clone()),
+ };
+ IngestDecision {
+ view,
+ write: WriteKind::UpdatePresent,
+ dispatchable: None,
+ status: status_kind.delivery_status(),
+ message: Some(reason),
+ }
+ }
+ }
+}
+
/// The AppHandle-bound webhook ingest (#61): the body of the [`WebhookIngestor`] the
-/// composition root installs. Takes ONE parsed, routed [`WebhookEvent`] and (a) upserts /
+/// composition root installs. A THIN IO shell around the pure [`decide_ingest`]: it
+/// resolves config + ledger (fail-closed), calls `decide_ingest`, then performs only the
+/// AppHandle-bound IO — the registry `write`, the `prs:updated` emit, the detached dispatch
+/// spawn, and the single delivery record. The branch LOGIC (view + write + dispatchable +
+/// terminal status + message) lives in `decide_ingest` and is unit-tested there; the
+/// remaining `mutate_tracked` / `emit` / `spawn` here is the untestable AppHandle shell.
+/// Takes ONE parsed, routed [`WebhookEvent`] and (a) upserts /
/// updates the persisted PR list row, (b) emits `prs:updated` so the list reflects the
/// push WITHOUT waiting for the next poll round (the #61 fix — webhook PRs now enter the
/// list even when autoReview is off), and (c) dispatches the gated-clean candidate iff
@@ -328,8 +483,9 @@ fn webhook_view(
///
/// Reuses the registry's single serialized write seam ([`registry::mutate_tracked`]) for
/// the upsert + emit (so this can't interleave with a poll-cycle upsert / `set_pr_archived`
-/// and lose a write) and the scheduler's per-project `auto_review_enabled` gate for the
-/// dispatch decision — the SAME primitives both auto-trigger paths share.
+/// and lose a write). The dispatch decision uses the project's `auto_review` flag — read
+/// ONCE off the same loaded project that `scheduler::auto_review_enabled` resolves from, so
+/// both auto-trigger paths share the SAME per-project autoReview gate.
pub(crate) async fn ingest_webhook(
app: &tauri::AppHandle,
dispatcher: &ProjectDispatcher,
@@ -374,6 +530,12 @@ pub(crate) async fn ingest_webhook(
authors: project.authors,
pr_cooldown_seconds: project.pr_cooldown_seconds,
};
+ // The project's autoReview flag, read ONCE off the SAME loaded project (the per-project
+ // gate `scheduler::auto_review_enabled` resolves from the same `config_service::project`).
+ // Reading it here — rather than re-loading config via `auto_review_enabled` after persist —
+ // ties the dispatch decision and the terminal delivery status to one consistent value and
+ // lets the pure `decide_ingest` finalize both.
+ let auto_review = project.auto_review;
let ledger = match Ledger::load(app, &project_id) {
Ok(l) => l,
Err(e) => {
@@ -383,87 +545,27 @@ pub(crate) async fn ingest_webhook(
};
let now = now_epoch();
- // Build the list-row view + dispatch decision + (for the already-terminal cases) the
- // delivery status from the parsed intent. The terminal status for a DISPATCHABLE
- // candidate is NOT decided here — it depends on the autoReview gate below
- // (Dispatched vs ListUpdated), so it is finalized in ONE place after persist+dispatch
- // (`final_status`) rather than pre-assigned and overwritten. For the gated / conflict /
- // status-only cases the status IS terminal (no dispatch can change it), so it is set
- // here as `provisional_status`.
- let (view, dispatchable, provisional_status, message, write_kind): (
- PullRequestView,
- Option,
- DeliveryStatus,
- Option,
- WriteKind,
+ // The full ingest decision (view + write + dispatch + terminal status + message) is
+ // computed by the PURE `decide_ingest` (unit-tested per branch); the rest of this fn is
+ // the thin AppHandle-bound IO shell that acts on it.
+ let IngestDecision {
+ view,
+ write: write_kind,
+ dispatchable,
+ status: final_status,
+ message,
+ } = decide_ingest(
+ intent,
+ number,
+ title,
+ labels,
+ url,
+ ¶ms,
+ &ledger,
+ now,
+ auto_review,
);
- match intent {
- IngestIntent::Track {
- candidate: Some(cand),
- ..
- } => {
- let (v, d) = webhook_view(cand, title, labels, url, ¶ms, &ledger, now);
- // A single trigger label. A gated one (draft/fork/author/cooldown) is a `Gated`
- // row carrying the reason — terminal. A clean (dispatchable) one's terminal
- // status (Dispatched vs ListUpdated) is decided by the autoReview gate below, so
- // `provisional_status` here is a placeholder ONLY consulted when `dispatchable`
- // is `None`; `final_status` always overrides it for the dispatchable path.
- let (st, msg) = match (&d, &v.skip_reason) {
- (Some(_), _) => (DeliveryStatus::Dispatched, None),
- (None, reason) => (DeliveryStatus::Gated, reason.clone()),
- };
- view = v;
- dispatchable = d;
- provisional_status = st;
- message = msg;
- write_kind = WriteKind::Upsert;
- }
- IngestIntent::Track {
- candidate: None,
- conflict: _,
- } => {
- // Both trigger labels (conflict): a skipped row, never dispatched. Kind
- // "review" for the view (the parse picked review for the conflict view).
- view = PullRequestView {
- number,
- title,
- labels,
- url,
- kind: "review".to_string(),
- skip_reason: Some(discover::BOTH_TRIGGER_LABELS_REASON.to_string()),
- };
- dispatchable = None;
- provisional_status = DeliveryStatus::Gated;
- message = Some(discover::BOTH_TRIGGER_LABELS_REASON.to_string());
- write_kind = WriteKind::Upsert;
- }
- IngestIntent::StatusOnly { kind: status_kind } => {
- // Closed/merged or trigger-label-removed: refresh an existing row's status,
- // never insert, never dispatch. `kind` from the current labels (check vs the
- // review default). The reason text + terminal delivery status both come from the
- // type-locked `StatusOnlyKind` (no string compare — see FIX 1 / ai-robust.md).
- let kind = if labels.iter().any(|l| l == ¶ms.check_label) {
- "check"
- } else {
- "review"
- };
- let reason = status_kind.reason().to_string();
- view = PullRequestView {
- number,
- title,
- labels,
- url,
- kind: kind.to_string(),
- skip_reason: Some(reason.clone()),
- };
- dispatchable = None;
- provisional_status = status_kind.delivery_status();
- message = Some(reason);
- write_kind = WriteKind::UpdatePresent;
- }
- }
-
// Persist + emit through the single serialized write seam. The Upsert path always
// persists + emits (an upsert always changes the set); the UpdatePresent path persists
// + emits ONLY when the row existed (mirrors `set_pr_archived`'s no-op skip), so a
@@ -516,34 +618,26 @@ pub(crate) async fn ingest_webhook(
}
}
- // Dispatch the gated-clean candidate iff autoReview is on (the SAME per-project gate
- // the scheduler applies at its call site). Detached spawn, mirroring the scheduler's
- // detached dispatch (a stop must not cancel a start in flight). The terminal delivery
- // status is decided HERE, in ONE place, from the dispatch decision — a `dispatchable`
- // candidate becomes `Dispatched` (autoReview on) or `ListUpdated` (autoReview off);
- // every other case keeps its already-terminal `provisional_status`.
- let final_status = match dispatchable {
- Some(cand) if super::scheduler::auto_review_enabled(app, &project_id) => {
+ // Dispatch the gated-clean candidate iff autoReview is on. `decide_ingest` already
+ // gates `dispatchable` to `Some` ONLY for a clean (un-skipped) candidate AND already
+ // baked the autoReview flag into `final_status` (Dispatched on / ListUpdated off, the
+ // #61 core "PR enters the list even with autoReview off"), so the shell just spawns
+ // when both hold — it never re-decides the status. The same `auto_review` value drives
+ // both, so the spawn and the recorded status can't disagree. Detached spawn, mirroring
+ // the scheduler's detached dispatch (a stop must not cancel a start in flight); the
+ // JoinHandle is dropped explicitly so the task runs to completion regardless of caller.
+ if let Some(cand) = dispatchable {
+ if auto_review {
drop(tauri::async_runtime::spawn(dispatcher(
project_id.clone(),
vec![cand],
)));
- DeliveryStatus::Dispatched
}
- // A clean candidate but autoReview off: the list was updated, no dispatch — by
- // design (#61: webhook PRs enter the list even with autoReview off). This
- // AppHandle-bound path (autoReview-off → ListUpdated, the #61 core "PR enters the
- // list even with autoReview off") is verified via integration / manual verify, NOT
- // a unit test — `ingest_webhook` is generic over `tauri::Runtime` and an
- // `AppHandle` isn't constructible in a plain `#[test]`, so the coverage story for
- // this branch lives in the verify pass, not in `mod tests`.
- Some(_) => DeliveryStatus::ListUpdated,
- // No dispatch candidate (gated / conflict / status-only): the status set in the
- // intent match is already terminal.
- None => provisional_status,
- };
+ }
- // Record the single terminal delivery diagnostic (#62) for this routable event.
+ // Record the single terminal delivery diagnostic (#62) for this routable event. The
+ // status came straight from `decide_ingest` — the dispatch decision above only acts on
+ // it, it does not override it.
record_webhook_delivery(
app,
&repo,
@@ -868,4 +962,246 @@ mod tests {
"a cooldown-gated candidate is not dispatchable"
);
}
+
+ // ── `decide_ingest` (F7): the PURE ingest-decision seam extracted from the
+ // AppHandle-bound `ingest_webhook` body so EVERY branch (incl. the #61 core
+ // "autoReview off still LISTS the PR") has automated coverage rather than only the old
+ // "verified via integration / NOT a unit test" note. Each test asserts the FULL
+ // decision: view fields + write + dispatchable + terminal status + message.
+ use super::super::webhook::StatusOnlyKind;
+
+ /// A clean single-label candidate wrapped as `IngestIntent::Track { candidate: Some }`.
+ /// `row` builds a non-draft, non-fork, allowed-author candidate → clean under the
+ /// empty-ledger `params()` gates, so the only thing left to vary is autoReview.
+ fn wrap_some(number: u64) -> IngestIntent {
+ IngestIntent::Track {
+ candidate: Some(row(number, "review", false).candidate),
+ conflict: false,
+ }
+ }
+
+ #[test]
+ fn decide_ingest_clean_candidate_auto_review_on_dispatches() {
+ // #61: a clean candidate with autoReview ON → Upsert row, dispatch the candidate,
+ // status Dispatched, no message.
+ let d = decide_ingest(
+ wrap_some(1),
+ 1,
+ "PR 1".to_string(),
+ vec!["review-label".to_string()],
+ "https://x/1".to_string(),
+ ¶ms(),
+ &Ledger::default(),
+ 0,
+ true,
+ );
+ assert_eq!(d.view.number, 1);
+ assert_eq!(d.view.kind, "review");
+ assert_eq!(d.view.skip_reason, None);
+ assert!(matches!(d.write, WriteKind::Upsert));
+ assert!(d.dispatchable.is_some(), "clean candidate is dispatchable");
+ assert!(matches!(d.status, DeliveryStatus::Dispatched));
+ assert_eq!(d.message, None);
+ }
+
+ #[test]
+ fn decide_ingest_clean_candidate_auto_review_off_lists_without_dispatch() {
+ // THE #61 CORE: a clean candidate with autoReview OFF → still Upserts the row +
+ // surfaces a dispatchable (the shell just won't spawn it), status ListUpdated (NOT
+ // Dispatched), no message. This is the branch that previously had no unit test.
+ let d = decide_ingest(
+ wrap_some(2),
+ 2,
+ "PR 2".to_string(),
+ vec!["review-label".to_string()],
+ "https://x/2".to_string(),
+ ¶ms(),
+ &Ledger::default(),
+ 0,
+ false,
+ );
+ assert_eq!(d.view.number, 2);
+ assert_eq!(d.view.skip_reason, None);
+ assert!(matches!(d.write, WriteKind::Upsert));
+ assert!(
+ d.dispatchable.is_some(),
+ "autoReview-off still surfaces the candidate (the shell gates the spawn)"
+ );
+ assert!(
+ matches!(d.status, DeliveryStatus::ListUpdated),
+ "autoReview off → ListUpdated, not Dispatched"
+ );
+ assert_eq!(d.message, None);
+ }
+
+ #[test]
+ fn decide_ingest_static_gated_candidate_is_gated_no_dispatch() {
+ // A draft candidate is gated by `should_skip` → Upsert a skipped row, NO dispatch,
+ // status Gated, message = the skip reason. autoReview on must NOT override the gate.
+ let mut cand = row(3, "review", false).candidate;
+ cand.is_draft = true;
+ let intent = IngestIntent::Track {
+ candidate: Some(cand),
+ conflict: false,
+ };
+ let d = decide_ingest(
+ intent,
+ 3,
+ "PR 3".to_string(),
+ vec!["review-label".to_string()],
+ "https://x/3".to_string(),
+ ¶ms(),
+ &Ledger::default(),
+ 0,
+ true,
+ );
+ assert_eq!(d.view.skip_reason, Some("draft PR".to_string()));
+ assert!(matches!(d.write, WriteKind::Upsert));
+ assert!(
+ d.dispatchable.is_none(),
+ "a gated candidate never dispatches"
+ );
+ assert!(matches!(d.status, DeliveryStatus::Gated));
+ assert_eq!(d.message, Some("draft PR".to_string()));
+ }
+
+ #[test]
+ fn decide_ingest_cooldown_gated_candidate_is_gated_no_dispatch() {
+ // A candidate within its dispatch cooldown is gated by `cooldown_skip` → Gated row,
+ // no dispatch, message = the cooldown reason (even with autoReview on).
+ use crate::pr::ledger::{dispatch_key, DispatchEvent};
+ use std::collections::HashSet;
+
+ let cand = row(4, "review", false).candidate;
+ let ledger = Ledger {
+ dispatched: HashSet::new(),
+ events: vec![DispatchEvent {
+ pr: 4,
+ kind: "review".to_string(),
+ head_sha: cand.head_sha.clone(),
+ key: dispatch_key(4, &cand.head_sha, "review"),
+ dispatched_at_epoch: 1_000,
+ }],
+ };
+ let intent = IngestIntent::Track {
+ candidate: Some(cand),
+ conflict: false,
+ };
+ // dispatched 500s before `now` (1800s cooldown) → within window.
+ let d = decide_ingest(
+ intent,
+ 4,
+ "PR 4".to_string(),
+ vec!["review-label".to_string()],
+ "https://x/4".to_string(),
+ ¶ms(),
+ &ledger,
+ 1_500,
+ true,
+ );
+ assert!(
+ d.view
+ .skip_reason
+ .as_deref()
+ .is_some_and(|r| r.contains("within cooldown")),
+ "cooldown gate fires: {:?}",
+ d.view.skip_reason
+ );
+ assert!(matches!(d.write, WriteKind::Upsert));
+ assert!(d.dispatchable.is_none());
+ assert!(matches!(d.status, DeliveryStatus::Gated));
+ assert!(d
+ .message
+ .as_deref()
+ .is_some_and(|m| m.contains("within cooldown")));
+ }
+
+ #[test]
+ fn decide_ingest_conflict_is_gated_with_both_labels_reason() {
+ // Both trigger labels (`candidate: None, conflict: true`) → Upsert a skipped "review"
+ // row carrying the BOTH-labels reason, NO dispatch, status Gated.
+ let intent = IngestIntent::Track {
+ candidate: None,
+ conflict: true,
+ };
+ let d = decide_ingest(
+ intent,
+ 5,
+ "PR 5".to_string(),
+ vec!["review-label".to_string(), "check-label".to_string()],
+ "https://x/5".to_string(),
+ ¶ms(),
+ &Ledger::default(),
+ 0,
+ true,
+ );
+ assert_eq!(d.view.number, 5);
+ assert_eq!(d.view.kind, "review");
+ assert_eq!(
+ d.view.skip_reason,
+ Some(discover::BOTH_TRIGGER_LABELS_REASON.to_string())
+ );
+ assert!(matches!(d.write, WriteKind::Upsert));
+ assert!(d.dispatchable.is_none());
+ assert!(matches!(d.status, DeliveryStatus::Gated));
+ assert_eq!(
+ d.message,
+ Some(discover::BOTH_TRIGGER_LABELS_REASON.to_string())
+ );
+ }
+
+ #[test]
+ fn decide_ingest_status_only_closed_is_not_open_update_present() {
+ // A closed/merged PR → StatusOnly { ClosedOrMerged }: UpdatePresent (refresh an
+ // existing row, never insert / dispatch), status NotOpen, reason "PR 已关闭或合并".
+ let intent = IngestIntent::StatusOnly {
+ kind: StatusOnlyKind::ClosedOrMerged,
+ };
+ let d = decide_ingest(
+ intent,
+ 6,
+ "PR 6".to_string(),
+ vec!["review-label".to_string()],
+ "https://x/6".to_string(),
+ ¶ms(),
+ &Ledger::default(),
+ 0,
+ true,
+ );
+ assert_eq!(d.view.number, 6);
+ assert_eq!(d.view.kind, "review");
+ assert_eq!(d.view.skip_reason, Some("PR 已关闭或合并".to_string()));
+ assert!(matches!(d.write, WriteKind::UpdatePresent));
+ assert!(d.dispatchable.is_none());
+ assert!(matches!(d.status, DeliveryStatus::NotOpen));
+ assert_eq!(d.message, Some("PR 已关闭或合并".to_string()));
+ }
+
+ #[test]
+ fn decide_ingest_status_only_trigger_label_removed_is_no_trigger_label_update_present() {
+ // An open PR with the trigger label removed → StatusOnly { TriggerLabelRemoved }:
+ // UpdatePresent, status NoTriggerLabel, reason "触发 label 已移除". With ONLY the
+ // check label present, the view kind is "check" (the labels-derived kind).
+ let intent = IngestIntent::StatusOnly {
+ kind: StatusOnlyKind::TriggerLabelRemoved,
+ };
+ let d = decide_ingest(
+ intent,
+ 7,
+ "PR 7".to_string(),
+ vec!["check-label".to_string()],
+ "https://x/7".to_string(),
+ ¶ms(),
+ &Ledger::default(),
+ 0,
+ false,
+ );
+ assert_eq!(d.view.number, 7);
+ assert_eq!(d.view.kind, "check", "check label present → kind check");
+ assert_eq!(d.view.skip_reason, Some("触发 label 已移除".to_string()));
+ assert!(matches!(d.write, WriteKind::UpdatePresent));
+ assert!(d.dispatchable.is_none());
+ assert!(matches!(d.status, DeliveryStatus::NoTriggerLabel));
+ assert_eq!(d.message, Some("触发 label 已移除".to_string()));
+ }
}
diff --git a/src-tauri/src/pr/webhook.rs b/src-tauri/src/pr/webhook.rs
index 2baf8a6..4ba2b9a 100644
--- a/src-tauri/src/pr/webhook.rs
+++ b/src-tauri/src/pr/webhook.rs
@@ -163,7 +163,8 @@ pub enum ParseResult {
/// Verified, but the event's repo matched no enabled route (fail-closed drop). The
/// repo (when known) is carried for the delivery diagnostic.
WrongRepo { repo: Option },
- /// No `pull_request`, or a required field (number / head.sha / head.ref) is missing.
+ /// No `pull_request`, or a required field (number / head.sha / head.ref / labels) is
+ /// missing or structurally invalid (`labels` not an array — F3).
Malformed,
}
@@ -1013,8 +1014,10 @@ fn verify_signature(secret: &str, body: &[u8], header: &str) -> bool {
/// case, so a webhook never touched the persisted PR list), this surfaces the FULL
/// outcome the ingest needs to upsert + emit even when nothing dispatches (the #61 fix):
///
-/// - missing `pull_request`, or a required field (`number` / `head.sha` / `head.ref`)
-/// absent → [`ParseResult::Malformed`];
+/// - missing `pull_request`, or a required field (`number` / `head.sha` / `head.ref` /
+/// `labels`) absent / structurally invalid → [`ParseResult::Malformed`] (`labels` must
+/// be an array — GitHub always sends one, possibly empty `[]`; an absent / `null` /
+/// non-array `labels` is malformed, NOT silently "no trigger label" — F3);
/// - repo matches no enabled route → [`ParseResult::WrongRepo`] (fail-closed: HMAC
/// proves the secret is known, NOT that the event is for a monitored repo);
/// - PR not open (closed/merged) → `Routable` with [`IngestIntent::StatusOnly`]
@@ -1097,15 +1100,22 @@ fn parse_delivery(payload: &Value, routes: &[ProjectRoute]) -> ParseResult {
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
- let labels: Vec = pr
- .get("labels")
- .and_then(Value::as_array)
- .map(|arr| {
- arr.iter()
- .filter_map(|l| l.get("name").and_then(Value::as_str).map(str::to_string))
- .collect()
- })
- .unwrap_or_default();
+ // `labels` is a REQUIRED structural field, same tier as number/head.sha/head.ref:
+ // GitHub ALWAYS sends a `labels` array on a real PR event (possibly empty `[]`), so an
+ // absent / `null` / non-array `labels` is a malformed payload, NOT "no labels". Coercing
+ // it to an empty Vec (the old `unwrap_or_default()`) silently turned a malformed payload
+ // into a valid "no trigger label" state update on a tracked PR (→ StatusOnly
+ // TriggerLabelRemoved) — F3. Validated HERE (alongside the other required-field
+ // extractions, before the open/closed + label classification that needs the names) so it
+ // rejects regardless of open/closed: a malformed payload is malformed either way. An
+ // EXPLICIT empty array `[]` is still valid → empty Vec → genuine "no trigger label".
+ let Some(labels_arr) = pr.get("labels").and_then(Value::as_array) else {
+ return ParseResult::Malformed;
+ };
+ let labels: Vec = labels_arr
+ .iter()
+ .filter_map(|l| l.get("name").and_then(Value::as_str).map(str::to_string))
+ .collect();
let action = payload
.get("action")
.and_then(Value::as_str)
@@ -1566,29 +1576,53 @@ mod tests {
other => panic!("expected StatusOnly, got {other:?}"),
}
- // `labels` null and the `labels` key absent both fall back via `unwrap_or_default()`
- // to an empty label set → neither trigger label → StatusOnly { TriggerLabelRemoved }
- // (locks the fallback: a missing/null `labels` is treated as "no trigger labels",
- // not a malformed payload).
- for labels in [serde_json::json!(null), serde_json::Value::Null] {
+ // F3: a `null` or non-array `labels`, and the `labels` key entirely absent, are
+ // structurally MALFORMED (GitHub always sends a `labels` array), NOT silently "no
+ // trigger label". The old behavior (`unwrap_or_default()` → empty Vec →
+ // TriggerLabelRemoved) turned a malformed payload into a valid state update on a
+ // tracked PR; this test now locks the rejection. (A `null` JSON value and a
+ // structurally non-array value both fail `Value::as_array`.)
+ for labels in [serde_json::json!(null), serde_json::json!("not-an-array")] {
let mut p = pr_payload(&["unrelated"], serde_json::json!({}));
- // Overwrite the PR's `labels` with null, then also test the key being absent.
p["pull_request"]["labels"] = labels;
- match routable(&p, &single_route("needs-review", "needs-check")).intent {
- IngestIntent::StatusOnly { kind } => {
- assert!(matches!(kind, StatusOnlyKind::TriggerLabelRemoved));
- }
- other => panic!("null labels: expected StatusOnly, got {other:?}"),
- }
+ assert!(
+ matches!(
+ parse_delivery(&p, &single_route("needs-review", "needs-check")),
+ ParseResult::Malformed
+ ),
+ "null/non-array labels must be Malformed, not a coerced empty set"
+ );
}
- // `labels` key entirely absent (removed from the PR object) → same fallback.
+ // `labels` key entirely absent (removed from the PR object) → Malformed too.
let mut p = pr_payload(&["unrelated"], serde_json::json!({}));
p["pull_request"].as_object_mut().unwrap().remove("labels");
- match routable(&p, &single_route("needs-review", "needs-check")).intent {
+ assert!(
+ matches!(
+ parse_delivery(&p, &single_route("needs-review", "needs-check")),
+ ParseResult::Malformed
+ ),
+ "absent labels key must be Malformed"
+ );
+
+ // …but an EXPLICIT empty array `[]` is the GENUINE "no trigger label" case and stays
+ // valid: an OPEN PR with `[]` → StatusOnly { TriggerLabelRemoved } (list-only, no
+ // dispatch). This is the case the absent/null subcases above must NOT be conflated
+ // with — `[]` is a real "labels were removed" state, absent `labels` is malformed.
+ let empty_open = pr_payload(&[], serde_json::json!({}));
+ match routable(&empty_open, &single_route("needs-review", "needs-check")).intent {
IngestIntent::StatusOnly { kind } => {
assert!(matches!(kind, StatusOnlyKind::TriggerLabelRemoved));
}
- other => panic!("absent labels key: expected StatusOnly, got {other:?}"),
+ other => panic!("empty [] on open PR: expected StatusOnly, got {other:?}"),
+ }
+ // An EXPLICIT empty array `[]` on a CLOSED PR → StatusOnly { ClosedOrMerged } (the
+ // closed-state check precedes label classification, so `[]` doesn't shadow it).
+ let empty_closed = pr_payload(&[], serde_json::json!({ "state": "closed" }));
+ match routable(&empty_closed, &single_route("needs-review", "needs-check")).intent {
+ IngestIntent::StatusOnly { kind } => {
+ assert!(matches!(kind, StatusOnlyKind::ClosedOrMerged));
+ }
+ other => panic!("empty [] on closed PR: expected StatusOnly, got {other:?}"),
}
}
diff --git a/src/pr/WebhookPanel.vue b/src/pr/WebhookPanel.vue
index a42727e..62014fd 100644
--- a/src/pr/WebhookPanel.vue
+++ b/src/pr/WebhookPanel.vue
@@ -198,11 +198,13 @@ function deliveryTime(epochSecs: number): string {
// Per-delivery explanatory line: prefer the backend message; otherwise, for a
// listUpdated delivery with no message, explain the #61 core scenario (autoReview off
-// → enqueued but not dispatched) so the green status isn't reasonless. Empty string =
+// → enqueued but not dispatched) so the green status isn't reasonless. Mode-agnostic
+// wording (#66 F6): a delivery may carry kind "review" OR "check", so the hardcoded
+// "未派发 review" was wrong for check-kind PRs — say "自动任务" instead. Empty string =
// nothing to show.
function deliveryDetail(d: WebhookDelivery): string {
if (d.message) return d.message;
- if (d.status === "listUpdated") return "autoReview 关闭:已入列表,未派发 review";
+ if (d.status === "listUpdated") return "autoReview 关闭:已入列表,未派发自动任务";
return "";
}
diff --git a/src/pr/usePrStore.test.ts b/src/pr/usePrStore.test.ts
index 77a1400..20e18d3 100644
--- a/src/pr/usePrStore.test.ts
+++ b/src/pr/usePrStore.test.ts
@@ -6,12 +6,30 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import type { PrEvent, TrackedPrView } from "../types";
+import type { PollStatus } from "./types";
import type { Project } from "../config/types";
// Captured callback handed to `onPrsUpdated`, so a test can push a `PrEvent`
// through the same path `subscribe()` wires up.
let prsCb: ((e: PrEvent) => void) | null = null;
+// A full PollStatus shape (#66 F8) so the `pollStatus` mock resolves the same wire
+// form refreshPollStatus writes; `running` defaults true so the F4 reconciliation
+// keeps `polling` at its baseline unless a test overrides it.
+const pollStatus = (
+ over: Partial = {},
+): PollStatus => ({
+ running: true,
+ intervalSecs: 60,
+ lastStartedEpoch: null,
+ lastSuccessEpoch: null,
+ lastErrorEpoch: null,
+ lastErrorMessage: null,
+ lastPersistEpoch: null,
+ lastDiscoveredCount: null,
+ ...over,
+});
+
vi.mock("./api", () => ({
pollNow: vi.fn(() => Promise.resolve()),
startPolling: vi.fn(() => Promise.resolve()),
@@ -19,6 +37,20 @@ vi.mock("./api", () => ({
ghStatus: vi.fn(() => Promise.resolve({ authenticated: true, message: "" })),
getPrs: vi.fn(() => Promise.resolve([])),
setPrArchived: vi.fn(() => Promise.resolve()),
+ // pollStatus mock (#66 F8): refreshPollStatus is fired from subscribe()/init()/
+ // toggle()/switchTo(), so the chains need a resolved PollStatus to write.
+ pollStatus: vi.fn(() =>
+ Promise.resolve({
+ running: true,
+ intervalSecs: 60,
+ lastStartedEpoch: null,
+ lastSuccessEpoch: null,
+ lastErrorEpoch: null,
+ lastErrorMessage: null,
+ lastPersistEpoch: null,
+ lastDiscoveredCount: null,
+ }),
+ ),
onPrsUpdated: vi.fn((cb: (e: PrEvent) => void) => {
prsCb = cb;
// onPrsUpdated returns a Promise.
@@ -82,6 +114,8 @@ beforeEach(() => {
vi.mocked(api.stopPolling).mockResolvedValue(undefined);
vi.mocked(api.getPrs).mockResolvedValue([]);
vi.mocked(api.setPrArchived).mockResolvedValue(undefined);
+ // Restore the pollStatus default wiped by clearAllMocks (#66 F8).
+ vi.mocked(api.pollStatus).mockResolvedValue(pollStatus());
vi.mocked(api.onPrsUpdated).mockImplementation((cb) => {
prsCb = cb;
return Promise.resolve(() => {});
@@ -166,6 +200,18 @@ describe("usePrStore subscribe()", () => {
prsCb?.({ kind: "updated", projectId: "p2", prs: [view(10)] });
expect(store.hasNewPr.p2).toBe(false);
});
+
+ it("refreshes the backend poll status for the event's project (#66 F8)", () => {
+ const store = usePrStore();
+ store.subscribe();
+
+ prsCb?.({ kind: "updated", projectId: "p1", prs: [view(1)] });
+ expect(api.pollStatus).toHaveBeenCalledWith("p1");
+
+ // The error branch must refresh too, so a running-but-failing loop still surfaces.
+ prsCb?.({ kind: "error", projectId: "p2", message: "boom" });
+ expect(api.pollStatus).toHaveBeenCalledWith("p2");
+ });
});
describe("usePrStore pollNow()", () => {
@@ -190,15 +236,24 @@ describe("usePrStore pollNow()", () => {
describe("usePrStore toggle()", () => {
it("polling -> paused calls stopPolling and flips polling=false for all projects", async () => {
+ // Backend confirms the loop stopped, so the F4 reconciliation in the trailing
+ // refreshPollStatus agrees with the optimistic flip (default mock would report
+ // running:true and bounce p1 back, masking the optimistic stop under test).
+ vi.mocked(api.pollStatus).mockResolvedValue(pollStatus({ running: false }));
const store = usePrStore();
expect(store.pollingActive).toBe(true);
await store.toggle();
+ // Let the fire-and-forget refreshPollStatus(activeId) settle so its reconciliation
+ // (p1 -> running:false) is reflected before asserting.
+ await Promise.resolve();
expect(api.stopPolling).toHaveBeenCalledOnce();
expect(store.pollingFor("p1")).toBe(false);
expect(store.pollingFor("p2")).toBe(false);
expect(store.errorActive).toBeNull();
+ // Reflects the start/stop in the active project's backend diagnostics (#66 F8).
+ expect(api.pollStatus).toHaveBeenCalledWith("p1");
});
it("on a rejected command sets error and does NOT flip polling", async () => {
@@ -263,6 +318,8 @@ describe("usePrStore init()", () => {
expect(api.getPrs).toHaveBeenCalledWith("p1");
expect(store.prs.p1).toEqual(snapshot);
expect(typeof (await unlisten)).toBe("function");
+ // Baselines the active project's poll-loop diagnostics (#66 F8).
+ expect(api.pollStatus).toHaveBeenCalledWith("p1");
});
it("registers the listener BEFORE reading the snapshot (#27 F3 race guard)", async () => {
@@ -300,6 +357,8 @@ describe("usePrStore switchTo()", () => {
expect(store.hasNewPr.p2).toBe(false);
expect(api.getPrs).toHaveBeenCalledWith("p2");
expect(store.prs.p2).toEqual(snapshot);
+ // Refreshes the switched-to project's poll diagnostics immediately (#66 F5/F8).
+ expect(api.pollStatus).toHaveBeenCalledWith("p2");
});
});
@@ -366,3 +425,36 @@ describe("usePrStore setArchived()", () => {
expect(store.error.p1).toBe("archive failed");
});
});
+
+describe("usePrStore refreshPollStatus() (#62, #66 F4/F8)", () => {
+ it("writes pollStatus and reconciles the optimistic polling flag to backend running", async () => {
+ // Backend reports the loop STOPPED — the optimistic flag (default true) must
+ // reconcile to false so the pause/resume button reads "恢复轮询" (#66 F4).
+ const status = pollStatus({ running: false, lastDiscoveredCount: 3 });
+ vi.mocked(api.pollStatus).mockResolvedValueOnce(status);
+ const store = usePrStore();
+ expect(store.pollingFor("p1")).toBe(true);
+
+ await store.refreshPollStatus("p1");
+
+ expect(api.pollStatus).toHaveBeenCalledWith("p1");
+ expect(store.pollStatus.p1).toEqual(status);
+ expect(store.pollingFor("p1")).toBe(false);
+ });
+
+ it("leaves the prior pollStatus AND polling flag unchanged on a rejected fetch (error swallowed)", async () => {
+ const store = usePrStore();
+ // Seed a prior good status + a known optimistic flag.
+ const prior = pollStatus({ running: true, lastDiscoveredCount: 1 });
+ store.pollStatus.p1 = prior;
+ store.polling.p1 = true;
+
+ vi.mocked(api.pollStatus).mockRejectedValueOnce({ message: "status failed" });
+ await store.refreshPollStatus("p1");
+
+ // Rejected fetch: no error banner, no mutation of either field.
+ expect(store.pollStatus.p1).toEqual(prior);
+ expect(store.polling.p1).toBe(true);
+ expect(store.error.p1).toBeUndefined();
+ });
+});
diff --git a/src/pr/usePrStore.ts b/src/pr/usePrStore.ts
index d948740..aecbfb0 100644
--- a/src/pr/usePrStore.ts
+++ b/src/pr/usePrStore.ts
@@ -204,6 +204,10 @@ export const usePrStore = defineStore("pr", {
await useProjects().setActive(id);
this.hasNewPr[id] = false;
await this.loadSnapshot(id);
+ // Refresh the switched-to project's poll diagnostics now (#66 F5): otherwise
+ // PollControls shows stale/empty status until the 10s backstop timer or the
+ // next prs:updated event. Fire-and-forget — refreshPollStatus swallows errors.
+ void this.refreshPollStatus(id);
},
async pollNow(projectId: string) {
if (this.loading[projectId]) return;
@@ -264,9 +268,16 @@ export const usePrStore = defineStore("pr", {
// Read one project's backend poll-loop status into its partition (#62). Pure
// diagnostics: a rejected command leaves the prior snapshot as-is (no error
// banner, no list mutation) so a transient fetch failure can't blank the readout.
+ // On a SUCCESSFUL fetch, reconcile the optimistic `polling` flag to the backend
+ // truth (#66 F4): the pause/resume button reads `pollingActive` (optimistic), so
+ // if the backend loop is actually stopped the button must say "恢复轮询" — letting
+ // the first click resume rather than mistakenly stop. The catch leaves both
+ // `pollStatus` and `polling` unchanged so a transient failure can't flip the flag.
async refreshPollStatus(id: string) {
try {
- this.pollStatus[id] = await fetchPollStatus(id);
+ const status = await fetchPollStatus(id);
+ this.pollStatus[id] = status;
+ this.polling[id] = status.running;
} catch {
/* leave as-is */
}