diff --git a/Cargo.lock b/Cargo.lock index 4c6c3d4e..3cfc9cec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1439,6 +1439,7 @@ version = "0.0.0" dependencies = [ "chrono", "mergify-core", + "mergify-tui", "serde", "serde_json", "thiserror", @@ -1470,6 +1471,7 @@ dependencies = [ "anstyle", "chrono", "mergify-core", + "mergify-events", "mergify-test-support", "mergify-tui", "serde", diff --git a/crates/mergify-events/Cargo.toml b/crates/mergify-events/Cargo.toml index 446de43f..869fb0aa 100644 --- a/crates/mergify-events/Cargo.toml +++ b/crates/mergify-events/Cargo.toml @@ -11,6 +11,7 @@ publish = false [dependencies] mergify-core = { path = "../mergify-core" } +mergify-tui = { path = "../mergify-tui" } chrono = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/mergify-events/src/lib.rs b/crates/mergify-events/src/lib.rs index e12cb721..bd4418cd 100644 --- a/crates/mergify-events/src/lib.rs +++ b/crates/mergify-events/src/lib.rs @@ -32,9 +32,17 @@ //! fields every event shares — a newer engine cannot break an older //! CLI, and `--json` consumers see Mergify's contract, not this //! crate's. +//! +//! On top of the raw client sits the queue-leave **explain layer** +//! ([`queue_leave`]): decode a pull request's last +//! `action.queue.leave` and render why it left — the engine's own +//! reason prose, the failing checks with their job URLs, and the next +//! step. `queue show`'s dequeue diagnosis is a consumer of this +//! crate, not a second implementation of the contract. pub mod client; pub mod event; +pub mod queue_leave; pub mod window; pub use client::{Query, fetch}; diff --git a/crates/mergify-queue/src/last_leave.rs b/crates/mergify-events/src/queue_leave.rs similarity index 86% rename from crates/mergify-queue/src/last_leave.rs rename to crates/mergify-events/src/queue_leave.rs index 99f084f5..a798b050 100644 --- a/crates/mergify-queue/src/last_leave.rs +++ b/crates/mergify-events/src/queue_leave.rs @@ -1,36 +1,15 @@ -//! Diagnosis fallback for a pull request that is **not** currently in -//! the merge queue. +//! The queue-leave explain layer: decode a pull request's last +//! `action.queue.leave` event and render *why* it left the merge +//! queue — the sentence, the per-reason hint, the failing checks with +//! their URLs, and the next step. //! -//! `GET /v1/repos//merge-queue/pull/` answers only for a PR -//! that is queued *right now*; it 404s for everything else. That 404 -//! is overloaded — it covers "dequeued ten minutes ago on failing -//! CI", "nobody ever queued it", and "that PR number doesn't exist" — -//! so `queue show` falls back to the activity log to tell them apart: +//! The generic [`fetch`](crate::fetch) client owns the window, +//! pagination and ordering traps; two are queue-specific and live +//! here: //! -//! ```text -//! GET /v1/repos//logs -//! ?pull_request= -//! &event_type=action.queue.leave -//! &received_from= -//! &per_page=1 -//! ``` -//! -//! Four things about that request are load-bearing: -//! -//! - **The window.** `/logs` defaults `received_from` to -//! `received_to - 1 day`. Without an explicit range a PR dequeued -//! last week comes back as `size: 0`, which reads exactly like -//! never-queued — the failure mode this module exists to remove. The -//! API rejects a span over 93 days (422), and event retention is 90, -//! so [`LOOKBACK_DAYS`] asks for the whole retained history and no -//! more. -//! - **The ordering.** Events come back newest-first, so `events[0]` -//! with `per_page=1` is the PR's *last* transition out of the queue. -//! A PR that conflicted, was requeued and then merged reports the -//! merge, not the conflict. -//! - **The event type.** `action.queue.leave` is emitted only when the -//! PR actually leaves. The sibling `abort_code` values that ride on -//! `action.queue.checks_end` (`PR_AHEAD_DEQUEUED`, +//! - **The event type.** `action.queue.leave` is emitted only when +//! the PR actually leaves. The sibling `abort_code` values that +//! ride on `action.queue.checks_end` (`PR_AHEAD_DEQUEUED`, //! `MERGE_QUEUE_RESET`, `CHECKS_RETRIED`, …) interrupt the *checks* //! while the PR stays queued; filtering on the event type is what //! keeps them out of this diagnosis. Reporting one as a dequeue @@ -48,7 +27,6 @@ use std::io::Write; use chrono::DateTime; -use chrono::TimeDelta; use chrono::Utc; use mergify_core::CliError; use mergify_core::http::Client; @@ -56,11 +34,10 @@ use mergify_tui::Theme; use mergify_tui::relative_time; use serde::Deserialize; -/// How far back to look for the pull request's last queue exit. -/// Bounded by the API on both sides: `/logs` retains 90 days of -/// events and rejects a `received_from`/`received_to` span over 93 -/// days with a 422. -const LOOKBACK_DAYS: i64 = 90; +use crate::client::Query; +use crate::event::Event; +use crate::window::RETENTION_DAYS; +use crate::window::Window; const LEAVE_EVENT_TYPE: &str = "action.queue.leave"; @@ -82,6 +59,20 @@ pub struct LastLeave { } impl LastLeave { + /// Decode the leave-specific view out of a fetched [`Event`]. + /// + /// # Errors + /// + /// A payload whose fields have the wrong shape fails with a + /// wrapped decode error; callers treat that like an unreadable + /// log, never as "no dequeue found". + pub fn from_event(event: Event) -> Result { + let raw = event.raw; + let event: LeaveEvent = serde_json::from_value(raw.clone()) + .map_err(|e| CliError::wrap("decode queue leave event", e))?; + Ok(Self { raw, event }) + } + /// Whether the PR *left the queue without merging* — the answer /// `queued: false` alone could never give. #[must_use] @@ -164,12 +155,6 @@ impl LeaveCheck { } } -#[derive(Deserialize)] -struct EventsResponse { - #[serde(default)] - events: Vec, -} - /// Fetch the pull request's last exit from the merge queue, or `None` /// when the activity log holds no `action.queue.leave` for it in the /// retained window (the genuine never-queued case, and the case of a @@ -181,36 +166,25 @@ struct EventsResponse { /// determine", not as a command failure: `queue show` must keep /// answering (and exiting 0) for a token that cannot read the /// repository's activity log. -pub async fn fetch( +pub async fn fetch_last( client: &Client, repository: &str, pr_number: u64, now: DateTime, ) -> Result, CliError> { - let path = format!("/v1/repos/{repository}/logs"); - let received_from = (now - TimeDelta::days(LOOKBACK_DAYS)).to_rfc3339(); - let pr = pr_number.to_string(); - - let response: EventsResponse = client - .get_with_query( - &path, - &[ - ("pull_request", pr.as_str()), - ("event_type", LEAVE_EVENT_TYPE), - ("received_from", received_from.as_str()), - // Newest-first ordering makes the single returned - // event the PR's latest exit. - ("per_page", "1"), - ], - ) - .await?; - - let Some(raw) = response.events.into_iter().next() else { - return Ok(None); + let query = Query { + pull_request: Some(pr_number), + event_types: vec![LEAVE_EVENT_TYPE.to_string()], + window: Window::retained(now), + // Newest-first ordering makes the single fetched event the + // PR's latest exit. + limit: Some(1), }; - let event: LeaveEvent = serde_json::from_value(raw.clone()) - .map_err(|e| CliError::wrap("decode queue leave event", e))?; - Ok(Some(LastLeave { raw, event })) + let events = crate::client::fetch(client, repository, &query).await?; + match events.into_iter().next() { + Some(event) => LastLeave::from_event(event).map(Some), + None => Ok(None), + } } /// Render the diagnosis for a PR that left the merge queue: a @@ -378,7 +352,7 @@ fn print_failing_checks( pub fn render_no_activity(w: &mut dyn Write, theme: &Theme) -> std::io::Result<()> { writeln!( w, - " {D}No merge-queue activity in the last {LOOKBACK_DAYS} days.{R}", + " {D}No merge-queue activity in the last {RETENTION_DAYS} days.{R}", D = theme.dim, R = theme.reset, ) @@ -467,16 +441,16 @@ mod tests { } #[tokio::test] - async fn fetch_sends_the_queue_leave_query_with_a_90_day_window() { - // Every one of these four parameters is a documented trap: - // the default 1-day window hides older dequeues, the event - // type keeps `checks_end` abort codes (which do NOT dequeue) - // out, newest-first + per_page=1 selects the latest exit. + async fn fetch_last_sends_the_queue_leave_query_with_a_90_day_window() { + // Every one of these parameters is a documented trap: the + // default 1-day window hides older dequeues, the event type + // keeps `checks_end` abort codes (which do NOT dequeue) out, + // newest-first + a single-event page selects the latest exit. let server = MockServer::start().await; arrange(&server, vec![checks_failed_event()]).await; let now = at("2026-07-30T00:00:00Z"); - let got = fetch(&client(&server), "owner/repo", 1700, now) + let got = fetch_last(&client(&server), "owner/repo", 1700, now) .await .unwrap(); assert!(got.is_some()); @@ -508,11 +482,11 @@ mod tests { } #[tokio::test] - async fn fetch_returns_none_when_the_log_holds_no_leave_event() { + async fn fetch_last_returns_none_when_the_log_holds_no_leave_event() { let server = MockServer::start().await; arrange(&server, vec![]).await; - let got = fetch( + let got = fetch_last( &client(&server), "owner/repo", 999, @@ -524,13 +498,13 @@ mod tests { } #[tokio::test] - async fn fetch_tolerates_an_event_missing_every_optional_field() { + async fn fetch_last_tolerates_an_event_missing_every_optional_field() { // A payload the CLI has never seen must still produce a // diagnosis rather than a decode error. let server = MockServer::start().await; arrange(&server, vec![json!({"type": "action.queue.leave"})]).await; - let got = fetch( + let got = fetch_last( &client(&server), "owner/repo", 1, @@ -551,7 +525,7 @@ mod tests { event["metadata"]["dequeue_code"] = json!("PR_MERGED"); arrange(&server, vec![event]).await; - let got = fetch( + let got = fetch_last( &client(&server), "owner/repo", 1700, @@ -582,8 +556,7 @@ mod tests { } fn leave_from(raw: serde_json::Value) -> LastLeave { - let event = serde_json::from_value(raw.clone()).unwrap(); - LastLeave { raw, event } + LastLeave::from_event(Event::from_raw(raw)).unwrap() } #[test] diff --git a/crates/mergify-queue/Cargo.toml b/crates/mergify-queue/Cargo.toml index b43070cf..bb9eae3e 100644 --- a/crates/mergify-queue/Cargo.toml +++ b/crates/mergify-queue/Cargo.toml @@ -11,6 +11,7 @@ publish = false [dependencies] mergify-core = { path = "../mergify-core" } +mergify-events = { path = "../mergify-events" } mergify-tui = { path = "../mergify-tui" } anstyle = { workspace = true } chrono = { workspace = true } diff --git a/crates/mergify-queue/src/lib.rs b/crates/mergify-queue/src/lib.rs index ed8a9524..63e9014c 100644 --- a/crates/mergify-queue/src/lib.rs +++ b/crates/mergify-queue/src/lib.rs @@ -3,10 +3,9 @@ //! Hosts `pause` / `unpause` (idempotent API mutations), `status` //! (read-only batch tree + waiting list, with JSON passthrough), //! and `show` (per-PR detail with checks + conditions tree, plus the -//! [`last_leave`] activity-log fallback that tells a dequeued PR -//! apart from a never-queued one). +//! activity-log fallback — `mergify_events::queue_leave` — that tells +//! a dequeued PR apart from a never-queued one). -pub mod last_leave; pub mod pause; pub mod show; pub mod status; diff --git a/crates/mergify-queue/src/show.rs b/crates/mergify-queue/src/show.rs index 2edb53be..db055150 100644 --- a/crates/mergify-queue/src/show.rs +++ b/crates/mergify-queue/src/show.rs @@ -23,8 +23,9 @@ //! dequeued ten minutes ago on failing CI, a PR nobody ever queued, //! and a PR number that does not exist. So the 404 path asks the //! activity log for the pull request's last `action.queue.leave` (see -//! [`crate::last_leave`]) and reports which of those three worlds it -//! is in, with the dequeue reason and the failing checks' URLs. +//! [`mergify_events::queue_leave`]) and reports which of those three +//! worlds it is in, with the dequeue reason and the failing checks' +//! URLs. //! //! Contract, unchanged in both modes: **exit 0**, and under `--json` a //! `queued: false` document. The JSON gains `dequeued` (`true` / @@ -46,15 +47,14 @@ use mergify_core::CliError; use mergify_core::CommandContext; use mergify_core::Output; use mergify_core::http::Client; +use mergify_events::queue_leave; +use mergify_events::queue_leave::LastLeave; use mergify_tui::StyledGlyph; use mergify_tui::Theme; use mergify_tui::relative_time; use mergify_tui::tree; use serde::Deserialize; -use crate::last_leave; -use crate::last_leave::LastLeave; - pub struct ShowOptions<'a> { pub repository: Option<&'a str>, pub token: Option<&'a str>, @@ -178,7 +178,7 @@ async fn emit_not_queued( ) -> Result<(), CliError> { let pr_number = opts.pr_number; let now = Utc::now(); - let lookup = last_leave::fetch(client, repository, pr_number, now).await; + let lookup = queue_leave::fetch_last(client, repository, pr_number, now).await; let (leave, error) = match lookup { Ok(leave) => (leave, None), Err(e) => { @@ -202,7 +202,7 @@ async fn emit_not_queued( let theme = Theme::detect(); output.emit(&(), &mut |w: &mut dyn Write| { if let Some(leave) = &leave { - return last_leave::render(w, &theme, leave, pr_number, now, opts.verbose); + return queue_leave::render(w, &theme, leave, pr_number, now, opts.verbose); } // The exact wording live smoke tests assert on. It is also // the truthful headline when the log lookup failed — the PR @@ -211,7 +211,7 @@ async fn emit_not_queued( writeln!(w, "PR #{pr_number} is not in the merge queue")?; if error.is_none() { writeln!(w)?; - last_leave::render_no_activity(w, &theme)?; + queue_leave::render_no_activity(w, &theme)?; } Ok(()) })?;