Skip to content
Closed
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
12 changes: 12 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions crates/mergify-ci/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions crates/mergify-ci/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
172 changes: 172 additions & 0 deletions crates/mergify-ci/src/queue_info.rs
Original file line number Diff line number Diff line change
@@ -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_<uuid>` 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<std::sync::Mutex<Vec<u8>>>;

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<<ghadelimiter_"));
assert!(written.contains("\"checking_base_sha\":\"deadbeef\""));
}

struct SharedWriter(SharedBytes);
impl std::io::Write for SharedWriter {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
}
10 changes: 9 additions & 1 deletion crates/mergify-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ enum NativeCommand {
ConfigSimulate(ConfigSimulateOpts),
CiScopesSend(CiScopesSendOpts),
CiGitRefs { format: GitRefsFormat },
CiQueueInfo,
QueuePause(QueuePauseOpts),
QueueUnpause(QueueUnpauseOpts),
}
Expand Down Expand Up @@ -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"),
)
})
Expand Down Expand Up @@ -225,6 +226,9 @@ fn detect_native(argv: &[String]) -> Option<NativeCommand> {
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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)]
Expand Down
28 changes: 0 additions & 28 deletions mergify_cli/ci/cli.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
from __future__ import annotations

import glob
import json
import os
import pathlib
import uuid

import click

from mergify_cli import utils
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
Expand Down Expand Up @@ -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",
)
62 changes: 0 additions & 62 deletions mergify_cli/tests/ci/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import json
import pathlib
from unittest import mock

Expand Down Expand Up @@ -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<<ghadelimiter_" in gha_content
lines = gha_content.splitlines()
metadata_json = json.loads(lines[1])
assert metadata_json["checking_base_sha"] == "xyz789"


def test_queue_info_not_merge_queue(
tmp_path: pathlib.Path,
monkeypatch: pytest.MonkeyPatch,
) -> 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
16 changes: 0 additions & 16 deletions mergify_cli/tests/ci/test_cli_exit_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading