Skip to content

Commit 71dd92c

Browse files
JulianMaurinclaude
andauthored
fix(queue): treat a not-queued PR as a normal state, not an API error (#1678)
`queue show` mapped a PR that isn't in the merge queue to exit 6 (Mergify API error) with empty stdout — so `--json` consumers got nothing parseable for the most common case, and scripts saw an API-failure code for a routine state. Report it on stdout and exit 0 instead: a `{"number": N, "queued": false}` document under `--json`, the `PR #N is not in the merge queue` line in human mode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 85009ae commit 71dd92c

2 files changed

Lines changed: 85 additions & 30 deletions

File tree

crates/mergify-cli/tests/live_smoke.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -397,9 +397,9 @@ fn queue_show_not_in_queue() {
397397
//
398398
// Calls with a PR number that is almost certainly not in the
399399
// queue (the test repo has far fewer than this many PRs).
400-
// Both Python and Rust special-case 404 with the same
401-
// user-facing message and `MERGIFY_API_ERROR` exit code (6)
402-
// — that contract is what this test pins.
400+
// A not-queued PR is a normal queryable state, so the command
401+
// reports it on stdout and exits 0 — that contract is what this
402+
// test pins.
403403
//
404404
// Testing the 404 path (instead of a real queued PR) makes
405405
// the test independent of whether PR #1 happens to be queued
@@ -421,17 +421,17 @@ fn queue_show_not_in_queue() {
421421
]);
422422
assert_eq!(
423423
result.returncode,
424-
6,
425-
"expected MERGIFY_API_ERROR (6), got {}{}",
424+
0,
425+
"expected a not-queued PR to exit 0, got {}{}",
426426
result.returncode,
427427
result.context()
428428
);
429429
assert!(
430430
result
431-
.combined()
431+
.stdout
432432
.to_lowercase()
433433
.contains("not in the merge queue"),
434-
"expected 'not in the merge queue' message{}",
434+
"expected 'not in the merge queue' message on stdout{}",
435435
result.context()
436436
);
437437
}

crates/mergify-queue/src/show.rs

Lines changed: 78 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,15 @@
1414
//! a tree.
1515
//!
1616
//! 404 responses are special-cased: the API returns 404 for "PR is
17-
//! not currently in the merge queue", which is a routine caller
18-
//! branch rather than a server failure. The command returns a
19-
//! [`CliError::MergifyApi`] whose message is `PR #N is not in the
20-
//! merge queue`; the binary's top-level handler prints it to
21-
//! stderr as `mergify: PR #N is not in the merge queue` (the
22-
//! `mergify: ` prefix is the binary's standard error envelope)
23-
//! and exits with the Mergify-API error code. Live smoke tests
24-
//! assert against the substring, which is stable across the
25-
//! Python and Rust implementations.
17+
//! not currently in the merge queue", which is a routine queryable
18+
//! state rather than a server failure. The command reports it on
19+
//! stdout and exits 0 — a not-queued PR is a normal answer, not an
20+
//! error a script should branch on as an API failure. Under `--json`
21+
//! the not-queued state is a `{"number": N, "queued": false}`
22+
//! document so pipeline consumers always get parseable output; in
23+
//! human mode it is the line `PR #N is not in the merge queue`. Live
24+
//! smoke tests assert against that substring, which is stable across
25+
//! the Python and Rust implementations.
2626
2727
use std::io::Write;
2828

