diff --git a/src/list.rs b/src/list.rs index e50d476..e10d1a7 100644 --- a/src/list.rs +++ b/src/list.rs @@ -4,17 +4,25 @@ use crate::utils; use serde_json::json; use std::path::Path; +#[allow(clippy::too_many_arguments)] pub fn run( config: &Config, issues: &bool, sca_issues: &bool, + code_quality: &bool, json: &bool, page: &Option, page_size: &Option, scan_id: &Option, + project_name: &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: 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( @@ -108,14 +116,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 +151,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 {}", @@ -141,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 5023c58..e94a53f 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, @@ -159,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 { @@ -612,13 +626,22 @@ fn main() { page_size, scan_id, sca_issues, + code_quality, + project_name, }) => { 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,10 +649,12 @@ fn main() { &corgea_config, issues, sca_issues, + code_quality, json, page, page_size, scan_id, + project_name, ); } Some(Commands::Inspect { diff --git a/src/utils/api.rs b/src/utils/api.rs index 47a68bc..8196448 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); @@ -1136,6 +1192,48 @@ 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 + // 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", + "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.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();