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
1 change: 1 addition & 0 deletions Cargo.lock

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

40 changes: 31 additions & 9 deletions crates/mergify-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,7 @@ struct EventsOpts {
pr_number: Option<u64>,
since: Option<TimeDelta>,
event_types: Vec<String>,
limit: Option<usize>,
limit: usize,
output_json: bool,
}

Expand Down Expand Up @@ -2736,7 +2736,8 @@ enum Subcommands {
/// 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`).
/// default, up to the 90 days the log retains (`--since 90d`), and
/// the newest page of events within it (`--limit`).
Events(EventsCliArgs),
/// Schedule and manage merge freezes.
///
Expand Down Expand Up @@ -4295,10 +4296,19 @@ struct EventsCliArgs {
#[arg(long = "type", value_name = "EVENT_TYPE")]
r#type: Vec<String>,

/// 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<usize>,
/// Stop after the newest N events. The default is one API page —
/// a busy repository records thousands of events a day, so
/// fetching the whole window takes minutes. The output says when
/// the cap took effect; raise it to read further back.
#[arg(
long,
value_name = "N",
default_value_t = mergify_events::list::DEFAULT_LIMIT,
// Zero would fetch a page and then print none of it, which
// reads exactly like an empty window.
value_parser = clap::builder::RangedU64ValueParser::<usize>::new().range(1..),
)]
limit: usize,

/// Emit a single JSON document (the raw events, newest first,
/// with the queried window echoed) instead of the timeline.
Expand Down Expand Up @@ -4745,24 +4755,36 @@ mod tests {
opts.event_types,
vec!["action.queue.leave", "command.queue"],
);
assert_eq!(opts.limit, Some(50));
assert_eq!(opts.limit, 50);
assert!(opts.output_json);
}

