diff --git a/Cargo.lock b/Cargo.lock index 2a65a621..7a1d49d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,6 +31,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -182,6 +191,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + [[package]] name = "clap" version = "4.6.1" @@ -228,6 +248,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "deadpool" version = "0.12.3" @@ -644,6 +670,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -895,10 +945,12 @@ dependencies = [ "mergify-core", "serde", "serde_json", + "serde_yaml_ng", "temp-env", "tempfile", "tokio", "url", + "uuid", "wiremock", ] @@ -911,6 +963,7 @@ dependencies = [ "mergify-config", "mergify-core", "mergify-py-shim", + "mergify-queue", "tokio", ] @@ -937,6 +990,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "temp-env", "thiserror", "tokio", "url", @@ -952,6 +1006,21 @@ dependencies = [ "thiserror", ] +[[package]] +name = "mergify-queue" +version = "0.0.0" +dependencies = [ + "chrono", + "indexmap", + "mergify-core", + "serde", + "serde_json", + "temp-env", + "tokio", + "url", + "wiremock", +] + [[package]] name = "mio" version = "1.2.0" @@ -1518,6 +1587,19 @@ dependencies = [ "serde", ] +[[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" @@ -1800,6 +1882,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" @@ -1836,6 +1924,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" @@ -2009,12 +2108,65 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/crates/mergify-ci/Cargo.toml b/crates/mergify-ci/Cargo.toml index 12009e9b..1416c013 100644 --- a/crates/mergify-ci/Cargo.toml +++ b/crates/mergify-ci/Cargo.toml @@ -13,7 +13,9 @@ publish = false mergify-core = { path = "../mergify-core" } 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/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 ee9137a0..4802e883 100644 --- a/crates/mergify-ci/src/lib.rs +++ b/crates/mergify-ci/src/lib.rs @@ -1,10 +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. +//! Phase 1.4 landed `ci scopes-send`. Phase 1.6 adds `ci queue-info` +//! and `ci git-refs`, which share GitHub event parsing and MQ +//! metadata extraction (`github_event` + `queue_metadata` +//! modules). Remaining commands (`scopes`, `junit-process`, +//! `junit-upload`) follow once the shared infrastructure they need +//! is in place. +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-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-ci/src/scopes_send.rs b/crates/mergify-ci/src/scopes_send.rs index 6b2a8fa3..68245e71 100644 --- a/crates/mergify-ci/src/scopes_send.rs +++ b/crates/mergify-ci/src/scopes_send.rs @@ -18,9 +18,11 @@ //! returns success — matches Python's "no PR, nothing to send" //! behavior. //! -//! Auth + API URL resolution follows the same fallback order as -//! ``config simulate``: explicit flag → ``MERGIFY_TOKEN`` / -//! ``MERGIFY_API_URL`` env var → default (or error). +//! Auth + API URL resolution goes through `mergify_core::auth`, +//! which adds a `gh auth token` fallback (matches Python's +//! `utils.get_default_token`) and a `git config remote.origin.url` +//! fallback for the repository slug (matches +//! `utils.get_default_repository`). use std::env; use std::path::Path; @@ -29,11 +31,9 @@ use mergify_core::ApiFlavor; use mergify_core::CliError; use mergify_core::HttpClient; use mergify_core::Output; +use mergify_core::auth; use serde::Deserialize; use serde::Serialize; -use url::Url; - -const DEFAULT_API_URL: &str = "https://api.mergify.com"; pub struct ScopesSendOptions<'a> { pub repository: Option<&'a str>, @@ -53,9 +53,9 @@ pub async fn run(opts: ScopesSendOptions<'_>, output: &mut dyn Output) -> Result return Ok(()); }; - let repository = resolve_repository(opts.repository)?; - let token = resolve_token(opts.token)?; - let api_url = resolve_api_url(opts.api_url)?; + let repository = auth::resolve_repository(opts.repository)?; + let token = auth::resolve_token(opts.token)?; + let api_url = auth::resolve_api_url(opts.api_url)?; // Whenever the deprecated `--file` flag is supplied, surface // the deprecation warning — even when `--scopes-json` is also @@ -90,20 +90,6 @@ pub async fn run(opts: ScopesSendOptions<'_>, output: &mut dyn Output) -> Result Ok(()) } -fn resolve_repository(explicit: Option<&str>) -> Result { - if let Some(value) = explicit.filter(|s| !s.is_empty()) { - return Ok(value.to_string()); - } - env::var("GITHUB_REPOSITORY") - .ok() - .filter(|s| !s.is_empty()) - .ok_or_else(|| { - CliError::Configuration( - "--repository not provided and GITHUB_REPOSITORY env var is unset".to_string(), - ) - }) -} - fn resolve_pull_request(explicit: Option) -> Result, CliError> { if let Some(n) = explicit { return Ok(Some(n)); @@ -135,33 +121,6 @@ fn resolve_pull_request(explicit: Option) -> Result, CliError> .and_then(serde_json::Value::as_u64)) } -fn resolve_token(explicit: Option<&str>) -> Result { - if let Some(value) = explicit.filter(|s| !s.is_empty()) { - return Ok(value.to_string()); - } - for env_name in ["MERGIFY_TOKEN", "GITHUB_TOKEN"] { - if let Ok(value) = env::var(env_name) { - if !value.is_empty() { - return Ok(value); - } - } - } - Err(CliError::Configuration( - "please set the 'MERGIFY_TOKEN' or 'GITHUB_TOKEN' environment variable, \ - or pass --token explicitly" - .to_string(), - )) -} - -fn resolve_api_url(explicit: Option<&str>) -> Result { - let raw = explicit - .map(str::to_string) - .or_else(|| env::var("MERGIFY_API_URL").ok()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| DEFAULT_API_URL.to_string()); - Url::parse(&raw).map_err(|e| CliError::Configuration(format!("invalid --api-url {raw:?}: {e}"))) -} - #[derive(Deserialize)] struct DetectedScopesFile { scopes: Vec, @@ -234,27 +193,6 @@ mod tests { } } - #[test] - fn resolve_repository_prefers_flag() { - temp_env::with_var("GITHUB_REPOSITORY", Some("env/env"), || { - assert_eq!(resolve_repository(Some("cli/cli")).unwrap(), "cli/cli"); - }); - } - - #[test] - fn resolve_repository_falls_back_to_env() { - temp_env::with_var("GITHUB_REPOSITORY", Some("env/env"), || { - assert_eq!(resolve_repository(None).unwrap(), "env/env"); - }); - } - - #[test] - fn resolve_repository_errors_when_unset() { - temp_env::with_var("GITHUB_REPOSITORY", None::<&str>, || { - assert!(resolve_repository(None).is_err()); - }); - } - #[test] fn resolve_pull_request_reads_event_json() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/mergify-cli/Cargo.toml b/crates/mergify-cli/Cargo.toml index 6665503b..7d28db2c 100644 --- a/crates/mergify-cli/Cargo.toml +++ b/crates/mergify-cli/Cargo.toml @@ -19,6 +19,7 @@ mergify-ci = { path = "../mergify-ci" } mergify-config = { path = "../mergify-config" } mergify-core = { path = "../mergify-core" } mergify-py-shim = { path = "../mergify-py-shim" } +mergify-queue = { path = "../mergify-queue" } tokio = { version = "1", default-features = false, features = ["macros", "rt", "time"] } [lints] diff --git a/crates/mergify-cli/src/main.rs b/crates/mergify-cli/src/main.rs index d0958a90..604b17b3 100644 --- a/crates/mergify-cli/src/main.rs +++ b/crates/mergify-cli/src/main.rs @@ -19,11 +19,16 @@ 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; use mergify_core::OutputMode; use mergify_core::StdioOutput; +use mergify_queue::pause::PauseOptions; +use mergify_queue::status::StatusOptions; +use mergify_queue::unpause::UnpauseOptions; fn main() -> ExitCode { let argv: Vec = env::args().skip(1).collect(); @@ -47,6 +52,11 @@ enum NativeCommand { ConfigValidate { config_file: Option }, ConfigSimulate(ConfigSimulateOpts), CiScopesSend(CiScopesSendOpts), + CiGitRefs { format: GitRefsFormat }, + CiQueueInfo, + QueuePause(QueuePauseOpts), + QueueUnpause(QueueUnpauseOpts), + QueueStatus(QueueStatusOpts), } struct ConfigSimulateOpts { @@ -67,24 +77,34 @@ struct CiScopesSendOpts { file_deprecated: Option, } -/// Try to recognize the invocation as a native command. -/// -/// Returns ``None`` when the argv doesn't look like a native -/// command — callers fall back to the Python shim, which produces -/// the same error messages as before the port started. When the -/// argv obviously targets a native command (contains ``config`` -/// and ``validate``/``simulate``) but clap can't parse it — e.g. -/// the user gave a bad flag or an invalid URL — this function -/// prints clap's formatted error to stderr and exits the process -/// with clap's exit code (2), matching the Python CLI's behavior -/// for argument errors. +struct QueuePauseOpts { + repository: Option, + token: Option, + api_url: Option, + reason: String, + yes_i_am_sure: bool, +} + +struct QueueUnpauseOpts { + repository: Option, + token: Option, + api_url: Option, +} + +struct QueueStatusOpts { + repository: Option, + token: Option, + api_url: Option, + branch: Option, + output_json: bool, +} + /// Heuristic: does argv look like the user intended a native -/// subcommand (`config validate`, `config simulate`, `ci -/// scopes-send`)? +/// subcommand? /// /// Used as a fallback when clap rejects the input — if the user -/// clearly meant a native command, surface clap's error rather than -/// silently dispatching to the Python shim. We look for two +/// clearly meant a native command, surface clap's error rather +/// than silently dispatching to the Python shim. We look for two /// *consecutive* tokens forming a `(group, subcommand)` pair so a /// flag value like `--repository config` doesn't accidentally /// classify the invocation as native. @@ -92,11 +112,31 @@ fn looks_native(argv: &[String]) -> bool { argv.windows(2).any(|pair| { matches!( (pair[0].as_str(), pair[1].as_str()), - ("config", "validate" | "simulate") | ("ci", "scopes-send"), + ("config", "validate" | "simulate") + | ("ci", "scopes-send" | "git-refs" | "queue-info") + | ("queue", "pause" | "unpause" | "status"), ) }) } +/// Try to recognize the invocation as a native command. +/// +/// Returns ``None`` when the argv doesn't look like a native +/// command — callers fall back to the Python shim, which produces +/// the same error messages as before the port started. When the +/// argv obviously targets a native command (per [`looks_native`]) +/// but clap can't parse it — e.g. the user gave an unknown flag +/// or omitted a required argument — this function prints clap's +/// formatted error to stderr and exits the process with clap's +/// exit code (2), matching the Python CLI's behavior for argument +/// errors. +/// +/// Argument *values* that are accepted by clap as `String` but +/// fail later domain validation (e.g. an `--api-url` that doesn't +/// parse as a URL) surface as [`mergify_core::CliError`] instead +/// — the corresponding exit code is the one chosen by the command +/// implementation (typically [`mergify_core::ExitCode::Configuration`] +/// = 8), not 2. fn detect_native(argv: &[String]) -> Option { let looks_native = looks_native(argv); @@ -154,6 +194,50 @@ fn detect_native(argv: &[String]) -> Option { scopes_file, file_deprecated, })), + 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, + api_url, + command: + QueueSubcommand::Pause(PauseCliArgs { + reason, + yes_i_am_sure, + }), + }) => Some(NativeCommand::QueuePause(QueuePauseOpts { + repository, + token, + api_url, + reason, + yes_i_am_sure, + })), + Subcommands::Queue(QueueArgs { + repository, + token, + api_url, + command: QueueSubcommand::Unpause, + }) => Some(NativeCommand::QueueUnpause(QueueUnpauseOpts { + repository, + token, + api_url, + })), + Subcommands::Queue(QueueArgs { + repository, + token, + api_url, + command: QueueSubcommand::Status(StatusCliArgs { branch, json }), + }) => Some(NativeCommand::QueueStatus(QueueStatusOpts { + repository, + token, + api_url, + branch, + output_json: json, + })), } } @@ -204,6 +288,47 @@ fn run_native(cmd: NativeCommand) -> ExitCode { ) .await } + 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 { + repository: opts.repository.as_deref(), + token: opts.token.as_deref(), + api_url: opts.api_url.as_deref(), + reason: &opts.reason, + yes_i_am_sure: opts.yes_i_am_sure, + }, + &mut output, + ) + .await + } + NativeCommand::QueueUnpause(opts) => { + mergify_queue::unpause::run( + UnpauseOptions { + repository: opts.repository.as_deref(), + token: opts.token.as_deref(), + api_url: opts.api_url.as_deref(), + }, + &mut output, + ) + .await + } + NativeCommand::QueueStatus(opts) => { + mergify_queue::status::run( + StatusOptions { + repository: opts.repository.as_deref(), + token: opts.token.as_deref(), + api_url: opts.api_url.as_deref(), + branch: opts.branch.as_deref(), + output_json: opts.output_json, + }, + &mut output, + ) + .await + } } }); @@ -231,6 +356,8 @@ enum Subcommands { Config(ConfigArgs), /// Mergify CI-related commands. Ci(CiArgs), + /// Manage the Mergify merge queue. + Queue(QueueArgs), } #[derive(clap::Args)] @@ -284,6 +411,25 @@ 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), + /// Print the merge queue batch metadata for the current draft PR. + #[command(name = "queue-info")] + QueueInfo, +} + +#[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)] @@ -326,3 +472,57 @@ struct ScopesSendCliArgs { #[arg(long = "file", short = 'f', hide = true)] file_deprecated: Option, } + +#[derive(clap::Args)] +struct QueueArgs { + /// Mergify or GitHub token. Falls back to ``MERGIFY_TOKEN`` and + /// then ``GITHUB_TOKEN`` env vars. + #[arg(long, short = 't', global = true)] + token: Option, + + /// Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, + /// then to the default. + #[arg(long = "api-url", short = 'u', global = true)] + api_url: Option, + + /// Repository full name (owner/repo). Falls back to + /// ``GITHUB_REPOSITORY`` env var. + #[arg(long, short = 'r', global = true)] + repository: Option, + + #[command(subcommand)] + command: QueueSubcommand, +} + +#[derive(Subcommand)] +enum QueueSubcommand { + /// Pause the merge queue for the repository. + Pause(PauseCliArgs), + /// Unpause the merge queue for the repository. + Unpause, + /// Show merge queue status for the repository. + Status(StatusCliArgs), +} + +#[derive(clap::Args)] +struct PauseCliArgs { + /// Reason for pausing the queue (max 255 characters). + #[arg(long, value_parser = mergify_queue::pause::parse_reason)] + reason: String, + + /// Skip the confirmation prompt. Required in non-interactive + /// sessions. + #[arg(long = "yes-i-am-sure", default_value_t = false)] + yes_i_am_sure: bool, +} + +#[derive(clap::Args)] +struct StatusCliArgs { + /// Filter the queue by branch name. + #[arg(long, short = 'b')] + branch: Option, + + /// Emit the raw API response as a single JSON document. + #[arg(long, default_value_t = false)] + json: bool, +} diff --git a/crates/mergify-config/src/simulate.rs b/crates/mergify-config/src/simulate.rs index 3ea8d14d..511d7d81 100644 --- a/crates/mergify-config/src/simulate.rs +++ b/crates/mergify-config/src/simulate.rs @@ -12,13 +12,10 @@ //! 5. Prints the simulator's title + summary. //! //! Token / api-url / config-file all follow the same resolution -//! order as the Python CLI: explicit flag, then env var, then -//! default. Missing token falls back from ``MERGIFY_TOKEN`` to -//! ``GITHUB_TOKEN``. The ``gh auth token`` subprocess fallback from -//! Python isn't ported yet — if neither env var is set the command -//! errors out. +//! order as the Python CLI (`mergify_core::auth`): explicit flag, +//! then env var, then `gh auth token` for the bearer, then the +//! default API URL. -use std::env; use std::io::Write; use std::path::Path; @@ -26,14 +23,12 @@ use mergify_core::ApiFlavor; use mergify_core::CliError; use mergify_core::HttpClient; use mergify_core::Output; +use mergify_core::auth; use serde::Deserialize; use serde::Serialize; -use url::Url; use crate::paths::resolve_config_path; -const DEFAULT_API_URL: &str = "https://api.mergify.com"; - /// Deserialized shape of the `(owner/repo, number)` pair parsed from /// a pull-request URL. #[derive(Clone, Debug, Eq, PartialEq)] @@ -75,39 +70,6 @@ pub fn parse_pr_url(url: &str) -> Result { }) } -/// Resolve the Mergify API bearer token. -/// -/// Precedence: explicit `--token`, then `MERGIFY_TOKEN`, then -/// `GITHUB_TOKEN`. Errors out when none of those are set. -fn resolve_token(explicit: Option<&str>) -> Result { - if let Some(token) = explicit.filter(|t| !t.is_empty()) { - return Ok(token.to_string()); - } - for env_name in ["MERGIFY_TOKEN", "GITHUB_TOKEN"] { - if let Ok(value) = env::var(env_name) { - if !value.is_empty() { - return Ok(value); - } - } - } - Err(CliError::Configuration( - "please set the 'MERGIFY_TOKEN' or 'GITHUB_TOKEN' environment variable, \ - or pass --token explicitly" - .to_string(), - )) -} - -/// Resolve the Mergify API base URL. Falls back to `MERGIFY_API_URL` -/// env var, then to the default `https://api.mergify.com`. -fn resolve_api_url(explicit: Option<&str>) -> Result { - let raw = explicit - .map(str::to_string) - .or_else(|| env::var("MERGIFY_API_URL").ok()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| DEFAULT_API_URL.to_string()); - Url::parse(&raw).map_err(|e| CliError::Configuration(format!("invalid --api-url {raw:?}: {e}"))) -} - #[derive(Serialize)] struct SimulatorRequest<'a> { mergify_yml: &'a str, @@ -133,8 +95,8 @@ pub async fn run(opts: SimulateOptions<'_>, output: &mut dyn Output) -> Result<( CliError::Configuration(format!("cannot read {}: {e}", config_path.display())) })?; - let token = resolve_token(opts.token)?; - let api_url = resolve_api_url(opts.api_url)?; + let token = auth::resolve_token(opts.token)?; + let api_url = auth::resolve_api_url(opts.api_url)?; output.status(&format!("Simulating against {api_url}…"))?; @@ -218,80 +180,6 @@ mod tests { assert!(parse_pr_url("https://github.com//repo/pull/42").is_err()); } - #[test] - fn resolve_token_prefers_explicit_over_env() { - temp_env::with_vars( - [ - ("MERGIFY_TOKEN", Some("from-env")), - ("GITHUB_TOKEN", Some("github-env")), - ], - || { - assert_eq!(resolve_token(Some("from-cli")).unwrap(), "from-cli"); - }, - ); - } - - #[test] - fn resolve_token_falls_back_to_mergify_env() { - temp_env::with_vars( - [ - ("MERGIFY_TOKEN", Some("mergify-env")), - ("GITHUB_TOKEN", Some("github-env")), - ], - || { - assert_eq!(resolve_token(None).unwrap(), "mergify-env"); - }, - ); - } - - #[test] - fn resolve_token_falls_back_to_github_env() { - temp_env::with_vars( - [ - ("MERGIFY_TOKEN", None::<&str>), - ("GITHUB_TOKEN", Some("github-env")), - ], - || { - assert_eq!(resolve_token(None).unwrap(), "github-env"); - }, - ); - } - - #[test] - fn resolve_token_errors_when_no_source_available() { - temp_env::with_vars( - [ - ("MERGIFY_TOKEN", None::<&str>), - ("GITHUB_TOKEN", None::<&str>), - ], - || { - let err = resolve_token(None).unwrap_err(); - assert!(matches!(err, CliError::Configuration(_))); - assert!(err.to_string().contains("MERGIFY_TOKEN")); - }, - ); - } - - #[test] - fn resolve_api_url_uses_default_when_unset() { - temp_env::with_vars([("MERGIFY_API_URL", None::<&str>)], || { - assert_eq!( - resolve_api_url(None).unwrap().as_str(), - "https://api.mergify.com/", - ); - }); - } - - #[test] - fn resolve_api_url_prefers_explicit() { - temp_env::with_vars([("MERGIFY_API_URL", Some("https://from.env"))], || { - assert_eq!( - resolve_api_url(Some("https://from.cli")).unwrap().as_str(), - "https://from.cli/", - ); - }); - } - #[tokio::test] async fn run_posts_config_and_prints_simulator_result() { let server = MockServer::start().await; diff --git a/crates/mergify-core/Cargo.toml b/crates/mergify-core/Cargo.toml index b57416eb..be6914cc 100644 --- a/crates/mergify-core/Cargo.toml +++ b/crates/mergify-core/Cargo.toml @@ -18,6 +18,7 @@ tokio = { version = "1", default-features = false, features = ["macros", "rt", " url = "2" [dev-dependencies] +temp-env = "0.3" tokio = { version = "1", default-features = false, features = ["macros", "rt", "rt-multi-thread", "time"] } wiremock = "0.6" diff --git a/crates/mergify-core/src/auth.rs b/crates/mergify-core/src/auth.rs new file mode 100644 index 00000000..1b822a43 --- /dev/null +++ b/crates/mergify-core/src/auth.rs @@ -0,0 +1,327 @@ +//! Resolve `--token`, `--api-url`, and `--repository` with the +//! same fallback order the Python CLI used. +//! +//! Token: `--token` flag → `MERGIFY_TOKEN` env → `GITHUB_TOKEN` +//! env → `gh auth token` (the GitHub CLI). Mirrors Python's +//! `utils.get_default_token`. +//! +//! Repository: `--repository` flag → `GITHUB_REPOSITORY` env → +//! `git config --get remote.origin.url` parsed into `/`. +//! Mirrors Python's `utils.get_default_repository` + `utils.get_slug`. +//! +//! API URL: `--api-url` flag → `MERGIFY_API_URL` env → default +//! `https://api.mergify.com`. +//! +//! Each ported command resolves these once before doing any +//! network or interactive work. The Rust copies that previously +//! lived in `mergify-config::simulate`, `mergify-ci::scopes_send`, +//! and `mergify-queue::auth` were missing the `gh auth token` and +//! `git config` fallbacks — that's why this module exists. + +use std::env; +use std::process::Command; + +use url::Url; + +use crate::CliError; + +const DEFAULT_API_URL: &str = "https://api.mergify.com"; + +/// Resolve the Mergify API bearer token. +/// +/// Precedence: explicit `--token`, then `MERGIFY_TOKEN`, then +/// `GITHUB_TOKEN`, then the output of `gh auth token`. Errors when +/// none of those produce a non-empty value. +pub fn resolve_token(explicit: Option<&str>) -> Result { + if let Some(value) = explicit.filter(|s| !s.is_empty()) { + return Ok(value.to_string()); + } + for env_name in ["MERGIFY_TOKEN", "GITHUB_TOKEN"] { + if let Ok(value) = env::var(env_name) { + if !value.is_empty() { + return Ok(value); + } + } + } + if let Ok(token) = gh_auth_token() { + if !token.is_empty() { + return Ok(token); + } + } + Err(CliError::Configuration( + "please set the 'MERGIFY_TOKEN' or 'GITHUB_TOKEN' environment variable, \ + or make sure that the gh client is installed and you are authenticated" + .to_string(), + )) +} + +/// Resolve the Mergify API base URL. Falls back to the +/// `MERGIFY_API_URL` env var, then the default +/// `https://api.mergify.com`. +pub fn resolve_api_url(explicit: Option<&str>) -> Result { + let raw = explicit + .map(str::to_string) + .or_else(|| env::var("MERGIFY_API_URL").ok()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| DEFAULT_API_URL.to_string()); + Url::parse(&raw).map_err(|e| CliError::Configuration(format!("invalid --api-url {raw:?}: {e}"))) +} + +/// Resolve the repository (`/`). +/// +/// Precedence: explicit `--repository`, then `GITHUB_REPOSITORY` +/// env, then `git config --get remote.origin.url` parsed via +/// [`parse_slug`]. Errors when none of those yield a slug. +pub fn resolve_repository(explicit: Option<&str>) -> Result { + if let Some(value) = explicit.filter(|s| !s.is_empty()) { + return Ok(value.to_string()); + } + if let Ok(value) = env::var("GITHUB_REPOSITORY") { + if !value.is_empty() { + return Ok(value); + } + } + if let Some(remote) = git_remote_origin_url() { + if let Some(slug) = parse_slug(&remote) { + return Ok(slug); + } + } + Err(CliError::Configuration( + "--repository not provided, GITHUB_REPOSITORY env var is unset, and \ + the local git config has no usable `remote.origin.url`" + .to_string(), + )) +} + +/// Run `gh auth token` and return stdout (trimmed). Returns an +/// `Err` when `gh` is missing or the command fails, which the +/// caller treats as "no token from gh". +fn gh_auth_token() -> Result { + let output = Command::new("gh").args(["auth", "token"]).output()?; + if !output.status.success() { + return Err(std::io::Error::other("`gh auth token` exited non-zero")); + } + let token = String::from_utf8(output.stdout) + .map_err(|e| std::io::Error::other(format!("`gh auth token` non-UTF-8 output: {e}")))? + .trim() + .to_string(); + Ok(token) +} + +/// Run `git config --get remote.origin.url` in the current +/// directory and return stdout (trimmed). Returns `None` when git +/// isn't available, the working tree isn't a git repo, or the +/// remote isn't configured. +fn git_remote_origin_url() -> Option { + let output = Command::new("git") + .args(["config", "--get", "remote.origin.url"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let value = String::from_utf8(output.stdout).ok()?.trim().to_string(); + (!value.is_empty()).then_some(value) +} + +/// Parse a git remote URL into `/`. +/// +/// Handles both HTTPS (`https://github.com/owner/repo.git`) and +/// SSH (`git@github.com:owner/repo.git`) shapes; `.git` suffix and +/// trailing slashes are stripped. Returns `None` when the URL +/// doesn't decompose into at least two path segments. +fn parse_slug(url: &str) -> Option { + let url = url.trim(); + + // SSH form: `git@host:owner/repo[.git]` — no scheme, the + // delimiter between user@host and path is `:`. We detect this + // by checking for `@…:` before the first `/`. + let path = if let Some(scheme_end) = url.find("://") { + let after_scheme = &url[scheme_end + 3..]; + after_scheme.split_once('/')?.1.to_string() + } else if let Some(colon) = url.find(':') { + url[colon + 1..].to_string() + } else { + return None; + }; + + let path = path.trim_end_matches('/').trim_start_matches('/'); + let (owner, rest) = path.split_once('/')?; + let repo = rest + .trim_end_matches('/') + .strip_suffix(".git") + .unwrap_or(rest); + let repo = repo.trim_end_matches('/'); + if owner.is_empty() || repo.is_empty() { + return None; + } + Some(format!("{owner}/{repo}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_token_prefers_explicit_over_env() { + temp_env::with_vars( + [ + ("MERGIFY_TOKEN", Some("env-mergify")), + ("GITHUB_TOKEN", Some("env-github")), + ], + || { + assert_eq!( + resolve_token(Some("explicit-token")).unwrap(), + "explicit-token", + ); + }, + ); + } + + #[test] + fn resolve_token_falls_back_to_mergify_env() { + temp_env::with_vars( + [ + ("MERGIFY_TOKEN", Some("env-mergify")), + ("GITHUB_TOKEN", Some("env-github")), + ], + || { + assert_eq!(resolve_token(None).unwrap(), "env-mergify"); + }, + ); + } + + #[test] + fn resolve_token_falls_back_to_github_env_when_mergify_unset() { + temp_env::with_vars( + [ + ("MERGIFY_TOKEN", None), + ("GITHUB_TOKEN", Some("env-github")), + ], + || { + assert_eq!(resolve_token(None).unwrap(), "env-github"); + }, + ); + } + + #[test] + fn resolve_token_error_message_mentions_gh() { + // When env vars are unset and `gh auth token` is unavailable + // (or fails), the user-facing error must mention the gh + // fallback so the user knows there's a third option. + // Forcing PATH to a directory with no `gh` keeps the test + // hermetic on machines that do have the GitHub CLI installed. + temp_env::with_vars( + [ + ("MERGIFY_TOKEN", None), + ("GITHUB_TOKEN", None), + ("PATH", Some("/nonexistent-directory-for-test")), + ], + || { + let err = resolve_token(None).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("MERGIFY_TOKEN"), "got {msg:?}"); + assert!(msg.contains("gh client"), "got {msg:?}"); + }, + ); + } + + #[test] + fn resolve_api_url_default() { + temp_env::with_var("MERGIFY_API_URL", None::<&str>, || { + let url = resolve_api_url(None).unwrap(); + assert_eq!(url.as_str(), "https://api.mergify.com/"); + }); + } + + #[test] + fn resolve_api_url_prefers_explicit() { + temp_env::with_var("MERGIFY_API_URL", Some("https://from-env.example/"), || { + let url = resolve_api_url(Some("https://explicit.example/")).unwrap(); + assert_eq!(url.as_str(), "https://explicit.example/"); + }); + } + + #[test] + fn resolve_api_url_uses_env_var_when_explicit_empty() { + temp_env::with_var("MERGIFY_API_URL", Some("https://from-env.example/"), || { + let url = resolve_api_url(None).unwrap(); + assert_eq!(url.as_str(), "https://from-env.example/"); + }); + } + + #[test] + fn resolve_api_url_rejects_garbage() { + temp_env::with_var("MERGIFY_API_URL", None::<&str>, || { + let err = resolve_api_url(Some("not a url")).unwrap_err(); + assert!(err.to_string().contains("invalid --api-url")); + }); + } + + #[test] + fn resolve_repository_prefers_explicit() { + temp_env::with_var("GITHUB_REPOSITORY", Some("owner-from-env/repo"), || { + assert_eq!( + resolve_repository(Some("explicit/repo")).unwrap(), + "explicit/repo", + ); + }); + } + + #[test] + fn resolve_repository_falls_back_to_env() { + temp_env::with_var("GITHUB_REPOSITORY", Some("owner/repo"), || { + assert_eq!(resolve_repository(None).unwrap(), "owner/repo"); + }); + } + + #[test] + fn parse_slug_https_with_dot_git() { + assert_eq!( + parse_slug("https://github.com/owner/repo.git").as_deref(), + Some("owner/repo"), + ); + } + + #[test] + fn parse_slug_https_without_dot_git() { + assert_eq!( + parse_slug("https://github.com/owner/repo").as_deref(), + Some("owner/repo"), + ); + } + + #[test] + fn parse_slug_https_with_trailing_slash() { + assert_eq!( + parse_slug("https://github.com/owner/repo/").as_deref(), + Some("owner/repo"), + ); + } + + #[test] + fn parse_slug_ssh_form() { + assert_eq!( + parse_slug("git@github.com:owner/repo.git").as_deref(), + Some("owner/repo"), + ); + } + + #[test] + fn parse_slug_ssh_without_dot_git() { + assert_eq!( + parse_slug("git@github.com:owner/repo").as_deref(), + Some("owner/repo"), + ); + } + + #[test] + fn parse_slug_rejects_empty_owner() { + assert!(parse_slug("https://github.com//repo.git").is_none()); + } + + #[test] + fn parse_slug_rejects_path_without_repo() { + assert!(parse_slug("https://github.com/owner").is_none()); + } +} diff --git a/crates/mergify-core/src/http.rs b/crates/mergify-core/src/http.rs index ec657ff5..15701557 100644 --- a/crates/mergify-core/src/http.rs +++ b/crates/mergify-core/src/http.rs @@ -42,6 +42,15 @@ pub enum ApiFlavor { Mergify, } +/// Outcome of [`Client::delete_if_exists`]. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum DeleteOutcome { + /// 2xx: the resource was deleted. + Deleted, + /// 404: the resource didn't exist (or was already gone). + NotFound, +} + /// Retry policy for transient failures. Only 5xx responses and /// connect/timeout errors are retried; 4xx responses are never /// retried — those are caller errors and retrying would hide bugs. @@ -149,6 +158,30 @@ impl Client { .map(drop) } + /// PUT `body` as JSON to `path` and deserialize the JSON + /// response as `T`. + pub async fn put( + &self, + path: &str, + body: &B, + ) -> Result { + let url = self.join(path)?; + let resp = self.execute_request(self.inner.put(url).json(body)).await?; + self.decode_json(resp).await + } + + /// DELETE `path`, returning whether the resource existed. + /// + /// Returns `Ok(DeleteOutcome::Deleted)` on 2xx responses and + /// `Ok(DeleteOutcome::NotFound)` on 404 — useful for idempotent + /// "turn this thing off if it's on" operations where 404 means + /// "nothing to do". 4xx-other and 5xx map to the normal API + /// errors. + pub async fn delete_if_exists(&self, path: &str) -> Result { + let url = self.join(path)?; + self.execute_status(self.inner.delete(url)).await + } + fn join(&self, path: &str) -> Result { // `Url::join` accepts absolute URLs and protocol-relative // paths (`//host/...`), which would let a caller-supplied @@ -164,6 +197,58 @@ impl Client { .map_err(|e| self.api_error(format!("invalid path {path:?}: {e}"))) } + /// Execute a request that cares only about the HTTP status. + /// + /// Used by [`Self::delete_if_exists`] — the response body (if + /// any) is discarded. + async fn execute_status( + &self, + builder: reqwest::RequestBuilder, + ) -> Result { + let mut backoff = self.retry.initial_backoff; + let mut last_message = String::from("HTTP request failed without response"); + + for attempt in 0..self.retry.max_attempts { + let Some(cloned) = builder.try_clone() else { + return Err(self.api_error( + "request body is not cloneable (streaming?) — cannot retry".into(), + )); + }; + let req = match &self.token { + Some(token) => cloned.bearer_auth(token), + None => cloned, + }; + + match req.send().await { + Ok(resp) => { + let status = resp.status(); + if status.is_success() { + return Ok(DeleteOutcome::Deleted); + } + if status == StatusCode::NOT_FOUND { + return Ok(DeleteOutcome::NotFound); + } + last_message = error_message(status, resp).await; + if status.is_server_error() && attempt + 1 < self.retry.max_attempts { + tokio::time::sleep(backoff).await; + backoff *= 2; + continue; + } + return Err(self.api_error(last_message)); + } + Err(e) if is_transient(&e) && attempt + 1 < self.retry.max_attempts => { + last_message = format!("network error: {e}"); + tokio::time::sleep(backoff).await; + backoff *= 2; + } + Err(e) => { + return Err(self.api_error(format!("request failed: {e}"))); + } + } + } + Err(self.api_error(last_message)) + } + async fn execute_request( &self, builder: reqwest::RequestBuilder, diff --git a/crates/mergify-core/src/lib.rs b/crates/mergify-core/src/lib.rs index 9c27f27a..8b731465 100644 --- a/crates/mergify-core/src/lib.rs +++ b/crates/mergify-core/src/lib.rs @@ -15,6 +15,7 @@ //! Git operations, interactive prompts, and config loading arrive //! in subsequent sub-phases. +pub mod auth; pub mod error; pub mod exit_code; pub mod http; @@ -22,7 +23,7 @@ pub mod output; pub use error::CliError; pub use exit_code::ExitCode; -pub use http::{ApiFlavor, Client as HttpClient, RetryPolicy}; +pub use http::{ApiFlavor, Client as HttpClient, DeleteOutcome, RetryPolicy}; pub use output::{Output, OutputMode, StdioOutput}; /// Compile-time version string taken from the crate package metadata diff --git a/crates/mergify-queue/Cargo.toml b/crates/mergify-queue/Cargo.toml new file mode 100644 index 00000000..0f95035c --- /dev/null +++ b/crates/mergify-queue/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "mergify-queue" +version = "0.0.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +description = "Native implementation of `mergify queue` subcommands." +publish = false + +[dependencies] +mergify-core = { path = "../mergify-core" } +chrono = { version = "0.4", default-features = false, features = ["clock"] } +indexmap = "2" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +url = "2" + +[dev-dependencies] +temp-env = { version = "0.3", features = ["async_closure"] } +tokio = { version = "1", default-features = false, features = ["macros", "rt", "time"] } +wiremock = "0.6" + +[lints] +workspace = true diff --git a/crates/mergify-queue/src/lib.rs b/crates/mergify-queue/src/lib.rs new file mode 100644 index 00000000..96455cb4 --- /dev/null +++ b/crates/mergify-queue/src/lib.rs @@ -0,0 +1,14 @@ +//! Native Rust implementation of the `mergify queue` subcommands. +//! +//! Phase 1.5 ported `pause` and `unpause` — two idempotent API +//! calls that rest on the HTTP client added in 1.2b and the +//! `put`/`delete_if_exists` methods added alongside this crate. +//! Phase 1.7 ports `status`, the read-only command that fetches +//! the merge-queue snapshot and renders it either as a JSON +//! passthrough or as the human-friendly batch tree + waiting list. +//! `queue show` stays shimmed until its conditions/checks tree +//! ports next. + +pub mod pause; +pub mod status; +pub mod unpause; diff --git a/crates/mergify-queue/src/pause.rs b/crates/mergify-queue/src/pause.rs new file mode 100644 index 00000000..7075549e --- /dev/null +++ b/crates/mergify-queue/src/pause.rs @@ -0,0 +1,275 @@ +//! `mergify queue pause` — pause the merge queue for a repository. +//! +//! PUTs ``{"reason": "..."}`` to +//! ``/v1/repos//merge-queue/pause``. Prints a "Queue paused" +//! confirmation with the reason (and the raw pause timestamp if +//! the API returned one). +//! +//! Confirmation flow: +//! +//! - ``--yes-i-am-sure`` skips the prompt. +//! - Interactive (TTY): asks "Proceed? [y/N]"; anything other than +//! "y"/"yes" aborts with a generic error. +//! - Non-interactive (no TTY, no ``--yes-i-am-sure``): refuses with +//! an ``INVALID_STATE`` error matching Python's behavior. + +use std::io::IsTerminal; +use std::io::Write; + +use mergify_core::ApiFlavor; +use mergify_core::CliError; +use mergify_core::HttpClient; +use mergify_core::Output; +use serde::Deserialize; +use serde::Serialize; + +use mergify_core::auth; + +const MAX_REASON_LEN: usize = 255; + +pub struct PauseOptions<'a> { + pub repository: Option<&'a str>, + pub token: Option<&'a str>, + pub api_url: Option<&'a str>, + pub reason: &'a str, + pub yes_i_am_sure: bool, +} + +/// Clap value-parser for the positional `--reason` flag. +/// +/// # Errors +/// +/// Returns a message when `value` exceeds 255 user-visible +/// characters. We count via [`str::chars`] (Unicode scalar values) +/// rather than [`str::len`] (UTF-8 bytes), so non-ASCII reasons +/// such as `"déploiement"` aren't rejected for being below 255 +/// chars but above 255 bytes. +pub fn parse_reason(value: &str) -> Result { + if value.chars().count() > MAX_REASON_LEN { + Err("must be 255 characters or fewer".to_string()) + } else { + Ok(value.to_string()) + } +} + +#[derive(Serialize)] +struct PauseRequest<'a> { + reason: &'a str, +} + +#[derive(Deserialize)] +struct PauseResponse { + // Both fields are optional defensively: the API has historically + // tolerated `reason: null` (the deleted Python `QueuePauseResponse` + // typed it as `str | None`), so the Rust port matches that + // shape rather than aborting deserialization on a missing or + // null value. + #[serde(default)] + reason: Option, + #[serde(default)] + paused_at: Option, +} + +/// Run the `queue pause` command. +pub async fn run(opts: PauseOptions<'_>, output: &mut dyn Output) -> Result<(), CliError> { + // Resolve auth/repo first so the prompt names the *actual* repo + // (including the `GITHUB_REPOSITORY` fallback) and so a missing + // repo or token fails loudly *before* we ask for confirmation. + let repository = auth::resolve_repository(opts.repository)?; + let token = auth::resolve_token(opts.token)?; + let api_url = auth::resolve_api_url(opts.api_url)?; + + confirm(opts.yes_i_am_sure, &repository)?; + + output.status(&format!("Pausing merge queue for {repository}…"))?; + + let client = HttpClient::new(api_url, token, ApiFlavor::Mergify)?; + let path = format!("/v1/repos/{repository}/merge-queue/pause"); + let resp: PauseResponse = client + .put( + &path, + &PauseRequest { + reason: opts.reason, + }, + ) + .await?; + + emit_confirmation(output, &resp)?; + Ok(()) +} + +fn confirm(skip: bool, repository: &str) -> Result<(), CliError> { + if skip { + return Ok(()); + } + if !std::io::stdin().is_terminal() { + return Err(CliError::InvalidState( + "refusing to pause without confirmation. Pass --yes-i-am-sure to proceed.".to_string(), + )); + } + // Prompt goes to stderr (matches click's `confirm`/`prompt` + // behavior) so users can pipe stdout cleanly without the + // prompt text mixed in. + let mut err = std::io::stderr().lock(); + write!( + err, + "You are about to pause the merge queue for {repository}. Proceed? [y/N]: ", + ) + .map_err(CliError::from)?; + err.flush().map_err(CliError::from)?; + drop(err); + + let mut line = String::new(); + std::io::stdin() + .read_line(&mut line) + .map_err(CliError::from)?; + match line.trim().to_ascii_lowercase().as_str() { + "y" | "yes" => Ok(()), + _ => Err(CliError::Generic("aborted by user".to_string())), + } +} + +fn emit_confirmation(output: &mut dyn Output, response: &PauseResponse) -> std::io::Result<()> { + let reason = response.reason.clone(); + let paused_at = response.paused_at.clone(); + output.emit(&(), &mut |w: &mut dyn Write| { + match &reason { + Some(r) => write!(w, "Queue paused: \"{r}\"")?, + None => write!(w, "Queue paused")?, + } + if let Some(ts) = &paused_at { + write!(w, " (since {ts})")?; + } + writeln!(w) + }) +} + +#[cfg(test)] +mod tests { + use mergify_core::OutputMode; + use mergify_core::StdioOutput; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::body_json; + use wiremock::matchers::header; + use wiremock::matchers::method; + use wiremock::matchers::path; + + 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 } + } + + #[test] + fn parse_reason_accepts_short() { + assert_eq!( + parse_reason("deploying hotfix").unwrap(), + "deploying hotfix" + ); + } + + #[test] + fn parse_reason_rejects_over_255() { + let long = "a".repeat(256); + assert!(parse_reason(&long).is_err()); + } + + #[test] + fn parse_reason_counts_chars_not_bytes() { + // 200 user-visible characters but well above 255 *bytes* + // because each `é` is a 2-byte UTF-8 sequence — we keep it. + let multibyte = "é".repeat(200); + assert!(multibyte.len() > MAX_REASON_LEN); + assert!(multibyte.chars().count() <= MAX_REASON_LEN); + assert!(parse_reason(&multibyte).is_ok()); + } + + #[test] + fn confirm_refuses_without_yes_when_non_tty() { + // Inside `cargo test` stdin is not a TTY — that's the + // exact branch we want to verify. With `skip = false` the + // function must short-circuit to `InvalidState` instead of + // hanging on `read_line`. + let err = confirm(false, "owner/repo").unwrap_err(); + assert!( + matches!(err, CliError::InvalidState(_)), + "expected InvalidState, got {err:?}" + ); + assert_eq!(err.exit_code(), mergify_core::ExitCode::InvalidState); + assert!( + err.to_string().contains("--yes-i-am-sure"), + "message should mention the override flag, got: {err}" + ); + } + + #[test] + fn confirm_skips_when_yes_i_am_sure_is_set() { + // `skip = true` must bypass even the TTY check — it's the + // contract of `--yes-i-am-sure`, including in CI where + // stdin isn't a terminal. + confirm(true, "owner/repo").unwrap(); + } + + #[tokio::test] + async fn run_pauses_and_prints_confirmation() { + let server = MockServer::start().await; + Mock::given(method("PUT")) + .and(path("/v1/repos/owner/repo/merge-queue/pause")) + .and(header("Authorization", "Bearer t")) + .and(body_json(serde_json::json!({"reason": "deploy freeze"}))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "reason": "deploy freeze", + "paused_at": "2026-04-23T12:34:56Z", + }))) + .expect(1) + .mount(&server) + .await; + + let mut cap = make_output(); + let api_url = server.uri(); + run( + PauseOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + reason: "deploy freeze", + yes_i_am_sure: true, + }, + &mut cap.output, + ) + .await + .unwrap(); + + let stdout = String::from_utf8(cap.stdout.lock().unwrap().clone()).unwrap(); + assert!(stdout.contains("Queue paused"), "got: {stdout:?}"); + assert!(stdout.contains("deploy freeze"), "got: {stdout:?}"); + assert!(stdout.contains("2026-04-23"), "got: {stdout:?}"); + } + + struct SharedWriter(SharedBytes); + impl 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-queue/src/status.rs b/crates/mergify-queue/src/status.rs new file mode 100644 index 00000000..9ce06ce5 --- /dev/null +++ b/crates/mergify-queue/src/status.rs @@ -0,0 +1,906 @@ +//! `mergify queue status` — show merge queue status for a repository. +//! +//! `GET /v1/repos//merge-queue/status[?branch=]`. Two +//! output modes: +//! +//! - `--json`: pretty-prints the raw API response as a single JSON +//! document. The schema is Mergify's API contract, not this CLI's, +//! so unknown fields are preserved (deserialize to +//! `serde_json::Value`, emit verbatim). +//! - Human (default): a header, an optional pause indicator, the +//! batch tree (grouped by scope when there is more than one), and +//! the waiting-PR list. Status icons and relative times match the +//! Python implementation. +//! +//! The command does not assume the response shape beyond the fields +//! it actively renders: every nested struct uses +//! `#[serde(default)] Option<…>` for fields the API has historically +//! treated as optional/nullable, so a missing field doesn't abort +//! deserialization. +//! +//! Exit codes: +//! +//! - `0` on a successful render (queue empty, paused, or active). +//! - Standard `CliError` exit codes on auth, API, or +//! parse/serialization errors. + +use std::collections::HashMap; +use std::collections::HashSet; +use std::fmt::Write as _; +use std::io::Write; + +use chrono::DateTime; +use chrono::Utc; +use indexmap::IndexMap; +use mergify_core::ApiFlavor; +use mergify_core::CliError; +use mergify_core::HttpClient; +use mergify_core::Output; +use mergify_core::auth; +use serde::Deserialize; +use url::form_urlencoded; + +pub struct StatusOptions<'a> { + pub repository: Option<&'a str>, + pub token: Option<&'a str>, + pub api_url: Option<&'a str>, + pub branch: Option<&'a str>, + pub output_json: bool, +} + +// All view structs use `#[serde(default)] Option<…>` for fields the +// API has historically treated as optional/nullable. The wire format +// is Mergify's API contract — we deserialize only the fields we +// render and accept everything else implicitly via the +// `serde_json::Value` passthrough used in JSON mode. +#[derive(Deserialize)] +struct StatusView { + #[serde(default)] + pause: Option, + #[serde(default)] + batches: Vec, + #[serde(default)] + waiting_pull_requests: Vec, +} + +#[derive(Deserialize)] +struct Pause { + #[serde(default)] + reason: Option, + #[serde(default)] + paused_at: Option, +} + +#[derive(Deserialize)] +struct Batch { + id: String, + #[serde(default)] + parent_ids: Vec, + #[serde(default)] + scopes: Vec, + status: BatchStatus, + #[serde(default)] + started_at: Option, + #[serde(default)] + estimated_merge_at: Option, + checks_summary: ChecksSummary, + #[serde(default)] + pull_requests: Vec, +} + +#[derive(Deserialize)] +struct BatchStatus { + code: String, +} + +#[derive(Deserialize)] +struct ChecksSummary { + #[serde(default)] + passed: u64, + #[serde(default)] + total: u64, +} + +#[derive(Deserialize)] +struct PullRequest { + number: u64, + title: String, + author: Author, + #[serde(default)] + queued_at: Option, + #[serde(default)] + priority_alias: Option, + #[serde(default)] + estimated_merge_at: Option, +} + +#[derive(Deserialize)] +struct Author { + login: String, +} + +/// Run the `queue status` command. +pub async fn run(opts: StatusOptions<'_>, output: &mut dyn Output) -> Result<(), CliError> { + let repository = auth::resolve_repository(opts.repository)?; + let token = auth::resolve_token(opts.token)?; + let api_url = auth::resolve_api_url(opts.api_url)?; + + output.status(&format!("Fetching merge queue status for {repository}…"))?; + + let client = HttpClient::new(api_url, token, ApiFlavor::Mergify)?; + let path = build_path(&repository, opts.branch); + + let raw: serde_json::Value = client.get(&path).await?; + + if opts.output_json { + emit_json(output, &raw)?; + } else { + let view: StatusView = serde_json::from_value(raw) + .map_err(|e| CliError::Generic(format!("decode merge queue status response: {e}")))?; + emit_human(output, &repository, &view)?; + } + Ok(()) +} + +fn build_path(repository: &str, branch: Option<&str>) -> String { + let mut path = format!("/v1/repos/{repository}/merge-queue/status"); + if let Some(branch) = branch { + // form_urlencoded::byte_serialize handles spaces, unicode and + // reserved characters. Unencoded slashes are tolerated by + // most servers but encoding is the safe contract. + let encoded: String = form_urlencoded::byte_serialize(branch.as_bytes()).collect(); + path.push_str("?branch="); + path.push_str(&encoded); + } + path +} + +fn emit_json(output: &mut dyn Output, value: &serde_json::Value) -> std::io::Result<()> { + output.emit(value, &mut |w: &mut dyn Write| { + let rendered = serde_json::to_string_pretty(value) + .map_err(|e| std::io::Error::other(e.to_string()))?; + writeln!(w, "{rendered}") + }) +} + +fn emit_human(output: &mut dyn Output, repository: &str, view: &StatusView) -> std::io::Result<()> { + let now = Utc::now(); + output.emit(&(), &mut |w: &mut dyn Write| { + writeln!(w, "Merge Queue: {repository}")?; + writeln!(w)?; + + if let Some(pause) = &view.pause { + print_pause(w, pause, now)?; + writeln!(w)?; + } + + if view.batches.is_empty() && view.waiting_pull_requests.is_empty() { + writeln!(w, "Queue is empty")?; + return Ok(()); + } + + if !view.batches.is_empty() { + print_batches(w, &view.batches, now)?; + } + + if !view.waiting_pull_requests.is_empty() { + if !view.batches.is_empty() { + writeln!(w)?; + } + print_waiting_prs(w, &view.waiting_pull_requests, now)?; + } + Ok(()) + }) +} + +fn print_pause(w: &mut dyn Write, pause: &Pause, now: DateTime) -> std::io::Result<()> { + let reason = pause.reason.as_deref().unwrap_or(""); + let mut line = format!("⚠ Queue is paused: \"{reason}\""); + if let Some(ts) = &pause.paused_at { + let rel = relative_time(ts, now, false); + if !rel.is_empty() { + // `write!` on `String` is infallible — the only error + // path comes from the underlying writer, which `String` + // doesn't have. Discarding the `Result` is the standard + // idiom and what clippy's `format_push_string` wants. + let _ = write!(line, " (since {rel})"); + } + } + writeln!(w, "{line}") +} + +fn print_batches(w: &mut dyn Write, batches: &[Batch], now: DateTime) -> std::io::Result<()> { + let sorted = topological_sort(batches); + let groups = group_by_scope(&sorted); + let single_scope = groups.len() == 1; + + for (i, (scope, scope_batches)) in groups.iter().enumerate() { + if i > 0 { + writeln!(w)?; + } + let label = if single_scope { + "Batches" + } else { + scope.as_str() + }; + writeln!(w, "{label}")?; + for batch in scope_batches { + print_batch(w, batch, now)?; + } + } + Ok(()) +} + +fn print_batch(w: &mut dyn Write, batch: &Batch, now: DateTime) -> std::io::Result<()> { + let icon = status_icon(&batch.status.code); + let mut header = format!(" {icon} {}", batch.status.code); + if batch.checks_summary.total > 0 { + let _ = write!( + header, + " checks {}/{}", + batch.checks_summary.passed, batch.checks_summary.total, + ); + } + if let Some(started) = &batch.started_at { + let rel = relative_time(started, now, false); + if !rel.is_empty() { + let _ = write!(header, " {rel}"); + } + } + if let Some(eta) = &batch.estimated_merge_at { + let rel = relative_time(eta, now, true); + if !rel.is_empty() { + let _ = write!(header, " ETA {rel}"); + } + } + writeln!(w, "{header}")?; + for pr in &batch.pull_requests { + writeln!(w, " #{} {} ({})", pr.number, pr.title, pr.author.login)?; + } + Ok(()) +} + +fn print_waiting_prs( + w: &mut dyn Write, + prs: &[PullRequest], + now: DateTime, +) -> std::io::Result<()> { + writeln!(w, "Waiting")?; + for pr in prs { + let mut line = format!(" #{} {} {}", pr.number, pr.title, pr.author.login); + if let Some(prio) = &pr.priority_alias { + let _ = write!(line, " {prio}"); + } + if let Some(queued_at) = &pr.queued_at { + let rel = relative_time(queued_at, now, false); + if !rel.is_empty() { + let _ = write!(line, " queued {rel}"); + } + } + if let Some(eta) = &pr.estimated_merge_at { + let rel = relative_time(eta, now, true); + if !rel.is_empty() { + let _ = write!(line, " ETA {rel}"); + } + } + writeln!(w, "{line}")?; + } + Ok(()) +} + +/// Map a batch-status code to a compact Unicode icon. Same icons as +/// the Python implementation; unknown codes fall back to `?`. +fn status_icon(code: &str) -> &'static str { + match code { + "running" => "●", + "bisecting" => "◑", + "preparing" | "waiting_for_batch" => "◌", + "failed" => "✗", + "merged" => "✓", + "waiting_for_merge" => "◎", + "waiting_for_previous_batches" | "waiting_for_requeue" => "⏳", + "waiting_schedule" => "⏰", + "frozen" => "❄", + _ => "?", + } +} + +/// Format an ISO-8601/RFC-3339 timestamp as a relative duration +/// (`s`/`m`/`h`/`d`). Past timestamps render as `"… ago"`; future +/// timestamps as `"~…"` when `future = true`. +/// +/// Returns an empty string when the timestamp can't be parsed — +/// mirrors the Python implementation, which silently degrades on +/// malformed input rather than failing the whole render. +fn relative_time(iso: &str, now: DateTime, future: bool) -> String { + let Ok(parsed) = DateTime::parse_from_rfc3339(iso) else { + return String::new(); + }; + let parsed = parsed.with_timezone(&Utc); + let delta = (now - parsed).num_seconds().abs(); + let value = if delta < 60 { + format!("{delta}s") + } else if delta < 3600 { + format!("{}m", delta / 60) + } else if delta < 86400 { + format!("{}h", delta / 3600) + } else { + format!("{}d", delta / 86400) + }; + if future { + format!("~{value}") + } else { + format!("{value} ago") + } +} + +/// Topological sort of batches by `parent_ids`. Roots come first, +/// children follow their parents — matches the Python +/// `_topological_sort`. Cycles are impossible by API contract, but +/// the `visited` set makes us tolerant of them anyway. +fn topological_sort(batches: &[Batch]) -> Vec<&Batch> { + let id_to_batch: HashMap<&str, &Batch> = batches.iter().map(|b| (b.id.as_str(), b)).collect(); + let mut visited: HashSet<&str> = HashSet::new(); + let mut result: Vec<&Batch> = Vec::with_capacity(batches.len()); + + for batch in batches { + visit(batch.id.as_str(), &id_to_batch, &mut visited, &mut result); + } + result +} + +fn visit<'a>( + id: &'a str, + id_to_batch: &HashMap<&'a str, &'a Batch>, + visited: &mut HashSet<&'a str>, + result: &mut Vec<&'a Batch>, +) { + if !visited.insert(id) { + return; + } + let Some(batch) = id_to_batch.get(id) else { + return; + }; + for parent in &batch.parent_ids { + visit(parent.as_str(), id_to_batch, visited, result); + } + result.push(batch); +} + +/// Group batches by scope, preserving insertion order for the +/// scopes (matches Python dict iteration). A batch with no scopes +/// is grouped under `"default"` to match the Python fallback. A +/// batch with multiple scopes appears in every group it claims — +/// the Python implementation does the same so users see each batch +/// in every scope it affects. +fn group_by_scope<'a>(batches: &[&'a Batch]) -> IndexMap> { + let mut groups: IndexMap> = IndexMap::new(); + for batch in batches { + let scopes: Vec = if batch.scopes.is_empty() { + vec!["default".to_string()] + } else { + batch.scopes.clone() + }; + for scope in scopes { + groups.entry(scope).or_default().push(batch); + } + } + groups +} + +#[cfg(test)] +mod tests { + use mergify_core::OutputMode; + use mergify_core::StdioOutput; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::header; + use wiremock::matchers::method; + use wiremock::matchers::path; + use wiremock::matchers::query_param; + + use super::*; + + type SharedBytes = std::sync::Arc>>; + + struct Captured { + output: StdioOutput, + stdout: SharedBytes, + } + + fn make_output(mode: OutputMode) -> 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( + mode, + SharedWriter(std::sync::Arc::clone(&stdout)), + SharedWriter(std::sync::Arc::clone(&stderr)), + ); + Captured { output, stdout } + } + + fn stdout_string(cap: &Captured) -> String { + String::from_utf8(cap.stdout.lock().unwrap().clone()).unwrap() + } + + #[test] + fn build_path_no_branch() { + assert_eq!( + build_path("owner/repo", None), + "/v1/repos/owner/repo/merge-queue/status", + ); + } + + #[test] + fn build_path_with_branch() { + assert_eq!( + build_path("owner/repo", Some("main")), + "/v1/repos/owner/repo/merge-queue/status?branch=main", + ); + } + + #[test] + fn build_path_url_encodes_branch() { + // Slashes and unicode in branch names must survive a round + // trip through the URL — `feature/foo` is common, and + // browser-pasted names occasionally include UTF-8. + let path = build_path("owner/repo", Some("feature/foo bar")); + assert!(path.ends_with("?branch=feature%2Ffoo+bar"), "got {path}"); + } + + #[test] + fn relative_time_seconds() { + let now = DateTime::parse_from_rfc3339("2026-01-01T00:01:00Z") + .unwrap() + .with_timezone(&Utc); + assert_eq!(relative_time("2026-01-01T00:00:30Z", now, false), "30s ago"); + } + + #[test] + fn relative_time_minutes() { + let now = DateTime::parse_from_rfc3339("2026-01-01T01:00:00Z") + .unwrap() + .with_timezone(&Utc); + assert_eq!(relative_time("2026-01-01T00:55:00Z", now, false), "5m ago"); + } + + #[test] + fn relative_time_hours() { + let now = DateTime::parse_from_rfc3339("2026-01-01T05:00:00Z") + .unwrap() + .with_timezone(&Utc); + assert_eq!(relative_time("2026-01-01T00:00:00Z", now, false), "5h ago"); + } + + #[test] + fn relative_time_days() { + let now = DateTime::parse_from_rfc3339("2026-01-08T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + assert_eq!(relative_time("2026-01-01T00:00:00Z", now, false), "7d ago"); + } + + #[test] + fn relative_time_future_prefix() { + // ETA-style timestamps render as `~…` so users can + // distinguish "happened 5m ago" from "in 5m". + let now = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + assert_eq!(relative_time("2026-01-01T00:30:00Z", now, true), "~30m"); + } + + #[test] + fn relative_time_unparseable_returns_empty() { + // Mirrors Python: a malformed timestamp shouldn't fail the + // whole render — degrade gracefully so the rest of the + // status block still appears. + let now = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + assert_eq!(relative_time("not-a-date", now, false), ""); + } + + #[test] + fn topological_sort_orders_parents_before_children() { + // Construct three batches, child references parent. Even if + // the input is in reverse order, the sort must put the + // parent first. + let batches = vec![ + sample_batch("c", &["b"]), + sample_batch("b", &["a"]), + sample_batch("a", &[]), + ]; + let sorted = topological_sort(&batches); + let ids: Vec<&str> = sorted.iter().map(|b| b.id.as_str()).collect(); + assert_eq!(ids, vec!["a", "b", "c"]); + } + + #[test] + fn topological_sort_handles_missing_parent_ids() { + // When `parent_ids` references an id that isn't in the + // batches list (the API has dropped it for some reason), + // the sort skips it instead of panicking. + let batches = [sample_batch("only", &["nonexistent"])]; + let sorted = topological_sort(&batches); + assert_eq!(sorted.len(), 1); + assert_eq!(sorted[0].id, "only"); + } + + #[test] + fn group_by_scope_default_when_empty_scopes() { + let batches = [sample_batch("a", &[])]; + let refs: Vec<&Batch> = batches.iter().collect(); + let groups = group_by_scope(&refs); + assert_eq!(groups.len(), 1); + assert!(groups.contains_key("default")); + } + + #[test] + fn group_by_scope_assigns_to_each_listed_scope() { + // Matches Python: a multi-scope batch appears under each + // scope's group, not just the first. + let mut b = sample_batch("a", &[]); + b.scopes = vec!["foo".to_string(), "bar".to_string()]; + let batches = [b]; + let refs: Vec<&Batch> = batches.iter().collect(); + let groups = group_by_scope(&refs); + assert_eq!(groups.len(), 2); + assert!(groups.contains_key("foo")); + assert!(groups.contains_key("bar")); + } + + #[test] + fn status_icon_known_codes() { + assert_eq!(status_icon("running"), "●"); + assert_eq!(status_icon("merged"), "✓"); + assert_eq!(status_icon("failed"), "✗"); + } + + #[test] + fn status_icon_unknown_falls_back() { + assert_eq!(status_icon("brand-new-status"), "?"); + } + + fn sample_batch(id: &str, parents: &[&str]) -> Batch { + Batch { + id: id.to_string(), + parent_ids: parents.iter().copied().map(String::from).collect(), + scopes: Vec::new(), + status: BatchStatus { + code: "running".to_string(), + }, + started_at: None, + estimated_merge_at: None, + checks_summary: ChecksSummary { + passed: 0, + total: 0, + }, + pull_requests: Vec::new(), + } + } + + #[tokio::test] + async fn run_json_passes_response_through_verbatim() { + // JSON mode is a passthrough — every field the server sends, + // including ones we don't render, must survive intact. + // `extra_field` here proves we don't reshape on the way out. + let server = MockServer::start().await; + let response = serde_json::json!({ + "batches": [], + "waiting_pull_requests": [], + "scope_queues": {"default": []}, + "pause": null, + "extra_field": "preserved", + }); + Mock::given(method("GET")) + .and(path("/v1/repos/owner/repo/merge-queue/status")) + .and(header("Authorization", "Bearer t")) + .respond_with(ResponseTemplate::new(200).set_body_json(response.clone())) + .expect(1) + .mount(&server) + .await; + + let mut cap = make_output(OutputMode::Human); + let api_url = server.uri(); + run( + StatusOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + branch: None, + output_json: true, + }, + &mut cap.output, + ) + .await + .unwrap(); + + let stdout = stdout_string(&cap); + let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!(parsed, response); + } + + #[tokio::test] + async fn run_human_renders_paused_queue() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/repos/owner/repo/merge-queue/status")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "batches": [], + "waiting_pull_requests": [], + "scope_queues": {}, + "pause": {"reason": "deploy freeze", "paused_at": "2026-01-01T00:00:00Z"}, + }))) + .expect(1) + .mount(&server) + .await; + + let mut cap = make_output(OutputMode::Human); + let api_url = server.uri(); + run( + StatusOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + branch: None, + output_json: false, + }, + &mut cap.output, + ) + .await + .unwrap(); + + let stdout = stdout_string(&cap); + assert!(stdout.contains("Merge Queue: owner/repo"), "got {stdout}"); + assert!(stdout.contains("Queue is paused"), "got {stdout}"); + assert!(stdout.contains("deploy freeze"), "got {stdout}"); + assert!(stdout.contains("Queue is empty"), "got {stdout}"); + } + + #[tokio::test] + async fn run_human_renders_empty_queue() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/repos/owner/repo/merge-queue/status")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "batches": [], + "waiting_pull_requests": [], + "scope_queues": {}, + "pause": null, + }))) + .mount(&server) + .await; + + let mut cap = make_output(OutputMode::Human); + let api_url = server.uri(); + run( + StatusOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + branch: None, + output_json: false, + }, + &mut cap.output, + ) + .await + .unwrap(); + + let stdout = stdout_string(&cap); + assert!(stdout.contains("Queue is empty"), "got {stdout}"); + } + + #[tokio::test] + async fn run_human_renders_batches_and_waiting_prs() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/repos/owner/repo/merge-queue/status")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "batches": [{ + "id": "b1", + "name": "batch-1", + "status": {"code": "running"}, + "checks_summary": {"passed": 3, "total": 5}, + "started_at": "2026-01-01T00:00:00Z", + "estimated_merge_at": "2026-01-01T01:00:00Z", + "pull_requests": [ + { + "number": 42, + "title": "Add feature foo", + "url": "https://example.test/42", + "author": {"id": 1, "login": "alice"}, + "queued_at": "2026-01-01T00:00:00Z", + "priority_alias": "default", + "priority_rule_name": "default", + "labels": [], + "scopes": [], + }, + ], + "parent_ids": [], + }], + "waiting_pull_requests": [ + { + "number": 43, + "title": "Update deps", + "url": "https://example.test/43", + "author": {"id": 2, "login": "bob"}, + "queued_at": "2026-01-01T00:00:00Z", + "priority_alias": "high", + "priority_rule_name": "high", + "labels": [], + "scopes": [], + }, + ], + "scope_queues": {}, + "pause": null, + }))) + .mount(&server) + .await; + + let mut cap = make_output(OutputMode::Human); + let api_url = server.uri(); + run( + StatusOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + branch: None, + output_json: false, + }, + &mut cap.output, + ) + .await + .unwrap(); + + let stdout = stdout_string(&cap); + assert!(stdout.contains("Batches"), "got {stdout}"); + assert!(stdout.contains("running"), "got {stdout}"); + assert!(stdout.contains("checks 3/5"), "got {stdout}"); + assert!( + stdout.contains("#42 Add feature foo (alice)"), + "got {stdout}" + ); + assert!(stdout.contains("Waiting"), "got {stdout}"); + assert!(stdout.contains("#43"), "got {stdout}"); + assert!(stdout.contains("Update deps"), "got {stdout}"); + assert!(stdout.contains("bob"), "got {stdout}"); + assert!(stdout.contains("high"), "got {stdout}"); + } + + #[tokio::test] + async fn run_human_groups_batches_by_scope_when_multiple() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/repos/owner/repo/merge-queue/status")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "batches": [ + { + "id": "b1", + "status": {"code": "running"}, + "checks_summary": {"passed": 0, "total": 0}, + "pull_requests": [], + "scopes": ["frontend"], + "parent_ids": [], + }, + { + "id": "b2", + "status": {"code": "preparing"}, + "checks_summary": {"passed": 0, "total": 0}, + "pull_requests": [], + "scopes": ["backend"], + "parent_ids": [], + }, + ], + "waiting_pull_requests": [], + "scope_queues": {}, + "pause": null, + }))) + .mount(&server) + .await; + + let mut cap = make_output(OutputMode::Human); + let api_url = server.uri(); + run( + StatusOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + branch: None, + output_json: false, + }, + &mut cap.output, + ) + .await + .unwrap(); + + let stdout = stdout_string(&cap); + // Two scopes → each labelled by its own name (no + // generic "Batches" header). + assert!(stdout.contains("frontend"), "got {stdout}"); + assert!(stdout.contains("backend"), "got {stdout}"); + assert!(!stdout.contains("\nBatches\n"), "got {stdout}"); + } + + #[tokio::test] + async fn run_passes_branch_query_param() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/repos/owner/repo/merge-queue/status")) + .and(query_param("branch", "main")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "batches": [], + "waiting_pull_requests": [], + "scope_queues": {}, + "pause": null, + }))) + .expect(1) + .mount(&server) + .await; + + let mut cap = make_output(OutputMode::Human); + let api_url = server.uri(); + run( + StatusOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + branch: Some("main"), + output_json: false, + }, + &mut cap.output, + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn run_tolerates_missing_optional_fields() { + // The API has historically dropped optional fields entirely + // rather than serializing them as null. Deserialization + // must accept that — the response below has neither + // `pause` nor any of the per-batch optional timestamps. + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/repos/owner/repo/merge-queue/status")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "batches": [{ + "id": "b1", + "status": {"code": "running"}, + "checks_summary": {"passed": 0, "total": 0}, + "pull_requests": [], + }], + "waiting_pull_requests": [], + "scope_queues": {}, + }))) + .mount(&server) + .await; + + let mut cap = make_output(OutputMode::Human); + let api_url = server.uri(); + run( + StatusOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + branch: None, + output_json: false, + }, + &mut cap.output, + ) + .await + .unwrap(); + } + + struct SharedWriter(SharedBytes); + impl 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-queue/src/unpause.rs b/crates/mergify-queue/src/unpause.rs new file mode 100644 index 00000000..a065c263 --- /dev/null +++ b/crates/mergify-queue/src/unpause.rs @@ -0,0 +1,146 @@ +//! `mergify queue unpause` — resume the merge queue for a +//! repository. +//! +//! DELETEs ``/v1/repos//merge-queue/pause``. When the API +//! responds 404 the command prints "Queue is not currently paused" +//! and exits with `MERGIFY_API_ERROR` — matches Python's behavior. + +use std::io::Write; + +use mergify_core::ApiFlavor; +use mergify_core::CliError; +use mergify_core::DeleteOutcome; +use mergify_core::HttpClient; +use mergify_core::Output; + +use mergify_core::auth; + +pub struct UnpauseOptions<'a> { + pub repository: Option<&'a str>, + pub token: Option<&'a str>, + pub api_url: Option<&'a str>, +} + +/// Run the `queue unpause` command. +pub async fn run(opts: UnpauseOptions<'_>, output: &mut dyn Output) -> Result<(), CliError> { + let repository = auth::resolve_repository(opts.repository)?; + let token = auth::resolve_token(opts.token)?; + let api_url = auth::resolve_api_url(opts.api_url)?; + + output.status(&format!("Unpausing merge queue for {repository}…"))?; + + let client = HttpClient::new(api_url, token, ApiFlavor::Mergify)?; + let path = format!("/v1/repos/{repository}/merge-queue/pause"); + + match client.delete_if_exists(&path).await? { + DeleteOutcome::Deleted => { + emit_resumed(output)?; + Ok(()) + } + DeleteOutcome::NotFound => Err(CliError::MergifyApi( + "Queue is not currently paused".to_string(), + )), + } +} + +fn emit_resumed(output: &mut dyn Output) -> std::io::Result<()> { + output.emit(&(), &mut |w: &mut dyn Write| writeln!(w, "Queue resumed.")) +} + +#[cfg(test)] +mod tests { + use mergify_core::OutputMode; + use mergify_core::StdioOutput; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::header; + use wiremock::matchers::method; + use wiremock::matchers::path; + + 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 } + } + + #[tokio::test] + async fn run_unpauses_on_2xx() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path("/v1/repos/owner/repo/merge-queue/pause")) + .and(header("Authorization", "Bearer t")) + .respond_with(ResponseTemplate::new(204)) + .expect(1) + .mount(&server) + .await; + + let mut cap = make_output(); + let api_url = server.uri(); + run( + UnpauseOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + }, + &mut cap.output, + ) + .await + .unwrap(); + + let stdout = String::from_utf8(cap.stdout.lock().unwrap().clone()).unwrap(); + assert!(stdout.contains("Queue resumed"), "got: {stdout:?}"); + } + + #[tokio::test] + async fn run_reports_not_currently_paused_on_404() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path("/v1/repos/owner/repo/merge-queue/pause")) + .respond_with(ResponseTemplate::new(404)) + .expect(1) + .mount(&server) + .await; + + let mut cap = make_output(); + let api_url = server.uri(); + let err = run( + UnpauseOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + }, + &mut cap.output, + ) + .await + .unwrap_err(); + assert!(matches!(err, CliError::MergifyApi(_))); + assert!(err.to_string().contains("not currently paused")); + assert_eq!(err.exit_code(), mergify_core::ExitCode::MergifyApiError); + } + + struct SharedWriter(SharedBytes); + impl 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/mergify_cli/ci/cli.py b/mergify_cli/ci/cli.py index 583d209e..a720eeba 100644 --- a/mergify_cli/ci/cli.py +++ b/mergify_cli/ci/cli.py @@ -1,11 +1,7 @@ from __future__ import annotations import glob -import json -import os import pathlib -import shlex -import uuid import click @@ -13,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 @@ -263,41 +258,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""", @@ -365,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/queue/api.py b/mergify_cli/queue/api.py index f48d79b4..1b6b977f 100644 --- a/mergify_cli/queue/api.py +++ b/mergify_cli/queue/api.py @@ -7,83 +7,6 @@ import httpx -class QueuePullRequestAuthor(typing.TypedDict): - id: int - login: str - - -class QueuePullRequest(typing.TypedDict, total=False): - number: typing.Required[int] - title: typing.Required[str] - url: typing.Required[str] - author: typing.Required[QueuePullRequestAuthor] - queued_at: typing.Required[str] - priority_alias: typing.Required[str] - priority_rule_name: typing.Required[str] - labels: typing.Required[list[str]] - scopes: typing.Required[list[str]] - estimated_merge_at: str | None - - -class QueueChecksSummary(typing.TypedDict): - passed: int - total: int - - -class QueueBatchStatus(typing.TypedDict): - code: str - - -class QueueBatch(typing.TypedDict, total=False): - id: typing.Required[str] - name: typing.Required[str] - status: typing.Required[QueueBatchStatus] - started_at: typing.Required[str] - estimated_merge_at: typing.Required[str] - checks_summary: typing.Required[QueueChecksSummary] - pull_requests: typing.Required[list[QueuePullRequest]] - parent_ids: list[str] - batch_filled_slots: int | None - max_batch_slots: int | None - batch_max_start_at: str | None - scopes: list[str] - sub_batches: list[typing.Any] | None - - -class QueuePause(typing.TypedDict): - reason: str - paused_at: str - - -class QueuePauseResponse(typing.TypedDict): - paused: bool - reason: str | None - paused_at: str | None - - -class QueueStatusResponse(typing.TypedDict, total=False): - batches: typing.Required[list[QueueBatch]] - waiting_pull_requests: typing.Required[list[QueuePullRequest]] - scope_queues: typing.Required[dict[str, typing.Any]] - pause: QueuePause | None - - -async def get_queue_status( - client: httpx.AsyncClient, - repository: str, - *, - branch: str | None = None, -) -> QueueStatusResponse: - params: dict[str, str] = {} - if branch is not None: - params["branch"] = branch - response = await client.get( - f"/v1/repos/{repository}/merge-queue/status", - params=params, - ) - return response.json() # type: ignore[no-any-return] - - class QueueRule(typing.TypedDict): name: str config: dict[str, typing.Any] @@ -137,25 +60,3 @@ async def get_queue_pull( f"/v1/repos/{repository}/merge-queue/pull/{pr_number}", ) return response.json() # type: ignore[no-any-return] - - -async def pause_queue( - client: httpx.AsyncClient, - repository: str, - *, - reason: str, -) -> QueuePauseResponse: - response = await client.put( - f"/v1/repos/{repository}/merge-queue/pause", - json={"reason": reason}, - ) - return response.json() # type: ignore[no-any-return] - - -async def unpause_queue( - client: httpx.AsyncClient, - repository: str, -) -> None: - await client.delete( - f"/v1/repos/{repository}/merge-queue/pause", - ) diff --git a/mergify_cli/queue/cli.py b/mergify_cli/queue/cli.py index 0246f5b8..f52eb6d6 100644 --- a/mergify_cli/queue/cli.py +++ b/mergify_cli/queue/cli.py @@ -8,27 +8,12 @@ from rich.tree import Tree from mergify_cli import console -from mergify_cli import console_error from mergify_cli import utils from mergify_cli.dym import DYMGroup from mergify_cli.exit_codes import ExitCode from mergify_cli.queue import api as queue_api -STATUS_STYLES: dict[str, tuple[str, str]] = { - "running": ("●", "green"), - "bisecting": ("◑", "yellow"), - "preparing": ("◌", "yellow"), - "failed": ("✗", "red"), - "merged": ("✓", "dim green"), - "waiting_for_merge": ("◎", "cyan"), - "waiting_for_previous_batches": ("⏳", "yellow"), - "waiting_for_requeue": ("↻", "yellow"), - "waiting_schedule": ("⏰", "yellow"), - "waiting_for_batch": ("⏳", "dim"), - "frozen": ("❄", "cyan"), -} - CHECK_STATE_STYLES: dict[str, tuple[str, str]] = { "success": ("✓", "green"), "pending": ("◌", "yellow"), @@ -68,110 +53,6 @@ def _relative_time(iso_str: str | None, *, future: bool = False) -> str: return f"{value} ago" -def _status_text(code: str) -> Text: - icon, style = STATUS_STYLES.get(code, ("?", "dim")) - text = Text() - text.append(f"{icon} ", style=style) - text.append(code, style=style) - return text - - -def _batch_label(batch: queue_api.QueueBatch) -> Text: - label = _status_text(batch["status"]["code"]) - checks = batch["checks_summary"] - if checks["total"] > 0: - label.append(f" checks {checks['passed']}/{checks['total']}", style="dim") - started = batch.get("started_at") - if started: - rel = _relative_time(started) - if rel: - label.append(f" {rel}", style="dim") - eta = batch.get("estimated_merge_at") - if eta: - rel = _relative_time(eta, future=True) - if rel: - label.append(f" ETA {rel}", style="dim") - return label - - -def _pr_label(pr: queue_api.QueuePullRequest) -> Text: - text = Text() - text.append(f"#{pr['number']}", style="cyan") - text.append(f" {pr['title']}") - text.append(f" ({pr['author']['login']})", style="dim") - return text - - -def _topological_sort( - batches: list[queue_api.QueueBatch], -) -> list[queue_api.QueueBatch]: - id_to_batch = {b["id"]: b for b in batches} - visited: set[str] = set() - result: list[queue_api.QueueBatch] = [] - - def visit(batch_id: str) -> None: - if batch_id in visited: - return - visited.add(batch_id) - batch = id_to_batch.get(batch_id) - if batch is None: - return - for parent_id in batch.get("parent_ids") or []: - visit(parent_id) - result.append(batch) - - for b in batches: - visit(b["id"]) - return result - - -def _group_batches_by_scope( - batches: list[queue_api.QueueBatch], -) -> dict[str, list[queue_api.QueueBatch]]: - groups: dict[str, list[queue_api.QueueBatch]] = {} - for batch in batches: - scopes = batch.get("scopes") or ["default"] - for scope in scopes: - groups.setdefault(scope, []).append(batch) - return groups - - -def _print_batches(batches: list[queue_api.QueueBatch]) -> None: - sorted_batches = _topological_sort(batches) - scope_groups = _group_batches_by_scope(sorted_batches) - all_scopes = list(scope_groups.keys()) - single_scope = len(all_scopes) == 1 - - for scope in all_scopes: - scope_batches = scope_groups[scope] - label = "Batches" if single_scope else scope - tree = Tree(Text(label, style="bold")) - for batch in scope_batches: - batch_node = tree.add(_batch_label(batch)) - for pr in batch["pull_requests"]: - batch_node.add(_pr_label(pr)) - console.print(tree) - - -def _print_waiting_prs(pull_requests: list[queue_api.QueuePullRequest]) -> None: - console.print(Text("Waiting", style="bold")) - for pr in pull_requests: - line = Text(" ") - line.append(f"#{pr['number']}", style="cyan") - line.append(f" {pr['title']}") - line.append(f" {pr['author']['login']}", style="dim") - line.append(f" {pr['priority_alias']}", style="magenta") - queued_rel = _relative_time(pr["queued_at"]) - if queued_rel: - line.append(f" queued {queued_rel}", style="dim") - eta = pr.get("estimated_merge_at") - if eta: - eta_rel = _relative_time(eta, future=True) - if eta_rel: - line.append(f" ETA {eta_rel}", style="dim") - console.print(line) - - def _print_pull_metadata(data: queue_api.QueuePullResponse) -> None: console.print(Text(f"PR #{data['number']}", style="bold")) console.print() @@ -366,160 +247,6 @@ def queue( click.echo(ctx.get_help()) -@queue.command(help="Show merge queue status for the repository") -@click.option( - "--branch", - "-b", - default=None, - help="Branch name to filter the queue", -) -@click.option( - "--json", - "output_json", - is_flag=True, - help="Output in JSON format", -) -@click.pass_context -@utils.run_with_asyncio -async def status(ctx: click.Context, *, branch: str | None, output_json: bool) -> None: - async with utils.get_mergify_http_client( - ctx.obj["api_url"], - ctx.obj["token"], - ) as client: - data = await queue_api.get_queue_status( - client, - ctx.obj["repository"], - branch=branch, - ) - - if output_json: - import json - - # JSON output is a passthrough of the Mergify API response. - # The schema is Mergify's API contract, not this CLI's — the - # Rust port must preserve this passthrough behavior. - click.echo(json.dumps(data, indent=2)) - return - - console.print( - Text(f"Merge Queue: {ctx.obj['repository']}", style="bold"), - ) - console.print() - - pause = data.get("pause") - if pause is not None: - pause_rel = _relative_time(pause["paused_at"]) - pause_text = Text() - pause_text.append("⚠ Queue is paused: ", style="bold yellow") - pause_text.append(f'"{pause["reason"]}"') - if pause_rel: - pause_text.append(f" (since {pause_rel})", style="dim") - console.print(pause_text) - console.print() - - batches = data["batches"] - waiting = data["waiting_pull_requests"] - - if not batches and not waiting: - console.print("Queue is empty") - return - - if batches: - _print_batches(batches) - - if waiting: - if batches: - console.print() - _print_waiting_prs(waiting) - - -def _validate_pause_reason( - ctx: click.Context, # noqa: ARG001 - param: click.Parameter, # noqa: ARG001 - value: str, -) -> str: - if len(value) > 255: - msg = "must be 255 characters or fewer" - raise click.BadParameter(msg) - return value - - -@queue.command(help="Pause the merge queue for the repository") -@click.option( - "--reason", - required=True, - callback=_validate_pause_reason, - help="Reason for pausing the queue (max 255 chars)", -) -@click.option( - "--yes-i-am-sure", - is_flag=True, - default=False, - help="Skip confirmation prompt (required in non-interactive mode)", -) -@click.pass_context -@utils.run_with_asyncio -async def pause(ctx: click.Context, *, reason: str, yes_i_am_sure: bool) -> None: - repository = ctx.obj["repository"] - - if not yes_i_am_sure: - import os - - if not os.isatty(0): - console_error( - "refusing to pause without confirmation. " - "Pass --yes-i-am-sure to proceed.", - ) - raise SystemExit(ExitCode.INVALID_STATE) - click.confirm( - f"You are about to pause the merge queue for {repository}. Proceed?", - abort=True, - ) - - async with utils.get_mergify_http_client( - ctx.obj["api_url"], - ctx.obj["token"], - ) as client: - data = await queue_api.pause_queue( - client, - repository, - reason=reason, - ) - - pause_text = Text() - pause_text.append("⚠ Queue paused", style="bold yellow") - pause_text.append(f': "{data["reason"]}"') - if data.get("paused_at"): - pause_rel = _relative_time(data["paused_at"]) - if pause_rel: - pause_text.append(f" (since {pause_rel})", style="dim") - console.print(pause_text) - - -@queue.command(help="Unpause the merge queue for the repository") -@click.pass_context -@utils.run_with_asyncio -async def unpause(ctx: click.Context) -> None: - import httpx - - try: - async with utils.get_mergify_http_client( - ctx.obj["api_url"], - ctx.obj["token"], - ) as client: - await queue_api.unpause_queue(client, ctx.obj["repository"]) - except httpx.HTTPStatusError as e: - if e.response.status_code == 404: - console.print( - "Queue is not currently paused", - style="yellow", - ) - raise SystemExit(ExitCode.MERGIFY_API_ERROR) from None - raise - - console.print("[green]Queue unpaused successfully.[/]") - - @queue.command(help="Show detailed state of a pull request in the merge queue") @click.argument("pr_number", type=int) @click.option( diff --git a/mergify_cli/tests/ci/test_cli.py b/mergify_cli/tests/ci/test_cli.py index 705b942b..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,198 +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_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, -) -> 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/queue/test_cli.py b/mergify_cli/tests/queue/test_cli.py index 97e5ea69..d8451f93 100644 --- a/mergify_cli/tests/queue/test_cli.py +++ b/mergify_cli/tests/queue/test_cli.py @@ -1,72 +1,9 @@ from __future__ import annotations import datetime -import typing from unittest.mock import patch -from click.testing import CliRunner -from httpx import Response -import respx - -from mergify_cli.exit_codes import ExitCode from mergify_cli.queue.cli import _relative_time -from mergify_cli.queue.cli import _topological_sort -from mergify_cli.queue.cli import queue -from mergify_cli.tests import utils as test_utils - - -FAKE_PR = { - "number": 123, - "title": "Add feature X", - "url": "https://github.com/owner/repo/pull/123", - "author": {"id": 1, "login": "octocat"}, - "queued_at": "2025-11-05T10:00:00Z", - "priority_alias": "medium", - "priority_rule_name": "default", - "labels": [], - "scopes": ["main"], - "estimated_merge_at": "2025-11-05T11:00:00Z", -} - -FAKE_BATCH = { - "id": "550e8400-e29b-41d4-a716-446655440000", - "name": "batch-1", - "status": {"code": "running"}, - "started_at": "2025-11-05T10:00:00Z", - "estimated_merge_at": "2025-11-05T11:00:00Z", - "checks_summary": {"passed": 5, "total": 10}, - "pull_requests": [FAKE_PR], - "parent_ids": [], - "scopes": ["main"], - "sub_batches": None, -} - -FAKE_PAUSE = { - "reason": "Deploying hotfix", - "paused_at": "2025-11-05T14:00:00Z", -} - -BASE_ARGS = [ - "--token", - "test-token", - "--api-url", - "https://api.mergify.com", - "--repository", - "owner/repo", -] - - -def _invoke_status( - mock: respx.MockRouter, - response_json: dict[str, typing.Any], - extra_args: list[str] | None = None, -) -> typing.Any: - mock.get("/v1/repos/owner/repo/merge-queue/status").mock( - return_value=Response(200, json=response_json), - ) - runner = CliRunner() - args = [*BASE_ARGS, "status", *(extra_args or [])] - return runner.invoke(queue, args) class TestRelativeTime: @@ -115,414 +52,3 @@ def test_none(self) -> None: def test_empty(self) -> None: assert not _relative_time("") - - -class TestTopologicalSort: - def test_no_parents(self) -> None: - batches = [ - {**FAKE_BATCH, "id": "a", "parent_ids": []}, - {**FAKE_BATCH, "id": "b", "parent_ids": []}, - ] - result = _topological_sort(batches) # type: ignore[arg-type] - assert [b["id"] for b in result] == ["a", "b"] - - def test_chain(self) -> None: - batches = [ - {**FAKE_BATCH, "id": "c", "parent_ids": ["b"]}, - {**FAKE_BATCH, "id": "a", "parent_ids": []}, - {**FAKE_BATCH, "id": "b", "parent_ids": ["a"]}, - ] - result = _topological_sort(batches) # type: ignore[arg-type] - assert [b["id"] for b in result] == ["a", "b", "c"] - - def test_diamond(self) -> None: - batches = [ - {**FAKE_BATCH, "id": "d", "parent_ids": ["b", "c"]}, - {**FAKE_BATCH, "id": "b", "parent_ids": ["a"]}, - {**FAKE_BATCH, "id": "c", "parent_ids": ["a"]}, - {**FAKE_BATCH, "id": "a", "parent_ids": []}, - ] - result = _topological_sort(batches) # type: ignore[arg-type] - ids = [b["id"] for b in result] - assert ids.index("a") < ids.index("b") - assert ids.index("a") < ids.index("c") - assert ids.index("b") < ids.index("d") - assert ids.index("c") < ids.index("d") - - -class TestStatusCommand: - def test_empty_queue(self) -> None: - with respx.mock(base_url="https://api.mergify.com") as mock: - result = _invoke_status( - mock, - { - "batches": [], - "waiting_pull_requests": [], - "scope_queues": {}, - }, - ) - assert result.exit_code == 0, result.output - assert "Merge Queue: owner/repo" in result.output - assert "Queue is empty" in result.output - - def test_with_batches(self) -> None: - with respx.mock(base_url="https://api.mergify.com") as mock: - result = _invoke_status( - mock, - { - "batches": [FAKE_BATCH], - "waiting_pull_requests": [], - "scope_queues": {}, - }, - ) - assert result.exit_code == 0, result.output - assert "Batches" in result.output - assert "running" in result.output - assert "5/10" in result.output - assert "#123" in result.output - assert "Add feature X" in result.output - assert "octocat" in result.output - - def test_with_waiting_prs(self) -> None: - with respx.mock(base_url="https://api.mergify.com") as mock: - result = _invoke_status( - mock, - { - "batches": [], - "waiting_pull_requests": [FAKE_PR], - "scope_queues": {}, - }, - ) - assert result.exit_code == 0, result.output - assert "Waiting" in result.output - assert "#123" in result.output - assert "Add feature X" in result.output - assert "octocat" in result.output - assert "medium" in result.output - - def test_with_batches_and_waiting_prs(self) -> None: - waiting_pr = { - **FAKE_PR, - "number": 456, - "title": "Another PR", - "author": {"id": 2, "login": "hubot"}, - } - with respx.mock(base_url="https://api.mergify.com") as mock: - result = _invoke_status( - mock, - { - "batches": [FAKE_BATCH], - "waiting_pull_requests": [waiting_pr], - "scope_queues": {}, - }, - ) - assert result.exit_code == 0, result.output - assert "Batches" in result.output - assert "Waiting" in result.output - assert "#123" in result.output - assert "#456" in result.output - - def test_paused(self) -> None: - with respx.mock(base_url="https://api.mergify.com") as mock: - result = _invoke_status( - mock, - { - "batches": [FAKE_BATCH], - "waiting_pull_requests": [], - "scope_queues": {}, - "pause": FAKE_PAUSE, - }, - ) - assert result.exit_code == 0, result.output - assert "paused" in result.output.lower() - assert "Deploying hotfix" in result.output - - def test_paused_empty_queue(self) -> None: - with respx.mock(base_url="https://api.mergify.com") as mock: - result = _invoke_status( - mock, - { - "batches": [], - "waiting_pull_requests": [], - "scope_queues": {}, - "pause": FAKE_PAUSE, - }, - ) - assert result.exit_code == 0, result.output - assert "paused" in result.output.lower() - assert "Queue is empty" in result.output - - def test_json_output(self) -> None: - api_response = { - "batches": [FAKE_BATCH], - "waiting_pull_requests": [FAKE_PR], - "scope_queues": {}, - } - with respx.mock(base_url="https://api.mergify.com") as mock: - result = _invoke_status(mock, api_response, extra_args=["--json"]) - assert result.exit_code == 0, result.output - data = test_utils.assert_stdout_is_single_json_document(result.output) - assert len(data["batches"]) == 1 - assert len(data["waiting_pull_requests"]) == 1 - - def test_branch_filter(self) -> None: - with respx.mock(base_url="https://api.mergify.com") as mock: - route = mock.get( - "/v1/repos/owner/repo/merge-queue/status", - params={"branch": "release"}, - ).mock( - return_value=Response( - 200, - json={ - "batches": [], - "waiting_pull_requests": [], - "scope_queues": {}, - }, - ), - ) - runner = CliRunner() - result = runner.invoke( - queue, - [*BASE_ARGS, "status", "--branch", "release"], - ) - assert result.exit_code == 0, result.output - assert route.called - - def test_api_error(self) -> None: - with respx.mock(base_url="https://api.mergify.com") as mock: - mock.get("/v1/repos/owner/repo/merge-queue/status").mock( - return_value=Response(403, json={"message": "Forbidden"}), - ) - runner = CliRunner() - result = runner.invoke(queue, [*BASE_ARGS, "status"]) - assert result.exit_code != 0 - - def test_pr_without_eta(self) -> None: - pr_no_eta = {**FAKE_PR, "estimated_merge_at": None} - with respx.mock(base_url="https://api.mergify.com") as mock: - result = _invoke_status( - mock, - { - "batches": [], - "waiting_pull_requests": [pr_no_eta], - "scope_queues": {}, - }, - ) - assert result.exit_code == 0, result.output - assert "#123" in result.output - - def test_multi_scope(self) -> None: - batch_main = { - **FAKE_BATCH, - "id": "aaa", - "scopes": ["main"], - } - batch_staging = { - **FAKE_BATCH, - "id": "bbb", - "scopes": ["staging"], - "status": {"code": "preparing"}, - "pull_requests": [ - { - **FAKE_PR, - "number": 456, - "title": "Staging fix", - "author": {"id": 2, "login": "hubot"}, - }, - ], - } - with respx.mock(base_url="https://api.mergify.com") as mock: - result = _invoke_status( - mock, - { - "batches": [batch_main, batch_staging], - "waiting_pull_requests": [], - "scope_queues": {}, - }, - ) - assert result.exit_code == 0, result.output - assert "main" in result.output - assert "staging" in result.output - assert "#123" in result.output - assert "#456" in result.output - - def test_multi_pr_batch(self) -> None: - pr2 = { - **FAKE_PR, - "number": 789, - "title": "Second PR", - "author": {"id": 3, "login": "alice"}, - } - batch = { - **FAKE_BATCH, - "pull_requests": [FAKE_PR, pr2], - } - with respx.mock(base_url="https://api.mergify.com") as mock: - result = _invoke_status( - mock, - { - "batches": [batch], - "waiting_pull_requests": [], - "scope_queues": {}, - }, - ) - assert result.exit_code == 0, result.output - assert "#123" in result.output - assert "#789" in result.output - assert "alice" in result.output - - def test_status_icons(self) -> None: - batch_failed = { - **FAKE_BATCH, - "status": {"code": "failed"}, - } - with respx.mock(base_url="https://api.mergify.com") as mock: - result = _invoke_status( - mock, - { - "batches": [batch_failed], - "waiting_pull_requests": [], - "scope_queues": {}, - }, - ) - assert result.exit_code == 0, result.output - assert "failed" in result.output - - def test_checks_omitted_when_zero(self) -> None: - batch_no_checks = { - **FAKE_BATCH, - "checks_summary": {"passed": 0, "total": 0}, - } - with respx.mock(base_url="https://api.mergify.com") as mock: - result = _invoke_status( - mock, - { - "batches": [batch_no_checks], - "waiting_pull_requests": [], - "scope_queues": {}, - }, - ) - assert result.exit_code == 0, result.output - assert "0/0" not in result.output - - -FAKE_PAUSE_RESPONSE = { - "paused": True, - "reason": "Deploying hotfix", - "paused_at": "2025-11-05T14:00:00Z", -} - - -class TestPauseCommand: - def test_pause_with_confirmation(self) -> None: - with respx.mock(base_url="https://api.mergify.com") as mock: - mock.put("/v1/repos/owner/repo/merge-queue/pause").mock( - return_value=Response(200, json=FAKE_PAUSE_RESPONSE), - ) - runner = CliRunner() - with patch("os.isatty", return_value=True): - result = runner.invoke( - queue, - [*BASE_ARGS, "pause", "--reason", "Deploying hotfix"], - input="y\n", - ) - assert result.exit_code == 0, result.output - assert "paused" in result.output.lower() - assert "Deploying hotfix" in result.output - - def test_pause_with_yes_flag(self) -> None: - with respx.mock(base_url="https://api.mergify.com") as mock: - mock.put("/v1/repos/owner/repo/merge-queue/pause").mock( - return_value=Response(200, json=FAKE_PAUSE_RESPONSE), - ) - runner = CliRunner() - result = runner.invoke( - queue, - [ - *BASE_ARGS, - "pause", - "--reason", - "Deploying hotfix", - "--yes-i-am-sure", - ], - ) - assert result.exit_code == 0, result.output - assert "paused" in result.output.lower() - assert "Deploying hotfix" in result.output - - def test_pause_confirmation_denied(self) -> None: - runner = CliRunner() - with patch("os.isatty", return_value=True): - result = runner.invoke( - queue, - [*BASE_ARGS, "pause", "--reason", "test"], - input="n\n", - ) - assert result.exit_code != 0 - - def test_pause_non_tty_without_flag(self) -> None: - runner = CliRunner() - with patch("os.isatty", return_value=False): - result = runner.invoke( - queue, - [*BASE_ARGS, "pause", "--reason", "test"], - ) - assert result.exit_code == ExitCode.INVALID_STATE - assert "--yes-i-am-sure" in result.output - - def test_pause_requires_reason(self) -> None: - runner = CliRunner() - result = runner.invoke( - queue, - [*BASE_ARGS, "pause"], - ) - assert result.exit_code != 0 - - def test_pause_reason_too_long(self) -> None: - runner = CliRunner() - result = runner.invoke( - queue, - [*BASE_ARGS, "pause", "--reason", "x" * 256, "--yes-i-am-sure"], - ) - assert result.exit_code != 0 - assert "255 characters" in result.output - - def test_pause_api_error(self) -> None: - with respx.mock(base_url="https://api.mergify.com") as mock: - mock.put("/v1/repos/owner/repo/merge-queue/pause").mock( - return_value=Response(422, json={"message": "Invalid reason"}), - ) - runner = CliRunner() - result = runner.invoke( - queue, - [ - *BASE_ARGS, - "pause", - "--reason", - "test", - "--yes-i-am-sure", - ], - ) - assert result.exit_code != 0 - - -class TestUnpauseCommand: - def test_unpause(self) -> None: - with respx.mock(base_url="https://api.mergify.com") as mock: - mock.delete("/v1/repos/owner/repo/merge-queue/pause").mock( - return_value=Response(204), - ) - runner = CliRunner() - result = runner.invoke(queue, [*BASE_ARGS, "unpause"]) - assert result.exit_code == 0, result.output - assert "unpaused" in result.output.lower() - - def test_unpause_not_paused(self) -> None: - with respx.mock(base_url="https://api.mergify.com") as mock: - mock.delete("/v1/repos/owner/repo/merge-queue/pause").mock( - return_value=Response(404, json={"message": "Not paused"}), - ) - runner = CliRunner() - result = runner.invoke(queue, [*BASE_ARGS, "unpause"]) - assert result.exit_code == ExitCode.MERGIFY_API_ERROR - assert "not currently paused" in result.output.lower() diff --git a/mergify_cli/tests/queue/test_skill.py b/mergify_cli/tests/queue/test_skill.py index f2096995..e701b83b 100644 --- a/mergify_cli/tests/queue/test_skill.py +++ b/mergify_cli/tests/queue/test_skill.py @@ -62,18 +62,25 @@ def test_skill_has_required_sections() -> None: assert section in content, f"Skill is missing required section: {section}" +# Rust-native queue commands. Each port PR appends to this list when +# it deletes the Python copy, so the validation below stays accurate +# without needing to spawn the Rust binary at test time. +NATIVE_QUEUE_COMMANDS: frozenset[str] = frozenset({"pause", "unpause", "status"}) + + def test_skill_references_valid_commands() -> None: - """Check that commands referenced in the skill exist in the CLI.""" + """Every `mergify queue ` reference in the skill must resolve + to either a registered click command (still-shimmed) or a known + Rust-native command. Catches typos and skill drift after a port.""" from mergify_cli.queue.cli import queue content = _get_skill_content() - # Extract `mergify queue ` references referenced = set(re.findall(r"mergify queue ([\w-]+)", content)) - - available = set(queue.commands.keys()) + available = set(queue.commands.keys()) | NATIVE_QUEUE_COMMANDS for cmd in referenced: assert cmd in available, ( - f"Skill references 'mergify queue {cmd}' but it's not a registered command. " + f"Skill references 'mergify queue {cmd}' but it's neither a " + f"registered click command nor a known Rust-native command. " f"Available: {sorted(available)}" ) 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)