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: 7 additions & 5 deletions .github/workflows/func-tests-live.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
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
9 changes: 6 additions & 3 deletions func-tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
73 changes: 73 additions & 0 deletions func-tests/test_live_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Comment thread
jd marked this conversation as resolved.
``--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:
Expand All @@ -124,6 +174,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
Loading
Loading