From 47cefb4e975dc7a172e42a7a2d1cba1f40cdddd2 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Mon, 11 May 2026 13:35:00 +0200 Subject: [PATCH 1/2] test(ci): add live smoke test for ci git-refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the contract for ``mergify ci git-refs`` so a future Rust port can be validated against the same test that exercised the Python implementation. The next commit ports the command; this one lands first so it runs against Python at its own CI, then against Rust on rebase — same test, both ends of the port. The test runs in the existing live-tests harness but doesn't need the live token: ``ci git-refs`` is locally evaluated (no API call). The conftest fixture scrubs every CI provider env var and runs in a tmp dir, so the detector lands on its literal ``HEAD^..HEAD`` fallback path. The assertion checks for ``Base: HEAD^`` and ``Head: HEAD`` in stdout — output format that the Python and Rust implementations share verbatim. Co-Authored-By: Claude Opus 4.7 (1M context) Change-Id: Iae0e3fe5b4cc3b653529b80ae10bae6c83f3e53d --- func-tests/test_live_smoke.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/func-tests/test_live_smoke.py b/func-tests/test_live_smoke.py index 7a4323c9..a5928d49 100644 --- a/func-tests/test_live_smoke.py +++ b/func-tests/test_live_smoke.py @@ -99,6 +99,31 @@ def test_queue_pause_unpause_roundtrip( ) +def test_ci_git_refs_fallback( + cli: typing.Callable[..., typing.Any], +) -> None: + """`mergify ci git-refs` falls back to ``HEAD^..HEAD`` when no + CI provider env is set. + + Doesn't need ``live_token`` — the command is locally evaluated + (no API call). The conftest fixture scrubs every CI/event env + var and runs in a tmp dir, so the detector lands on its + literal-string fallback path. This is the same smoke test we + want to keep working when the command moves from Python to + Rust — same contract, both ends of the port. + """ + result = cli("ci", "git-refs") + assert result.returncode == 0, f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + # Pin the exact two-line output. Substring matches would let + # added lines or rearrangements slip through silently, which + # defeats the "pin the contract" intent. The Python and Rust + # implementations both emit precisely this text on the + # fallback path. + assert result.stdout == "Base: HEAD^\nHead: HEAD\n", ( + f"output drifted from the pinned format\nstdout:\n{result.stdout!r}" + ) + + def test_scopes_send( live_token: str, cli: typing.Callable[..., typing.Any], From a9ed3f27f55fba55bdb450cf67d790c44304de0d Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Mon, 11 May 2026 13:41:36 +0200 Subject: [PATCH 2/2] feat(rust): port ci git-refs 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 git-refs`` natively. The Python implementation (``mergify_cli/ci/cli.py:git_refs``) 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 ``Base: HEAD^`` / ``Head: HEAD`` fallback contract. That same test now exercises the Rust path. The command detects the base/head git references for the current build: 1. Buildkite env (``BUILDKITE_PULL_REQUEST`` and friends) 2. GitHub Actions event payload at ``$GITHUB_EVENT_PATH`` 3. ``refs/notes/mergify/`` git notes (for merge-queue draft PRs created by the Mergify backend) 4. The MQ PR body's YAML metadata block (fallback for tools that can't read git notes) 5. Literal ``HEAD^..HEAD`` when none of the above match Three output formats: - ``text`` (default): ``Base: `` and ``Head: `` on two lines. - ``shell``: ``MERGIFY_GIT_REFS_{BASE,HEAD,SOURCE}=...`` lines for ``eval``; values are properly shell-quoted. - ``json``: a single JSON object ``{"base":..., "head":..., "source":...}``. The command also writes ``base=...\nhead=...`` to ``$GITHUB_OUTPUT`` when set, and calls ``buildkite-agent meta-data set`` when ``BUILDKITE=true``. This commit introduces two shared helper modules in the ``mergify-ci`` crate that the next port (``ci queue-info``) will reuse: - ``github_event``: GitHub Actions event payload deserialization (``GITHUB_EVENT_PATH``). - ``queue_metadata``: MQ YAML fenced-block extraction from PR bodies and git notes. The notes reader is injected as a trait-object callback so unit tests can exercise the note-driven detection path without touching a real git repository; the production path shells out via ``real_notes_reader``. Adds the ``serde_yaml_ng`` dep (YAML parser) to the ``mergify-ci`` crate. 12 new git-refs + 2 format round-trip tests plus 8 event/metadata tests in the shared helpers. The Python git-refs tests in ``mergify_cli/tests/ci/test_cli.py`` are removed. Co-Authored-By: Claude Opus 4.7 (1M context) Change-Id: I9a936b47b90f1128f6fdac6e4f29af6f1525a28c --- Cargo.lock | 30 +- crates/mergify-ci/Cargo.toml | 1 + crates/mergify-ci/src/git_refs.rs | 705 ++++++++++++++++++++++++ crates/mergify-ci/src/github_event.rs | 112 ++++ crates/mergify-ci/src/lib.rs | 15 +- crates/mergify-ci/src/queue_metadata.rs | 204 +++++++ crates/mergify-cli/src/main.rs | 27 +- mergify_cli/ci/cli.py | 36 -- mergify_cli/tests/ci/test_cli.py | 134 ----- 9 files changed, 1082 insertions(+), 182 deletions(-) create mode 100644 crates/mergify-ci/src/git_refs.rs create mode 100644 crates/mergify-ci/src/github_event.rs create mode 100644 crates/mergify-ci/src/queue_metadata.rs diff --git a/Cargo.lock b/Cargo.lock index d172079b..5d697fad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -350,7 +350,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1020,6 +1020,7 @@ dependencies = [ "mergify-core", "serde", "serde_json", + "serde_yaml_ng", "temp-env", "tempfile", "tokio", @@ -1550,7 +1551,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1607,7 +1608,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1749,6 +1750,19 @@ dependencies = [ "unsafe-libyaml-norway", ] +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1862,7 +1876,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2047,6 +2061,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "unsafe-libyaml-norway" version = "0.2.15" @@ -2272,7 +2292,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/mergify-ci/Cargo.toml b/crates/mergify-ci/Cargo.toml index 12009e9b..c2a0a4a5 100644 --- a/crates/mergify-ci/Cargo.toml +++ b/crates/mergify-ci/Cargo.toml @@ -13,6 +13,7 @@ publish = false mergify-core = { path = "../mergify-core" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +serde_yaml_ng = "0.10" url = "2" [dev-dependencies] diff --git a/crates/mergify-ci/src/git_refs.rs b/crates/mergify-ci/src/git_refs.rs new file mode 100644 index 00000000..754b1610 --- /dev/null +++ b/crates/mergify-ci/src/git_refs.rs @@ -0,0 +1,705 @@ +//! `mergify ci git-refs` — print the base/head git references for +//! the current build. +//! +//! Detection order (matches Python): +//! +//! 1. Buildkite env (`BUILDKITE=true`) — also consults the engine's +//! `refs/notes/mergify/` namespace when the branch is +//! known, to override the target branch with the MQ checking +//! base. +//! 2. GitHub event payload — `pull_request`/`push` events with +//! various fallbacks (git note, MQ PR body, base SHA, default +//! branch). +//! 3. Plain `HEAD^..HEAD` when no event is available. +//! +//! Output formats: +//! +//! - `text` (default): `Base: ` and `Head: ` on two lines. +//! - `shell`: `MERGIFY_GIT_REFS_{BASE,HEAD,SOURCE}=...` lines, each +//! single-quoted via `shlex`-style quoting so the caller can `eval` +//! them. +//! - `json`: one JSON object on a single line. +//! +//! Side-effects: when `$GITHUB_OUTPUT` is set the command appends +//! `base=` / `head=` lines. When `BUILDKITE=true` it invokes +//! `buildkite-agent meta-data set` for base/head/source. + +use std::env; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::PathBuf; +use std::process::Command; +use std::process::Stdio; + +use mergify_core::CliError; +use mergify_core::Output; +use serde::Serialize; + +use crate::github_event::GitHubEvent; +use crate::github_event::PULL_REQUEST_EVENTS; +use crate::github_event::load as load_event; +use crate::queue_metadata::MergeQueueMetadata; +use crate::queue_metadata::extract_from_event; +use crate::queue_metadata::parse_yaml_block; + +const BUILDKITE_BASE_METADATA_KEY: &str = "mergify-ci.base"; +const BUILDKITE_HEAD_METADATA_KEY: &str = "mergify-ci.head"; +const BUILDKITE_SOURCE_METADATA_KEY: &str = "mergify-ci.source"; + +/// Provenance tag for the detected references. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReferencesSource { + Manual, + MergeQueue, + FallbackLastCommit, + GithubEventOther, + GithubEventPullRequest, + GithubEventPush, + BuildkitePullRequest, +} + +impl ReferencesSource { + fn as_str(self) -> &'static str { + match self { + Self::Manual => "manual", + Self::MergeQueue => "merge_queue", + Self::FallbackLastCommit => "fallback_last_commit", + Self::GithubEventOther => "github_event_other", + Self::GithubEventPullRequest => "github_event_pull_request", + Self::GithubEventPush => "github_event_push", + Self::BuildkitePullRequest => "buildkite_pull_request", + } + } +} + +#[derive(Debug, Clone)] +pub struct References { + pub base: Option, + pub head: String, + pub source: ReferencesSource, +} + +/// Trait-object-compatible hook for reading merge-queue git notes. +/// +/// The real implementation shells out to `git`. Tests inject a stub +/// so detection can exercise the note-driven branches without +/// touching a real repository. +pub type NotesReader<'a> = &'a dyn Fn(&str, &str) -> Option; + +#[derive(Serialize)] +struct JsonOutput<'a> { + base: Option<&'a str>, + head: &'a str, + source: &'a str, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Format { + Text, + Shell, + Json, +} + +impl Format { + /// Clap value-parser for `--format`. + /// + /// # Errors + /// + /// Returns a message when `value` is not one of `text`, `shell`, + /// or `json`. + pub fn parse(value: &str) -> Result { + match value { + "text" => Ok(Self::Text), + "shell" => Ok(Self::Shell), + "json" => Ok(Self::Json), + other => Err(format!( + "invalid format {other:?} (expected text, shell, or json)" + )), + } + } +} + +pub struct GitRefsOptions { + pub format: Format, +} + +/// Run the `ci git-refs` command. +pub fn run(opts: &GitRefsOptions, output: &mut dyn Output) -> Result<(), CliError> { + let notes_reader: NotesReader = &real_notes_reader; + let refs = detect(output, notes_reader)?; + emit(&refs, opts.format, output)?; + write_github_output(&refs)?; + write_buildkite_metadata(&refs)?; + Ok(()) +} + +/// Detect base/head references using the current environment. +/// +/// `notes_reader` is injected so tests can bypass the git +/// subprocess. Production callers pass [`real_notes_reader`]. +/// +/// # Errors +/// +/// Returns `CliError::Generic` when the event is a pull-request or +/// push event but no base SHA can be derived — matches Python's +/// `BaseNotFoundError`. +pub fn detect( + output: &mut dyn Output, + notes_reader: NotesReader<'_>, +) -> Result { + if env::var("BUILDKITE").as_deref() == Ok("true") { + if let Some(refs) = detect_from_buildkite(notes_reader) { + return Ok(refs); + } + } + + let Some((event_name, event)) = load_event() else { + return Ok(References { + base: Some("HEAD^".to_string()), + head: "HEAD".to_string(), + source: ReferencesSource::FallbackLastCommit, + }); + }; + + if PULL_REQUEST_EVENTS.contains(&event_name.as_str()) { + if let Some(refs) = detect_from_pull_request_event(&event, output, notes_reader)? { + return Ok(refs); + } + } else if event_name == "push" { + if let Some(refs) = detect_from_push_event(&event) { + return Ok(refs); + } + } else { + return Ok(References { + base: None, + head: "HEAD".to_string(), + source: ReferencesSource::GithubEventOther, + }); + } + + Err(CliError::Generic( + "Could not detect base SHA. Provide GITHUB_EVENT_NAME / GITHUB_EVENT_PATH.".to_string(), + )) +} + +fn detect_from_buildkite(notes_reader: NotesReader<'_>) -> Option { + let pr = env::var("BUILDKITE_PULL_REQUEST").ok()?; + if pr.is_empty() || pr == "false" { + return None; + } + let commit = env::var("BUILDKITE_COMMIT") + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "HEAD".to_string()); + if let Ok(branch) = env::var("BUILDKITE_BRANCH") { + if !branch.is_empty() { + if let Some(note) = notes_reader(&branch, &commit) { + return Some(References { + base: Some(note.checking_base_sha), + head: commit, + source: ReferencesSource::MergeQueue, + }); + } + } + } + let base_branch = env::var("BUILDKITE_PULL_REQUEST_BASE_BRANCH") + .ok() + .filter(|s| !s.is_empty())?; + Some(References { + base: Some(base_branch), + head: commit, + source: ReferencesSource::BuildkitePullRequest, + }) +} + +fn detect_from_pull_request_event( + event: &GitHubEvent, + output: &mut dyn Output, + notes_reader: NotesReader<'_>, +) -> std::io::Result> { + let head = event + .pull_request + .as_ref() + .and_then(|pr| pr.head.as_ref()) + .map_or_else(|| "HEAD".to_string(), |r| r.sha.clone()); + + if let Some(pr) = &event.pull_request { + if let Some(head_ref) = &pr.head { + if let Some(branch) = head_ref.r#ref.as_deref() { + if let Some(note) = notes_reader(branch, &head_ref.sha) { + return Ok(Some(References { + base: Some(note.checking_base_sha), + head, + source: ReferencesSource::MergeQueue, + })); + } + } + } + } + + if let Some(meta) = extract_from_event(event, output)? { + return Ok(Some(References { + base: Some(meta.checking_base_sha), + head, + source: ReferencesSource::MergeQueue, + })); + } + + if let Some(pr) = &event.pull_request { + if let Some(base) = &pr.base { + return Ok(Some(References { + base: Some(base.sha.clone()), + head, + source: ReferencesSource::GithubEventPullRequest, + })); + } + } + + if let Some(repo) = &event.repository { + if let Some(default_branch) = &repo.default_branch { + return Ok(Some(References { + base: Some(default_branch.clone()), + head, + source: ReferencesSource::GithubEventPullRequest, + })); + } + } + + Ok(None) +} + +fn detect_from_push_event(event: &GitHubEvent) -> Option { + let head = event + .after + .clone() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "HEAD".to_string()); + + if let Some(before) = event.before.as_deref().filter(|s| !s.is_empty()) { + return Some(References { + base: Some(before.to_string()), + head, + source: ReferencesSource::GithubEventPush, + }); + } + + let default_branch = event + .repository + .as_ref() + .and_then(|r| r.default_branch.clone())?; + Some(References { + base: Some(default_branch), + head: "HEAD".to_string(), + source: ReferencesSource::GithubEventPush, + }) +} + +/// Production implementation of [`NotesReader`]. Shells out to +/// `git fetch` + `git notes show` and swallows any failure as `None` +/// so callers can transparently fall through to other detection +/// paths. +#[must_use] +pub fn real_notes_reader(branch: &str, head_sha: &str) -> Option { + let notes_ref_short = format!("mergify/{branch}"); + let notes_ref = format!("refs/notes/{notes_ref_short}"); + + let fetch = Command::new("git") + .args([ + "fetch", + "--no-tags", + "--quiet", + "origin", + &format!("+{notes_ref}:{notes_ref}"), + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .ok()?; + if !fetch.success() { + return None; + } + + let output = Command::new("git") + .args([ + "notes", + &format!("--ref={notes_ref_short}"), + "show", + head_sha, + ]) + .stderr(Stdio::null()) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let content = String::from_utf8(output.stdout).ok()?; + let meta: MergeQueueMetadata = serde_yaml_ng::from_str(&content).ok()?; + // Python also guards against non-dict payloads; `from_str` into + // our typed struct already enforces the shape, so just return. + Some(meta) +} + +#[allow(dead_code)] +fn parse_notes_payload(content: &str) -> Option { + // Exposed for unit-testing the YAML parsing independently of + // the git subprocess. + parse_yaml_block(content).or_else(|| serde_yaml_ng::from_str(content).ok()) +} + +fn emit(refs: &References, format: Format, output: &mut dyn Output) -> std::io::Result<()> { + match format { + Format::Text => output.emit(&(), &mut |w: &mut dyn Write| { + writeln!(w, "Base: {}", refs.base.as_deref().unwrap_or(""))?; + writeln!(w, "Head: {}", refs.head) + }), + Format::Shell => output.emit(&(), &mut |w: &mut dyn Write| { + writeln!( + w, + "MERGIFY_GIT_REFS_BASE={}", + shell_quote(refs.base.as_deref().unwrap_or("")) + )?; + writeln!(w, "MERGIFY_GIT_REFS_HEAD={}", shell_quote(&refs.head))?; + writeln!( + w, + "MERGIFY_GIT_REFS_SOURCE={}", + shell_quote(refs.source.as_str()) + ) + }), + Format::Json => { + let payload = JsonOutput { + base: refs.base.as_deref(), + head: &refs.head, + source: refs.source.as_str(), + }; + output.emit(&payload, &mut |w: &mut dyn Write| { + let rendered = serde_json::to_string(&payload) + .map_err(|e| std::io::Error::other(e.to_string()))?; + writeln!(w, "{rendered}") + }) + } + } +} + +/// Best-effort POSIX shell quoting. Mirrors `shlex.quote`: empty and +/// "safe" strings stay bare, everything else is single-quoted with +/// embedded `'` rewritten to `'"'"'`. +fn shell_quote(value: &str) -> String { + if value.is_empty() { + return "''".to_string(); + } + let safe = value.chars().all(|c| { + c.is_ascii_alphanumeric() + || matches!(c, '@' | '%' | '+' | '=' | ':' | ',' | '.' | '/' | '-' | '_') + }); + if safe { + return value.to_string(); + } + let escaped = value.replace('\'', "'\"'\"'"); + format!("'{escaped}'") +} + +fn write_github_output(refs: &References) -> std::io::Result<()> { + let Some(path) = env::var("GITHUB_OUTPUT").ok().filter(|s| !s.is_empty()) else { + return Ok(()); + }; + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(PathBuf::from(path))?; + writeln!(file, "base={}", refs.base.as_deref().unwrap_or(""))?; + writeln!(file, "head={}", refs.head)?; + Ok(()) +} + +fn write_buildkite_metadata(refs: &References) -> std::io::Result<()> { + if env::var("BUILDKITE").as_deref() != Ok("true") { + return Ok(()); + } + if let Some(base) = refs.base.as_deref() { + buildkite_meta_data_set(BUILDKITE_BASE_METADATA_KEY, base)?; + } + buildkite_meta_data_set(BUILDKITE_HEAD_METADATA_KEY, &refs.head)?; + buildkite_meta_data_set(BUILDKITE_SOURCE_METADATA_KEY, refs.source.as_str())?; + Ok(()) +} + +fn buildkite_meta_data_set(key: &str, value: &str) -> std::io::Result<()> { + let status = Command::new("buildkite-agent") + .args(["meta-data", "set", key, value]) + .status()?; + if !status.success() { + return Err(std::io::Error::other(format!( + "buildkite-agent meta-data set {key} exited with status {status}" + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + 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 no_notes(_branch: &str, _sha: &str) -> Option { + None + } + + fn write_event(dir: &TempDir, payload: &serde_json::Value) -> PathBuf { + let path = dir.path().join("event.json"); + std::fs::write(&path, serde_json::to_vec(payload).unwrap()).unwrap(); + path + } + + #[test] + fn falls_back_to_head_pair_when_no_event() { + let mut cap = make_output(); + let refs = temp_env::with_vars_unset( + ["GITHUB_EVENT_NAME", "GITHUB_EVENT_PATH", "BUILDKITE"], + || detect(&mut cap.output, &no_notes).unwrap(), + ); + assert_eq!(refs.base.as_deref(), Some("HEAD^")); + assert_eq!(refs.head, "HEAD"); + assert_eq!(refs.source, ReferencesSource::FallbackLastCommit); + } + + #[test] + fn detects_from_pull_request_base() { + let dir = tempfile::tempdir().unwrap(); + let path = write_event( + &dir, + &serde_json::json!({ + "pull_request": { + "base": {"sha": "base-sha"}, + "head": {"sha": "head-sha", "ref": "feat/x"}, + }, + }), + ); + let mut cap = make_output(); + let refs = temp_env::with_vars( + [ + ("GITHUB_EVENT_NAME", Some("pull_request")), + ("GITHUB_EVENT_PATH", Some(path.to_str().unwrap())), + ("BUILDKITE", None), + ], + || detect(&mut cap.output, &no_notes).unwrap(), + ); + assert_eq!(refs.base.as_deref(), Some("base-sha")); + assert_eq!(refs.head, "head-sha"); + assert_eq!(refs.source, ReferencesSource::GithubEventPullRequest); + } + + #[test] + fn detects_from_push_before_sha() { + let dir = tempfile::tempdir().unwrap(); + let path = write_event( + &dir, + &serde_json::json!({"before": "old-sha", "after": "new-sha"}), + ); + let mut cap = make_output(); + let refs = temp_env::with_vars( + [ + ("GITHUB_EVENT_NAME", Some("push")), + ("GITHUB_EVENT_PATH", Some(path.to_str().unwrap())), + ("BUILDKITE", None), + ], + || detect(&mut cap.output, &no_notes).unwrap(), + ); + assert_eq!(refs.base.as_deref(), Some("old-sha")); + assert_eq!(refs.head, "new-sha"); + assert_eq!(refs.source, ReferencesSource::GithubEventPush); + } + + #[test] + fn detects_mq_from_pr_body_yaml() { + let dir = tempfile::tempdir().unwrap(); + let path = write_event( + &dir, + &serde_json::json!({ + "pull_request": { + "title": "merge queue: batch", + "body": "prelude\n```yaml\nchecking_base_sha: mq-base\n```", + "head": {"sha": "mq-head", "ref": "mq/main/0"}, + }, + }), + ); + let mut cap = make_output(); + let refs = temp_env::with_vars( + [ + ("GITHUB_EVENT_NAME", Some("pull_request")), + ("GITHUB_EVENT_PATH", Some(path.to_str().unwrap())), + ("BUILDKITE", None), + ], + || detect(&mut cap.output, &no_notes).unwrap(), + ); + assert_eq!(refs.base.as_deref(), Some("mq-base")); + assert_eq!(refs.head, "mq-head"); + assert_eq!(refs.source, ReferencesSource::MergeQueue); + } + + #[test] + fn mq_notes_beat_body_yaml() { + let dir = tempfile::tempdir().unwrap(); + let path = write_event( + &dir, + &serde_json::json!({ + "pull_request": { + "title": "merge queue: batch", + "body": "```yaml\nchecking_base_sha: body-sha\n```", + "head": {"sha": "mq-head", "ref": "mq/main/0"}, + }, + }), + ); + let note_reader = |branch: &str, sha: &str| { + if branch == "mq/main/0" && sha == "mq-head" { + Some(MergeQueueMetadata { + checking_base_sha: "note-sha".to_string(), + pull_requests: Vec::new(), + previous_failed_batches: Vec::new(), + }) + } else { + None + } + }; + let mut cap = make_output(); + let refs = temp_env::with_vars( + [ + ("GITHUB_EVENT_NAME", Some("pull_request")), + ("GITHUB_EVENT_PATH", Some(path.to_str().unwrap())), + ("BUILDKITE", None), + ], + || detect(&mut cap.output, ¬e_reader).unwrap(), + ); + assert_eq!(refs.base.as_deref(), Some("note-sha")); + } + + #[test] + fn errors_when_pr_event_missing_base() { + let dir = tempfile::tempdir().unwrap(); + let path = write_event( + &dir, + &serde_json::json!({"pull_request": {"head": {"sha": "h"}}}), + ); + let mut cap = make_output(); + let err = temp_env::with_vars( + [ + ("GITHUB_EVENT_NAME", Some("pull_request")), + ("GITHUB_EVENT_PATH", Some(path.to_str().unwrap())), + ("BUILDKITE", None), + ], + || detect(&mut cap.output, &no_notes).unwrap_err(), + ); + assert!(err.to_string().contains("Could not detect base SHA")); + } + + #[test] + fn detects_buildkite_pull_request() { + let mut cap = make_output(); + let refs = temp_env::with_vars( + [ + ("BUILDKITE", Some("true")), + ("BUILDKITE_PULL_REQUEST", Some("42")), + ("BUILDKITE_COMMIT", Some("sha-head")), + ("BUILDKITE_BRANCH", Some("feat/x")), + ("BUILDKITE_PULL_REQUEST_BASE_BRANCH", Some("main")), + ("GITHUB_EVENT_NAME", None), + ("GITHUB_EVENT_PATH", None), + ], + || detect(&mut cap.output, &no_notes).unwrap(), + ); + assert_eq!(refs.base.as_deref(), Some("main")); + assert_eq!(refs.head, "sha-head"); + assert_eq!(refs.source, ReferencesSource::BuildkitePullRequest); + } + + #[test] + fn shell_quote_basic_cases() { + assert_eq!(shell_quote(""), "''"); + assert_eq!(shell_quote("feat/x"), "feat/x"); + assert_eq!(shell_quote("has space"), "'has space'"); + assert_eq!(shell_quote("bob's"), "'bob'\"'\"'s'"); + } + + #[test] + fn emits_text_format() { + let refs = References { + base: Some("b".into()), + head: "h".into(), + source: ReferencesSource::GithubEventPush, + }; + let mut cap = make_output(); + emit(&refs, Format::Text, &mut cap.output).unwrap(); + let stdout = String::from_utf8(cap.stdout.lock().unwrap().clone()).unwrap(); + assert_eq!(stdout, "Base: b\nHead: h\n"); + } + + #[test] + fn emits_shell_format() { + let refs = References { + base: Some("main".into()), + head: "has space".into(), + source: ReferencesSource::MergeQueue, + }; + let mut cap = make_output(); + emit(&refs, Format::Shell, &mut cap.output).unwrap(); + let stdout = String::from_utf8(cap.stdout.lock().unwrap().clone()).unwrap(); + assert!(stdout.contains("MERGIFY_GIT_REFS_BASE=main")); + assert!(stdout.contains("MERGIFY_GIT_REFS_HEAD='has space'")); + assert!(stdout.contains("MERGIFY_GIT_REFS_SOURCE=merge_queue")); + } + + #[test] + fn emits_json_format() { + let refs = References { + base: None, + head: "HEAD".into(), + source: ReferencesSource::GithubEventOther, + }; + let mut cap = make_output(); + emit(&refs, Format::Json, &mut cap.output).unwrap(); + let stdout = String::from_utf8(cap.stdout.lock().unwrap().clone()).unwrap(); + assert_eq!( + stdout.trim_end(), + r#"{"base":null,"head":"HEAD","source":"github_event_other"}"# + ); + } + + #[test] + fn format_parse_round_trips() { + assert!(matches!(Format::parse("text"), Ok(Format::Text))); + assert!(matches!(Format::parse("shell"), Ok(Format::Shell))); + assert!(matches!(Format::parse("json"), Ok(Format::Json))); + assert!(Format::parse("yaml").is_err()); + } + + struct SharedWriter(SharedBytes); + impl std::io::Write for SharedWriter { + fn write(&mut self, bytes: &[u8]) -> 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-ci/src/github_event.rs b/crates/mergify-ci/src/github_event.rs new file mode 100644 index 00000000..f725225b --- /dev/null +++ b/crates/mergify-ci/src/github_event.rs @@ -0,0 +1,112 @@ +//! Deserialization of the GitHub Actions event payload. +//! +//! Mirrors the `pydantic` models in `mergify_cli.ci.github_event`. +//! All structs ignore unknown fields (`serde(default)` + no +//! `deny_unknown_fields` on purpose) so the payload's superset of +//! fields doesn't break us. + +use std::env; +use std::path::PathBuf; + +use serde::Deserialize; + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct GitRef { + pub sha: String, + #[serde(default)] + pub r#ref: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct PullRequest { + #[serde(default)] + pub number: Option, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub body: Option, + #[serde(default)] + pub base: Option, + #[serde(default)] + pub head: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct Repository { + #[serde(default)] + pub default_branch: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct GitHubEvent { + #[serde(default)] + pub pull_request: Option, + #[serde(default)] + pub repository: Option, + #[serde(default)] + pub before: Option, + #[serde(default)] + pub after: Option, +} + +/// Events that carry a pull request in their payload. +pub const PULL_REQUEST_EVENTS: &[&str] = &[ + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "pull_request_target", +]; + +/// Load the event payload from `GITHUB_EVENT_PATH`, keyed by +/// `GITHUB_EVENT_NAME`. +/// +/// Returns `None` when either env var is missing, the file does not +/// exist, or the JSON cannot be parsed — mirrors Python's +/// `GitHubEventNotFoundError` being converted to a fallback. +#[must_use] +pub fn load() -> Option<(String, GitHubEvent)> { + let event_name = env::var("GITHUB_EVENT_NAME") + .ok() + .filter(|s| !s.is_empty())?; + let event_path = env::var("GITHUB_EVENT_PATH") + .ok() + .filter(|s| !s.is_empty())?; + let path = PathBuf::from(event_path); + if !path.is_file() { + return None; + } + let raw = std::fs::read_to_string(&path).ok()?; + let event: GitHubEvent = serde_json::from_str(&raw).ok()?; + Some((event_name, event)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialize_minimal_event() { + let raw = r#"{"pull_request": {"number": 42}}"#; + let ev: GitHubEvent = serde_json::from_str(raw).unwrap(); + assert_eq!(ev.pull_request.unwrap().number, Some(42)); + } + + #[test] + fn deserialize_ignores_unknown_fields() { + let raw = r#"{"pull_request": {"number": 7, "unknown": "x"}, "foo": 1}"#; + let ev: GitHubEvent = serde_json::from_str(raw).unwrap(); + assert_eq!(ev.pull_request.unwrap().number, Some(7)); + } + + #[test] + fn deserialize_push_event_shape() { + let raw = r#"{"before": "a", "after": "b", "repository": {"default_branch": "main"}}"#; + let ev: GitHubEvent = serde_json::from_str(raw).unwrap(); + assert_eq!(ev.before.as_deref(), Some("a")); + assert_eq!(ev.after.as_deref(), Some("b")); + assert_eq!( + ev.repository.unwrap().default_branch.as_deref(), + Some("main") + ); + } +} diff --git a/crates/mergify-ci/src/lib.rs b/crates/mergify-ci/src/lib.rs index 5bf008f3..1c7e3209 100644 --- a/crates/mergify-ci/src/lib.rs +++ b/crates/mergify-ci/src/lib.rs @@ -1,11 +1,14 @@ //! Native Rust implementation of the `mergify ci` subcommands. //! -//! Phase 1.4 starts with `ci scopes-send` — straight HTTP POST to -//! Mergify with the scopes detected for a pull request. Other ci -//! commands (`git-refs`, `scopes`, `queue-info`, `junit-process`) -//! land in follow-up PRs as the shared infrastructure they need -//! (git-subprocess runner, GitHub event parser, `JUnit` XML reader) -//! is built out. +//! `ci scopes-send` was the first ported command. This module +//! adds `ci git-refs` (base/head detection) and the two shared +//! helpers it depends on: `github_event` (GitHub Actions event +//! payload deserialization) and `queue_metadata` (MQ YAML +//! fenced-block extraction). Subsequent ports (`queue-info`, +//! `junit-process`, `scopes` outer command) reuse these helpers. pub mod detector; +pub mod git_refs; +pub mod github_event; +pub mod queue_metadata; pub mod scopes_send; diff --git a/crates/mergify-ci/src/queue_metadata.rs b/crates/mergify-ci/src/queue_metadata.rs new file mode 100644 index 00000000..1ddd5c10 --- /dev/null +++ b/crates/mergify-ci/src/queue_metadata.rs @@ -0,0 +1,204 @@ +//! Extract merge-queue batch metadata from a GitHub event payload. +//! +//! Mirrors `mergify_cli.ci.queue.metadata`. The engine publishes the +//! batch info as a ```yaml``` fenced block inside the MQ draft PR +//! body. `detect` returns `None` when the current event has no such +//! metadata — callers either fall back to other detection paths +//! (`git_refs`) or surface it as an `INVALID_STATE` (`queue_info`). + +use mergify_core::Output; +use serde::Deserialize; +use serde::Serialize; + +use crate::github_event::GitHubEvent; +use crate::github_event::PULL_REQUEST_EVENTS; +use crate::github_event::load as load_event; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MergeQueuePullRequest { + pub number: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MergeQueueBatchFailed { + pub draft_pr_number: u64, + pub checked_pull_requests: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MergeQueueMetadata { + pub checking_base_sha: String, + #[serde(default)] + pub pull_requests: Vec, + #[serde(default)] + pub previous_failed_batches: Vec, +} + +/// Parse the first ```yaml``` fenced block out of `body` and try to +/// read a `MergeQueueMetadata` out of it. Returns `None` when the +/// body has no fenced block or the YAML payload is the wrong shape. +#[must_use] +pub fn parse_yaml_block(body: &str) -> Option { + let mut inside = false; + let mut lines: Vec<&str> = Vec::new(); + for line in body.lines() { + if !inside { + if line.starts_with("```yaml") { + inside = true; + } + } else if line.starts_with("```") { + break; + } else { + lines.push(line); + } + } + if lines.is_empty() { + return None; + } + serde_yaml_ng::from_str(&lines.join("\n")).ok() +} + +/// Extract MQ metadata from an event payload's pull-request body. +/// +/// Emits a warning on `output` (stderr for human mode) when the PR is +/// an MQ draft but the body is missing or lacks the fenced block — +/// matches Python's stderr warnings. +pub fn extract_from_event( + ev: &GitHubEvent, + output: &mut dyn Output, +) -> std::io::Result> { + let Some(pr) = &ev.pull_request else { + return Ok(None); + }; + let Some(title) = pr.title.as_deref() else { + return Ok(None); + }; + if !title.starts_with("merge queue: ") { + return Ok(None); + } + let Some(body) = pr.body.as_deref() else { + output.status("WARNING: MQ pull request without body, skipping metadata extraction")?; + return Ok(None); + }; + let parsed = parse_yaml_block(body); + if parsed.is_none() { + output.status( + "WARNING: MQ pull request body without Mergify metadata, skipping metadata extraction", + )?; + } + Ok(parsed) +} + +/// Load the current event and extract merge-queue metadata. +/// +/// Returns `None` when not in a pull-request event or when no MQ +/// metadata is attached to the event's PR. Callers decide how to +/// treat that `None` (skip, error, fall back). +pub fn detect(output: &mut dyn Output) -> std::io::Result> { + let Some((event_name, event)) = load_event() else { + return Ok(None); + }; + if !PULL_REQUEST_EVENTS.contains(&event_name.as_str()) { + return Ok(None); + } + extract_from_event(&event, output) +} + +#[cfg(test)] +mod tests { + use mergify_core::OutputMode; + use mergify_core::StdioOutput; + + use super::*; + + type SharedBytes = std::sync::Arc>>; + + struct Captured { + output: StdioOutput, + stderr: 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, stderr } + } + + #[test] + fn parse_yaml_block_extracts_metadata() { + let body = "prelude\n\n```yaml\nchecking_base_sha: abc\npull_requests:\n - number: 1\n```\ntrailing"; + let meta = parse_yaml_block(body).unwrap(); + assert_eq!(meta.checking_base_sha, "abc"); + assert_eq!(meta.pull_requests.len(), 1); + assert_eq!(meta.pull_requests[0].number, 1); + } + + #[test] + fn parse_yaml_block_returns_none_without_block() { + assert!(parse_yaml_block("just text").is_none()); + } + + #[test] + fn extract_ignores_non_mq_pr() { + let ev = GitHubEvent { + pull_request: Some(crate::github_event::PullRequest { + title: Some("feat: something".into()), + ..Default::default() + }), + ..Default::default() + }; + let mut cap = make_output(); + let result = extract_from_event(&ev, &mut cap.output).unwrap(); + assert!(result.is_none()); + assert!(cap.stderr.lock().unwrap().is_empty()); + } + + #[test] + fn extract_warns_on_mq_pr_without_body() { + let ev = GitHubEvent { + pull_request: Some(crate::github_event::PullRequest { + title: Some("merge queue: deploy".into()), + body: None, + ..Default::default() + }), + ..Default::default() + }; + let mut cap = make_output(); + let result = extract_from_event(&ev, &mut cap.output).unwrap(); + assert!(result.is_none()); + let stderr = String::from_utf8(cap.stderr.lock().unwrap().clone()).unwrap(); + assert!(stderr.contains("without body"), "got: {stderr:?}"); + } + + #[test] + fn extract_returns_metadata_for_mq_pr() { + let body = "blah\n```yaml\nchecking_base_sha: deadbeef\n```"; + let ev = GitHubEvent { + pull_request: Some(crate::github_event::PullRequest { + title: Some("merge queue: batch".into()), + body: Some(body.into()), + ..Default::default() + }), + ..Default::default() + }; + let mut cap = make_output(); + let meta = extract_from_event(&ev, &mut cap.output).unwrap().unwrap(); + assert_eq!(meta.checking_base_sha, "deadbeef"); + } + + struct SharedWriter(SharedBytes); + impl std::io::Write for SharedWriter { + fn write(&mut self, bytes: &[u8]) -> 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 599c9379..9d3470d9 100644 --- a/crates/mergify-cli/src/main.rs +++ b/crates/mergify-cli/src/main.rs @@ -19,6 +19,8 @@ use std::process::ExitCode; use clap::Parser; use clap::Subcommand; +use mergify_ci::git_refs::Format as GitRefsFormat; +use mergify_ci::git_refs::GitRefsOptions; use mergify_ci::scopes_send::ScopesSendOptions; use mergify_config::simulate::PullRequestRef; use mergify_config::simulate::SimulateOptions; @@ -65,6 +67,7 @@ enum NativeCommand { ConfigValidate { config_file: Option }, ConfigSimulate(ConfigSimulateOpts), CiScopesSend(CiScopesSendOpts), + CiGitRefs { format: GitRefsFormat }, QueuePause(QueuePauseOpts), QueueUnpause(QueueUnpauseOpts), } @@ -115,7 +118,7 @@ fn looks_native(argv: &[String]) -> bool { matches!( (pair[0].as_str(), pair[1].as_str()), ("config", "validate" | "simulate") - | ("ci", "scopes-send") + | ("ci", "scopes-send" | "git-refs") | ("queue", "pause" | "unpause"), ) }) @@ -219,6 +222,9 @@ fn detect_native(argv: &[String]) -> Option { scopes_file, file_deprecated, })), + Subcommands::Ci(CiArgs { + command: CiSubcommand::GitRefs(GitRefsCliArgs { format }), + }) => Some(NativeCommand::CiGitRefs { format }), Subcommands::Queue(QueueArgs { repository, token, @@ -295,6 +301,9 @@ fn run_native(cmd: NativeCommand) -> ExitCode { ) .await } + NativeCommand::CiGitRefs { format } => { + mergify_ci::git_refs::run(&GitRefsOptions { format }, &mut output) + } NativeCommand::QueuePause(opts) => { mergify_queue::pause::run( PauseOptions { @@ -401,6 +410,22 @@ enum CiSubcommand { /// Send scopes tied to a pull request to Mergify. #[command(name = "scopes-send")] ScopesSend(ScopesSendCliArgs), + /// Print the base/head git references for the current build. + #[command(name = "git-refs")] + GitRefs(GitRefsCliArgs), +} + +#[derive(clap::Args)] +struct GitRefsCliArgs { + /// Output format: `text` (default), `shell` for eval-friendly + /// `MERGIFY_GIT_REFS_*` lines, or `json` for a single JSON + /// object. + #[arg( + long = "format", + default_value = "text", + value_parser = mergify_ci::git_refs::Format::parse, + )] + format: GitRefsFormat, } #[derive(clap::Args)] diff --git a/mergify_cli/ci/cli.py b/mergify_cli/ci/cli.py index 583d209e..14476ea4 100644 --- a/mergify_cli/ci/cli.py +++ b/mergify_cli/ci/cli.py @@ -4,7 +4,6 @@ import json import os import pathlib -import shlex import uuid import click @@ -263,41 +262,6 @@ async def junit_process( ) -@ci.command( - help="""Give the base/head git references of the pull request""", - short_help="""Give the base/head git references of the pull request""", -) -@click.option( - "--format", - "output_format", - type=click.Choice(["text", "shell", "json"]), - default="text", - show_default=True, - help=( - "Output format. 'text' is human-readable. " - "'shell' emits MERGIFY_GIT_REFS_{BASE,HEAD,SOURCE}=... lines for `eval`. " - "'json' emits a single-line JSON object." - ), -) -def git_refs(output_format: str) -> None: - ref = git_refs_detector.detect() - - if output_format == "shell": - click.echo(f"MERGIFY_GIT_REFS_BASE={shlex.quote(ref.base or '')}") - click.echo(f"MERGIFY_GIT_REFS_HEAD={shlex.quote(ref.head)}") - click.echo(f"MERGIFY_GIT_REFS_SOURCE={shlex.quote(ref.source)}") - elif output_format == "json": - click.echo( - json.dumps({"base": ref.base, "head": ref.head, "source": ref.source}), - ) - else: - click.echo(f"Base: {ref.base}") - click.echo(f"Head: {ref.head}") - - ref.maybe_write_to_github_outputs() - ref.maybe_write_to_buildkite_metadata() - - @ci.command( help="""Give the list scope impacted by changed files""", short_help="""Give the list scope impacted by changed files""", diff --git a/mergify_cli/tests/ci/test_cli.py b/mergify_cli/tests/ci/test_cli.py index 705b942b..44d31e2f 100644 --- a/mergify_cli/tests/ci/test_cli.py +++ b/mergify_cli/tests/ci/test_cli.py @@ -524,140 +524,6 @@ def test_scopes_empty_mergify_config_env_uses_autodetection( assert "source `manual` has been set" in result.output -def test_git_refs( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - event_data = {"before": "abc123", "after": "xyz987"} - 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", "push") - monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_file)) - - runner = testing.CliRunner() - result = runner.invoke(ci_cli.git_refs, []) - assert result.exit_code == 0, result.output - assert result.output == "Base: abc123\nHead: xyz987\n" - - content = output_file.read_text() - expected = """base=abc123 -head=xyz987 -""" - assert content == expected - - -def test_git_refs_github_output_empty_base_when_none( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """When base can't be detected, GITHUB_OUTPUT gets `base=` (empty), not `base=None`.""" - event_data: dict[str, object] = {} - 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", "workflow_dispatch") - monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_file)) - - runner = testing.CliRunner() - result = runner.invoke(ci_cli.git_refs, []) - assert result.exit_code == 0, result.output - - assert output_file.read_text() == "base=\nhead=HEAD\n" - - -def test_git_refs_format_shell( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - event_data = {"before": "abc123", "after": "xyz987"} - event_file = tmp_path / "event.json" - event_file.write_text(json.dumps(event_data)) - - monkeypatch.delenv("GITHUB_OUTPUT", raising=False) - monkeypatch.setenv("GITHUB_EVENT_NAME", "push") - monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_file)) - - runner = testing.CliRunner() - result = runner.invoke(ci_cli.git_refs, ["--format", "shell"]) - assert result.exit_code == 0, result.output - assert result.output == ( - "MERGIFY_GIT_REFS_BASE=abc123\n" - "MERGIFY_GIT_REFS_HEAD=xyz987\n" - "MERGIFY_GIT_REFS_SOURCE=github_event_push\n" - ) - - -def test_git_refs_format_shell_quotes_special_chars( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Values containing shell-special chars must be properly quoted so `eval` is safe.""" - event_data = { - "repository": {"default_branch": "weird branch $name"}, - } - event_file = tmp_path / "event.json" - event_file.write_text(json.dumps(event_data)) - - monkeypatch.delenv("GITHUB_OUTPUT", raising=False) - monkeypatch.setenv("GITHUB_EVENT_NAME", "push") - monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_file)) - - runner = testing.CliRunner() - result = runner.invoke(ci_cli.git_refs, ["--format", "shell"]) - assert result.exit_code == 0, result.output - # space and `$` both trigger shlex.quote to wrap the value in single quotes - assert "MERGIFY_GIT_REFS_BASE='weird branch $name'\n" in result.output - - -def test_git_refs_format_shell_empty_base( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """When base is None, shell format emits an empty quoted string.""" - event_data: dict[str, object] = {} - event_file = tmp_path / "event.json" - event_file.write_text(json.dumps(event_data)) - - monkeypatch.delenv("GITHUB_OUTPUT", raising=False) - monkeypatch.setenv("GITHUB_EVENT_NAME", "workflow_dispatch") - monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_file)) - - runner = testing.CliRunner() - result = runner.invoke(ci_cli.git_refs, ["--format", "shell"]) - assert result.exit_code == 0, result.output - assert "MERGIFY_GIT_REFS_BASE=''\n" in result.output - assert "MERGIFY_GIT_REFS_HEAD=HEAD\n" in result.output - assert "MERGIFY_GIT_REFS_SOURCE=github_event_other\n" in result.output - - -def test_git_refs_format_json( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - event_data = {"before": "abc123", "after": "xyz987"} - event_file = tmp_path / "event.json" - event_file.write_text(json.dumps(event_data)) - - monkeypatch.delenv("GITHUB_OUTPUT", raising=False) - monkeypatch.setenv("GITHUB_EVENT_NAME", "push") - monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_file)) - - runner = testing.CliRunner() - result = runner.invoke(ci_cli.git_refs, ["--format", "json"]) - assert result.exit_code == 0, result.output - assert json.loads(result.output) == { - "base": "abc123", - "head": "xyz987", - "source": "github_event_push", - } - - def test_queue_info( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch,