diff --git a/AGENTS.md b/AGENTS.md index 19f1c905..9683d1cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,8 +18,10 @@ pedantic clean, snapshot-tested output. Concrete rules follow. Stays dependency-light and **clap-free**. - `mergify-cli` — the binary: clap tree, dispatch, `run_native`, `self_update`, `cli_schema`. -- `mergify-stack` / `mergify-ci` / `mergify-queue` / `mergify-freeze` / - `mergify-config` — one crate per command group. +- `mergify-stack` / `mergify-ci` / `mergify-queue` / `mergify-events` / + `mergify-freeze` / `mergify-config` — one crate per command group. + `mergify-events` also hosts the shared `/logs` client the `queue` + crate's dequeue diagnosis consumes. - `mergify-test-support` — shared test scaffolding (not published). ## Error handling @@ -236,6 +238,7 @@ Doc updates ship in the **same** commit/PR as the change, never a follow-up. | `ci`, `tests` | `mergify-ci` | | `config` | `mergify-config` | | `queue` | `mergify-merge-queue` | + | `events` | `mergify-events` | | `freeze` | `mergify-merge-protections` | 3. **Crate `//!` module docs** — keep the purpose/invariant header accurate when diff --git a/Cargo.lock b/Cargo.lock index 3cfc9cec..7e0888d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1381,6 +1381,7 @@ dependencies = [ "mergify-ci", "mergify-config", "mergify-core", + "mergify-events", "mergify-freeze", "mergify-queue", "mergify-stack", @@ -1439,6 +1440,7 @@ version = "0.0.0" dependencies = [ "chrono", "mergify-core", + "mergify-test-support", "mergify-tui", "serde", "serde_json", diff --git a/README.md b/README.md index 5e7d40cf..d685b74b 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,8 @@ Every command group maps to a section of the [Docs](https://docs.mergify.com/stacks/) - **`mergify queue`** — Inspect and control the merge queue. [Docs](https://docs.mergify.com/merge-queue/) +- **`mergify events`** — Browse the events Mergify recorded for the + repository or one pull request, as a timeline or JSON. - **`mergify ci`** — Send JUnit results and pull request scopes from any CI provider. [Docs](https://docs.mergify.com/ci-insights/) - **`mergify tests`** — Look up test health and manage the flaky-test diff --git a/crates/mergify-cli/Cargo.toml b/crates/mergify-cli/Cargo.toml index 44a41fbd..75ccfb36 100644 --- a/crates/mergify-cli/Cargo.toml +++ b/crates/mergify-cli/Cargo.toml @@ -21,6 +21,7 @@ clap_mangen = { workspace = true } mergify-ci = { path = "../mergify-ci" } mergify-config = { path = "../mergify-config" } mergify-core = { path = "../mergify-core" } +mergify-events = { path = "../mergify-events" } mergify-freeze = { path = "../mergify-freeze" } mergify-queue = { path = "../mergify-queue" } mergify-stack = { path = "../mergify-stack" } diff --git a/crates/mergify-cli/src/main.rs b/crates/mergify-cli/src/main.rs index 6bd160d3..e160121d 100644 --- a/crates/mergify-cli/src/main.rs +++ b/crates/mergify-cli/src/main.rs @@ -13,6 +13,7 @@ use std::io::IsTerminal; use std::path::PathBuf; use std::process::ExitCode; +use chrono::TimeDelta; use clap::CommandFactory; use clap::Parser; use clap::Subcommand; @@ -29,6 +30,7 @@ use mergify_config::simulate::SimulateOptions; use mergify_core::OutputMode; use mergify_core::StdioOutput; use mergify_core::pull_request::PullRequestRef; +use mergify_events::list::ListOptions as EventsListOptions; use mergify_freeze::common::parse_naive_datetime; use mergify_freeze::create::CreateOptions as FreezeCreateOptions; use mergify_freeze::delete::DeleteOptions as FreezeDeleteOptions; @@ -180,6 +182,7 @@ enum NativeCommand { QueueUnpause(QueueUnpauseOpts), QueueStatus(QueueStatusOpts), QueueShow(QueueShowOpts), + Events(EventsOpts), FreezeList(FreezeListOpts), FreezeCreate(FreezeCreateOpts), FreezeUpdate(FreezeUpdateOpts), @@ -547,6 +550,17 @@ struct QueueShowOpts { output_json: bool, } +struct EventsOpts { + repository: Option, + token: Option, + api_url: Option, + pr_number: Option, + since: Option, + event_types: Vec, + limit: Option, + output_json: bool, +} + struct FreezeListOpts { repository: Option, token: Option, @@ -1028,6 +1042,25 @@ fn dispatch_from_parsed(parsed: CliRoot) -> Dispatch { verbose: parsed.verbose > 0, output_json: json, })), + Subcommands::Events(EventsCliArgs { + repository, + token, + api_url, + pr, + since, + r#type, + limit, + json, + }) => Dispatch::Native(NativeCommand::Events(EventsOpts { + repository, + token, + api_url, + pr_number: pr, + since, + event_types: r#type, + limit, + output_json: json, + })), Subcommands::Freeze(FreezeArgs { repository, token, @@ -1665,6 +1698,21 @@ fn run_native(cmd: NativeCommand) -> ExitCode { ) .await .map(|()| mergify_core::ExitCode::Success), + NativeCommand::Events(opts) => mergify_events::list::run( + EventsListOptions { + repository: opts.repository.as_deref(), + token: opts.token.as_deref(), + api_url: opts.api_url.as_deref(), + pr_number: opts.pr_number, + since: opts.since, + event_types: opts.event_types, + limit: opts.limit, + output_json: opts.output_json, + }, + &mut output, + ) + .await + .map(|()| mergify_core::ExitCode::Success), NativeCommand::FreezeList(opts) => mergify_freeze::list::run( FreezeListOptions { repository: opts.repository.as_deref(), @@ -2682,6 +2730,14 @@ enum Subcommands { /// pull request's queue state, and pause or resume merging for a /// repository. Queue(QueueArgs), + /// Browse the events Mergify recorded for the repository. + /// + /// List the activity log as a timeline — queue enters and leaves, + /// merges, commands, CI Insights, every event type — for the + /// whole repository or one pull request (`--pr`). The output + /// always states the time window it covers: the last 24 hours by + /// default, up to the 90 days the log retains (`--since 90d`). + Events(EventsCliArgs), /// Schedule and manage merge freezes. /// /// Create, list, update, and delete freezes that temporarily stop @@ -4204,6 +4260,52 @@ struct ShowCliArgs { json: bool, } +#[derive(clap::Args)] +struct EventsCliArgs { + /// Mergify or GitHub token. Falls back to ``MERGIFY_TOKEN`` and + /// then ``GITHUB_TOKEN`` env vars. + #[arg(long, short = 't')] + token: Option, + + /// Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, + /// then to the default. + #[arg(long = "api-url", short = 'u')] + api_url: Option, + + /// Repository full name (owner/repo). Falls back to + /// ``GITHUB_REPOSITORY`` env var. + #[arg(long, short = 'r')] + repository: Option, + + /// Only events for this pull request; omit to cover the whole + /// repository. + #[arg(long, value_name = "PR_NUMBER")] + pr: Option, + + /// How far back to look: an integer with a unit — s, m, h, d or w + /// (e.g. 30m, 12h, 7d). Defaults to 24h; the log retains 90 days + /// (--since 90d is the widest useful window). The window always + /// ends now and is stated in the output. + #[arg(long, value_name = "DURATION", value_parser = mergify_events::list::parse_since)] + since: Option, + + /// Only events of this type (e.g. action.queue.leave). Repeat the + /// flag to match several types; values pass to the API verbatim, + /// so types newer than this CLI work too. + #[arg(long = "type", value_name = "EVENT_TYPE")] + r#type: Vec, + + /// Stop after the newest N events instead of fetching the whole + /// window. The output says so when it takes effect. + #[arg(long, value_name = "N")] + limit: Option, + + /// Emit a single JSON document (the raw events, newest first, + /// with the queried window echoed) instead of the timeline. + #[arg(long, default_value_t = false)] + json: bool, +} + #[derive(clap::Args)] struct FreezeArgs { /// Mergify or GitHub token. Falls back to ``MERGIFY_TOKEN`` and @@ -4376,6 +4478,7 @@ mod tests { "ci", "tests", "queue", + "events", "freeze", "stack", "self-update", @@ -4614,6 +4717,62 @@ mod tests { assert_eq!(opts.files, vec!["report.xml"]); } + #[test] + fn events_dispatches_natively_with_every_flag() { + let parsed = parse(&[ + "events", + "-r", + "owner/repo", + "--pr", + "1740", + "--since", + "7d", + "--type", + "action.queue.leave", + "--type", + "command.queue", + "--limit", + "50", + "--json", + ]); + let Dispatch::Native(NativeCommand::Events(opts)) = dispatch_from_parsed(parsed) else { + panic!("events must dispatch to the native Events variant"); + }; + assert_eq!(opts.repository.as_deref(), Some("owner/repo")); + assert_eq!(opts.pr_number, Some(1740)); + assert_eq!(opts.since, Some(TimeDelta::days(7))); + assert_eq!( + opts.event_types, + vec!["action.queue.leave", "command.queue"], + ); + assert_eq!(opts.limit, Some(50)); + assert!(opts.output_json); + } + + #[test] + fn events_defaults_to_repo_wide_last_24h() { + // No --pr, no --since: the command covers the repository and + // the run applies (and states) the 24h default itself. + let parsed = parse(&["events"]); + let Dispatch::Native(NativeCommand::Events(opts)) = dispatch_from_parsed(parsed) else { + panic!("events must dispatch to the native Events variant"); + }; + assert_eq!(opts.pr_number, None); + assert_eq!(opts.since, None); + assert!(opts.event_types.is_empty()); + assert!(!opts.output_json); + } + + #[test] + fn events_rejects_a_since_past_the_retention_cap() { + // The impossible window dies as a usage error carrying the + // fix, not as an API 422 later. + let Err(err) = CliRoot::try_parse_from(["mergify", "events", "--since", "94d"]) else { + panic!("a 94d window must be a usage error"); + }; + assert!(err.to_string().contains("90d"), "got: {err}"); + } + #[test] fn tests_quarantines_add_dispatches_natively() { let parsed = parse(&[ diff --git a/crates/mergify-cli/src/snapshots/mergify__tests__cli_schema_golden.snap b/crates/mergify-cli/src/snapshots/mergify__tests__cli_schema_golden.snap index bffeaa66..32c53d8b 100644 --- a/crates/mergify-cli/src/snapshots/mergify__tests__cli_schema_golden.snap +++ b/crates/mergify-cli/src/snapshots/mergify__tests__cli_schema_golden.snap @@ -1746,6 +1746,164 @@ expression: schema "subcommandRequired": true, "usage": "mergify queue [OPTIONS] " }, + { + "about": "Browse the events Mergify recorded for the repository", + "aliases": [], + "args": [ + { + "default": null, + "env": null, + "global": false, + "help": "Mergify or GitHub token. Falls back to ``MERGIFY_TOKEN`` and then ``GITHUB_TOKEN`` env vars", + "id": "token", + "kind": "option", + "long": "token", + "longHelp": "Mergify or GitHub token. Falls back to ``MERGIFY_TOKEN`` and then ``GITHUB_TOKEN`` env vars", + "numArgs": "1", + "possibleValues": [], + "required": false, + "short": "t", + "valueHint": null, + "valueNames": [ + "TOKEN" + ] + }, + { + "default": null, + "env": null, + "global": false, + "help": "Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, then to the default", + "id": "api_url", + "kind": "option", + "long": "api-url", + "longHelp": "Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, then to the default", + "numArgs": "1", + "possibleValues": [], + "required": false, + "short": "u", + "valueHint": null, + "valueNames": [ + "API_URL" + ] + }, + { + "default": null, + "env": null, + "global": false, + "help": "Repository full name (owner/repo). Falls back to ``GITHUB_REPOSITORY`` env var", + "id": "repository", + "kind": "option", + "long": "repository", + "longHelp": "Repository full name (owner/repo). Falls back to ``GITHUB_REPOSITORY`` env var", + "numArgs": "1", + "possibleValues": [], + "required": false, + "short": "r", + "valueHint": null, + "valueNames": [ + "REPOSITORY" + ] + }, + { + "default": null, + "env": null, + "global": false, + "help": "Only events for this pull request; omit to cover the whole repository", + "id": "pr", + "kind": "option", + "long": "pr", + "longHelp": "Only events for this pull request; omit to cover the whole repository", + "numArgs": "1", + "possibleValues": [], + "required": false, + "short": null, + "valueHint": null, + "valueNames": [ + "PR_NUMBER" + ] + }, + { + "default": null, + "env": null, + "global": false, + "help": "How far back to look: an integer with a unit — s, m, h, d or w (e.g. 30m, 12h, 7d). Defaults to 24h; the log retains 90 days (--since 90d is the widest useful window). The window always ends now and is stated in the output", + "id": "since", + "kind": "option", + "long": "since", + "longHelp": "How far back to look: an integer with a unit — s, m, h, d or w (e.g. 30m, 12h, 7d). Defaults to 24h; the log retains 90 days (--since 90d is the widest useful window). The window always ends now and is stated in the output", + "numArgs": "1", + "possibleValues": [], + "required": false, + "short": null, + "valueHint": null, + "valueNames": [ + "DURATION" + ] + }, + { + "default": null, + "env": null, + "global": false, + "help": "Only events of this type (e.g. action.queue.leave). Repeat the flag to match several types; values pass to the API verbatim, so types newer than this CLI work too", + "id": "type", + "kind": "option", + "long": "type", + "longHelp": "Only events of this type (e.g. action.queue.leave). Repeat the flag to match several types; values pass to the API verbatim, so types newer than this CLI work too", + "numArgs": "1", + "possibleValues": [], + "required": false, + "short": null, + "valueHint": null, + "valueNames": [ + "EVENT_TYPE" + ] + }, + { + "default": null, + "env": null, + "global": false, + "help": "Stop after the newest N events instead of fetching the whole window. The output says so when it takes effect", + "id": "limit", + "kind": "option", + "long": "limit", + "longHelp": "Stop after the newest N events instead of fetching the whole window. The output says so when it takes effect", + "numArgs": "1", + "possibleValues": [], + "required": false, + "short": null, + "valueHint": null, + "valueNames": [ + "N" + ] + }, + { + "default": "false", + "env": null, + "global": false, + "help": "Emit a single JSON document (the raw events, newest first, with the queried window echoed) instead of the timeline", + "id": "json", + "kind": "flag", + "long": "json", + "longHelp": "Emit a single JSON document (the raw events, newest first, with the queried window echoed) instead of the timeline", + "numArgs": "0", + "possibleValues": [], + "required": false, + "short": null, + "valueHint": null, + "valueNames": [] + } + ], + "commands": [], + "longAbout": "Browse the events Mergify recorded for the repository.\n\nList the activity log as a timeline — queue enters and leaves, merges, commands, CI Insights, every event type — for the whole repository or one pull request (`--pr`). The output always states the time window it covers: the last 24 hours by default, up to the 90 days the log retains (`--since 90d`).", + "name": "events", + "path": [ + "mergify", + "events" + ], + "source": "native", + "subcommandRequired": false, + "usage": "mergify events [OPTIONS]" + }, { "about": "Schedule and manage merge freezes", "aliases": [], diff --git a/crates/mergify-events/Cargo.toml b/crates/mergify-events/Cargo.toml index 869fb0aa..4326c181 100644 --- a/crates/mergify-events/Cargo.toml +++ b/crates/mergify-events/Cargo.toml @@ -18,6 +18,7 @@ serde_json = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +mergify-test-support = { path = "../mergify-test-support" } tokio = { workspace = true } url = { workspace = true } wiremock = { workspace = true } diff --git a/crates/mergify-events/src/event.rs b/crates/mergify-events/src/event.rs index 9b17c46a..dd27080c 100644 --- a/crates/mergify-events/src/event.rs +++ b/crates/mergify-events/src/event.rs @@ -30,6 +30,8 @@ struct Envelope { trigger: Option, #[serde(default)] pull_request: Option, + #[serde(default)] + outcome: Option, } impl Event { @@ -79,6 +81,13 @@ impl Event { self.envelope.pull_request } + /// The API's derived outcome label (`success` / `failure` / + /// `pending` / `neutral`). + #[must_use] + pub fn outcome(&self) -> Option<&str> { + non_empty(self.envelope.outcome.as_deref()) + } + /// The event's type-specific `metadata` object; `Null` when the /// payload has none. Callers decode the slice of it they /// understand. diff --git a/crates/mergify-events/src/lib.rs b/crates/mergify-events/src/lib.rs index bd4418cd..0d5f5349 100644 --- a/crates/mergify-events/src/lib.rs +++ b/crates/mergify-events/src/lib.rs @@ -42,6 +42,7 @@ pub mod client; pub mod event; +pub mod list; pub mod queue_leave; pub mod window; diff --git a/crates/mergify-events/src/list.rs b/crates/mergify-events/src/list.rs new file mode 100644 index 00000000..4a9daa61 --- /dev/null +++ b/crates/mergify-events/src/list.rs @@ -0,0 +1,633 @@ +//! `mergify events` — browse the repository's activity log as a +//! timeline, repo-wide or for one pull request. +//! +//! One command over a filter covers all ~45 event types; there is +//! deliberately no command per type. Two details carry the lesson +//! the `/logs` contract taught `queue show` (see the crate docs): +//! the header **always states the window**, so an empty result reads +//! as "nothing in the last 24 hours" and never as "nothing ever" — +//! that ambiguity is the bug this command removes — and the empty +//! case says so explicitly, naming the range and the retention. +//! +//! Two output modes: +//! +//! - Human (default): a header naming the scope, the event count and +//! the exact UTC window, then one line per event, **oldest first** +//! (a timeline reads down the page), with a dim date row wherever +//! the calendar date changes. +//! - `--json`: a single document with the query echoed +//! (`repository`, `pull_request`, `received_from`, `received_to`), +//! `size`, and `events` — the raw API events **newest first**, +//! unknown fields intact, matching the client's ordering guarantee. + +use std::io::Write; + +use chrono::DateTime; +use chrono::TimeDelta; +use chrono::Utc; +use mergify_core::CliError; +use mergify_core::CommandContext; +use mergify_core::Output; +use mergify_tui::Theme; +use serde_json::Value; + +use crate::client; +use crate::client::Query; +use crate::event::Event; +use crate::window::MAX_SPAN_DAYS; +use crate::window::RETENTION_DAYS; +use crate::window::Window; + +/// The window used when `--since` is not given. Matches the API's +/// own default — but stated in the output instead of silently +/// applied, which is the difference that matters. +const DEFAULT_SINCE: TimeDelta = TimeDelta::hours(24); + +pub struct ListOptions<'a> { + pub repository: Option<&'a str>, + pub token: Option<&'a str>, + pub api_url: Option<&'a str>, + /// Only this pull request's events; `None` covers the repository. + pub pr_number: Option, + /// How far back to look; `None` applies [`DEFAULT_SINCE`]. + pub since: Option, + /// `event_type` filters, passed to the API verbatim (repeatable, + /// OR semantics); empty selects every type. + pub event_types: Vec, + /// Stop after the newest N events instead of fetching the whole + /// window. The header says so when it takes effect. + pub limit: Option, + pub output_json: bool, +} + +/// Parse a `--since` duration: an integer with a unit — `s`, `m`, +/// `h`, `d`, or `w` (e.g. `30m`, `12h`, `7d`). +/// +/// The span cap is enforced here so an impossible window dies as a +/// usage error with the fix in it, not as an API 422 later. +/// +/// # Errors +/// +/// A message suitable for clap's `invalid value` report. +pub fn parse_since(value: &str) -> Result { + let value = value.trim(); + let (number, unit) = value.split_at(value.len().saturating_sub(1)); + let count: i64 = + number.parse().ok().filter(|n| *n > 0).ok_or_else(|| { + "expected a positive integer with a unit, e.g. 30m, 12h, 7d".to_string() + })?; + let span = match unit { + "s" => TimeDelta::seconds(count), + "m" => TimeDelta::minutes(count), + "h" => TimeDelta::hours(count), + "d" => TimeDelta::days(count), + "w" => TimeDelta::weeks(count), + other => { + return Err(format!( + "unknown unit {other:?}: use s, m, h, d or w, e.g. 30m, 12h, 7d" + )); + } + }; + if span > TimeDelta::days(MAX_SPAN_DAYS) { + return Err(format!( + "the activity log retains {RETENTION_DAYS} days and the API rejects \ + windows over {MAX_SPAN_DAYS} days — use {RETENTION_DAYS}d or less" + )); + } + Ok(span) +} + +/// Run the `events` command. +pub async fn run(opts: ListOptions<'_>, output: &mut dyn Output) -> Result<(), CliError> { + run_at(opts, Utc::now(), output).await +} + +/// [`run`] with an injected clock, the testable seam — the header +/// and the window bounds derive from `now`. +pub async fn run_at( + opts: ListOptions<'_>, + now: DateTime, + output: &mut dyn Output, +) -> Result<(), CliError> { + let ctx = CommandContext::resolve(opts.repository, opts.token, opts.api_url)?; + let client = ctx.mergify_client()?; + + let window = Window::last(opts.since.unwrap_or(DEFAULT_SINCE), now) + // `parse_since` already enforced the cap; anything left is a + // caller bug surfaced as the typed message rather than a panic. + .map_err(|e| CliError::InvalidState(e.to_string()))?; + + let scope = match opts.pr_number { + Some(n) => format!("PR #{n}"), + None => ctx.repository.clone(), + }; + output.status(&format!("Fetching events for {scope}…"))?; + + let query = Query { + pull_request: opts.pr_number, + event_types: opts.event_types.clone(), + window, + limit: opts.limit, + }; + let events = client::fetch(&client, &ctx.repository, &query).await?; + + if opts.output_json { + return emit_json(output, &ctx.repository, &opts, &window, &events); + } + + let theme = Theme::detect(); + let truncated = opts.limit.is_some_and(|limit| events.len() == limit); + output.emit(&(), &mut |w: &mut dyn Write| { + render(w, &theme, &scope, &window, &events, truncated) + })?; + Ok(()) +} + +/// `--json`: the query echoed back plus the raw events, newest +/// first. Echoing `received_from`/`received_to` keeps the +/// anti-ambiguity contract for machine consumers too — an empty +/// `events` names the window it is empty *over*. +fn emit_json( + output: &mut dyn Output, + repository: &str, + opts: &ListOptions<'_>, + window: &Window, + events: &[Event], +) -> Result<(), CliError> { + let raw: Vec<&Value> = events.iter().map(|event| &event.raw).collect(); + output.emit_json_value(&serde_json::json!({ + "repository": repository, + "pull_request": opts.pr_number, + "received_from": window.from().to_rfc3339(), + "received_to": window.to().to_rfc3339(), + "size": events.len(), + "events": raw, + }))?; + Ok(()) +} + +fn render( + w: &mut dyn Write, + theme: &Theme, + scope: &str, + window: &Window, + events: &[Event], + truncated: bool, +) -> std::io::Result<()> { + let from = format_minute(window.from()); + let to = format_minute(window.to()); + + if events.is_empty() { + // The empty case must name the window: "nothing in the last + // 24h" and "nothing ever" are different answers, and the + // second one is not this command's to give. + writeln!(w, "No events for {scope} between {from} and {to} UTC.")?; + if window.from() > window.to() - TimeDelta::days(RETENTION_DAYS) { + writeln!( + w, + "{D}Retention is {RETENTION_DAYS} days; try --since {RETENTION_DAYS}d.{R}", + D = theme.dim, + R = theme.reset, + )?; + } + return Ok(()); + } + + let count = if truncated { + format!("newest {n} events", n = events.len()) + } else if events.len() == 1 { + "1 event".to_string() + } else { + format!("{n} events", n = events.len()) + }; + writeln!( + w, + "{B}{scope}{R} {D}· {count} · {from} → {to} UTC{R}", + B = theme.bold, + R = theme.reset, + D = theme.dim, + )?; + writeln!(w)?; + + let type_width = events + .iter() + .map(|event| event.event_type().unwrap_or("?").chars().count()) + .max() + .unwrap_or(0); + + // Oldest first: a timeline reads down the page. Date rows appear + // wherever the calendar date changes — and before the first event + // when the window spans more than one date, where a bare time + // would be ambiguous. + let mut current_date: Option = None; + let multi_date = window.from().date_naive() != window.to().date_naive(); + for event in events.iter().rev() { + let stamp = event.received_at_utc(); + if let Some(date) = stamp.map(|ts| ts.format("%Y-%m-%d").to_string()) { + let changed = current_date.as_ref().is_some_and(|d| *d != date); + if changed || (current_date.is_none() && multi_date) { + writeln!(w, " {D}{date}{R}", D = theme.dim, R = theme.reset)?; + } + current_date = Some(date); + } + let time = stamp.map_or_else(|| "--:--".to_string(), |ts| ts.format("%H:%M").to_string()); + let event_type = event.event_type().unwrap_or("?"); + write!( + w, + " {D}{time}{R} {event_type: writeln!(w, " {D}{summary}{R}", D = theme.dim, R = theme.reset)?, + None => writeln!(w)?, + } + } + Ok(()) +} + +fn format_minute(ts: DateTime) -> String { + ts.format("%Y-%m-%d %H:%M").to_string() +} + +/// One best-effort hint per event line, read from the metadata +/// fields the queue family is known to carry. Everything here +/// degrades: an unknown type (or a known type missing a field) just +/// renders without a summary — `--json` has the whole payload. +fn summary(event: &Event) -> Option { + let meta = event.metadata(); + let text = |key: &str| { + meta.get(key) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + }; + let flag = |key: &str| meta.get(key).and_then(Value::as_bool).unwrap_or(false); + + match event.event_type().unwrap_or_default() { + "action.queue.enter" => text("queue_name"), + "action.queue.checks_start" => { + let mut parts: Vec = Vec::new(); + parts.extend(text("queue_name")); + if let Some(draft) = meta + .get("speculative_check_pull_request") + .and_then(Value::as_u64) + { + parts.push(format!("draft PR #{draft}")); + } + (!parts.is_empty()).then(|| parts.join(" · ")) + } + // The abort codes riding on checks_end interrupt the checks + // while the PR stays queued — naming them here is what keeps + // a reader from mistaking one for a dequeue. + "action.queue.checks_end" if flag("aborted") => text("abort_code"), + "action.queue.leave" => { + if flag("merged") { + Some("merged".to_string()) + } else { + text("dequeue_code") + } + } + t if t.starts_with("command.") => event.trigger().map(str::to_owned), + _ => event + .outcome() + .map(str::to_owned) + .or_else(|| event.trigger().map(str::to_owned)), + } +} + +#[cfg(test)] +mod tests { + use mergify_core::OutputMode; + use mergify_test_support::Captured; + use serde_json::json; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + + use super::*; + + fn at(iso: &str) -> DateTime { + DateTime::parse_from_rfc3339(iso) + .unwrap() + .with_timezone(&Utc) + } + + fn queue_lifecycle() -> Vec { + // Newest first, as the API serves them. + vec![ + json!({ + "id": 5, + "type": "command.queue", + "received_at": "2026-07-30T15:12:00Z", + "trigger": "@jd", + "pull_request": 1740, + "metadata": {}, + }), + json!({ + "id": 4, + "type": "action.queue.leave", + "received_at": "2026-07-30T15:04:00Z", + "trigger": "merge queue internal", + "pull_request": 1740, + "metadata": {"merged": false, "dequeue_code": "CHECKS_FAILED"}, + }), + json!({ + "id": 3, + "type": "action.queue.checks_end", + "received_at": "2026-07-30T15:04:00Z", + "outcome": "failure", + "pull_request": 1740, + "metadata": {"aborted": false}, + }), + json!({ + "id": 2, + "type": "action.queue.checks_start", + "received_at": "2026-07-30T14:31:00Z", + "pull_request": 1740, + "metadata": {"queue_name": "default", "speculative_check_pull_request": 1801}, + }), + json!({ + "id": 1, + "type": "action.queue.enter", + "received_at": "2026-07-30T14:02:00Z", + "pull_request": 1740, + "metadata": {"queue_name": "default"}, + }), + ] + } + + async fn arrange(server: &MockServer, events: Vec) { + Mock::given(method("GET")) + .and(path("/v1/repos/owner/repo/logs")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "size": events.len(), + "per_page": 100, + "events": events, + }))) + .expect(1) + .mount(server) + .await; + } + + async fn run_list( + server: &MockServer, + opts_for: impl FnOnce(&str) -> (Option, Option, Vec, Option), + output_json: bool, + ) -> Captured { + let api_url = server.uri(); + let (pr_number, since, event_types, limit) = opts_for(&api_url); + let mut cap = if output_json { + Captured::new(OutputMode::Json) + } else { + Captured::human() + }; + run_at( + ListOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + pr_number, + since, + event_types, + limit, + output_json, + }, + at("2026-07-30T21:00:00Z"), + &mut cap.output, + ) + .await + .unwrap(); + cap + } + + #[tokio::test] + async fn human_header_states_scope_count_and_window() { + let server = MockServer::start().await; + arrange(&server, queue_lifecycle()).await; + + let cap = run_list(&server, |_| (Some(1740), None, vec![], None), false).await; + let stdout = cap.stdout(); + // The window is the whole point: an empty-looking day must + // never read as an empty history. + assert!( + stdout.contains("PR #1740 · 5 events · 2026-07-29 21:00 → 2026-07-30 21:00 UTC"), + "got: {stdout}", + ); + } + + #[tokio::test] + async fn human_timeline_reads_oldest_first_with_summaries() { + let server = MockServer::start().await; + arrange(&server, queue_lifecycle()).await; + + let cap = run_list(&server, |_| (Some(1740), None, vec![], None), false).await; + let stdout = cap.stdout(); + let enter = stdout.find("action.queue.enter").unwrap(); + let leave = stdout.find("action.queue.leave").unwrap(); + let requeue = stdout.find("command.queue").unwrap(); + assert!( + enter < leave && leave < requeue, + "a timeline reads down the page: {stdout}", + ); + assert!(stdout.contains("14:02"), "got: {stdout}"); + // Per-type summaries from metadata. + assert!(stdout.contains("default · draft PR #1801"), "got: {stdout}"); + assert!(stdout.contains("CHECKS_FAILED"), "got: {stdout}"); + assert!(stdout.contains("failure"), "got: {stdout}"); + assert!(stdout.contains("@jd"), "got: {stdout}"); + } + + #[tokio::test] + async fn human_timeline_marks_date_changes() { + let server = MockServer::start().await; + arrange( + &server, + vec![ + json!({ + "id": 2, + "type": "action.merge", + "received_at": "2026-07-30T09:00:00Z", + "outcome": "success", + }), + json!({ + "id": 1, + "type": "action.queue.enter", + "received_at": "2026-07-29T22:00:00Z", + "metadata": {"queue_name": "default"}, + }), + ], + ) + .await; + + let cap = run_list(&server, |_| (None, None, vec![], None), false).await; + let stdout = cap.stdout(); + // Both dates appear as rows: the window spans two dates, so a + // bare `22:00` would be ambiguous. + assert!(stdout.contains(" 2026-07-29\n"), "got: {stdout}"); + assert!(stdout.contains(" 2026-07-30\n"), "got: {stdout}"); + } + + #[tokio::test] + async fn human_repo_wide_header_names_the_repository() { + let server = MockServer::start().await; + arrange(&server, queue_lifecycle()).await; + + let cap = run_list(&server, |_| (None, None, vec![], None), false).await; + let stdout = cap.stdout(); + assert!(stdout.contains("owner/repo · 5 events"), "got: {stdout}"); + } + + #[tokio::test] + async fn human_empty_names_the_window_and_the_retention() { + // The bug this command removes: an empty result must read as + // "nothing in this window", never "nothing ever". + let server = MockServer::start().await; + arrange(&server, vec![]).await; + + let cap = run_list(&server, |_| (Some(1740), None, vec![], None), false).await; + let stdout = cap.stdout(); + assert!( + stdout.contains( + "No events for PR #1740 between 2026-07-29 21:00 and 2026-07-30 21:00 UTC." + ), + "got: {stdout}", + ); + assert!( + stdout.contains("Retention is 90 days; try --since 90d."), + "got: {stdout}", + ); + } + + #[tokio::test] + async fn human_empty_at_full_retention_offers_no_wider_window() { + // `--since 90d` came back empty: there is nothing wider to + // suggest, and suggesting it anyway would be noise. + let server = MockServer::start().await; + arrange(&server, vec![]).await; + + let cap = run_list( + &server, + |_| (Some(1740), Some(TimeDelta::days(90)), vec![], None), + false, + ) + .await; + let stdout = cap.stdout(); + assert!(stdout.contains("No events for PR #1740"), "got: {stdout}"); + assert!(!stdout.contains("try --since"), "got: {stdout}"); + } + + #[tokio::test] + async fn human_header_says_newest_when_the_limit_bites() { + // A silent cap would read as "that's everything"; the header + // must say the list was cut. + let server = MockServer::start().await; + let events: Vec = queue_lifecycle().into_iter().take(2).collect(); + arrange(&server, events).await; + + let cap = run_list(&server, |_| (Some(1740), None, vec![], Some(2)), false).await; + let stdout = cap.stdout(); + assert!(stdout.contains("newest 2 events"), "got: {stdout}"); + } + + #[tokio::test] + async fn human_survives_an_unknown_event_type() { + let server = MockServer::start().await; + arrange( + &server, + vec![json!({ + "id": 1, + "type": "something.from.2027", + "received_at": "2026-07-30T14:00:00Z", + "metadata": {"mystery": true}, + })], + ) + .await; + + let cap = run_list(&server, |_| (None, None, vec![], None), false).await; + let stdout = cap.stdout(); + assert!(stdout.contains("something.from.2027"), "got: {stdout}"); + assert!(stdout.contains("14:00"), "got: {stdout}"); + } + + #[tokio::test] + async fn json_echoes_the_query_and_republishes_raw_events_newest_first() { + let server = MockServer::start().await; + let events = queue_lifecycle(); + arrange(&server, events.clone()).await; + + let cap = run_list(&server, |_| (Some(1740), None, vec![], None), true).await; + let parsed: Value = serde_json::from_str(&cap.stdout()).unwrap(); + assert_eq!( + parsed, + json!({ + "repository": "owner/repo", + "pull_request": 1740, + "received_from": "2026-07-29T21:00:00+00:00", + "received_to": "2026-07-30T21:00:00+00:00", + "size": 5, + "events": events, + }), + ); + } + + #[tokio::test] + async fn json_keeps_unknown_fields_intact() { + let server = MockServer::start().await; + let raw = json!({ + "id": 1, + "type": "action.queue.leave", + "received_at": "2026-07-30T14:00:00Z", + "field_from_2027": {"nested": [1, 2, 3]}, + }); + arrange(&server, vec![raw.clone()]).await; + + let cap = run_list(&server, |_| (None, None, vec![], None), true).await; + let parsed: Value = serde_json::from_str(&cap.stdout()).unwrap(); + assert_eq!(parsed["events"][0], raw); + } + + #[tokio::test] + async fn json_empty_still_names_the_window() { + let server = MockServer::start().await; + arrange(&server, vec![]).await; + + let cap = run_list(&server, |_| (None, None, vec![], None), true).await; + let parsed: Value = serde_json::from_str(&cap.stdout()).unwrap(); + assert_eq!(parsed["size"], json!(0)); + assert_eq!(parsed["events"], json!([])); + assert_eq!(parsed["received_from"], json!("2026-07-29T21:00:00+00:00")); + assert_eq!(parsed["pull_request"], json!(null)); + } + + #[test] + fn parse_since_reads_the_documented_units() { + assert_eq!(parse_since("45s").unwrap(), TimeDelta::seconds(45)); + assert_eq!(parse_since("30m").unwrap(), TimeDelta::minutes(30)); + assert_eq!(parse_since("12h").unwrap(), TimeDelta::hours(12)); + assert_eq!(parse_since("7d").unwrap(), TimeDelta::days(7)); + assert_eq!(parse_since("2w").unwrap(), TimeDelta::weeks(2)); + } + + #[test] + fn parse_since_rejects_garbage_with_the_format_in_the_message() { + for bad in ["", "7", "d", "-7d", "0d", "7x", "1.5h"] { + let err = parse_since(bad).unwrap_err(); + assert!( + err.contains("7d") || err.contains("30m"), + "the error must show the expected shape, got: {err}", + ); + } + } + + #[test] + fn parse_since_rejects_a_span_past_the_cap_and_names_the_fix() { + let err = parse_since("94d").unwrap_err(); + assert!(err.contains("90d"), "got: {err}"); + assert!(parse_since("93d").is_ok()); + let err = parse_since("14w").unwrap_err(); + assert!(err.contains("90d"), "got: {err}"); + } +} diff --git a/skills/mergify-events/SKILL.md b/skills/mergify-events/SKILL.md new file mode 100644 index 00000000..b8f9fa0b --- /dev/null +++ b/skills/mergify-events/SKILL.md @@ -0,0 +1,68 @@ +--- +name: mergify-events +description: Use `mergify events` to browse the Mergify activity log — every event Mergify recorded for a repository or one pull request (queue enters/leaves, merges, commands, CI Insights, freezes), as a human timeline or JSON. ALWAYS use this skill when investigating what Mergify did to a PR or repository and when, reconstructing a pull request's merge-queue lifecycle, auditing Mergify actions, or filtering events by type. Triggers on activity log, event log, events, what did Mergify do, queue lifecycle, queue history, event timeline, event_type, action.queue. +--- + +# Mergify Events + +## Overview + +`mergify events` lists the repository's Mergify activity log as a timeline — the ~45 event types the engine records: the `action.queue.*` lifecycle, workflow actions (`action.merge`, `action.rebase`, `action.label`, …), user commands (`command.queue`, `command.dequeue`), `ci_insights.*`, queue pauses, and scheduled freezes. One command over filters; there is deliberately no command per event type. + +```bash +mergify events # whole repo, last 24h +mergify events --pr 1740 # one PR's events, last 24h +mergify events --pr 1740 --since 7d # wider window (s/m/h/d/w, max 90d) +mergify events --type action.queue.leave --type command.queue # filter, repeatable +mergify events --pr 1740 --json # raw events, newest first +mergify events --limit 20 # newest 20 only (the header says so) +``` + +## The window rule (the one thing to get right) + +Every result covers an **explicit time window**, stated in the header and in the empty-case message: + +``` +PR #1740 · 6 events · 2026-07-29 21:00 → 2026-07-30 21:00 UTC +``` + +- Default window: the **last 24 hours**. A PR dequeued last week shows **nothing** in the default window — that is "nothing in the last 24h", never "no history". +- Retention is **90 days**; `--since 90d` is the widest useful window. Anything wider is rejected up front with the fix in the message. +- An empty result names the window: `No events for PR #1740 between and UTC.` If you did not search the full retention yet, widen with `--since 90d` before concluding anything. + +## Reading the timeline + +Oldest first (it reads down the page), with a summary per event where the metadata carries one: + +``` + 2026-07-30 + 14:02 action.queue.enter default + 14:31 action.queue.checks_start default · draft PR #1801 + 15:04 action.queue.checks_end CHECKS_RETRIED + 15:04 action.queue.leave CHECKS_FAILED + 15:12 command.queue @jd +``` + +Caveat that prevents a real mistake: an abort code on **`action.queue.checks_end`** (`PR_AHEAD_DEQUEUED`, `MERGE_QUEUE_RESET`, `CHECKS_RETRIED`, …) means the *checks* were interrupted while the PR **stayed queued**. Only **`action.queue.leave`** means the PR left the queue — and its `merged: true` variant means it left by merging. Do not requeue a PR over a `checks_end` event. + +## JSON contract + +`--json` emits one document; `events` are the API's raw objects (unknown fields intact), **newest first**: + +```json +{ + "repository": "owner/repo", + "pull_request": 1740, + "received_from": "2026-07-29T21:00:00+00:00", + "received_to": "2026-07-30T21:00:00+00:00", + "size": 6, + "events": [ { "id": 123, "type": "action.queue.leave", "received_at": "…", "metadata": { "…": "…" } } ] +} +``` + +The window is echoed so an empty `events` is self-describing. Filter with `jq` on `.events[].type` and `.events[].metadata`. + +## When to use something else + +- **"Why was this PR dequeued?"** — `mergify queue show ` (the `mergify-merge-queue` skill) is the dedicated answer: it renders the last leave event with the reason, the failing checks' job URLs, and the head-SHA staleness check. `mergify events` is for the *whole* trail or for non-queue events. +- **Raw API access** (CLI not installed, or a token refused the log): `GET /v1/repos/{owner}/{repo}/logs` — same data; always pass `received_from` (the API silently defaults to 1 day) and keep the span ≤ 93 days. diff --git a/skills/mergify-merge-queue/SKILL.md b/skills/mergify-merge-queue/SKILL.md index 41b9835b..409ad47f 100644 --- a/skills/mergify-merge-queue/SKILL.md +++ b/skills/mergify-merge-queue/SKILL.md @@ -37,7 +37,7 @@ mergify queue pause --reason "..." # Pause the queue (requires reason) mergify queue unpause # Resume the queue ``` -That is the whole `queue` group: `status`, `show`, `pause`, `unpause`. There is no subcommand for dequeuing a PR and none for browsing queue history — the dequeue reason comes from `queue show` on a PR that has left the queue, not from a flag. +That is the whole `queue` group: `status`, `show`, `pause`, `unpause`. There is no subcommand for dequeuing a PR — the dequeue reason comes from `queue show` on a PR that has left the queue, not from a flag. For the full queue *trail* (every enter / checks / leave event, not just the last exit), use `mergify events --pr --since 90d` — see the `mergify-events` skill. ## Is the PR queued, dequeued, or never queued? @@ -111,7 +111,7 @@ Reading the activity log is **best-effort**: a token scoped to the merge queue c ### 2. The Mergify activity log (what surface 1 reads underneath) -`GET /v1/repos/{owner}/{repo}/logs` returns the queue lifecycle events, newest first. `queue show` calls this for you; go direct when it degrades (403 above), when you need the whole lifecycle rather than the last exit, or from somewhere the CLI is not installed: +`GET /v1/repos/{owner}/{repo}/logs` returns the queue lifecycle events, newest first. `queue show` calls this for you, and `mergify events --pr --since 90d` (the `mergify-events` skill) browses the whole lifecycle from the CLI — including every event type, not just queue ones. Go direct with `curl` only when it degrades (403 above, with a token that can read the log) or from somewhere the CLI is not installed: ```bash REPO=owner/repo