Skip to content

fix(cli): resolve list/wait projects by repo URL, not directory name (COR-1577) - #122

Open
juangaitanv wants to merge 9 commits into
mainfrom
worktree-COR-1577
Open

fix(cli): resolve list/wait projects by repo URL, not directory name (COR-1577)#122
juangaitanv wants to merge 9 commits into
mainfrom
worktree-COR-1577

Conversation

@juangaitanv

Copy link
Copy Markdown
Contributor

What & why

corgea list, corgea list --issues, and standalone corgea wait resolved "which project" by the current-directory basename and asked the backend for an exact, case-sensitive match against Project.name. When the checkout directory name differs from the stored project name — the Bank of Hope case, dir dotnet-azure-web-tsb vs project bohappdev/dotnet-azure-web-tsb — three commands misbehaved:

  • corgea list --issues → exit 1, "Project with name '…' doesn't exist."
  • corgea list → exit 0, silent empty scan table (no error)
  • corgea wait (no --scan-id) → exit 1, "Error querying scan list"

This is the residual of COR-1493 (which fixed only the corgea scan → report-results path). Relates to COR-1577.

Approach (CLI-only)

The ticket suggested threading project_id/scan_id, but the listing endpoints accept neither as a query param. Instead this uses an identity the CLI already has and the backend already indexes — the repo URL:

  • Resolve the canonical project via GET /api/v1/projects?repo_url=<git remote slug>, then drive the existing name-based query_scan_list / get_scan_issues with the returned canonical name.
  • Old-backend safety guard: keep only candidates whose returned repo_url contains the slug. Pre-COR-1426 doghouse ignores the param and returns all projects; the guard rejects that, so resolution falls back cleanly.
  • CWD-name fallback when there is no git remote (no regression for non-git checkouts or older backends).
  • Drop the now-wrong client-side scan.project == project_name filter.
  • Add mutually-exclusive --project-name / --repo override flags to list and wait.
  • Replace the silent empty table and the bare "Error querying scan list" with clear miss messages that name the identifier tried.

No doghouse changes, no new persisted CLI state, no new backend query param.

Files

  • src/utils/generic.rsextract_repo_slug (org/repo slug; keeps Azure _git).
  • src/utils/api.rs/projects resolver (resolve_project_by_repo) with the old-backend guard, and the resolve_project orchestrator.
  • src/main.rs--project-name / --repo flags on List/Wait, threaded through.
  • src/list.rs — resolve once (non-SCA arm), drop the client filter, clear miss messaging, keep --json stdout clean.
  • src/wait.rs — resolve once, build the scan URL from the resolved numeric id, actionable miss message.
  • src/scan.rs — semgrep/snyk paths now thread their existing project_name as the override (the wait::run project_id arg was not dead after all). BLAST flow untouched.
  • tests/ — 14 new integration tests (list_resolution.rs, wait_resolution.rs) + shared helpers, plus 5 new unit tests.

Testing

  • cargo build, cargo clippy --all-targets -- -D warnings, cargo fmt --check: clean.
  • cargo test: 456 passed, 0 failed.
  • The three baseline failures are reproduced and proven fixed by the new integration tests (mock backend faithful to the doghouse contract).

