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
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
23 changes: 23 additions & 0 deletions func-tests/test_live_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
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",
)
Loading
Loading