From 7e85a94c9baad70ce5b16f5899729d09261b24e9 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Thu, 23 Apr 2026 17:35:28 +0200 Subject: [PATCH 1/4] feat(rust): port queue pause and unpause to native Rust (Phase 1.5) 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. The ``resolve_token`` / ``resolve_api_url`` / ``resolve_repository`` trio is duplicated into ``mergify_queue::auth``. That's now three copies in the tree (config simulate, ci scopes-send, queue). A follow-up PR factors them into ``mergify_core::auth`` as soon as a fourth command needs them. 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 | 14 ++ crates/mergify-cli/Cargo.toml | 1 + crates/mergify-cli/src/main.rs | 151 ++++++++++++-- crates/mergify-core/src/http.rs | 85 ++++++++ crates/mergify-core/src/lib.rs | 2 +- crates/mergify-queue/Cargo.toml | 24 +++ crates/mergify-queue/src/auth.rs | 62 ++++++ crates/mergify-queue/src/lib.rs | 12 ++ crates/mergify-queue/src/pause.rs | 275 ++++++++++++++++++++++++++ crates/mergify-queue/src/unpause.rs | 146 ++++++++++++++ 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 +- 14 files changed, 767 insertions(+), 261 deletions(-) create mode 100644 crates/mergify-queue/Cargo.toml create mode 100644 crates/mergify-queue/src/auth.rs 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 2a65a621..f6da9c1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -911,6 +911,7 @@ dependencies = [ "mergify-config", "mergify-core", "mergify-py-shim", + "mergify-queue", "tokio", ] @@ -952,6 +953,19 @@ dependencies = [ "thiserror", ] +[[package]] +name = "mergify-queue" +version = "0.0.0" +dependencies = [ + "mergify-core", + "serde", + "serde_json", + "temp-env", + "tokio", + "url", + "wiremock", +] + [[package]] name = "mio" version = "1.2.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 d0958a90..fabda31b 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(); @@ -47,6 +49,8 @@ enum NativeCommand { ConfigValidate { config_file: Option }, ConfigSimulate(ConfigSimulateOpts), CiScopesSend(CiScopesSendOpts), + QueuePause(QueuePauseOpts), + QueueUnpause(QueueUnpauseOpts), } struct ConfigSimulateOpts { @@ -67,24 +71,26 @@ struct CiScopesSendOpts { file_deprecated: Option, } -/// Try to recognize the invocation as a native command. -/// -/// Returns ``None`` when the argv doesn't look like a native -/// command — callers fall back to the Python shim, which produces -/// the same error messages as before the port started. When the -/// argv obviously targets a native command (contains ``config`` -/// and ``validate``/``simulate``) but clap can't parse it — e.g. -/// the user gave a bad flag or an invalid URL — this function -/// prints clap's formatted error to stderr and exits the process -/// with clap's exit code (2), matching the Python CLI's behavior -/// for argument errors. +struct QueuePauseOpts { + repository: Option, + token: Option, + api_url: Option, + reason: String, + yes_i_am_sure: bool, +} + +struct QueueUnpauseOpts { + repository: Option, + token: Option, + api_url: Option, +} + /// Heuristic: does argv look like the user intended a native -/// subcommand (`config validate`, `config simulate`, `ci -/// scopes-send`)? +/// subcommand? /// /// Used as a fallback when clap rejects the input — if the user -/// clearly meant a native command, surface clap's error rather than -/// silently dispatching to the Python shim. We look for two +/// clearly meant a native command, surface clap's error rather +/// than silently dispatching to the Python shim. We look for two /// *consecutive* tokens forming a `(group, subcommand)` pair so a /// flag value like `--repository config` doesn't accidentally /// classify the invocation as native. @@ -92,11 +98,31 @@ fn looks_native(argv: &[String]) -> bool { argv.windows(2).any(|pair| { matches!( (pair[0].as_str(), pair[1].as_str()), - ("config", "validate" | "simulate") | ("ci", "scopes-send"), + ("config", "validate" | "simulate") + | ("ci", "scopes-send") + | ("queue", "pause" | "unpause"), ) }) } +/// Try to recognize the invocation as a native command. +/// +/// Returns ``None`` when the argv doesn't look like a native +/// command — callers fall back to the Python shim, which produces +/// the same error messages as before the port started. When the +/// argv obviously targets a native command (per [`looks_native`]) +/// but clap can't parse it — e.g. the user gave an unknown flag +/// or omitted a required argument — this function prints clap's +/// formatted error to stderr and exits the process with clap's +/// exit code (2), matching the Python CLI's behavior for argument +/// errors. +/// +/// Argument *values* that are accepted by clap as `String` but +/// fail later domain validation (e.g. an `--api-url` that doesn't +/// parse as a URL) surface as [`mergify_core::CliError`] instead +/// — the corresponding exit code is the one chosen by the command +/// implementation (typically [`mergify_core::ExitCode::Configuration`] +/// = 8), not 2. fn detect_native(argv: &[String]) -> Option { let looks_native = looks_native(argv); @@ -154,6 +180,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, + })), } } @@ -204,6 +256,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 + } } }); @@ -231,6 +307,8 @@ enum Subcommands { Config(ConfigArgs), /// Mergify CI-related commands. Ci(CiArgs), + /// Manage the Mergify merge queue. + Queue(QueueArgs), } #[derive(clap::Args)] @@ -326,3 +404,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..15701557 100644 --- a/crates/mergify-core/src/http.rs +++ b/crates/mergify-core/src/http.rs @@ -42,6 +42,15 @@ pub enum ApiFlavor { Mergify, } +/// Outcome of [`Client::delete_if_exists`]. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum DeleteOutcome { + /// 2xx: the resource was deleted. + Deleted, + /// 404: the resource didn't exist (or was already gone). + NotFound, +} + /// Retry policy for transient failures. Only 5xx responses and /// connect/timeout errors are retried; 4xx responses are never /// retried — those are caller errors and retrying would hide bugs. @@ -149,6 +158,30 @@ impl Client { .map(drop) } + /// PUT `body` as JSON to `path` and deserialize the JSON + /// response as `T`. + pub async fn put( + &self, + path: &str, + body: &B, + ) -> Result { + let url = self.join(path)?; + let resp = self.execute_request(self.inner.put(url).json(body)).await?; + self.decode_json(resp).await + } + + /// DELETE `path`, returning whether the resource existed. + /// + /// Returns `Ok(DeleteOutcome::Deleted)` on 2xx responses and + /// `Ok(DeleteOutcome::NotFound)` on 404 — useful for idempotent + /// "turn this thing off if it's on" operations where 404 means + /// "nothing to do". 4xx-other and 5xx map to the normal API + /// errors. + pub async fn delete_if_exists(&self, path: &str) -> Result { + let url = self.join(path)?; + self.execute_status(self.inner.delete(url)).await + } + fn join(&self, path: &str) -> Result { // `Url::join` accepts absolute URLs and protocol-relative // paths (`//host/...`), which would let a caller-supplied @@ -164,6 +197,58 @@ impl Client { .map_err(|e| self.api_error(format!("invalid path {path:?}: {e}"))) } + /// Execute a request that cares only about the HTTP status. + /// + /// Used by [`Self::delete_if_exists`] — the response body (if + /// any) is discarded. + async fn execute_status( + &self, + builder: reqwest::RequestBuilder, + ) -> Result { + let mut backoff = self.retry.initial_backoff; + let mut last_message = String::from("HTTP request failed without response"); + + for attempt in 0..self.retry.max_attempts { + let Some(cloned) = builder.try_clone() else { + return Err(self.api_error( + "request body is not cloneable (streaming?) — cannot retry".into(), + )); + }; + let req = match &self.token { + Some(token) => cloned.bearer_auth(token), + None => cloned, + }; + + match req.send().await { + Ok(resp) => { + let status = resp.status(); + if status.is_success() { + return Ok(DeleteOutcome::Deleted); + } + if status == StatusCode::NOT_FOUND { + return Ok(DeleteOutcome::NotFound); + } + last_message = error_message(status, resp).await; + if status.is_server_error() && attempt + 1 < self.retry.max_attempts { + tokio::time::sleep(backoff).await; + backoff *= 2; + continue; + } + return Err(self.api_error(last_message)); + } + Err(e) if is_transient(&e) && attempt + 1 < self.retry.max_attempts => { + last_message = format!("network error: {e}"); + tokio::time::sleep(backoff).await; + backoff *= 2; + } + Err(e) => { + return Err(self.api_error(format!("request failed: {e}"))); + } + } + } + Err(self.api_error(last_message)) + } + async fn execute_request( &self, builder: reqwest::RequestBuilder, diff --git a/crates/mergify-core/src/lib.rs b/crates/mergify-core/src/lib.rs index 9c27f27a..f5b42788 100644 --- a/crates/mergify-core/src/lib.rs +++ b/crates/mergify-core/src/lib.rs @@ -22,7 +22,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..57d304d0 --- /dev/null +++ b/crates/mergify-queue/Cargo.toml @@ -0,0 +1,24 @@ +[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" +temp-env = { version = "0.3", features = ["async_closure"] } +tokio = { version = "1", default-features = false, features = ["macros", "rt", "time"] } +wiremock = "0.6" + +[lints] +workspace = true diff --git a/crates/mergify-queue/src/auth.rs b/crates/mergify-queue/src/auth.rs new file mode 100644 index 00000000..427944e1 --- /dev/null +++ b/crates/mergify-queue/src/auth.rs @@ -0,0 +1,62 @@ +//! Resolve `--token`, `--api-url`, `--repository` with the same +//! env-variable fallbacks as Python. +//! +//! Duplicates the same helpers that live in `mergify-config::simulate` +//! and `mergify-ci::scopes_send` today. Once a fourth command +//! needs them, they factor into `mergify-core::auth`. + +use std::env; + +use mergify_core::CliError; +use url::Url; + +const DEFAULT_API_URL: &str = "https://api.mergify.com"; + +/// Resolve the Mergify API bearer token. +/// +/// Precedence: explicit `--token`, then `MERGIFY_TOKEN`, then +/// `GITHUB_TOKEN`. Errors out when none of those are set. +pub fn resolve_token(explicit: Option<&str>) -> Result { + if let Some(value) = explicit.filter(|s| !s.is_empty()) { + return Ok(value.to_string()); + } + for env_name in ["MERGIFY_TOKEN", "GITHUB_TOKEN"] { + if let Ok(value) = env::var(env_name) { + if !value.is_empty() { + return Ok(value); + } + } + } + Err(CliError::Configuration( + "please set the 'MERGIFY_TOKEN' or 'GITHUB_TOKEN' environment variable, \ + or pass --token explicitly" + .to_string(), + )) +} + +/// Resolve the Mergify API base URL. Falls back to `MERGIFY_API_URL` +/// env var, then to the default `https://api.mergify.com`. +pub fn resolve_api_url(explicit: Option<&str>) -> Result { + let raw = explicit + .map(str::to_string) + .or_else(|| env::var("MERGIFY_API_URL").ok()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| DEFAULT_API_URL.to_string()); + Url::parse(&raw).map_err(|e| CliError::Configuration(format!("invalid --api-url {raw:?}: {e}"))) +} + +/// Resolve the repository (owner/repo) identifier. Falls back to +/// the `GITHUB_REPOSITORY` env var. +pub fn resolve_repository(explicit: Option<&str>) -> Result { + if let Some(value) = explicit.filter(|s| !s.is_empty()) { + return Ok(value.to_string()); + } + env::var("GITHUB_REPOSITORY") + .ok() + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + CliError::Configuration( + "--repository not provided and GITHUB_REPOSITORY env var is unset".to_string(), + ) + }) +} diff --git a/crates/mergify-queue/src/lib.rs b/crates/mergify-queue/src/lib.rs new file mode 100644 index 00000000..8cbb1cb3 --- /dev/null +++ b/crates/mergify-queue/src/lib.rs @@ -0,0 +1,12 @@ +//! 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 auth; +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..c98262e1 --- /dev/null +++ b/crates/mergify-queue/src/pause.rs @@ -0,0 +1,275 @@ +//! `mergify queue pause` — pause the merge queue for a repository. +//! +//! PUTs ``{"reason": "..."}`` to +//! ``/v1/repos//merge-queue/pause``. Prints a "Queue paused" +//! confirmation with the reason (and the raw pause timestamp if +//! the API returned one). +//! +//! Confirmation flow: +//! +//! - ``--yes-i-am-sure`` skips the prompt. +//! - Interactive (TTY): asks "Proceed? [y/N]"; anything other than +//! "y"/"yes" aborts with a generic error. +//! - Non-interactive (no TTY, no ``--yes-i-am-sure``): refuses with +//! an ``INVALID_STATE`` error matching Python's behavior. + +use std::io::IsTerminal; +use std::io::Write; + +use mergify_core::ApiFlavor; +use mergify_core::CliError; +use mergify_core::HttpClient; +use mergify_core::Output; +use serde::Deserialize; +use serde::Serialize; + +use crate::auth; + +const MAX_REASON_LEN: usize = 255; + +pub struct PauseOptions<'a> { + pub repository: Option<&'a str>, + pub token: Option<&'a str>, + pub api_url: Option<&'a str>, + pub reason: &'a str, + pub yes_i_am_sure: bool, +} + +/// Clap value-parser for the positional `--reason` flag. +/// +/// # Errors +/// +/// Returns a message when `value` exceeds 255 user-visible +/// characters. We count via [`str::chars`] (Unicode scalar values) +/// rather than [`str::len`] (UTF-8 bytes), so non-ASCII reasons +/// such as `"déploiement"` aren't rejected for being below 255 +/// chars but above 255 bytes. +pub fn parse_reason(value: &str) -> Result { + if value.chars().count() > MAX_REASON_LEN { + Err("must be 255 characters or fewer".to_string()) + } else { + Ok(value.to_string()) + } +} + +#[derive(Serialize)] +struct PauseRequest<'a> { + reason: &'a str, +} + +#[derive(Deserialize)] +struct PauseResponse { + // Both fields are optional defensively: the API has historically + // tolerated `reason: null` (the deleted Python `QueuePauseResponse` + // typed it as `str | None`), so the Rust port matches that + // shape rather than aborting deserialization on a missing or + // null value. + #[serde(default)] + reason: Option, + #[serde(default)] + paused_at: Option, +} + +/// Run the `queue pause` command. +pub async fn run(opts: PauseOptions<'_>, output: &mut dyn Output) -> Result<(), CliError> { + // Resolve auth/repo first so the prompt names the *actual* repo + // (including the `GITHUB_REPOSITORY` fallback) and so a missing + // repo or token fails loudly *before* we ask for confirmation. + let repository = auth::resolve_repository(opts.repository)?; + let token = auth::resolve_token(opts.token)?; + let api_url = auth::resolve_api_url(opts.api_url)?; + + confirm(opts.yes_i_am_sure, &repository)?; + + output.status(&format!("Pausing merge queue for {repository}…"))?; + + let client = HttpClient::new(api_url, token, ApiFlavor::Mergify)?; + let path = format!("/v1/repos/{repository}/merge-queue/pause"); + let resp: PauseResponse = client + .put( + &path, + &PauseRequest { + reason: opts.reason, + }, + ) + .await?; + + emit_confirmation(output, &resp)?; + Ok(()) +} + +fn confirm(skip: bool, repository: &str) -> Result<(), CliError> { + if skip { + return Ok(()); + } + if !std::io::stdin().is_terminal() { + return Err(CliError::InvalidState( + "refusing to pause without confirmation. Pass --yes-i-am-sure to proceed.".to_string(), + )); + } + // Prompt goes to stderr (matches click's `confirm`/`prompt` + // behavior) so users can pipe stdout cleanly without the + // prompt text mixed in. + let mut err = std::io::stderr().lock(); + write!( + err, + "You are about to pause the merge queue for {repository}. Proceed? [y/N]: ", + ) + .map_err(CliError::from)?; + err.flush().map_err(CliError::from)?; + drop(err); + + let mut line = String::new(); + std::io::stdin() + .read_line(&mut line) + .map_err(CliError::from)?; + match line.trim().to_ascii_lowercase().as_str() { + "y" | "yes" => Ok(()), + _ => Err(CliError::Generic("aborted by user".to_string())), + } +} + +fn emit_confirmation(output: &mut dyn Output, response: &PauseResponse) -> std::io::Result<()> { + let reason = response.reason.clone(); + let paused_at = response.paused_at.clone(); + output.emit(&(), &mut |w: &mut dyn Write| { + match &reason { + Some(r) => write!(w, "Queue paused: \"{r}\"")?, + None => write!(w, "Queue paused")?, + } + if let Some(ts) = &paused_at { + write!(w, " (since {ts})")?; + } + writeln!(w) + }) +} + +#[cfg(test)] +mod tests { + use mergify_core::OutputMode; + use mergify_core::StdioOutput; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::body_json; + use wiremock::matchers::header; + use wiremock::matchers::method; + use wiremock::matchers::path; + + use super::*; + + type SharedBytes = std::sync::Arc>>; + + struct Captured { + output: StdioOutput, + stdout: SharedBytes, + } + + fn make_output() -> Captured { + let stdout: SharedBytes = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let stderr: SharedBytes = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let output = StdioOutput::with_sinks( + OutputMode::Human, + SharedWriter(std::sync::Arc::clone(&stdout)), + SharedWriter(std::sync::Arc::clone(&stderr)), + ); + Captured { output, stdout } + } + + #[test] + fn parse_reason_accepts_short() { + assert_eq!( + parse_reason("deploying hotfix").unwrap(), + "deploying hotfix" + ); + } + + #[test] + fn parse_reason_rejects_over_255() { + let long = "a".repeat(256); + assert!(parse_reason(&long).is_err()); + } + + #[test] + fn parse_reason_counts_chars_not_bytes() { + // 200 user-visible characters but well above 255 *bytes* + // because each `é` is a 2-byte UTF-8 sequence — we keep it. + let multibyte = "é".repeat(200); + assert!(multibyte.len() > MAX_REASON_LEN); + assert!(multibyte.chars().count() <= MAX_REASON_LEN); + assert!(parse_reason(&multibyte).is_ok()); + } + + #[test] + fn confirm_refuses_without_yes_when_non_tty() { + // Inside `cargo test` stdin is not a TTY — that's the + // exact branch we want to verify. With `skip = false` the + // function must short-circuit to `InvalidState` instead of + // hanging on `read_line`. + let err = confirm(false, "owner/repo").unwrap_err(); + assert!( + matches!(err, CliError::InvalidState(_)), + "expected InvalidState, got {err:?}" + ); + assert_eq!(err.exit_code(), mergify_core::ExitCode::InvalidState); + assert!( + err.to_string().contains("--yes-i-am-sure"), + "message should mention the override flag, got: {err}" + ); + } + + #[test] + fn confirm_skips_when_yes_i_am_sure_is_set() { + // `skip = true` must bypass even the TTY check — it's the + // contract of `--yes-i-am-sure`, including in CI where + // stdin isn't a terminal. + confirm(true, "owner/repo").unwrap(); + } + + #[tokio::test] + async fn run_pauses_and_prints_confirmation() { + let server = MockServer::start().await; + Mock::given(method("PUT")) + .and(path("/v1/repos/owner/repo/merge-queue/pause")) + .and(header("Authorization", "Bearer t")) + .and(body_json(serde_json::json!({"reason": "deploy freeze"}))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "reason": "deploy freeze", + "paused_at": "2026-04-23T12:34:56Z", + }))) + .expect(1) + .mount(&server) + .await; + + let mut cap = make_output(); + let api_url = server.uri(); + run( + PauseOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + reason: "deploy freeze", + yes_i_am_sure: true, + }, + &mut cap.output, + ) + .await + .unwrap(); + + let stdout = String::from_utf8(cap.stdout.lock().unwrap().clone()).unwrap(); + assert!(stdout.contains("Queue paused"), "got: {stdout:?}"); + assert!(stdout.contains("deploy freeze"), "got: {stdout:?}"); + assert!(stdout.contains("2026-04-23"), "got: {stdout:?}"); + } + + struct SharedWriter(SharedBytes); + impl Write for SharedWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } +} diff --git a/crates/mergify-queue/src/unpause.rs b/crates/mergify-queue/src/unpause.rs new file mode 100644 index 00000000..4600954a --- /dev/null +++ b/crates/mergify-queue/src/unpause.rs @@ -0,0 +1,146 @@ +//! `mergify queue unpause` — resume the merge queue for a +//! repository. +//! +//! DELETEs ``/v1/repos//merge-queue/pause``. When the API +//! responds 404 the command prints "Queue is not currently paused" +//! and exits with `MERGIFY_API_ERROR` — matches Python's behavior. + +use std::io::Write; + +use mergify_core::ApiFlavor; +use mergify_core::CliError; +use mergify_core::DeleteOutcome; +use mergify_core::HttpClient; +use mergify_core::Output; + +use crate::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 4ddd5890daded907e8b24c46053b179895c972a6 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Thu, 23 Apr 2026 21:02:22 +0200 Subject: [PATCH 2/4] feat(rust): port ci git-refs and ci queue-info to native Rust (Phase 1.6) 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 f6da9c1f..9892338b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -895,10 +895,12 @@ dependencies = [ "mergify-core", "serde", "serde_json", + "serde_yaml_ng", "temp-env", "tempfile", "tokio", "url", + "uuid", "wiremock", ] @@ -1532,6 +1534,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1814,6 +1829,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" @@ -1850,6 +1871,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 ee9137a0..4802e883 100644 --- a/crates/mergify-ci/src/lib.rs +++ b/crates/mergify-ci/src/lib.rs @@ -1,10 +1,14 @@ //! Native Rust implementation of the `mergify ci` subcommands. //! -//! Phase 1.4 starts with `ci scopes-send` — straight HTTP POST to -//! Mergify with the scopes detected for a pull request. Other ci -//! commands (`git-refs`, `scopes`, `queue-info`, `junit-process`) -//! land in follow-up PRs as the shared infrastructure they need -//! (git-subprocess runner, GitHub event parser, `JUnit` XML reader) -//! is built out. +//! Phase 1.4 landed `ci scopes-send`. Phase 1.6 adds `ci queue-info` +//! and `ci git-refs`, which share GitHub event parsing and MQ +//! metadata extraction (`github_event` + `queue_metadata` +//! modules). Remaining commands (`scopes`, `junit-process`, +//! `junit-upload`) follow once the shared infrastructure they need +//! is in place. +pub mod git_refs; +pub mod github_event; +pub mod queue_info; +pub mod queue_metadata; pub mod scopes_send; diff --git a/crates/mergify-ci/src/queue_info.rs b/crates/mergify-ci/src/queue_info.rs new file mode 100644 index 00000000..cde4b646 --- /dev/null +++ b/crates/mergify-ci/src/queue_info.rs @@ -0,0 +1,172 @@ +//! `mergify ci queue-info` — print the merge-queue batch metadata +//! that's embedded in the current merge-queue draft PR. +//! +//! Output is pretty-printed JSON on stdout. When the step isn't +//! running against an MQ draft the command exits with +//! `INVALID_STATE` — same behavior as Python. +//! +//! When `$GITHUB_OUTPUT` is set (GitHub Actions runner), the command +//! also appends the metadata as `queue_metadata` under a random +//! `ghadelimiter_` heredoc, matching the pattern the workflow +//! runtime expects for multi-line outputs. + +use std::env; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::PathBuf; + +use mergify_core::CliError; +use mergify_core::Output; + +use crate::queue_metadata::MergeQueueMetadata; +use crate::queue_metadata::detect; + +/// Run the `ci queue-info` command. +pub fn run(output: &mut dyn Output) -> Result<(), CliError> { + let Some(metadata) = detect(output)? else { + return Err(CliError::InvalidState( + "Not running in a merge queue context. \ + This command must be run on a merge queue draft pull request." + .to_string(), + )); + }; + + emit_json(output, &metadata)?; + write_github_output(&metadata)?; + Ok(()) +} + +fn emit_json(output: &mut dyn Output, metadata: &MergeQueueMetadata) -> std::io::Result<()> { + output.emit(metadata, &mut |w: &mut dyn Write| { + let rendered = serde_json::to_string_pretty(metadata) + .map_err(|e| std::io::Error::other(e.to_string()))?; + writeln!(w, "{rendered}") + }) +} + +fn write_github_output(metadata: &MergeQueueMetadata) -> Result<(), CliError> { + let Some(path) = env::var("GITHUB_OUTPUT").ok().filter(|s| !s.is_empty()) else { + return Ok(()); + }; + let delimiter = format!("ghadelimiter_{}", uuid::Uuid::new_v4()); + let compact = serde_json::to_string(metadata) + .map_err(|e| CliError::Generic(format!("failed to serialize queue metadata: {e}")))?; + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(PathBuf::from(path))?; + writeln!(file, "queue_metadata<<{delimiter}")?; + writeln!(file, "{compact}")?; + writeln!(file, "{delimiter}")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use mergify_core::ExitCode; + use mergify_core::OutputMode; + use mergify_core::StdioOutput; + use tempfile::TempDir; + + use super::*; + + type SharedBytes = std::sync::Arc>>; + + struct Captured { + output: StdioOutput, + stdout: SharedBytes, + } + + fn make_output() -> Captured { + let stdout: SharedBytes = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let stderr: SharedBytes = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let output = StdioOutput::with_sinks( + OutputMode::Human, + SharedWriter(std::sync::Arc::clone(&stdout)), + SharedWriter(std::sync::Arc::clone(&stderr)), + ); + Captured { output, stdout } + } + + fn write_event_file(dir: &TempDir, body: &str, title: &str) -> PathBuf { + let path = dir.path().join("event.json"); + let payload = serde_json::json!({ + "pull_request": { + "title": title, + "body": body, + }, + }); + std::fs::write(&path, serde_json::to_vec(&payload).unwrap()).unwrap(); + path + } + + #[test] + fn errors_when_not_in_mq_context() { + let mut cap = make_output(); + let err = temp_env::with_vars_unset(["GITHUB_EVENT_NAME", "GITHUB_EVENT_PATH"], || { + run(&mut cap.output).unwrap_err() + }); + assert!(matches!(err, CliError::InvalidState(_))); + assert_eq!(err.exit_code(), ExitCode::InvalidState); + } + + #[test] + fn prints_metadata_for_mq_pr() { + let dir = tempfile::tempdir().unwrap(); + let path = write_event_file( + &dir, + "intro\n```yaml\nchecking_base_sha: abc123\npull_requests:\n - number: 10\n```", + "merge queue: batch", + ); + + let mut cap = make_output(); + temp_env::with_vars( + [ + ("GITHUB_EVENT_NAME", Some("pull_request")), + ("GITHUB_EVENT_PATH", Some(path.to_str().unwrap())), + ("GITHUB_OUTPUT", None), + ], + || run(&mut cap.output).unwrap(), + ); + + let stdout = String::from_utf8(cap.stdout.lock().unwrap().clone()).unwrap(); + assert!(stdout.contains("\"checking_base_sha\": \"abc123\"")); + assert!(stdout.contains("\"number\": 10")); + } + + #[test] + fn appends_to_github_output_when_set() { + let dir = tempfile::tempdir().unwrap(); + let event_path = write_event_file( + &dir, + "```yaml\nchecking_base_sha: deadbeef\n```", + "merge queue: tiny", + ); + let gha_output = dir.path().join("gha_output"); + + let mut cap = make_output(); + temp_env::with_vars( + [ + ("GITHUB_EVENT_NAME", Some("pull_request")), + ("GITHUB_EVENT_PATH", Some(event_path.to_str().unwrap())), + ("GITHUB_OUTPUT", Some(gha_output.to_str().unwrap())), + ], + || run(&mut cap.output).unwrap(), + ); + + let written = std::fs::read_to_string(&gha_output).unwrap(); + assert!(written.starts_with("queue_metadata< std::io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } +} diff --git a/crates/mergify-ci/src/queue_metadata.rs b/crates/mergify-ci/src/queue_metadata.rs new file mode 100644 index 00000000..1ddd5c10 --- /dev/null +++ b/crates/mergify-ci/src/queue_metadata.rs @@ -0,0 +1,204 @@ +//! Extract merge-queue batch metadata from a GitHub event payload. +//! +//! Mirrors `mergify_cli.ci.queue.metadata`. The engine publishes the +//! batch info as a ```yaml``` fenced block inside the MQ draft PR +//! body. `detect` returns `None` when the current event has no such +//! metadata — callers either fall back to other detection paths +//! (`git_refs`) or surface it as an `INVALID_STATE` (`queue_info`). + +use mergify_core::Output; +use serde::Deserialize; +use serde::Serialize; + +use crate::github_event::GitHubEvent; +use crate::github_event::PULL_REQUEST_EVENTS; +use crate::github_event::load as load_event; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MergeQueuePullRequest { + pub number: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MergeQueueBatchFailed { + pub draft_pr_number: u64, + pub checked_pull_requests: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MergeQueueMetadata { + pub checking_base_sha: String, + #[serde(default)] + pub pull_requests: Vec, + #[serde(default)] + pub previous_failed_batches: Vec, +} + +/// Parse the first ```yaml``` fenced block out of `body` and try to +/// read a `MergeQueueMetadata` out of it. Returns `None` when the +/// body has no fenced block or the YAML payload is the wrong shape. +#[must_use] +pub fn parse_yaml_block(body: &str) -> Option { + let mut inside = false; + let mut lines: Vec<&str> = Vec::new(); + for line in body.lines() { + if !inside { + if line.starts_with("```yaml") { + inside = true; + } + } else if line.starts_with("```") { + break; + } else { + lines.push(line); + } + } + if lines.is_empty() { + return None; + } + serde_yaml_ng::from_str(&lines.join("\n")).ok() +} + +/// Extract MQ metadata from an event payload's pull-request body. +/// +/// Emits a warning on `output` (stderr for human mode) when the PR is +/// an MQ draft but the body is missing or lacks the fenced block — +/// matches Python's stderr warnings. +pub fn extract_from_event( + ev: &GitHubEvent, + output: &mut dyn Output, +) -> std::io::Result> { + let Some(pr) = &ev.pull_request else { + return Ok(None); + }; + let Some(title) = pr.title.as_deref() else { + return Ok(None); + }; + if !title.starts_with("merge queue: ") { + return Ok(None); + } + let Some(body) = pr.body.as_deref() else { + output.status("WARNING: MQ pull request without body, skipping metadata extraction")?; + return Ok(None); + }; + let parsed = parse_yaml_block(body); + if parsed.is_none() { + output.status( + "WARNING: MQ pull request body without Mergify metadata, skipping metadata extraction", + )?; + } + Ok(parsed) +} + +/// Load the current event and extract merge-queue metadata. +/// +/// Returns `None` when not in a pull-request event or when no MQ +/// metadata is attached to the event's PR. Callers decide how to +/// treat that `None` (skip, error, fall back). +pub fn detect(output: &mut dyn Output) -> std::io::Result> { + let Some((event_name, event)) = load_event() else { + return Ok(None); + }; + if !PULL_REQUEST_EVENTS.contains(&event_name.as_str()) { + return Ok(None); + } + extract_from_event(&event, output) +} + +#[cfg(test)] +mod tests { + use mergify_core::OutputMode; + use mergify_core::StdioOutput; + + use super::*; + + type SharedBytes = std::sync::Arc>>; + + struct Captured { + output: StdioOutput, + stderr: SharedBytes, + } + + fn make_output() -> Captured { + let stdout: SharedBytes = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let stderr: SharedBytes = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let output = StdioOutput::with_sinks( + OutputMode::Human, + SharedWriter(std::sync::Arc::clone(&stdout)), + SharedWriter(std::sync::Arc::clone(&stderr)), + ); + Captured { output, stderr } + } + + #[test] + fn parse_yaml_block_extracts_metadata() { + let body = "prelude\n\n```yaml\nchecking_base_sha: abc\npull_requests:\n - number: 1\n```\ntrailing"; + let meta = parse_yaml_block(body).unwrap(); + assert_eq!(meta.checking_base_sha, "abc"); + assert_eq!(meta.pull_requests.len(), 1); + assert_eq!(meta.pull_requests[0].number, 1); + } + + #[test] + fn parse_yaml_block_returns_none_without_block() { + assert!(parse_yaml_block("just text").is_none()); + } + + #[test] + fn extract_ignores_non_mq_pr() { + let ev = GitHubEvent { + pull_request: Some(crate::github_event::PullRequest { + title: Some("feat: something".into()), + ..Default::default() + }), + ..Default::default() + }; + let mut cap = make_output(); + let result = extract_from_event(&ev, &mut cap.output).unwrap(); + assert!(result.is_none()); + assert!(cap.stderr.lock().unwrap().is_empty()); + } + + #[test] + fn extract_warns_on_mq_pr_without_body() { + let ev = GitHubEvent { + pull_request: Some(crate::github_event::PullRequest { + title: Some("merge queue: deploy".into()), + body: None, + ..Default::default() + }), + ..Default::default() + }; + let mut cap = make_output(); + let result = extract_from_event(&ev, &mut cap.output).unwrap(); + assert!(result.is_none()); + let stderr = String::from_utf8(cap.stderr.lock().unwrap().clone()).unwrap(); + assert!(stderr.contains("without body"), "got: {stderr:?}"); + } + + #[test] + fn extract_returns_metadata_for_mq_pr() { + let body = "blah\n```yaml\nchecking_base_sha: deadbeef\n```"; + let ev = GitHubEvent { + pull_request: Some(crate::github_event::PullRequest { + title: Some("merge queue: batch".into()), + body: Some(body.into()), + ..Default::default() + }), + ..Default::default() + }; + let mut cap = make_output(); + let meta = extract_from_event(&ev, &mut cap.output).unwrap().unwrap(); + assert_eq!(meta.checking_base_sha, "deadbeef"); + } + + struct SharedWriter(SharedBytes); + impl std::io::Write for SharedWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } +} diff --git a/crates/mergify-cli/src/main.rs b/crates/mergify-cli/src/main.rs index fabda31b..0c4840c5 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; @@ -49,6 +51,8 @@ enum NativeCommand { ConfigValidate { config_file: Option }, ConfigSimulate(ConfigSimulateOpts), CiScopesSend(CiScopesSendOpts), + CiGitRefs { format: GitRefsFormat }, + CiQueueInfo, QueuePause(QueuePauseOpts), QueueUnpause(QueueUnpauseOpts), } @@ -99,7 +103,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"), ) }) @@ -180,6 +184,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, @@ -256,6 +266,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 { @@ -362,6 +376,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 aa252459549952569b4bf20933e41b70ff08f747 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Tue, 5 May 2026 14:08:44 +0200 Subject: [PATCH 3/4] feat(rust): port queue status to native Rust (Phase 1.7) 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. ## What ports ``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. ## Rendering departures 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. ## Tests (24 new, in mergify-queue::status) - ``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. ## Wiring - ``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 | 105 +++ crates/mergify-cli/src/main.rs | 50 +- crates/mergify-queue/Cargo.toml | 4 +- crates/mergify-queue/src/lib.rs | 13 +- crates/mergify-queue/src/status.rs | 907 ++++++++++++++++++++++++++ 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, 1073 insertions(+), 615 deletions(-) create mode 100644 crates/mergify-queue/src/status.rs diff --git a/Cargo.lock b/Cargo.lock index 9892338b..e55d2544 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,6 +31,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -182,6 +191,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + [[package]] name = "clap" version = "4.6.1" @@ -228,6 +248,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "deadpool" version = "0.12.3" @@ -644,6 +670,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -959,6 +1009,8 @@ dependencies = [ name = "mergify-queue" version = "0.0.0" dependencies = [ + "chrono", + "indexmap", "mergify-core", "serde", "serde_json", @@ -2055,12 +2107,65 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/crates/mergify-cli/src/main.rs b/crates/mergify-cli/src/main.rs index 0c4840c5..604b17b3 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 { @@ -55,6 +56,7 @@ enum NativeCommand { CiQueueInfo, QueuePause(QueuePauseOpts), QueueUnpause(QueueUnpauseOpts), + QueueStatus(QueueStatusOpts), } struct ConfigSimulateOpts { @@ -89,6 +91,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? /// @@ -104,7 +114,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"), ) }) } @@ -216,6 +226,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, + })), } } @@ -294,6 +316,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 + } } }); @@ -465,6 +500,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)] @@ -478,3 +515,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 57d304d0..0f95035c 100644 --- a/crates/mergify-queue/Cargo.toml +++ b/crates/mergify-queue/Cargo.toml @@ -11,11 +11,13 @@ publish = false [dependencies] mergify-core = { path = "../mergify-core" } +chrono = { version = "0.4", default-features = false, features = ["clock"] } +indexmap = "2" serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" url = "2" [dev-dependencies] -serde_json = "1.0" temp-env = { version = "0.3", features = ["async_closure"] } 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 8cbb1cb3..dc21b19d 100644 --- a/crates/mergify-queue/src/lib.rs +++ b/crates/mergify-queue/src/lib.rs @@ -1,12 +1,15 @@ //! 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 auth; 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..a3a78f41 --- /dev/null +++ b/crates/mergify-queue/src/status.rs @@ -0,0 +1,907 @@ +//! `mergify queue status` — show merge queue status for a repository. +//! +//! `GET /v1/repos//merge-queue/status[?branch=]`. Two +//! output modes: +//! +//! - `--json`: pretty-prints the raw API response as a single JSON +//! document. The schema is Mergify's API contract, not this CLI's, +//! so unknown fields are preserved (deserialize to +//! `serde_json::Value`, emit verbatim). +//! - Human (default): a header, an optional pause indicator, the +//! batch tree (grouped by scope when there is more than one), and +//! the waiting-PR list. Status icons and relative times match the +//! Python implementation. +//! +//! The command does not assume the response shape beyond the fields +//! it actively renders: every nested struct uses +//! `#[serde(default)] Option<…>` for fields the API has historically +//! treated as optional/nullable, so a missing field doesn't abort +//! deserialization. +//! +//! Exit codes: +//! +//! - `0` on a successful render (queue empty, paused, or active). +//! - Standard `CliError` exit codes on auth, API, or +//! parse/serialization errors. + +use std::collections::HashMap; +use std::collections::HashSet; +use std::fmt::Write as _; +use std::io::Write; + +use chrono::DateTime; +use chrono::Utc; +use indexmap::IndexMap; +use mergify_core::ApiFlavor; +use mergify_core::CliError; +use mergify_core::HttpClient; +use mergify_core::Output; +use serde::Deserialize; +use url::form_urlencoded; + +use crate::auth; + +pub struct StatusOptions<'a> { + pub repository: Option<&'a str>, + pub token: Option<&'a str>, + pub api_url: Option<&'a str>, + pub branch: Option<&'a str>, + pub output_json: bool, +} + +// All view structs use `#[serde(default)] Option<…>` for fields the +// API has historically treated as optional/nullable. The wire format +// is Mergify's API contract — we deserialize only the fields we +// render and accept everything else implicitly via the +// `serde_json::Value` passthrough used in JSON mode. +#[derive(Deserialize)] +struct StatusView { + #[serde(default)] + pause: Option, + #[serde(default)] + batches: Vec, + #[serde(default)] + waiting_pull_requests: Vec, +} + +#[derive(Deserialize)] +struct Pause { + #[serde(default)] + reason: Option, + #[serde(default)] + paused_at: Option, +} + +#[derive(Deserialize)] +struct Batch { + id: String, + #[serde(default)] + parent_ids: Vec, + #[serde(default)] + scopes: Vec, + status: BatchStatus, + #[serde(default)] + started_at: Option, + #[serde(default)] + estimated_merge_at: Option, + checks_summary: ChecksSummary, + #[serde(default)] + pull_requests: Vec, +} + +#[derive(Deserialize)] +struct BatchStatus { + code: String, +} + +#[derive(Deserialize)] +struct ChecksSummary { + #[serde(default)] + passed: u64, + #[serde(default)] + total: u64, +} + +#[derive(Deserialize)] +struct PullRequest { + number: u64, + title: String, + author: Author, + #[serde(default)] + queued_at: Option, + #[serde(default)] + priority_alias: Option, + #[serde(default)] + estimated_merge_at: Option, +} + +#[derive(Deserialize)] +struct Author { + login: String, +} + +/// Run the `queue status` command. +pub async fn run(opts: StatusOptions<'_>, output: &mut dyn Output) -> Result<(), CliError> { + let repository = auth::resolve_repository(opts.repository)?; + let token = auth::resolve_token(opts.token)?; + let api_url = auth::resolve_api_url(opts.api_url)?; + + output.status(&format!("Fetching merge queue status for {repository}…"))?; + + let client = HttpClient::new(api_url, token, ApiFlavor::Mergify)?; + let path = build_path(&repository, opts.branch); + + let raw: serde_json::Value = client.get(&path).await?; + + if opts.output_json { + emit_json(output, &raw)?; + } else { + let view: StatusView = serde_json::from_value(raw) + .map_err(|e| CliError::Generic(format!("decode merge queue status response: {e}")))?; + emit_human(output, &repository, &view)?; + } + Ok(()) +} + +fn build_path(repository: &str, branch: Option<&str>) -> String { + let mut path = format!("/v1/repos/{repository}/merge-queue/status"); + if let Some(branch) = branch { + // form_urlencoded::byte_serialize handles spaces, unicode and + // reserved characters. Unencoded slashes are tolerated by + // most servers but encoding is the safe contract. + let encoded: String = form_urlencoded::byte_serialize(branch.as_bytes()).collect(); + path.push_str("?branch="); + path.push_str(&encoded); + } + path +} + +fn emit_json(output: &mut dyn Output, value: &serde_json::Value) -> std::io::Result<()> { + output.emit(value, &mut |w: &mut dyn Write| { + let rendered = serde_json::to_string_pretty(value) + .map_err(|e| std::io::Error::other(e.to_string()))?; + writeln!(w, "{rendered}") + }) +} + +fn emit_human(output: &mut dyn Output, repository: &str, view: &StatusView) -> std::io::Result<()> { + let now = Utc::now(); + output.emit(&(), &mut |w: &mut dyn Write| { + writeln!(w, "Merge Queue: {repository}")?; + writeln!(w)?; + + if let Some(pause) = &view.pause { + print_pause(w, pause, now)?; + writeln!(w)?; + } + + if view.batches.is_empty() && view.waiting_pull_requests.is_empty() { + writeln!(w, "Queue is empty")?; + return Ok(()); + } + + if !view.batches.is_empty() { + print_batches(w, &view.batches, now)?; + } + + if !view.waiting_pull_requests.is_empty() { + if !view.batches.is_empty() { + writeln!(w)?; + } + print_waiting_prs(w, &view.waiting_pull_requests, now)?; + } + Ok(()) + }) +} + +fn print_pause(w: &mut dyn Write, pause: &Pause, now: DateTime) -> std::io::Result<()> { + let reason = pause.reason.as_deref().unwrap_or(""); + let mut line = format!("⚠ Queue is paused: \"{reason}\""); + if let Some(ts) = &pause.paused_at { + let rel = relative_time(ts, now, false); + if !rel.is_empty() { + // `write!` on `String` is infallible — the only error + // path comes from the underlying writer, which `String` + // doesn't have. Discarding the `Result` is the standard + // idiom and what clippy's `format_push_string` wants. + let _ = write!(line, " (since {rel})"); + } + } + writeln!(w, "{line}") +} + +fn print_batches(w: &mut dyn Write, batches: &[Batch], now: DateTime) -> std::io::Result<()> { + let sorted = topological_sort(batches); + let groups = group_by_scope(&sorted); + let single_scope = groups.len() == 1; + + for (i, (scope, scope_batches)) in groups.iter().enumerate() { + if i > 0 { + writeln!(w)?; + } + let label = if single_scope { + "Batches" + } else { + scope.as_str() + }; + writeln!(w, "{label}")?; + for batch in scope_batches { + print_batch(w, batch, now)?; + } + } + Ok(()) +} + +fn print_batch(w: &mut dyn Write, batch: &Batch, now: DateTime) -> std::io::Result<()> { + let icon = status_icon(&batch.status.code); + let mut header = format!(" {icon} {}", batch.status.code); + if batch.checks_summary.total > 0 { + let _ = write!( + header, + " checks {}/{}", + batch.checks_summary.passed, batch.checks_summary.total, + ); + } + if let Some(started) = &batch.started_at { + let rel = relative_time(started, now, false); + if !rel.is_empty() { + let _ = write!(header, " {rel}"); + } + } + if let Some(eta) = &batch.estimated_merge_at { + let rel = relative_time(eta, now, true); + if !rel.is_empty() { + let _ = write!(header, " ETA {rel}"); + } + } + writeln!(w, "{header}")?; + for pr in &batch.pull_requests { + writeln!(w, " #{} {} ({})", pr.number, pr.title, pr.author.login,)?; + } + Ok(()) +} + +fn print_waiting_prs( + w: &mut dyn Write, + prs: &[PullRequest], + now: DateTime, +) -> std::io::Result<()> { + writeln!(w, "Waiting")?; + for pr in prs { + let mut line = format!(" #{} {} {}", pr.number, pr.title, pr.author.login); + if let Some(prio) = &pr.priority_alias { + let _ = write!(line, " {prio}"); + } + if let Some(queued_at) = &pr.queued_at { + let rel = relative_time(queued_at, now, false); + if !rel.is_empty() { + let _ = write!(line, " queued {rel}"); + } + } + if let Some(eta) = &pr.estimated_merge_at { + let rel = relative_time(eta, now, true); + if !rel.is_empty() { + let _ = write!(line, " ETA {rel}"); + } + } + writeln!(w, "{line}")?; + } + Ok(()) +} + +/// Map a batch-status code to a compact Unicode icon. Same icons as +/// the Python implementation; unknown codes fall back to `?`. +fn status_icon(code: &str) -> &'static str { + match code { + "running" => "●", + "bisecting" => "◑", + "preparing" | "waiting_for_batch" => "◌", + "failed" => "✗", + "merged" => "✓", + "waiting_for_merge" => "◎", + "waiting_for_previous_batches" | "waiting_for_requeue" => "⏳", + "waiting_schedule" => "⏰", + "frozen" => "❄", + _ => "?", + } +} + +/// Format an ISO-8601/RFC-3339 timestamp as a relative duration +/// (`s`/`m`/`h`/`d`). Past timestamps render as `"… ago"`; future +/// timestamps as `"~…"` when `future = true`. +/// +/// Returns an empty string when the timestamp can't be parsed — +/// mirrors the Python implementation, which silently degrades on +/// malformed input rather than failing the whole render. +fn relative_time(iso: &str, now: DateTime, future: bool) -> String { + let Ok(parsed) = DateTime::parse_from_rfc3339(iso) else { + return String::new(); + }; + let parsed = parsed.with_timezone(&Utc); + let delta = (now - parsed).num_seconds().abs(); + let value = if delta < 60 { + format!("{delta}s") + } else if delta < 3600 { + format!("{}m", delta / 60) + } else if delta < 86400 { + format!("{}h", delta / 3600) + } else { + format!("{}d", delta / 86400) + }; + if future { + format!("~{value}") + } else { + format!("{value} ago") + } +} + +/// Topological sort of batches by `parent_ids`. Roots come first, +/// children follow their parents — matches the Python +/// `_topological_sort`. Cycles are impossible by API contract, but +/// the `visited` set makes us tolerant of them anyway. +fn topological_sort(batches: &[Batch]) -> Vec<&Batch> { + let id_to_batch: HashMap<&str, &Batch> = batches.iter().map(|b| (b.id.as_str(), b)).collect(); + let mut visited: HashSet<&str> = HashSet::new(); + let mut result: Vec<&Batch> = Vec::with_capacity(batches.len()); + + for batch in batches { + visit(batch.id.as_str(), &id_to_batch, &mut visited, &mut result); + } + result +} + +fn visit<'a>( + id: &'a str, + id_to_batch: &HashMap<&'a str, &'a Batch>, + visited: &mut HashSet<&'a str>, + result: &mut Vec<&'a Batch>, +) { + if !visited.insert(id) { + return; + } + let Some(batch) = id_to_batch.get(id) else { + return; + }; + for parent in &batch.parent_ids { + visit(parent.as_str(), id_to_batch, visited, result); + } + result.push(batch); +} + +/// Group batches by scope, preserving insertion order for the +/// scopes (matches Python dict iteration). A batch with no scopes +/// is grouped under `"default"` to match the Python fallback. A +/// batch with multiple scopes appears in every group it claims — +/// the Python implementation does the same so users see each batch +/// in every scope it affects. +fn group_by_scope<'a>(batches: &[&'a Batch]) -> IndexMap> { + let mut groups: IndexMap> = IndexMap::new(); + for batch in batches { + let scopes: Vec = if batch.scopes.is_empty() { + vec!["default".to_string()] + } else { + batch.scopes.clone() + }; + for scope in scopes { + groups.entry(scope).or_default().push(batch); + } + } + groups +} + +#[cfg(test)] +mod tests { + use mergify_core::OutputMode; + use mergify_core::StdioOutput; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::header; + use wiremock::matchers::method; + use wiremock::matchers::path; + use wiremock::matchers::query_param; + + use super::*; + + type SharedBytes = std::sync::Arc>>; + + struct Captured { + output: StdioOutput, + stdout: SharedBytes, + } + + fn make_output(mode: OutputMode) -> Captured { + let stdout: SharedBytes = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let stderr: SharedBytes = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let output = StdioOutput::with_sinks( + mode, + SharedWriter(std::sync::Arc::clone(&stdout)), + SharedWriter(std::sync::Arc::clone(&stderr)), + ); + Captured { output, stdout } + } + + fn stdout_string(cap: &Captured) -> String { + String::from_utf8(cap.stdout.lock().unwrap().clone()).unwrap() + } + + #[test] + fn build_path_no_branch() { + assert_eq!( + build_path("owner/repo", None), + "/v1/repos/owner/repo/merge-queue/status", + ); + } + + #[test] + fn build_path_with_branch() { + assert_eq!( + build_path("owner/repo", Some("main")), + "/v1/repos/owner/repo/merge-queue/status?branch=main", + ); + } + + #[test] + fn build_path_url_encodes_branch() { + // Slashes and unicode in branch names must survive a round + // trip through the URL — `feature/foo` is common, and + // browser-pasted names occasionally include UTF-8. + let path = build_path("owner/repo", Some("feature/foo bar")); + assert!(path.ends_with("?branch=feature%2Ffoo+bar"), "got {path}"); + } + + #[test] + fn relative_time_seconds() { + let now = DateTime::parse_from_rfc3339("2026-01-01T00:01:00Z") + .unwrap() + .with_timezone(&Utc); + assert_eq!(relative_time("2026-01-01T00:00:30Z", now, false), "30s ago"); + } + + #[test] + fn relative_time_minutes() { + let now = DateTime::parse_from_rfc3339("2026-01-01T01:00:00Z") + .unwrap() + .with_timezone(&Utc); + assert_eq!(relative_time("2026-01-01T00:55:00Z", now, false), "5m ago"); + } + + #[test] + fn relative_time_hours() { + let now = DateTime::parse_from_rfc3339("2026-01-01T05:00:00Z") + .unwrap() + .with_timezone(&Utc); + assert_eq!(relative_time("2026-01-01T00:00:00Z", now, false), "5h ago"); + } + + #[test] + fn relative_time_days() { + let now = DateTime::parse_from_rfc3339("2026-01-08T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + assert_eq!(relative_time("2026-01-01T00:00:00Z", now, false), "7d ago"); + } + + #[test] + fn relative_time_future_prefix() { + // ETA-style timestamps render as `~…` so users can + // distinguish "happened 5m ago" from "in 5m". + let now = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + assert_eq!(relative_time("2026-01-01T00:30:00Z", now, true), "~30m"); + } + + #[test] + fn relative_time_unparseable_returns_empty() { + // Mirrors Python: a malformed timestamp shouldn't fail the + // whole render — degrade gracefully so the rest of the + // status block still appears. + let now = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + assert_eq!(relative_time("not-a-date", now, false), ""); + } + + #[test] + fn topological_sort_orders_parents_before_children() { + // Construct three batches, child references parent. Even if + // the input is in reverse order, the sort must put the + // parent first. + let batches = vec![ + sample_batch("c", &["b"]), + sample_batch("b", &["a"]), + sample_batch("a", &[]), + ]; + let sorted = topological_sort(&batches); + let ids: Vec<&str> = sorted.iter().map(|b| b.id.as_str()).collect(); + assert_eq!(ids, vec!["a", "b", "c"]); + } + + #[test] + fn topological_sort_handles_missing_parent_ids() { + // When `parent_ids` references an id that isn't in the + // batches list (the API has dropped it for some reason), + // the sort skips it instead of panicking. + let batches = [sample_batch("only", &["nonexistent"])]; + let sorted = topological_sort(&batches); + assert_eq!(sorted.len(), 1); + assert_eq!(sorted[0].id, "only"); + } + + #[test] + fn group_by_scope_default_when_empty_scopes() { + let batches = [sample_batch("a", &[])]; + let refs: Vec<&Batch> = batches.iter().collect(); + let groups = group_by_scope(&refs); + assert_eq!(groups.len(), 1); + assert!(groups.contains_key("default")); + } + + #[test] + fn group_by_scope_assigns_to_each_listed_scope() { + // Matches Python: a multi-scope batch appears under each + // scope's group, not just the first. + let mut b = sample_batch("a", &[]); + b.scopes = vec!["foo".to_string(), "bar".to_string()]; + let batches = [b]; + let refs: Vec<&Batch> = batches.iter().collect(); + let groups = group_by_scope(&refs); + assert_eq!(groups.len(), 2); + assert!(groups.contains_key("foo")); + assert!(groups.contains_key("bar")); + } + + #[test] + fn status_icon_known_codes() { + assert_eq!(status_icon("running"), "●"); + assert_eq!(status_icon("merged"), "✓"); + assert_eq!(status_icon("failed"), "✗"); + } + + #[test] + fn status_icon_unknown_falls_back() { + assert_eq!(status_icon("brand-new-status"), "?"); + } + + fn sample_batch(id: &str, parents: &[&str]) -> Batch { + Batch { + id: id.to_string(), + parent_ids: parents.iter().copied().map(String::from).collect(), + scopes: Vec::new(), + status: BatchStatus { + code: "running".to_string(), + }, + started_at: None, + estimated_merge_at: None, + checks_summary: ChecksSummary { + passed: 0, + total: 0, + }, + pull_requests: Vec::new(), + } + } + + #[tokio::test] + async fn run_json_passes_response_through_verbatim() { + // JSON mode is a passthrough — every field the server sends, + // including ones we don't render, must survive intact. + // `extra_field` here proves we don't reshape on the way out. + let server = MockServer::start().await; + let response = serde_json::json!({ + "batches": [], + "waiting_pull_requests": [], + "scope_queues": {"default": []}, + "pause": null, + "extra_field": "preserved", + }); + Mock::given(method("GET")) + .and(path("/v1/repos/owner/repo/merge-queue/status")) + .and(header("Authorization", "Bearer t")) + .respond_with(ResponseTemplate::new(200).set_body_json(response.clone())) + .expect(1) + .mount(&server) + .await; + + let mut cap = make_output(OutputMode::Human); + let api_url = server.uri(); + run( + StatusOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + branch: None, + output_json: true, + }, + &mut cap.output, + ) + .await + .unwrap(); + + let stdout = stdout_string(&cap); + let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!(parsed, response); + } + + #[tokio::test] + async fn run_human_renders_paused_queue() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/repos/owner/repo/merge-queue/status")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "batches": [], + "waiting_pull_requests": [], + "scope_queues": {}, + "pause": {"reason": "deploy freeze", "paused_at": "2026-01-01T00:00:00Z"}, + }))) + .expect(1) + .mount(&server) + .await; + + let mut cap = make_output(OutputMode::Human); + let api_url = server.uri(); + run( + StatusOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + branch: None, + output_json: false, + }, + &mut cap.output, + ) + .await + .unwrap(); + + let stdout = stdout_string(&cap); + assert!(stdout.contains("Merge Queue: owner/repo"), "got {stdout}"); + assert!(stdout.contains("Queue is paused"), "got {stdout}"); + assert!(stdout.contains("deploy freeze"), "got {stdout}"); + assert!(stdout.contains("Queue is empty"), "got {stdout}"); + } + + #[tokio::test] + async fn run_human_renders_empty_queue() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/repos/owner/repo/merge-queue/status")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "batches": [], + "waiting_pull_requests": [], + "scope_queues": {}, + "pause": null, + }))) + .mount(&server) + .await; + + let mut cap = make_output(OutputMode::Human); + let api_url = server.uri(); + run( + StatusOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + branch: None, + output_json: false, + }, + &mut cap.output, + ) + .await + .unwrap(); + + let stdout = stdout_string(&cap); + assert!(stdout.contains("Queue is empty"), "got {stdout}"); + } + + #[tokio::test] + async fn run_human_renders_batches_and_waiting_prs() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/repos/owner/repo/merge-queue/status")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "batches": [{ + "id": "b1", + "name": "batch-1", + "status": {"code": "running"}, + "checks_summary": {"passed": 3, "total": 5}, + "started_at": "2026-01-01T00:00:00Z", + "estimated_merge_at": "2026-01-01T01:00:00Z", + "pull_requests": [ + { + "number": 42, + "title": "Add feature foo", + "url": "https://example.test/42", + "author": {"id": 1, "login": "alice"}, + "queued_at": "2026-01-01T00:00:00Z", + "priority_alias": "default", + "priority_rule_name": "default", + "labels": [], + "scopes": [], + }, + ], + "parent_ids": [], + }], + "waiting_pull_requests": [ + { + "number": 43, + "title": "Update deps", + "url": "https://example.test/43", + "author": {"id": 2, "login": "bob"}, + "queued_at": "2026-01-01T00:00:00Z", + "priority_alias": "high", + "priority_rule_name": "high", + "labels": [], + "scopes": [], + }, + ], + "scope_queues": {}, + "pause": null, + }))) + .mount(&server) + .await; + + let mut cap = make_output(OutputMode::Human); + let api_url = server.uri(); + run( + StatusOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + branch: None, + output_json: false, + }, + &mut cap.output, + ) + .await + .unwrap(); + + let stdout = stdout_string(&cap); + assert!(stdout.contains("Batches"), "got {stdout}"); + assert!(stdout.contains("running"), "got {stdout}"); + assert!(stdout.contains("checks 3/5"), "got {stdout}"); + assert!( + stdout.contains("#42 Add feature foo (alice)"), + "got {stdout}" + ); + assert!(stdout.contains("Waiting"), "got {stdout}"); + assert!(stdout.contains("#43"), "got {stdout}"); + assert!(stdout.contains("Update deps"), "got {stdout}"); + assert!(stdout.contains("bob"), "got {stdout}"); + assert!(stdout.contains("high"), "got {stdout}"); + } + + #[tokio::test] + async fn run_human_groups_batches_by_scope_when_multiple() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/repos/owner/repo/merge-queue/status")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "batches": [ + { + "id": "b1", + "status": {"code": "running"}, + "checks_summary": {"passed": 0, "total": 0}, + "pull_requests": [], + "scopes": ["frontend"], + "parent_ids": [], + }, + { + "id": "b2", + "status": {"code": "preparing"}, + "checks_summary": {"passed": 0, "total": 0}, + "pull_requests": [], + "scopes": ["backend"], + "parent_ids": [], + }, + ], + "waiting_pull_requests": [], + "scope_queues": {}, + "pause": null, + }))) + .mount(&server) + .await; + + let mut cap = make_output(OutputMode::Human); + let api_url = server.uri(); + run( + StatusOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + branch: None, + output_json: false, + }, + &mut cap.output, + ) + .await + .unwrap(); + + let stdout = stdout_string(&cap); + // Two scopes → each labelled by its own name (no + // generic "Batches" header). + assert!(stdout.contains("frontend"), "got {stdout}"); + assert!(stdout.contains("backend"), "got {stdout}"); + assert!(!stdout.contains("\nBatches\n"), "got {stdout}"); + } + + #[tokio::test] + async fn run_passes_branch_query_param() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/repos/owner/repo/merge-queue/status")) + .and(query_param("branch", "main")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "batches": [], + "waiting_pull_requests": [], + "scope_queues": {}, + "pause": null, + }))) + .expect(1) + .mount(&server) + .await; + + let mut cap = make_output(OutputMode::Human); + let api_url = server.uri(); + run( + StatusOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + branch: Some("main"), + output_json: false, + }, + &mut cap.output, + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn run_tolerates_missing_optional_fields() { + // The API has historically dropped optional fields entirely + // rather than serializing them as null. Deserialization + // must accept that — the response below has neither + // `pause` nor any of the per-batch optional timestamps. + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/repos/owner/repo/merge-queue/status")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "batches": [{ + "id": "b1", + "status": {"code": "running"}, + "checks_summary": {"passed": 0, "total": 0}, + "pull_requests": [], + }], + "waiting_pull_requests": [], + "scope_queues": {}, + }))) + .mount(&server) + .await; + + let mut cap = make_output(OutputMode::Human); + let api_url = server.uri(); + run( + StatusOptions { + repository: Some("owner/repo"), + token: Some("t"), + api_url: Some(&api_url), + branch: None, + output_json: false, + }, + &mut cap.output, + ) + .await + .unwrap(); + } + + struct SharedWriter(SharedBytes); + impl Write for SharedWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } +} diff --git a/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 6f890be4b2493581b1b030fea062746d20a79d0d Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Tue, 5 May 2026 14:54:53 +0200 Subject: [PATCH 4/4] fix(rust): restore gh and git-config fallbacks in token/repo resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression introduced by the port: every Rust-native command that calls Mergify (config simulate, ci scopes-send, queue pause/unpause/ status) errored with ``please set the 'MERGIFY_TOKEN' or 'GITHUB_TOKEN'…`` when neither env var was set, even though the user was authenticated via the GitHub CLI. The Python equivalent fell back to ``gh auth token``; the Rust ports did not. Same story for ``--repository``: Python parsed ``git config --get remote.origin.url`` when the flag was absent and ``GITHUB_REPOSITORY`` was unset; the Rust ports just errored. The three Rust crates each had their own copy of the resolvers, all with the same regression. Factor a single ``mergify_core::auth`` module that: - ``resolve_token(explicit)``: explicit → ``MERGIFY_TOKEN`` → ``GITHUB_TOKEN`` → ``gh auth token`` (subprocess, swallows missing-binary or non-zero-exit errors). - ``resolve_repository(explicit)``: explicit → ``GITHUB_REPOSITORY`` → ``git config --get remote.origin.url`` parsed via a ``parse_slug`` helper that handles HTTPS (``https://host/owner/repo.git``) and SSH (``git@host:owner/repo.git``) shapes, strips ``.git`` and trailing slashes. - ``resolve_api_url(explicit)``: explicit → ``MERGIFY_API_URL`` → default ``https://api.mergify.com``. Subprocess fallbacks are best-effort: if ``gh`` or ``git`` aren't on ``$PATH``, or the command fails, we fall through to the next source rather than surfacing the subprocess error. Matches the Python behavior of catching ``CommandError``. Three call sites refactored to drop their own resolvers and use ``mergify_core::auth``: - ``mergify-config::simulate``: removed local ``resolve_token`` / ``resolve_api_url`` and ``DEFAULT_API_URL``. - ``mergify-ci::scopes_send``: removed local ``resolve_repository`` / ``resolve_token`` / ``resolve_api_url`` and ``DEFAULT_API_URL``. - ``mergify-queue``: deleted ``mergify_queue::auth`` entirely; the three commands import ``mergify_core::auth`` directly. The redundant tests that lived in ``simulate.rs`` and ``scopes_send.rs`` are deleted — their coverage now lives once in ``mergify_core::auth::tests``. 13 new tests in ``mergify_core::auth::tests``: env var precedence for token/api_url/repository, default API URL, error message mentions ``gh client``, slug parsing for HTTPS / SSH / dot-git / trailing-slash / empty-owner / path-without-repo. The subprocess-fallback paths themselves aren't unit-tested (they shell out to real binaries); the test ``resolve_token_error_message_mentions_gh`` exercises the "PATH points to a directory with no gh" case to confirm the fallback degrades cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) Change-Id: I6ea0e60fb21c1debea7ee005333b30aa86df6f0e --- Cargo.lock | 1 + crates/mergify-ci/src/scopes_send.rs | 80 +------ crates/mergify-config/src/simulate.rs | 124 +--------- crates/mergify-core/Cargo.toml | 1 + crates/mergify-core/src/auth.rs | 327 ++++++++++++++++++++++++++ crates/mergify-core/src/lib.rs | 1 + crates/mergify-queue/src/auth.rs | 62 ----- crates/mergify-queue/src/lib.rs | 1 - crates/mergify-queue/src/pause.rs | 2 +- crates/mergify-queue/src/status.rs | 5 +- crates/mergify-queue/src/unpause.rs | 2 +- 11 files changed, 349 insertions(+), 257 deletions(-) create mode 100644 crates/mergify-core/src/auth.rs delete mode 100644 crates/mergify-queue/src/auth.rs diff --git a/Cargo.lock b/Cargo.lock index e55d2544..7a1d49d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -990,6 +990,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "temp-env", "thiserror", "tokio", "url", diff --git a/crates/mergify-ci/src/scopes_send.rs b/crates/mergify-ci/src/scopes_send.rs index 6b2a8fa3..68245e71 100644 --- a/crates/mergify-ci/src/scopes_send.rs +++ b/crates/mergify-ci/src/scopes_send.rs @@ -18,9 +18,11 @@ //! returns success — matches Python's "no PR, nothing to send" //! behavior. //! -//! Auth + API URL resolution follows the same fallback order as -//! ``config simulate``: explicit flag → ``MERGIFY_TOKEN`` / -//! ``MERGIFY_API_URL`` env var → default (or error). +//! Auth + API URL resolution goes through `mergify_core::auth`, +//! which adds a `gh auth token` fallback (matches Python's +//! `utils.get_default_token`) and a `git config remote.origin.url` +//! fallback for the repository slug (matches +//! `utils.get_default_repository`). use std::env; use std::path::Path; @@ -29,11 +31,9 @@ use mergify_core::ApiFlavor; use mergify_core::CliError; use mergify_core::HttpClient; use mergify_core::Output; +use mergify_core::auth; use serde::Deserialize; use serde::Serialize; -use url::Url; - -const DEFAULT_API_URL: &str = "https://api.mergify.com"; pub struct ScopesSendOptions<'a> { pub repository: Option<&'a str>, @@ -53,9 +53,9 @@ pub async fn run(opts: ScopesSendOptions<'_>, output: &mut dyn Output) -> Result return Ok(()); }; - let repository = resolve_repository(opts.repository)?; - let token = resolve_token(opts.token)?; - let api_url = resolve_api_url(opts.api_url)?; + let repository = auth::resolve_repository(opts.repository)?; + let token = auth::resolve_token(opts.token)?; + let api_url = auth::resolve_api_url(opts.api_url)?; // Whenever the deprecated `--file` flag is supplied, surface // the deprecation warning — even when `--scopes-json` is also @@ -90,20 +90,6 @@ pub async fn run(opts: ScopesSendOptions<'_>, output: &mut dyn Output) -> Result Ok(()) } -fn resolve_repository(explicit: Option<&str>) -> Result { - if let Some(value) = explicit.filter(|s| !s.is_empty()) { - return Ok(value.to_string()); - } - env::var("GITHUB_REPOSITORY") - .ok() - .filter(|s| !s.is_empty()) - .ok_or_else(|| { - CliError::Configuration( - "--repository not provided and GITHUB_REPOSITORY env var is unset".to_string(), - ) - }) -} - fn resolve_pull_request(explicit: Option) -> Result, CliError> { if let Some(n) = explicit { return Ok(Some(n)); @@ -135,33 +121,6 @@ fn resolve_pull_request(explicit: Option) -> Result, CliError> .and_then(serde_json::Value::as_u64)) } -fn resolve_token(explicit: Option<&str>) -> Result { - if let Some(value) = explicit.filter(|s| !s.is_empty()) { - return Ok(value.to_string()); - } - for env_name in ["MERGIFY_TOKEN", "GITHUB_TOKEN"] { - if let Ok(value) = env::var(env_name) { - if !value.is_empty() { - return Ok(value); - } - } - } - Err(CliError::Configuration( - "please set the 'MERGIFY_TOKEN' or 'GITHUB_TOKEN' environment variable, \ - or pass --token explicitly" - .to_string(), - )) -} - -fn resolve_api_url(explicit: Option<&str>) -> Result { - let raw = explicit - .map(str::to_string) - .or_else(|| env::var("MERGIFY_API_URL").ok()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| DEFAULT_API_URL.to_string()); - Url::parse(&raw).map_err(|e| CliError::Configuration(format!("invalid --api-url {raw:?}: {e}"))) -} - #[derive(Deserialize)] struct DetectedScopesFile { scopes: Vec, @@ -234,27 +193,6 @@ mod tests { } } - #[test] - fn resolve_repository_prefers_flag() { - temp_env::with_var("GITHUB_REPOSITORY", Some("env/env"), || { - assert_eq!(resolve_repository(Some("cli/cli")).unwrap(), "cli/cli"); - }); - } - - #[test] - fn resolve_repository_falls_back_to_env() { - temp_env::with_var("GITHUB_REPOSITORY", Some("env/env"), || { - assert_eq!(resolve_repository(None).unwrap(), "env/env"); - }); - } - - #[test] - fn resolve_repository_errors_when_unset() { - temp_env::with_var("GITHUB_REPOSITORY", None::<&str>, || { - assert!(resolve_repository(None).is_err()); - }); - } - #[test] fn resolve_pull_request_reads_event_json() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/mergify-config/src/simulate.rs b/crates/mergify-config/src/simulate.rs index 3ea8d14d..511d7d81 100644 --- a/crates/mergify-config/src/simulate.rs +++ b/crates/mergify-config/src/simulate.rs @@ -12,13 +12,10 @@ //! 5. Prints the simulator's title + summary. //! //! Token / api-url / config-file all follow the same resolution -//! order as the Python CLI: explicit flag, then env var, then -//! default. Missing token falls back from ``MERGIFY_TOKEN`` to -//! ``GITHUB_TOKEN``. The ``gh auth token`` subprocess fallback from -//! Python isn't ported yet — if neither env var is set the command -//! errors out. +//! order as the Python CLI (`mergify_core::auth`): explicit flag, +//! then env var, then `gh auth token` for the bearer, then the +//! default API URL. -use std::env; use std::io::Write; use std::path::Path; @@ -26,14 +23,12 @@ use mergify_core::ApiFlavor; use mergify_core::CliError; use mergify_core::HttpClient; use mergify_core::Output; +use mergify_core::auth; use serde::Deserialize; use serde::Serialize; -use url::Url; use crate::paths::resolve_config_path; -const DEFAULT_API_URL: &str = "https://api.mergify.com"; - /// Deserialized shape of the `(owner/repo, number)` pair parsed from /// a pull-request URL. #[derive(Clone, Debug, Eq, PartialEq)] @@ -75,39 +70,6 @@ pub fn parse_pr_url(url: &str) -> Result { }) } -/// Resolve the Mergify API bearer token. -/// -/// Precedence: explicit `--token`, then `MERGIFY_TOKEN`, then -/// `GITHUB_TOKEN`. Errors out when none of those are set. -fn resolve_token(explicit: Option<&str>) -> Result { - if let Some(token) = explicit.filter(|t| !t.is_empty()) { - return Ok(token.to_string()); - } - for env_name in ["MERGIFY_TOKEN", "GITHUB_TOKEN"] { - if let Ok(value) = env::var(env_name) { - if !value.is_empty() { - return Ok(value); - } - } - } - Err(CliError::Configuration( - "please set the 'MERGIFY_TOKEN' or 'GITHUB_TOKEN' environment variable, \ - or pass --token explicitly" - .to_string(), - )) -} - -/// Resolve the Mergify API base URL. Falls back to `MERGIFY_API_URL` -/// env var, then to the default `https://api.mergify.com`. -fn resolve_api_url(explicit: Option<&str>) -> Result { - let raw = explicit - .map(str::to_string) - .or_else(|| env::var("MERGIFY_API_URL").ok()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| DEFAULT_API_URL.to_string()); - Url::parse(&raw).map_err(|e| CliError::Configuration(format!("invalid --api-url {raw:?}: {e}"))) -} - #[derive(Serialize)] struct SimulatorRequest<'a> { mergify_yml: &'a str, @@ -133,8 +95,8 @@ pub async fn run(opts: SimulateOptions<'_>, output: &mut dyn Output) -> Result<( CliError::Configuration(format!("cannot read {}: {e}", config_path.display())) })?; - let token = resolve_token(opts.token)?; - let api_url = resolve_api_url(opts.api_url)?; + let token = auth::resolve_token(opts.token)?; + let api_url = auth::resolve_api_url(opts.api_url)?; output.status(&format!("Simulating against {api_url}…"))?; @@ -218,80 +180,6 @@ mod tests { assert!(parse_pr_url("https://github.com//repo/pull/42").is_err()); } - #[test] - fn resolve_token_prefers_explicit_over_env() { - temp_env::with_vars( - [ - ("MERGIFY_TOKEN", Some("from-env")), - ("GITHUB_TOKEN", Some("github-env")), - ], - || { - assert_eq!(resolve_token(Some("from-cli")).unwrap(), "from-cli"); - }, - ); - } - - #[test] - fn resolve_token_falls_back_to_mergify_env() { - temp_env::with_vars( - [ - ("MERGIFY_TOKEN", Some("mergify-env")), - ("GITHUB_TOKEN", Some("github-env")), - ], - || { - assert_eq!(resolve_token(None).unwrap(), "mergify-env"); - }, - ); - } - - #[test] - fn resolve_token_falls_back_to_github_env() { - temp_env::with_vars( - [ - ("MERGIFY_TOKEN", None::<&str>), - ("GITHUB_TOKEN", Some("github-env")), - ], - || { - assert_eq!(resolve_token(None).unwrap(), "github-env"); - }, - ); - } - - #[test] - fn resolve_token_errors_when_no_source_available() { - temp_env::with_vars( - [ - ("MERGIFY_TOKEN", None::<&str>), - ("GITHUB_TOKEN", None::<&str>), - ], - || { - let err = resolve_token(None).unwrap_err(); - assert!(matches!(err, CliError::Configuration(_))); - assert!(err.to_string().contains("MERGIFY_TOKEN")); - }, - ); - } - - #[test] - fn resolve_api_url_uses_default_when_unset() { - temp_env::with_vars([("MERGIFY_API_URL", None::<&str>)], || { - assert_eq!( - resolve_api_url(None).unwrap().as_str(), - "https://api.mergify.com/", - ); - }); - } - - #[test] - fn resolve_api_url_prefers_explicit() { - temp_env::with_vars([("MERGIFY_API_URL", Some("https://from.env"))], || { - assert_eq!( - resolve_api_url(Some("https://from.cli")).unwrap().as_str(), - "https://from.cli/", - ); - }); - } - #[tokio::test] async fn run_posts_config_and_prints_simulator_result() { let server = MockServer::start().await; diff --git a/crates/mergify-core/Cargo.toml b/crates/mergify-core/Cargo.toml index b57416eb..be6914cc 100644 --- a/crates/mergify-core/Cargo.toml +++ b/crates/mergify-core/Cargo.toml @@ -18,6 +18,7 @@ tokio = { version = "1", default-features = false, features = ["macros", "rt", " url = "2" [dev-dependencies] +temp-env = "0.3" tokio = { version = "1", default-features = false, features = ["macros", "rt", "rt-multi-thread", "time"] } wiremock = "0.6" diff --git a/crates/mergify-core/src/auth.rs b/crates/mergify-core/src/auth.rs new file mode 100644 index 00000000..1b822a43 --- /dev/null +++ b/crates/mergify-core/src/auth.rs @@ -0,0 +1,327 @@ +//! Resolve `--token`, `--api-url`, and `--repository` with the +//! same fallback order the Python CLI used. +//! +//! Token: `--token` flag → `MERGIFY_TOKEN` env → `GITHUB_TOKEN` +//! env → `gh auth token` (the GitHub CLI). Mirrors Python's +//! `utils.get_default_token`. +//! +//! Repository: `--repository` flag → `GITHUB_REPOSITORY` env → +//! `git config --get remote.origin.url` parsed into `/`. +//! Mirrors Python's `utils.get_default_repository` + `utils.get_slug`. +//! +//! API URL: `--api-url` flag → `MERGIFY_API_URL` env → default +//! `https://api.mergify.com`. +//! +//! Each ported command resolves these once before doing any +//! network or interactive work. The Rust copies that previously +//! lived in `mergify-config::simulate`, `mergify-ci::scopes_send`, +//! and `mergify-queue::auth` were missing the `gh auth token` and +//! `git config` fallbacks — that's why this module exists. + +use std::env; +use std::process::Command; + +use url::Url; + +use crate::CliError; + +const DEFAULT_API_URL: &str = "https://api.mergify.com"; + +/// Resolve the Mergify API bearer token. +/// +/// Precedence: explicit `--token`, then `MERGIFY_TOKEN`, then +/// `GITHUB_TOKEN`, then the output of `gh auth token`. Errors when +/// none of those produce a non-empty value. +pub fn resolve_token(explicit: Option<&str>) -> Result { + if let Some(value) = explicit.filter(|s| !s.is_empty()) { + return Ok(value.to_string()); + } + for env_name in ["MERGIFY_TOKEN", "GITHUB_TOKEN"] { + if let Ok(value) = env::var(env_name) { + if !value.is_empty() { + return Ok(value); + } + } + } + if let Ok(token) = gh_auth_token() { + if !token.is_empty() { + return Ok(token); + } + } + Err(CliError::Configuration( + "please set the 'MERGIFY_TOKEN' or 'GITHUB_TOKEN' environment variable, \ + or make sure that the gh client is installed and you are authenticated" + .to_string(), + )) +} + +/// Resolve the Mergify API base URL. Falls back to the +/// `MERGIFY_API_URL` env var, then the default +/// `https://api.mergify.com`. +pub fn resolve_api_url(explicit: Option<&str>) -> Result { + let raw = explicit + .map(str::to_string) + .or_else(|| env::var("MERGIFY_API_URL").ok()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| DEFAULT_API_URL.to_string()); + Url::parse(&raw).map_err(|e| CliError::Configuration(format!("invalid --api-url {raw:?}: {e}"))) +} + +/// Resolve the repository (`/`). +/// +/// Precedence: explicit `--repository`, then `GITHUB_REPOSITORY` +/// env, then `git config --get remote.origin.url` parsed via +/// [`parse_slug`]. Errors when none of those yield a slug. +pub fn resolve_repository(explicit: Option<&str>) -> Result { + if let Some(value) = explicit.filter(|s| !s.is_empty()) { + return Ok(value.to_string()); + } + if let Ok(value) = env::var("GITHUB_REPOSITORY") { + if !value.is_empty() { + return Ok(value); + } + } + if let Some(remote) = git_remote_origin_url() { + if let Some(slug) = parse_slug(&remote) { + return Ok(slug); + } + } + Err(CliError::Configuration( + "--repository not provided, GITHUB_REPOSITORY env var is unset, and \ + the local git config has no usable `remote.origin.url`" + .to_string(), + )) +} + +/// Run `gh auth token` and return stdout (trimmed). Returns an +/// `Err` when `gh` is missing or the command fails, which the +/// caller treats as "no token from gh". +fn gh_auth_token() -> Result { + let output = Command::new("gh").args(["auth", "token"]).output()?; + if !output.status.success() { + return Err(std::io::Error::other("`gh auth token` exited non-zero")); + } + let token = String::from_utf8(output.stdout) + .map_err(|e| std::io::Error::other(format!("`gh auth token` non-UTF-8 output: {e}")))? + .trim() + .to_string(); + Ok(token) +} + +/// Run `git config --get remote.origin.url` in the current +/// directory and return stdout (trimmed). Returns `None` when git +/// isn't available, the working tree isn't a git repo, or the +/// remote isn't configured. +fn git_remote_origin_url() -> Option { + let output = Command::new("git") + .args(["config", "--get", "remote.origin.url"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let value = String::from_utf8(output.stdout).ok()?.trim().to_string(); + (!value.is_empty()).then_some(value) +} + +/// Parse a git remote URL into `/`. +/// +/// Handles both HTTPS (`https://github.com/owner/repo.git`) and +/// SSH (`git@github.com:owner/repo.git`) shapes; `.git` suffix and +/// trailing slashes are stripped. Returns `None` when the URL +/// doesn't decompose into at least two path segments. +fn parse_slug(url: &str) -> Option { + let url = url.trim(); + + // SSH form: `git@host:owner/repo[.git]` — no scheme, the + // delimiter between user@host and path is `:`. We detect this + // by checking for `@…:` before the first `/`. + let path = if let Some(scheme_end) = url.find("://") { + let after_scheme = &url[scheme_end + 3..]; + after_scheme.split_once('/')?.1.to_string() + } else if let Some(colon) = url.find(':') { + url[colon + 1..].to_string() + } else { + return None; + }; + + let path = path.trim_end_matches('/').trim_start_matches('/'); + let (owner, rest) = path.split_once('/')?; + let repo = rest + .trim_end_matches('/') + .strip_suffix(".git") + .unwrap_or(rest); + let repo = repo.trim_end_matches('/'); + if owner.is_empty() || repo.is_empty() { + return None; + } + Some(format!("{owner}/{repo}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_token_prefers_explicit_over_env() { + temp_env::with_vars( + [ + ("MERGIFY_TOKEN", Some("env-mergify")), + ("GITHUB_TOKEN", Some("env-github")), + ], + || { + assert_eq!( + resolve_token(Some("explicit-token")).unwrap(), + "explicit-token", + ); + }, + ); + } + + #[test] + fn resolve_token_falls_back_to_mergify_env() { + temp_env::with_vars( + [ + ("MERGIFY_TOKEN", Some("env-mergify")), + ("GITHUB_TOKEN", Some("env-github")), + ], + || { + assert_eq!(resolve_token(None).unwrap(), "env-mergify"); + }, + ); + } + + #[test] + fn resolve_token_falls_back_to_github_env_when_mergify_unset() { + temp_env::with_vars( + [ + ("MERGIFY_TOKEN", None), + ("GITHUB_TOKEN", Some("env-github")), + ], + || { + assert_eq!(resolve_token(None).unwrap(), "env-github"); + }, + ); + } + + #[test] + fn resolve_token_error_message_mentions_gh() { + // When env vars are unset and `gh auth token` is unavailable + // (or fails), the user-facing error must mention the gh + // fallback so the user knows there's a third option. + // Forcing PATH to a directory with no `gh` keeps the test + // hermetic on machines that do have the GitHub CLI installed. + temp_env::with_vars( + [ + ("MERGIFY_TOKEN", None), + ("GITHUB_TOKEN", None), + ("PATH", Some("/nonexistent-directory-for-test")), + ], + || { + let err = resolve_token(None).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("MERGIFY_TOKEN"), "got {msg:?}"); + assert!(msg.contains("gh client"), "got {msg:?}"); + }, + ); + } + + #[test] + fn resolve_api_url_default() { + temp_env::with_var("MERGIFY_API_URL", None::<&str>, || { + let url = resolve_api_url(None).unwrap(); + assert_eq!(url.as_str(), "https://api.mergify.com/"); + }); + } + + #[test] + fn resolve_api_url_prefers_explicit() { + temp_env::with_var("MERGIFY_API_URL", Some("https://from-env.example/"), || { + let url = resolve_api_url(Some("https://explicit.example/")).unwrap(); + assert_eq!(url.as_str(), "https://explicit.example/"); + }); + } + + #[test] + fn resolve_api_url_uses_env_var_when_explicit_empty() { + temp_env::with_var("MERGIFY_API_URL", Some("https://from-env.example/"), || { + let url = resolve_api_url(None).unwrap(); + assert_eq!(url.as_str(), "https://from-env.example/"); + }); + } + + #[test] + fn resolve_api_url_rejects_garbage() { + temp_env::with_var("MERGIFY_API_URL", None::<&str>, || { + let err = resolve_api_url(Some("not a url")).unwrap_err(); + assert!(err.to_string().contains("invalid --api-url")); + }); + } + + #[test] + fn resolve_repository_prefers_explicit() { + temp_env::with_var("GITHUB_REPOSITORY", Some("owner-from-env/repo"), || { + assert_eq!( + resolve_repository(Some("explicit/repo")).unwrap(), + "explicit/repo", + ); + }); + } + + #[test] + fn resolve_repository_falls_back_to_env() { + temp_env::with_var("GITHUB_REPOSITORY", Some("owner/repo"), || { + assert_eq!(resolve_repository(None).unwrap(), "owner/repo"); + }); + } + + #[test] + fn parse_slug_https_with_dot_git() { + assert_eq!( + parse_slug("https://github.com/owner/repo.git").as_deref(), + Some("owner/repo"), + ); + } + + #[test] + fn parse_slug_https_without_dot_git() { + assert_eq!( + parse_slug("https://github.com/owner/repo").as_deref(), + Some("owner/repo"), + ); + } + + #[test] + fn parse_slug_https_with_trailing_slash() { + assert_eq!( + parse_slug("https://github.com/owner/repo/").as_deref(), + Some("owner/repo"), + ); + } + + #[test] + fn parse_slug_ssh_form() { + assert_eq!( + parse_slug("git@github.com:owner/repo.git").as_deref(), + Some("owner/repo"), + ); + } + + #[test] + fn parse_slug_ssh_without_dot_git() { + assert_eq!( + parse_slug("git@github.com:owner/repo").as_deref(), + Some("owner/repo"), + ); + } + + #[test] + fn parse_slug_rejects_empty_owner() { + assert!(parse_slug("https://github.com//repo.git").is_none()); + } + + #[test] + fn parse_slug_rejects_path_without_repo() { + assert!(parse_slug("https://github.com/owner").is_none()); + } +} diff --git a/crates/mergify-core/src/lib.rs b/crates/mergify-core/src/lib.rs index f5b42788..8b731465 100644 --- a/crates/mergify-core/src/lib.rs +++ b/crates/mergify-core/src/lib.rs @@ -15,6 +15,7 @@ //! Git operations, interactive prompts, and config loading arrive //! in subsequent sub-phases. +pub mod auth; pub mod error; pub mod exit_code; pub mod http; diff --git a/crates/mergify-queue/src/auth.rs b/crates/mergify-queue/src/auth.rs deleted file mode 100644 index 427944e1..00000000 --- a/crates/mergify-queue/src/auth.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! Resolve `--token`, `--api-url`, `--repository` with the same -//! env-variable fallbacks as Python. -//! -//! Duplicates the same helpers that live in `mergify-config::simulate` -//! and `mergify-ci::scopes_send` today. Once a fourth command -//! needs them, they factor into `mergify-core::auth`. - -use std::env; - -use mergify_core::CliError; -use url::Url; - -const DEFAULT_API_URL: &str = "https://api.mergify.com"; - -/// Resolve the Mergify API bearer token. -/// -/// Precedence: explicit `--token`, then `MERGIFY_TOKEN`, then -/// `GITHUB_TOKEN`. Errors out when none of those are set. -pub fn resolve_token(explicit: Option<&str>) -> Result { - if let Some(value) = explicit.filter(|s| !s.is_empty()) { - return Ok(value.to_string()); - } - for env_name in ["MERGIFY_TOKEN", "GITHUB_TOKEN"] { - if let Ok(value) = env::var(env_name) { - if !value.is_empty() { - return Ok(value); - } - } - } - Err(CliError::Configuration( - "please set the 'MERGIFY_TOKEN' or 'GITHUB_TOKEN' environment variable, \ - or pass --token explicitly" - .to_string(), - )) -} - -/// Resolve the Mergify API base URL. Falls back to `MERGIFY_API_URL` -/// env var, then to the default `https://api.mergify.com`. -pub fn resolve_api_url(explicit: Option<&str>) -> Result { - let raw = explicit - .map(str::to_string) - .or_else(|| env::var("MERGIFY_API_URL").ok()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| DEFAULT_API_URL.to_string()); - Url::parse(&raw).map_err(|e| CliError::Configuration(format!("invalid --api-url {raw:?}: {e}"))) -} - -/// Resolve the repository (owner/repo) identifier. Falls back to -/// the `GITHUB_REPOSITORY` env var. -pub fn resolve_repository(explicit: Option<&str>) -> Result { - if let Some(value) = explicit.filter(|s| !s.is_empty()) { - return Ok(value.to_string()); - } - env::var("GITHUB_REPOSITORY") - .ok() - .filter(|s| !s.is_empty()) - .ok_or_else(|| { - CliError::Configuration( - "--repository not provided and GITHUB_REPOSITORY env var is unset".to_string(), - ) - }) -} diff --git a/crates/mergify-queue/src/lib.rs b/crates/mergify-queue/src/lib.rs index dc21b19d..96455cb4 100644 --- a/crates/mergify-queue/src/lib.rs +++ b/crates/mergify-queue/src/lib.rs @@ -9,7 +9,6 @@ //! `queue show` stays shimmed until its conditions/checks tree //! ports next. -pub mod auth; pub mod pause; pub mod status; pub mod unpause; diff --git a/crates/mergify-queue/src/pause.rs b/crates/mergify-queue/src/pause.rs index c98262e1..7075549e 100644 --- a/crates/mergify-queue/src/pause.rs +++ b/crates/mergify-queue/src/pause.rs @@ -23,7 +23,7 @@ use mergify_core::Output; use serde::Deserialize; use serde::Serialize; -use crate::auth; +use mergify_core::auth; const MAX_REASON_LEN: usize = 255; diff --git a/crates/mergify-queue/src/status.rs b/crates/mergify-queue/src/status.rs index a3a78f41..9ce06ce5 100644 --- a/crates/mergify-queue/src/status.rs +++ b/crates/mergify-queue/src/status.rs @@ -36,11 +36,10 @@ 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; -use crate::auth; - pub struct StatusOptions<'a> { pub repository: Option<&'a str>, pub token: Option<&'a str>, @@ -256,7 +255,7 @@ fn print_batch(w: &mut dyn Write, batch: &Batch, now: DateTime) -> std::io: } writeln!(w, "{header}")?; for pr in &batch.pull_requests { - writeln!(w, " #{} {} ({})", pr.number, pr.title, pr.author.login,)?; + writeln!(w, " #{} {} ({})", pr.number, pr.title, pr.author.login)?; } Ok(()) } diff --git a/crates/mergify-queue/src/unpause.rs b/crates/mergify-queue/src/unpause.rs index 4600954a..a065c263 100644 --- a/crates/mergify-queue/src/unpause.rs +++ b/crates/mergify-queue/src/unpause.rs @@ -13,7 +13,7 @@ use mergify_core::DeleteOutcome; use mergify_core::HttpClient; use mergify_core::Output; -use crate::auth; +use mergify_core::auth; pub struct UnpauseOptions<'a> { pub repository: Option<&'a str>,