From dd391d3b8d02296a26e0970a9ab7dd0374a3f364 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Thu, 23 Apr 2026 17:35:28 +0200 Subject: [PATCH 1/5] feat(rust): port queue pause and unpause to native Rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two queue commands in one PR — both are idempotent one-shot API calls that share the same auth + repository resolution. Pause exercises the new PUT method; unpause exercises the new DELETE-with-status-check method. PUTs ``{"reason": "..."}`` to ``/v1/repos//merge-queue/pause``, prints a confirmation line with the reason and timestamp. Safety rails match Python: - ``--yes-i-am-sure`` skips confirmation outright. - Interactive (TTY): prompts "Proceed? [y/N]". Anything other than ``y``/``yes`` aborts as a generic error. - Non-interactive without the flag: refuses with INVALID_STATE (exit 7), matching Python's ``raise SystemExit(ExitCode.INVALID_STATE)``. ``--reason`` has a 255-char cap enforced by clap's ``value_parser`` — bad input exits 2. DELETEs the same path. On 404 the API is telling us the queue wasn't paused, so the command prints "Queue is not currently paused" and exits MERGIFY_API_ERROR (matches Python). On 2xx it prints "Queue resumed." and exits 0. Two new methods on ``mergify_core::HttpClient``: - ``put(path, body) -> Result`` — mirror of ``post``, different verb. - ``delete_if_exists(path) -> Result`` — returns ``Deleted`` on 2xx, ``NotFound`` on 404, errors on any other non-success status. Lets commands like ``unpause`` give a friendlier 404 message without parsing error strings. Auth resolution (token / api-url / repository) goes through the shared ``mergify_core::auth`` module added earlier in the stack — no per-crate copy of the resolvers in ``mergify-queue``. 5 new unit tests in the queue crate: - ``parse_reason`` accepts short strings and rejects > 255 chars - ``run`` pauses and prints the API-returned reason + timestamp - ``run`` prints "Queue resumed" on 2xx - ``run`` errors with MERGIFY_API_ERROR on 404 carrying the "not currently paused" message End-to-end smoke tested three paths: ``queue pause --reason X -r owner/repo`` → exit 8 (missing token), ``queue unpause -r owner/repo`` → exit 8 (missing token), ``echo n | queue pause --reason X`` → exit 7 (non-TTY, no --sure). The Python ``queue pause`` / ``queue unpause`` implementations and their tests are removed in the same PR — the Rust binary now owns both commands end-to-end. Binary: 8.4 MB → 8.5 MB. 56 Rust tests. Co-Authored-By: Claude Opus 4.7 (1M context) Change-Id: Idba6fa38caf403fd5f4184cda462b5f7c1eb3ebf --- Cargo.lock | 99 ++++----- crates/mergify-cli/Cargo.toml | 1 + crates/mergify-cli/src/main.rs | 134 +++++++++++- crates/mergify-core/src/http.rs | 116 ++++++++++- crates/mergify-core/src/lib.rs | 2 +- crates/mergify-queue/Cargo.toml | 23 +++ crates/mergify-queue/src/lib.rs | 11 + crates/mergify-queue/src/pause.rs | 287 ++++++++++++++++++++++++++ crates/mergify-queue/src/unpause.rs | 145 +++++++++++++ mergify_cli/queue/api.py | 28 --- mergify_cli/queue/cli.py | 88 -------- mergify_cli/tests/queue/test_cli.py | 123 ----------- mergify_cli/tests/queue/test_skill.py | 17 +- 13 files changed, 759 insertions(+), 315 deletions(-) create mode 100644 crates/mergify-queue/Cargo.toml create mode 100644 crates/mergify-queue/src/lib.rs create mode 100644 crates/mergify-queue/src/pause.rs create mode 100644 crates/mergify-queue/src/unpause.rs diff --git a/Cargo.lock b/Cargo.lock index a1c8cd73..cf4bcc79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -184,9 +184,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", @@ -416,9 +416,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 +560,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", @@ -846,16 +846,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 +919,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 +970,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" @@ -1046,6 +1036,7 @@ dependencies = [ "mergify-config", "mergify-core", "mergify-py-shim", + "mergify-queue", "tokio", ] @@ -1088,6 +1079,18 @@ dependencies = [ "thiserror", ] +[[package]] +name = "mergify-queue" +version = "0.0.0" +dependencies = [ + "mergify-core", + "serde", + "serde_json", + "tokio", + "url", + "wiremock", +] + [[package]] name = "micromap" version = "0.3.0" @@ -1552,9 +1555,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 +1581,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", @@ -1973,20 +1976,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]] @@ -2129,11 +2132,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 +2150,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 +2163,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 +2173,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 +2183,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 +2196,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 +2239,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", @@ -2457,6 +2460,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 +2475,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-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..599c9379 100644 --- a/crates/mergify-cli/src/main.rs +++ b/crates/mergify-cli/src/main.rs @@ -24,6 +24,8 @@ 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::unpause::UnpauseOptions; fn main() -> ExitCode { let argv: Vec = env::args().skip(1).collect(); @@ -63,6 +65,8 @@ enum NativeCommand { ConfigValidate { config_file: Option }, ConfigSimulate(ConfigSimulateOpts), CiScopesSend(CiScopesSendOpts), + QueuePause(QueuePauseOpts), + QueueUnpause(QueueUnpauseOpts), } struct ConfigSimulateOpts { @@ -83,13 +87,26 @@ 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, +} + /// 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. @@ -97,7 +114,9 @@ fn looks_native(argv: &[String]) -> bool { argv.windows(2).any(|pair| { matches!( (pair[0].as_str(), pair[1].as_str()), - ("config", "validate" | "simulate") | ("ci", "scopes-send"), + ("config", "validate" | "simulate") + | ("ci", "scopes-send") + | ("queue", "pause" | "unpause"), ) }) } @@ -121,12 +140,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 +219,32 @@ fn detect_native(argv: &[String]) -> Option { scopes_file, file_deprecated, })), + 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, + })), } } @@ -250,6 +295,30 @@ fn run_native(cmd: NativeCommand) -> ExitCode { ) .await } + 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 + } } }); @@ -277,6 +346,8 @@ enum Subcommands { Config(ConfigArgs), /// Mergify CI-related commands. Ci(CiArgs), + /// Manage the Mergify merge queue. + Queue(QueueArgs), } #[derive(clap::Args)] @@ -372,3 +443,44 @@ 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, +} + +#[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, +} 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..bdd0ceca --- /dev/null +++ b/crates/mergify-queue/Cargo.toml @@ -0,0 +1,23 @@ +[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" } +serde = { version = "1.0", features = ["derive"] } +url = "2" + +[dev-dependencies] +serde_json = "1.0" +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..1de93ff8 --- /dev/null +++ b/crates/mergify-queue/src/lib.rs @@ -0,0 +1,11 @@ +//! Native Rust implementation of the `mergify queue` subcommands. +//! +//! Phase 1.5 ports `pause` and `unpause` — two idempotent API +//! calls that rest on the HTTP client added in 1.2b and the new +//! `put`/`delete_if_exists` methods added alongside this crate. +//! `queue status` and `queue show` stay shimmed until their +//! JSON-output contracts are locked (they carry considerable +//! structured data and want careful schema work). + +pub mod pause; +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/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/mergify_cli/queue/api.py b/mergify_cli/queue/api.py index f48d79b4..8c2b3fa1 100644 --- a/mergify_cli/queue/api.py +++ b/mergify_cli/queue/api.py @@ -55,12 +55,6 @@ class QueuePause(typing.TypedDict): 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]] @@ -137,25 +131,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..7acb5216 100644 --- a/mergify_cli/queue/cli.py +++ b/mergify_cli/queue/cli.py @@ -8,7 +8,6 @@ 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 @@ -433,93 +432,6 @@ async def status(ctx: click.Context, *, branch: str | None, output_json: bool) - _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/queue/test_cli.py b/mergify_cli/tests/queue/test_cli.py index 97e5ea69..387380aa 100644 --- a/mergify_cli/tests/queue/test_cli.py +++ b/mergify_cli/tests/queue/test_cli.py @@ -8,7 +8,6 @@ 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 @@ -404,125 +403,3 @@ def test_checks_omitted_when_zero(self) -> None: ) 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..1982d735 100644 --- a/mergify_cli/tests/queue/test_skill.py +++ b/mergify_cli/tests/queue/test_skill.py @@ -62,18 +62,25 @@ def test_skill_has_required_sections() -> None: assert section in content, f"Skill is missing required section: {section}" +# Rust-native queue commands. Each port PR appends to this list when +# it deletes the Python copy, so the validation below stays accurate +# without needing to spawn the Rust binary at test time. +NATIVE_QUEUE_COMMANDS: frozenset[str] = frozenset({"pause", "unpause"}) + + def test_skill_references_valid_commands() -> None: - """Check that commands referenced in the skill exist in the CLI.""" + """Every `mergify queue ` reference in the skill must resolve + to either a registered click command (still-shimmed) or a known + Rust-native command. Catches typos and skill drift after a port.""" from mergify_cli.queue.cli import queue content = _get_skill_content() - # Extract `mergify queue ` references referenced = set(re.findall(r"mergify queue ([\w-]+)", content)) - - available = set(queue.commands.keys()) + available = set(queue.commands.keys()) | NATIVE_QUEUE_COMMANDS for cmd in referenced: assert cmd in available, ( - f"Skill references 'mergify queue {cmd}' but it's not a registered command. " + f"Skill references 'mergify queue {cmd}' but it's neither a " + f"registered click command nor a known Rust-native command. " f"Available: {sorted(available)}" ) From cc96b9ab7eb85d6ad36e04519a6872b99c61a249 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Thu, 23 Apr 2026 21:02:22 +0200 Subject: [PATCH 2/5] feat(rust): port ci git-refs and ci queue-info to native Rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `github_event` (GitHub Actions event payload deserialization) and `queue_metadata` (MQ YAML fenced-block extraction) shared modules to the mergify-ci crate, and ports two commands on top of them: - `ci queue-info` — prints MQ batch metadata as pretty JSON; errors INVALID_STATE (exit 7) outside an MQ context. Appends to `$GITHUB_OUTPUT` with the same `ghadelimiter_` heredoc the Python version uses. - `ci git-refs` — detects base/head refs from Buildkite env, GitHub event payload, `refs/notes/mergify/` git notes, MQ PR body, or falls back to `HEAD^..HEAD`. Supports `text`/`shell`/`json` output formats, writes `base`/`head` to `$GITHUB_OUTPUT`, and calls `buildkite-agent meta-data set` when `BUILDKITE=true`. The notes reader is injected as a trait-object callback so unit tests can exercise the note-driven detection path without touching a real git repository; the production path shells out via `real_notes_reader`. The Python implementations of `ci git-refs` and `ci queue-info` are removed in the same PR — the Rust binary now owns both commands end-to-end. The looks-native heuristic in `main.rs` learns the two new subcommand names so argv errors surface as clap exit 2 instead of silently falling through to the Python shim. Adds `serde_yaml_ng` (YAML parser) and `uuid` (ghadelimiter) deps to the mergify-ci crate. Binary grows ~100KB to 8.6MB. 19 new Rust tests (8 event/metadata, 3 queue-info, 12 git-refs + 2 format round-trips merged in). Full workspace: 79 tests green, compat harness 4/4. Change-Id: I8d3f96e6cb4eb51e6cd195951b3e622cee7efdd4 --- Cargo.lock | 32 + crates/mergify-ci/Cargo.toml | 2 + crates/mergify-ci/src/git_refs.rs | 705 +++++++++++++++++++ crates/mergify-ci/src/github_event.rs | 112 +++ crates/mergify-ci/src/lib.rs | 16 +- crates/mergify-ci/src/queue_info.rs | 172 +++++ crates/mergify-ci/src/queue_metadata.rs | 204 ++++++ crates/mergify-cli/src/main.rs | 35 +- mergify_cli/ci/cli.py | 64 -- mergify_cli/tests/ci/test_cli.py | 196 ------ mergify_cli/tests/ci/test_cli_exit_codes.py | 16 - mergify_cli/tests/test_exit_code_contract.py | 17 - 12 files changed, 1271 insertions(+), 300 deletions(-) create mode 100644 crates/mergify-ci/src/git_refs.rs create mode 100644 crates/mergify-ci/src/github_event.rs create mode 100644 crates/mergify-ci/src/queue_info.rs create mode 100644 crates/mergify-ci/src/queue_metadata.rs diff --git a/Cargo.lock b/Cargo.lock index cf4bcc79..3da5d6e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1020,10 +1020,12 @@ dependencies = [ "mergify-core", "serde", "serde_json", + "serde_yaml_ng", "temp-env", "tempfile", "tokio", "url", + "uuid", "wiremock", ] @@ -1749,6 +1751,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" @@ -2047,6 +2062,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" @@ -2083,6 +2104,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" 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/src/main.rs b/crates/mergify-cli/src/main.rs index 599c9379..b3e658af 100644 --- a/crates/mergify-cli/src/main.rs +++ b/crates/mergify-cli/src/main.rs @@ -19,6 +19,8 @@ use std::process::ExitCode; use clap::Parser; use clap::Subcommand; +use mergify_ci::git_refs::Format as GitRefsFormat; +use mergify_ci::git_refs::GitRefsOptions; use mergify_ci::scopes_send::ScopesSendOptions; use mergify_config::simulate::PullRequestRef; use mergify_config::simulate::SimulateOptions; @@ -65,6 +67,8 @@ enum NativeCommand { ConfigValidate { config_file: Option }, ConfigSimulate(ConfigSimulateOpts), CiScopesSend(CiScopesSendOpts), + CiGitRefs { format: GitRefsFormat }, + CiQueueInfo, QueuePause(QueuePauseOpts), QueueUnpause(QueueUnpauseOpts), } @@ -115,7 +119,7 @@ fn looks_native(argv: &[String]) -> bool { matches!( (pair[0].as_str(), pair[1].as_str()), ("config", "validate" | "simulate") - | ("ci", "scopes-send") + | ("ci", "scopes-send" | "git-refs" | "queue-info") | ("queue", "pause" | "unpause"), ) }) @@ -219,6 +223,12 @@ 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, @@ -295,6 +305,10 @@ 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 { @@ -401,6 +415,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)] 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/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/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) From e9b1c706fc781c39da16d470ff3c653f800a4b7f Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Tue, 5 May 2026 14:08:44 +0200 Subject: [PATCH 3/5] feat(rust): port queue status to native Rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Rust binary now serves ``mergify queue status`` natively. The Python implementation (``mergify_cli/queue/cli.py:status`` plus the batch/scope/topology helpers it depended on) is removed in the same PR — the port-and-delete rule we adopted in #1322 keeps a single live copy of every command. ``mergify queue status [-r REPO] [-t TOKEN] [-u URL] [-b BRANCH] [--json]``: 1. Resolves repository / token / API URL via the shared ``mergify_queue::auth`` resolver introduced in #1352. 2. Fetches ``GET /v1/repos//merge-queue/status``, optionally with ``?branch=`` (URL-encoded via ``url::form_urlencoded::byte_serialize``). 3. With ``--json``: pretty-prints the raw response. The schema is Mergify's API contract, not this CLI's, so we deserialize into ``serde_json::Value`` and emit verbatim — unknown fields and future schema additions survive the round trip. 4. Without ``--json``: deserializes into a typed ``StatusView`` that uses ``#[serde(default)] Option<…>`` for every field the Mergify API has historically treated as optional/nullable (matches the port checklist from #1357), then renders 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 (``5m ago`` / ``~1h``) match the Python implementation. Python used Rich's ``Tree`` for batches. The Rust port emits flat indented text instead — same data, simpler rendering. Both are line-oriented and round-trip cleanly through pipes; the fancy box-drawing was visual sugar that didn't survive piping anyway. - ``build_path`` covers no-branch, branch, and URL-encoding of a branch name with slashes + spaces (e.g. ``feature/foo bar`` becomes ``feature%2Ffoo+bar`` in the query). - ``relative_time`` covers seconds / minutes / hours / days, future prefix, and graceful empty-string return on a malformed timestamp (matches Python's "degrade rather than fail"). - ``topological_sort`` covers parents-before-children ordering and tolerance of ``parent_ids`` that reference missing batches. - ``group_by_scope`` covers the ``[]`` → ``"default"`` fallback and multi-scope batches appearing under each scope they claim. - ``status_icon`` covers known + unknown codes. - End-to-end wiremock tests: empty queue, paused queue, batches + waiting PRs, multi-scope grouping, ``?branch=…`` query threading, JSON-passthrough preserving an ``extra_field``, and tolerance of a response that omits all optional fields. - ``crates/mergify-queue/Cargo.toml``: adds ``chrono`` (relative time math), ``indexmap`` (scope groups in insertion order), promotes ``serde_json`` from dev to runtime (used for the ``serde_json::Value`` passthrough). - ``crates/mergify-cli/src/main.rs``: registers the ``status`` subcommand under ``QueueSubcommand``, threads the ``--branch``/``--json`` flags, dispatches to ``mergify_queue::status::run``. Adds ``status`` to ``looks_native``. - ``mergify_cli/queue/api.py``: removes ``QueueStatusResponse``, ``QueueBatch``, ``QueuePause``, ``QueueChecksSummary``, ``QueueBatchStatus``, ``QueuePullRequest``, ``QueuePullRequestAuthor``, and ``get_queue_status`` — all now Rust-native. ``QueuePullResponse`` and friends stay for the still-shimmed ``queue show`` (next phase). - ``mergify_cli/queue/cli.py``: removes the ``@queue.command status`` block and the helpers it owned (``STATUS_STYLES``, ``_status_text``, ``_batch_label``, ``_pr_label``, ``_topological_sort``, ``_group_batches_by_scope``, ``_print_batches``, ``_print_waiting_prs``). ``_relative_time`` stays — ``show`` still uses it. - ``mergify_cli/tests/queue/test_cli.py``: deletes ``TestStatusCommand``, ``TestTopologicalSort``, and the ``_invoke_status`` helper. ``TestRelativeTime`` stays. Workspace: 138 Rust tests green, 590 Python tests green. Co-Authored-By: Claude Opus 4.7 (1M context) Change-Id: I8cebcd325f05173dfa41083da2ec6516a6ec3a3f --- Cargo.lock | 100 +++ crates/mergify-cli/src/main.rs | 50 +- crates/mergify-queue/Cargo.toml | 5 +- crates/mergify-queue/src/lib.rs | 13 +- crates/mergify-queue/src/status.rs | 1069 +++++++++++++++++++++++++ mergify_cli/queue/api.py | 71 -- mergify_cli/queue/cli.py | 185 ----- mergify_cli/tests/queue/test_cli.py | 351 -------- mergify_cli/tests/queue/test_skill.py | 2 +- 9 files changed, 1231 insertions(+), 615 deletions(-) create mode 100644 crates/mergify-queue/src/status.rs diff --git a/Cargo.lock b/Cargo.lock index 3da5d6e7..62f2331c 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" @@ -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" @@ -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" @@ -1085,6 +1129,9 @@ dependencies = [ name = "mergify-queue" version = "0.0.0" dependencies = [ + "anstyle", + "chrono", + "indexmap", "mergify-core", "serde", "serde_json", @@ -2307,12 +2354,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" diff --git a/crates/mergify-cli/src/main.rs b/crates/mergify-cli/src/main.rs index b3e658af..53f47f99 100644 --- a/crates/mergify-cli/src/main.rs +++ b/crates/mergify-cli/src/main.rs @@ -27,6 +27,7 @@ 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 { @@ -71,6 +72,7 @@ enum NativeCommand { CiQueueInfo, QueuePause(QueuePauseOpts), QueueUnpause(QueueUnpauseOpts), + QueueStatus(QueueStatusOpts), } struct ConfigSimulateOpts { @@ -105,6 +107,14 @@ struct QueueUnpauseOpts { 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? /// @@ -120,7 +130,7 @@ fn looks_native(argv: &[String]) -> bool { (pair[0].as_str(), pair[1].as_str()), ("config", "validate" | "simulate") | ("ci", "scopes-send" | "git-refs" | "queue-info") - | ("queue", "pause" | "unpause"), + | ("queue", "pause" | "unpause" | "status"), ) }) } @@ -255,6 +265,18 @@ fn detect_native(argv: &[String]) -> Option { 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, + })), } } @@ -333,6 +355,19 @@ fn run_native(cmd: NativeCommand) -> ExitCode { ) .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 + } } }); @@ -504,6 +539,8 @@ enum QueueSubcommand { Pause(PauseCliArgs), /// Unpause the merge queue for the repository. Unpause, + /// Show merge queue status for the repository. + Status(StatusCliArgs), } #[derive(clap::Args)] @@ -517,3 +554,14 @@ struct PauseCliArgs { #[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-queue/Cargo.toml b/crates/mergify-queue/Cargo.toml index bdd0ceca..bd0761b3 100644 --- a/crates/mergify-queue/Cargo.toml +++ b/crates/mergify-queue/Cargo.toml @@ -11,11 +11,14 @@ publish = false [dependencies] mergify-core = { path = "../mergify-core" } +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] -serde_json = "1.0" tokio = { version = "1", default-features = false, features = ["macros", "rt", "time"] } wiremock = "0.6" diff --git a/crates/mergify-queue/src/lib.rs b/crates/mergify-queue/src/lib.rs index 1de93ff8..96455cb4 100644 --- a/crates/mergify-queue/src/lib.rs +++ b/crates/mergify-queue/src/lib.rs @@ -1,11 +1,14 @@ //! Native Rust implementation of the `mergify queue` subcommands. //! -//! Phase 1.5 ports `pause` and `unpause` — two idempotent API -//! calls that rest on the HTTP client added in 1.2b and the new +//! 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. -//! `queue status` and `queue show` stay shimmed until their -//! JSON-output contracts are locked (they carry considerable -//! structured data and want careful schema work). +//! 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/status.rs b/crates/mergify-queue/src/status.rs new file mode 100644 index 00000000..881e33b1 --- /dev/null +++ b/crates/mergify-queue/src/status.rs @@ -0,0 +1,1069 @@ +//! `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::IsTerminal; +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 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(()) + }) +} + +/// ANSI styling — opt-in based on stdout being a TTY and `NO_COLOR` +/// being unset (the de-facto standard, ). +/// +/// `anstyle::Style::new()` (the disabled variant for every field) +/// emits no escape sequences in its `Display` impl, so the same +/// formatting code paths produce plain text in non-TTY contexts. +struct Theme { + enabled: bool, + bold: Style, + dim: Style, + /// SGR reset escape, or empty when colors are disabled. Using a + /// `&'static str` instead of `anstyle::Reset` keeps both the + /// styled and plain code paths free of escape sequences when + /// `enabled = false`. + reset: &'static str, + pr_number: Style, + author: Style, + priority: Style, + relative: Style, + pause_warn: Style, +} + +impl Theme { + fn detect() -> Self { + // `cfg!(test)` makes the unit tests deterministic: when + // `cargo test` runs from a terminal the parent stdout *is* a + // TTY, but tests write into in-memory buffers and asserting + // on raw output shouldn't depend on the developer's terminal. + let enabled = !cfg!(test) + && std::io::stdout().is_terminal() + && std::env::var_os("NO_COLOR").is_none(); + Self::new(enabled) + } + + 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 { "" }, + pr_number: on(Style::new().fg_color(Some(AnsiColor::Cyan.into()))), + author: on(Style::new().dimmed()), + priority: on(Style::new().fg_color(Some(AnsiColor::Magenta.into()))), + relative: on(Style::new().dimmed()), + pause_warn: on(Style::new().bold().fg_color(Some(AnsiColor::Yellow.into()))), + } + } + + /// Return the per-state foreground color for a batch-status icon. + /// Mirrors Python's `STATUS_STYLES` map; unknown codes render + /// with no color (the default terminal foreground). + fn icon_style(&self, code: &str) -> Style { + if !self.enabled { + return Style::new(); + } + let color = match code { + "running" | "merged" => Some(AnsiColor::Green), + "failed" => Some(AnsiColor::Red), + "bisecting" + | "preparing" + | "waiting_for_previous_batches" + | "waiting_for_requeue" + | "waiting_schedule" => Some(AnsiColor::Yellow), + "waiting_for_merge" | "frozen" => Some(AnsiColor::Cyan), + _ => None, + }; + match color { + Some(c) => Style::new().fg_color(Some(c.into())), + None => Style::new().dimmed(), + } + } +} + +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.pause_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 is_last_batch = bi == last_batch_idx; + // `├──`/`└──` mark the batch row; the continuation + // column is `│ ` for non-last batches and ` ` for + // the last so the tree closes cleanly. + let branch = if is_last_batch { + "└── " + } else { + "├── " + }; + let continuation = if is_last_batch { " " } else { "│ " }; + 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 = theme.icon_style(&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.relative, 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.relative, 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 = if pi == last_pr_idx { + "└── " + } else { + "├── " + }; + writeln!( + w, + "{continuation}{pr_branch}{N}#{num}{R} {title} {A}({author}){R}", + N = theme.pr_number, + num = pr.number, + title = pr.title, + A = theme.author, + 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.pr_number, + num = pr.number, + title = pr.title, + A = theme.author, + author = pr.author.login, + R = theme.reset, + )?; + if let Some(prio) = &pr.priority_alias { + write!(w, " {P}{prio}{R}", P = theme.priority, 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.relative, + 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.relative, 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" => "❄", + _ => "?", + } +} + +/// Format an ISO-8601/RFC-3339 timestamp as a relative duration +/// (`s`/`m`/`h`/`d`). Past timestamps render as `"… ago"`; future +/// timestamps as `"~…"` when `future = true`. +/// +/// Returns an empty string when the timestamp can't be parsed — +/// mirrors the Python implementation, which silently degrades on +/// malformed input rather than failing the whole render. +fn relative_time(iso: &str, now: DateTime, future: bool) -> String { + let Ok(parsed) = DateTime::parse_from_rfc3339(iso) else { + return String::new(); + }; + let parsed = parsed.with_timezone(&Utc); + let delta = (now - parsed).num_seconds().abs(); + let value = if delta < 60 { + format!("{delta}s") + } else if delta < 3600 { + format!("{}m", delta / 60) + } else if delta < 86400 { + format!("{}h", delta / 3600) + } else { + format!("{}d", delta / 86400) + }; + if future { + format!("~{value}") + } else { + format!("{value} ago") + } +} + +/// Topological sort of batches by `parent_ids`. Roots come first, +/// children follow their parents — matches the Python +/// `_topological_sort`. Cycles are impossible by API contract, but +/// the `visited` set makes us tolerant of them anyway. +fn topological_sort(batches: &[Batch]) -> Vec<&Batch> { + let id_to_batch: HashMap<&str, &Batch> = batches.iter().map(|b| (b.id.as_str(), b)).collect(); + let mut visited: HashSet<&str> = HashSet::new(); + let mut result: Vec<&Batch> = Vec::with_capacity(batches.len()); + + for batch in batches { + visit(batch.id.as_str(), &id_to_batch, &mut visited, &mut result); + } + result +} + +fn visit<'a>( + id: &'a str, + id_to_batch: &HashMap<&'a str, &'a Batch>, + visited: &mut HashSet<&'a str>, + result: &mut Vec<&'a Batch>, +) { + if !visited.insert(id) { + return; + } + let Some(batch) = id_to_batch.get(id) else { + return; + }; + for parent in &batch.parent_ids { + visit(parent.as_str(), id_to_batch, visited, result); + } + result.push(batch); +} + +/// Group batches by scope, preserving insertion order for the +/// scopes (matches Python dict iteration). A batch with no scopes +/// is grouped under `"default"` to match the Python fallback. A +/// batch with multiple scopes appears in every group it claims — +/// the Python implementation does the same so users see each batch +/// in every scope it affects. +fn group_by_scope<'a>(batches: &[&'a Batch]) -> IndexMap> { + let mut groups: IndexMap> = IndexMap::new(); + for batch in batches { + let scopes: Vec = if batch.scopes.is_empty() { + vec!["default".to_string()] + } else { + batch.scopes.clone() + }; + for scope in scopes { + groups.entry(scope).or_default().push(batch); + } + } + groups +} + +#[cfg(test)] +mod tests { + use mergify_core::OutputMode; + use mergify_core::StdioOutput; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::header; + use wiremock::matchers::method; + use wiremock::matchers::path; + use wiremock::matchers::query_param; + + use super::*; + + type SharedBytes = std::sync::Arc>>; + + struct Captured { + output: StdioOutput, + stdout: SharedBytes, + } + + fn make_output(mode: OutputMode) -> Captured { + let stdout: SharedBytes = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let stderr: SharedBytes = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let output = StdioOutput::with_sinks( + mode, + SharedWriter(std::sync::Arc::clone(&stdout)), + SharedWriter(std::sync::Arc::clone(&stderr)), + ); + Captured { output, stdout } + } + + fn stdout_string(cap: &Captured) -> String { + String::from_utf8(cap.stdout.lock().unwrap().clone()).unwrap() + } + + #[test] + fn build_path_no_branch() { + assert_eq!( + build_path("owner/repo", None), + "/v1/repos/owner/repo/merge-queue/status", + ); + } + + #[test] + fn build_path_with_branch() { + assert_eq!( + build_path("owner/repo", Some("main")), + "/v1/repos/owner/repo/merge-queue/status?branch=main", + ); + } + + #[test] + fn build_path_url_encodes_branch() { + // Slashes and unicode in branch names must survive a round + // trip through the URL — `feature/foo` is common, and + // browser-pasted names occasionally include UTF-8. + let path = build_path("owner/repo", Some("feature/foo bar")); + assert!(path.ends_with("?branch=feature%2Ffoo+bar"), "got {path}"); + } + + #[test] + fn relative_time_seconds() { + let now = DateTime::parse_from_rfc3339("2026-01-01T00:01:00Z") + .unwrap() + .with_timezone(&Utc); + assert_eq!(relative_time("2026-01-01T00:00:30Z", now, false), "30s ago"); + } + + #[test] + fn relative_time_minutes() { + let now = DateTime::parse_from_rfc3339("2026-01-01T01:00:00Z") + .unwrap() + .with_timezone(&Utc); + assert_eq!(relative_time("2026-01-01T00:55:00Z", now, false), "5m ago"); + } + + #[test] + fn relative_time_hours() { + let now = DateTime::parse_from_rfc3339("2026-01-01T05:00:00Z") + .unwrap() + .with_timezone(&Utc); + assert_eq!(relative_time("2026-01-01T00:00:00Z", now, false), "5h ago"); + } + + #[test] + fn relative_time_days() { + let now = DateTime::parse_from_rfc3339("2026-01-08T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + assert_eq!(relative_time("2026-01-01T00:00:00Z", now, false), "7d ago"); + } + + #[test] + fn relative_time_future_prefix() { + // ETA-style timestamps render as `~…` so users can + // distinguish "happened 5m ago" from "in 5m". + let now = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + assert_eq!(relative_time("2026-01-01T00:30:00Z", now, true), "~30m"); + } + + #[test] + fn relative_time_unparseable_returns_empty() { + // Mirrors Python: a malformed timestamp shouldn't fail the + // whole render — degrade gracefully so the rest of the + // status block still appears. + let now = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + assert_eq!(relative_time("not-a-date", now, false), ""); + } + + #[test] + fn topological_sort_orders_parents_before_children() { + // Construct three batches, child references parent. Even if + // the input is in reverse order, the sort must put the + // parent first. + let batches = vec![ + sample_batch("c", &["b"]), + sample_batch("b", &["a"]), + sample_batch("a", &[]), + ]; + let sorted = topological_sort(&batches); + let ids: Vec<&str> = sorted.iter().map(|b| b.id.as_str()).collect(); + assert_eq!(ids, vec!["a", "b", "c"]); + } + + #[test] + fn topological_sort_handles_missing_parent_ids() { + // When `parent_ids` references an id that isn't in the + // batches list (the API has dropped it for some reason), + // the sort skips it instead of panicking. + let batches = [sample_batch("only", &["nonexistent"])]; + let sorted = topological_sort(&batches); + assert_eq!(sorted.len(), 1); + assert_eq!(sorted[0].id, "only"); + } + + #[test] + fn group_by_scope_default_when_empty_scopes() { + let batches = [sample_batch("a", &[])]; + let refs: Vec<&Batch> = batches.iter().collect(); + let groups = group_by_scope(&refs); + assert_eq!(groups.len(), 1); + assert!(groups.contains_key("default")); + } + + #[test] + fn group_by_scope_assigns_to_each_listed_scope() { + // Matches Python: a multi-scope batch appears under each + // scope's group, not just the first. + let mut b = sample_batch("a", &[]); + b.scopes = vec!["foo".to_string(), "bar".to_string()]; + let batches = [b]; + let refs: Vec<&Batch> = batches.iter().collect(); + let groups = group_by_scope(&refs); + assert_eq!(groups.len(), 2); + assert!(groups.contains_key("foo")); + assert!(groups.contains_key("bar")); + } + + #[test] + fn status_icon_known_codes() { + assert_eq!(status_icon("running"), "●"); + assert_eq!(status_icon("merged"), "✓"); + assert_eq!(status_icon("failed"), "✗"); + } + + #[test] + fn status_icon_unknown_falls_back() { + assert_eq!(status_icon("brand-new-status"), "?"); + } + + fn sample_batch(id: &str, parents: &[&str]) -> Batch { + Batch { + id: id.to_string(), + parent_ids: parents.iter().copied().map(String::from).collect(), + scopes: Vec::new(), + status: BatchStatus { + code: "running".to_string(), + }, + started_at: None, + estimated_merge_at: None, + checks_summary: ChecksSummary { + passed: 0, + total: 0, + }, + pull_requests: Vec::new(), + } + } + + #[tokio::test] + async fn run_json_passes_response_through_verbatim() { + // JSON mode is a passthrough — every field the server sends, + // including ones we don't render, must survive intact. + // `extra_field` here proves we don't reshape on the way out. + let server = MockServer::start().await; + let response = serde_json::json!({ + "batches": [], + "waiting_pull_requests": [], + "scope_queues": {"default": []}, + "pause": null, + "extra_field": "preserved", + }); + Mock::given(method("GET")) + .and(path("/v1/repos/owner/repo/merge-queue/status")) + .and(header("Authorization", "Bearer t")) + .respond_with(ResponseTemplate::new(200).set_body_json(response.clone())) + .expect(1) + .mount(&server) + .await; + + let mut cap = make_output(OutputMode::Human); + let api_url = server.uri(); + run( + StatusOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + branch: None, + output_json: true, + }, + &mut cap.output, + ) + .await + .unwrap(); + + let stdout = stdout_string(&cap); + let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!(parsed, response); + } + + #[tokio::test] + async fn run_human_renders_paused_queue() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/repos/owner/repo/merge-queue/status")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "batches": [], + "waiting_pull_requests": [], + "scope_queues": {}, + "pause": {"reason": "deploy freeze", "paused_at": "2026-01-01T00:00:00Z"}, + }))) + .expect(1) + .mount(&server) + .await; + + let mut cap = make_output(OutputMode::Human); + let api_url = server.uri(); + run( + StatusOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + branch: None, + output_json: false, + }, + &mut cap.output, + ) + .await + .unwrap(); + + let stdout = stdout_string(&cap); + assert!(stdout.contains("Merge Queue: owner/repo"), "got {stdout}"); + assert!(stdout.contains("Queue is paused"), "got {stdout}"); + assert!(stdout.contains("deploy freeze"), "got {stdout}"); + assert!(stdout.contains("Queue is empty"), "got {stdout}"); + } + + #[tokio::test] + async fn run_human_renders_empty_queue() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/repos/owner/repo/merge-queue/status")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "batches": [], + "waiting_pull_requests": [], + "scope_queues": {}, + "pause": null, + }))) + .mount(&server) + .await; + + let mut cap = make_output(OutputMode::Human); + let api_url = server.uri(); + run( + StatusOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + branch: None, + output_json: false, + }, + &mut cap.output, + ) + .await + .unwrap(); + + let stdout = stdout_string(&cap); + assert!(stdout.contains("Queue is empty"), "got {stdout}"); + } + + #[tokio::test] + async fn run_human_renders_batches_and_waiting_prs() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/repos/owner/repo/merge-queue/status")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "batches": [{ + "id": "b1", + "name": "batch-1", + "status": {"code": "running"}, + "checks_summary": {"passed": 3, "total": 5}, + "started_at": "2026-01-01T00:00:00Z", + "estimated_merge_at": "2026-01-01T01:00:00Z", + "pull_requests": [ + { + "number": 42, + "title": "Add feature foo", + "url": "https://example.test/42", + "author": {"id": 1, "login": "alice"}, + "queued_at": "2026-01-01T00:00:00Z", + "priority_alias": "default", + "priority_rule_name": "default", + "labels": [], + "scopes": [], + }, + ], + "parent_ids": [], + }], + "waiting_pull_requests": [ + { + "number": 43, + "title": "Update deps", + "url": "https://example.test/43", + "author": {"id": 2, "login": "bob"}, + "queued_at": "2026-01-01T00:00:00Z", + "priority_alias": "high", + "priority_rule_name": "high", + "labels": [], + "scopes": [], + }, + ], + "scope_queues": {}, + "pause": null, + }))) + .mount(&server) + .await; + + let mut cap = make_output(OutputMode::Human); + let api_url = server.uri(); + run( + StatusOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + branch: None, + output_json: false, + }, + &mut cap.output, + ) + .await + .unwrap(); + + let stdout = stdout_string(&cap); + assert!(stdout.contains("Batches"), "got {stdout}"); + assert!(stdout.contains("running"), "got {stdout}"); + assert!(stdout.contains("checks 3/5"), "got {stdout}"); + assert!( + stdout.contains("#42 Add feature foo (alice)"), + "got {stdout}" + ); + assert!(stdout.contains("Waiting"), "got {stdout}"); + assert!(stdout.contains("#43"), "got {stdout}"); + assert!(stdout.contains("Update deps"), "got {stdout}"); + assert!(stdout.contains("bob"), "got {stdout}"); + assert!(stdout.contains("high"), "got {stdout}"); + } + + #[tokio::test] + async fn run_human_groups_batches_by_scope_when_multiple() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/repos/owner/repo/merge-queue/status")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "batches": [ + { + "id": "b1", + "status": {"code": "running"}, + "checks_summary": {"passed": 0, "total": 0}, + "pull_requests": [], + "scopes": ["frontend"], + "parent_ids": [], + }, + { + "id": "b2", + "status": {"code": "preparing"}, + "checks_summary": {"passed": 0, "total": 0}, + "pull_requests": [], + "scopes": ["backend"], + "parent_ids": [], + }, + ], + "waiting_pull_requests": [], + "scope_queues": {}, + "pause": null, + }))) + .mount(&server) + .await; + + let mut cap = make_output(OutputMode::Human); + let api_url = server.uri(); + run( + StatusOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + branch: None, + output_json: false, + }, + &mut cap.output, + ) + .await + .unwrap(); + + let stdout = stdout_string(&cap); + // Two scopes → each labelled by its own name (no + // generic "Batches" header). + assert!(stdout.contains("frontend"), "got {stdout}"); + assert!(stdout.contains("backend"), "got {stdout}"); + assert!(!stdout.contains("\nBatches\n"), "got {stdout}"); + } + + #[tokio::test] + async fn run_passes_branch_query_param() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/repos/owner/repo/merge-queue/status")) + .and(query_param("branch", "main")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "batches": [], + "waiting_pull_requests": [], + "scope_queues": {}, + "pause": null, + }))) + .expect(1) + .mount(&server) + .await; + + let mut cap = make_output(OutputMode::Human); + let api_url = server.uri(); + run( + StatusOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + branch: Some("main"), + output_json: false, + }, + &mut cap.output, + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn run_tolerates_missing_optional_fields() { + // The API has historically dropped optional fields entirely + // rather than serializing them as null. Deserialization + // must accept that — the response below has neither + // `pause` nor any of the per-batch optional timestamps. + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/repos/owner/repo/merge-queue/status")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "batches": [{ + "id": "b1", + "status": {"code": "running"}, + "checks_summary": {"passed": 0, "total": 0}, + "pull_requests": [], + }], + "waiting_pull_requests": [], + "scope_queues": {}, + }))) + .mount(&server) + .await; + + let mut cap = make_output(OutputMode::Human); + let api_url = server.uri(); + run( + StatusOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + branch: None, + output_json: false, + }, + &mut cap.output, + ) + .await + .unwrap(); + } + + struct SharedWriter(SharedBytes); + impl Write for SharedWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } +} diff --git a/mergify_cli/queue/api.py b/mergify_cli/queue/api.py index 8c2b3fa1..1b6b977f 100644 --- a/mergify_cli/queue/api.py +++ b/mergify_cli/queue/api.py @@ -7,77 +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 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] diff --git a/mergify_cli/queue/cli.py b/mergify_cli/queue/cli.py index 7acb5216..f52eb6d6 100644 --- a/mergify_cli/queue/cli.py +++ b/mergify_cli/queue/cli.py @@ -14,20 +14,6 @@ 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"), @@ -67,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() @@ -365,73 +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) - - @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/queue/test_cli.py b/mergify_cli/tests/queue/test_cli.py index 387380aa..d8451f93 100644 --- a/mergify_cli/tests/queue/test_cli.py +++ b/mergify_cli/tests/queue/test_cli.py @@ -1,71 +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.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: @@ -114,292 +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 diff --git a/mergify_cli/tests/queue/test_skill.py b/mergify_cli/tests/queue/test_skill.py index 1982d735..e701b83b 100644 --- a/mergify_cli/tests/queue/test_skill.py +++ b/mergify_cli/tests/queue/test_skill.py @@ -65,7 +65,7 @@ def test_skill_has_required_sections() -> None: # Rust-native queue commands. Each port PR appends to this list when # it deletes the Python copy, so the validation below stays accurate # without needing to spawn the Rust binary at test time. -NATIVE_QUEUE_COMMANDS: frozenset[str] = frozenset({"pause", "unpause"}) +NATIVE_QUEUE_COMMANDS: frozenset[str] = frozenset({"pause", "unpause", "status"}) def test_skill_references_valid_commands() -> None: From ae15b73d23a7786c88e37e181d7c7ea5ce542d36 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Tue, 5 May 2026 22:03:02 +0200 Subject: [PATCH 4/5] test: derive native queue commands from the binary, not a hardcoded list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer feedback (#1352): hardcoding the set of Rust-native queue subcommands in ``mergify_cli/tests/queue/test_skill.py`` made the skill-reference validation drift-prone — a port PR that forgot to update ``NATIVE_QUEUE_COMMANDS`` would silently pass even though the skill was referencing a command the binary couldn't handle. Two changes make the binary the single source of truth: 1. ``crates/mergify-cli/src/main.rs`` factors the ``(group, subcommand)`` pairs into a top-level ``NATIVE_COMMANDS`` const. ``looks_native`` iterates that const instead of a `match` arm. A new hidden flag ``--list-native-commands`` (intercepted before clap or the shim) prints one `` `` pair per line and exits ``0``. 2. ``test_skill.py`` queries the installed ``mergify`` binary via ``subprocess.run([…, "--list-native-commands"])`` to discover the native set, replacing the ``NATIVE_QUEUE_COMMANDS`` frozenset. The test skips cleanly when the binary isn't on ``PATH`` (rare; ``uv run pytest`` installs it first). The result: the next port PR adds an entry to ``NATIVE_COMMANDS`` in main.rs as part of its normal wiring, and ``test_skill.py`` picks it up automatically. No parallel list to maintain. Co-Authored-By: Claude Opus 4.7 (1M context) Change-Id: I74502fe8affcc58f26eaaa9d058668eb36fec83b --- crates/mergify-cli/src/main.rs | 38 ++++++++++++++++---- mergify_cli/tests/queue/test_skill.py | 52 +++++++++++++++++++++------ 2 files changed, 74 insertions(+), 16 deletions(-) diff --git a/crates/mergify-cli/src/main.rs b/crates/mergify-cli/src/main.rs index 53f47f99..12dca9ec 100644 --- a/crates/mergify-cli/src/main.rs +++ b/crates/mergify-cli/src/main.rs @@ -49,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); } @@ -62,6 +74,23 @@ 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 { @@ -126,12 +155,9 @@ struct QueueStatusOpts { /// 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" | "git-refs" | "queue-info") - | ("queue", "pause" | "unpause" | "status"), - ) + NATIVE_COMMANDS + .iter() + .any(|(g, s)| pair[0] == *g && pair[1] == *s) }) } diff --git a/mergify_cli/tests/queue/test_skill.py b/mergify_cli/tests/queue/test_skill.py index e701b83b..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 @@ -62,25 +96,23 @@ def test_skill_has_required_sections() -> None: assert section in content, f"Skill is missing required section: {section}" -# Rust-native queue commands. Each port PR appends to this list when -# it deletes the Python copy, so the validation below stays accurate -# without needing to spawn the Rust binary at test time. -NATIVE_QUEUE_COMMANDS: frozenset[str] = frozenset({"pause", "unpause", "status"}) - - def test_skill_references_valid_commands() -> None: """Every `mergify queue ` reference in the skill must resolve - to either a registered click command (still-shimmed) or a known - Rust-native command. Catches typos and skill drift after a port.""" + 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() referenced = set(re.findall(r"mergify queue ([\w-]+)", content)) - available = set(queue.commands.keys()) | NATIVE_QUEUE_COMMANDS + 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 neither a " - f"registered click command nor a known Rust-native command. " + f"registered click command nor a Rust-native command. " f"Available: {sorted(available)}" ) From 98399235bfb97195cb591096951d1c3442baaaff Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Wed, 6 May 2026 14:35:15 +0200 Subject: [PATCH 5/5] feat(rust): extract reusable terminal-UI primitives into mergify-tui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer feedback: the colored-tree rendering helpers in ``crates/mergify-queue/src/status.rs`` (Theme + relative-time formatter + box-drawing characters) are general-purpose and will be needed by every command with structured human-readable output (``queue show``, ``freeze list``, future ports). Centralize them in a new ``mergify-tui`` crate so each port consumes the same primitives instead of forking its own copy. The new crate has three modules: - ``theme``: ``Theme`` struct with TTY/``NO_COLOR``-aware enable/disable. Pre-built named styles (``bold``, ``dim``, ``cyan``/``green``/``red``/``yellow``/``magenta``, ``warn``) plus a ``fg(AnsiColor)`` helper for domain-specific palettes. ``Theme::detect`` is the production constructor; ``Theme::new(enabled)`` is for tests that need to exercise the styled or plain branch deterministically. - ``time``: ``relative_time(iso, now, future)`` — coarse ``Ns``/``Nm``/``Nh``/``Nd`` formatter mirroring the Python CLI's ``_relative_time``. Empty string on parse failure so a malformed timestamp doesn't abort the surrounding render. - ``tree``: Unicode box-drawing constants (``BRANCH``, ``LAST_BRANCH``, ``CONTINUATION``, ``LAST_CONTINUATION``) plus ``branch_chars(is_last)`` to pick both prefixes for a row in one call. ``mergify-queue::status`` is refactored to consume from ``mergify-tui``: the local ``Theme`` struct and ``relative_time`` function are deleted, the inline ``├── ``/``└── ``/``│ ``/ `` `` literals are replaced with ``tree::branch_chars`` calls, and the queue-specific ``batch_status_style`` helper now uses ``Theme::fg`` for its per-status color mapping. Tests: 11 new in ``mergify-tui`` (theme on/off, relative-time units + parse failure + future prefix, tree-character pairing). The 6 ``relative_time_*`` tests in ``mergify-queue`` are removed — their coverage moves with the function. Workspace count: queue 24 (was 30), tui 11. Co-Authored-By: Claude Opus 4.7 (1M context) Change-Id: Ibbdbce1e8272ab6d81b2dc8a194d527dd41d8744 --- Cargo.lock | 9 ++ crates/mergify-queue/Cargo.toml | 1 + crates/mergify-queue/src/status.rs | 218 +++++------------------------ crates/mergify-tui/Cargo.toml | 17 +++ crates/mergify-tui/src/lib.rs | 35 +++++ crates/mergify-tui/src/theme.rs | 138 ++++++++++++++++++ crates/mergify-tui/src/time.rs | 109 +++++++++++++++ crates/mergify-tui/src/tree.rs | 65 +++++++++ 8 files changed, 409 insertions(+), 183 deletions(-) create mode 100644 crates/mergify-tui/Cargo.toml create mode 100644 crates/mergify-tui/src/lib.rs create mode 100644 crates/mergify-tui/src/theme.rs create mode 100644 crates/mergify-tui/src/time.rs create mode 100644 crates/mergify-tui/src/tree.rs diff --git a/Cargo.lock b/Cargo.lock index 62f2331c..8e178302 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1133,6 +1133,7 @@ dependencies = [ "chrono", "indexmap", "mergify-core", + "mergify-tui", "serde", "serde_json", "tokio", @@ -1140,6 +1141,14 @@ dependencies = [ "wiremock", ] +[[package]] +name = "mergify-tui" +version = "0.0.0" +dependencies = [ + "anstyle", + "chrono", +] + [[package]] name = "micromap" version = "0.3.0" diff --git a/crates/mergify-queue/Cargo.toml b/crates/mergify-queue/Cargo.toml index bd0761b3..a1eae21e 100644 --- a/crates/mergify-queue/Cargo.toml +++ b/crates/mergify-queue/Cargo.toml @@ -11,6 +11,7 @@ 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" diff --git a/crates/mergify-queue/src/status.rs b/crates/mergify-queue/src/status.rs index 881e33b1..a94b0aba 100644 --- a/crates/mergify-queue/src/status.rs +++ b/crates/mergify-queue/src/status.rs @@ -26,7 +26,6 @@ use std::collections::HashMap; use std::collections::HashSet; -use std::io::IsTerminal; use std::io::Write; use anstyle::AnsiColor; @@ -39,6 +38,9 @@ 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; @@ -201,77 +203,23 @@ fn emit_human(output: &mut dyn Output, repository: &str, view: &StatusView) -> s }) } -/// ANSI styling — opt-in based on stdout being a TTY and `NO_COLOR` -/// being unset (the de-facto standard, ). -/// -/// `anstyle::Style::new()` (the disabled variant for every field) -/// emits no escape sequences in its `Display` impl, so the same -/// formatting code paths produce plain text in non-TTY contexts. -struct Theme { - enabled: bool, - bold: Style, - dim: Style, - /// SGR reset escape, or empty when colors are disabled. Using a - /// `&'static str` instead of `anstyle::Reset` keeps both the - /// styled and plain code paths free of escape sequences when - /// `enabled = false`. - reset: &'static str, - pr_number: Style, - author: Style, - priority: Style, - relative: Style, - pause_warn: Style, -} - -impl Theme { - fn detect() -> Self { - // `cfg!(test)` makes the unit tests deterministic: when - // `cargo test` runs from a terminal the parent stdout *is* a - // TTY, but tests write into in-memory buffers and asserting - // on raw output shouldn't depend on the developer's terminal. - let enabled = !cfg!(test) - && std::io::stdout().is_terminal() - && std::env::var_os("NO_COLOR").is_none(); - Self::new(enabled) - } - - 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 { "" }, - pr_number: on(Style::new().fg_color(Some(AnsiColor::Cyan.into()))), - author: on(Style::new().dimmed()), - priority: on(Style::new().fg_color(Some(AnsiColor::Magenta.into()))), - relative: on(Style::new().dimmed()), - pause_warn: on(Style::new().bold().fg_color(Some(AnsiColor::Yellow.into()))), - } +/// 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(); } - - /// Return the per-state foreground color for a batch-status icon. - /// Mirrors Python's `STATUS_STYLES` map; unknown codes render - /// with no color (the default terminal foreground). - fn icon_style(&self, code: &str) -> Style { - if !self.enabled { - return Style::new(); - } - let color = match code { - "running" | "merged" => Some(AnsiColor::Green), - "failed" => Some(AnsiColor::Red), - "bisecting" - | "preparing" - | "waiting_for_previous_batches" - | "waiting_for_requeue" - | "waiting_schedule" => Some(AnsiColor::Yellow), - "waiting_for_merge" | "frozen" => Some(AnsiColor::Cyan), - _ => None, - }; - match color { - Some(c) => Style::new().fg_color(Some(c.into())), - None => Style::new().dimmed(), - } + 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, } } @@ -285,7 +233,7 @@ fn print_pause( write!( w, "{W}⚠ Queue is paused: \"{reason}\"{R}", - W = theme.pause_warn, + W = theme.warn, R = theme.reset, )?; if let Some(ts) = &pause.paused_at { @@ -320,16 +268,7 @@ fn print_batches( let last_batch_idx = scope_batches.len() - 1; for (bi, batch) in scope_batches.iter().enumerate() { - let is_last_batch = bi == last_batch_idx; - // `├──`/`└──` mark the batch row; the continuation - // column is `│ ` for non-last batches and ` ` for - // the last so the tree closes cleanly. - let branch = if is_last_batch { - "└── " - } else { - "├── " - }; - let continuation = if is_last_batch { " " } else { "│ " }; + 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)?; } @@ -345,7 +284,7 @@ fn print_batch_line( now: DateTime, ) -> std::io::Result<()> { let icon = status_icon(&batch.status.code); - let icon_style = theme.icon_style(&batch.status.code); + let icon_style = batch_status_style(theme, &batch.status.code); write!( w, "{branch}{S}{icon} {code}{R}", @@ -366,13 +305,13 @@ fn print_batch_line( 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.relative, R = theme.reset)?; + 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.relative, R = theme.reset)?; + write!(w, " {D}ETA {rel}{R}", D = theme.dim, R = theme.reset)?; } } writeln!(w) @@ -389,18 +328,14 @@ fn print_batch_prs( } let last_pr_idx = batch.pull_requests.len() - 1; for (pi, pr) in batch.pull_requests.iter().enumerate() { - let pr_branch = if pi == last_pr_idx { - "└── " - } else { - "├── " - }; + let (pr_branch, _) = tree::branch_chars(pi == last_pr_idx); writeln!( w, "{continuation}{pr_branch}{N}#{num}{R} {title} {A}({author}){R}", - N = theme.pr_number, + N = theme.cyan, num = pr.number, title = pr.title, - A = theme.author, + A = theme.dim, author = pr.author.login, R = theme.reset, )?; @@ -419,31 +354,26 @@ fn print_waiting_prs( write!( w, " {N}#{num}{R} {title} {A}{author}{R}", - N = theme.pr_number, + N = theme.cyan, num = pr.number, title = pr.title, - A = theme.author, + A = theme.dim, author = pr.author.login, R = theme.reset, )?; if let Some(prio) = &pr.priority_alias { - write!(w, " {P}{prio}{R}", P = theme.priority, R = theme.reset)?; + 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.relative, - R = theme.reset, - )?; + 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.relative, R = theme.reset)?; + write!(w, " {D}ETA {rel}{R}", D = theme.dim, R = theme.reset)?; } } writeln!(w)?; @@ -468,35 +398,6 @@ fn status_icon(code: &str) -> &'static str { } } -/// Format an ISO-8601/RFC-3339 timestamp as a relative duration -/// (`s`/`m`/`h`/`d`). Past timestamps render as `"… ago"`; future -/// timestamps as `"~…"` when `future = true`. -/// -/// Returns an empty string when the timestamp can't be parsed — -/// mirrors the Python implementation, which silently degrades on -/// malformed input rather than failing the whole render. -fn relative_time(iso: &str, now: DateTime, future: bool) -> String { - let Ok(parsed) = DateTime::parse_from_rfc3339(iso) else { - return String::new(); - }; - let parsed = parsed.with_timezone(&Utc); - let delta = (now - parsed).num_seconds().abs(); - let value = if delta < 60 { - format!("{delta}s") - } else if delta < 3600 { - format!("{}m", delta / 60) - } else if delta < 86400 { - format!("{}h", delta / 3600) - } else { - format!("{}d", delta / 86400) - }; - if future { - format!("~{value}") - } else { - format!("{value} ago") - } -} - /// Topological sort of batches by `parent_ids`. Roots come first, /// children follow their parents — matches the Python /// `_topological_sort`. Cycles are impossible by API contract, but @@ -612,58 +513,9 @@ mod tests { assert!(path.ends_with("?branch=feature%2Ffoo+bar"), "got {path}"); } - #[test] - fn relative_time_seconds() { - let now = DateTime::parse_from_rfc3339("2026-01-01T00:01:00Z") - .unwrap() - .with_timezone(&Utc); - assert_eq!(relative_time("2026-01-01T00:00:30Z", now, false), "30s ago"); - } - - #[test] - fn relative_time_minutes() { - let now = DateTime::parse_from_rfc3339("2026-01-01T01:00:00Z") - .unwrap() - .with_timezone(&Utc); - assert_eq!(relative_time("2026-01-01T00:55:00Z", now, false), "5m ago"); - } - - #[test] - fn relative_time_hours() { - let now = DateTime::parse_from_rfc3339("2026-01-01T05:00:00Z") - .unwrap() - .with_timezone(&Utc); - assert_eq!(relative_time("2026-01-01T00:00:00Z", now, false), "5h ago"); - } - - #[test] - fn relative_time_days() { - let now = DateTime::parse_from_rfc3339("2026-01-08T00:00:00Z") - .unwrap() - .with_timezone(&Utc); - assert_eq!(relative_time("2026-01-01T00:00:00Z", now, false), "7d ago"); - } - - #[test] - fn relative_time_future_prefix() { - // ETA-style timestamps render as `~…` so users can - // distinguish "happened 5m ago" from "in 5m". - let now = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") - .unwrap() - .with_timezone(&Utc); - assert_eq!(relative_time("2026-01-01T00:30:00Z", now, true), "~30m"); - } - - #[test] - fn relative_time_unparseable_returns_empty() { - // Mirrors Python: a malformed timestamp shouldn't fail the - // whole render — degrade gracefully so the rest of the - // status block still appears. - let now = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") - .unwrap() - .with_timezone(&Utc); - assert_eq!(relative_time("not-a-date", now, false), ""); - } + // `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() { 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, "│ "); + } +}