From 05e2c1891e52a128637f14f78cb5f7724726a7fe Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 20 Jul 2026 09:17:35 +0000 Subject: [PATCH 1/6] Add code quality issue listing to CLI Add 'corgea list --code-quality' (alias --quality) to list code quality findings, mirroring --issues but hitting the code quality endpoints (/scan//issues/quality and /issues/code-quality). - Add get_quality_issues() to the API client. - Deserialize the new 'type' discriminator on issues (optional). - Make --issues, --sca-issues, and --code-quality mutually exclusive. - Add a deserialization test for code quality issue responses. Co-authored-by: ibrahim --- src/list.rs | 36 ++++++++++++---- src/main.rs | 23 +++++++++-- src/utils/api.rs | 105 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 12 deletions(-) diff --git a/src/list.rs b/src/list.rs index e50d476..db726f2 100644 --- a/src/list.rs +++ b/src/list.rs @@ -8,6 +8,7 @@ pub fn run( config: &Config, issues: &bool, sca_issues: &bool, + code_quality: &bool, json: &bool, page: &Option, page_size: &Option, @@ -108,14 +109,30 @@ pub fn run( Some(sca_issues_response.page), Some(sca_issues_response.total_pages), ); - } else if *issues { - let issues_response = match utils::api::get_scan_issues( - &config.get_url(), - &project_name, - Some((*page).unwrap_or(1)), - *page_size, - scan_id.clone(), - ) { + } else if *issues || *code_quality { + let fetch_result = if *code_quality { + utils::api::get_quality_issues( + &config.get_url(), + &project_name, + Some((*page).unwrap_or(1)), + *page_size, + scan_id.clone(), + ) + } else { + utils::api::get_scan_issues( + &config.get_url(), + &project_name, + Some((*page).unwrap_or(1)), + *page_size, + scan_id.clone(), + ) + }; + let issue_kind = if *code_quality { + "code quality issues" + } else { + "scan issues" + }; + let issues_response = match fetch_result { Ok(response) => response, Err(e) => { debug(&format!("Error Sending Request: {}", e)); @@ -127,7 +144,7 @@ pub fn run( } } else { log::error!( - "Unable to fetch scan issues. Please check your connection and ensure that:\n\ + "Unable to fetch {issue_kind}. Please check your connection and ensure that:\n\ - The server URL is reachable.\n\ - Your authentication token is valid.\n\n\ Check out our docs at https://docs.corgea.app/install_cli#login-with-the-cli {}", @@ -185,6 +202,7 @@ pub fn run( .map(|issue| { serde_json::json!(utils::api::IssueWithBlockingRules { id: issue.id.clone(), + issue_type: issue.issue_type.clone(), scan_id: issue.scan_id.clone(), status: issue.status.clone(), urgency: issue.urgency.clone(), diff --git a/src/main.rs b/src/main.rs index 5023c58..b237c34 100644 --- a/src/main.rs +++ b/src/main.rs @@ -148,6 +148,14 @@ enum Commands { )] sca_issues: bool, + #[arg( + long, + short = 'q', + visible_alias = "quality", + help = "List code quality issues instead of scans" + )] + code_quality: bool, + #[arg(short, long, help = "Specify the scan id to list issues for.")] scan_id: Option, @@ -612,13 +620,21 @@ fn main() { page_size, scan_id, sca_issues, + code_quality, }) => { verify_token_and_exit_when_fail(&corgea_config); - if *issues && *sca_issues { - ::log::error!("Cannot use both --issues and --sca-issues at the same time."); + if [*issues, *sca_issues, *code_quality] + .iter() + .filter(|flag| **flag) + .count() + > 1 + { + ::log::error!( + "Cannot use more than one of --issues, --sca-issues, and --code-quality at the same time." + ); std::process::exit(1); } - if scan_id.is_some() && !*issues && !*sca_issues { + if scan_id.is_some() && !*issues && !*sca_issues && !*code_quality { println!("scan_id option is only supported for issues list command."); std::process::exit(1); } @@ -626,6 +642,7 @@ fn main() { &corgea_config, issues, sca_issues, + code_quality, json, page, page_size, diff --git a/src/utils/api.rs b/src/utils/api.rs index 47a68bc..dc08805 100644 --- a/src/utils/api.rs +++ b/src/utils/api.rs @@ -513,6 +513,62 @@ pub fn get_scan_issues( } } +pub fn get_quality_issues( + url: &str, + project: &str, + page: Option, + page_size: Option, + scan_id: Option, +) -> Result> { + let mut seperator = "?"; + let mut url = match scan_id { + Some(scan_id) => format!("{}{}/scan/{}/issues/quality", url, API_BASE, scan_id), + None => { + seperator = "&"; + format!( + "{}{}/issues/code-quality?project={}", + url, API_BASE, project + ) + } + }; + if let Some(p) = page { + url.push_str(&format!("{}page={}", seperator, p)); + } + if let Some(p_size) = page_size { + url.push_str(&format!("&page_size={}", p_size)); + } else { + url.push_str("&page_size=30"); + } + let client = http_client(); + + debug(&format!("Sending request to URL: {}", url)); + + let response = match client.get(&url).send() { + Ok(res) => { + check_for_warnings(res.headers(), res.status()); + res + } + Err(e) => return Err(format!("Failed to send request: {}", e).into()), + }; + let response_text = response.text()?; + let project_issues_response: ProjectIssuesResponse = serde_json::from_str(&response_text) + .map_err(|e| { + debug(&format!( + "Failed to parse response: {}. Response body: {}", + e, response_text + )); + format!("Failed to parse response: {}", e) + })?; + + if project_issues_response.status == "ok" { + Ok(project_issues_response) + } else if project_issues_response.status == "no_project_found" { + Err("Project not found 404".into()) + } else { + Err("Server error 500".into()) + } +} + pub fn get_scan(url: &str, scan_id: &str) -> Result> { let url = format!("{}{}/scan/{}", url, API_BASE, scan_id); @@ -968,6 +1024,8 @@ pub struct FullIssueResponse { #[derive(Serialize, Deserialize, Debug)] pub struct Issue { pub id: String, + #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] + pub issue_type: Option, pub scan_id: Option, pub status: String, pub urgency: String, @@ -982,6 +1040,8 @@ pub struct Issue { #[derive(Serialize, Deserialize, Debug)] pub struct IssueWithBlockingRules { pub id: String, + #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] + pub issue_type: Option, pub scan_id: Option, pub status: String, pub urgency: String, @@ -1136,6 +1196,51 @@ mod tests { assert!(headers.get("CORGEA-SOURCE").is_some()); } + #[test] + fn deserializes_code_quality_issue_response() { + // Code quality issues carry a free-form classification label (no CWE) and + // a `type` discriminator, and must deserialize into the same Issue struct + // used for security issues. + let body = r#"{ + "status": "ok", + "page": 1, + "total_pages": 1, + "total_issues": 1, + "issues": [ + { + "id": "11111111-1111-1111-1111-111111111111", + "type": "code_quality", + "urgency": "ME", + "created_at": "2026-01-01T00:00:00Z", + "status": "open", + "classification": { + "id": "Maintainability", + "name": "Maintainability", + "description": null + }, + "location": { + "file": {"name": "app.py", "language": "python", "path": "app/app.py"}, + "project": {"name": "proj", "branch": "main", "git_sha": "abc"}, + "line_number": 20 + }, + "auto_triage": {"false_positive_detection": {"status": "valid"}}, + "auto_fix_suggestion": {"status": "no_fix"} + } + ] + }"#; + + let parsed: ProjectIssuesResponse = + serde_json::from_str(body).expect("should parse code quality response"); + assert_eq!(parsed.status, "ok"); + let issues = parsed.issues.expect("issues present"); + assert_eq!(issues.len(), 1); + let issue = &issues[0]; + assert_eq!(issue.issue_type.as_deref(), Some("code_quality")); + assert_eq!(issue.classification.id, "Maintainability"); + assert_eq!(issue.classification.name, "Maintainability"); + assert!(issue.classification.description.is_none()); + } + #[test] fn should_warn_deprecated_false_when_no_warning_header() { let headers = HeaderMap::new(); From ecf51c0f0ab84b777ea0900c05ff203c4a38719b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 20 Jul 2026 09:25:25 +0000 Subject: [PATCH 2/6] Drop redundant issue type field from CLI Code quality and security issues come from separate endpoints, so the CLI does not need a 'type' discriminator on the Issue struct. Keeps the CLI in sync with the API response, which no longer emits the field. Co-authored-by: ibrahim --- src/list.rs | 1 - src/utils/api.rs | 9 +-------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/list.rs b/src/list.rs index db726f2..0c77801 100644 --- a/src/list.rs +++ b/src/list.rs @@ -202,7 +202,6 @@ pub fn run( .map(|issue| { serde_json::json!(utils::api::IssueWithBlockingRules { id: issue.id.clone(), - issue_type: issue.issue_type.clone(), scan_id: issue.scan_id.clone(), status: issue.status.clone(), urgency: issue.urgency.clone(), diff --git a/src/utils/api.rs b/src/utils/api.rs index dc08805..8196448 100644 --- a/src/utils/api.rs +++ b/src/utils/api.rs @@ -1024,8 +1024,6 @@ pub struct FullIssueResponse { #[derive(Serialize, Deserialize, Debug)] pub struct Issue { pub id: String, - #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] - pub issue_type: Option, pub scan_id: Option, pub status: String, pub urgency: String, @@ -1040,8 +1038,6 @@ pub struct Issue { #[derive(Serialize, Deserialize, Debug)] pub struct IssueWithBlockingRules { pub id: String, - #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] - pub issue_type: Option, pub scan_id: Option, pub status: String, pub urgency: String, @@ -1199,8 +1195,7 @@ mod tests { #[test] fn deserializes_code_quality_issue_response() { // Code quality issues carry a free-form classification label (no CWE) and - // a `type` discriminator, and must deserialize into the same Issue struct - // used for security issues. + // must deserialize into the same Issue struct used for security issues. let body = r#"{ "status": "ok", "page": 1, @@ -1209,7 +1204,6 @@ mod tests { "issues": [ { "id": "11111111-1111-1111-1111-111111111111", - "type": "code_quality", "urgency": "ME", "created_at": "2026-01-01T00:00:00Z", "status": "open", @@ -1235,7 +1229,6 @@ mod tests { let issues = parsed.issues.expect("issues present"); assert_eq!(issues.len(), 1); let issue = &issues[0]; - assert_eq!(issue.issue_type.as_deref(), Some("code_quality")); assert_eq!(issue.classification.id, "Maintainability"); assert_eq!(issue.classification.name, "Maintainability"); assert!(issue.classification.description.is_none()); From c72c3d6a09b5902531615e18333a1baebc828ef8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 20 Jul 2026 09:41:32 +0000 Subject: [PATCH 3/6] Fix clippy too_many_arguments on list::run CI runs clippy with -D warnings; adding the --code-quality flag pushed list::run to 8 arguments (limit 7). Allow the lint here, matching the existing pattern used in blast.rs and deps/ecosystems/evaluate.rs. Co-authored-by: ibrahim --- src/list.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/list.rs b/src/list.rs index 0c77801..77c007f 100644 --- a/src/list.rs +++ b/src/list.rs @@ -4,6 +4,7 @@ use crate::utils; use serde_json::json; use std::path::Path; +#[allow(clippy::too_many_arguments)] pub fn run( config: &Config, issues: &bool, From e368bd4153435fc2dd0b9079ccc8b8015c8efa7e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 20 Jul 2026 13:20:37 +0000 Subject: [PATCH 4/6] Resolve list project name via determine_project_name corgea list used the current directory basename as the project key, while corgea scan stores projects under determine_project_name (which prefers the Git remote repository name). This caused 'Project not found' when the checkout directory differed from the repo name, including Git worktrees. Use the same determine_project_name helper for list so the lookup key matches what scans are stored under. Applies to --issues, --sca-issues, --code-quality, and the default scan listing. Co-authored-by: ibrahim --- src/list.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/list.rs b/src/list.rs index 77c007f..9b121cf 100644 --- a/src/list.rs +++ b/src/list.rs @@ -15,8 +15,12 @@ pub fn run( page_size: &Option, scan_id: &Option, ) { - let project_name = - utils::generic::get_current_working_directory().unwrap_or("unknown".to_string()); + // Resolve the project name the same way `corgea scan` does (prefer the Git + // remote repository name, then fall back to the directory name) so lookups + // match the project key scans are stored under. Using the bare directory + // basename here caused "Project not found" whenever the checkout directory + // differed from the repository name (e.g. Git worktrees). + let project_name = utils::generic::determine_project_name(None); println!(); if *sca_issues { let sca_issues_response = match utils::api::get_sca_issues( From 6b56917357ad82a036319902a506429154c31794 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 20 Jul 2026 13:31:13 +0000 Subject: [PATCH 5/6] Add --project-name to list and stop CQ inheriting blocking rules - Add a --project-name flag to 'corgea list', matching 'corgea scan', and thread it through determine_project_name so users can override the resolved project key explicitly. - Gate blocking-rules enrichment to security listings only. Previously 'corgea list --code-quality --scan-id' ran check_blocking_rules and hard-exited on any failure (even after the CQ fetch succeeded), and could render Blocking columns driven by non-CQ findings. Co-authored-by: ibrahim --- src/list.rs | 19 ++++++++++++------- src/main.rs | 8 ++++++++ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/list.rs b/src/list.rs index 9b121cf..e10d1a7 100644 --- a/src/list.rs +++ b/src/list.rs @@ -14,13 +14,15 @@ pub fn run( page: &Option, page_size: &Option, scan_id: &Option, + project_name: &Option, ) { - // Resolve the project name the same way `corgea scan` does (prefer the Git - // remote repository name, then fall back to the directory name) so lookups - // match the project key scans are stored under. Using the bare directory - // basename here caused "Project not found" whenever the checkout directory - // differed from the repository name (e.g. Git worktrees). - let project_name = utils::generic::determine_project_name(None); + // Resolve the project name the same way `corgea scan` does: honor an explicit + // --project-name, otherwise prefer the Git remote repository name and fall + // back to the directory name. This matches the project key scans are stored + // under; using the bare directory basename caused "Project not found" + // whenever the checkout directory differed from the repository name (e.g. + // Git worktrees). + let project_name = utils::generic::determine_project_name(project_name.as_deref()); println!(); if *sca_issues { let sca_issues_response = match utils::api::get_sca_issues( @@ -163,7 +165,10 @@ pub fn run( let mut blocking_rules: std::collections::HashMap = std::collections::HashMap::new(); - if scan_id.is_some() { + // Blocking rules are a security-listing concern. Skip the enrichment for + // code quality so a blocking-rules API failure can't take down the CQ + // listing and so Blocking columns aren't driven by non-CQ findings. + if scan_id.is_some() && !*code_quality { let mut page: u32 = 1; loop { match utils::api::check_blocking_rules( diff --git a/src/main.rs b/src/main.rs index b237c34..e94a53f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -167,6 +167,12 @@ enum Commands { #[arg(long, value_parser = clap::value_parser!(u16), help = "Number of items per page")] page_size: Option, + + #[arg( + long, + help = "The name of the Corgea project. Defaults to git repository name if found, otherwise to the current directory name." + )] + project_name: Option, }, /// Inspect something, by default it will inspect a scan Inspect { @@ -621,6 +627,7 @@ fn main() { scan_id, sca_issues, code_quality, + project_name, }) => { verify_token_and_exit_when_fail(&corgea_config); if [*issues, *sca_issues, *code_quality] @@ -647,6 +654,7 @@ fn main() { page, page_size, scan_id, + project_name, ); } Some(Commands::Inspect { From 90e17a571ba2cee34dae5c6efff676b5bcde8f53 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 12:30:06 +0000 Subject: [PATCH 6/6] Lock the code quality endpoint contract with tests Adds end-to-end coverage for the two documented code quality routes, the project resolution they share with --issues, the blocking-rules gate, and the 404 mapping, plus a unit test pinning the request paths and query. Documents the flag in the agent skill. Co-authored-by: Ibrahim Rahhal --- skills/corgea/SKILL.md | 2 + src/utils/api.rs | 37 +++++++ tests/common/mod.rs | 10 +- tests/list_code_quality.rs | 209 +++++++++++++++++++++++++++++++++++++ 4 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 tests/list_code_quality.rs diff --git a/skills/corgea/SKILL.md b/skills/corgea/SKILL.md index df32d48..ebc45d0 100644 --- a/skills/corgea/SKILL.md +++ b/skills/corgea/SKILL.md @@ -77,6 +77,7 @@ corgea wait --scan-id SCAN_ID # Wait for specific scan corgea ls # List scans corgea ls --issues --scan-id SCAN_ID # Issues for a scan corgea ls --sca-issues # SCA (dependency) issues +corgea ls --code-quality # Code quality issues corgea ls --issues --page 2 --page-size 10 # Pagination corgea ls --issues --scan-id SCAN_ID --json # JSON output ``` @@ -85,6 +86,7 @@ corgea ls --issues --scan-id SCAN_ID --json # JSON output |------|-------|-------------| | `--issues` | `-i` | List code/SAST issues | | `--sca-issues` | `-c` | List SCA issues | +| `--code-quality` | `-q` | List code quality issues (alias `--quality`) | | `--scan-id` | `-s` | Filter to a scan | | `--page` | `-p` | Page number | | `--page-size` | | Items per page | diff --git a/src/utils/api.rs b/src/utils/api.rs index 2662fab..e4c4f74 100644 --- a/src/utils/api.rs +++ b/src/utils/api.rs @@ -1616,6 +1616,43 @@ mod tests { assert!(issue.classification.description.is_none()); } + #[test] + fn quality_issues_request_targets_the_documented_paths() { + // The two code quality routes are named asymmetrically on the backend, + // so the paths are pinned here rather than derived from each other. + let (endpoint, query) = + quality_issues_request("https://api.example.com", "proj", Some(2), Some(10), None); + assert_eq!( + endpoint, + "https://api.example.com/api/v1/issues/code-quality" + ); + assert_eq!( + query, + vec![ + ("project", "proj".to_string()), + ("page", "2".to_string()), + ("page_size", "10".to_string()), + ] + ); + + let (endpoint, query) = quality_issues_request( + "https://api.example.com", + "proj", + Some(1), + None, + Some("scan-123"), + ); + assert_eq!( + endpoint, + "https://api.example.com/api/v1/scan/scan-123/issues/quality" + ); + // A scan selects its own project, and the page size defaults to 30. + assert_eq!( + query, + vec![("page", "1".to_string()), ("page_size", "30".to_string())] + ); + } + #[test] fn should_warn_deprecated_false_when_no_warning_header() { let headers = HeaderMap::new(); diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 83068c9..77d4be4 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -559,11 +559,15 @@ pub struct Routes { pub scans: Option, pub issues: Option, pub sca_issues: Option, + /// `GET /issues/code-quality` — the project-scoped `--code-quality` route. + pub code_quality_issues: Option, /// `GET /scan/{id}` — `check_scan_status`. pub scan: Option, /// `GET /scan/{id}/issues` — `report_scan_status` and the `--scan-id` /// issue route. pub scan_issues: Option, + /// `GET /scan/{id}/issues/quality` — the `--code-quality --scan-id` route. + pub scan_quality_issues: Option, } #[allow(dead_code)] @@ -579,10 +583,14 @@ impl Routes { self.scans.clone() } else if path.starts_with("/api/v1/issues/sca") { self.sca_issues.clone() + } else if path.starts_with("/api/v1/issues/code-quality") { + self.code_quality_issues.clone() } else if path.starts_with("/api/v1/issues?") { self.issues.clone() } else if path.starts_with("/api/v1/scan/") { - if path.contains("/issues") { + if path.contains("/issues/quality") { + self.scan_quality_issues.clone() + } else if path.contains("/issues") { self.scan_issues.clone() } else { self.scan.clone() diff --git a/tests/list_code_quality.rs b/tests/list_code_quality.rs new file mode 100644 index 0000000..e213196 --- /dev/null +++ b/tests/list_code_quality.rs @@ -0,0 +1,209 @@ +//! End-to-end tests for `corgea list --code-quality`. +//! +//! The code quality routes are named asymmetrically on the backend +//! (`/issues/code-quality` for a project, `/scan/{id}/issues/quality` for a +//! scan), so the request targets are asserted rather than assumed. Stubs route +//! on the request-target path PREFIX. + +mod common; + +use common::{projects_empty, projects_match, Hits, Routes, CANON, REMOTE}; +use std::path::Path; +use std::process::Output; + +// --- stub bodies ----------------------------------------------------------- + +/// A code quality page: the classification is a label (`Maintainability`), +/// not a CWE, and carries no description. +fn quality_one() -> String { + r#"{"status":"ok","page":1,"total_pages":1,"total_issues":1,"issues":[{"id":"quality-abc","scan_id":"scan-123","status":"open","urgency":"medium","created_at":"2026-01-01T00:00:00Z","classification":{"id":"Maintainability","name":"Maintainability","description":null},"location":{"file":{"name":"app.py","language":"python","path":"src/app.py"},"line_number":20,"project":{"name":"bohappdev/dotnet-azure-web-tsb","branch":null,"git_sha":null}},"details":null,"auto_triage":{"false_positive_detection":{"status":"valid","reasoning":null}},"auto_fix_suggestion":null}]}"#.to_string() +} + +/// `/issues` returning one security issue, so a test can tell the two listings +/// apart by which id was rendered. +fn issues_one() -> String { + r#"{"status":"ok","page":1,"total_pages":1,"total_issues":1,"issues":[{"id":"issue-abc","scan_id":"scan-123","status":"open","urgency":"high","created_at":"2026-01-01T00:00:00Z","classification":{"id":"CWE-89","name":"SQL Injection","description":null},"location":{"file":{"name":"app.py","language":"python","path":"src/app.py"},"line_number":42,"project":{"name":"bohappdev/dotnet-azure-web-tsb","branch":null,"git_sha":null}},"details":null,"auto_triage":{"false_positive_detection":{"status":"none","reasoning":null}},"auto_fix_suggestion":null}]}"#.to_string() +} + +// --- harness --------------------------------------------------------------- + +/// Serves the project-scoped code quality route plus the security routes it +/// must not fall back to. +fn spawn_project_stub(projects: String) -> (String, Hits) { + common::spawn_resolution_stub(Routes { + projects: Some(projects), + issues: Some(issues_one()), + code_quality_issues: Some(quality_one()), + ..Default::default() + }) +} + +fn run_list(args: &[&str], url: &str, cwd: &Path) -> Output { + common::run_corgea("list", args, url, cwd) +} + +fn assert_exit(out: &Output, code: i32) { + assert_eq!( + out.status.code(), + Some(code), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); +} + +// --- tests ----------------------------------------------------------------- + +#[test] +fn code_quality_reads_the_code_quality_endpoint_not_the_security_one() { + let (url, hits) = spawn_project_stub(projects_empty()); + let (_tmp, repo) = common::temp_git_repo("dotnet-azure-web-tsb", REMOTE); + let out = run_list(&["--code-quality"], &url, &repo); + assert_exit(&out, 0); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("quality-abc"), "stdout: {stdout}"); + // The label stands in for the CWE column. + assert!(stdout.contains("Maintainability"), "stdout: {stdout}"); + assert!( + !stdout.contains("issue-abc"), + "the security listing must not answer --code-quality; stdout: {stdout}" + ); + let hits = hits.lock().unwrap(); + assert!( + hits.iter() + .any(|h| h.starts_with("/api/v1/issues/code-quality?")), + "expected the code quality endpoint; hits: {hits:?}" + ); + assert!( + !hits.iter().any(|h| h.starts_with("/api/v1/issues?")), + "the security issue endpoint must not be dialed; hits: {hits:?}" + ); +} + +#[test] +fn code_quality_alias_and_short_flag_reach_the_same_endpoint() { + for flag in ["--quality", "-q"] { + let (url, hits) = spawn_project_stub(projects_empty()); + let (_tmp, repo) = common::temp_git_repo("dotnet-azure-web-tsb", REMOTE); + let out = run_list(&[flag], &url, &repo); + assert_exit(&out, 0); + let hits = hits.lock().unwrap(); + assert!( + hits.iter() + .any(|h| h.starts_with("/api/v1/issues/code-quality?")), + "{flag} should list code quality; hits: {hits:?}" + ); + } +} + +#[test] +fn code_quality_scopes_to_the_project_resolved_from_the_repo() { + // The checkout is `build-123`, so a canonical `project=` can only have come + // from /projects resolution — the same path `--issues` takes. (COR-1577) + let (url, hits) = spawn_project_stub(projects_match()); + let (_tmp, repo) = common::temp_git_repo("build-123", REMOTE); + let out = run_list(&["--code-quality"], &url, &repo); + assert_exit(&out, 0); + let encoded = CANON.replace('/', "%2F"); + let hits = hits.lock().unwrap(); + assert!( + hits.iter() + .any(|h| h.starts_with("/api/v1/issues/code-quality?") + && h.contains(&format!("project={encoded}"))), + "the canonical project must scope the code quality request; hits: {hits:?}" + ); + assert!( + !hits.iter().any(|h| h.contains("project=build-123")), + "the checkout dir name must not be queried; hits: {hits:?}" + ); +} + +#[test] +fn code_quality_percent_encodes_the_project_name() { + // Interpolated raw, an `&` would split the query and address `foo` instead. + let (url, hits) = spawn_project_stub(projects_empty()); + let (_tmp, dir) = common::temp_plain_dir("whatever"); + let out = run_list( + &["--code-quality", "--project-name", "foo&bar#baz"], + &url, + &dir, + ); + assert_exit(&out, 0); + let hits = hits.lock().unwrap(); + assert!( + hits.iter() + .any(|h| h.starts_with("/api/v1/issues/code-quality?") + && h.contains("project=foo%26bar%23baz")), + "the delimiters must be encoded, not split the query; hits: {hits:?}" + ); +} + +#[test] +fn code_quality_with_a_scan_id_uses_the_scan_route_and_skips_blocking_rules() { + // Blocking rules are a security concern: with `check_blocking_rules` + // unstubbed (404), reaching it would exit 1 even though the code quality + // fetch succeeded. + let (url, hits) = common::spawn_resolution_stub(Routes { + scan_issues: Some(issues_one()), + scan_quality_issues: Some(quality_one()), + ..Default::default() + }); + let (_tmp, repo) = common::temp_git_repo("dotnet-azure-web-tsb", REMOTE); + let out = run_list(&["--code-quality", "--scan-id", "scan-123"], &url, &repo); + assert_exit(&out, 0); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("quality-abc"), "stdout: {stdout}"); + assert!( + !stdout.contains("Blocking"), + "no blocking columns on a code quality table; stdout: {stdout}" + ); + let hits = hits.lock().unwrap(); + assert!( + hits.iter() + .any(|h| h.starts_with("/api/v1/scan/scan-123/issues/quality")), + "expected the scan-scoped code quality endpoint; hits: {hits:?}" + ); + assert!( + !hits.iter().any(|h| h.contains("check_blocking_rules")), + "blocking rules must not be checked for code quality; hits: {hits:?}" + ); + assert!( + !hits.iter().any(|h| h.starts_with("/api/v1/projects")), + "no /projects resolution on the --scan-id route; hits: {hits:?}" + ); +} + +#[test] +fn code_quality_reports_a_missing_scan_rather_than_a_parse_failure() { + // These endpoints answer a missing scan with a bare HTTP 404, so the status + // has to be read before the body or the miss surfaces as "Failed to parse". + let (url, _hits) = common::spawn_resolution_stub(Routes::default()); + let (_tmp, repo) = common::temp_git_repo("dotnet-azure-web-tsb", REMOTE); + let out = run_list(&["--code-quality", "--scan-id", "nope"], &url, &repo); + assert_exit(&out, 1); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("Scan with ID 'nope'"), "stderr: {stderr}"); + assert!( + !stderr.contains("Failed to parse"), + "a 404 must not read as a parse failure; stderr: {stderr}" + ); +} + +#[test] +fn issue_kind_flags_are_mutually_exclusive() { + for args in [ + ["--issues", "--code-quality"], + ["--sca-issues", "--code-quality"], + ["--issues", "--sca-issues"], + ] { + let (url, _hits) = common::spawn_resolution_stub(Routes::default()); + let (_tmp, dir) = common::temp_plain_dir("whatever"); + let out = run_list(&args, &url, &dir); + assert_exit(&out, 1); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("Cannot use more than one of"), + "{args:?} should be rejected; stderr: {stderr}" + ); + } +}