From 0419af51c0b83949b9aa7551d55e5998fcc45e75 Mon Sep 17 00:00:00 2001 From: Remy DUTHU Date: Wed, 6 May 2026 16:43:11 +0300 Subject: [PATCH] feat(ci-insights): Add `mergify tests show` command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps two CI Insights endpoints into a single batch command: - `GET /v1/ci/{owner}/repositories/{repo}/search/tests` resolves test identities by name (glob-aware) on the default branch. Filters (`test_name`, `pipeline_name`, `job_name`, …) travel as repeated query parameters; page size travels as `per_page`. - `GET /v1/ci/{owner}/repositories/{repo}/tests/{test_id}` returns the full health/metrics payload for one identity. The search is a true batch API — pass one or more `` positionals (globs allowed) and one block per match is rendered. `--json` emits a single `{"tests": [...]}` document; the human renderer hides metadata lines for absent fields rather than printing placeholders. Exit code reflects the worst health observed across results (0 = healthy or unknown, 1 = any flaky, 6 = any broken). `split_owner_repo` lives in `detector.rs` next to the existing `owner/repo` validators so callers can interpolate the segments into request paths without re-escaping. A new `HttpClient::get_with_query` helper percent-encodes values and preserves repeated keys in caller order, which the search endpoint relies on for `test_name` repetition. Also adds a live-smoke case that exercises the search endpoint with a guaranteed-nonexistent name so the round-trip stays independent of canary repository state. Fixes: MRGFY-7166 Co-Authored-By: Claude Opus 4.7 (1M context) Change-Id: Iafe286495b7842079e9c63b437a6305926fc22a5 --- Cargo.lock | 2 + README.md | 3 + crates/mergify-ci/Cargo.toml | 2 + crates/mergify-ci/src/detector.rs | 96 ++- crates/mergify-ci/src/lib.rs | 1 + crates/mergify-ci/src/tests_show.rs | 944 ++++++++++++++++++++++++++++ crates/mergify-cli/src/main.rs | 306 ++++++--- crates/mergify-core/src/http.rs | 79 +++ func-tests/test_live_smoke.py | 39 ++ skills/mergify-ci/SKILL.md | 34 + 10 files changed, 1412 insertions(+), 94 deletions(-) create mode 100644 crates/mergify-ci/src/tests_show.rs diff --git a/Cargo.lock b/Cargo.lock index 76dc6061..29f2f958 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -223,6 +223,7 @@ checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "num-traits", + "serde", "windows-link", ] @@ -1061,6 +1062,7 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" name = "mergify-ci" version = "0.0.0" dependencies = [ + "chrono", "mergify-core", "serde", "serde_json", diff --git a/README.md b/README.md index eab77166..62a162bc 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,9 @@ and global options (`--token`, `--repository`, `--api-url`). - **`mergify ci`** — Upload JUnit results, evaluate quarantine, detect git refs and CI scopes. [Docs](https://docs.mergify.com/ci-insights/) +- **`mergify tests`** — Inspect test health tracked by Mergify CI Insights + (`mergify tests show NAME...`). + [Docs](https://docs.mergify.com/ci-insights/) - **`mergify queue`** — Monitor and manage the Mergify merge queue. [Docs](https://docs.mergify.com/merge-queue/) - **`mergify freeze`** — Create and manage scheduled merge freezes. diff --git a/crates/mergify-ci/Cargo.toml b/crates/mergify-ci/Cargo.toml index 1416c013..c8918139 100644 --- a/crates/mergify-ci/Cargo.toml +++ b/crates/mergify-ci/Cargo.toml @@ -10,10 +10,12 @@ description = "Native implementation of `mergify ci` subcommands." publish = false [dependencies] +chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] } mergify-core = { path = "../mergify-core" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_yaml_ng = "0.10" +tokio = { version = "1", default-features = false, features = ["rt"] } url = "2" uuid = { version = "1", features = ["v4"] } diff --git a/crates/mergify-ci/src/detector.rs b/crates/mergify-ci/src/detector.rs index ab0830c8..1c93910e 100644 --- a/crates/mergify-ci/src/detector.rs +++ b/crates/mergify-ci/src/detector.rs @@ -78,17 +78,57 @@ fn parse_repository_url(url_str: &str) -> Option { fn validate_owner_repo(path: &str) -> Option { let (owner, repo) = path.split_once('/')?; - if owner.is_empty() || repo.is_empty() || repo.contains('/') { + if !is_valid_segment(owner) || !is_valid_segment(repo) || repo.contains('/') { return None; } - let valid = |s: &str| { - s.chars() + Some(format!("{owner}/{repo}")) +} + +/// Allowed character set for an `owner` or `repo` path segment. +/// +/// Matches GitHub's allowance (alphanumerics, `_`, `.`, `-`) and the +/// regex used by `parse_repository_url`. Rejects every URL-reserved +/// character (`?`, `#`, `%`, `/`, space) so callers can interpolate +/// the segments straight into a request path without percent-encoding +/// and without enabling path or query injection. +fn is_valid_segment(segment: &str) -> bool { + !segment.is_empty() + && segment + .chars() .all(|c| c.is_alphanumeric() || c == '_' || c == '.' || c == '-') +} + +/// Clap `value_parser` for `--repository`. Returning `Result<_, String>` +/// makes clap surface a bad value as exit code 2 instead of letting it +/// slip through to runtime as a `Configuration` error. +/// +/// # Errors +/// +/// Returns the validation message from `split_owner_repo` when the +/// input is not exactly `owner/repo` with allowed characters. +pub fn parse_owner_repo(value: &str) -> Result { + split_owner_repo(value) + .map(|_| value.to_string()) + .map_err(|e| e.to_string()) +} + +/// Split a `"owner/repo"` string into its two parts. The +/// Mergify CI Insights endpoints take owner and repository name as +/// separate path segments, while `--repository` accepts the +/// `owner/repo` shorthand. Rejects empty parts and any character +/// outside `is_valid_segment` so the values can be interpolated into +/// URL paths without further escaping. +pub fn split_owner_repo(value: &str) -> Result<(&str, &str), CliError> { + let mismatch = || { + CliError::Configuration(format!( + "invalid repository {value:?}: expected `owner/repo`", + )) }; - if !valid(owner) || !valid(repo) { - return None; + let (owner, repo) = value.split_once('/').ok_or_else(mismatch)?; + if !is_valid_segment(owner) || !is_valid_segment(repo) || repo.contains('/') { + return Err(mismatch()); } - Some(format!("{owner}/{repo}")) + Ok((owner, repo)) } #[must_use] @@ -343,6 +383,50 @@ mod tests { ); } + #[test] + fn split_owner_repo_accepts_owner_repo() { + assert_eq!( + split_owner_repo("Mergifyio/monorepo").unwrap(), + ("Mergifyio", "monorepo") + ); + assert_eq!(split_owner_repo("a/b").unwrap(), ("a", "b")); + } + + #[test] + fn split_owner_repo_rejects_inputs_without_exactly_one_slash() { + for bad in ["", "owner", "owner/", "/repo", "a/b/c", "/", "//"] { + let err = split_owner_repo(bad).unwrap_err(); + assert!( + matches!(err, CliError::Configuration(_)), + "input {bad:?} should map to Configuration, got {err:?}", + ); + assert!( + err.to_string().contains("owner/repo"), + "error for {bad:?} should mention expected shape, got: {err}", + ); + } + } + + #[test] + fn split_owner_repo_rejects_url_reserved_characters() { + // These would otherwise inject extra path or query segments + // when interpolated into a request URL. + for bad in [ + "owner/repo?x=1", + "owner/repo#frag", + "owner/repo%2e", + "own er/repo", + "owner /repo", + "owner/re po", + ] { + let err = split_owner_repo(bad).unwrap_err(); + assert!( + matches!(err, CliError::Configuration(_)), + "input {bad:?} should map to Configuration, got {err:?}", + ); + } + } + #[test] fn parse_repository_url_handles_known_shapes() { let cases = [ diff --git a/crates/mergify-ci/src/lib.rs b/crates/mergify-ci/src/lib.rs index b06c9f3e..ac0d94e6 100644 --- a/crates/mergify-ci/src/lib.rs +++ b/crates/mergify-ci/src/lib.rs @@ -13,3 +13,4 @@ pub mod github_event; pub mod queue_info; pub mod queue_metadata; pub mod scopes_send; +pub mod tests_show; diff --git a/crates/mergify-ci/src/tests_show.rs b/crates/mergify-ci/src/tests_show.rs new file mode 100644 index 00000000..77da028e --- /dev/null +++ b/crates/mergify-ci/src/tests_show.rs @@ -0,0 +1,944 @@ +//! `mergify tests show` — resolve test identities by name and fetch +//! full health/metrics details for each match. +//! +//! Exit code `2` is intentionally skipped: the CLI-wide contract +//! reserves it for clap argument errors. + +use std::io::{self, Write}; +use std::sync::Arc; + +use chrono::DateTime; +use chrono::Utc; +use mergify_core::ApiFlavor; +use mergify_core::CliError; +use mergify_core::ExitCode; +use mergify_core::HttpClient; +use mergify_core::Output; +use mergify_core::auth; +use serde::Deserialize; +use serde::Serialize; + +use crate::detector::split_owner_repo; + +const DETAILS_FANOUT: usize = 5; + +pub struct TestsShowOptions<'a> { + pub repository: &'a str, + pub test_names: &'a [String], + pub token: Option<&'a str>, + pub api_url: Option<&'a str>, + pub pipeline_name: &'a [String], + pub pipeline_name_exclude: &'a [String], + pub job_name: &'a [String], + pub job_name_exclude: &'a [String], + pub per_page: Option, +} + +/// Run the command and return the exit code that reflects the +/// aggregate test health. +pub async fn run( + opts: TestsShowOptions<'_>, + output: &mut dyn Output, +) -> Result { + let (owner, repo) = split_owner_repo(opts.repository)?; + let token = auth::resolve_token(opts.token)?; + let api_url = auth::resolve_api_url(opts.api_url)?; + let client = Arc::new(HttpClient::new(api_url, token, ApiFlavor::Mergify)?); + + let search_path = format!("/v1/ci/{owner}/repositories/{repo}/search/tests"); + let identities = search(&client, &search_path, &opts).await?; + + if identities.is_empty() { + let quoted = opts + .test_names + .iter() + .map(|n| format!("'{n}'")) + .collect::>() + .join(", "); + output.status(&format!("no tests matched {quoted}"))?; + let payload = TestsShowPayload { tests: vec![] }; + output.emit(&payload, &mut |_| Ok(()))?; + return Ok(ExitCode::Success); + } + + let details = fetch_all_details(client.clone(), owner, repo, &identities).await?; + + let payload = TestsShowPayload::new(identities, details); + let exit_code = aggregate_exit_code(payload.tests.iter().map(|t| t.details.health_status)); + + output.emit(&payload, &mut |w| render_human(w, &payload))?; + + Ok(exit_code) +} + +async fn search( + client: &HttpClient, + path: &str, + opts: &TestsShowOptions<'_>, +) -> Result, CliError> { + let per_page = opts.per_page.map(|n| n.to_string()); + let query = build_search_query(opts, per_page.as_deref()); + let response: SearchTestsResponse = client.get_with_query(path, &query).await?; + Ok(response.tests) +} + +/// Optional filters with no values are omitted so the server falls +/// back to its own defaults. +fn build_search_query<'a>( + opts: &'a TestsShowOptions<'a>, + per_page: Option<&'a str>, +) -> Vec<(&'a str, &'a str)> { + let mut query: Vec<(&str, &str)> = Vec::new(); + for name in opts.test_names { + query.push(("test_name", name)); + } + for name in opts.pipeline_name { + query.push(("pipeline_name", name)); + } + for name in opts.pipeline_name_exclude { + query.push(("pipeline_name_exclude", name)); + } + for name in opts.job_name { + query.push(("job_name", name)); + } + for name in opts.job_name_exclude { + query.push(("job_name_exclude", name)); + } + if let Some(per_page) = per_page { + query.push(("per_page", per_page)); + } + query +} + +/// Returned details follow the input order of `identities` regardless +/// of completion order — callers rely on it for stable output. +async fn fetch_all_details( + client: Arc, + owner: &str, + repo: &str, + identities: &[TestSearchResult], +) -> Result, CliError> { + let mut set: tokio::task::JoinSet<(usize, Result)> = + tokio::task::JoinSet::new(); + let mut results: Vec> = (0..identities.len()).map(|_| None).collect(); + let mut next = 0usize; + + let spawn_at = |set: &mut tokio::task::JoinSet<_>, index: usize| { + let client = client.clone(); + let path = format!( + "/v1/ci/{owner}/repositories/{repo}/tests/{}", + identities[index].test_id, + ); + set.spawn(async move { (index, client.get::(&path).await) }); + }; + + while next < identities.len() && next < DETAILS_FANOUT { + spawn_at(&mut set, next); + next += 1; + } + while let Some(joined) = set.join_next().await { + // Tasks are never cancelled, so a `JoinError` can only mean a + // panic — propagate it verbatim instead of wrapping. + let (index, result) = + joined.unwrap_or_else(|err| std::panic::resume_unwind(err.into_panic())); + results[index] = Some(result?); + if next < identities.len() { + spawn_at(&mut set, next); + next += 1; + } + } + + Ok(results + .into_iter() + .map(|slot| slot.expect("slot filled by JoinSet loop")) + .collect()) +} + +fn aggregate_exit_code(statuses: impl IntoIterator) -> ExitCode { + let mut max = ExitCode::Success; + for status in statuses { + match status { + HealthStatus::Broken => return ExitCode::MergifyApiError, + HealthStatus::Flaky if max == ExitCode::Success => max = ExitCode::GenericError, + _ => {} + } + } + max +} + +#[derive(Deserialize)] +struct SearchTestsResponse { + tests: Vec, +} + +#[derive(Deserialize)] +struct TestSearchResult { + test_id: String, + pipeline_name: String, + job_name: String, +} + +#[derive(Deserialize, Serialize)] +struct TestDetails { + repository: String, + test_name: String, + test_id: String, + health_status: HealthStatus, + last_conclusion: LastConclusion, + failure_ratio: f64, + flakiness_ratio: f64, + success_ratio: f64, + flaky_detection_enabled: bool, + first_failure_at: Option>, + first_failure_commit: Option, + first_failure_pull: Option, + last_failure_at: Option>, + last_success_at: Option>, + test_framework: Option, + test_framework_version: Option, + test_programming_language: Option, + test_filepath: Option, + test_function_name: Option, +} + +#[derive(Deserialize, Serialize)] +struct TestDetailsPull { + id: u64, + number: u64, + title: String, + user: TestDetailsUser, +} + +#[derive(Deserialize, Serialize)] +struct TestDetailsUser { + id: u64, + login: String, +} + +#[derive(Copy, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum HealthStatus { + Healthy, + Flaky, + Broken, + /// Captures any future server-side enum value so a single + /// unrecognized status does not abort the whole batch. + #[serde(other, alias = "unknown")] + Unknown, +} + +#[derive(Copy, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum LastConclusion { + Passed, + Failed, + Skipped, + /// Reserved for future API additions; rendered as `—`. + #[serde(other)] + Unknown, +} + +#[derive(Serialize)] +struct TestsShowPayload { + tests: Vec, +} + +#[derive(Serialize)] +struct TestRow { + pipeline_name: String, + job_name: String, + #[serde(flatten)] + details: TestDetails, +} + +impl TestsShowPayload { + fn new(identities: Vec, details: Vec) -> Self { + let tests = identities + .into_iter() + .zip(details) + .map(|(identity, details)| TestRow { + pipeline_name: identity.pipeline_name, + job_name: identity.job_name, + details, + }) + .collect(); + Self { tests } + } +} + +fn render_human(w: &mut dyn Write, payload: &TestsShowPayload) -> io::Result<()> { + for (index, row) in payload.tests.iter().enumerate() { + if index > 0 { + writeln!(w)?; + } + render_one(w, row)?; + } + Ok(()) +} + +fn render_one(w: &mut dyn Write, row: &TestRow) -> io::Result<()> { + let details = &row.details; + writeln!(w, "{}", details.test_name)?; + writeln!(w, " test_id: {}", details.test_id)?; + writeln!( + w, + " pipeline: {} › job: {}", + row.pipeline_name, row.job_name, + )?; + if let Some(language) = &details.test_programming_language { + writeln!(w, " language: {language}")?; + } + if let Some(framework) = &details.test_framework { + match &details.test_framework_version { + Some(version) => writeln!(w, " framework: {framework} {version}")?, + None => writeln!(w, " framework: {framework}")?, + } + } + if let Some(filepath) = &details.test_filepath { + writeln!(w, " file: {filepath}")?; + } + if let Some(function) = &details.test_function_name { + writeln!(w, " function: {function}")?; + } + writeln!( + w, + " health: {}", + health_label(details.health_status) + )?; + writeln!( + w, + " last result: {}", + conclusion_label(details.last_conclusion) + )?; + writeln!(w, " success ratio: {:.1}%", details.success_ratio * 100.0)?; + writeln!(w, " failure ratio: {:.1}%", details.failure_ratio * 100.0)?; + if details.flaky_detection_enabled { + writeln!( + w, + " flakiness: {:.1}%", + details.flakiness_ratio * 100.0 + )?; + } + if let Some(ts) = details.last_success_at { + writeln!(w, " last success: {}", format_timestamp(ts))?; + } + if let Some(ts) = details.last_failure_at { + writeln!(w, " last failure: {}", format_timestamp(ts))?; + } + if let Some(ts) = details.first_failure_at { + writeln!(w, " first failure: {}", format_timestamp(ts))?; + let commit = details.first_failure_commit.as_deref(); + if let Some(pull) = &details.first_failure_pull { + let prefix = commit + .map(|c| format!("commit {} in ", short_sha(c))) + .unwrap_or_default(); + writeln!( + w, + " {prefix}#{} \"{}\"", + pull.number, pull.title, + )?; + writeln!(w, " by {}", pull.user.login)?; + } else if let Some(short) = commit.map(short_sha) { + writeln!(w, " commit {short}")?; + } + } + Ok(()) +} + +fn health_label(status: HealthStatus) -> &'static str { + match status { + HealthStatus::Healthy => "● healthy", + HealthStatus::Flaky => "● flaky", + HealthStatus::Broken => "✗ broken", + HealthStatus::Unknown => "— unknown", + } +} + +fn conclusion_label(conclusion: LastConclusion) -> &'static str { + match conclusion { + LastConclusion::Passed => "✓ passed", + LastConclusion::Failed => "✗ failed", + LastConclusion::Skipped => "○ skipped", + LastConclusion::Unknown => "— unknown", + } +} + +fn format_timestamp(ts: DateTime) -> String { + ts.format("%Y-%m-%d %H:%M UTC").to_string() +} + +fn short_sha(sha: &str) -> String { + sha.chars().take(7).collect() +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::Mutex; + + use mergify_core::OutputMode; + use mergify_core::StdioOutput; + use serde_json::json; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path as path_matcher; + + use super::*; + + type SharedBytes = Arc>>; + + struct SharedWriter(SharedBytes); + + impl Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + struct Captured { + output: StdioOutput, + stdout: SharedBytes, + stderr: SharedBytes, + } + + fn captured(mode: OutputMode) -> Captured { + let stdout: SharedBytes = Arc::new(Mutex::new(Vec::new())); + let stderr: SharedBytes = Arc::new(Mutex::new(Vec::new())); + let output = StdioOutput::with_sinks( + mode, + SharedWriter(Arc::clone(&stdout)), + SharedWriter(Arc::clone(&stderr)), + ); + Captured { + output, + stdout, + stderr, + } + } + + fn read(b: &SharedBytes) -> String { + String::from_utf8(b.lock().unwrap().clone()).unwrap() + } + + /// Mount the search endpoint. The fixture body usually contains + /// just `{"tests": [...]}`; the real API also returns `size` and + /// `per_page` next to `tests`, but the CLI ignores those, so + /// callers don't need to pass them. + async fn mount_search(server: &MockServer, body: serde_json::Value) { + Mock::given(method("GET")) + .and(path_matcher("/v1/ci/owner/repositories/repo/search/tests")) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .mount(server) + .await; + } + + async fn mount_details(server: &MockServer, id: &str, response: ResponseTemplate) { + Mock::given(method("GET")) + .and(path_matcher(format!( + "/v1/ci/owner/repositories/repo/tests/{id}" + ))) + .respond_with(response) + .mount(server) + .await; + } + + fn details_template(id: &str, name: &str, health: &str, conclusion: &str) -> ResponseTemplate { + ResponseTemplate::new(200).set_body_json(details_json(id, name, health, conclusion)) + } + + fn details_json( + test_id: &str, + test_name: &str, + health: &str, + conclusion: &str, + ) -> serde_json::Value { + json!({ + "repository": "monorepo", + "test_name": test_name, + "test_id": test_id, + "health_status": health, + "last_conclusion": conclusion, + "failure_ratio": 0.08, + "flakiness_ratio": 0.0, + "success_ratio": 0.92, + "flaky_detection_enabled": false, + "first_failure_at": null, + "first_failure_commit": null, + "first_failure_pull": null, + "last_failure_at": null, + "last_success_at": null, + "test_framework": null, + "test_framework_version": null, + "test_programming_language": null, + "test_filepath": null, + "test_function_name": null, + }) + } + + fn test_id(n: usize) -> String { + format!("00000000-0000-5000-8000-00000000000{n}") + } + + fn options<'a>(api_url: &'a str, names: &'a [String]) -> TestsShowOptions<'a> { + TestsShowOptions { + repository: "owner/repo", + test_names: names, + token: Some("test-token"), + api_url: Some(api_url), + pipeline_name: &[], + pipeline_name_exclude: &[], + job_name: &[], + job_name_exclude: &[], + per_page: None, + } + } + + #[tokio::test] + async fn empty_search_emits_empty_tests_and_returns_success() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path_matcher("/v1/ci/owner/repositories/repo/search/tests")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"tests": []}))) + .expect(1) + .mount(&server) + .await; + + let mut cap = captured(OutputMode::Json); + let api_url = server.uri(); + let names = vec!["ghost".to_string()]; + let exit = run(options(&api_url, &names), &mut cap.output) + .await + .unwrap(); + + assert_eq!(exit, ExitCode::Success); + let stdout = read(&cap.stdout); + assert_eq!( + serde_json::from_str::(&stdout).unwrap(), + json!({"tests": []}) + ); + } + + #[tokio::test] + async fn empty_search_in_human_mode_writes_no_match_to_stderr() { + let server = MockServer::start().await; + mount_search(&server, json!({"tests": []})).await; + + let mut cap = captured(OutputMode::Human); + let api_url = server.uri(); + let names = vec!["ghost".to_string()]; + run(options(&api_url, &names), &mut cap.output) + .await + .unwrap(); + + assert!(read(&cap.stderr).contains("no tests matched 'ghost'")); + assert_eq!(read(&cap.stdout), ""); + } + + #[tokio::test] + async fn single_match_fetches_details_and_renders_in_human_mode() { + let server = MockServer::start().await; + let id = test_id(1); + mount_search( + &server, + json!({ + "tests": [{ + "test_id": id, + "test_name": "test_login", + "pipeline_name": "ci", + "job_name": "unit", + }] + }), + ) + .await; + mount_details( + &server, + &id, + details_template(&id, "test_login", "healthy", "passed"), + ) + .await; + + let mut cap = captured(OutputMode::Human); + let api_url = server.uri(); + let names = vec!["test_login".to_string()]; + let exit = run(options(&api_url, &names), &mut cap.output) + .await + .unwrap(); + + assert_eq!(exit, ExitCode::Success); + let out = read(&cap.stdout); + assert!(out.contains("test_login"), "missing test name in {out:?}"); + assert!(out.contains("● healthy"), "missing health line in {out:?}"); + assert!(out.contains("✓ passed"), "missing conclusion in {out:?}"); + assert!( + out.contains("success ratio: 92.0%"), + "missing ratio in {out:?}" + ); + assert!( + !out.contains("flakiness"), + "flakiness line must be hidden when flaky_detection_enabled=false, got {out:?}" + ); + assert!( + !out.contains("disabled"), + "no `disabled` placeholder allowed, got {out:?}" + ); + } + + #[tokio::test] + async fn batch_preserves_search_order_even_when_responses_arrive_out_of_order() { + let server = MockServer::start().await; + let id_a = test_id(1); + let id_b = test_id(2); + let id_c = test_id(3); + + mount_search( + &server, + json!({ + "tests": [ + {"test_id": id_a, "test_name": "alpha", "pipeline_name": "ci", "job_name": "j"}, + {"test_id": id_b, "test_name": "bravo", "pipeline_name": "ci", "job_name": "j"}, + {"test_id": id_c, "test_name": "charlie", "pipeline_name": "ci", "job_name": "j"}, + ] + }), + ) + .await; + + let delayed = |id: &str, name: &str, ms: u64| { + details_template(id, name, "healthy", "passed") + .set_delay(std::time::Duration::from_millis(ms)) + }; + mount_details(&server, &id_a, delayed(&id_a, "alpha", 80)).await; + mount_details(&server, &id_b, delayed(&id_b, "bravo", 0)).await; + mount_details(&server, &id_c, delayed(&id_c, "charlie", 40)).await; + + let mut cap = captured(OutputMode::Json); + let api_url = server.uri(); + let names = vec!["*".to_string()]; + run(options(&api_url, &names), &mut cap.output) + .await + .unwrap(); + + let stdout = read(&cap.stdout); + let value: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let names: Vec<&str> = value["tests"] + .as_array() + .unwrap() + .iter() + .map(|t| t["test_name"].as_str().unwrap()) + .collect(); + assert_eq!(names, vec!["alpha", "bravo", "charlie"]); + } + + #[tokio::test] + async fn exit_code_promotes_for_flaky_then_broken() { + let server = MockServer::start().await; + let id_a = test_id(1); + let id_b = test_id(2); + let id_c = test_id(3); + + mount_search( + &server, + json!({ + "tests": [ + {"test_id": id_a, "test_name": "a", "pipeline_name": "ci", "job_name": "j"}, + {"test_id": id_b, "test_name": "b", "pipeline_name": "ci", "job_name": "j"}, + {"test_id": id_c, "test_name": "c", "pipeline_name": "ci", "job_name": "j"}, + ] + }), + ) + .await; + mount_details( + &server, + &id_a, + details_template(&id_a, "a", "healthy", "passed"), + ) + .await; + mount_details( + &server, + &id_b, + details_template(&id_b, "b", "flaky", "passed"), + ) + .await; + mount_details( + &server, + &id_c, + details_template(&id_c, "c", "broken", "failed"), + ) + .await; + + let mut cap = captured(OutputMode::Json); + let api_url = server.uri(); + let names = vec!["*".to_string()]; + let exit = run(options(&api_url, &names), &mut cap.output) + .await + .unwrap(); + assert_eq!( + exit, + ExitCode::MergifyApiError, + "broken must promote past flaky" + ); + } + + #[tokio::test] + async fn exit_code_is_generic_error_when_only_flaky() { + let server = MockServer::start().await; + let id = test_id(1); + mount_search( + &server, + json!({ + "tests": [ + {"test_id": id, "test_name": "a", "pipeline_name": "ci", "job_name": "j"}, + ] + }), + ) + .await; + mount_details(&server, &id, details_template(&id, "a", "flaky", "passed")).await; + + let mut cap = captured(OutputMode::Json); + let api_url = server.uri(); + let names = vec!["a".to_string()]; + let exit = run(options(&api_url, &names), &mut cap.output) + .await + .unwrap(); + assert_eq!(exit, ExitCode::GenericError); + } + + #[tokio::test] + async fn unknown_health_status_does_not_promote_severity() { + let server = MockServer::start().await; + let id = test_id(1); + mount_search( + &server, + json!({ + "tests": [ + {"test_id": id, "test_name": "a", "pipeline_name": "ci", "job_name": "j"}, + ] + }), + ) + .await; + mount_details( + &server, + &id, + details_template(&id, "a", "future_value_we_dont_know", "passed"), + ) + .await; + + let mut cap = captured(OutputMode::Json); + let api_url = server.uri(); + let names = vec!["a".to_string()]; + let exit = run(options(&api_url, &names), &mut cap.output) + .await + .unwrap(); + assert_eq!( + exit, + ExitCode::Success, + "unknown enum must not promote severity" + ); + let mut cap = captured(OutputMode::Human); + run(options(&api_url, &names), &mut cap.output) + .await + .unwrap(); + assert!(read(&cap.stdout).contains("— unknown")); + } + + #[tokio::test] + async fn flaky_detection_enabled_true_renders_flakiness_line() { + let server = MockServer::start().await; + let id = test_id(1); + mount_search( + &server, + json!({ + "tests": [ + {"test_id": id, "test_name": "a", "pipeline_name": "ci", "job_name": "j"}, + ] + }), + ) + .await; + let mut details = details_json(&id, "a", "flaky", "passed"); + details["flaky_detection_enabled"] = json!(true); + details["flakiness_ratio"] = json!(0.12); + mount_details( + &server, + &id, + ResponseTemplate::new(200).set_body_json(details), + ) + .await; + + let mut cap = captured(OutputMode::Human); + let api_url = server.uri(); + let names = vec!["a".to_string()]; + run(options(&api_url, &names), &mut cap.output) + .await + .unwrap(); + let out = read(&cap.stdout); + assert!(out.contains("flakiness: 12.0%"), "got {out:?}"); + } + + #[tokio::test] + async fn optional_metadata_lines_rendered_when_present() { + let server = MockServer::start().await; + let id = test_id(1); + mount_search( + &server, + json!({ + "tests": [ + {"test_id": id, "test_name": "test_login", "pipeline_name": "ci", "job_name": "j"}, + ] + }), + ) + .await; + let mut details = details_json(&id, "test_login", "healthy", "passed"); + details["test_framework"] = json!("pytest"); + details["test_framework_version"] = json!("8.3.2"); + details["test_programming_language"] = json!("python"); + details["test_filepath"] = json!("tests/auth.py"); + details["test_function_name"] = json!("test_login"); + details["last_success_at"] = json!("2026-05-06T08:00:00Z"); + details["first_failure_at"] = json!("2026-04-07T07:52:28Z"); + details["first_failure_commit"] = json!("9ee5a5183b0640220d009982ab62e336d3f64d0f"); + details["first_failure_pull"] = json!({ + "id": 3_496_538_490_u64, + "number": 28834, + "title": "chore(deps): update click", + "user": {"id": 29_139_614, "login": "renovate[bot]"}, + }); + mount_details( + &server, + &id, + ResponseTemplate::new(200).set_body_json(details), + ) + .await; + + let mut cap = captured(OutputMode::Human); + let api_url = server.uri(); + let names = vec!["test_login".to_string()]; + run(options(&api_url, &names), &mut cap.output) + .await + .unwrap(); + let out = read(&cap.stdout); + for needle in [ + "language: python", + "framework: pytest 8.3.2", + "file: tests/auth.py", + "function: test_login", + "last success: 2026-05-06 08:00 UTC", + "first failure: 2026-04-07 07:52 UTC", + "commit 9ee5a51 in #28834", + "by renovate[bot]", + ] { + assert!( + out.contains(needle), + "expected {needle:?} in output:\n{out}" + ); + } + } + + #[tokio::test] + async fn search_query_includes_names_and_filters_and_omits_absent_ones() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path_matcher("/v1/ci/owner/repositories/repo/search/tests")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"tests": []}))) + .expect(1) + .mount(&server) + .await; + + let mut cap = captured(OutputMode::Json); + let api_url = server.uri(); + let names = vec!["alpha".to_string(), "*beta*".to_string()]; + let pipelines = vec!["e2e".to_string()]; + let job_excludes = vec!["lint".to_string()]; + + let opts = TestsShowOptions { + repository: "owner/repo", + test_names: &names, + token: Some("t"), + api_url: Some(&api_url), + pipeline_name: &pipelines, + pipeline_name_exclude: &[], + job_name: &[], + job_name_exclude: &job_excludes, + per_page: Some(20), + }; + run(opts, &mut cap.output).await.unwrap(); + + let received = server.received_requests().await.unwrap(); + assert_eq!(received.len(), 1); + let pairs: Vec<(String, String)> = received[0] + .url + .query_pairs() + .map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect(); + assert_eq!( + pairs, + vec![ + ("test_name".into(), "alpha".into()), + ("test_name".into(), "*beta*".into()), + ("pipeline_name".into(), "e2e".into()), + ("job_name_exclude".into(), "lint".into()), + ("per_page".into(), "20".into()), + ], + ); + } + + #[tokio::test] + async fn details_endpoint_4xx_surfaces_as_error() { + let server = MockServer::start().await; + let id_a = test_id(1); + let id_b = test_id(2); + mount_search( + &server, + json!({ + "tests": [ + {"test_id": id_a, "test_name": "a", "pipeline_name": "ci", "job_name": "j"}, + {"test_id": id_b, "test_name": "b", "pipeline_name": "ci", "job_name": "j"}, + ] + }), + ) + .await; + mount_details( + &server, + &id_a, + ResponseTemplate::new(404).set_body_string("not found"), + ) + .await; + mount_details( + &server, + &id_b, + details_template(&id_b, "b", "healthy", "passed"), + ) + .await; + + let mut cap = captured(OutputMode::Json); + let api_url = server.uri(); + let names = vec!["*".to_string()]; + let err = run(options(&api_url, &names), &mut cap.output) + .await + .unwrap_err(); + assert!(matches!(err, CliError::MergifyApi(_)), "got {err:?}"); + } + + #[tokio::test] + async fn invalid_repository_format_is_a_configuration_error() { + let mut cap = captured(OutputMode::Human); + let names = vec!["a".to_string()]; + let opts = TestsShowOptions { + repository: "not-a-slash-pair", + test_names: &names, + token: Some("t"), + api_url: Some("https://example.invalid"), + pipeline_name: &[], + pipeline_name_exclude: &[], + job_name: &[], + job_name_exclude: &[], + per_page: None, + }; + let err = run(opts, &mut cap.output).await.unwrap_err(); + assert!(matches!(err, CliError::Configuration(_)), "got {err:?}"); + } +} diff --git a/crates/mergify-cli/src/main.rs b/crates/mergify-cli/src/main.rs index 6158a2aa..b29a438a 100644 --- a/crates/mergify-cli/src/main.rs +++ b/crates/mergify-cli/src/main.rs @@ -29,6 +29,7 @@ use clap::Subcommand; use mergify_ci::git_refs::Format as GitRefsFormat; use mergify_ci::git_refs::GitRefsOptions; use mergify_ci::scopes_send::ScopesSendOptions; +use mergify_ci::tests_show::TestsShowOptions; use mergify_config::simulate::PullRequestRef; use mergify_config::simulate::SimulateOptions; use mergify_core::OutputMode; @@ -125,6 +126,7 @@ const NATIVE_COMMANDS: &[(&str, &str)] = &[ ("ci", "scopes-send"), ("ci", "git-refs"), ("ci", "queue-info"), + ("tests", "show"), ("queue", "pause"), ("queue", "unpause"), ("queue", "status"), @@ -140,6 +142,7 @@ enum NativeCommand { CiScopesSend(CiScopesSendOpts), CiGitRefs { format: GitRefsFormat }, CiQueueInfo, + TestsShow(TestsShowOpts), QueuePause(QueuePauseOpts), QueueUnpause(QueueUnpauseOpts), QueueStatus(QueueStatusOpts), @@ -203,6 +206,19 @@ struct FreezeListOpts { output_json: bool, } +struct TestsShowOpts { + repository: String, + test_names: Vec, + token: Option, + api_url: Option, + pipeline_name: Vec, + pipeline_name_exclude: Vec, + job_name: Vec, + job_name_exclude: Vec, + per_page: Option, + json: bool, +} + /// Heuristic: does argv look like the user intended a native /// subcommand? /// @@ -342,6 +358,32 @@ fn dispatch_from_parsed(parsed: CliRoot) -> Dispatch { Subcommands::Ci(CiArgs { command: CiSubcommand::QueueInfo, }) => Dispatch::Native(NativeCommand::CiQueueInfo), + Subcommands::Tests(TestsArgs { + command: + TestsSubcommand::Show(TestsShowCliArgs { + repository, + test_names, + token, + api_url, + pipeline_name, + pipeline_name_exclude, + job_name, + job_name_exclude, + per_page, + json, + }), + }) => Dispatch::Native(NativeCommand::TestsShow(TestsShowOpts { + repository, + test_names, + token, + api_url, + pipeline_name, + pipeline_name_exclude, + job_name, + job_name_exclude, + per_page, + json, + })), Subcommands::Queue(QueueArgs { repository, token, @@ -425,113 +467,135 @@ fn run_native(cmd: NativeCommand) -> ExitCode { } }; - let mut output = StdioOutput::new(OutputMode::Human); + // `tests show` is the only native command whose `--json` flag is + // honored through the shared `StdioOutput` machinery; everything + // else writes Human and manages its own JSON via run-time flags. + let mode = match &cmd { + NativeCommand::TestsShow(opts) if opts.json => OutputMode::Json, + _ => OutputMode::Human, + }; + let mut output = StdioOutput::new(mode); - let result = rt.block_on(async { + let result: Result = rt.block_on(async { match cmd { NativeCommand::ConfigValidate { config_file } => { - mergify_config::validate::run(config_file.as_deref(), &mut output).await - } - NativeCommand::ConfigSimulate(opts) => { - mergify_config::simulate::run( - SimulateOptions { - pull_request: &opts.pull_request, - config_file: opts.config_file.as_deref(), - token: opts.token.as_deref(), - api_url: opts.api_url.as_deref(), - }, - &mut output, - ) - .await - } - NativeCommand::CiScopesSend(opts) => { - mergify_ci::scopes_send::run( - ScopesSendOptions { - repository: opts.repository.as_deref(), - pull_request: opts.pull_request, - token: opts.token.as_deref(), - api_url: opts.api_url.as_deref(), - scopes: &opts.scopes, - scopes_json: opts.scopes_json.as_deref(), - scopes_file: opts.scopes_file.as_deref(), - deprecated_file: opts.file_deprecated.as_deref(), - }, - &mut output, - ) - .await + mergify_config::validate::run(config_file.as_deref(), &mut output) + .await + .map(|()| mergify_core::ExitCode::Success) } + NativeCommand::ConfigSimulate(opts) => mergify_config::simulate::run( + SimulateOptions { + pull_request: &opts.pull_request, + config_file: opts.config_file.as_deref(), + token: opts.token.as_deref(), + api_url: opts.api_url.as_deref(), + }, + &mut output, + ) + .await + .map(|()| mergify_core::ExitCode::Success), + NativeCommand::CiScopesSend(opts) => mergify_ci::scopes_send::run( + ScopesSendOptions { + repository: opts.repository.as_deref(), + pull_request: opts.pull_request, + token: opts.token.as_deref(), + api_url: opts.api_url.as_deref(), + scopes: &opts.scopes, + scopes_json: opts.scopes_json.as_deref(), + scopes_file: opts.scopes_file.as_deref(), + deprecated_file: opts.file_deprecated.as_deref(), + }, + &mut output, + ) + .await + .map(|()| mergify_core::ExitCode::Success), NativeCommand::CiGitRefs { format } => { mergify_ci::git_refs::run(&GitRefsOptions { format }, &mut output) + .map(|()| mergify_core::ExitCode::Success) } - NativeCommand::CiQueueInfo => mergify_ci::queue_info::run(&mut output), - NativeCommand::QueuePause(opts) => { - mergify_queue::pause::run( - PauseOptions { - repository: opts.repository.as_deref(), - token: opts.token.as_deref(), - api_url: opts.api_url.as_deref(), - reason: &opts.reason, - yes_i_am_sure: opts.yes_i_am_sure, - }, - &mut output, - ) - .await - } - NativeCommand::QueueUnpause(opts) => { - mergify_queue::unpause::run( - UnpauseOptions { - repository: opts.repository.as_deref(), - token: opts.token.as_deref(), - api_url: opts.api_url.as_deref(), - }, - &mut output, - ) - .await + NativeCommand::CiQueueInfo => { + mergify_ci::queue_info::run(&mut output).map(|()| mergify_core::ExitCode::Success) } - NativeCommand::QueueStatus(opts) => { - mergify_queue::status::run( - StatusOptions { - repository: opts.repository.as_deref(), + NativeCommand::TestsShow(opts) => { + mergify_ci::tests_show::run( + TestsShowOptions { + repository: &opts.repository, + test_names: &opts.test_names, token: opts.token.as_deref(), api_url: opts.api_url.as_deref(), - branch: opts.branch.as_deref(), - output_json: opts.output_json, - }, - &mut output, - ) - .await - } - NativeCommand::QueueShow(opts) => { - mergify_queue::show::run( - ShowOptions { - repository: opts.repository.as_deref(), - token: opts.token.as_deref(), - api_url: opts.api_url.as_deref(), - pr_number: opts.pr_number, - verbose: opts.verbose, - output_json: opts.output_json, - }, - &mut output, - ) - .await - } - NativeCommand::FreezeList(opts) => { - mergify_freeze::list::run( - FreezeListOptions { - repository: opts.repository.as_deref(), - token: opts.token.as_deref(), - api_url: opts.api_url.as_deref(), - output_json: opts.output_json, + pipeline_name: &opts.pipeline_name, + pipeline_name_exclude: &opts.pipeline_name_exclude, + job_name: &opts.job_name, + job_name_exclude: &opts.job_name_exclude, + per_page: opts.per_page, }, &mut output, ) .await } + NativeCommand::QueuePause(opts) => mergify_queue::pause::run( + PauseOptions { + repository: opts.repository.as_deref(), + token: opts.token.as_deref(), + api_url: opts.api_url.as_deref(), + reason: &opts.reason, + yes_i_am_sure: opts.yes_i_am_sure, + }, + &mut output, + ) + .await + .map(|()| mergify_core::ExitCode::Success), + NativeCommand::QueueUnpause(opts) => mergify_queue::unpause::run( + UnpauseOptions { + repository: opts.repository.as_deref(), + token: opts.token.as_deref(), + api_url: opts.api_url.as_deref(), + }, + &mut output, + ) + .await + .map(|()| mergify_core::ExitCode::Success), + NativeCommand::QueueStatus(opts) => mergify_queue::status::run( + StatusOptions { + repository: opts.repository.as_deref(), + token: opts.token.as_deref(), + api_url: opts.api_url.as_deref(), + branch: opts.branch.as_deref(), + output_json: opts.output_json, + }, + &mut output, + ) + .await + .map(|()| mergify_core::ExitCode::Success), + NativeCommand::QueueShow(opts) => mergify_queue::show::run( + ShowOptions { + repository: opts.repository.as_deref(), + token: opts.token.as_deref(), + api_url: opts.api_url.as_deref(), + pr_number: opts.pr_number, + verbose: opts.verbose, + output_json: opts.output_json, + }, + &mut output, + ) + .await + .map(|()| mergify_core::ExitCode::Success), + NativeCommand::FreezeList(opts) => mergify_freeze::list::run( + FreezeListOptions { + repository: opts.repository.as_deref(), + token: opts.token.as_deref(), + api_url: opts.api_url.as_deref(), + output_json: opts.output_json, + }, + &mut output, + ) + .await + .map(|()| mergify_core::ExitCode::Success), } }); match result { - Ok(()) => ExitCode::from(mergify_core::ExitCode::Success.as_u8()), + Ok(code) => ExitCode::from(code.as_u8()), Err(err) => { let code = err.exit_code(); eprintln!("mergify: {err}"); @@ -554,6 +618,8 @@ enum Subcommands { Config(ConfigArgs), /// Mergify CI-related commands. Ci(CiArgs), + /// Inspect tests tracked by Mergify CI Insights. + Tests(TestsArgs), /// Manage the Mergify merge queue. Queue(QueueArgs), /// Manage scheduled freezes. @@ -699,6 +765,70 @@ struct ScopesSendCliArgs { file_deprecated: Option, } +#[derive(clap::Args)] +struct TestsArgs { + #[command(subcommand)] + command: TestsSubcommand, +} + +#[derive(Subcommand)] +enum TestsSubcommand { + /// Look up tests by name and print their health and metrics. + Show(TestsShowCliArgs), +} + +#[derive(clap::Args)] +struct TestsShowCliArgs { + /// Test name(s) to look up. Glob patterns (`*`, `?`) are + /// supported by the API. + #[arg(value_name = "NAME", required = true, num_args = 1..)] + test_names: Vec, + + /// Repository full name (owner/repo). + #[arg( + long, + short = 'r', + required = true, + value_parser = mergify_ci::detector::parse_owner_repo, + )] + repository: String, + + /// Mergify or GitHub token. Falls back to ``MERGIFY_TOKEN`` and + /// then ``GITHUB_TOKEN`` env vars. + #[arg(long, short = 't')] + token: Option, + + /// Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, + /// then to the default. + #[arg(long = "api-url", short = 'u')] + api_url: Option, + + /// Restrict matches to the given pipeline name(s). + #[arg(long = "pipeline-name")] + pipeline_name: Vec, + + /// Exclude matches from the given pipeline name(s). + #[arg(long = "pipeline-name-exclude")] + pipeline_name_exclude: Vec, + + /// Restrict matches to the given job name(s). + #[arg(long = "job-name")] + job_name: Vec, + + /// Exclude matches from the given job name(s). + #[arg(long = "job-name-exclude")] + job_name_exclude: Vec, + + /// Maximum number of identities the search endpoint may return + /// per page (1–100, server default is 10). + #[arg(long = "per-page", value_parser = clap::value_parser!(u32).range(1..=100))] + per_page: Option, + + /// Emit a single JSON document to stdout instead of human prose. + #[arg(long)] + json: bool, +} + #[derive(clap::Args)] struct QueueArgs { /// Mergify or GitHub token. Falls back to ``MERGIFY_TOKEN`` and diff --git a/crates/mergify-core/src/http.rs b/crates/mergify-core/src/http.rs index b17e3526..a7d14ef8 100644 --- a/crates/mergify-core/src/http.rs +++ b/crates/mergify-core/src/http.rs @@ -146,6 +146,25 @@ impl Client { } } + /// GET `path` with query-string pairs appended in caller order. + /// + /// Repeating the same key is supported (each entry produces its + /// own `key=value`), and values are percent-encoded so callers can + /// pass arbitrary strings (`*`, `&`, `?`, spaces, unicode). + /// An empty `query` slice produces no `?`. + pub async fn get_with_query( + &self, + path: &str, + query: &[(&str, &str)], + ) -> Result { + let mut url = self.join(path)?; + if !query.is_empty() { + url.query_pairs_mut().extend_pairs(query.iter().copied()); + } + let resp = self.execute_request(self.inner.get(url)).await?; + self.decode_json(resp).await + } + /// POST `body` as JSON to `path` and deserialize the JSON /// response as `T`. pub async fn post( @@ -703,6 +722,66 @@ mod tests { ); } + #[tokio::test] + async fn get_with_query_appends_repeated_keys_and_percent_encodes_values() { + let server = MockServer::start().await; + let client = fast_client(&server, ApiFlavor::Mergify); + + Mock::given(method("GET")) + .and(path("/lookup")) + .respond_with(ResponseTemplate::new(200).set_body_json(Foo { bar: 1 })) + .mount(&server) + .await; + + let _: Foo = client + .get_with_query( + "/lookup", + &[ + ("test_name", "*test login*"), + ("test_name", "a&b?c"), + ("limit", "5"), + ], + ) + .await + .unwrap(); + + let received = server.received_requests().await.unwrap(); + assert_eq!(received.len(), 1); + let raw_query = received[0].url.query().expect("expected a query string"); + // Repeated keys must preserve caller order; query-reserved + // characters (`&`, `?`) must be percent-encoded so the server + // doesn't mistake them for separators. Spaces become `+` (the + // application/x-www-form-urlencoded convention `url` follows). + // `*` is a sub-delim that servers parse literally, so it + // passes through unencoded. + assert_eq!( + raw_query, + "test_name=*test+login*&test_name=a%26b%3Fc&limit=5", + ); + } + + #[tokio::test] + async fn get_with_query_omits_question_mark_when_no_pairs() { + let server = MockServer::start().await; + let client = fast_client(&server, ApiFlavor::Mergify); + + Mock::given(method("GET")) + .and(path("/foo")) + .respond_with(ResponseTemplate::new(200).set_body_json(Foo { bar: 0 })) + .mount(&server) + .await; + + let _: Foo = client.get_with_query("/foo", &[]).await.unwrap(); + + let received = server.received_requests().await.unwrap(); + assert_eq!(received.len(), 1); + assert!( + received[0].url.query().is_none(), + "no pairs must produce no `?`, got {:?}", + received[0].url.query(), + ); + } + #[tokio::test] async fn error_message_truncates_oversized_body() { let server = MockServer::start().await; diff --git a/func-tests/test_live_smoke.py b/func-tests/test_live_smoke.py index b7f8e5d3..2db44285 100644 --- a/func-tests/test_live_smoke.py +++ b/func-tests/test_live_smoke.py @@ -316,6 +316,45 @@ def test_scopes_send( assert result.returncode == 0, f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" +def test_tests_show_no_match( + live_token: str, + cli: typing.Callable[..., typing.Any], +) -> None: + """`GET /v1/ci/{owner}/repositories/{repo}/search/tests` round-trip. + + Queries a guaranteed-nonexistent name so the test is independent + of whatever live test data the canary repository currently holds. + A green run proves auth, URL routing, and JSON deserialization for + the search endpoint — the empty-match path returns exit 0 with a + `{"tests": []}` payload on stdout. + """ + import json + + result = cli( + "tests", + "show", + "--api-url", + API_URL, + "--token", + live_token, + "--repository", + REPOSITORY, + "--json", + "__mergify_cli_smoke_no_such_test__", + ) + assert result.returncode == 0, f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError as exc: + pytest.fail( + f"tests show --json emitted non-JSON output\n" + f"error: {exc}\nstdout:\n{result.stdout}", + ) + assert payload == {"tests": []}, ( + f"expected empty `tests` list for nonexistent test name, got:\n{result.stdout}" + ) + + def test_junit_process( live_token: str, cli: typing.Callable[..., typing.Any], diff --git a/skills/mergify-ci/SKILL.md b/skills/mergify-ci/SKILL.md index 758d5758..9d3c2061 100644 --- a/skills/mergify-ci/SKILL.md +++ b/skills/mergify-ci/SKILL.md @@ -18,6 +18,7 @@ mergify ci git-refs # Detect base/head git references for mergify ci scopes --config PATH # Detect scopes impacted by changed files mergify ci scopes-send -s SCOPE # Send scopes tied to a pull request to Mergify mergify ci queue-info # Output merge queue batch metadata from the current PR event +mergify tests show NAME... # Look up tests by name and print health, ratios, last failure ``` ## JUnit Processing (`junit-process`) @@ -142,6 +143,39 @@ mergify ci scopes-send --scopes-file scopes.txt -p 123 - `--scopes-json` -- JSON file containing scopes (output of `mergify ci scopes --write`) - `--scopes-file` -- Plain-text file with one scope per line +## Tests Show (`tests show`) + +Looks up tests by name on the repository's default branch and prints their +health, success/failure ratios, and last failure context. The search is a +batch API: pass one or more names (globs supported) and one block per match +is rendered. Exit code reflects the worst health observed. + +```bash +# Single test. +mergify tests show -r owner/repo \ + 'ApplicationKeys.spec.ts.Permissions › Should not see keys table if not admin' + +# Batch with glob, narrowed to one pipeline, JSON for jq. +mergify tests show -r owner/repo \ + --pipeline-name e2e --json \ + '*test_login*' '*test_logout*' \ + | jq '.tests[] | {test_name, health_status}' +``` + +**Key options:** +- `--repository` / `-r` -- Repository full name (`owner/repo`); required. +- `--token` / `-t` (env: `MERGIFY_TOKEN`, then `GITHUB_TOKEN`) -- Auth token. +- `--api-url` / `-u` (env: `MERGIFY_API_URL`) -- API base URL. +- `--pipeline-name`, `--pipeline-name-exclude` -- Restrict / exclude by pipeline. +- `--job-name`, `--job-name-exclude` -- Restrict / exclude by job. +- `--per-page` -- Cap the search result count (1–100, server default 10). +- `--json` -- Emit a single JSON document `{"tests": [...]}` to stdout. + +**Exit codes:** +- `0` -- All matched tests are `healthy` or unknown (or no match at all). +- `1` -- At least one test is `flaky`. +- `6` -- At least one test is `broken` (consistently failing). + ## Queue Info (`queue-info`) Outputs merge queue batch metadata from the current pull request event. Only works on merge queue draft pull requests. Writes output to `GITHUB_OUTPUT` when running in GitHub Actions.