#[test]
fn events_defaults_to_repo_wide_last_24h() {
fn events_defaults_to_repo_wide_last_24h_capped_at_one_page() {
// No --pr, no --since: the command covers the repository and
// the run applies (and states) the 24h default itself.
// the run applies (and states) the 24h default itself. The
// limit is not optional — an uncapped default is the bug.
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_eq!(opts.limit, mergify_events::list::DEFAULT_LIMIT);
assert!(!opts.output_json);
}

#[test]
fn events_rejects_a_zero_limit() {
// `--limit 0` would fetch a page and print none of it, which
// is indistinguishable from an empty window.
let Err(err) = CliRoot::try_parse_from(["mergify", "events", "--limit", "0"]) else {
panic!("a zero limit must be a usage error");
};
assert!(err.to_string().contains('0'), "got: {err}");
}

#[test]
fn events_rejects_a_since_past_the_retention_cap() {
// The impossible window dies as a usage error carrying the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1859,14 +1859,14 @@ expression: schema
]
},
{
"default": null,
"default": "100",
"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",
"help": "Stop after the newest N events. The default is one API page — a busy repository records thousands of events a day, so fetching the whole window takes minutes. The output says when the cap took effect; raise it to read further back",
"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",
"longHelp": "Stop after the newest N events. The default is one API page — a busy repository records thousands of events a day, so fetching the whole window takes minutes. The output says when the cap took effect; raise it to read further back",
"numArgs": "1",
"possibleValues": [],
"required": false,
Expand Down Expand Up @@ -1894,7 +1894,7 @@ expression: schema
}
],
"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`).",
"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`), and the newest page of events within it (`--limit`).",
"name": "events",
"path": [
"mergify",
Expand Down
1 change: 1 addition & 0 deletions crates/mergify-events/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ publish = false
[dependencies]
mergify-core = { path = "../mergify-core" }
mergify-tui = { path = "../mergify-tui" }
anstyle = { workspace = true }
chrono = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
Expand Down
167 changes: 148 additions & 19 deletions crates/mergify-events/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,22 @@ struct EventsResponse {
events: Vec<serde_json::Value>,
}

/// The result of a [`fetch`]: the events, plus whether the limit cut
/// the log short.
///
/// `truncated` is observed, not inferred: only the fetch knows it
/// stopped on [`Query::limit`] while the window still had more to
/// give. A caller comparing `events.len()` to the limit would call a
/// window holding exactly `limit` events truncated, and say so in an
/// output whose whole job is to state what it did not show.
#[derive(Debug)]
pub struct Log {
/// The window's events, newest first, capped at [`Query::limit`].
pub events: Vec<Event>,
/// The limit stopped the walk with events left in the window.
pub truncated: bool,
}

/// Fetch every event matching `query`, newest first.
///
/// Both window bounds are always sent (the API's silent
Expand All @@ -49,15 +65,14 @@ struct EventsResponse {
/// Propagates the API failure ([`CliError::MergifyApi`] for HTTP
/// errors). What that means is the caller's call — `queue show`
/// treats it as "could not determine", not as a command failure.
pub async fn fetch(
client: &Client,
repository: &str,
query: &Query,
) -> Result<Vec<Event>, CliError> {
pub async fn fetch(client: &Client, repository: &str, query: &Query) -> Result<Log, CliError> {
let path = format!("/v1/repos/{repository}/logs");
let from = query.window.from().to_rfc3339();
let to = query.window.to().to_rfc3339();
let pull_request = query.pull_request.map(|n| n.to_string());
// Sizing the page to the limit is what makes a limited fetch a
// single request: the default limit is one page, so the no-flag
// command asks the API once and stops.
let per_page = query
.limit
.map_or(PER_PAGE_MAX, |limit| limit.clamp(1, PER_PAGE_MAX))
Expand All @@ -76,21 +91,31 @@ pub async fn fetch(

let mut raw_events: Vec<serde_json::Value> = Vec::new();
let mut cursor: Option<String> = None;
let mut truncated = false;
loop {
let mut pairs = base.clone();
if let Some(cursor) = &cursor {
pairs.push(("cursor", cursor));
}
let page = client.get_page::<EventsResponse>(&path, &pairs).await?;
raw_events.extend(page.body.events);
if query.limit.is_some_and(|limit| raw_events.len() >= limit) {
// A server bug echoing the cursor we just used would
// otherwise loop forever.
let next = page
.next_cursor
.filter(|next| Some(next) != cursor.as_ref());
if let Some(limit) = query.limit
&& raw_events.len() >= limit
{
// Either this page overshot the limit, or the server says
// another page exists: both mean the window outlives what
// we return.
truncated = raw_events.len() > limit || next.is_some();
break;
}
match page.next_cursor {
// A server bug echoing the cursor we just used would
// otherwise loop forever.
Some(next) if Some(&next) != cursor.as_ref() => cursor = Some(next),
_ => break,
match next {
Some(next) => cursor = Some(next),
None => break,
}
}

Expand All @@ -101,7 +126,7 @@ pub async fn fetch(
if let Some(limit) = query.limit {
events.truncate(limit);
}
Ok(events)
Ok(Log { events, truncated })
}

#[cfg(test)]
Expand Down Expand Up @@ -226,7 +251,10 @@ mod tests {
window: Window::retained(at("2026-07-30T00:00:00Z")),
limit: None,
};
let events = fetch(&client(&server), "owner/repo", &query).await.unwrap();
let events = fetch(&client(&server), "owner/repo", &query)
.await
.unwrap()
.events;
assert!(events.is_empty());
}

Expand Down Expand Up @@ -277,7 +305,10 @@ mod tests {
window: Window::retained(at("2026-07-30T00:00:00Z")),
limit: None,
};
let events = fetch(&client(&server), "owner/repo", &query).await.unwrap();
let events = fetch(&client(&server), "owner/repo", &query)
.await
.unwrap()
.events;
let ids: Vec<u64> = events
.iter()
.map(|e| e.raw["id"].as_u64().unwrap())
Expand Down Expand Up @@ -315,8 +346,97 @@ mod tests {
window: Window::retained(at("2026-07-30T00:00:00Z")),
limit: Some(2),
};
let events = fetch(&client(&server), "owner/repo", &query).await.unwrap();
assert_eq!(events.len(), 2);
let log = fetch(&client(&server), "owner/repo", &query).await.unwrap();
assert_eq!(log.events.len(), 2);
// The server said another page exists, so the window outlives
// what we return — and the caller has to be able to say so.
assert!(log.truncated);
}

#[tokio::test]
async fn a_window_that_fits_the_limit_exactly_is_not_truncated() {
// The reason `truncated` is observed rather than inferred
// from `events.len() == limit`: a window holding exactly the
// limit is complete, and announcing "newest 2 events" over it
// would be a lie in an output whose job is to state what it
// left out.
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/repos/owner/repo/logs"))
.respond_with(ResponseTemplate::new(200).set_body_json(page_body(&[
event(2, "2026-07-29T12:00:00Z"),
event(1, "2026-07-29T11:00:00Z"),
])))
.expect(1)
.mount(&server)
.await;

let query = Query {
pull_request: None,
event_types: vec![],
window: Window::retained(at("2026-07-30T00:00:00Z")),
limit: Some(2),
};
let log = fetch(&client(&server), "owner/repo", &query).await.unwrap();
assert_eq!(log.events.len(), 2);
assert!(!log.truncated);
}

#[tokio::test]
async fn a_page_overshooting_the_limit_is_truncated() {
// No `next` link, but the page held more than the caller
// asked for: the extras are dropped, and dropping them is
// exactly what `truncated` reports.
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/repos/owner/repo/logs"))
.respond_with(ResponseTemplate::new(200).set_body_json(page_body(&[
event(3, "2026-07-29T13:00:00Z"),
event(2, "2026-07-29T12:00:00Z"),
event(1, "2026-07-29T11:00:00Z"),
])))
.expect(1)
.mount(&server)
.await;

let query = Query {
pull_request: None,
event_types: vec![],
window: Window::retained(at("2026-07-30T00:00:00Z")),
limit: Some(2),
};
let log = fetch(&client(&server), "owner/repo", &query).await.unwrap();
let ids: Vec<u64> = log
.events
.iter()
.map(|e| e.raw["id"].as_u64().unwrap())
.collect();
// Newest first, so the limit keeps the newest.
assert_eq!(ids, vec![3, 2]);
assert!(log.truncated);
}

#[tokio::test]
async fn an_unlimited_fetch_is_never_truncated() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/repos/owner/repo/logs"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(page_body(&[event(1, "2026-07-29T11:00:00Z")])),
)
.expect(1)
.mount(&server)
.await;

let query = Query {
pull_request: None,
event_types: vec![],
window: Window::retained(at("2026-07-30T00:00:00Z")),
limit: None,
};
let log = fetch(&client(&server), "owner/repo", &query).await.unwrap();
assert!(!log.truncated);
}

#[tokio::test]
Expand All @@ -343,7 +463,10 @@ mod tests {
window: Window::retained(at("2026-07-30T00:00:00Z")),
limit: None,
};
let events = fetch(&client(&server), "owner/repo", &query).await.unwrap();
let events = fetch(&client(&server), "owner/repo", &query)
.await
.unwrap()
.events;
let ids: Vec<u64> = events
.iter()
.map(|e| e.raw["id"].as_u64().unwrap())
Expand Down Expand Up @@ -386,7 +509,10 @@ mod tests {
window: Window::retained(at("2026-07-30T00:00:00Z")),
limit: None,
};
let events = fetch(&client(&server), "owner/repo", &query).await.unwrap();
let events = fetch(&client(&server), "owner/repo", &query)
.await
.unwrap()
.events;
assert_eq!(events.len(), 2);
}

Expand Down Expand Up @@ -414,7 +540,10 @@ mod tests {
window: Window::retained(at("2026-07-30T00:00:00Z")),
limit: None,
};
let events = fetch(&client(&server), "owner/repo", &query).await.unwrap();
let events = fetch(&client(&server), "owner/repo", &query)
.await
.unwrap()
.events;
assert_eq!(events[0].raw, raw);
}

Expand Down
Loading