Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions skills/corgea/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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 |
Expand Down
44 changes: 33 additions & 11 deletions src/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use std::path::Path;
pub struct ListArgs {
pub issues: bool,
pub sca_issues: bool,
pub code_quality: bool,
pub json: bool,
pub page: Option<u16>,
pub page_size: Option<u16>,
Expand All @@ -20,6 +21,7 @@ pub fn run(config: &Config, args: ListArgs) {
let ListArgs {
issues,
sca_issues,
code_quality,
json,
page,
page_size,
Expand Down Expand Up @@ -131,22 +133,39 @@ pub fn run(config: &Config, args: ListArgs) {
Some(sca_issues_response.page),
Some(sca_issues_response.total_pages),
);
} else if issues {
// The --scan-id route hits /scan/{id}/issues and ignores the project.
} else if issues || code_quality {
// The --scan-id route hits /scan/{id}/issues[/quality] and ignores the
// project.
let resolved = scan_id
.is_none()
.then(|| utils::api::resolve_project_or_exit(&config.get_url(), &selector));
let project_name = resolved
.as_ref()
.map(|r| r.query_name.clone())
.unwrap_or_default();
let issues_response = match 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 fetch_result = if code_quality {
utils::api::get_quality_issues(
&config.get_url(),
&project_name,
Comment thread
Ibrahimrahhal marked this conversation as resolved.
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 issues_response = match fetch_result {
Ok(response) => response,
Err(e) => {
debug(&format!("Error Sending Request: {}", e));
Expand All @@ -162,7 +181,7 @@ pub fn run(config: &Config, args: ListArgs) {
}
} 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 {}",
Expand All @@ -176,7 +195,10 @@ pub fn run(config: &Config, args: ListArgs) {
let mut blocking_rules: std::collections::HashMap<String, String> =
std::collections::HashMap::new();

if let Some(id) = &scan_id {
// 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 let Some(id) = scan_id.as_ref().filter(|_| !code_quality) {
let mut page: u32 = 1;
loop {
match utils::api::check_blocking_rules(&config.get_url(), id, Some(page)) {
Expand Down
23 changes: 20 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,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<String>,

Expand Down Expand Up @@ -774,15 +782,23 @@ fn main() {
page_size,
scan_id,
sca_issues,
code_quality,
project_name,
repo,
}) => {
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);
}
Expand All @@ -791,6 +807,7 @@ fn main() {
list::ListArgs {
issues: *issues,
sca_issues: *sca_issues,
code_quality: *code_quality,
json: *json,
page: *page,
page_size: *page_size,
Expand Down
162 changes: 162 additions & 0 deletions src/utils/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,89 @@ pub fn get_scan_issues(
}
}

/// Endpoint and query for a code quality listing. The backend serves code
/// quality from paths parallel to — but not named like — the security routes:
/// `/scan/{id}/issues/quality` for a scan, `/issues/code-quality` otherwise.
fn quality_issues_request(
url: &str,
project: &str,
page: Option<u16>,
page_size: Option<u16>,
scan_id: Option<&str>,
) -> (String, Vec<(&'static str, String)>) {
// Project names can contain `&`/`?`/`#`, so use `query`, not `format!`.
let (endpoint, mut query_params) = match scan_id {
Some(scan_id) => (
format!("{}{}/scan/{}/issues/quality", url, API_BASE, scan_id),
vec![],
),
None => (
format!("{}{}/issues/code-quality", url, API_BASE),
vec![("project", project.to_string())],
),
};
if let Some(p) = page {
query_params.push(("page", p.to_string()));
}
query_params.push(("page_size", page_size.unwrap_or(30).to_string()));
(endpoint, query_params)
}

pub fn get_quality_issues(
url: &str,
project: &str,
page: Option<u16>,
page_size: Option<u16>,
scan_id: Option<String>,
) -> Result<ProjectIssuesResponse, Box<dyn std::error::Error>> {
let (endpoint, query_params) =
quality_issues_request(url, project, page, page_size, scan_id.as_deref());
let client = http_client();

debug(&format!("Sending request to URL: {}", endpoint));
debug(&format!("Query params: {:?}", query_params));

let response = match client.get(&endpoint).query(&query_params).send() {
Ok(res) => {
check_for_warnings(res.headers(), res.status());
res
}
Err(e) => return Err(format!("Failed to send request: {}", e).into()),
};
// Unlike the security routes, these endpoints answer a missing scan with a
// bare HTTP 404 rather than a `no_project_found` body, so the status has to
// be read before the parse or the miss surfaces as a parse failure.
let status = response.status();
if !status.is_success() {
let body = response.text().unwrap_or_default();
debug(&format!(
"Code quality request failed: HTTP {}. Response body: {}",
status, body
));
if status == StatusCode::NOT_FOUND {
return Err("Code quality issues not found 404".into());
}
return Err(format!("Request failed with status: {}", status).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<ScanResponse, Box<dyn std::error::Error>> {
let url = format!("{}{}/scan/{}", url, API_BASE, scan_id);

Expand Down Expand Up @@ -1491,6 +1574,85 @@ 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");
Comment on lines +1578 to +1609

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test passes without exercising the new behavior (endpoint contract / CLI wiring).

deserializes_code_quality_issue_response only round-trips a hand-written JSON fixture into ProjectIssuesResponse. It would still pass if:

  • scan path were /scan/{id}/issues/code-quality (or project path /issues/quality) — the PR uses asymmetric /scan/.../issues/quality vs /issues/code-quality with zero assertion
  • --issues / --sca-issues / --code-quality mutual exclusion regressed
  • --code-quality --scan-id wrongly called get_scan_issues

Impact: False confidence for a net-new production API surface.

Fix: Add a unit/integration test that builds the request URL (or uses a mock HTTP server) and asserts both endpoint variants + query params; add a clap/CLI test that --issues --code-quality exits 1 and that --code-quality selects get_quality_issues.

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 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();
Expand Down
10 changes: 9 additions & 1 deletion tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -559,11 +559,15 @@ pub struct Routes {
pub scans: Option<String>,
pub issues: Option<String>,
pub sca_issues: Option<String>,
/// `GET /issues/code-quality` — the project-scoped `--code-quality` route.
pub code_quality_issues: Option<String>,
/// `GET /scan/{id}` — `check_scan_status`.
pub scan: Option<String>,
/// `GET /scan/{id}/issues` — `report_scan_status` and the `--scan-id`
/// issue route.
pub scan_issues: Option<String>,
/// `GET /scan/{id}/issues/quality` — the `--code-quality --scan-id` route.
pub scan_quality_issues: Option<String>,
}

#[allow(dead_code)]
Expand All @@ -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()
Expand Down
Loading
Loading