Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/mergify-events/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
8 changes: 8 additions & 0 deletions crates/mergify-events/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
Original file line number Diff line number Diff line change
@@ -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/<repo>/merge-queue/pull/<n>` 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/<repo>/logs
//! ?pull_request=<n>
//! &event_type=action.queue.leave
//! &received_from=<now - 90d>
//! &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
Expand All @@ -48,19 +27,17 @@
use std::io::Write;

use chrono::DateTime;
use chrono::TimeDelta;
use chrono::Utc;
use mergify_core::CliError;
use mergify_core::http::Client;
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";

Expand All @@ -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<Self, CliError> {
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]
Expand Down Expand Up @@ -164,12 +155,6 @@ impl LeaveCheck {
}
}

#[derive(Deserialize)]
struct EventsResponse {
#[serde(default)]
events: Vec<serde_json::Value>,
}

/// 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
Expand All @@ -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<Utc>,
) -> Result<Option<LastLeave>, 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
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions crates/mergify-queue/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
5 changes: 2 additions & 3 deletions crates/mergify-queue/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 8 additions & 8 deletions crates/mergify-queue/src/show.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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` /
Expand All @@ -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>,
Expand Down Expand Up @@ -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) => {
Expand All @@ -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
Expand All @@ -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(())
})?;
Expand Down
Loading