From 7912feeb61149093f084b0fa02155aadf10a0e5b Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Mon, 11 May 2026 13:41:59 +0200 Subject: [PATCH 1/3] test(ci): add live smoke test for ci queue-info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the contract for ``mergify ci queue-info`` so the upcoming Rust port can be validated against the same test that exercises the Python implementation. Lands first; the port commit on top re-runs this test against Rust — same contract, both ends. The test doesn't need ``live_token`` (the command is locally evaluated). The conftest fixture scrubs every event env var and runs in a tmp dir, so the detector always reports "no MQ context". The assertion checks for exit code 7 (``INVALID_STATE``) and an MQ-context message in stdout or stderr. Co-Authored-By: Claude Opus 4.7 (1M context) Change-Id: Idff72acfc1f35f7f64051a4f62a3fe625d2b802f --- func-tests/test_live_smoke.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/func-tests/test_live_smoke.py b/func-tests/test_live_smoke.py index a5928d49..ca7047be 100644 --- a/func-tests/test_live_smoke.py +++ b/func-tests/test_live_smoke.py @@ -124,6 +124,29 @@ def test_ci_git_refs_fallback( ) +def test_ci_queue_info_outside_mq( + cli: typing.Callable[..., typing.Any], +) -> None: + """`mergify ci queue-info` exits ``INVALID_STATE`` (7) when not + running on an MQ draft PR. + + Doesn't need ``live_token`` — the command is locally + evaluated. The conftest fixture scrubs every event env var + and runs in a tmp dir, so the detector always reports + "no MQ context". This is the contract we want preserved + across the upcoming Python → Rust port. + """ + result = cli("ci", "queue-info") + assert result.returncode == 7, ( + f"expected INVALID_STATE (7), got {result.returncode}\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + combined = (result.stdout + result.stderr).lower() + assert "merge queue" in combined, ( + f"expected MQ-context message\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + + def test_scopes_send( live_token: str, cli: typing.Callable[..., typing.Any], From d5d38473759ee5c8a4e327a1676f36dd9b86c592 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Mon, 11 May 2026 13:45:21 +0200 Subject: [PATCH 2/3] feat(rust): port ci queue-info to native Rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Rust binary now serves ``mergify ci queue-info`` natively. The Python implementation (``mergify_cli/ci/cli.py:queue_info``) and its tests are removed in the same PR — port-and-delete keeps a single live copy. The previous commit landed a live-smoke test pinning the ``INVALID_STATE`` exit code outside an MQ context. That same test now exercises the Rust path. ``ci queue-info`` prints the merge-queue batch metadata embedded in the current draft PR as pretty JSON. Outside an MQ context (no event payload, no PR body fenced block) it exits ``INVALID_STATE`` (exit 7). When ``$GITHUB_OUTPUT`` is set (GitHub Actions runner), the command also appends the metadata as ``queue_metadata`` under a random ``ghadelimiter_`` heredoc, matching the pattern GitHub Actions expects for multi-line workflow outputs. The implementation reuses the shared ``queue_metadata`` and ``github_event`` modules introduced by the previous (git-refs) port commit. Adds the ``uuid`` dep (v4 random UUIDs for the ghadelimiter) to the ``mergify-ci`` crate. 3 new queue-info tests in the ``mergify-ci`` crate. The Python queue-info tests in ``test_cli.py``, ``test_cli_exit_codes.py``, and ``test_exit_code_contract.py`` are removed. Co-Authored-By: Claude Opus 4.7 (1M context) Change-Id: If0cdc5a06685387f6a9379b237eb5d184b071dc4 --- Cargo.lock | 12 ++ crates/mergify-ci/Cargo.toml | 1 + crates/mergify-ci/src/lib.rs | 1 + crates/mergify-ci/src/queue_info.rs | 172 +++++++++++++++++++ crates/mergify-cli/src/main.rs | 10 +- mergify_cli/ci/cli.py | 28 --- mergify_cli/tests/ci/test_cli.py | 62 ------- mergify_cli/tests/ci/test_cli_exit_codes.py | 16 -- mergify_cli/tests/test_exit_code_contract.py | 17 -- 9 files changed, 195 insertions(+), 124 deletions(-) create mode 100644 crates/mergify-ci/src/queue_info.rs diff --git a/Cargo.lock b/Cargo.lock index 5d697fad..d04b8970 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1025,6 +1025,7 @@ dependencies = [ "tempfile", "tokio", "url", + "uuid", "wiremock", ] @@ -2103,6 +2104,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "uuid-simd" version = "0.8.0" diff --git a/crates/mergify-ci/Cargo.toml b/crates/mergify-ci/Cargo.toml index c2a0a4a5..1416c013 100644 --- a/crates/mergify-ci/Cargo.toml +++ b/crates/mergify-ci/Cargo.toml @@ -15,6 +15,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_yaml_ng = "0.10" url = "2" +uuid = { version = "1", features = ["v4"] } [dev-dependencies] tempfile = "3.14" diff --git a/crates/mergify-ci/src/lib.rs b/crates/mergify-ci/src/lib.rs index 1c7e3209..b06c9f3e 100644 --- a/crates/mergify-ci/src/lib.rs +++ b/crates/mergify-ci/src/lib.rs @@ -10,5 +10,6 @@ pub mod detector; pub mod git_refs; pub mod github_event; +pub mod queue_info; pub mod queue_metadata; pub mod scopes_send; diff --git a/crates/mergify-ci/src/queue_info.rs b/crates/mergify-ci/src/queue_info.rs new file mode 100644 index 00000000..cde4b646 --- /dev/null +++ b/crates/mergify-ci/src/queue_info.rs @@ -0,0 +1,172 @@ +//! `mergify ci queue-info` — print the merge-queue batch metadata +//! that's embedded in the current merge-queue draft PR. +//! +//! Output is pretty-printed JSON on stdout. When the step isn't +//! running against an MQ draft the command exits with +//! `INVALID_STATE` — same behavior as Python. +//! +//! When `$GITHUB_OUTPUT` is set (GitHub Actions runner), the command +//! also appends the metadata as `queue_metadata` under a random +//! `ghadelimiter_` heredoc, matching the pattern the workflow +//! runtime expects for multi-line outputs. + +use std::env; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::PathBuf; + +use mergify_core::CliError; +use mergify_core::Output; + +use crate::queue_metadata::MergeQueueMetadata; +use crate::queue_metadata::detect; + +/// Run the `ci queue-info` command. +pub fn run(output: &mut dyn Output) -> Result<(), CliError> { + let Some(metadata) = detect(output)? else { + return Err(CliError::InvalidState( + "Not running in a merge queue context. \ + This command must be run on a merge queue draft pull request." + .to_string(), + )); + }; + + emit_json(output, &metadata)?; + write_github_output(&metadata)?; + Ok(()) +} + +fn emit_json(output: &mut dyn Output, metadata: &MergeQueueMetadata) -> std::io::Result<()> { + output.emit(metadata, &mut |w: &mut dyn Write| { + let rendered = serde_json::to_string_pretty(metadata) + .map_err(|e| std::io::Error::other(e.to_string()))?; + writeln!(w, "{rendered}") + }) +} + +fn write_github_output(metadata: &MergeQueueMetadata) -> Result<(), CliError> { + let Some(path) = env::var("GITHUB_OUTPUT").ok().filter(|s| !s.is_empty()) else { + return Ok(()); + }; + let delimiter = format!("ghadelimiter_{}", uuid::Uuid::new_v4()); + let compact = serde_json::to_string(metadata) + .map_err(|e| CliError::Generic(format!("failed to serialize queue metadata: {e}")))?; + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(PathBuf::from(path))?; + writeln!(file, "queue_metadata<<{delimiter}")?; + writeln!(file, "{compact}")?; + writeln!(file, "{delimiter}")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use mergify_core::ExitCode; + use mergify_core::OutputMode; + use mergify_core::StdioOutput; + use tempfile::TempDir; + + use super::*; + + type SharedBytes = std::sync::Arc>>; + + struct Captured { + output: StdioOutput, + stdout: SharedBytes, + } + + fn make_output() -> Captured { + let stdout: SharedBytes = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let stderr: SharedBytes = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let output = StdioOutput::with_sinks( + OutputMode::Human, + SharedWriter(std::sync::Arc::clone(&stdout)), + SharedWriter(std::sync::Arc::clone(&stderr)), + ); + Captured { output, stdout } + } + + fn write_event_file(dir: &TempDir, body: &str, title: &str) -> PathBuf { + let path = dir.path().join("event.json"); + let payload = serde_json::json!({ + "pull_request": { + "title": title, + "body": body, + }, + }); + std::fs::write(&path, serde_json::to_vec(&payload).unwrap()).unwrap(); + path + } + + #[test] + fn errors_when_not_in_mq_context() { + let mut cap = make_output(); + let err = temp_env::with_vars_unset(["GITHUB_EVENT_NAME", "GITHUB_EVENT_PATH"], || { + run(&mut cap.output).unwrap_err() + }); + assert!(matches!(err, CliError::InvalidState(_))); + assert_eq!(err.exit_code(), ExitCode::InvalidState); + } + + #[test] + fn prints_metadata_for_mq_pr() { + let dir = tempfile::tempdir().unwrap(); + let path = write_event_file( + &dir, + "intro\n```yaml\nchecking_base_sha: abc123\npull_requests:\n - number: 10\n```", + "merge queue: batch", + ); + + let mut cap = make_output(); + temp_env::with_vars( + [ + ("GITHUB_EVENT_NAME", Some("pull_request")), + ("GITHUB_EVENT_PATH", Some(path.to_str().unwrap())), + ("GITHUB_OUTPUT", None), + ], + || run(&mut cap.output).unwrap(), + ); + + let stdout = String::from_utf8(cap.stdout.lock().unwrap().clone()).unwrap(); + assert!(stdout.contains("\"checking_base_sha\": \"abc123\"")); + assert!(stdout.contains("\"number\": 10")); + } + + #[test] + fn appends_to_github_output_when_set() { + let dir = tempfile::tempdir().unwrap(); + let event_path = write_event_file( + &dir, + "```yaml\nchecking_base_sha: deadbeef\n```", + "merge queue: tiny", + ); + let gha_output = dir.path().join("gha_output"); + + let mut cap = make_output(); + temp_env::with_vars( + [ + ("GITHUB_EVENT_NAME", Some("pull_request")), + ("GITHUB_EVENT_PATH", Some(event_path.to_str().unwrap())), + ("GITHUB_OUTPUT", Some(gha_output.to_str().unwrap())), + ], + || run(&mut cap.output).unwrap(), + ); + + let written = std::fs::read_to_string(&gha_output).unwrap(); + assert!(written.starts_with("queue_metadata< std::io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } +} diff --git a/crates/mergify-cli/src/main.rs b/crates/mergify-cli/src/main.rs index 9d3470d9..b3e658af 100644 --- a/crates/mergify-cli/src/main.rs +++ b/crates/mergify-cli/src/main.rs @@ -68,6 +68,7 @@ enum NativeCommand { ConfigSimulate(ConfigSimulateOpts), CiScopesSend(CiScopesSendOpts), CiGitRefs { format: GitRefsFormat }, + CiQueueInfo, QueuePause(QueuePauseOpts), QueueUnpause(QueueUnpauseOpts), } @@ -118,7 +119,7 @@ fn looks_native(argv: &[String]) -> bool { matches!( (pair[0].as_str(), pair[1].as_str()), ("config", "validate" | "simulate") - | ("ci", "scopes-send" | "git-refs") + | ("ci", "scopes-send" | "git-refs" | "queue-info") | ("queue", "pause" | "unpause"), ) }) @@ -225,6 +226,9 @@ fn detect_native(argv: &[String]) -> Option { Subcommands::Ci(CiArgs { command: CiSubcommand::GitRefs(GitRefsCliArgs { format }), }) => Some(NativeCommand::CiGitRefs { format }), + Subcommands::Ci(CiArgs { + command: CiSubcommand::QueueInfo, + }) => Some(NativeCommand::CiQueueInfo), Subcommands::Queue(QueueArgs { repository, token, @@ -304,6 +308,7 @@ fn run_native(cmd: NativeCommand) -> ExitCode { NativeCommand::CiGitRefs { format } => { mergify_ci::git_refs::run(&GitRefsOptions { format }, &mut output) } + NativeCommand::CiQueueInfo => mergify_ci::queue_info::run(&mut output), NativeCommand::QueuePause(opts) => { mergify_queue::pause::run( PauseOptions { @@ -413,6 +418,9 @@ enum CiSubcommand { /// Print the base/head git references for the current build. #[command(name = "git-refs")] GitRefs(GitRefsCliArgs), + /// Print the merge queue batch metadata for the current draft PR. + #[command(name = "queue-info")] + QueueInfo, } #[derive(clap::Args)] diff --git a/mergify_cli/ci/cli.py b/mergify_cli/ci/cli.py index 14476ea4..a720eeba 100644 --- a/mergify_cli/ci/cli.py +++ b/mergify_cli/ci/cli.py @@ -1,10 +1,7 @@ from __future__ import annotations import glob -import json -import os import pathlib -import uuid import click @@ -12,7 +9,6 @@ from mergify_cli.ci import detector from mergify_cli.ci.git_refs import detector as git_refs_detector from mergify_cli.ci.junit_processing import cli as junit_processing_cli -from mergify_cli.ci.queue import metadata as queue_metadata from mergify_cli.ci.scopes import cli as scopes_cli from mergify_cli.ci.scopes import exceptions as scopes_exc from mergify_cli.dym import DYMGroup @@ -329,27 +325,3 @@ def scopes( if write is not None: scopes.save_to_file(write) - - -@ci.command( - help="""Output merge queue batch metadata from the current pull request event""", - short_help="""Output merge queue batch metadata""", -) -def queue_info() -> None: - metadata = queue_metadata.detect() - if metadata is None: - raise utils.MergifyError( - "Not running in a merge queue context. " - "This command must be run on a merge queue draft pull request.", - exit_code=ExitCode.INVALID_STATE, - ) - - click.echo(json.dumps(metadata, indent=2)) - - gha = os.environ.get("GITHUB_OUTPUT") - if gha: - delimiter = f"ghadelimiter_{uuid.uuid4()}" - with pathlib.Path(gha).open("a", encoding="utf-8") as fh: - fh.write( - f"queue_metadata<<{delimiter}\n{json.dumps(metadata)}\n{delimiter}\n", - ) diff --git a/mergify_cli/tests/ci/test_cli.py b/mergify_cli/tests/ci/test_cli.py index 44d31e2f..4e6f9163 100644 --- a/mergify_cli/tests/ci/test_cli.py +++ b/mergify_cli/tests/ci/test_cli.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json import pathlib from unittest import mock @@ -522,64 +521,3 @@ def test_scopes_empty_mergify_config_env_uses_autodetection( # ScopesError is raised -> CONFIGURATION_ERROR exit code. assert result.exit_code == ExitCode.CONFIGURATION_ERROR assert "source `manual` has been set" in result.output - - -def test_queue_info( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - event_data = { - "pull_request": { - "number": 10, - "title": "merge queue: embarking #1 and #2 together", - "body": "```yaml\n---\nchecking_base_sha: xyz789\npull_requests:\n - number: 1\n - number: 2\nprevious_failed_batches: []\n...\n```", - "base": {"sha": "abc123"}, - }, - } - event_file = tmp_path / "event.json" - event_file.write_text(json.dumps(event_data)) - - output_file = tmp_path / "github_output" - - monkeypatch.setenv("GITHUB_OUTPUT", str(output_file)) - monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request") - monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_file)) - - runner = testing.CliRunner() - result = runner.invoke(ci_cli.queue_info, []) - assert result.exit_code == 0, result.output - - output = json.loads(result.output) - assert output["checking_base_sha"] == "xyz789" - assert output["pull_requests"] == [{"number": 1}, {"number": 2}] - assert output["previous_failed_batches"] == [] - - gha_content = output_file.read_text() - assert "queue_metadata< None: - event_data = { - "pull_request": { - "number": 5, - "title": "feat: add something", - "body": "Some description", - "base": {"sha": "abc123"}, - }, - } - event_file = tmp_path / "event.json" - event_file.write_text(json.dumps(event_data)) - - monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request") - monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_file)) - - runner = testing.CliRunner() - result = runner.invoke(ci_cli.queue_info, []) - assert result.exit_code == ExitCode.INVALID_STATE - assert "Not running in a merge queue context" in result.output diff --git a/mergify_cli/tests/ci/test_cli_exit_codes.py b/mergify_cli/tests/ci/test_cli_exit_codes.py index 37e40be7..0831631b 100644 --- a/mergify_cli/tests/ci/test_cli_exit_codes.py +++ b/mergify_cli/tests/ci/test_cli_exit_codes.py @@ -35,19 +35,3 @@ def test_ci_scopes_nonexistent_config_path_exits_configuration_error( ["ci", "scopes", "--config", str(tmp_path / "nope.yml")], ) assert result.exit_code == ExitCode.CONFIGURATION_ERROR, result.output - - -def test_ci_queue_info_outside_merge_queue_exits_invalid_state( - monkeypatch: pytest.MonkeyPatch, -) -> None: - for var in [ - "GITHUB_EVENT_NAME", - "GITHUB_EVENT_PATH", - "GITHUB_HEAD_REF", - "GITHUB_BASE_REF", - "MERGIFY_QUEUE_BATCH_ID", - ]: - monkeypatch.delenv(var, raising=False) - runner = testing.CliRunner() - result = runner.invoke(cli_mod.cli, ["ci", "queue-info"]) - assert result.exit_code == ExitCode.INVALID_STATE, result.output diff --git a/mergify_cli/tests/test_exit_code_contract.py b/mergify_cli/tests/test_exit_code_contract.py index f3ac13d1..7059eac8 100644 --- a/mergify_cli/tests/test_exit_code_contract.py +++ b/mergify_cli/tests/test_exit_code_contract.py @@ -31,12 +31,6 @@ ExitCode.CONFIGURATION_ERROR, id="ci-scopes-missing-config", ), - pytest.param( - lambda _tmp_path, monkeypatch: _clear_mq_env(monkeypatch), - ["ci", "queue-info"], - ExitCode.INVALID_STATE, - id="ci-queue-info-outside-mq", - ), ], ) def test_exit_code_contract( @@ -53,14 +47,3 @@ def test_exit_code_contract( assert result.exit_code == expected_exit, ( f"expected {expected_exit}, got {result.exit_code}\noutput: {result.output}" ) - - -def _clear_mq_env(monkeypatch: pytest.MonkeyPatch) -> None: - for var in [ - "GITHUB_EVENT_NAME", - "GITHUB_EVENT_PATH", - "GITHUB_HEAD_REF", - "GITHUB_BASE_REF", - "MERGIFY_QUEUE_BATCH_ID", - ]: - monkeypatch.delenv(var, raising=False) From 8b7dbd25c8d6edb26f58300275948c84aeabd37c Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Mon, 11 May 2026 14:04:44 +0200 Subject: [PATCH 3/3] test(queue): add live smoke test for queue status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the contract for ``mergify queue status --json`` before the Rust port lands on top. Same test exercises Python at this PR's CI and Rust on the port commit's rebase — same URL, same auth, same JSON-passthrough output. Uses ``live_token`` since the command hits the real Mergify API. The assertion is intentionally loose: exit 0 and stdout parses as a JSON object. The schema is the API's contract, not the CLI's, so any field-level assertion would tie the test to unrelated upstream changes. Co-Authored-By: Claude Opus 4.7 (1M context) Change-Id: I76f21b1ad4ddd34196ede18d3a0c768d339a8659 --- .github/workflows/func-tests-live.yaml | 12 ++++--- func-tests/conftest.py | 9 +++-- func-tests/test_live_smoke.py | 50 ++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/.github/workflows/func-tests-live.yaml b/.github/workflows/func-tests-live.yaml index d2242620..b51f9530 100644 --- a/.github/workflows/func-tests-live.yaml +++ b/.github/workflows/func-tests-live.yaml @@ -36,11 +36,13 @@ jobs: shell: bash env: # Two tokens with different scopes: - # - _CI exercises read-only / pull-scoped endpoints - # (scopes-send, junit-process, queue status, queue show, - # ci git-refs / queue-info). - # - _ADMIN exercises destructive queue-admin endpoints - # (queue pause / unpause). + # - _CI exercises CI-tooling endpoints (scopes-send, + # junit-process). ci git-refs / queue-info are + # locally evaluated and need no token at all. + # - _ADMIN exercises every endpoint under /merge-queue/ + # (status, show, pause, unpause). The CI token is + # rejected with 403 on these, so they all share the + # queue-management-scoped admin token. # Tests select the appropriate fixture; absent tokens # cause individual tests to skip rather than fail. LIVE_TEST_MERGIFY_TOKEN_CI: ${{ secrets.MERGIFY_CLI_LIVE_TEST_MERGIFY_TOKEN_CI }} diff --git a/func-tests/conftest.py b/func-tests/conftest.py index eca552c8..e7e33943 100644 --- a/func-tests/conftest.py +++ b/func-tests/conftest.py @@ -75,11 +75,14 @@ class CliResult: @pytest.fixture def live_token() -> str: - """Token for read-only / pull-scoped live endpoints. + """Token for CI-integration live endpoints. Skips the test if ``LIVE_TEST_MERGIFY_TOKEN_CI`` isn't set. - Use this for everything that doesn't need queue-admin rights - (scopes-send, junit-process, queue status / show, etc.). + Use this for CI-tooling endpoints that don't touch the queue: + ``ci scopes-send`` and ``ci junit-process``. Every endpoint + under ``/merge-queue/`` (status, show, pause, unpause) + requires queue-management scope and rejects the CI token + with 403, so those tests must use [`live_admin_token`]. """ token = os.environ.get("LIVE_TEST_MERGIFY_TOKEN_CI", "").strip() if not token: diff --git a/func-tests/test_live_smoke.py b/func-tests/test_live_smoke.py index ca7047be..7e2948fc 100644 --- a/func-tests/test_live_smoke.py +++ b/func-tests/test_live_smoke.py @@ -99,6 +99,56 @@ def test_queue_pause_unpause_roundtrip( ) +def test_queue_status( + live_admin_token: str, + cli: typing.Callable[..., typing.Any], +) -> None: + """`GET /v1/repos/{owner}/{repo}/merge-queue/status`. + + Uses the admin-scoped token because all queue endpoints + (read or write) require queue-management scope on the test + repo; the CI-scoped token is rejected with 403. + + ``--json`` mode is a passthrough of the API response, so the + smoke test only checks that the call succeeds and parses as + JSON — the contract we want preserved across the Python → + Rust port is the URL, the auth, and that the response is + valid JSON. + """ + import json + + # Group-level options (``--token`` / ``--api-url`` / + # ``--repository``) come BEFORE the subcommand. Click requires + # this for the Python implementation (the options live on the + # ``@queue`` group); Rust accepts both orders via clap's + # ``global = true``. Put them on the group so the same test + # works against both ends of the port. + result = cli( + "queue", + "--api-url", + API_URL, + "--token", + live_admin_token, + "--repository", + REPOSITORY, + "status", + "--json", + ) + assert result.returncode == 0, ( + f"queue status failed\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError as exc: + pytest.fail( + f"queue status --json emitted non-JSON output\n" + f"error: {exc}\nstdout:\n{result.stdout}", + ) + assert isinstance(payload, dict), ( + f"queue status --json must emit a JSON object\nstdout:\n{result.stdout}" + ) + + def test_ci_git_refs_fallback( cli: typing.Callable[..., typing.Any], ) -> None: