diff --git a/Cargo.lock b/Cargo.lock index 2210ad73..633d0f7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.8.12" @@ -318,6 +324,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -359,6 +374,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "email_address" version = "0.2.9" @@ -407,6 +428,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fluent-uri" version = "0.4.1" @@ -589,6 +620,12 @@ dependencies = [ "wasip3", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "globset" version = "0.4.18" @@ -920,6 +957,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1086,12 +1132,17 @@ name = "mergify-ci" version = "0.0.0" dependencies = [ "chrono", + "flate2", "getrandom 0.3.4", + "glob", "globset", "mergify-config", "mergify-core", "mergify-test-support", + "opentelemetry-proto", + "prost", "quick-xml", + "reqwest", "serde", "serde_json", "serde_yaml_ng", @@ -1212,6 +1263,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.0" @@ -1330,6 +1391,46 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "opentelemetry" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "prost", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368afaed344110f40b179bb8fbe54bc52d98f9bd2b281799ef32487c2650c956" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "portable-atomic", + "rand", + "thiserror", +] + [[package]] name = "outref" version = "0.5.2" @@ -1371,6 +1472,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.5" @@ -1408,6 +1515,29 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "quick-xml" version = "0.40.1" @@ -1879,6 +2009,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "simd_cesu8" version = "1.1.1" diff --git a/crates/mergify-ci/Cargo.toml b/crates/mergify-ci/Cargo.toml index c4750cee..167b65bd 100644 --- a/crates/mergify-ci/Cargo.toml +++ b/crates/mergify-ci/Cargo.toml @@ -11,11 +11,16 @@ publish = false [dependencies] chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] } +flate2 = "1" getrandom = "0.3" +glob = "0.3" globset = "0.4" mergify-config = { path = "../mergify-config" } mergify-core = { path = "../mergify-core" } +opentelemetry-proto = { version = "0.32", default-features = false, features = ["gen-tonic-messages", "trace"] } +prost = "0.14" quick-xml = "0.40" +reqwest = { version = "0.13", default-features = false, features = ["rustls"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_yaml_ng = "0.10" diff --git a/crates/mergify-ci/src/detector.rs b/crates/mergify-ci/src/detector.rs index f18a2559..b61b8043 100644 --- a/crates/mergify-ci/src/detector.rs +++ b/crates/mergify-ci/src/detector.rs @@ -19,6 +19,22 @@ pub enum CIProvider { Buildkite, } +impl CIProvider { + /// String identifier Python emits as the `cicd.provider.name` + /// span attribute. Must match `mergify_cli.ci.detector.CIProviderT` + /// (`snake_case`, no underscore for the multi-word ones except + /// `github_actions`). + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::GithubActions => "github_actions", + Self::CircleCi => "circleci", + Self::Jenkins => "jenkins", + Self::Buildkite => "buildkite", + } + } +} + #[must_use] pub fn get_ci_provider() -> Option { if env::var("JENKINS_URL").ok().is_some_and(|v| !v.is_empty()) { @@ -155,15 +171,15 @@ pub fn get_github_pull_request_number() -> Result, CliError> { } fn read_github_event_pull_request_number() -> Result, CliError> { - let Ok(event_path) = env::var("GITHUB_EVENT_PATH") else { + // The PR-number lookup is strict about JSON failures because + // it's the only signal that decides whether `scopes-send` runs + // at all — silently swallowing a parse error there would hide + // a misconfigured workflow. The head-SHA lookup (see + // [`read_github_event_pull_request_head_sha`]) has a sane + // fallback (`GITHUB_SHA`), so it stays lenient. + let Some(event_path) = env::var("GITHUB_EVENT_PATH").ok().filter(|s| !s.is_empty()) else { return Ok(None); }; - if event_path.is_empty() { - return Ok(None); - } - // A missing event file means "this isn't a GitHub Actions - // pull-request event" — match the Python CLI and treat it as - // "no PR detected", not a Configuration error. let content = match std::fs::read_to_string(&event_path) { Ok(content) => content, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), @@ -181,6 +197,201 @@ fn read_github_event_pull_request_number() -> Result, CliError> { .and_then(serde_json::Value::as_u64)) } +/// `cicd.pipeline.name` resource attribute. None when the +/// provider can't be detected or its env var isn't set. +#[must_use] +pub fn get_pipeline_name() -> Option { + let var = match get_ci_provider()? { + CIProvider::GithubActions => "GITHUB_WORKFLOW", + CIProvider::Jenkins => "JOB_NAME", + CIProvider::Buildkite => "BUILDKITE_PIPELINE_SLUG", + CIProvider::CircleCi => return None, + }; + non_empty_env(var) +} + +/// `cicd.pipeline.task.name` — the job within a pipeline. +#[must_use] +pub fn get_job_name() -> Option { + match get_ci_provider()? { + CIProvider::GithubActions => non_empty_env("GITHUB_JOB"), + CIProvider::CircleCi => non_empty_env("CIRCLE_JOB"), + CIProvider::Jenkins => non_empty_env("JOB_NAME"), + CIProvider::Buildkite => { + non_empty_env("BUILDKITE_LABEL").or_else(|| non_empty_env("BUILDKITE_STEP_KEY")) + } + } +} + +/// `vcs.ref.head.name` — name of the branch the test ran on. +#[must_use] +pub fn get_head_ref_name() -> Option { + match get_ci_provider()? { + CIProvider::GithubActions => { + // GitHub Actions sets `GITHUB_HEAD_REF` only on PR + // events. Fall back to `GITHUB_REF_NAME` everywhere + // else (the bare branch name, not `/merge`). + non_empty_env("GITHUB_HEAD_REF").or_else(|| non_empty_env("GITHUB_REF_NAME")) + } + CIProvider::CircleCi => non_empty_env("CIRCLE_BRANCH"), + CIProvider::Jenkins => non_empty_env("GIT_BRANCH").map(|raw| { + // Jenkins' Git plugin sets `GIT_BRANCH` to + // `/` (or `refs/heads/` when + // the job's configured for a refspec). Strip the + // common prefixes so the wire value matches what + // GitHub Actions reports. + for prefix in ["origin/", "refs/heads/"] { + if let Some(stripped) = raw.strip_prefix(prefix) { + return stripped.to_string(); + } + } + raw + }), + CIProvider::Buildkite => non_empty_env("BUILDKITE_BRANCH"), + } +} + +/// `vcs.ref.base.name` — PR target branch, when running for a PR. +#[must_use] +pub fn get_base_ref_name() -> Option { + match get_ci_provider()? { + CIProvider::GithubActions => non_empty_env("GITHUB_BASE_REF"), + CIProvider::Jenkins => non_empty_env("CHANGE_TARGET"), + CIProvider::Buildkite => non_empty_env("BUILDKITE_PULL_REQUEST_BASE_BRANCH"), + CIProvider::CircleCi => None, + } +} + +/// `cicd.pipeline.runner.name` — host / agent identity. +#[must_use] +pub fn get_cicd_pipeline_runner_name() -> Option { + match get_ci_provider()? { + CIProvider::GithubActions => non_empty_env("RUNNER_NAME"), + CIProvider::Jenkins => non_empty_env("NODE_NAME"), + CIProvider::Buildkite => non_empty_env("BUILDKITE_AGENT_NAME"), + CIProvider::CircleCi => None, + } +} + +/// `cicd.pipeline.run.id` — the workflow / build identifier. +/// Returned as a string because GitHub uses an integer-like ID +/// while Jenkins and Buildkite emit free-form strings. +#[must_use] +pub fn get_cicd_pipeline_run_id() -> Option { + match get_ci_provider()? { + CIProvider::GithubActions => non_empty_env("GITHUB_RUN_ID"), + CIProvider::CircleCi => non_empty_env("CIRCLE_WORKFLOW_ID"), + CIProvider::Jenkins => non_empty_env("BUILD_ID"), + CIProvider::Buildkite => non_empty_env("BUILDKITE_BUILD_ID"), + } +} + +/// `cicd.pipeline.run.attempt` — 1-indexed retry counter. +#[must_use] +pub fn get_cicd_pipeline_run_attempt() -> Option { + match get_ci_provider()? { + CIProvider::GithubActions => non_empty_env("GITHUB_RUN_ATTEMPT")?.parse().ok(), + CIProvider::CircleCi => non_empty_env("CIRCLE_BUILD_NUM")?.parse().ok(), + // Buildkite uses 0-indexed retries; add 1 so a fresh run + // reads as attempt 1 (matching the GHA/CircleCI semantics). + CIProvider::Buildkite => non_empty_env("BUILDKITE_RETRY_COUNT")? + .parse::() + .ok() + .map(|n| n + 1), + CIProvider::Jenkins => None, + } +} + +/// `cicd.pipeline.run.url` — direct link to the running build. +#[must_use] +pub fn get_cicd_pipeline_run_url() -> Option { + match get_ci_provider()? { + CIProvider::Buildkite => non_empty_env("BUILDKITE_BUILD_URL"), + _ => None, + } +} + +/// `vcs.repository.url.full` — clone URL of the repository under +/// test. GitHub Actions has no equivalent env (the repo is implicit +/// from `GITHUB_REPOSITORY`); we report `None` there. +#[must_use] +pub fn get_repository_url() -> Option { + match get_ci_provider()? { + CIProvider::Buildkite => non_empty_env("BUILDKITE_REPO"), + CIProvider::CircleCi => non_empty_env("CIRCLE_REPOSITORY_URL"), + CIProvider::Jenkins => non_empty_env("GIT_URL"), + CIProvider::GithubActions => None, + } +} + +/// `vcs.ref.head.revision` — the commit SHA the tests ran against. +/// +/// For GitHub Actions PR builds, `GITHUB_SHA` is the *synthetic +/// merge commit* GitHub creates by merging the PR head into the +/// base — not the actual code under test. The event payload at +/// `GITHUB_EVENT_PATH` carries the real `pull_request.head.sha`, +/// which is what dashboards correlate with the contributor's +/// commit. We prefer the event-payload value when present and +/// fall back to `GITHUB_SHA` otherwise. +/// +/// For other providers we only have the bare env var today; the +/// `CircleCI` PR-build API fallback Python implements stays +/// Python-side until a Rust HTTP shim for GitHub's REST API lands. +#[must_use] +pub fn get_head_sha() -> Option { + match get_ci_provider()? { + CIProvider::GithubActions => get_github_actions_head_sha(), + CIProvider::CircleCi => non_empty_env("CIRCLE_SHA1"), + CIProvider::Jenkins => non_empty_env("GIT_COMMIT"), + CIProvider::Buildkite => non_empty_env("BUILDKITE_COMMIT"), + } +} + +fn get_github_actions_head_sha() -> Option { + if env::var("GITHUB_EVENT_NAME").as_deref() == Ok("pull_request") { + if let Some(sha) = read_github_event_pull_request_head_sha() { + return Some(sha); + } + } + non_empty_env("GITHUB_SHA") +} + +/// Read `GITHUB_EVENT_PATH` and pluck the +/// `pull_request.head.sha` out of the JSON. Returns `None` for +/// every "not applicable" case — env unset, file missing, file +/// not JSON, key not present — so the caller can quietly fall +/// back to `GITHUB_SHA` without surfacing an error to the user. +fn read_github_event_pull_request_head_sha() -> Option { + let event = read_github_event_json()?; + event + .pointer("/pull_request/head/sha") + .and_then(serde_json::Value::as_str) + .map(str::to_string) +} + +fn read_github_event_json() -> Option { + let event_path = env::var("GITHUB_EVENT_PATH").ok()?; + if event_path.is_empty() { + return None; + } + let content = std::fs::read_to_string(&event_path).ok()?; + serde_json::from_str(&content).ok() +} + +fn non_empty_env(name: &str) -> Option { + env::var(name).ok().filter(|s| !s.is_empty()) +} + +/// Branch the quarantine API should look up tests for. Mirrors +/// Python's `get_tests_target_branch`: the PR base branch when +/// available, otherwise the head branch — i.e. "the branch the +/// tests *will* land on" so quarantine decisions match where +/// the merge will go. +#[must_use] +pub fn get_tests_target_branch() -> Option { + get_base_ref_name().or_else(get_head_ref_name) +} + #[cfg(test)] mod tests { use super::*; @@ -430,4 +641,83 @@ mod tests { ); } } + + #[test] + fn head_sha_prefers_pr_event_payload_over_github_sha() { + // PR events: `GITHUB_SHA` is the synthetic merge commit + // GitHub creates by pre-merging the PR; the contributor's + // actual head sha lives in the event payload at + // `pull_request.head.sha`. Dashboards correlate with the + // payload value, so we must prefer it. + let tmp = tempfile::tempdir().unwrap(); + let event_path = tmp.path().join("event.json"); + std::fs::write( + &event_path, + serde_json::json!({ + "pull_request": { + "number": 7, + "head": { "sha": "feedface00000000000000000000000000000000" } + } + }) + .to_string(), + ) + .unwrap(); + + with_ci_env( + &[ + ("GITHUB_ACTIONS", Some("true")), + ("GITHUB_EVENT_NAME", Some("pull_request")), + ("GITHUB_EVENT_PATH", Some(event_path.to_str().unwrap())), + ( + "GITHUB_SHA", + Some("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"), + ), + ], + || { + assert_eq!( + get_head_sha().as_deref(), + Some("feedface00000000000000000000000000000000"), + ); + }, + ); + } + + #[test] + fn head_sha_falls_back_to_github_sha_when_event_lacks_pr_head() { + // push events still leave `GITHUB_EVENT_PATH` pointing at a + // payload, but it has no `pull_request` field. Fall back to + // `GITHUB_SHA` rather than returning None. + let tmp = tempfile::tempdir().unwrap(); + let event_path = tmp.path().join("event.json"); + std::fs::write(&event_path, serde_json::json!({}).to_string()).unwrap(); + with_ci_env( + &[ + ("GITHUB_ACTIONS", Some("true")), + ("GITHUB_EVENT_NAME", Some("push")), + ("GITHUB_EVENT_PATH", Some(event_path.to_str().unwrap())), + ("GITHUB_SHA", Some("deadbeef")), + ], + || { + assert_eq!(get_head_sha().as_deref(), Some("deadbeef")); + }, + ); + } + + #[test] + fn head_sha_uses_github_sha_when_event_path_missing() { + // Workflows without an event file (e.g. local + // `act` runs) still set GITHUB_SHA — we must not regress + // to `None` just because the JSON file isn't there. + with_ci_env( + &[ + ("GITHUB_ACTIONS", Some("true")), + ("GITHUB_EVENT_NAME", Some("pull_request")), + ("GITHUB_EVENT_PATH", Some("/this/path/does/not/exist")), + ("GITHUB_SHA", Some("cafef00d")), + ], + || { + assert_eq!(get_head_sha().as_deref(), Some("cafef00d")); + }, + ); + } } diff --git a/crates/mergify-ci/src/junit_process/command.rs b/crates/mergify-ci/src/junit_process/command.rs new file mode 100644 index 00000000..daab84c3 --- /dev/null +++ b/crates/mergify-ci/src/junit_process/command.rs @@ -0,0 +1,721 @@ +//! `mergify ci junit-process` orchestration. +//! +//! Glues the four `junit_process` modules together: parse `JUnit` +//! XML → check quarantine status → build OTLP spans (now tagged +//! with `cicd.test.quarantined`) → upload them, then render the +//! human-facing report the way Python's `process_junit_files` +//! does. Errors during quarantine or upload are *non-fatal* by +//! design — the report calls them out but the overall exit code +//! is driven by the failing-tests-not-quarantined count plus the +//! silent-failure detection. + +// The report builder appends formatted snippets to a single +// `String`. clippy's `format_push_string` lint suggests `write!` +// everywhere, which adds a `use std::fmt::Write` and an awkward +// `let _ = write!(…)` per line for no semantic improvement — +// `String::push_str(&format!(…))` is the readable form for this +// kind of templated text emission. +#![allow(clippy::format_push_string)] + +use std::env; +use std::path::{Path, PathBuf}; + +use mergify_core::{CliError, ExitCode, Output}; +use url::Url; + +use crate::detector; +use crate::junit_process::junit::{self, ParseResult, TestCase}; +use crate::junit_process::quarantine::{self, QuarantineFailed, QuarantineResult}; +use crate::junit_process::spans::{self, UploadMetadata}; +use crate::junit_process::upload; + +const SEPARATOR: &str = "══════════════════════════════════════════"; +const SEPARATOR_LIGHT: &str = "──────────────────────────────────────────"; + +/// CLI options for `mergify ci junit-process`. Mirrors the +/// Python flag set — see `mergify_cli/ci/cli.py`. +pub struct JunitProcessOptions<'a> { + pub api_url: Option<&'a str>, + pub token: Option<&'a str>, + pub repository: Option<&'a str>, + pub test_framework: Option<&'a str>, + pub test_language: Option<&'a str>, + pub tests_target_branch: Option<&'a str>, + pub test_exit_code: Option, + /// Raw `files` arguments as the user typed them. Globs (`**`, + /// `*`, `?`) are expanded here, matching Python's + /// `_expand_junit_patterns` callback. + pub files: &'a [String], +} + +/// Run the command. Returns an [`ExitCode`] reflecting the final +/// verdict so the caller can plumb it through to the process +/// exit. Network failures (quarantine / upload) do NOT propagate +/// as errors — they print to the report and the run continues. +/// The only `Err` paths are argument resolution failures (e.g. +/// missing token) and unrecoverable input errors (no XML, parse +/// failure on every file). +#[allow(clippy::too_many_lines)] // Straight-line orchestration: parse → +// quarantine → build spans → upload → render. Splitting this into +// helpers spreads the report builder's ordering across the file +// without buying anything you can't already see by scrolling. +pub async fn run( + opts: JunitProcessOptions<'_>, + output: &mut dyn Output, +) -> Result { + // ── Resolve required inputs up front so we fail before + // printing any of the banner — matches Python's click defaults + // surfacing as exit code 2 when a required flag is missing, + // before the command body runs. + let api_url_raw = resolve_api_url(opts.api_url); + let api_url = Url::parse(&api_url_raw) + .map_err(|e| CliError::Configuration(format!("--api-url is not a valid URL: {e}")))?; + let token = resolve_token(opts.token)?; + let repository = resolve_repository(opts.repository)?; + let tests_target_branch = resolve_tests_target_branch(opts.tests_target_branch)?; + let files = expand_files(opts.files)?; + + // ── Header (printed regardless of outcome). + let mut report = String::new(); + write_header(&mut report); + + // ── Parse. A parse failure aborts before the upload step + // (Python returns ExitCode.GENERIC_ERROR with an inline error + // banner; we do the same). + let parsed = match parse_all(&files) { + Ok(p) => p, + Err(err) => { + write_early_exit( + &mut report, + &format!("Failed to parse JUnit XML: {err}"), + "Check that your test framework is generating valid JUnit XML output.", + ); + emit(output, &report)?; + return Ok(ExitCode::GenericError); + } + }; + + if parsed.cases.is_empty() { + write_early_exit( + &mut report, + "No spans found in the JUnit files", + "Check that the JUnit XML files are not empty.", + ); + emit(output, &report)?; + return Ok(ExitCode::GenericError); + } + + // ── Quarantine check (best effort). Failures here don't + // abort — we fall back to "treat every failure as blocking". + let quarantine_result = quarantine::check_failing( + &api_url, + &token, + &repository, + &tests_target_branch, + &parsed.cases, + ) + .await; + + let (quarantine_result, quarantine_error) = match quarantine_result { + Ok(r) => (r, None::), + Err(QuarantineFailed { message }) => ( + // Conservative fallback: every failure is treated as + // blocking, none as quarantined. + blocking_fallback(&parsed.cases), + Some(message), + ), + }; + + // ── Build spans + upload (best effort). The quarantined set + // gets folded into each case span's `cicd.test.quarantined` + // attribute at build time, so we don't need to re-tag after + // the fact. + let metadata = UploadMetadata { + test_framework: opts.test_framework.map(str::to_string), + test_language: opts.test_language.map(str::to_string), + mergify_test_job_name: env::var("MERGIFY_TEST_JOB_NAME") + .ok() + .filter(|s| !s.is_empty()), + quarantined: quarantine_result + .quarantined + .iter() + .map(|c| c.name.clone()) + .collect(), + }; + let built = spans::build_traces(&parsed, &metadata); + + let client = upload::default_client(); + let upload_error = + match upload::upload(&client, &api_url_raw, &token, &repository, &built.request).await { + Ok(()) => None, + Err(err) => Some(err.to_string()), + }; + + // ── Report sections — order matches Python verbatim. + write_run_id(&mut report, &built.run_id); + + let total_cases = count_test_cases(&parsed); + let nb_failures = count_failures(&parsed); + write_upload_summary( + &mut report, + files.len(), + total_cases, + nb_failures, + upload_error.is_some(), + ); + + if let Some(err) = &upload_error { + write_upload_error_block(&mut report, err); + } + + write_quarantine_section(&mut report, &quarantine_result, quarantine_error.as_deref()); + + // ── Silent-failure detection. If the test runner exited + // non-zero but the JUnit report has no failures, the runner + // probably crashed — fail loudly so the user knows the report + // is incomplete. + if let Some(exit_code) = opts.test_exit_code { + if exit_code != 0 && nb_failures == 0 { + write_silent_failure(&mut report, exit_code); + emit(output, &report)?; + return Ok(ExitCode::GenericError); + } + } + + // ── Verdict. + let final_failure_message = + quarantine_failure_message(&quarantine_result, nb_failures, quarantine_error.is_some()); + let nb_quarantined_failures = quarantine_result.failing.len(); + write_verdict( + &mut report, + final_failure_message.as_deref(), + nb_quarantined_failures, + ); + + emit(output, &report)?; + + Ok(if final_failure_message.is_some() { + ExitCode::GenericError + } else { + ExitCode::Success + }) +} + +fn emit(output: &mut dyn Output, report: &str) -> Result<(), CliError> { + output + .emit(&(), &mut |w| w.write_all(report.as_bytes())) + .map_err(|e| CliError::Generic(format!("could not write output: {e}"))) +} + +fn resolve_api_url(explicit: Option<&str>) -> String { + if let Some(v) = explicit.filter(|s| !s.is_empty()) { + return v.to_string(); + } + if let Ok(v) = env::var("MERGIFY_API_URL") { + if !v.is_empty() { + return v; + } + } + "https://api.mergify.com".to_string() +} + +fn resolve_token(explicit: Option<&str>) -> Result { + if let Some(v) = explicit.filter(|s| !s.is_empty()) { + return Ok(v.to_string()); + } + env::var("MERGIFY_TOKEN") + .ok() + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + CliError::Configuration( + "--token not provided and MERGIFY_TOKEN env var is empty".to_string(), + ) + }) +} + +fn resolve_repository(explicit: Option<&str>) -> Result { + if let Some(v) = explicit.filter(|s| !s.is_empty()) { + return Ok(v.to_string()); + } + detector::get_github_repository().ok_or_else(|| { + CliError::Configuration( + "--repository not provided and could not be detected from the CI environment" + .to_string(), + ) + }) +} + +fn resolve_tests_target_branch(explicit: Option<&str>) -> Result { + let raw = if let Some(v) = explicit.filter(|s| !s.is_empty()) { + v.to_string() + } else { + detector::get_tests_target_branch().ok_or_else(|| { + CliError::Configuration( + "--tests-target-branch not provided and could not be detected".to_string(), + ) + })? + }; + // Mirror Python's `_process_tests_target_branch` callback that + // strips `refs/heads/` so the branch name matches what the + // quarantine API expects. + Ok(raw.strip_prefix("refs/heads/").unwrap_or(&raw).to_string()) +} + +/// Expand each `files` entry: existing literal paths take +/// precedence over glob expansion (so `report[1].xml` keeps +/// working), then `**`/`*`/`?` patterns get expanded. A glob that +/// matches nothing is a hard error — same as Python's behavior, +/// since "no test reports" almost always means a previous CI step +/// silently failed and we want the user to see it. +fn expand_files(raw: &[String]) -> Result, CliError> { + if raw.is_empty() { + return Err(CliError::Configuration( + "at least one JUnit XML file path is required".to_string(), + )); + } + // Preserve insertion order while deduplicating — `Vec` + // is small (a handful of XML reports per CI step), so a linear + // `contains` check on each insert is cheaper than a `BTreeSet` + // and keeps ordering deterministic across runs. + let mut out: Vec = Vec::new(); + for entry in raw { + let literal = Path::new(entry); + if literal.is_file() { + if !out.iter().any(|p| p == literal) { + out.push(literal.to_path_buf()); + } + continue; + } + if has_glob_magic(entry) { + let matches = glob::glob(entry).map_err(|e| { + CliError::Configuration(format!("invalid glob pattern {entry:?}: {e}")) + })?; + let mut any_match = false; + for m in matches { + let path = m.map_err(|e| { + CliError::Configuration(format!("glob walk failed for {entry:?}: {e}")) + })?; + if !path.is_file() { + continue; + } + if !out.iter().any(|p| p == &path) { + out.push(path); + } + any_match = true; + } + if !any_match { + return Err(CliError::Configuration(format!( + "Pattern '{entry}' did not match any file.\n\n\ + This usually indicates that a previous CI step failed to generate the test results.\n\ + Please check if your test execution step completed successfully and produced the expected output files." + ))); + } + continue; + } + if literal.is_dir() { + return Err(CliError::Configuration(format!( + "'{entry}' is a directory, not a JUnit XML file.\n\n\ + Pass a file path or a quoted glob pattern (e.g. 'reports/**/*.xml') instead." + ))); + } + return Err(CliError::Configuration(format!( + "JUnit XML file '{entry}' does not exist.\n\n\ + This usually indicates that a previous CI step failed to generate the test results.\n\ + Please check if your test execution step completed successfully and produced the expected output file." + ))); + } + Ok(out) +} + +fn has_glob_magic(s: &str) -> bool { + s.contains(['*', '?', '[']) +} + +fn parse_all(files: &[PathBuf]) -> Result { + // Concatenate the cases from every file. The OTLP layer + // doesn't care which file a case came from — JUnit suites + // already group them, and that grouping is what becomes a + // suite span downstream. + let mut all_cases = Vec::new(); + for path in files { + let bytes = std::fs::read(path).map_err(|e| junit::InvalidJunitXml { + details: format!("cannot read {}: {e}", path.display()), + })?; + let parsed = junit::parse(&bytes)?; + all_cases.extend(parsed.cases); + } + Ok(ParseResult { cases: all_cases }) +} + +fn count_test_cases(parsed: &ParseResult) -> usize { + parsed.cases.len() +} + +fn count_failures(parsed: &ParseResult) -> usize { + parsed + .cases + .iter() + .filter(|c| c.status.is_failure()) + .count() +} + +fn blocking_fallback(cases: &[TestCase]) -> QuarantineResult { + let failing: Vec = cases + .iter() + .filter(|c| c.status.is_failure()) + .cloned() + .collect(); + let count = failing.len(); + QuarantineResult { + non_quarantined: failing.clone(), + failing, + quarantined: Vec::new(), + failing_not_quarantined_count: count, + } +} + +fn quarantine_failure_message( + result: &QuarantineResult, + nb_failures: usize, + quarantine_errored: bool, +) -> Option { + if quarantine_errored { + return Some(format!( + "Treating {nb_failures}/{nb_failures} failures as blocking" + )); + } + if result.failing_not_quarantined_count > 0 { + let count = result.failing_not_quarantined_count; + let total = result.failing.len(); + let quarantined = total - count; + return Some(format!("{quarantined}/{total} failures quarantined")); + } + None +} + +fn write_header(out: &mut String) { + out.push_str(SEPARATOR); + out.push('\n'); + out.push_str(" 🚀 CI Insights\n"); + out.push('\n'); + out.push_str(concat!( + " Uploads JUnit test results to Mergify CI Insights and evaluates\n", + " quarantine status for failing tests. This step determines the\n", + " final CI status — quarantined failures are ignored.\n", + " Learn more: https://docs.mergify.com/ci-insights/quarantine\n", + )); + out.push_str(SEPARATOR); + out.push('\n'); +} + +fn write_run_id(out: &mut String, run_id: &str) { + out.push('\n'); + out.push_str(&format!(" Run ID: {run_id}\n")); +} + +fn write_upload_summary( + out: &mut String, + reports: usize, + tests: usize, + failures: usize, + upload_failed: bool, +) { + let reports_label = format!( + "{reports} {kind}", + kind = if reports == 1 { "report" } else { "reports" } + ); + let failures_label = format!( + "{failures} {kind}", + kind = if failures == 1 { "failure" } else { "failures" } + ); + if upload_failed { + out.push_str(&format!(" ☁️ {reports_label} not uploaded\n")); + } else { + out.push_str(&format!(" ☁️ {reports_label} uploaded\n")); + } + out.push_str(&format!(" 🧪 {tests} tests ({failures_label})\n")); +} + +fn write_upload_error_block(out: &mut String, error: &str) { + out.push_str("\n ⚠️ Failed to upload test results\n"); + out.push_str(" Mergify CI Insights won't process these test results.\n"); + out.push_str(" Quarantine status and CI outcome are unaffected.\n"); + out.push('\n'); + out.push_str(" ┌ Details\n"); + for line in error.lines() { + out.push_str(&format!(" │ {line}\n")); + } + out.push_str(" └─\n"); +} + +fn write_quarantine_section(out: &mut String, result: &QuarantineResult, error: Option<&str>) { + // Skip the whole section when there's nothing to say — Python + // does the same `if not result.failing_spans and error is None` + // early return. + if result.failing.is_empty() && error.is_none() { + return; + } + out.push('\n'); + out.push_str(SEPARATOR_LIGHT); + out.push('\n'); + out.push('\n'); + out.push_str("🛡️ Quarantine\n"); + + if let Some(err) = error { + out.push('\n'); + out.push_str(" ⚠️ Failed to check quarantine status\n"); + out.push_str(" Contact Mergify support if this error persists.\n"); + out.push('\n'); + out.push_str(" ┌ Details\n"); + for line in err.lines() { + out.push_str(&format!(" │ {line}\n")); + } + out.push_str(" └─\n"); + } + + if !result.quarantined.is_empty() { + out.push_str(&format!( + "\n 🔒 Quarantined ({n}):\n", + n = result.quarantined.len() + )); + for case in &result.quarantined { + out.push_str(&format!(" · {name}\n", name = case.name)); + } + } + + if !result.non_quarantined.is_empty() { + let label = if error.is_some() { + "Could not verify quarantine status" + } else { + "Unquarantined" + }; + out.push_str(&format!( + "\n ❌ {label} ({n}):\n", + n = result.non_quarantined.len() + )); + for case in &result.non_quarantined { + write_failure_block(out, case); + } + } +} + +fn write_failure_block(out: &mut String, case: &TestCase) { + out.push_str(&format!("\n ┌ {name}\n", name = case.name)); + let f = &case.failure; + if f.kind.is_none() && f.message.is_none() && f.stacktrace.is_none() { + out.push_str(" │\n"); + out.push_str(" │ (no error details in JUnit report)\n"); + out.push_str(" └─\n"); + return; + } + if f.kind.is_some() || f.message.is_some() { + let parts: Vec<&str> = [f.kind.as_deref(), f.message.as_deref()] + .into_iter() + .flatten() + .collect(); + out.push_str(" │\n"); + out.push_str(&format!(" │ {joined}\n", joined = parts.join(": "))); + } + if let Some(stack) = &f.stacktrace { + out.push_str(" │\n"); + for line in stack.lines() { + out.push_str(&format!(" │ {line}\n")); + } + } + out.push_str(" └─\n"); +} + +fn write_silent_failure(out: &mut String, test_exit_code: i32) { + out.push('\n'); + out.push_str(SEPARATOR_LIGHT); + out.push('\n'); + out.push('\n'); + out.push_str(&format!( + " ⚠️ Test runner exited with an error (exit code: {test_exit_code})\n" + )); + out.push_str(" but no test failures appear in the JUnit report.\n"); + out.push_str(" The report may be incomplete — check your test runner logs.\n"); + out.push('\n'); + out.push_str(SEPARATOR); + out.push('\n'); + out.push_str("❌ FAIL — test runner exited with an error but no failures were reported\n"); + out.push_str(&format!( + " Exit code: {code}\n", + code = ExitCode::GenericError.as_u8() + )); + out.push_str(SEPARATOR); + out.push('\n'); +} + +fn write_verdict(out: &mut String, failure_message: Option<&str>, nb_quarantined_failures: usize) { + out.push('\n'); + out.push_str(SEPARATOR); + out.push('\n'); + if let Some(msg) = failure_message { + out.push_str(&format!("❌ FAIL — {msg}\n")); + out.push_str(" Exit code: 1\n"); + } else if nb_quarantined_failures == 0 { + out.push_str("✅ OK — all tests passed, no quarantine needed\n"); + out.push_str(" Exit code: 0\n"); + } else { + let n = nb_quarantined_failures; + out.push_str(&format!( + "✅ OK — {n}/{n} failures quarantined, CI status unaffected\n", + )); + out.push_str(" Exit code: 0\n"); + } + out.push_str(SEPARATOR); + out.push('\n'); +} + +fn write_early_exit(out: &mut String, message: &str, hint: &str) { + out.push_str(&format!("❌ FAIL — {message}\n")); + out.push_str(&format!(" {hint}\n")); + out.push_str(" Exit code: 1\n"); + out.push_str(SEPARATOR); + out.push('\n'); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::junit_process::junit::{Failure, TestStatus}; + use std::time::Duration; + + fn case(name: &str, status: TestStatus) -> TestCase { + TestCase { + name: name.to_string(), + suite_name: "s".to_string(), + duration: Some(Duration::from_secs(0)), + file: None, + line: None, + status, + failure: Failure::default(), + } + } + + #[test] + fn quarantine_failure_message_signals_blocking_when_check_errored() { + let result = QuarantineResult { + failing: vec![case("a", TestStatus::Failed), case("b", TestStatus::Failed)], + non_quarantined: vec![case("a", TestStatus::Failed), case("b", TestStatus::Failed)], + quarantined: vec![], + failing_not_quarantined_count: 2, + }; + let msg = quarantine_failure_message(&result, 2, true); + // Pythonic phrasing: "Treating X/X failures as blocking". + assert_eq!(msg.as_deref(), Some("Treating 2/2 failures as blocking")); + } + + #[test] + fn quarantine_failure_message_says_quarantined_when_some_pass() { + let result = QuarantineResult { + failing: vec![case("a", TestStatus::Failed), case("b", TestStatus::Failed)], + quarantined: vec![case("a", TestStatus::Failed)], + non_quarantined: vec![case("b", TestStatus::Failed)], + failing_not_quarantined_count: 1, + }; + // 1/2 still blocking → message says "1/2 quarantined". + let msg = quarantine_failure_message(&result, 2, false); + assert_eq!(msg.as_deref(), Some("1/2 failures quarantined")); + } + + #[test] + fn quarantine_failure_message_none_when_all_quarantined() { + let result = QuarantineResult { + failing: vec![case("a", TestStatus::Failed)], + quarantined: vec![case("a", TestStatus::Failed)], + non_quarantined: vec![], + failing_not_quarantined_count: 0, + }; + // Every failure quarantined → no failure message. + assert_eq!(quarantine_failure_message(&result, 1, false), None); + } + + #[test] + fn expand_files_rejects_unknown_path() { + let err = expand_files(&["does/not/exist.xml".to_string()]).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("does/not/exist.xml") && msg.contains("does not exist"), + "got: {msg}" + ); + } + + #[test] + fn expand_files_rejects_directory() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("sub"); + std::fs::create_dir(&dir).unwrap(); + let err = expand_files(&[dir.to_string_lossy().to_string()]).unwrap_err(); + assert!(err.to_string().contains("directory"), "got: {err}"); + } + + #[test] + fn expand_files_dedupes_repeated_literal_paths() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("a.xml"); + std::fs::write(&path, b"x").unwrap(); + let raw = path.to_string_lossy().to_string(); + let out = expand_files(&[raw.clone(), raw]).unwrap(); + assert_eq!(out.len(), 1); + } + + #[test] + fn expand_files_rejects_pattern_with_no_matches() { + let tmp = tempfile::tempdir().unwrap(); + // tempdir is empty — a wildcard for *.xml here matches nothing. + let pattern = tmp.path().join("*.xml").to_string_lossy().to_string(); + let err = expand_files(&[pattern]).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("did not match any file"), "got: {msg}"); + } + + #[test] + fn write_verdict_renders_blocking_failure() { + let mut s = String::new(); + write_verdict(&mut s, Some("1/2 failures quarantined"), 0); + // The "Exit code: 1" line is what users grep for in CI logs. + assert!(s.contains("❌ FAIL — 1/2 failures quarantined"), "{s}"); + assert!(s.contains("Exit code: 1"), "{s}"); + } + + #[test] + fn write_verdict_renders_all_pass() { + let mut s = String::new(); + write_verdict(&mut s, None, 0); + assert!(s.contains("✅ OK — all tests passed"), "{s}"); + assert!(s.contains("Exit code: 0"), "{s}"); + } + + #[test] + fn write_verdict_renders_all_quarantined_pass() { + let mut s = String::new(); + write_verdict(&mut s, None, 3); + // "3/3 failures quarantined" — the second arm of the verdict. + assert!(s.contains("3/3 failures quarantined"), "{s}"); + assert!(s.contains("Exit code: 0"), "{s}"); + } + + #[test] + fn write_failure_block_handles_missing_details() { + let mut s = String::new(); + write_failure_block(&mut s, &case("orphan", TestStatus::Failed)); + assert!(s.contains("(no error details in JUnit report)"), "{s}"); + } + + #[test] + fn write_failure_block_joins_kind_and_message() { + let mut s = String::new(); + let mut c = case("t", TestStatus::Failed); + c.failure.kind = Some("AssertionError".to_string()); + c.failure.message = Some("assert 1 == 0".to_string()); + c.failure.stacktrace = Some("line1\nline2".to_string()); + write_failure_block(&mut s, &c); + // Kind and message joined with ": ", stacktrace lines each + // prefixed with the box-drawing column. + assert!(s.contains("AssertionError: assert 1 == 0"), "{s}"); + assert!(s.contains("│ line1"), "{s}"); + assert!(s.contains("│ line2"), "{s}"); + } +} diff --git a/crates/mergify-ci/src/junit_process/mod.rs b/crates/mergify-ci/src/junit_process/mod.rs index 56c2bfb9..68c0b588 100644 --- a/crates/mergify-ci/src/junit_process/mod.rs +++ b/crates/mergify-ci/src/junit_process/mod.rs @@ -4,18 +4,27 @@ //! The command port lands in three steps so each layer is //! reviewable on its own: //! -//! - **Phase A** (this commit) — [`junit`]: `JUnit` XML parser +//! - **Phase A** (landed) — [`junit`]: `JUnit` XML parser //! producing semantically-tagged [`TestCase`] values. //! Hermetic, no network. -//! - **Phase B** (next) — OTLP protobuf encoding + upload. -//! - **Phase C** (final) — quarantine API client, CLI dispatch, -//! and `Subcommands::Ci(CiSubcommand::JunitProcess)` promotion -//! from shim to native. -//! -//! Until Phase C lands, the binary keeps shimming -//! `ci junit-process` to Python — but the parser already lives -//! here so subsequent layers have something to consume. +//! - **Phase B** (landed) — [`spans`] turns parser output into an +//! OTLP `ExportTraceServiceRequest`; [`upload`] gzips that +//! protobuf payload and POSTs it to +//! `/v1/repos///ci/traces`. +//! - **Phase C** (this commit) — [`quarantine`] queries the +//! quarantine API; [`command::run`] orchestrates everything and +//! renders the human report so the binary can promote +//! `Subcommands::Ci(CiSubcommand::JunitProcess)` from shim to +//! native. +pub mod command; pub mod junit; +pub mod quarantine; +pub mod spans; +pub mod upload; +pub use command::{JunitProcessOptions, run}; pub use junit::{Failure, InvalidJunitXml, ParseResult, TestCase, TestStatus}; +pub use quarantine::{QuarantineFailed, QuarantineResult, QuarantinedTests}; +pub use spans::{BuiltTraces, UploadMetadata, build_traces}; +pub use upload::{UploadError, default_client, upload}; diff --git a/crates/mergify-ci/src/junit_process/quarantine.rs b/crates/mergify-ci/src/junit_process/quarantine.rs new file mode 100644 index 00000000..66ae783a --- /dev/null +++ b/crates/mergify-ci/src/junit_process/quarantine.rs @@ -0,0 +1,330 @@ +//! Quarantine API client for `mergify ci junit-process`. +//! +//! Mirrors `mergify_cli/ci/junit_processing/quarantine.py`. The +//! API answers "for these failing tests on this branch, which are +//! currently quarantined?" — failures of quarantined tests are +//! ignored by the final CI verdict; failures of non-quarantined +//! tests still block. +//! +//! Endpoint shape: +//! `POST {api_url}/v1/ci/{owner}/repositories/{repo}/quarantines/check` +//! ```json +//! { "tests_names": [...], "branch": "..." } +//! ``` +//! returns +//! ```json +//! { "quarantined_tests_names": [...], "non_quarantined_tests_names": [...] } +//! ``` + +use std::collections::BTreeSet; + +use mergify_core::{ApiFlavor, CliError, HttpClient}; +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::detector; +use crate::junit_process::junit::TestCase; + +/// What the quarantine API told us about a set of failing test +/// case names — sets so membership checks are O(log n) when we +/// later tag each span. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct QuarantinedTests { + pub quarantined: BTreeSet, + pub non_quarantined: BTreeSet, +} + +/// Cross-cutting view of a `junit-process` run: which case names +/// failed, which the backend says are currently quarantined, and +/// which are not. Drives the OTLP attribute tagging (a failing +/// case in the quarantined set gets `cicd.test.quarantined = +/// true`) as well as the CLI verdict (a non-zero count of +/// non-quarantined failures means the run fails). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct QuarantineResult { + /// Every failing test case (status = Failed or Errored). + pub failing: Vec, + /// Subset of `failing` whose names appear in the + /// `quarantined_tests_names` API response. + pub quarantined: Vec, + /// Subset of `failing` the API explicitly reported as + /// non-quarantined. May be a strict subset of + /// `failing - quarantined` when the API silently dropped some + /// names; we trust the API's split rather than reconstructing + /// it locally, to match Python. + pub non_quarantined: Vec, + /// Count of failing tests that are NOT quarantined. This is + /// what determines the final exit code: zero → CI passes, + /// non-zero → CI fails. + pub failing_not_quarantined_count: usize, +} + +#[derive(Debug, Clone)] +pub struct QuarantineFailed { + pub message: String, +} + +impl std::fmt::Display for QuarantineFailed { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for QuarantineFailed {} + +/// Find every test case in `cases` whose status is a failure +/// (`Failed` or `Errored`). Mirrors Python's filter; the spans +/// inheriting this property are tagged `cicd.test.quarantined` +/// at the `spans` layer based on the result of [`check`]. +fn failing_cases(cases: &[TestCase]) -> Vec { + cases + .iter() + .filter(|c| c.status.is_failure()) + .cloned() + .collect() +} + +/// Query the Mergify CI Insights quarantine API for the names in +/// `failing_names` against the given branch. Returns the API's +/// own split — we do NOT reconstruct `non_quarantined = +/// failing - quarantined` locally, to match Python's behavior. +/// +/// `failing_names` may contain duplicates if the `JUnit` input has +/// the same test name reported by multiple suites; that's fine — +/// the API treats names as a set. +pub async fn check( + api_url: &Url, + token: &str, + repository: &str, + branch: &str, + failing_names: &[String], +) -> Result { + let (owner, repo) = detector::split_owner_repo(repository).map_err(|e| QuarantineFailed { + message: e.to_string(), + })?; + + let client = HttpClient::new(api_url.clone(), token, ApiFlavor::Mergify).map_err(|e| { + QuarantineFailed { + message: e.to_string(), + } + })?; + + let path = format!("/v1/ci/{owner}/repositories/{repo}/quarantines/check"); + let body = CheckRequest { + tests_names: failing_names, + branch, + }; + + let resp: CheckResponse = client + .post(&path, &body) + .await + .map_err(|e| QuarantineFailed { + message: e.to_string(), + })?; + + Ok(QuarantinedTests { + quarantined: resp.quarantined_tests_names.into_iter().collect(), + non_quarantined: resp.non_quarantined_tests_names.into_iter().collect(), + }) +} + +/// Categorize the failing test cases into quarantined and +/// non-quarantined buckets, given the API's verdict. The result +/// keeps the failing-cases list intact so the CLI can render the +/// "X/Y failures quarantined" summary without re-walking the +/// original `JUnit` input. +#[must_use] +pub fn categorize(failing: Vec, verdict: &QuarantinedTests) -> QuarantineResult { + let mut quarantined = Vec::new(); + let mut non_quarantined = Vec::new(); + let mut failing_not_quarantined_count = 0; + + for case in &failing { + let is_quarantined = verdict.quarantined.contains(&case.name); + if is_quarantined { + quarantined.push(case.clone()); + } else { + failing_not_quarantined_count += 1; + if verdict.non_quarantined.contains(&case.name) { + non_quarantined.push(case.clone()); + } + } + } + + QuarantineResult { + failing, + quarantined, + non_quarantined, + failing_not_quarantined_count, + } +} + +/// Resolve the failing test cases for `cases`, hit the quarantine +/// API, and combine the two into a [`QuarantineResult`]. The CLI +/// orchestration uses the bundled fn so the happy path is a single +/// call instead of three. +pub async fn check_failing( + api_url: &Url, + token: &str, + repository: &str, + branch: &str, + cases: &[TestCase], +) -> Result { + let failing = failing_cases(cases); + if failing.is_empty() { + return Ok(QuarantineResult::default()); + } + let names: Vec = failing.iter().map(|c| c.name.clone()).collect(); + let verdict = check(api_url, token, repository, branch, &names).await?; + Ok(categorize(failing, &verdict)) +} + +/// Lift a [`QuarantineFailed`] into the shared [`CliError`] so +/// callers can `?` it. +impl From for CliError { + fn from(err: QuarantineFailed) -> Self { + Self::Generic(err.message) + } +} + +#[derive(Serialize)] +struct CheckRequest<'a> { + tests_names: &'a [String], + branch: &'a str, +} + +#[derive(Deserialize)] +struct CheckResponse { + quarantined_tests_names: Vec, + non_quarantined_tests_names: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::junit_process::junit::{Failure, TestStatus}; + use std::time::Duration; + use wiremock::matchers::{body_json, header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn case(name: &str, status: TestStatus) -> TestCase { + TestCase { + name: name.to_string(), + suite_name: "s".to_string(), + duration: Some(Duration::from_secs(0)), + file: None, + line: None, + status, + failure: Failure::default(), + } + } + + #[test] + fn categorize_buckets_quarantined_separately() { + let failing = vec![ + case("a", TestStatus::Failed), + case("b", TestStatus::Errored), + case("c", TestStatus::Failed), + ]; + let verdict = QuarantinedTests { + quarantined: ["a".to_string()].into_iter().collect(), + non_quarantined: ["b".to_string(), "c".to_string()].into_iter().collect(), + }; + let r = categorize(failing, &verdict); + assert_eq!( + r.quarantined.iter().map(|c| &c.name).collect::>(), + vec!["a"] + ); + assert_eq!( + r.non_quarantined + .iter() + .map(|c| &c.name) + .collect::>(), + vec!["b", "c"] + ); + // 2 failures not quarantined — drives the non-zero exit code. + assert_eq!(r.failing_not_quarantined_count, 2); + } + + #[test] + fn categorize_counts_unknown_as_not_quarantined() { + // The API may omit names it doesn't recognize (e.g. typo, + // never seen before). Python treats those as failures that + // weren't quarantined → must count toward + // `failing_not_quarantined_count` even though they're not + // explicitly listed in `non_quarantined_tests_names`. + let failing = vec![case("x", TestStatus::Failed)]; + let verdict = QuarantinedTests::default(); + let r = categorize(failing, &verdict); + assert!(r.quarantined.is_empty()); + assert!(r.non_quarantined.is_empty()); + assert_eq!(r.failing_not_quarantined_count, 1); + } + + #[tokio::test] + async fn check_posts_to_owner_scoped_path() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/ci/owner/repositories/repo/quarantines/check")) + .and(header("Authorization", "Bearer secret")) + .and(body_json(serde_json::json!({ + "tests_names": ["t1", "t2"], + "branch": "main", + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "quarantined_tests_names": ["t1"], + "non_quarantined_tests_names": ["t2"], + }))) + .mount(&server) + .await; + + let api_url = Url::parse(&server.uri()).unwrap(); + let verdict = check( + &api_url, + "secret", + "owner/repo", + "main", + &["t1".to_string(), "t2".to_string()], + ) + .await + .expect("API call succeeds"); + assert_eq!(verdict.quarantined.len(), 1); + assert!(verdict.quarantined.contains("t1")); + assert!(verdict.non_quarantined.contains("t2")); + } + + #[tokio::test] + async fn check_failing_short_circuits_when_no_failures() { + // Empty failing list → no HTTP call, no QuarantineResult to + // categorize. Mirrors Python's early return. If the function + // accidentally tried to POST, the bogus URL would fail. + let api_url = Url::parse("http://127.0.0.1:1").unwrap(); + let cases = vec![ + case("ok", TestStatus::Passed), + case("skipped", TestStatus::Skipped), + ]; + let r = check_failing(&api_url, "tok", "owner/repo", "main", &cases) + .await + .expect("must short-circuit"); + assert!(r.failing.is_empty()); + assert_eq!(r.failing_not_quarantined_count, 0); + } + + #[tokio::test] + async fn check_surfaces_non_200_as_quarantine_failed() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(503).set_body_string("backend down")) + .mount(&server) + .await; + let api_url = Url::parse(&server.uri()).unwrap(); + let err = check(&api_url, "tok", "owner/repo", "main", &["t".to_string()]) + .await + .expect_err("503 must surface as QuarantineFailed"); + assert!( + err.message.contains("503") || err.message.contains("backend down"), + "got: {}", + err.message, + ); + } +} diff --git a/crates/mergify-ci/src/junit_process/spans.rs b/crates/mergify-ci/src/junit_process/spans.rs new file mode 100644 index 00000000..1f568528 --- /dev/null +++ b/crates/mergify-ci/src/junit_process/spans.rs @@ -0,0 +1,720 @@ +//! `TestCase` → OTLP `ExportTraceServiceRequest`. +//! +//! Mirrors the span layout `mergify_cli/ci/junit_processing/junit.py` +//! produces: +//! +//! - one root **session** span per upload (parent: optional +//! `MERGIFY_TRACEPARENT`), +//! - one **suite** span per `` (parent: session), +//! - one **case** span per `` (parent: suite). +//! +//! All spans share a single OTLP `Resource` carrying the CI +//! environment attributes the backend uses for routing and +//! dashboards (provider, pipeline, run, branch, …). Common +//! attributes (`test.framework`, `test.language`) — the +//! caller-supplied per-upload metadata — get folded into every +//! span on top of its scope-specific attributes. + +use std::collections::BTreeSet; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest; +use opentelemetry_proto::tonic::common::v1::any_value::Value as AnyValueOneof; +use opentelemetry_proto::tonic::common::v1::{AnyValue, InstrumentationScope, KeyValue}; +use opentelemetry_proto::tonic::resource::v1::Resource; +use opentelemetry_proto::tonic::trace::v1::status::StatusCode; +use opentelemetry_proto::tonic::trace::v1::{ResourceSpans, ScopeSpans, Span, Status}; + +use crate::detector; +use crate::junit_process::junit::{ParseResult, TestCase, TestStatus}; + +/// Caller-supplied per-upload metadata. `test_framework` and +/// `test_language` get propagated to every span as attributes; +/// `run_id` is the human-readable identifier surfaced in the CLI +/// output (e.g. `Run ID: `). +#[derive(Debug, Clone, Default)] +pub struct UploadMetadata { + pub test_framework: Option, + pub test_language: Option, + /// Optional `mergify.test.job.name` attribute set when the + /// `MERGIFY_TEST_JOB_NAME` env var is present at parse time. + pub mergify_test_job_name: Option, + /// Set of test names the quarantine API confirmed are + /// currently quarantined. Each case span whose name is in this + /// set gets `cicd.test.quarantined = true`; everything else + /// defaults to `false`. Pass an empty set when the quarantine + /// check was skipped (no failures) or failed (network/API + /// error) — the spans then upload with everything marked + /// non-quarantined, which is the conservative default. + pub quarantined: BTreeSet, +} + +/// Result of converting a [`ParseResult`] (one or more `JUnit` +/// files) into a wire-ready OTLP request. +#[derive(Debug, Clone)] +pub struct BuiltTraces { + /// Lowercase hex identifier the CLI prints back to the user. + /// Same value populates the `test.run.id` resource attribute. + pub run_id: String, + pub request: ExportTraceServiceRequest, +} + +/// Convert a [`ParseResult`] (the union of every parsed `JUnit` +/// file) into an OTLP `ExportTraceServiceRequest`. +/// +/// Random trace and span IDs are produced via [`getrandom::fill`]. +/// `now_unix_nanos` and `id_source` exist so tests can pin a +/// deterministic clock and randomness source; production callers +/// use [`build_traces`] which fills them from +/// `SystemTime::now()` and `getrandom`. +#[must_use] +pub fn build_traces(parsed: &ParseResult, metadata: &UploadMetadata) -> BuiltTraces { + build_traces_with(parsed, metadata, system_now_unix_nanos(), &mut OsRandom) +} + +#[allow(clippy::too_many_lines)] // Straight-line span construction; splitting +// would just hide the per-span attribute set behind helper noise that's +// harder to skim than the inline form. +fn build_traces_with( + parsed: &ParseResult, + metadata: &UploadMetadata, + now_unix_nanos: u64, + rng: &mut dyn RandomBytes, +) -> BuiltTraces { + let trace_id = rng.bytes16(); + let session_span_id = rng.bytes8(); + let run_id = hex_lower(&session_span_id); + + let resource = build_resource(&run_id, metadata); + + let common_attrs = common_attributes(metadata); + + let mut spans: Vec = Vec::new(); + + // Suite spans are appended after we know each suite's earliest + // case start (so the suite's start_time covers all its cases). + // Group the parser's flat case list by `suite_name`, preserving + // first-seen order so the wire output matches Python's + // document-order iteration over `` elements. + let suites = group_by_suite(&parsed.cases); + + let mut session_start_time_unix_nanos = now_unix_nanos; + + for (suite_name, suite_cases) in &suites { + let suite_span_id = rng.bytes8(); + let mut suite_start_time_unix_nanos = now_unix_nanos; + + for case in suite_cases { + let case_span_id = rng.bytes8(); + let start_time_unix_nanos = case_start_time(now_unix_nanos, case.duration); + suite_start_time_unix_nanos = suite_start_time_unix_nanos.min(start_time_unix_nanos); + + let mut attrs = common_attrs.clone(); + attrs.push(kv_string("test.scope", "case")); + attrs.push(kv_string("test.case.name", &case.name)); + attrs.push(kv_string("code.function.name", &case.name)); + attrs.push(kv_bool( + "cicd.test.quarantined", + metadata.quarantined.contains(&case.name), + )); + if let Some(file) = &case.file { + attrs.push(kv_string("code.filepath", file)); + } + if let Some(line) = &case.line { + attrs.push(kv_string("code.lineno", line)); + } + attrs.push(kv_string( + "test.case.result.status", + case.status.status_attr(), + )); + + let status_code = match case.status { + TestStatus::Passed | TestStatus::Skipped => StatusCode::Ok, + TestStatus::Failed | TestStatus::Errored => StatusCode::Error, + }; + if case.status.is_failure() { + if let Some(kind) = &case.failure.kind { + attrs.push(kv_string("exception.type", kind)); + } + if let Some(msg) = &case.failure.message { + attrs.push(kv_string("exception.message", msg)); + } + if let Some(trace) = &case.failure.stacktrace { + attrs.push(kv_string("exception.stacktrace", trace)); + } + } + + spans.push(Span { + trace_id: trace_id.to_vec(), + span_id: case_span_id.to_vec(), + trace_state: String::new(), + parent_span_id: suite_span_id.to_vec(), + flags: 0, + name: case.name.clone(), + kind: 0, + start_time_unix_nano: start_time_unix_nanos, + end_time_unix_nano: now_unix_nanos, + attributes: attrs, + dropped_attributes_count: 0, + events: Vec::new(), + dropped_events_count: 0, + links: Vec::new(), + dropped_links_count: 0, + status: Some(Status { + message: String::new(), + code: status_code.into(), + }), + }); + } + + session_start_time_unix_nanos = + session_start_time_unix_nanos.min(suite_start_time_unix_nanos); + + let mut suite_attrs = common_attrs.clone(); + suite_attrs.push(kv_string("test.case.name", suite_name)); + suite_attrs.push(kv_string("test.scope", "suite")); + spans.push(Span { + trace_id: trace_id.to_vec(), + span_id: suite_span_id.to_vec(), + trace_state: String::new(), + parent_span_id: session_span_id.to_vec(), + flags: 0, + name: suite_name.clone(), + kind: 0, + start_time_unix_nano: suite_start_time_unix_nanos, + end_time_unix_nano: now_unix_nanos, + attributes: suite_attrs, + dropped_attributes_count: 0, + events: Vec::new(), + dropped_events_count: 0, + links: Vec::new(), + dropped_links_count: 0, + status: None, + }); + } + + // Session is the root span. Place it FIRST in the wire vector + // so the backend has it before any child references it. + let mut session_attrs = common_attrs.clone(); + session_attrs.push(kv_string("test.scope", "session")); + let session_span = Span { + trace_id: trace_id.to_vec(), + span_id: session_span_id.to_vec(), + trace_state: String::new(), + parent_span_id: Vec::new(), + flags: 0, + name: "test session".to_string(), + kind: 0, + start_time_unix_nano: session_start_time_unix_nanos, + end_time_unix_nano: now_unix_nanos, + attributes: session_attrs, + dropped_attributes_count: 0, + events: Vec::new(), + dropped_events_count: 0, + links: Vec::new(), + dropped_links_count: 0, + status: None, + }; + spans.insert(0, session_span); + + let resource_spans = ResourceSpans { + resource: Some(resource), + scope_spans: vec![ScopeSpans { + scope: Some(InstrumentationScope { + name: "mergify-cli".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + attributes: Vec::new(), + dropped_attributes_count: 0, + }), + spans, + schema_url: String::new(), + }], + schema_url: String::new(), + }; + + BuiltTraces { + run_id, + request: ExportTraceServiceRequest { + resource_spans: vec![resource_spans], + }, + } +} + +fn group_by_suite(cases: &[TestCase]) -> Vec<(String, Vec<&TestCase>)> { + // Preserve first-seen order. A `Vec<(K, Vec<&T>)>` linear-scan + // group-by is fine here — JUnit reports rarely exceed a handful + // of suites, and we get deterministic iteration for free. + let mut groups: Vec<(String, Vec<&TestCase>)> = Vec::new(); + for case in cases { + if let Some(existing) = groups.iter_mut().find(|(name, _)| *name == case.suite_name) { + existing.1.push(case); + } else { + groups.push((case.suite_name.clone(), vec![case])); + } + } + groups +} + +fn case_start_time(now_unix_nanos: u64, duration: Option) -> u64 { + // Mirror Python's `now - int(float(time) * 10e9)`. The `10e9` + // is a long-standing bug in the Python emitter (should be + // `1e9`), so cases appear ~10× longer on the wire than the + // JUnit report claims. The Mergify backend interprets the + // current shape, so we replicate it verbatim — fixing it + // here would silently change every uploaded dashboard. If + // the Python side ever fixes the multiplier, mirror the + // change in both places. + let Some(d) = duration else { + return now_unix_nanos; + }; + #[allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss + )] + let scaled_nanos = (d.as_secs_f64() * 10e9) as u64; + now_unix_nanos.saturating_sub(scaled_nanos) +} + +fn build_resource(run_id: &str, metadata: &UploadMetadata) -> Resource { + let mut attrs = Vec::new(); + attrs.push(kv_string("test.run.id", run_id)); + + if let Some(job_name) = &metadata.mergify_test_job_name { + attrs.push(kv_string("mergify.test.job.name", job_name)); + } + + if let Some(name) = detector::get_pipeline_name() { + attrs.push(kv_string("cicd.pipeline.name", &name)); + } + if let Some(name) = detector::get_job_name() { + attrs.push(kv_string("cicd.pipeline.task.name", &name)); + } + if let Some(id) = detector::get_cicd_pipeline_run_id() { + attrs.push(kv_string("cicd.pipeline.run.id", &id)); + } + if let Some(url) = detector::get_cicd_pipeline_run_url() { + attrs.push(kv_string("cicd.pipeline.run.url", &url)); + } + if let Some(attempt) = detector::get_cicd_pipeline_run_attempt() { + #[allow(clippy::cast_possible_wrap)] + attrs.push(kv_int("cicd.pipeline.run.attempt", attempt as i64)); + } + if let Some(sha) = detector::get_head_sha() { + attrs.push(kv_string("vcs.ref.head.revision", &sha)); + } + if let Some(name) = detector::get_head_ref_name() { + attrs.push(kv_string("vcs.ref.head.name", &name)); + } + if let Some(name) = detector::get_base_ref_name() { + attrs.push(kv_string("vcs.ref.base.name", &name)); + } + if let Some(url) = detector::get_repository_url() { + attrs.push(kv_string("vcs.repository.url.full", &url)); + } + if let Some(repo) = detector::get_github_repository() { + attrs.push(kv_string("vcs.repository.name", &repo)); + } + if let Some(name) = detector::get_cicd_pipeline_runner_name() { + attrs.push(kv_string("cicd.pipeline.runner.name", &name)); + } + if let Some(provider) = detector::get_ci_provider() { + attrs.push(kv_string("cicd.provider.name", provider.as_str())); + } + + Resource { + attributes: attrs, + dropped_attributes_count: 0, + entity_refs: Vec::new(), + } +} + +fn common_attributes(metadata: &UploadMetadata) -> Vec { + let mut attrs = Vec::new(); + if let Some(framework) = &metadata.test_framework { + attrs.push(kv_string("test.framework", framework)); + } + if let Some(language) = &metadata.test_language { + attrs.push(kv_string("test.language", language)); + } + attrs +} + +fn kv(key: &str, value: AnyValueOneof) -> KeyValue { + // `..Default::default()` so any future proto-generated fields + // (e.g. the profiling-signal `key_strindex` already present in + // 0.32) round-trip as their default without us having to spell + // them out. + KeyValue { + key: key.to_string(), + value: Some(AnyValue { value: Some(value) }), + ..KeyValue::default() + } +} + +fn kv_string(key: &str, value: &str) -> KeyValue { + kv(key, AnyValueOneof::StringValue(value.to_string())) +} + +fn kv_bool(key: &str, value: bool) -> KeyValue { + kv(key, AnyValueOneof::BoolValue(value)) +} + +fn kv_int(key: &str, value: i64) -> KeyValue { + kv(key, AnyValueOneof::IntValue(value)) +} + +fn hex_lower(bytes: &[u8]) -> String { + use std::fmt::Write as _; + let mut out = String::with_capacity(bytes.len() * 2); + for b in bytes { + // `write!` on a `String` is infallible; the `_` discards + // the `Ok` value rather than going through a `format!` + // round trip that allocates per byte. + let _ = write!(out, "{b:02x}"); + } + out +} + +fn system_now_unix_nanos() -> u64 { + match SystemTime::now().duration_since(UNIX_EPOCH) { + Ok(d) => u64::try_from(d.as_nanos()).unwrap_or(u64::MAX), + Err(_) => 0, + } +} + +/// Internal seam for tests: any source of random bytes. The +/// production impl reads from the OS via `getrandom`; tests +/// hand-feed deterministic byte streams. +trait RandomBytes { + fn bytes8(&mut self) -> [u8; 8]; + fn bytes16(&mut self) -> [u8; 16]; +} + +struct OsRandom; + +impl RandomBytes for OsRandom { + fn bytes8(&mut self) -> [u8; 8] { + let mut buf = [0u8; 8]; + getrandom::fill(&mut buf).expect("OS rng available"); + buf + } + fn bytes16(&mut self) -> [u8; 16] { + let mut buf = [0u8; 16]; + getrandom::fill(&mut buf).expect("OS rng available"); + buf + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::junit_process::junit::Failure; + use crate::testing::with_ci_env; + + /// Deterministic byte source for tests. Bytes are consumed + /// in order. Tests provide enough buffer for the spans they + /// expect to build; running out of bytes panics so a wrong + /// span-count assumption is loud rather than silent. + struct FixedRng { + bytes: Vec, + cursor: usize, + } + impl FixedRng { + fn new(bytes: Vec) -> Self { + Self { bytes, cursor: 0 } + } + fn take(&mut self, n: usize) -> Vec { + let slice = self.bytes[self.cursor..self.cursor + n].to_vec(); + self.cursor += n; + slice + } + } + impl RandomBytes for FixedRng { + fn bytes8(&mut self) -> [u8; 8] { + let mut out = [0u8; 8]; + out.copy_from_slice(&self.take(8)); + out + } + fn bytes16(&mut self) -> [u8; 16] { + let mut out = [0u8; 16]; + out.copy_from_slice(&self.take(16)); + out + } + } + + fn sample_parsed() -> ParseResult { + ParseResult { + cases: vec![ + TestCase { + name: "tests.test_func.test_success".to_string(), + suite_name: "pytest".to_string(), + duration: Some(Duration::from_secs_f64(0.001)), + file: None, + line: None, + status: TestStatus::Passed, + failure: Failure::default(), + }, + TestCase { + name: "tests.test_func.test_failed".to_string(), + suite_name: "pytest".to_string(), + duration: Some(Duration::from_secs_f64(0.002)), + file: Some("tests/test_func.py".to_string()), + line: Some("6".to_string()), + status: TestStatus::Failed, + failure: Failure { + kind: None, + message: Some("assert 1 == 0".to_string()), + stacktrace: Some("trace".to_string()), + }, + }, + ], + } + } + + #[test] + fn builds_session_suite_and_case_spans_with_consistent_parent_chain() { + // 16 bytes for trace_id; 4×8 bytes for session, suite, + // case-1, case-2 span ids. Distinct fill bytes per region + // so the assertions below can tell them apart at a glance. + let mut bytes: Vec = Vec::with_capacity(16 + 4 * 8); + bytes.extend(std::iter::repeat_n(0xAA, 16)); // trace_id + bytes.extend(std::iter::repeat_n(0x11, 8)); // session + bytes.extend(std::iter::repeat_n(0x22, 8)); // suite + bytes.extend(std::iter::repeat_n(0x33, 8)); // case-1 + bytes.extend(std::iter::repeat_n(0x44, 8)); // case-2 + let mut rng = FixedRng::new(bytes); + + let now: u64 = 1_700_000_000_000_000_000; + let metadata = UploadMetadata::default(); + let built = with_ci_env(&[], || { + build_traces_with(&sample_parsed(), &metadata, now, &mut rng) + }); + + // run_id is the session span id rendered as hex. + assert_eq!(built.run_id, "1111111111111111"); + + let resource_spans = &built.request.resource_spans; + assert_eq!(resource_spans.len(), 1); + let scope_spans = &resource_spans[0].scope_spans; + assert_eq!(scope_spans.len(), 1); + let spans = &scope_spans[0].spans; + // 1 session + 1 suite + 2 cases. + assert_eq!(spans.len(), 4); + + // Session is first; suite reports session as parent; both + // cases report suite as parent. + let session = &spans[0]; + assert_eq!(session.name, "test session"); + assert!(session.parent_span_id.is_empty()); + assert_eq!(session.span_id, vec![0x11; 8]); + assert_eq!(session.trace_id, vec![0xAA; 16]); + + let suite = spans.iter().find(|s| s.name == "pytest").unwrap(); + assert_eq!(suite.parent_span_id, vec![0x11; 8]); + assert_eq!(suite.span_id, vec![0x22; 8]); + + let cases: Vec<&Span> = spans + .iter() + .filter(|s| s.name.starts_with("tests.test_func")) + .collect(); + assert_eq!(cases.len(), 2); + for case in &cases { + assert_eq!(case.parent_span_id, vec![0x22; 8]); + assert_eq!(case.trace_id, vec![0xAA; 16]); + } + } + + #[test] + fn case_status_maps_to_otlp_status_code() { + let mut rng = FixedRng::new(vec![0xFF; 256]); + let now: u64 = 1_700_000_000_000_000_000; + let metadata = UploadMetadata::default(); + let built = with_ci_env(&[], || { + build_traces_with(&sample_parsed(), &metadata, now, &mut rng) + }); + let spans = &built.request.resource_spans[0].scope_spans[0].spans; + + let pass = spans + .iter() + .find(|s| s.name == "tests.test_func.test_success") + .unwrap(); + assert_eq!( + pass.status.as_ref().unwrap().code, + i32::from(StatusCode::Ok), + ); + + let fail = spans + .iter() + .find(|s| s.name == "tests.test_func.test_failed") + .unwrap(); + assert_eq!( + fail.status.as_ref().unwrap().code, + i32::from(StatusCode::Error), + ); + // exception.message / .stacktrace are attached to failing + // cases; passing ones don't get the keys at all. + let fail_attr_keys: Vec<&str> = fail.attributes.iter().map(|kv| kv.key.as_str()).collect(); + assert!(fail_attr_keys.contains(&"exception.message")); + assert!(fail_attr_keys.contains(&"exception.stacktrace")); + let pass_attr_keys: Vec<&str> = pass.attributes.iter().map(|kv| kv.key.as_str()).collect(); + assert!(!pass_attr_keys.contains(&"exception.message")); + } + + #[test] + fn case_attributes_include_file_line_and_code_function() { + let mut rng = FixedRng::new(vec![0xFF; 256]); + let metadata = UploadMetadata::default(); + let built = with_ci_env(&[], || { + build_traces_with(&sample_parsed(), &metadata, 0, &mut rng) + }); + let spans = &built.request.resource_spans[0].scope_spans[0].spans; + let fail = spans + .iter() + .find(|s| s.name == "tests.test_func.test_failed") + .unwrap(); + let by_key: std::collections::HashMap<&str, &AnyValue> = fail + .attributes + .iter() + .filter_map(|kv| kv.value.as_ref().map(|v| (kv.key.as_str(), v))) + .collect(); + // file/line straight passthrough from the parser. + assert!(matches!( + by_key.get("code.filepath").and_then(|v| v.value.as_ref()), + Some(AnyValueOneof::StringValue(s)) if s == "tests/test_func.py" + )); + assert!(matches!( + by_key.get("code.lineno").and_then(|v| v.value.as_ref()), + Some(AnyValueOneof::StringValue(s)) if s == "6" + )); + // code.function.name mirrors the test case name. + assert!(matches!( + by_key.get("code.function.name").and_then(|v| v.value.as_ref()), + Some(AnyValueOneof::StringValue(s)) if s == "tests.test_func.test_failed" + )); + // cicd.test.quarantined defaults to false on every case; + // the quarantine layer flips it later (Phase C). + assert!(matches!( + by_key + .get("cicd.test.quarantined") + .and_then(|v| v.value.as_ref()), + Some(AnyValueOneof::BoolValue(false)) + )); + } + + #[test] + fn resource_attributes_carry_ci_env_when_set() { + let mut rng = FixedRng::new(vec![0xFF; 256]); + let metadata = UploadMetadata::default(); + let built = with_ci_env( + &[ + ("GITHUB_ACTIONS", Some("true")), + ("GITHUB_REPOSITORY", Some("owner/repo")), + ("GITHUB_WORKFLOW", Some("CI")), + ("GITHUB_JOB", Some("build")), + ("GITHUB_RUN_ID", Some("12345")), + ("GITHUB_RUN_ATTEMPT", Some("2")), + ("GITHUB_SHA", Some("abc123")), + ("GITHUB_REF_NAME", Some("main")), + ("RUNNER_NAME", Some("runner-1")), + ], + || build_traces_with(&sample_parsed(), &metadata, 0, &mut rng), + ); + let resource = built.request.resource_spans[0].resource.as_ref().unwrap(); + let by_key: std::collections::HashMap<&str, &AnyValue> = resource + .attributes + .iter() + .filter_map(|kv| kv.value.as_ref().map(|v| (kv.key.as_str(), v))) + .collect(); + + for (key, expected) in [ + ("cicd.provider.name", "github_actions"), + ("cicd.pipeline.name", "CI"), + ("cicd.pipeline.task.name", "build"), + ("cicd.pipeline.run.id", "12345"), + ("vcs.ref.head.revision", "abc123"), + ("vcs.ref.head.name", "main"), + ("vcs.repository.name", "owner/repo"), + ("cicd.pipeline.runner.name", "runner-1"), + ] { + assert!( + matches!( + by_key.get(key).and_then(|v| v.value.as_ref()), + Some(AnyValueOneof::StringValue(s)) if s == expected + ), + "expected resource attr {key} == {expected:?}, got {:?}", + by_key.get(key), + ); + } + // Attempt comes through as int, not string. + assert!(matches!( + by_key + .get("cicd.pipeline.run.attempt") + .and_then(|v| v.value.as_ref()), + Some(AnyValueOneof::IntValue(2)) + )); + } + + #[test] + fn common_attributes_propagate_to_every_span() { + let mut rng = FixedRng::new(vec![0xFF; 256]); + let metadata = UploadMetadata { + test_framework: Some("pytest".to_string()), + test_language: Some("python".to_string()), + mergify_test_job_name: None, + quarantined: BTreeSet::new(), + }; + let built = with_ci_env(&[], || { + build_traces_with(&sample_parsed(), &metadata, 0, &mut rng) + }); + let spans = &built.request.resource_spans[0].scope_spans[0].spans; + for span in spans { + let keys: Vec<&str> = span.attributes.iter().map(|kv| kv.key.as_str()).collect(); + assert!( + keys.contains(&"test.framework"), + "span {} missing test.framework: {keys:?}", + span.name + ); + assert!( + keys.contains(&"test.language"), + "span {} missing test.language: {keys:?}", + span.name + ); + } + } + + #[test] + fn timestamps_propagate_duration_and_session_envelopes_all_cases() { + // Cases of durations 0.001s and 0.002s. With the 10× scale + // Python uses, those become 0.01s and 0.02s in nanos before + // `now`. The session start must be the earliest case start. + let mut rng = FixedRng::new(vec![0xFF; 256]); + let now: u64 = 1_000_000_000_000_000_000; + let metadata = UploadMetadata::default(); + let built = with_ci_env(&[], || { + build_traces_with(&sample_parsed(), &metadata, now, &mut rng) + }); + let spans = &built.request.resource_spans[0].scope_spans[0].spans; + let session = spans.iter().find(|s| s.name == "test session").unwrap(); + let suite = spans.iter().find(|s| s.name == "pytest").unwrap(); + let earliest_case = spans + .iter() + .filter(|s| s.name.starts_with("tests.")) + .map(|s| s.start_time_unix_nano) + .min() + .unwrap(); + assert_eq!(session.start_time_unix_nano, earliest_case); + assert_eq!(suite.start_time_unix_nano, earliest_case); + // End time is `now` for every span. + assert_eq!(session.end_time_unix_nano, now); + assert_eq!(suite.end_time_unix_nano, now); + for span in spans { + assert!( + span.end_time_unix_nano == now, + "{}: {}", + span.name, + span.end_time_unix_nano + ); + } + } +} diff --git a/crates/mergify-ci/src/junit_process/upload.rs b/crates/mergify-ci/src/junit_process/upload.rs new file mode 100644 index 00000000..35e60a67 --- /dev/null +++ b/crates/mergify-ci/src/junit_process/upload.rs @@ -0,0 +1,260 @@ +//! POST an `ExportTraceServiceRequest` to the Mergify CI Insights +//! traces endpoint as OTLP/HTTP/protobuf with gzip. +//! +//! Mirrors `mergify_cli/ci/junit_processing/upload.py`. The Python +//! version delegates to `opentelemetry-exporter-otlp-proto-http`, +//! which boils down to a single `POST` with three headers: +//! +//! - `Authorization: Bearer ` +//! - `Content-Type: application/x-protobuf` +//! - `Content-Encoding: gzip` +//! +//! No retries, no streaming, no SDK lifecycle — small enough to do +//! by hand with `reqwest` so we don't drag in `opentelemetry-otlp` +//! and its tonic dependency. The endpoint +//! (`{api_url}/v1/repos/{repository}/ci/traces`) matches the +//! Python URL byte for byte. + +use std::io::Write as _; +use std::time::Duration; + +use flate2::Compression; +use flate2::write::GzEncoder; +use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest; +use prost::Message as _; + +#[derive(Debug)] +pub struct UploadError { + pub status: Option, + pub message: String, +} + +impl std::fmt::Display for UploadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(status) = self.status { + write!( + f, + "Failed to export span batch code: {status}, reason: {msg}", + msg = self.message, + ) + } else { + write!(f, "Failed to export span batch: {}", self.message) + } + } +} + +impl std::error::Error for UploadError {} + +const ENDPOINT_PATH: &str = "/v1/repos/"; +const ENDPOINT_SUFFIX: &str = "/ci/traces"; + +fn endpoint_url(api_url: &str, repository: &str) -> String { + // The shape `/v1/repos///ci/traces` + // matches the Python implementation. The repository segment is + // pre-validated by `detector::split_owner_repo` at the CLI + // boundary, so we can interpolate it without further escaping. + let trimmed = api_url.trim_end_matches('/'); + format!("{trimmed}{ENDPOINT_PATH}{repository}{ENDPOINT_SUFFIX}") +} + +fn gzip(bytes: &[u8]) -> Result, std::io::Error> { + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(bytes)?; + encoder.finish() +} + +/// Encode `request` to OTLP/HTTP/protobuf, gzip it, and POST. +/// +/// `client` is passed in so callers can configure timeouts, TLS, +/// or test-time wiremock interception once and reuse it for both +/// the quarantine API and the OTLP endpoint (Phase C will share +/// a single `reqwest::Client`). +pub async fn upload( + client: &reqwest::Client, + api_url: &str, + token: &str, + repository: &str, + request: &ExportTraceServiceRequest, +) -> Result<(), UploadError> { + if request.resource_spans.is_empty() { + // Match Python's `upload()` short-circuit: no spans, no + // request. The backend would 400 anyway, so we save a + // round trip. + return Ok(()); + } + + let url = endpoint_url(api_url, repository); + + let encoded = request.encode_to_vec(); + let compressed = gzip(&encoded).map_err(|e| UploadError { + status: None, + message: format!("failed to gzip OTLP payload: {e}"), + })?; + + let resp = client + .post(&url) + .bearer_auth(token) + .header("Content-Type", "application/x-protobuf") + .header("Content-Encoding", "gzip") + .body(compressed) + .send() + .await + .map_err(|e| UploadError { + status: None, + message: e.to_string(), + })?; + + if resp.status().is_success() { + return Ok(()); + } + let status = resp.status().as_u16(); + let body = resp + .text() + .await + .unwrap_or_else(|e| format!("")); + Err(UploadError { + status: Some(status), + message: body, + }) +} + +/// Build a `reqwest::Client` with a sensible per-request timeout +/// for OTLP uploads. The default `reqwest` timeout is unlimited, +/// which can hang CI for an hour if the backend is slow. +#[must_use] +pub fn default_client() -> reqwest::Client { + reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .expect("rustls reqwest client builds with default config") +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::read::GzDecoder; + use opentelemetry_proto::tonic::resource::v1::Resource; + use opentelemetry_proto::tonic::trace::v1::{ResourceSpans, ScopeSpans, Span}; + use std::io::Read as _; + use wiremock::matchers::{header, method, path}; + use wiremock::{Mock, MockServer, Request as MockRequest, ResponseTemplate}; + + fn sample_request() -> ExportTraceServiceRequest { + ExportTraceServiceRequest { + resource_spans: vec![ResourceSpans { + resource: Some(Resource { + attributes: Vec::new(), + dropped_attributes_count: 0, + entity_refs: Vec::new(), + }), + scope_spans: vec![ScopeSpans { + scope: None, + spans: vec![Span { + name: "x".into(), + ..Span::default() + }], + schema_url: String::new(), + }], + schema_url: String::new(), + }], + } + } + + #[test] + fn endpoint_url_matches_python_layout() { + assert_eq!( + endpoint_url("https://api.mergify.com", "owner/repo"), + "https://api.mergify.com/v1/repos/owner/repo/ci/traces" + ); + // Trailing slash on api_url must not produce a double slash. + assert_eq!( + endpoint_url("https://api.mergify.com/", "owner/repo"), + "https://api.mergify.com/v1/repos/owner/repo/ci/traces" + ); + } + + #[tokio::test] + async fn empty_request_skips_http_round_trip() { + // No spans → no request. If the function tries to POST, + // it'll fail because the URL is bogus. + let client = reqwest::Client::new(); + let request = ExportTraceServiceRequest { + resource_spans: Vec::new(), + }; + upload( + &client, + "http://127.0.0.1:1", // refused if actually hit + "token", + "owner/repo", + &request, + ) + .await + .expect("empty request must short-circuit"); + } + + #[tokio::test] + async fn posts_gzipped_protobuf_to_traces_endpoint() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/repos/owner/repo/ci/traces")) + .and(header("Authorization", "Bearer secret")) + .and(header("Content-Type", "application/x-protobuf")) + .and(header("Content-Encoding", "gzip")) + .respond_with(|req: &MockRequest| { + // Decode the body to assert it's valid gzip-protobuf + // round-tripping back into the same request shape. + let mut decoder = GzDecoder::new(req.body.as_slice()); + let mut unzipped = Vec::new(); + decoder + .read_to_end(&mut unzipped) + .expect("body decompresses"); + let decoded_req = ExportTraceServiceRequest::decode(unzipped.as_slice()) + .expect("body decodes to OTLP request"); + assert_eq!(decoded_req.resource_spans.len(), 1); + ResponseTemplate::new(200) + }) + .mount(&server) + .await; + + let client = default_client(); + upload( + &client, + &server.uri(), + "secret", + "owner/repo", + &sample_request(), + ) + .await + .expect("upload succeeds"); + } + + #[tokio::test] + async fn surfaces_http_error_status_and_body() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/repos/owner/repo/ci/traces")) + .respond_with(ResponseTemplate::new(401).set_body_string("bad token")) + .mount(&server) + .await; + + let client = default_client(); + let err = upload( + &client, + &server.uri(), + "wrong", + "owner/repo", + &sample_request(), + ) + .await + .expect_err("401 must surface as UploadError"); + assert_eq!(err.status, Some(401)); + assert!(err.message.contains("bad token"), "got: {}", err.message); + let rendered = err.to_string(); + // The Display impl is what Python prints; match the wording + // so existing log scrapers / docs don't drift. + assert!( + rendered.contains("code: 401") && rendered.contains("reason: bad token"), + "got: {rendered}" + ); + } +} diff --git a/crates/mergify-ci/src/testing.rs b/crates/mergify-ci/src/testing.rs index c0c4a261..092a84a8 100644 --- a/crates/mergify-ci/src/testing.rs +++ b/crates/mergify-ci/src/testing.rs @@ -11,10 +11,14 @@ use std::future::Future; /// Env vars the CI-provider detection chain inspects. Clear every /// one of them before applying the test-specific overrides, so the -/// host environment can't leak into the test. +/// host environment can't leak into the test — running the test +/// suite *on* a real GitHub Actions / `CircleCI` / Jenkins / Buildkite +/// host would otherwise produce `vcs.ref.head.name` etc. values +/// taken from the runner instead of the test's explicit override +/// and silently fail. /// /// `GITHUB_OUTPUT` belongs on this list too — when the suite runs -/// *on* a GHA runner that var points at the runner's real +/// on a GHA runner that var points at the runner's real /// step-output file, and any test that exercises a code path /// appending a heredoc (e.g. `ci scopes` → /// `MERGIFY_SCOPES<)]) -> Vec<(String, Option)> { diff --git a/crates/mergify-cli/src/main.rs b/crates/mergify-cli/src/main.rs index 85e043fd..92d58e5d 100644 --- a/crates/mergify-cli/src/main.rs +++ b/crates/mergify-cli/src/main.rs @@ -5,9 +5,9 @@ //! //! - **Natively-ported commands** ([`NATIVE_COMMANDS`]) — clap //! parses the full flag set and the binary runs them in process. -//! - **Python-shimmed commands** (`stack`, `ci scopes`, `ci -//! junit-process`, `ci junit-upload`) — clap registers them as -//! stub variants with a catch-all `args: Vec`. That way +//! - **Python-shimmed commands** (`stack` is the last one left) +//! — clap registers them as stub variants with a catch-all +//! `args: Vec`. That way //! `mergify --help` and `mergify --help` list the entire //! CLI surface, but the captured argv is forwarded verbatim to //! the Python implementation by [`mergify_py_shim::run`]. @@ -28,6 +28,7 @@ use clap::Parser; use clap::Subcommand; use mergify_ci::git_refs::Format as GitRefsFormat; use mergify_ci::git_refs::GitRefsOptions; +use mergify_ci::junit_process::JunitProcessOptions; use mergify_ci::scopes_send::ScopesSendOptions; use mergify_ci::tests_show::TestsShowOptions; use mergify_config::simulate::PullRequestRef; @@ -110,14 +111,6 @@ fn prepend_one(head: &str, tail: Vec) -> Vec { out } -fn prepend_two(first: &str, second: &str, tail: Vec) -> Vec { - let mut out = Vec::with_capacity(tail.len() + 2); - out.push(first.to_string()); - out.push(second.to_string()); - out.extend(tail); - out -} - /// Re-inject the global `--debug` flag at the front of the forwarded /// argv so Python's root group sees it. Clap consumed the flag when /// parsing the Rust-side argv, but the Python CLI declares it at @@ -146,6 +139,8 @@ const NATIVE_COMMANDS: &[(&str, &str)] = &[ ("ci", "scopes-send"), ("ci", "git-refs"), ("ci", "queue-info"), + ("ci", "junit-process"), + ("ci", "junit-upload"), ("tests", "show"), ("queue", "pause"), ("queue", "unpause"), @@ -160,12 +155,22 @@ const NATIVE_COMMANDS: &[(&str, &str)] = &[ /// Native commands the Rust binary handles without delegating to /// the Python shim. enum NativeCommand { - ConfigValidate { config_file: Option }, + ConfigValidate { + config_file: Option, + }, ConfigSimulate(ConfigSimulateOpts), CiScopes(CiScopesOpts), CiScopesSend(CiScopesSendOpts), - CiGitRefs { format: GitRefsFormat }, + CiGitRefs { + format: GitRefsFormat, + }, CiQueueInfo, + CiJunitProcess(CiJunitProcessOpts), + /// Deprecated alias for `CiJunitProcess`. Same orchestrator, + /// same args; the dispatcher prints a deprecation warning to + /// stderr before running. Matches Python's `deprecated=...` + /// click decorator on `ci junit-upload`. + CiJunitUpload(CiJunitProcessOpts), TestsShow(TestsShowOpts), QueuePause(QueuePauseOpts), QueueUnpause(QueueUnpauseOpts), @@ -202,6 +207,17 @@ struct CiScopesSendOpts { file_deprecated: Option, } +struct CiJunitProcessOpts { + api_url: Option, + token: Option, + repository: Option, + test_framework: Option, + test_language: Option, + tests_target_branch: Option, + test_exit_code: Option, + files: Vec, +} + struct QueuePauseOpts { repository: Option, token: Option, @@ -388,17 +404,49 @@ fn dispatch_from_parsed(parsed: CliRoot) -> Dispatch { write, })), Subcommands::Ci(CiArgs { - command: CiSubcommand::JunitProcess(ShimmedArgs { args }), - }) => Dispatch::Shim(inject_global_flags( - debug, - prepend_two("ci", "junit-process", args), - )), + command: + CiSubcommand::JunitProcess(JunitProcessCliArgs { + api_url, + token, + repository, + test_framework, + test_language, + tests_target_branch, + test_exit_code, + files, + }), + }) => Dispatch::Native(NativeCommand::CiJunitProcess(CiJunitProcessOpts { + api_url, + token, + repository, + test_framework, + test_language, + tests_target_branch, + test_exit_code, + files, + })), Subcommands::Ci(CiArgs { - command: CiSubcommand::JunitUpload(ShimmedArgs { args }), - }) => Dispatch::Shim(inject_global_flags( - debug, - prepend_two("ci", "junit-upload", args), - )), + command: + CiSubcommand::JunitUpload(JunitProcessCliArgs { + api_url, + token, + repository, + test_framework, + test_language, + tests_target_branch, + test_exit_code, + files, + }), + }) => Dispatch::Native(NativeCommand::CiJunitUpload(CiJunitProcessOpts { + api_url, + token, + repository, + test_framework, + test_language, + tests_target_branch, + test_exit_code, + files, + })), Subcommands::Config(ConfigArgs { config_file, command: ConfigSubcommand::Validate(_), @@ -683,6 +731,46 @@ fn run_native(cmd: NativeCommand) -> ExitCode { NativeCommand::CiQueueInfo => { mergify_ci::queue_info::run(&mut output).map(|()| mergify_core::ExitCode::Success) } + NativeCommand::CiJunitProcess(opts) => { + mergify_ci::junit_process::run( + JunitProcessOptions { + api_url: opts.api_url.as_deref(), + token: opts.token.as_deref(), + repository: opts.repository.as_deref(), + test_framework: opts.test_framework.as_deref(), + test_language: opts.test_language.as_deref(), + tests_target_branch: opts.tests_target_branch.as_deref(), + test_exit_code: opts.test_exit_code, + files: &opts.files, + }, + &mut output, + ) + .await + } + NativeCommand::CiJunitUpload(opts) => { + // Match Python's `@ci.command(deprecated="...")` + // behavior: click prints a warning to stderr on + // first invocation before running the command body. + // The orchestrator is identical to junit-process, + // so we just forward. + eprintln!( + "DeprecationWarning: 'junit-upload' is deprecated, use `junit-process` instead.", + ); + mergify_ci::junit_process::run( + JunitProcessOptions { + api_url: opts.api_url.as_deref(), + token: opts.token.as_deref(), + repository: opts.repository.as_deref(), + test_framework: opts.test_framework.as_deref(), + test_language: opts.test_language.as_deref(), + tests_target_branch: opts.tests_target_branch.as_deref(), + test_exit_code: opts.test_exit_code, + files: &opts.files, + }, + &mut output, + ) + .await + } NativeCommand::CiScopes(opts) => mergify_ci::scopes_detect::run( mergify_ci::scopes_detect::ScopesOptions { config: opts.config.as_deref(), @@ -921,6 +1009,11 @@ struct CiArgs { } #[derive(Subcommand)] +// CiSubcommand variant docstrings double as `mergify ci --help` +// entries — clap renders them verbatim, so backticks would surface +// as literal characters to the user. Suppress doc_markdown here +// so the help text reads naturally. +#[allow(clippy::doc_markdown)] enum CiSubcommand { /// Send scopes tied to a pull request to Mergify. #[command(name = "scopes-send")] @@ -933,13 +1026,13 @@ enum CiSubcommand { QueueInfo, /// Give the list of scopes impacted by changed files. Scopes(ScopesCliArgs), - /// Upload `JUnit` XML reports and ignore failed tests with + /// Upload JUnit XML reports and ignore failed tests with /// Mergify's CI Insights Quarantine. #[command(name = "junit-process")] - JunitProcess(ShimmedArgs), - /// Upload `JUnit` XML reports (deprecated: use `junit-process`). + JunitProcess(JunitProcessCliArgs), + /// Upload JUnit XML reports (deprecated: use `junit-process`). #[command(name = "junit-upload")] - JunitUpload(ShimmedArgs), + JunitUpload(JunitProcessCliArgs), } #[derive(clap::Args)] @@ -1019,6 +1112,54 @@ struct ScopesSendCliArgs { file_deprecated: Option, } +#[derive(clap::Args)] +// Help text is rendered verbatim by clap; backticks would surface +// as literal characters to the user. Suppress the doc_markdown +// lint just for this struct so the docstrings read naturally in +// `--help` output. +#[allow(clippy::doc_markdown)] +struct JunitProcessCliArgs { + /// Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, + /// then to the default (`https://api.mergify.com`). + #[arg(long = "api-url", short = 'u')] + api_url: Option, + + /// CI Issues application key. Falls back to ``MERGIFY_TOKEN``. + #[arg(long, short = 't')] + token: Option, + + /// Repository full name (owner/repo). Auto-detected from the + /// CI environment when omitted. + #[arg(long, short = 'r')] + repository: Option, + + /// Test framework label (e.g. `pytest`). Optional; passed as a + /// span attribute. + #[arg(long = "test-framework")] + test_framework: Option, + + /// Test language label (e.g. `python`). Optional; passed as a + /// span attribute. + #[arg(long = "test-language")] + test_language: Option, + + /// Branch the quarantine API should look up tests on. Defaults + /// to the PR base branch, or the head branch as a fallback. + #[arg(long = "tests-target-branch", short = 'b')] + tests_target_branch: Option, + + /// Exit code of the test runner. When this is non-zero but no + /// failures appear in the JUnit report, the run is flagged + /// as a silent failure. Falls back to ``MERGIFY_TEST_EXIT_CODE``. + #[arg(long = "test-exit-code", short = 'e', env = "MERGIFY_TEST_EXIT_CODE")] + test_exit_code: Option, + + /// JUnit XML files or glob patterns (e.g. + /// `reports/**/*.xml`). At least one path or pattern is required. + #[arg(value_name = "FILE", required = true, num_args = 1..)] + files: Vec, +} + #[derive(clap::Args)] struct TestsArgs { #[command(subcommand)] @@ -1337,29 +1478,30 @@ mod tests { } #[test] - fn shimmed_dispatch_reinjects_debug_for_ci_subcommand() { - // The remaining two-token shim paths (`ci junit-process`, - // `ci junit-upload`) need the same treatment as the - // single-token `stack` shim — every shim arm must re-inject - // `--debug` so the Python side honors it. `ci scopes` is now - // native and follows the native dispatch path. - for (group, sub, tail) in &[ - ("ci", "junit-process", vec!["--files", "a.xml"]), - ("ci", "junit-upload", vec!["--files", "a.xml"]), - ] { - let mut argv_in = vec!["--debug", group, sub]; - argv_in.extend(tail.iter().copied()); - let parsed = parse(&argv_in); - let Dispatch::Shim(argv) = dispatch_from_parsed(parsed) else { - panic!("ci {sub} must dispatch to the Python shim"); - }; - let mut expected = vec![ - "--debug".to_string(), - (*group).to_string(), - (*sub).to_string(), - ]; - expected.extend(tail.iter().map(|s| (*s).to_string())); - assert_eq!(argv, expected, "ci {sub} dispatch dropped --debug"); - } + fn ci_junit_upload_dispatches_natively_via_deprecated_alias() { + // `ci junit-upload` is the deprecated alias for + // `junit-process`. Both must dispatch to the native + // orchestrator; the alias gets its own + // `NativeCommand::CiJunitUpload` variant so `run_native` + // can print the deprecation warning before forwarding. + let parsed = parse(&[ + "ci", + "junit-upload", + "-r", + "owner/repo", + "-t", + "tok", + "-b", + "main", + "report.xml", + ]); + let Dispatch::Native(NativeCommand::CiJunitUpload(opts)) = dispatch_from_parsed(parsed) + else { + panic!("ci junit-upload must dispatch to the native CiJunitUpload variant"); + }; + assert_eq!(opts.repository.as_deref(), Some("owner/repo")); + assert_eq!(opts.token.as_deref(), Some("tok")); + assert_eq!(opts.tests_target_branch.as_deref(), Some("main")); + assert_eq!(opts.files, vec!["report.xml"]); } }