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)