@@ -119,10 +119,8 @@ pub async fn run(opts: ShowOptions<'_>, output: &mut dyn Output) -> Result<(), C
119119

120120
let raw: Option<serde_json::Value> = client.get_if_exists(&path).await?;
121121
let Some(raw) = raw else {
122-
return Err(CliError::MergifyApi(format!(
123-
"PR #{n} is not in the merge queue",
124-
n = opts.pr_number,
125-
)));
122+
emit_not_queued(output, opts.pr_number, opts.output_json)?;
123+
return Ok(());
126124
};
127125

128126
if opts.output_json {
@@ -136,6 +134,28 @@ pub async fn run(opts: ShowOptions<'_>, output: &mut dyn Output) -> Result<(), C
136134
Ok(())
137135
}
138136

137+
/// Emit the "PR is not in the merge queue" state. This is a normal
138+
/// answer, not a failure, so the command exits 0 — see the module
139+
/// docs. Under `--json` we emit a `{number, queued: false}` document
140+
/// (a machine consumer always gets parseable output); in human mode
141+
/// a single notice line. The wording is load-bearing: live smoke
142+
/// tests assert on the "is not in the merge queue" substring.
143+
fn emit_not_queued(
144+
output: &mut dyn Output,
145+
pr_number: u64,
146+
output_json: bool,
147+
) -> Result<(), CliError> {
148+
if output_json {
149+
let payload = serde_json::json!({ "number": pr_number, "queued": false });
150+
output.emit_json_value(&payload)?;
151+
} else {
152+
output.emit(&(), &mut |w: &mut dyn Write| {
153+
writeln!(w, "PR #{pr_number} is not in the merge queue")
154+
})?;
155+
}
156+
Ok(())
157+
}
158+
139159
fn emit_human(output: &mut dyn Output, view: &PullView, verbose: bool) -> std::io::Result<()> {
140160
let now = Utc::now();
141161
let theme = Theme::detect();
@@ -657,7 +677,10 @@ mod tests {
657677
}
658678

659679
#[tokio::test]
660-
async fn run_404_is_not_in_queue() {
680+
async fn run_404_human_is_not_in_queue_and_succeeds() {
681+
// A not-queued PR is a normal queryable state, not an API
682+
// failure: human mode prints the notice to stdout and the
683+
// command returns Ok (exit 0).
661684
let server = MockServer::start().await;
662685
Mock::given(method("GET"))
663686
.and(path("/v1/repos/owner/repo/merge-queue/pull/999"))
@@ -668,7 +691,7 @@ mod tests {
668691

669692
let mut cap = Captured::human();
670693
let api_url = server.uri();
671-
let err = run(
694+
run(
672695
ShowOptions {
673696
repository: Some("owner/repo"),
674697
token: Some("t"),
@@ -680,19 +703,51 @@ mod tests {
680703
&mut cap.output,
681704
)
682705
.await
683-
.unwrap_err();
706+
.unwrap();
684707

708+
let stdout = cap.stdout();
685709
assert!(
686-
matches!(err, CliError::MergifyApi(_)),
687-
"expected MergifyApi, got {err:?}",
688-
);
689-
assert_eq!(err.exit_code(), mergify_core::ExitCode::MergifyApiError);
690-
assert!(
691-
err.to_string().contains("not in the merge queue"),
692-
"got: {err}"
710+
stdout.contains("PR #999 is not in the merge queue"),
711+
"got: {stdout:?}",
693712
);
694713
}
695714

715+
#[tokio::test]
716+
async fn run_404_json_emits_not_queued_document() {
717+
// Under `--json`, the not-queued state is a parseable
718+
// `{number, queued: false}` document on stdout (exit 0), so
719+
// pipeline consumers never get empty output for the common
720+
// case.
721+
let server = MockServer::start().await;
722+
Mock::given(method("GET"))
723+
.and(path("/v1/repos/owner/repo/merge-queue/pull/999"))
724+
.respond_with(ResponseTemplate::new(404))
725+
.expect(1)
726+
.mount(&server)
727+
.await;
728+
729+
let mut cap = Captured::new(OutputMode::Json);
730+
let api_url = server.uri();
731+
run(
732+
ShowOptions {
733+
repository: Some("owner/repo"),
734+
token: Some("t"),
735+
api_url: Some(&api_url),
736+
pr_number: 999,
737+
verbose: false,
738+
output_json: true,
739+
},
740+
&mut cap.output,
741+
)
742+
.await
743+
.unwrap();
744+
745+
let stdout = cap.stdout();
746+
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
747+
assert_eq!(parsed["number"], json!(999));
748+
assert_eq!(parsed["queued"], json!(false));
749+
}
750+
696751
#[tokio::test]
697752
async fn run_no_mergeability_check() {
698753
let server = MockServer::start().await;

0 commit comments

Comments
 (0)