diff --git a/Cargo.lock b/Cargo.lock index a1c8cd73..8e178302 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" @@ -184,9 +193,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.60" +version = "1.2.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", "jobserver", @@ -206,6 +215,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" @@ -416,9 +436,9 @@ dependencies = [ [[package]] name = "fraction" -version = "0.15.3" +version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f158e3ff0a1b334408dc9fb811cd99b446986f4d8b741bb08f9df1604085ae7" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" dependencies = [ "lazy_static", "num", @@ -560,9 +580,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -720,6 +740,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" @@ -846,16 +890,6 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -929,9 +963,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ "cfg-if", "futures-util", @@ -980,9 +1014,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.185" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "linux-raw-sys" @@ -1030,10 +1064,12 @@ dependencies = [ "mergify-core", "serde", "serde_json", + "serde_yaml_ng", "temp-env", "tempfile", "tokio", "url", + "uuid", "wiremock", ] @@ -1046,6 +1082,7 @@ dependencies = [ "mergify-config", "mergify-core", "mergify-py-shim", + "mergify-queue", "tokio", ] @@ -1088,6 +1125,30 @@ dependencies = [ "thiserror", ] +[[package]] +name = "mergify-queue" +version = "0.0.0" +dependencies = [ + "anstyle", + "chrono", + "indexmap", + "mergify-core", + "mergify-tui", + "serde", + "serde_json", + "tokio", + "url", + "wiremock", +] + +[[package]] +name = "mergify-tui" +version = "0.0.0" +dependencies = [ + "anstyle", + "chrono", +] + [[package]] name = "micromap" version = "0.3.0" @@ -1552,9 +1613,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.38" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "once_cell", @@ -1578,9 +1639,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -1746,6 +1807,19 @@ dependencies = [ "unsafe-libyaml-norway", ] +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1973,20 +2047,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ "bitflags", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -2044,6 +2118,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" @@ -2080,6 +2160,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" @@ -2129,11 +2220,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen 0.57.1", + "wit-bindgen 0.46.0", ] [[package]] @@ -2147,9 +2238,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -2160,9 +2251,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ "js-sys", "wasm-bindgen", @@ -2170,9 +2261,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2180,9 +2271,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -2193,9 +2284,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -2236,9 +2327,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", @@ -2272,12 +2363,65 @@ dependencies = [ "windows-sys 0.52.0", ] +[[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" @@ -2457,6 +2601,12 @@ dependencies = [ "url", ] +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -2466,12 +2616,6 @@ dependencies = [ "wit-bindgen-rust-macro", ] -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - [[package]] name = "wit-bindgen-core" version = "0.51.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 5bf008f3..26f4c70c 100644 --- a/crates/mergify-ci/src/lib.rs +++ b/crates/mergify-ci/src/lib.rs @@ -1,11 +1,15 @@ //! 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 detector; +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-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 7b69caa8..12dca9ec 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(); @@ -44,6 +49,18 @@ fn main() -> ExitCode { println!("✅"); } + // Hidden flag — used by `mergify_cli/tests/queue/test_skill.py` + // (and any future cross-language test) to learn the set of + // commands the Rust binary handles natively without resorting + // to a hardcoded list that drifts. Format is one + // ` ` pair per line. + if argv.first().is_some_and(|a| a == "--list-native-commands") { + for (group, sub) in NATIVE_COMMANDS { + println!("{group} {sub}"); + } + return ExitCode::SUCCESS; + } + if let Some(cmd) = detect_native(&argv) { return run_native(cmd); } @@ -57,12 +74,34 @@ fn main() -> ExitCode { } } +/// Single source of truth for the `(group, subcommand)` pairs the +/// Rust binary handles natively. Used by [`looks_native`] for argv +/// recognition and by the `--list-native-commands` hidden flag so +/// out-of-process tests can discover the list without hard-coding +/// it. Add new entries here when porting a command; the matching +/// `clap` `Subcommands` variant is what actually wires it up. +const NATIVE_COMMANDS: &[(&str, &str)] = &[ + ("config", "validate"), + ("config", "simulate"), + ("ci", "scopes-send"), + ("ci", "git-refs"), + ("ci", "queue-info"), + ("queue", "pause"), + ("queue", "unpause"), + ("queue", "status"), +]; + /// Native commands the Rust binary handles without delegating to /// the Python shim. enum NativeCommand { ConfigValidate { config_file: Option }, ConfigSimulate(ConfigSimulateOpts), CiScopesSend(CiScopesSendOpts), + CiGitRefs { format: GitRefsFormat }, + CiQueueInfo, + QueuePause(QueuePauseOpts), + QueueUnpause(QueueUnpauseOpts), + QueueStatus(QueueStatusOpts), } struct ConfigSimulateOpts { @@ -83,22 +122,42 @@ struct CiScopesSendOpts { file_deprecated: Option, } +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. 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"), - ) + NATIVE_COMMANDS + .iter() + .any(|(g, s)| pair[0] == *g && pair[1] == *s) }) } @@ -121,12 +180,12 @@ fn is_help_or_version(err: &clap::Error) -> bool { /// 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. +/// 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 @@ -200,6 +259,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, + })), } } @@ -250,6 +353,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 + } } }); @@ -277,6 +421,8 @@ enum Subcommands { Config(ConfigArgs), /// Mergify CI-related commands. Ci(CiArgs), + /// Manage the Mergify merge queue. + Queue(QueueArgs), } #[derive(clap::Args)] @@ -330,6 +476,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)] @@ -372,3 +537,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-core/src/http.rs b/crates/mergify-core/src/http.rs index ec657ff5..42dcb057 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(self.terminal_send_error_message(&e))); + } + } + } + Err(self.api_error(last_message)) + } + async fn execute_request( &self, builder: reqwest::RequestBuilder, @@ -202,17 +287,7 @@ impl Client { backoff *= 2; } Err(e) => { - let msg = if e.is_timeout() { - format!( - "{} did not respond in time. The request was aborted — please retry.", - self.service_name() - ) - } else if e.is_connect() { - format!("could not reach {}: {e}", self.service_name()) - } else { - format!("request failed: {e}") - }; - return Err(self.api_error(msg)); + return Err(self.api_error(self.terminal_send_error_message(&e))); } } } @@ -241,6 +316,25 @@ impl Client { ApiFlavor::Mergify => "Mergify", } } + + /// Render a non-retried `reqwest` send error as the message + /// body for `CliError`. Shared between the GET/POST/PUT path + /// (`execute_request`) and the DELETE-style status-only path + /// (`execute_status`) so verbs don't drift on user-facing + /// diagnostics — timeouts and connect failures must read the + /// same regardless of HTTP method. + fn terminal_send_error_message(&self, e: &reqwest::Error) -> String { + if e.is_timeout() { + format!( + "{} did not respond in time. The request was aborted — please retry.", + self.service_name() + ) + } else if e.is_connect() { + format!("could not reach {}: {e}", self.service_name()) + } else { + format!("request failed: {e}") + } + } } fn is_transient(e: &reqwest::Error) -> bool { diff --git a/crates/mergify-core/src/lib.rs b/crates/mergify-core/src/lib.rs index 6b9da36b..8b731465 100644 --- a/crates/mergify-core/src/lib.rs +++ b/crates/mergify-core/src/lib.rs @@ -23,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..a1eae21e --- /dev/null +++ b/crates/mergify-queue/Cargo.toml @@ -0,0 +1,27 @@ +[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" } +mergify-tui = { path = "../mergify-tui" } +anstyle = "1" +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] +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..02ea2ed9 --- /dev/null +++ b/crates/mergify-queue/src/pause.rs @@ -0,0 +1,287 @@ +//! `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 mergify_core::auth; +use serde::Deserialize; +use serde::Serialize; + +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, + std::io::stdin().is_terminal(), + &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(()) +} + +/// Decide whether to proceed with a destructive queue-pause. +/// +/// `is_tty` is taken as a parameter (rather than read from +/// `std::io::stdin()` here) so unit tests can exercise the +/// non-TTY branch deterministically — `cargo test` happens to run +/// without a terminal stdin most of the time, but that's not +/// something we want to depend on, especially when developers run +/// `cargo test` from inside an interactive shell. +fn confirm(skip: bool, is_tty: bool, repository: &str) -> Result<(), CliError> { + if skip { + return Ok(()); + } + if !is_tty { + 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() { + // Pass `is_tty = false` explicitly so the test exercises + // the non-TTY branch without depending on the runtime + // shape of `cargo test`'s stdin (which can be a TTY when + // run from an interactive shell). + let err = confirm(false, 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 the TTY check — it's the + // contract of `--yes-i-am-sure`, including in CI where + // stdin isn't a terminal. The `is_tty` value passed here + // is irrelevant. + confirm(true, false, "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..a94b0aba --- /dev/null +++ b/crates/mergify-queue/src/status.rs @@ -0,0 +1,921 @@ +//! `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::io::Write; + +use anstyle::AnsiColor; +use anstyle::Style; +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 mergify_tui::Theme; +use mergify_tui::relative_time; +use mergify_tui::tree; +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(); + let theme = Theme::detect(); + output.emit(&(), &mut |w: &mut dyn Write| { + writeln!( + w, + "{B}Merge Queue: {repository}{R}", + B = theme.bold, + R = theme.reset + )?; + writeln!(w)?; + + if let Some(pause) = &view.pause { + print_pause(w, &theme, pause, now)?; + writeln!(w)?; + } + + if view.batches.is_empty() && view.waiting_pull_requests.is_empty() { + writeln!(w, "{D}Queue is empty{R}", D = theme.dim, R = theme.reset)?; + return Ok(()); + } + + if !view.batches.is_empty() { + print_batches(w, &theme, &view.batches, now)?; + } + + if !view.waiting_pull_requests.is_empty() { + if !view.batches.is_empty() { + writeln!(w)?; + } + print_waiting_prs(w, &theme, &view.waiting_pull_requests, now)?; + } + Ok(()) + }) +} + +/// Map a queue batch status code to a foreground color, honoring +/// the theme's enabled flag. Mirrors Python's `STATUS_STYLES`; +/// unknown codes render dim. +fn batch_status_style(theme: &Theme, code: &str) -> Style { + if !theme.enabled { + return Style::new(); + } + match code { + "running" | "merged" => theme.fg(AnsiColor::Green), + "failed" => theme.fg(AnsiColor::Red), + "bisecting" + | "preparing" + | "waiting_for_previous_batches" + | "waiting_for_requeue" + | "waiting_schedule" => theme.fg(AnsiColor::Yellow), + "waiting_for_merge" | "frozen" => theme.fg(AnsiColor::Cyan), + _ => theme.dim, + } +} + +fn print_pause( + w: &mut dyn Write, + theme: &Theme, + pause: &Pause, + now: DateTime, +) -> std::io::Result<()> { + let reason = pause.reason.as_deref().unwrap_or(""); + write!( + w, + "{W}⚠ Queue is paused: \"{reason}\"{R}", + W = theme.warn, + R = theme.reset, + )?; + if let Some(ts) = &pause.paused_at { + let rel = relative_time(ts, now, false); + if !rel.is_empty() { + write!(w, " {D}(since {rel}){R}", D = theme.dim, R = theme.reset)?; + } + } + writeln!(w) +} + +fn print_batches( + w: &mut dyn Write, + theme: &Theme, + 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, "{B}{label}{R}", B = theme.bold, R = theme.reset)?; + + let last_batch_idx = scope_batches.len() - 1; + for (bi, batch) in scope_batches.iter().enumerate() { + let (branch, continuation) = tree::branch_chars(bi == last_batch_idx); + print_batch_line(w, theme, branch, batch, now)?; + print_batch_prs(w, theme, continuation, batch)?; + } + } + Ok(()) +} + +fn print_batch_line( + w: &mut dyn Write, + theme: &Theme, + branch: &str, + batch: &Batch, + now: DateTime, +) -> std::io::Result<()> { + let icon = status_icon(&batch.status.code); + let icon_style = batch_status_style(theme, &batch.status.code); + write!( + w, + "{branch}{S}{icon} {code}{R}", + S = icon_style, + code = batch.status.code, + R = theme.reset, + )?; + if batch.checks_summary.total > 0 { + write!( + w, + " {D}checks {p}/{t}{R}", + D = theme.dim, + p = batch.checks_summary.passed, + t = batch.checks_summary.total, + R = theme.reset, + )?; + } + if let Some(started) = &batch.started_at { + let rel = relative_time(started, now, false); + if !rel.is_empty() { + write!(w, " {D}{rel}{R}", D = theme.dim, R = theme.reset)?; + } + } + if let Some(eta) = &batch.estimated_merge_at { + let rel = relative_time(eta, now, true); + if !rel.is_empty() { + write!(w, " {D}ETA {rel}{R}", D = theme.dim, R = theme.reset)?; + } + } + writeln!(w) +} + +fn print_batch_prs( + w: &mut dyn Write, + theme: &Theme, + continuation: &str, + batch: &Batch, +) -> std::io::Result<()> { + if batch.pull_requests.is_empty() { + return Ok(()); + } + let last_pr_idx = batch.pull_requests.len() - 1; + for (pi, pr) in batch.pull_requests.iter().enumerate() { + let (pr_branch, _) = tree::branch_chars(pi == last_pr_idx); + writeln!( + w, + "{continuation}{pr_branch}{N}#{num}{R} {title} {A}({author}){R}", + N = theme.cyan, + num = pr.number, + title = pr.title, + A = theme.dim, + author = pr.author.login, + R = theme.reset, + )?; + } + Ok(()) +} + +fn print_waiting_prs( + w: &mut dyn Write, + theme: &Theme, + prs: &[PullRequest], + now: DateTime, +) -> std::io::Result<()> { + writeln!(w, "{B}Waiting{R}", B = theme.bold, R = theme.reset)?; + for pr in prs { + write!( + w, + " {N}#{num}{R} {title} {A}{author}{R}", + N = theme.cyan, + num = pr.number, + title = pr.title, + A = theme.dim, + author = pr.author.login, + R = theme.reset, + )?; + if let Some(prio) = &pr.priority_alias { + write!(w, " {P}{prio}{R}", P = theme.magenta, R = theme.reset)?; + } + if let Some(queued_at) = &pr.queued_at { + let rel = relative_time(queued_at, now, false); + if !rel.is_empty() { + write!(w, " {D}queued {rel}{R}", D = theme.dim, R = theme.reset)?; + } + } + if let Some(eta) = &pr.estimated_merge_at { + let rel = relative_time(eta, now, true); + if !rel.is_empty() { + write!(w, " {D}ETA {rel}{R}", D = theme.dim, R = theme.reset)?; + } + } + writeln!(w)?; + } + 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" => "❄", + _ => "?", + } +} + +/// 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}"); + } + + // `relative_time` lives in `mergify-tui::time` and is exercised + // there; we re-export it via `mergify_tui::relative_time` and + // don't re-test it here. + + #[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..c88f8858 --- /dev/null +++ b/crates/mergify-queue/src/unpause.rs @@ -0,0 +1,145 @@ +//! `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/crates/mergify-tui/Cargo.toml b/crates/mergify-tui/Cargo.toml new file mode 100644 index 00000000..7e09c43a --- /dev/null +++ b/crates/mergify-tui/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "mergify-tui" +version = "0.0.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +description = "Reusable terminal-UI primitives for mergify-cli: ANSI styling, relative-time formatter, tree-drawing characters." +publish = false + +[dependencies] +anstyle = "1" +chrono = { version = "0.4", default-features = false, features = ["clock"] } + +[lints] +workspace = true diff --git a/crates/mergify-tui/src/lib.rs b/crates/mergify-tui/src/lib.rs new file mode 100644 index 00000000..aaf20275 --- /dev/null +++ b/crates/mergify-tui/src/lib.rs @@ -0,0 +1,35 @@ +//! Terminal-UI primitives shared across the ported `mergify` +//! commands. +//! +//! Each command renders its own bespoke layout, but the building +//! blocks — color/TTY detection, relative-time formatter, tree +//! characters — are uniform. Centralizing them here keeps the +//! visual style consistent across `queue status`, `queue show`, +//! `freeze list`, and any future command that needs structured +//! human-readable output. +//! +//! Modules: +//! +//! - [`theme`]: [`Theme`] struct that wraps `anstyle::Style` with +//! TTY-and-`NO_COLOR`-aware enable/disable, plus a named-color +//! palette. The same closure-based emit code paths produce +//! styled output on a TTY and plain text everywhere else with no +//! conditional branching at every write. +//! - [`time`]: [`relative_time`](time::relative_time) formats an +//! ISO-8601/RFC-3339 timestamp as a coarse delta (`Ns` / `Nm` / +//! `Nh` / `Nd`), with `~…` / `… ago` decorators for +//! future/past. Returns an empty string on parse failure rather +//! than panicking — degrading gracefully matches the Python +//! originals' behavior. +//! - [`tree`]: Unicode box-drawing constants +//! ([`BRANCH`](tree::BRANCH), [`LAST_BRANCH`](tree::LAST_BRANCH), +//! [`CONTINUATION`](tree::CONTINUATION), +//! [`LAST_CONTINUATION`](tree::LAST_CONTINUATION)) and the +//! [`branch_chars`](tree::branch_chars) helper. + +pub mod theme; +pub mod time; +pub mod tree; + +pub use theme::Theme; +pub use time::relative_time; diff --git a/crates/mergify-tui/src/theme.rs b/crates/mergify-tui/src/theme.rs new file mode 100644 index 00000000..bcff0484 --- /dev/null +++ b/crates/mergify-tui/src/theme.rs @@ -0,0 +1,138 @@ +//! ANSI styling wrapped with TTY/`NO_COLOR` detection. +//! +//! The intent is to write normal `format!` / `write!` code paths +//! that emit styled output on an interactive terminal and produce +//! plain text everywhere else, *without* conditional branching at +//! every call site. `anstyle::Style::new()` (the default) +//! deliberately emits no escape sequences in its `Display` impl — +//! so when [`Theme::detect`] decides colors are off, every named +//! style on the [`Theme`] is a `Style::new()` no-op and `reset` +//! is the empty string. Code reads the same in both modes. + +use std::io::IsTerminal; + +use anstyle::AnsiColor; +use anstyle::Style; + +/// Pre-built styles + reset escape, matched to the renderers in +/// the ported commands. Each field is either a real `Style` (when +/// colors are enabled) or `Style::new()` (when disabled — emits +/// nothing); `reset` mirrors that with `"\x1b[0m"` vs `""`. +/// +/// Construct via [`Theme::detect`] for the production policy +/// (TTY-only, `NO_COLOR`-aware, suppressed under `cfg!(test)`). +/// Tests that need to assert on styled output explicitly can pass +/// `enabled = true` to [`Theme::new`]. +pub struct Theme { + pub enabled: bool, + pub bold: Style, + pub dim: Style, + /// SGR reset escape, or empty when colors are disabled. Using + /// a `&'static str` instead of `anstyle::Reset` keeps both + /// styled and plain code paths free of escape sequences when + /// `enabled = false`. + pub reset: &'static str, + pub cyan: Style, + pub green: Style, + pub red: Style, + pub yellow: Style, + pub magenta: Style, + /// Bold + yellow. Distinct named style because it shows up in + /// every "warning"-flavored line (e.g. the queue pause + /// indicator) and nesting `{B}{Y}` at every call site is + /// noisy. + pub warn: Style, +} + +impl Theme { + /// Detect whether the process should emit colors. + /// + /// Policy: + /// + /// 1. `cfg!(test)` ⇒ disabled. `cargo test` may inherit a TTY + /// parent stdout, but tests assert on in-memory buffers and + /// shouldn't take a dependency on the developer's terminal. + /// 2. `stdout` is not a terminal ⇒ disabled (piped output stays + /// pristine for downstream tools). + /// 3. `NO_COLOR` env var is set (any value) ⇒ disabled. The + /// de-facto standard, . + /// 4. Otherwise enabled. + #[must_use] + pub fn detect() -> Self { + let enabled = !cfg!(test) + && std::io::stdout().is_terminal() + && std::env::var_os("NO_COLOR").is_none(); + Self::new(enabled) + } + + /// Construct with explicit `enabled`. Tests use this to + /// deterministically exercise the styled or plain branch. + #[must_use] + pub fn new(enabled: bool) -> Self { + let on = |style: Style| if enabled { style } else { Style::new() }; + Self { + enabled, + bold: on(Style::new().bold()), + dim: on(Style::new().dimmed()), + reset: if enabled { "\x1b[0m" } else { "" }, + cyan: on(Style::new().fg_color(Some(AnsiColor::Cyan.into()))), + green: on(Style::new().fg_color(Some(AnsiColor::Green.into()))), + red: on(Style::new().fg_color(Some(AnsiColor::Red.into()))), + yellow: on(Style::new().fg_color(Some(AnsiColor::Yellow.into()))), + magenta: on(Style::new().fg_color(Some(AnsiColor::Magenta.into()))), + warn: on(Style::new().bold().fg_color(Some(AnsiColor::Yellow.into()))), + } + } + + /// Build an arbitrary foreground color [`Style`] honoring the + /// theme's enabled flag. Useful when a renderer maps domain + /// state (status code, severity, …) to a color and the named + /// fields above don't cover it. + #[must_use] + pub fn fg(&self, color: AnsiColor) -> Style { + if self.enabled { + Style::new().fg_color(Some(color.into())) + } else { + Style::new() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn disabled_theme_emits_no_escape_sequences() { + let theme = Theme::new(false); + assert_eq!(theme.reset, ""); + assert_eq!(format!("{}text{:#}", theme.bold, theme.bold), "text"); + assert_eq!(format!("{}text{:#}", theme.cyan, theme.cyan), "text"); + assert_eq!( + format!( + "{}text{:#}", + theme.fg(AnsiColor::Blue), + theme.fg(AnsiColor::Blue) + ), + "text", + ); + } + + #[test] + fn enabled_theme_wraps_with_codes() { + let theme = Theme::new(true); + assert_eq!(theme.reset, "\x1b[0m"); + // anstyle's `{:#}` prints the reset; we just need codes + // surrounding the payload. + let rendered = format!("{}text{}", theme.bold, theme.reset); + assert!(rendered.starts_with("\x1b["), "got {rendered:?}"); + assert!(rendered.contains("text")); + assert!(rendered.ends_with("\x1b[0m")); + } + + #[test] + fn fg_respects_enabled_flag() { + assert_eq!(format!("{}", Theme::new(false).fg(AnsiColor::Red)), ""); + assert!(!format!("{}", Theme::new(true).fg(AnsiColor::Red)).is_empty()); + } +} diff --git a/crates/mergify-tui/src/time.rs b/crates/mergify-tui/src/time.rs new file mode 100644 index 00000000..1d1ef49f --- /dev/null +++ b/crates/mergify-tui/src/time.rs @@ -0,0 +1,109 @@ +//! Coarse relative-time formatter. +//! +//! Mirrors the Python CLI's `_relative_time` helper: an ISO-8601 / +//! RFC-3339 timestamp becomes a short delta like `5m ago` or +//! `~2h`. The granularity is intentionally coarse — seconds / +//! minutes / hours / days, single component — because these +//! numbers show up in dense table layouts where exact fidelity +//! adds noise without information. +//! +//! Unparseable input returns an empty string. Callers treat that +//! as "skip this column" so a single malformed timestamp doesn't +//! abort the whole render. + +use chrono::DateTime; +use chrono::Utc; + +/// Format an ISO-8601 / RFC-3339 timestamp as a coarse delta from +/// `now`. +/// +/// - Past timestamps render as `" ago"`. +/// - Future timestamps render as `"~"` when `future = true` +/// (callers use this for ETAs to distinguish them visually from +/// "happened" times). +/// - Granularity collapses to the largest non-zero unit (`Ns`, +/// `Nm`, `Nh`, or `Nd`). +/// - Returns `""` when `iso` is not a valid RFC-3339 timestamp. +#[must_use] +pub 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") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(iso: &str) -> DateTime { + DateTime::parse_from_rfc3339(iso) + .unwrap() + .with_timezone(&Utc) + } + + #[test] + fn seconds() { + assert_eq!( + relative_time("2026-01-01T00:00:30Z", at("2026-01-01T00:01:00Z"), false), + "30s ago", + ); + } + + #[test] + fn minutes() { + assert_eq!( + relative_time("2026-01-01T00:55:00Z", at("2026-01-01T01:00:00Z"), false), + "5m ago", + ); + } + + #[test] + fn hours() { + assert_eq!( + relative_time("2026-01-01T00:00:00Z", at("2026-01-01T05:00:00Z"), false), + "5h ago", + ); + } + + #[test] + fn days() { + assert_eq!( + relative_time("2026-01-01T00:00:00Z", at("2026-01-08T00:00:00Z"), false), + "7d ago", + ); + } + + #[test] + fn future_prefix() { + assert_eq!( + relative_time("2026-01-01T00:30:00Z", at("2026-01-01T00:00:00Z"), true), + "~30m", + ); + } + + #[test] + fn unparseable_returns_empty() { + // Mirrors the Python CLI: skip the column rather than + // abort the render on bad input. + assert_eq!( + relative_time("not-a-date", at("2026-01-01T00:00:00Z"), false), + "", + ); + } +} diff --git a/crates/mergify-tui/src/tree.rs b/crates/mergify-tui/src/tree.rs new file mode 100644 index 00000000..011da7d2 --- /dev/null +++ b/crates/mergify-tui/src/tree.rs @@ -0,0 +1,65 @@ +//! Unicode box-drawing characters for indented tree output. +//! +//! Each tree row pairs a *branch* prefix (the connector for the +//! row itself) with a *continuation* prefix (the column drawn +//! beneath the row to keep the visual lineage clear). Whether a +//! row is the last child of its parent flips both: +//! +//! ```text +//! parent +//! ├── child A ← BRANCH ┐ +//! │ └── grandchild ← CONTINUATION + LAST_ │ child A is not last, +//! ├── child B │ so its continuation +//! │ ├── grandchild │ column draws `│ ` +//! │ └── grandchild ┘ +//! └── child C ← LAST_BRANCH ┐ +//! └── grandchild ← LAST_CONTINUATION+LAST│ last child: column +//! │ collapses to spaces +//! ``` +//! +//! Use [`branch_chars`] when you have a `(is_last, ...)` decision +//! and want both prefixes back in one call. + +/// Branch connector for a non-last child: `├── `. +pub const BRANCH: &str = "├── "; + +/// Branch connector for the last child of its parent: `└── `. +pub const LAST_BRANCH: &str = "└── "; + +/// Continuation column under a non-last child (keeps the vertical +/// pipe drawn so descendants stay visually attached): `│ `. +pub const CONTINUATION: &str = "│ "; + +/// Continuation column under the last child (no more vertical +/// pipe — the lineage stops here): ` ` (four spaces). +pub const LAST_CONTINUATION: &str = " "; + +/// Pick the `(branch, continuation)` pair for a row based on +/// whether it's the last child of its parent. +#[must_use] +pub fn branch_chars(is_last: bool) -> (&'static str, &'static str) { + if is_last { + (LAST_BRANCH, LAST_CONTINUATION) + } else { + (BRANCH, CONTINUATION) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn last_child_uses_corner_and_blank_continuation() { + let (branch, cont) = branch_chars(true); + assert_eq!(branch, "└── "); + assert_eq!(cont, " "); + } + + #[test] + fn middle_child_uses_tee_and_pipe_continuation() { + let (branch, cont) = branch_chars(false); + assert_eq!(branch, "├── "); + assert_eq!(cont, "│ "); + } +} 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..e1f6c828 100644 --- a/mergify_cli/tests/queue/test_skill.py +++ b/mergify_cli/tests/queue/test_skill.py @@ -16,7 +16,10 @@ import pathlib import re +import shutil +import subprocess +import pytest import yaml @@ -29,6 +32,37 @@ def _get_skill_content() -> str: return SKILL_PATH.read_text(encoding="utf-8") +def _native_commands_for_group(group: str) -> set[str]: + """Ask the installed `mergify` binary which ` ` pairs + it handles natively, then return the subcommands for `group`. + + Spawning the binary keeps this test honest: the source of truth + is the binary itself, so a port that adds a native subcommand + (and its `NATIVE_COMMANDS` entry) automatically becomes visible + here. No parallel hard-coded list to drift. + + Skips when `mergify` isn't on PATH — that's the case when tests + run before the package is installed (rare; `uv run pytest` + installs it first). + """ + binary = shutil.which("mergify") + if binary is None: + pytest.skip("`mergify` binary not on PATH; install the wheel first") + out = subprocess.run( + [binary, "--list-native-commands"], + check=True, + capture_output=True, + text=True, + ).stdout + pairs = (line.split(maxsplit=1) for line in out.splitlines() if line.strip()) + return { + sub + for pair in pairs + if len(pair) == 2 and pair[0] == group + for sub in [pair[1]] + } + + def test_skill_content_is_readable() -> None: content = _get_skill_content() assert len(content) > 0 @@ -63,17 +97,22 @@ def test_skill_has_required_sections() -> None: 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 + Rust-native command reported by the binary. Catches typos and + skill drift after a port — and stays accurate without a parallel + hard-coded list because the native set is queried from + `mergify --list-native-commands` itself. + """ 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_commands_for_group("queue") 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 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)