Notes for reviewers / ship-gates

  • On-prem version floor: the repo-URL fix takes effect on doghouse ≥ COR-1426 (2026-06-11). Older instances keep working via the CWD-name fallback (no regression) until upgraded.
  • src/scan.rs change is display-only for the semgrep/snyk post-scan results URL; the primary BLAST scan flow uses a separate UploadZipResult and never calls the modified wait::run.
  • Manual checks needing live backends (out of scope for this PR's automated tests): list/list --issues/wait on a mismatched checkout against doghouse ≥ COR-1426; the fallback path against a pre-COR-1426 instance; --repo <unknown slug>; the semgrep/snyk results link.

…(COR-1577)

list/wait now resolve the canonical project via GET /api/v1/projects?repo_url= (with an old-backend safety guard and CWD-name fallback when there's no git remote); dropped the brittle client-side scan.project==name filter; added mutually-exclusive --project-name/--repo override flags; replaced the silent empty table and bare "Error querying scan list" with clear miss messages. CLI-only — no backend/persisted-state changes. Residual of COR-1493.
Comment thread src/utils/api.rs Outdated
Comment thread src/utils/api.rs Outdated
Comment thread src/utils/api.rs Outdated
Comment thread src/list.rs Outdated
Addresses four review findings on the COR-1577 repo-URL resolver:

1. Boundary-aware repo match (was a substring `contains`): the backend's
   `repo_url__icontains` returns siblings like `org/repo-v2` for slug
   `org/repo`, and the old guard could confirm the wrong project. Now matches
   on a path-segment boundary (equal or ends-with `/<slug>`, after stripping
   `.git`/trailing slash).
2. Subdirectory invocation: resolution used `Repository::open`, which only
   succeeds at the repo root, so `list`/`wait` from a subdir fell back to the
   subdir basename. New `discover_repo_url` walks up via `Repository::discover`.
3. Surface hard resolver failures: `resolve_project` now returns a Result and
   propagates network/auth/5xx from `/projects` instead of silently falling
   back to the local-directory project; a clean no-match (or 404 on an old
   backend) stays a soft fallback.
4. Skip redundant resolution: `list --issues --scan-id` hits
   `/scan/{id}/issues`, which ignores the project, so the extra `/projects`
   round-trip is now skipped.

Adds unit tests (boundary match, sibling rejection, 404-soft vs 5xx-hard) and
integration tests (no `/projects` call for `--issues --scan-id`, resolution
from a subdirectory).
Comment thread src/scan.rs
Comment thread src/wait.rs Outdated
Comment thread src/wait.rs
Comment thread src/utils/api.rs Outdated
Comment thread src/list.rs Outdated

@Ibrahimrahhal Ibrahimrahhal left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

can we reduce the amount of slop comments

Test added 2 commits July 28, 2026 15:01
Resolutions:
- wait.rs: keep COR-1577 resolve_project signature (main's determine_project_name(None) is the bug being fixed).
- generic.rs: keep both discover_repo_url (PR) and is_at_repo_root (main).
- list.rs: keep the PR's resolution flow; port main's COR-1639 SHA column (header + format_short_sha cell).
- Compare the whole post-host repo path, not a trailing org/repo slug:
  extract_repo_slug -> extract_repo_path, repo_url_matches_slug ->
  repo_url_matches_path. A nested mirror (…/mirrors/acme/api) or a
  differently-rooted path (…/org/team/repo) no longer matches; Azure `_git`
  and GitLab subgroup paths now compare in full.
- Restore ScanUploadResult.project_id and thread it into wait::run, which
  skips resolution entirely when it has both a scan id and an upload project
  id, and only lists scans when no scan id was given. The semgrep/snyk
  post-scan wait makes zero /projects and /scans calls and links the id-form
  URL again.
- Validate the project name before building the name-form scan URL, so a
  slash-only name can no longer produce /project//?scan_id=.
- The unresolved-project listing miss exits 1 in both human and --json mode
  (JSON still prints its envelope first); a confirmed project with no scans
  stays exit 0.
- Trim comment density to match the surrounding files.

New tests: whole-path matching boundaries, wait resolving from a
subdirectory, the post-upload wait issuing no /projects or /scans, trailing
slash trimming, and the miss/empty exit codes.
@juangaitanv

Copy link
Copy Markdown
Contributor Author

@Ibrahimrahhal cut the comment density down: the diff went from 209 comment lines on 1383 added (15%) to 158 on 1613 (9.8%), and most of what is left is in the tests. src/utils/api.rs and src/utils/generic.rs took the bulk of it — multi-line doc blocks collapsed to one or two lines, inline restatements of the code below them dropped.

I kept three that carry information not in the code: the doghouse normalize_repo_url citation on extract_repo_path, the old-backend /projects guard rationale, and the TCP-RST note on the api.rs test stub.

Also in this push: merged origin/main (conflicts resolved, COR-1639's SHA column ported into the re-indented block), and the five open review threads answered in code — whole-path repo matching, restored project_id threaded into wait, project-name validation before the scan URL, and exit 1 on an unresolved listing miss.

running_in_ci() is true under GitHub Actions, which sent upload_scan down the
GITHUB_REPOSITORY branch and panicked. The test covers the wait phase, so
remove CI/GITHUB_ACTIONS from its env and exercise one path everywhere.
Comment thread src/wait.rs Outdated
// A scan id plus the project id from the upload response leaves nothing to
// resolve: everything below keys off the scan, and the id-form URL is
// already known.
let resolved: Option<utils::api::ResolvedProject> = if scan_id.is_some()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

corgea wait <scan-id> still takes the resolver path because the CLI never supplies project_id_override; only post-upload waits satisfy this condition. A hard /projects failure therefore exits at lines 26–35 before check_scan_status, even though both status polling and get_scan_issues use /scan/{id} and need no project. The new wait_with_scan_id_does_not_list_scans test misses this because its stub explicitly serves /projects and only asserts that /scans was skipped. Impact: users with a known scan ID cannot wait during an otherwise unrelated /projects outage. Skip project resolution for any supplied scan ID and derive the canonical project name for the results link from the ScanResponse already fetched by check_scan_status (or otherwise make resolution best-effort on this path); add a test where /projects returns 500 while the scan endpoints succeed.

Comment thread src/utils/api.rs
"Failed to parse /projects response: {} | body: {}",
e, text
));
return Ok(None);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A successful HTTP response with an invalid or incompatible /projects body is still converted to a clean resolver miss here. resolve_project then falls back to the CWD name (lines 884–893), so a proxy returning HTML or a partially rolled-out schema can make list/wait query a different project instead of surfacing the API failure. This is the same unsafe fallback class as request failures; only an actual 404 or a valid envelope with no exact match should be soft. Propagate this parse error and cover 200 + malformed JSON.

Suggested change
return Ok(None);
return Err(format!("Failed to parse /projects response: {e}").into());

Comment thread src/utils/api.rs
}
};
let expected = repo_path.to_lowercase();
let matched = parsed.projects.unwrap_or_default().into_iter().find(|p| {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This searches only the first response page, but GET /projects?repo_url= is a case-insensitive partial-match endpoint with a default page size of 20 and a total_pages field. Because ProjectsResponse discards the pagination metadata and this request sends neither page nor page_size, 20 substring matches such as acme/api-* can fill page 1 while the exact acme/api candidate is on page 2. The exact-path guard then reports None, recreating the CWD-name miss this PR is intended to fix. Add page/total_pages to the response model and continue requesting pages until an exact repo_url_matches_path candidate is found or all pages are exhausted; add a two-page regression test.

Test added 4 commits July 28, 2026 16:02
Behavior-preserving:
- api.rs test stub reuses `read_http_request`/`http_response` instead of
  hand-rolling the drain loop and the response `format!`.
- drop the never-read `ProjectsResponse.status` (serde ignores unknown
  fields, so it bought no validation).
- extract `resolve_or_exit` for the error handler duplicated verbatim in
  `list` and `wait`.
- list: resolve per branch, dropping the `Option<ResolvedProject>` and its
  `expect`; collapse the duplicated unresolved-miss block to one copy (the
  JSON envelope still prints before the error).
- wait: construct a `ResolvedProject` on the skip path, dropping the four
  `.as_ref().map(...)` chains; that path's name now falls back to "unknown"
  like `resolve_project` instead of an empty string.
- generic.rs: share the origin-URL extraction via `origin_url`.
- resolve_project: drop the `repo_was_explicit` flag and the lazy `cwd`
  closure.
- generic.rs test: run its git commands through one helper that scrubs the
  inherited GIT_* env, as tests/cli_deps.rs already does, so the temp repo
  is built even when the suite runs from a git hook.
`wait::run` took five positional params, four of them Option<String>;
`repo_override` and `project_id_override` were adjacent and same-typed, so
transposing them at the scan.rs call sites compiled silently and changed
resolution. `list::run` took nine and carried
#[allow(clippy::too_many_arguments)].

- `ProjectSelector { name, repo }` replaces the two Option<&str> resolver
  params, so the pair travels as one value.
- `WaitArgs`/`ListArgs` name every argument at the call sites; the
  too_many_arguments allow is gone rather than institutionalized.
- list::run now owns its arguments, so two `is_some()` + `unwrap()` pairs
  clippy started flagging become `if let Some(id)`.
`spawn_stub` and `run_list`/`run_wait` were near-identical across
list_resolution.rs and wait_resolution.rs, and the Arc<Mutex<Vec<String>>>
hit-recording closure was copy-pasted at seven sites.

- `common::Routes` is one route table for every endpoint the two suites
  stub; an unset field 404s, so "this endpoint is never dialed" tests just
  leave it out.
- `spawn_recording_http_stub` wraps `spawn_http_stub` with the hit log,
  mirroring `vuln_api_stub::spawn_capturing_vuln_api_stub`.
- `run_corgea(subcommand, ...)` replaces the two copies of the runner.

Scaffolding only: no assertion changed.
The diff wrapped ~290 lines in a new `} else {`. The SCA branch ends at its
print_table call, so returning there puts the issues and scan-listing arms
back at their original indent level.

Reviewable with `git show --ignore-all-space`: the removed `else`, the
`return;`, one closing brace, and one line rustfmt rejoined now that it
fits. Nothing else.
@juangaitanv

Copy link
Copy Markdown
Contributor Author

Pushed a cleanup pass on top of the reviewed diff (fb7774a..da0848d) — no force-push, so the existing review threads are intact. No test assertion changed; this is quality only, not new behavior.

Four commits, each reviewable on its own:

1. d77a3e7 — core cleanups

  • The api.rs test stub now calls the existing vuln_api_stub::read_http_request / http_response instead of hand-rolling a header-drain loop and a response format!.
  • Deleted the never-read ProjectsResponse.status (serde ignores unknown fields, so it bought no validation).
  • Extracted resolve_or_exit — the same ~10-line resolver error handler was duplicated verbatim in list.rs and wait.rs.
  • list.rs: resolve per branch, dropping Option<ResolvedProject> and its .expect(...); the copy-pasted unresolved-miss block collapses to one copy (the JSON envelope still prints before the error — list_json_miss_is_valid_empty_envelope pins it).
  • wait.rs: construct a ResolvedProject directly on the skip path, dropping four .as_ref().map(...) chains. This also fixes a small inconsistency it surfaced: that path fell back to an empty string while resolve_project uses "unknown".
  • generic.rs: discover_repo_url and get_repo_info shared 4 identical lines → origin_url.
  • resolve_project: dropped the repo_was_explicit flag and the lazy cwd closure.

2. 175f2af — args structs
wait::run took 5 positional params, and repo_override / project_id_override were adjacent and same-typed — transposing them at the scan.rs call sites compiled silently and changed resolution. list::run took 9 and carried #[allow(clippy::too_many_arguments)]. Now ProjectSelector { name, repo } + WaitArgs / ListArgs, and the too_many_arguments allow is gone rather than institutionalized.

3. 0b0abdd — e2e harness dedup (tests only)
spawn_stub and the runner were near-duplicates across list_resolution.rs / wait_resolution.rs, and the Arc<Mutex<Vec<String>>> hit-recording closure was copy-pasted at 7 sites. Now one common::Routes table (an unset field 404s, so "this endpoint is never dialed" tests just leave it out), spawn_recording_http_stub, and run_corgea(subcommand, …).

4. da0848dlist.rs re-indent, alone
The original diff wrapped ~290 lines in a new } else {. The SCA branch now early-returns, putting the rest back at its original indent. Worth reading with git show --ignore-all-space da0848d — it's the removed else, a return;, one brace, and one line rustfmt rejoined.

Verified: cargo build, cargo clippy --all-targets (clean), cargo fmt --check, and the full suite under CI=1 GITHUB_ACTIONS=true (542 passing).


Deliberately not done here

  • Skipping the /projects round trip in corgea wait <scan-id> (raised twice in review). That call is what produces the id-form URL /project/17/?scan_id=…; without it we fall back to the CWD-basename name-form URL, which reintroduces the COR-1577 bug. Doing it properly means threading ScanResponse.project out of blast::check_scan_status — a behavior change, not a cleanup.
  • formatted_repo hand-parsing in list.rs (split('/').nth(3) assumes HTTPS and gets the wrong owner for scp-form remotes). Pre-existing code this PR only re-indented.

Filed as follow-up: COR-1718corgea scan and corgea list resolve projects by different keys. The write path (determine_project_name → last URL segment, CWD basename off the worktree root) and the read path (full org/repo, discovered upward) disagree in two verified cases: from a subdirectory, scan names the project after the subdir while list resolves the root project; and for org-qualified projects, scan sends ?project=repo while list resolves org/repo — the shape of this PR's own CANON fixture. Changing where scans land has backend implications well outside COR-1577's read-side scope, so it stays out of this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants