diff --git a/.changeset/coverage-status-in-repo-metrics.md b/.changeset/coverage-status-in-repo-metrics.md new file mode 100644 index 0000000..e95dc1c --- /dev/null +++ b/.changeset/coverage-status-in-repo-metrics.md @@ -0,0 +1,28 @@ +--- +"@codacy/codacy-cloud-cli": minor +--- + +Show the repository's coverage **status**, not just its percentage. + +Codacy now reports whether a repository's coverage is up to date, still waiting +on a report, has stopped receiving them, or was never set up — and the CLI can +tell those apart: + +- `codacy repos` marks a repository whose latest commit has no report yet with a + dim `⋯` after its last known value, and shows a dim `⊘` instead of a number + for one that has stopped receiving reports. A legend under the table explains + only the states actually present in the listing. +- `codacy repo`'s Metrics section spells the same states out, with the date and + commit of the last report, and notes when a stopped repository's coverage gate + is no longer being enforced. A repository that never had coverage now reads + `Not set up` rather than a bare `N/A`. +- `codacy repo`'s Analysis row reads coverage state from the API's own status + field instead of inferring it from a separate request. This fixes repositories + that were reported as healthy while showing a stale percentage, drops one + request per run, and makes the coverage state available under a repository + token for the first time. + +`--output json` gains `coverage.status`, `coverage.lastCommitWithCoverage`, +`coverage.statusUpdatedAt` and `coverage.valueUpdatedAt` on both commands. Under +a repository token, `codacy repo`'s `unavailable` array is now `["pullRequests"]` +only. diff --git a/.codacy/instructions/review.md b/.codacy/instructions/review.md index 18392c0..78e77fc 100644 --- a/.codacy/instructions/review.md +++ b/.codacy/instructions/review.md @@ -18,7 +18,11 @@ flag a finding when it points at a concrete defect. - Test files are deliberately long and repetitive: fixtures are written out in full rather than factored into builders, so each test reads standalone. **File-level length and duplication findings on `*.test.ts` are expected** and should not be - reported. + reported. This covers suggestions to *reorganize* as well as metrics — "split + this file into focused files", "extract shared fixtures into a module" and the + like. Tests are co-located one-per-module by design (`.test.ts` beside + its source), so a helper's tests belong in its module's file however long that + file grows; splitting by theme instead would break that mapping. - Each command test builds its own bare `new Command()` harness rather than importing `src/index.ts`. That duplication is intentional — it keeps a command's tests independent of global CLI wiring. diff --git a/README.md b/README.md index 8024e90..b618af1 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Codacy accepts repository tokens on a **limited set of repository-scoped operati | `repository`, `repository --reanalyze` | `repository --add`/`--remove`/`--follow`/`--unfollow`/`--link-standard`/`--unlink-standard` | | | `pull-request`, `pull-requests`, `ls`, `directories`, `findings`, `finding` | -`codacy repository` works, but omits the pull request and coverage-report sections — those endpoints don't accept repository tokens. In `--output json` it marks them as `"unavailable": ["pullRequests", "coverageReports"]`, so a consumer can tell "none" apart from "couldn't look". Note that skipping coverage reports also suppresses the "waiting for / missing coverage reports" hint on the Analysis row. +`codacy repository` works, but omits the pull request section — that endpoint doesn't accept repository tokens. In `--output json` it marks it as `"unavailable": ["pullRequests"]`, so a consumer can tell "none" apart from "couldn't look". Everything else, including the coverage status, is available. `codacy login` stores account tokens only; pass repository tokens per command or via `CODACY_PROJECT_TOKEN`. diff --git a/SPECS/README.md b/SPECS/README.md index c9c0ad0..fa154a2 100644 --- a/SPECS/README.md +++ b/SPECS/README.md @@ -13,8 +13,8 @@ _No pending tasks._ All commands implemented. | Command | Alias | Status | Spec | |---|---|---|---| | `info` | `inf` | ✅ Done | [info.md](commands/info.md) | -| `repositories` | `repos` | ✅ Done | [repositories.md](commands/repositories.md) | -| `repository` | `repo` | ✅ Done (actions added) | [repository.md](commands/repository.md) | +| `repositories` | `repos` | ✅ Done (coverage status added) | [repositories.md](commands/repositories.md) | +| `repository` | `repo` | ✅ Done (actions + coverage status added) | [repository.md](commands/repository.md) | | `ls` | N/A | ✅ Done | [ls.md](commands/ls.md) | | `directories` | `dirs` | ✅ Done | [directories.md](commands/directories.md) | | `pull-request` | `pr` | ✅ Done (--diff + Diff Coverage Summary added) | [pull-request.md](commands/pull-request.md) | @@ -88,3 +88,4 @@ _No pending tasks._ All commands implemented. | 2026-08-11 | (OD-489) Repository (project) token support. New `--repository-token ` on every command (plus `CODACY_PROJECT_TOKEN`), sent as the `project-token` header; account tokens keep `api-token`. `src/utils/auth.ts` rewritten around a `RemoteAuth` discriminated union carrying both kind and source, replacing `checkApiToken()` with `resolveAuth(this)` / `resolveAccountAuth(this, why)` / `requireAccountToken(...)` / `fetchIfAccountToken(...)`. Precedence matches `codacy-analysis` exactly — flag > `CODACY_PROJECT_TOKEN` > `CODACY_API_TOKEN` > stored login — so `vitest.config.mts` now blanks `CODACY_PROJECT_TOKEN` (it outranks the account token and is exported job-wide by the coverage reporter, so tests would otherwise depend on the developer's shell). Codacy whitelists only 13 operations for repository tokens, so `tool`/`patterns`/`pattern` work unchanged, `issues` (incl. `--overview`) and `tools --import` work, and the 9 account-only commands plus `repository`'s 6 management flags, `issues --ignore`/`--ignored`, and `tools --import --force` (only when standards exist) **fail fast before any request** with a message naming the operation, the reason, and where the token came from. `repository`'s dashboard skips the two non-whitelisted calls: the table keeps the "Open Pull Requests" header with an explanatory line, and JSON keeps `pullRequests: []` (so `jq '.pullRequests[]'` still works) plus an additive `unavailable: ["pullRequests"]` — under an account token the payload is byte-identical. Also added the long-missing `.catch()` on the PR call so an account token lacking PR access degrades instead of losing the whole dashboard, and fixed `login`'s 401 message, which told repository-token users their token was "invalid" when it is rejected by `/user` by design. New `SPECS/repository-tokens.md` (whitelist + matrix, re-verify on every `npm run update-api`) and `SPECS/missing-endpoints.md` (ranked gaps for follow-up Linear tasks) (40 new tests, 606 total) | | 2026-09-07 | HTTP/HTTPS proxy + TLS support (issue #40). Node's global `fetch` — used by the generated client and the MITRE CVE lookup in `commands/finding.ts` — ignores `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY`, so the CLI was unusable behind a corporate proxy. Rather than reimplement it, this delegates to `configureProxy()` from `@codacy/tooling` (pinned `0.1.0` → `0.23.0`, the same function `analysis-cli` calls), which installs a global `undici` dispatcher doing per-request protocol + `NO_PROXY` routing, bare `host:port` normalization, and `SSL_CERT_FILE`/`NODE_EXTRA_CA_CERTS` CA loading. New `src/utils/proxy.ts` is a ~4-line seam — `configureProxyFromEnv()` calls it and routes its deliberate fail-loud throw (unreadable/non-PEM CA bundle) into `handleError()`, giving red `Error: ` and exit 1 like every other failure here; `analysis-cli` exits 2 because it has a documented exit-code scheme, which this CLI does not. Called at the top of `src/index.ts`, above `OpenAPI.BASE` (ordering is only constrained to precede `program.parse`, since the dispatcher is resolved per request). Kept top-level rather than in the `preAction` hook so a typo'd `SSL_CERT_FILE` fails even on `--version`. Deliberately zero-argument: env is the sole input, which is what keeps parity exact. Superseded external PR #39, which hand-rolled the same feature with `undici@8.10.1` — that requires Node ≥ 22.19.0 against this package's `engines: ">=20"`, so `require("undici")` threw at module load and the CLI would not start at all on any Node 20.x; tooling's `undici@^6.21.0` supports Node ≥ 18.17. That regression passed CI, so `ci.yml` gained a smoke step running the built entry point (plain, with `HTTPS_PROXY`, and with a bad `SSL_CERT_FILE` expected to fail) — previously nothing executed `src/index.ts`, since every command test builds a bare `new Command()`. Upstream owns the proxy semantics and their 24 tests, so only the seam is tested here. Pinned exactly rather than with a caret: for a pre-1.0 package `^0.22.0` spans patches only (`>=0.22.0 <0.23.0-0`), so a caret would have bought silent patch drift against a dependency this repo has no proxy coverage for, without ever picking up a minor. Two findings from this work were fixed upstream and taken here via 0.23.0 — `undici` now loads lazily behind `configureProxy`'s early-out (an unproxied `--version` went from +27 ms to +0 ms against a `main` build), and a malformed proxy URL now fails with `Invalid HTTPS_PROXY value "...": `, naming the setting and redacting any credentials instead of surfacing a bare `Invalid URL` (4 new tests, 614 total) | | 2026-09-09 | New `-k, --matches-stack [value]` filter on `patterns`, surfacing the API's `matchesStack` query param (filter a tool's code patterns by whether they match the repository's detected stack). Tri-state, matching the existing `issues --false-positives`: the bare flag or `true` sends `matchesStack=true`, `false` sends `matchesStack=false`, omitting it sends nothing — read explicitly rather than by truthiness so an explicit `false` stays distinct from "not requested". Applies in **both** list mode (`listRepositoryToolPatterns`) and bulk mode (`updateRepositoryToolPatterns`), like every other filter; the post-update `toolPatternsOverview` call deliberately stays unfiltered, since its counts describe the whole tool rather than the updated subset. The shared tri-state coercion `parseBooleanOption` moved out of `issues.ts` into a new `utils/options.ts` (+ tests) and is now imported by both commands. **Required an API bump: pinned `57.3.9` → `57.4.17`** (`matchesStack` first ships in `57.4.14`; `57.4.17` is the latest published build). The spec delta is purely additive — 2 unused new operations, 4 new schemas, `stackTagsFilterParam` on `listOrganizationRepositories` (unused; the CLI calls `...WithAnalysis`) — but `matchesStack` is inserted *mid-signature* on `listRepositoryToolPatterns` (arg 12, before `sort`), so every full-positional-arg assertion in `patterns.test.ts` gained a trailing `undefined`; `pattern.ts`/`issues.ts` stop at `search` (arg 9) and were unaffected. `SPECS/repository-tokens.md` re-verified: `57.4.x` now declares the `ProjectTokenAuth` scheme in the spec (it was absent in `57.3.9`), making the whitelist machine-checkable, and it is **14** operations, not 13 — the addition is `searchAiInventoryCategories`, unused here. `patterns` stays fully whitelisted, so no new token guard (11 new tests, 625 total) | +| 2026-09-10 | Coverage **status** surfaced in `repositories` and `repository`, from the API's new `Coverage.status` ([`CoverageStatus`](https://api.codacy.com/api/api-docs#tocs_coveragestatus): `None`/`UpToDate`/`Waiting`/`Stopped`), mirroring codacy-spa#3110. **No API bump** — pinned `57.4.17` already ships the field. It rides on `Coverage`, embedded only in `RepositoryWithAnalysis`, so these two commands are the only places it can appear; `ls`/`directories` (flat `coverageWithDecimals`) and `pull-request`/`pull-requests` (`PullRequestCoverage`/`DiffCoverage`) carry no status. The payload shapes differ in more than `status`, which is what drove the rendering: `Waiting` returns a **stale** percentage (from `lastCommitWithCoverage`, `valueUpdatedAt` older than `statusUpdatedAt`), `Stopped` returns **no percentage at all**, `None` returns nothing but the status, and `status` is `undefined` on a large share of repositories. **`repositories`:** a dim `⋯` after a `Waiting` value, a dim `⊘` *instead of* a `Stopped` value, and a legend under the table carrying only the statuses actually present (`coverageStatusLegend`). Glyphs follow the existing vocabulary — `⋯` is already `formatStandards`'s "not final yet" marker, `⊘` shares the Mathematical Operators block with the `⊙` public-repo marker — no emojis. **`repository`:** the Metrics row spells the state out (`Not reported yet for the latest commit — value from 11h ago (5474cbf)` / `Stopped receiving reports 2026-08-26 — last report 8752dbd`, plus `— coverage gate no longer enforced` when `goals.minCoveragePercentage` is set / dim `Not set up` for `None`, which a bare `N/A` could never distinguish from an uncomputed metric), colored on `formatAnalysisStatus`'s existing scale (blueBright = in-flight, yellow = attention, dim = nothing there). **Analysis row rewritten:** `formatAnalysisStatus` gained an authoritative `coverageStatus` that wins over its `expectsCoverage`/`hasCoverageData` heuristic, extracted into `coverageAnalysisSuffix`. The heuristic was *wrong* for `Waiting` — a waiting repo still reports a (stale) percentage, so `hasCoverageData` was true and the row said nothing in exactly the case worth surfacing — and vague for `Stopped` ("Missing coverage reports"). That let `repository` **drop its `listCoverageReports` call entirely** (5 parallel requests → 4), which in turn means repository-token users get the coverage state for the first time (`getRepositoryWithAnalysis` is whitelisted, `listCoverageReports` is not) and `unavailable` is now `["pullRequests"]` alone. `pull-request` keeps the heuristic — its coverage models have no status field — and is untouched. Accepted trade-off: with `status` undefined, `repository`'s Analysis row now shows no coverage hint where the heuristic might have said "Missing coverage reports". Also de-duplicated `repositories.ts`'s local `formatMetric` (a stale copy of the shared `colorMetric` returning a bare `"N/A"`), which would otherwise have let coverage and complexity/duplication drift inside the same table. Five new helpers in `utils/formatting.ts` (`coverageStatusGlyph`/`formatRepoCoverageCell`/`coverageStatusLegend`/`coverageStatusNote`/`formatRepoCoverageDetail`); `formatCoverageCell` deliberately untouched — it renders file/folder coverage, which has no status. JSON gains `coverage.status`/`lastCommitWithCoverage`/`statusUpdatedAt`/`valueUpdatedAt` on both commands (`valueUpdatedAt` is what tells a consumer the `Waiting` value is stale — the job the glyph does in the table); `pickDeep` drops undefined, so a `None` repo emits `{"status":"None"}` and a status-less one gains no keys (39 new tests, 664 total) | diff --git a/SPECS/commands/analysis.md b/SPECS/commands/analysis.md index 43040c8..6c5ae51 100644 --- a/SPECS/commands/analysis.md +++ b/SPECS/commands/analysis.md @@ -80,33 +80,74 @@ Analysis Finished 12h ago (c00e638) — Reanalysis in progress... Analysis In progress... (c00e638) ``` -- Analysis finished, waiting for coverage (within 3h): +- Analysis finished, no coverage report for the latest commit yet + (`coverage.status === "Waiting"`): ``` Analysis Finished 12h ago (c00e638) — Waiting for coverage reports... ``` -- Analysis finished, coverage overdue (>3h): +- Analysis finished, coverage reports stopped arriving + (`coverage.status === "Stopped"`): ``` -Analysis Finished 12h ago (c00e638) — Missing coverage reports +Analysis Finished 12h ago (c00e638) — Stopped receiving coverage reports ``` -- Normal finished state: +- Normal finished state (`UpToDate`, `None`, or no status at all): ``` Analysis Finished 12h ago (c00e638) ``` -"In progress..." and "Reanalysis in progress..." are colored light blue. "Missing coverage reports" is yellow. +"In progress...", "Reanalysis in progress..." and "Waiting for coverage +reports..." are colored light blue. "Stopped receiving coverage reports" (and +the pull-request-only "Missing coverage reports") are yellow. + +The row deliberately stays short: the Metrics section's Coverage row carries the +same state with its dates and commit, so the dashboard states it at two +altitudes rather than saying the same sentence twice. See +[repository.md](repository.md). ### `pull-request` command — About section -Same "Analysis" row replaces the former "Head Commit" row, with the same status logic applied to the PR's HEAD commit. +Same "Analysis" row replaces the former "Head Commit" row, with the same status +logic applied to the PR's HEAD commit — except for the coverage state, which +still comes from the heuristic below. `PullRequestCoverage`/`DiffCoverage` carry +no `status` field, so there is nothing authoritative to read. This is the only +remaining caller of the heuristic, and the only reason it still exists. + +- Coverage expected, none yet (within 3h): `— Waiting for coverage reports...` +- Coverage expected, overdue (>3h): `— Missing coverage reports` ## Analysis Status Logic - **Being analyzed**: `startedAnalysis` is set AND (`endedAnalysis` is absent OR `startedAnalysis > endedAnalysis`) -- **Coverage expected**: determined by `listCoverageReports(limit=1).data.hasCoverageOverview` -- **Coverage data present**: `diffCoverage.value !== undefined OR deltaCoverage !== undefined` (PR); `coveragePercentage !== undefined` (repo) -- **Wait threshold**: 3 hours from `endedAnalysis` + +The coverage half is decided by `coverageAnalysisSuffix()`, which has two +sources in priority order: + +1. **`coverageStatus`** — the API's own `Coverage.status`, available on the + repository endpoints (`getRepositoryWithAnalysis`). Authoritative, so it wins + outright: `Waiting` and `Stopped` each get their line, `UpToDate`/`None` get + nothing. Used by `repository`. +2. **The heuristic** — "a coverage overview exists but this commit has no + coverage number", with a 3-hour grace period from `endedAnalysis`: + - **Coverage expected**: `listCoverageReports(limit=1).data.hasCoverageOverview` + - **Coverage data present**: `diffCoverage.value !== undefined OR deltaCoverage !== undefined` + - **Wait threshold**: 3 hours from `endedAnalysis` + + Only `pull-request` still needs it (see above). + +**Why (1) exists.** The heuristic is *wrong* for `Waiting`: a waiting repository +still reports a percentage — a stale one, from `lastCommitWithCoverage` — so +"coverage data present" is true and the heuristic reads the repository as +healthy, leaving the row silent in exactly the case worth surfacing. It was also +vaguer than necessary for `Stopped` ("Missing coverage reports"), and could never +work under a repository token at all, since `listCoverageReports` is not +whitelisted while `getRepositoryWithAnalysis` is. + +**Accepted trade-off.** When `status` is `undefined` — which a substantial share +of repositories return — `repository` now shows no coverage hint, where the +heuristic might have said "Missing coverage reports". That is the honest reading +of an absent status. Implemented in `formatAnalysisStatus()` in `src/utils/formatting.ts`. @@ -115,7 +156,7 @@ Implemented in `formatAnalysisStatus()` in `src/utils/formatting.ts`. - [`reanalyzeCommitById`](https://api.codacy.com/api/api-docs#reanalyzecommitbyid) — `RepositoryService.reanalyzeCommitById(provider, org, repo, { commitUuid: sha })` - [`getPullRequestCommits`](https://api.codacy.com/api/api-docs#getpullrequestcommits) with `limit=1` — head commit timing for PR - [`listRepositoryCommits`](https://api.codacy.com/api/api-docs#listrepositorycommits) with `limit=1` — head commit timing for repo -- [`listCoverageReports`](https://api.codacy.com/api/api-docs#listcoveragereports) with `limit=1` — check `hasCoverageOverview` +- [`listCoverageReports`](https://api.codacy.com/api/api-docs#listcoveragereports) with `limit=1` — check `hasCoverageOverview`. **`pull-request` only** — `repository` dropped this call when `coverage.status` superseded it Additionally used by `--reanalyze-and-wait`: - `listRepositoryCommits` (`limit=1`) — repo first-commit analysis timestamps, polled for status @@ -133,10 +174,11 @@ Additionally used by `--reanalyze-and-wait`: - [x] Update existing tests for the status sections - [x] Add tests for the new `--reanalyze` option - [x] Add `--reanalyze-and-wait` (`-w`) blocking variant to both commands (2026-06-02) +- [x] Drive `repository`'s coverage state from `Coverage.status` instead of the `listCoverageReports` heuristic (2026-09-10) ## Tests -- `src/utils/formatting.test.ts` — 6 unit tests for `formatAnalysisStatus`; + `formatDuration` and `isBeingAnalyzed` tests +- `src/utils/formatting.test.ts` — 11 unit tests for `formatAnalysisStatus` (6 for the heuristic, 5 for the authoritative `coverageStatus`, including the Waiting-with-a-stale-percentage case the heuristic got wrong); + `formatDuration` and `isBeingAnalyzed` tests - `src/commands/repository.test.ts` — 4 tests (analysis status, reanalyze) + 3 for `--reanalyze-and-wait` - `src/commands/pull-request.test.ts` — 3 tests (analysis status, reanalyze) + 3 for `--reanalyze-and-wait` - `src/utils/reanalyze-wait.test.ts` — 12 unit tests (snapshots, diff, poll loop incl. timeout, render, json) diff --git a/SPECS/commands/pull-request.md b/SPECS/commands/pull-request.md index 48a9dc0..111a8a5 100644 --- a/SPECS/commands/pull-request.md +++ b/SPECS/commands/pull-request.md @@ -37,7 +37,7 @@ codacy pr gh my-org my-repo 42 --reanalyze - [`listPullRequestFiles`](https://api.codacy.com/api/api-docs#listpullrequestfiles) — files with metric deltas - [`getRepositoryPullRequestFilesCoverage`](https://api.codacy.com/api/api-docs#getrepositorypullrequestfilescoverage) — files coverage - [`getPullRequestCommits`](https://api.codacy.com/api/api-docs#getpullrequestcommits) with `limit=1` — head commit timing for analysis status -- [`listCoverageReports`](https://api.codacy.com/api/api-docs#listcoveragereports) with `limit=1` — `hasCoverageOverview` flag +- [`listCoverageReports`](https://api.codacy.com/api/api-docs#listcoveragereports) with `limit=1` — `hasCoverageOverview` flag. Retained here (and only here) because `PullRequestCoverage`/`DiffCoverage` carry no `CoverageStatus`; `repository` dropped this call once `coverage.status` superseded the heuristic — see [analysis.md](analysis.md) ## `--issue` mode diff --git a/SPECS/commands/repositories.md b/SPECS/commands/repositories.md index 30733c9..3fd37f6 100644 --- a/SPECS/commands/repositories.md +++ b/SPECS/commands/repositories.md @@ -35,11 +35,23 @@ Columnar table. Each row is one repository. | Issues | `repo.issuesCount` | | | Complex Files | `repo.complexFilesPercentage` | Colored by goals threshold (max mode) | | Duplication | `repo.duplicationPercentage` | Colored by goals threshold (max mode) | -| Coverage | `repo.coveragePercentage` | Colored by goals threshold (min mode) | +| Coverage | `repo.coverage.coveragePercentage` + `repo.coverage.status` | Colored by goals threshold (min mode). `Waiting` appends a dim `⋯`; `Stopped` shows a dim `⊘` instead of a value (the API sends none). `UpToDate`, `None` and an absent status render as before | | Last Updated | `repo.lastUpdated` | Friendly date via `formatFriendlyDate()` | +### Coverage status legend + +Printed after the table and before the pagination warning (the legend explains +the table, the warning explains the query), via `coverageStatusLegend()`. Only +the statuses actually present in the listing get a line, so an organization with +healthy coverage everywhere sees nothing: + +``` +⋯ no coverage report for the latest commit yet — showing the last known value +⊘ stopped receiving coverage reports +``` + Shows pagination warning if more pages exist. ## Tests -File: `src/commands/repositories.test.ts` — 5 tests. +File: `src/commands/repositories.test.ts` — 10 tests. diff --git a/SPECS/commands/repository.md b/SPECS/commands/repository.md index 3fff968..81c4caa 100644 --- a/SPECS/commands/repository.md +++ b/SPECS/commands/repository.md @@ -20,7 +20,12 @@ codacy repo gh my-org my-repo --reanalyze - [`listRepositoryPullRequests`](https://api.codacy.com/api/api-docs#listrepositorypullrequests) — `AnalysisService.listRepositoryPullRequests(provider, org, repo)` - [`issuesOverview`](https://api.codacy.com/api/api-docs#issuesoverview) — `AnalysisService.issuesOverview(provider, org, repo)` - [`listRepositoryCommits`](https://api.codacy.com/api/api-docs#listrepositorycommits) with `limit=1` — head commit timing for analysis status -- [`listCoverageReports`](https://api.codacy.com/api/api-docs#listcoveragereports) with `limit=1` — `hasCoverageOverview` flag + +`listCoverageReports` used to be a fifth call, supplying the `hasCoverageOverview` +flag behind the Analysis row's coverage hint. `getRepositoryWithAnalysis` now +returns `coverage.status` ([`CoverageStatus`](https://api.codacy.com/api/api-docs#tocs_coveragestatus)), +which answers the same question authoritatively, so the call was dropped — see +[analysis.md](analysis.md). ## Output Sections @@ -50,10 +55,21 @@ Colored by goals thresholds from `RepositoryQualitySettings`: | Field | Notes | |---|---| | Issues | Count + Issues/kLoC ratio | -| Coverage | % (min threshold) | +| Coverage | % (min threshold), plus the coverage status spelled out — see below | | Complex Files | % (max threshold) | | Duplication | % (max threshold) | +The Coverage row reads `coverage.status` via `formatRepoCoverageDetail()`. Where +the `repositories` table has room only for a glyph, this row has room for a +sentence: + +| `status` | Coverage row | +|---|---| +| `Waiting` | `19.0% Not reported yet for the latest commit — value from 11h ago (5474cbf)` (blueBright) | +| `Stopped` | `Stopped receiving reports 2026-08-26 — last report 8752dbd` (yellow), plus ` — coverage gate no longer enforced` when `goals.minCoveragePercentage` is set. No percentage: the API sends none | +| `None` | dim `Not set up` — distinguishable from a metric Codacy didn't compute, which is all a bare `N/A` could say | +| `UpToDate`, absent status, absent `coverage` | unchanged (`colorMetric`) | + ### Open Pull Requests (columnar table) Columns: `#`, `Title` (truncated 50), `Branch` (truncated 40), `✓`, `Issues`, `Coverage`, `Complexity`, `Duplication`, `Updated`. @@ -95,4 +111,4 @@ In all cases, show a success message on completion or an error message with deta ## Tests -File: `src/commands/repository.test.ts` — 16 tests. +File: `src/commands/repository.test.ts` — 45 tests. diff --git a/SPECS/repository-tokens.md b/SPECS/repository-tokens.md index 4d5d676..e0092ca 100644 --- a/SPECS/repository-tokens.md +++ b/SPECS/repository-tokens.md @@ -125,19 +125,22 @@ partial-but-sufficient `repository` dashboard. make it invisible to the test harnesses, which each build a bare `new Command()`. `repositoryTokenFlag()` reads the command's own value before the inherited one, so the nearest wins. -- **`repository` dashboard degradation.** `listRepositoryPullRequests` and - `listCoverageReports` are skipped rather than attempted. The table keeps the - `Open Pull Requests` header with an explanatory line — a vanishing section - reads as a bug, and `printPullRequests([])` would claim "No open pull - requests", a different and false statement. In JSON, `pullRequests` stays `[]` - (so `jq '.pullRequests[]'` and `| length` keep working) and an additive - `unavailable` array distinguishes "none" from "couldn't look". - Under an account token the payload is byte-identical to before this change. - `unavailable` lists `coverageReports` too, even though no coverage key is - projected: skipping that call forces `expectsCoverage` false, which silently - suppresses the "waiting for / missing coverage reports" state on the Analysis - row — without the marker, a repo that *is* configured for coverage but has - uploaded none would look identical to a healthy one. +- **`repository` dashboard degradation.** `listRepositoryPullRequests` is + skipped rather than attempted. The table keeps the `Open Pull Requests` header + with an explanatory line — a vanishing section reads as a bug, and + `printPullRequests([])` would claim "No open pull requests", a different and + false statement. In JSON, `pullRequests` stays `[]` (so `jq '.pullRequests[]'` + and `| length` keep working) and an additive `unavailable` array distinguishes + "none" from "couldn't look". Under an account token the payload is + byte-identical. + + `unavailable` used to list `coverageReports` too: `listCoverageReports` is not + whitelisted, and skipping it silently suppressed the coverage state on the + Analysis row. That call is gone — `getRepositoryWithAnalysis`, which *is* + whitelisted, now returns `coverage.status`, so a repository token gets the + full coverage state (in the Analysis row, the Metrics section and JSON) and + `unavailable` is `["pullRequests"]` alone. This is one of the few places where + a repository token gained capability rather than losing it. - **`login` is account-only.** It validates against `/user`, which a repository token can never reach, and the credentials store holds a single bare token with no record of its kind. Its 401 message names the repository-token case diff --git a/src/commands/AGENTS.md b/src/commands/AGENTS.md index 3152905..8ba8bb1 100644 --- a/src/commands/AGENTS.md +++ b/src/commands/AGENTS.md @@ -97,11 +97,67 @@ When displaying "Last Updated" or similar dates, use `formatFriendlyDate()` from Instead of a dedicated "Visibility" column (wastes horizontal space), public repositories are marked with a dimmed `⊙` (U+2299, circled dot operator) appended to the name. Private repositories show the name alone. This character is in the Mathematical Operators Unicode block and renders reliably across terminals. +## Coverage Status Markers + +The API returns a `CoverageStatus` (`None` | `UpToDate` | `Waiting` | `Stopped`) +on `Coverage`, which is embedded only in `RepositoryWithAnalysis` — so only +`repositories` and `repository` can show it. `ls`/`directories` use a flat +`coverageWithDecimals` and `pull-request`/`pull-requests` use +`PullRequestCoverage`/`DiffCoverage`; neither carries a status, and there is +nothing to add there. + +- **The table decorates `Waiting` and `Stopped` only** (the same two the SPA + flags). `UpToDate`, an undefined `status` and an absent `coverage` object must + render exactly as they did before the field existed — the API leaves `status` + undefined on a large share of repositories, so that is the common path, not an + edge case. `formatRepoCoverageCell`/`formatRepoCoverageDetail` have unit tests + asserting byte-identical output against `colorMetric` for precisely this + reason; keep them passing. +- **`None` is the asymmetric one.** The table leaves it alone (dim `N/A`, no + room to say more), but the detail view renders a deliberate dim `Not set up` — + "never received a report" is worth distinguishing from "metric not computed", + which is all a bare `N/A` can say. Don't "simplify" that back to `colorMetric`. +- **`COVERAGE_STATUS_GLYPH` is the exhaustiveness anchor.** It is a + `Record`, so a new status member arriving from + `npm run update-api` fails to compile there rather than silently rendering as + nothing in all four renderers. `coverageStatusNote` and + `coverageAnalysisSuffix` keep their own switches (their prose differs too much + per state to share a table) — when the union widens, start at the Record and + work outwards. +- **Glyph in a table, words in a detail view.** `repositories` appends a dim + `⋯` (U+22EF) for `Waiting` and shows a dim `⊘` (U+2298) for `Stopped`; + `repository`'s Metrics row spells the state out with its dates and commit. The + same split the SPA makes between its row icon and its pill/banner text. + No emojis, per the note in `tree-view.ts`. `⋯` is already the CLI's + "not final yet" marker in `formatStandards`, and `⊘` is in the same + Mathematical Operators block as the `⊙` public-repository marker. +- **Read the payload shapes before changing this.** They differ in more than + `status`: `Waiting` carries a *stale* percentage (from + `lastCommitWithCoverage`, with `valueUpdatedAt` older than `statusUpdatedAt`), + `Stopped` carries **no percentage at all**, and `None` carries nothing but the + status. So the `Stopped` marker *replaces* the value rather than suffixing it, + and a `Waiting` value is real but needs qualifying. +- **A glyph legend is conditional.** `coverageStatusLegend()` returns a line + only for the statuses actually present in the listing, printed after the table + and before the pagination warning — the legend explains the table, the warning + explains the query. +- **`Waiting` keeps its threshold coloring.** The glyph/note marks the value + stale; the red/green still answers "is this repository above its coverage + goal", same as every other row. The SPA does the same. + ## repositories command (`repositories.ts`) - Takes `` and `` as required arguments - Optional `--search ` passes through to the API's `search` parameter - Public repos show `⊙` after the name instead of a separate Visibility column +- Metric cells use the **shared** `colorMetric` from `utils/formatting.ts`. This + file used to carry a local `formatMetric` copy of it, which returned a bare + `"N/A"` instead of a dim one; it was deleted when the coverage cell started + going through `colorMetric`, since a local copy would let coverage and + complexity/duplication drift inside the same table +- Coverage goes through `formatRepoCoverageCell(repo.coverage, minGoal)` and a + conditional `coverageStatusLegend()` under the table — see "Coverage Status + Markers" above - Quality metrics (complexity, duplication, coverage) are colored red/green based on `goals` thresholds from `RepositoryQualitySettings`: - **Max thresholds** (issues, complexity, duplication): green if under, red if over - **Min thresholds** (coverage): green if above, red if below @@ -112,11 +168,11 @@ Instead of a dedicated "Visibility" column (wastes horizontal space), public rep ## repository command (`repository.ts`) - Takes ``, ``, and `` as required arguments -- Calls three API endpoints in parallel: `getRepositoryWithAnalysis`, `listRepositoryPullRequests`, `issuesOverview` +- Calls four API endpoints in parallel: `getRepositoryWithAnalysis`, `listRepositoryPullRequests`, `issuesOverview`, `listRepositoryCommits` (`limit=1`). It used to make a fifth call, `listCoverageReports`, purely to feed `formatAnalysisStatus`'s coverage heuristic; `getRepositoryWithAnalysis`'s `coverage.status` answers the same question authoritatively, so the call was dropped. Two consequences worth knowing: the Analysis row is now *correct* for `Waiting` (the heuristic read a waiting repository's stale percentage as healthy and said nothing), and because `getRepositoryWithAnalysis` is whitelisted for repository tokens while `listCoverageReports` is not, repository-token users get the coverage state and `unavailable` no longer lists `coverageReports` - Displays a multi-section dashboard: - **About**: provider/org/name, visibility, default branch, last updated (friendly date), last analysis (time + short SHA) - **Setup**: languages, coding standards, quality gate, problems (yellow if present, green "None" otherwise) - - **Metrics**: issues (count + per kLoC), coverage, complexity, duplication — colored by goals thresholds + - **Metrics**: issues (count + per kLoC), coverage, complexity, duplication — colored by goals thresholds. Coverage goes through `formatRepoCoverageDetail(data.coverage, minGoal)`, which appends the coverage status in words — see "Coverage Status Markers" above - **Open Pull Requests**: filtered to open status, columns: - `#`, `Title` (truncated at 50), `Branch` (truncated at 40) - `✓` (header is a gray ✓) — green ✓ if `isUpToStandards` is true, red ✗ if false, dim `-` if undefined @@ -211,6 +267,18 @@ Several helpers are shared between `repository.ts` and `pull-request.ts` via `ut - `prQualityMetric(pr, key)` — reads `newIssues`/`fixedIssues`/`deltaComplexity`/`deltaClonesCount`, **preferring `pr.quality[key]` over the flat top-level `pr[key]`**. The API populates the two inconsistently: the pull-request endpoints return `quality.deltaComplexity` but omit the top-level `deltaComplexity` (while still sending a top-level `deltaClonesCount`), so reading the flat field alone made every PR's complexity render as "no data". `quality` is the newer structured shape — same direction as `coverage` vs. the deprecated top-level coverage fields — so it wins, flat field as fallback. Use this instead of `pr.deltaComplexity` / `pr.deltaClonesCount` in any new PR rendering - `hasAnyPrCoverage(prs)` — true when at least one PR in the list carries a coverage number. Callers listing many PRs use it to drop the Coverage column when the repo has no coverage set up (the API returns `diffCoverage.cause` and no values). Lives next to `formatPrCoverage` so both agree on what counts as "has data" +Repository coverage-status helpers shared between `repositories.ts` (table) and +`repository.ts` (detail) — see "Coverage Status Markers" above for the rules: +- `coverageStatusGlyph(coverage)` — dim `⋯`/`⊘`, or `undefined` when there is nothing to flag +- `formatRepoCoverageCell(coverage, threshold)` — the `repositories` Coverage cell: percentage + glyph, or the glyph alone for `Stopped` +- `coverageStatusLegend(coverages)` — legend lines for only the statuses present in a listing; `[]` when there is nothing to explain +- `coverageStatusNote(coverage, { gateConfigured })` — the state in words; `gateConfigured` adds "coverage gate no longer enforced" to `Stopped` +- `formatRepoCoverageDetail(coverage, threshold)` — the `repository` Metrics Coverage row: percentage + note, or the note alone for `Stopped`/`None`. Passes `gateConfigured: threshold !== undefined` + +**`formatCoverageCell` is a different helper** — it renders file/folder +`coverageWithDecimals` for `ls`/`directories`, which has no status. Don't +repurpose it for repository coverage, and don't confuse the two names. + **Empty-metric convention:** `formatDelta`, `formatPrCoverage`, and `formatPrIssues` render missing values as a dim `-`, matching `formatStandards`, `formatCountCell`, and `formatCoverageCell`. `N/A` is still used elsewhere in the CLI for non-metric fields (grades, dates, author names, default branch) — new metric rendering should use `-`. Dependency-chain helpers shared between `findings.ts` (list) and `finding.ts` (detail): diff --git a/src/commands/pull-request.test.ts b/src/commands/pull-request.test.ts index 7e97c0a..50702b1 100644 --- a/src/commands/pull-request.test.ts +++ b/src/commands/pull-request.test.ts @@ -1808,6 +1808,58 @@ describe("pull-request command", () => { expect(allOutput).not.toContain("Head Commit"); }); + // `repository` now reads the API's authoritative `coverage.status` instead of + // this heuristic, and is `formatAnalysisStatus`'s only other caller — so + // `pull-request` is the sole remaining reason the heuristic exists. Nothing + // else would fail if someone deleted it. `PullRequestCoverage`/`DiffCoverage` + // carry no status field, so there is nothing here to replace it with. + it("still derives the coverage hint from the heuristic", async () => { + vi.mocked(AnalysisService.getRepositoryPullRequest).mockResolvedValue({ + ...mockPrData, + // No coverage numbers on the PR: `hasCoverageData` is false. + coverage: {}, + } as any); + vi.mocked(AnalysisService.listPullRequestIssues) + .mockResolvedValueOnce({ data: [], pagination: {} } as any) + .mockResolvedValueOnce({ data: [], pagination: {} } as any); + vi.mocked(AnalysisService.listPullRequestFiles).mockResolvedValue( + { data: [], pagination: {} } as any, + ); + // A coverage overview exists, so a report is expected: `expectsCoverage`. + vi.mocked(RepositoryService.listCoverageReports).mockResolvedValue({ + data: { hasCoverageOverview: true }, + } as any); + vi.mocked(AnalysisService.getPullRequestCommits).mockResolvedValue({ + data: [{ + commit: { + sha: "abc1234567890", + id: 1, + commitTimestamp: "2025-06-14T10:00:00Z", + authorName: "Test", + authorEmail: "test@test.com", + message: "fix things", + startedAnalysis: "2025-06-14T09:55:00Z", + // Finished 4h ago — past the 3h grace period, so "Missing", not + // "Waiting". Pins the threshold, not just that *some* hint appears. + endedAnalysis: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(), + }, + }], + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "pull-request", "gh", "test-org", "test-repo", "42", + ]); + + const allOutput = (console.log as ReturnType).mock.calls + .map((c) => c[0]) + .join("\n"); + expect(allOutput).toContain("Missing coverage reports"); + // The repository-only wording must not leak into the PR path. + expect(allOutput).not.toContain("Stopped receiving coverage reports"); + expect(RepositoryService.listCoverageReports).toHaveBeenCalled(); + }); + // ─── Control-character neutralization (CWE-150) ────────────────────────── describe("neutralizes terminal control characters in untrusted output", () => { diff --git a/src/commands/repositories.test.ts b/src/commands/repositories.test.ts index 767566d..50de30b 100644 --- a/src/commands/repositories.test.ts +++ b/src/commands/repositories.test.ts @@ -170,6 +170,143 @@ describe("repositories command", () => { expect(allOutput).toMatch(/my-repo(?!.*⊙)/); }); + describe("coverage status", () => { + // A separate fixture rather than mutating `mockRepos`, so the existing + // tests keep asserting against unchanged rows. + function repoWithCoverage(name: string, coverage: any) { + return { + repository: { + name, + visibility: "Private", + lastUpdated: "2025-06-15T10:00:00Z", + problems: [], + languages: ["TypeScript"], + standards: [], + addedState: "Added", + }, + gradeLetter: "A", + issuesCount: 5, + complexFilesPercentage: 10.5, + duplicationPercentage: 3.2, + coverage, + goals: { minCoveragePercentage: 60 }, + }; + } + + const waitingRepo = repoWithCoverage("waiting-repo", { + coveragePercentage: 19, + status: "Waiting", + lastCommitWithCoverage: "5474cbf195db8f6fb0704d2bc9a8dc4e16065dc9", + statusUpdatedAt: "2026-09-10T10:44:04.440743Z", + valueUpdatedAt: "2026-09-10T01:13:16.102431Z", + }); + const stoppedRepo = repoWithCoverage("stopped-repo", { + status: "Stopped", + lastCommitWithCoverage: "8752dbd2bef1fab22db1197ae5e87de371ff2ead", + statusUpdatedAt: "2026-08-26T11:08:31.090599Z", + }); + const upToDateRepo = repoWithCoverage("uptodate-repo", { + coveragePercentage: 81, + status: "UpToDate", + }); + const noStatusRepo = repoWithCoverage("nostatus-repo", { + coveragePercentage: 85, + }); + const noCoverageRepo = repoWithCoverage("nocoverage-repo", undefined); + + async function run(repos: any[]): Promise { + vi.mocked( + AnalysisService.listOrganizationRepositoriesWithAnalysis + ).mockResolvedValue({ data: repos as any }); + + const program = createProgram(); + await program.parseAsync(["node", "test", "repositories", "gh", "test-org"]); + + return (console.log as ReturnType).mock.calls + .map((c) => c[0]) + .join("\n"); + } + + /** + * The output line for one repository. Row-scoped, so an assertion can't be + * satisfied by a marker that actually belongs to a different repository. + */ + function rowFor(output: string, name: string): string { + const row = output.split("\n").find((line) => line.includes(name)); + expect(row, `no row for ${name}`).toBeDefined(); + return row!; + } + + it("marks a waiting repository's value as stale and explains the marker", async () => { + const output = await run([upToDateRepo, waitingRepo]); + + const row = rowFor(output, "waiting-repo"); + expect(row).toContain("19.0%"); + expect(row).toContain("⋯"); + + expect(output).toContain( + "⋯ no coverage report for the latest commit yet", + ); + // The other repository is healthy, so only one legend line is warranted. + expect(output).not.toContain("⊘"); + }); + + it("replaces a stopped repository's missing value with the marker", async () => { + const output = await run([upToDateRepo, stoppedRepo]); + + const row = rowFor(output, "stopped-repo"); + expect(row).toContain("⊘"); + // There is no percentage in a Stopped payload, and no "N/A" either — the + // marker itself says why the cell carries no number. Counted rather than + // matched, since complexity and duplication put their own % on the row: + // the stopped row must have one fewer than an up-to-date one. + const percents = (r: string) => (r.match(/%/g) || []).length; + expect(percents(row)).toBe( + percents(rowFor(output, "uptodate-repo")) - 1, + ); + expect(row).not.toContain("N/A"); + + expect(output).toContain("⊘ stopped receiving coverage reports"); + expect(output).not.toContain("⋯"); + }); + + it("prints no markers and no legend for a healthy listing", async () => { + const output = await run([upToDateRepo, noStatusRepo, noCoverageRepo]); + + expect(output).not.toContain("⋯"); + expect(output).not.toContain("⊘"); + expect(output).not.toContain("coverage report"); + // Unchanged rendering for the two states this feature doesn't touch. + expect(rowFor(output, "uptodate-repo")).toContain("81.0%"); + expect(rowFor(output, "nostatus-repo")).toContain("85.0%"); + expect(rowFor(output, "nocoverage-repo")).toContain("N/A"); + }); + + it("includes the coverage status fields in JSON output", async () => { + vi.mocked( + AnalysisService.listOrganizationRepositoriesWithAnalysis + ).mockResolvedValue({ data: [waitingRepo, noStatusRepo] as any }); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "repositories", "gh", "test-org", "--output", "json", + ]); + + const calls = (console.log as ReturnType).mock.calls; + const parsed = JSON.parse(calls[calls.length - 1][0]); + + expect(parsed[0].coverage).toEqual({ + coveragePercentage: 19, + status: "Waiting", + lastCommitWithCoverage: "5474cbf195db8f6fb0704d2bc9a8dc4e16065dc9", + statusUpdatedAt: "2026-09-10T10:44:04.440743Z", + valueUpdatedAt: "2026-09-10T01:13:16.102431Z", + }); + // pickDeep drops undefined, so a status-less repository gains no keys. + expect(parsed[1].coverage).not.toHaveProperty("status"); + }); + }); + it("should fail when CODACY_API_TOKEN is not set", async () => { delete process.env.CODACY_API_TOKEN; diff --git a/src/commands/repositories.ts b/src/commands/repositories.ts index a2a23fc..5b8328c 100644 --- a/src/commands/repositories.ts +++ b/src/commands/repositories.ts @@ -12,29 +12,15 @@ import { printPaginationWarning, } from "../utils/output"; import { AnalysisService } from "../api/client/services/AnalysisService"; -import { formatCount, formatGrade } from "../utils/formatting"; +import { + colorMetric, + coverageStatusLegend, + formatCount, + formatGrade, + formatRepoCoverageCell, +} from "../utils/formatting"; import pluralize from "pluralize"; -/** - * Format a percentage value, coloring it red or green based on a threshold. - * For "max" thresholds (issues, complexity, duplication): green if under, red if over. - * For "min" thresholds (coverage): green if over, red if under. - */ -function formatMetric( - value: number | undefined, - threshold: number | undefined, - mode: "max" | "min", -): string { - if (value === undefined || value === null) return "N/A"; - const display = `${value.toFixed(1)}%`; - if (threshold === undefined) return display; - if (mode === "max") { - return value > threshold ? ansis.red(display) : ansis.green(display); - } - // mode === "min" - return value < threshold ? ansis.red(display) : ansis.green(display); -} - export function registerRepositoriesCommand(program: Command) { program .command("repositories") @@ -86,6 +72,12 @@ Examples: "complexFilesPercentage", "duplicationPercentage", "coverage.coveragePercentage", + "coverage.status", + "coverage.lastCommitWithCoverage", + "coverage.statusUpdatedAt", + // What tells a consumer the `Waiting` percentage above is stale — + // the job the ⋯ marker does in the table. + "coverage.valueUpdatedAt", "goals", ]))); return; @@ -127,20 +119,19 @@ Examples: name, formatGrade(repo.gradeLetter), repo.issuesCount !== undefined ? String(repo.issuesCount) : "N/A", - formatMetric( + colorMetric( repo.complexFilesPercentage, goals?.maxComplexFilesPercentage, "max", ), - formatMetric( + colorMetric( repo.duplicationPercentage, goals?.maxDuplicatedFilesPercentage, "max", ), - formatMetric( - repo.coverage?.coveragePercentage, + formatRepoCoverageCell( + repo.coverage, goals?.minCoveragePercentage, - "min", ), repo.repository.lastUpdated ? formatFriendlyDate(repo.repository.lastUpdated) @@ -150,6 +141,13 @@ Examples: console.log(table.toString()); + // Only the coverage statuses actually present in this listing are + // explained, so a healthy organization never pays for the legend. + const legend = coverageStatusLegend( + repos.map((repo: any) => repo.coverage), + ); + if (legend.length > 0) console.log(`\n${legend.join("\n")}`); + printPaginationWarning( response.pagination, "Use --search to filter by name.", diff --git a/src/commands/repository.test.ts b/src/commands/repository.test.ts index 9e226dc..3263e67 100644 --- a/src/commands/repository.test.ts +++ b/src/commands/repository.test.ts @@ -35,9 +35,6 @@ function setupDefaultMocks() { }, }], } as any); - vi.mocked(RepositoryService.listCoverageReports).mockResolvedValue({ - data: { hasCoverageOverview: false }, - } as any); } function createProgram(): Command { @@ -519,6 +516,167 @@ describe("repository command", () => { expect(allOutput).toContain("feature/very-long..."); }); + describe("coverage status", () => { + const waitingCoverage = { + coveragePercentage: 19, + numberTotalFiles: 83, + status: "Waiting", + lastCommitWithCoverage: "5474cbf195db8f6fb0704d2bc9a8dc4e16065dc9", + statusUpdatedAt: "2026-09-10T10:44:04.440743Z", + valueUpdatedAt: "2026-09-10T01:13:16.102431Z", + }; + const stoppedCoverage = { + status: "Stopped", + lastCommitWithCoverage: "8752dbd2bef1fab22db1197ae5e87de371ff2ead", + statusUpdatedAt: "2026-08-26T11:08:31.090599Z", + }; + + async function run( + coverage: any, + opts: { goals?: any; json?: boolean } = {}, + ): Promise { + // Called more than once in a single test, so don't inherit the previous + // run's output. + (console.log as ReturnType).mockClear(); + + vi.mocked(AnalysisService.getRepositoryWithAnalysis).mockResolvedValue({ + data: { + ...mockRepoData, + coverage, + ...(opts.goals !== undefined ? { goals: opts.goals } : {}), + } as any, + }); + vi.mocked(AnalysisService.listRepositoryPullRequests).mockResolvedValue({ + data: [], + }); + vi.mocked(AnalysisService.issuesOverview).mockResolvedValue({ + data: { counts: mockIssuesCounts }, + }); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", + ...(opts.json ? ["--output", "json"] : []), + "repository", "gh", "test-org", "test-repo", + ]); + + return (console.log as ReturnType).mock.calls + .map((c) => c[0]) + .join("\n"); + } + + /** The `--output json` payload, which spans lines and so isn't one of them. */ + function lastJson(): any { + const call = (console.log as ReturnType).mock.calls.find( + (c) => typeof c[0] === "string" && c[0].startsWith("{"), + ); + return JSON.parse(call![0]); + } + + /** The Metrics section's Coverage row, so a match can't come from elsewhere. */ + function coverageRow(output: string): string { + const row = output + .split("\n") + .find((line) => /^\s*Coverage\s/.test(line)); + expect(row, "no Coverage row in Metrics").toBeDefined(); + return row!; + } + + it("explains that a waiting repository's value is stale", async () => { + const output = await run(waitingCoverage); + + const row = coverageRow(output); + expect(row).toContain("19.0%"); + expect(row).toContain("Not reported yet for the latest commit"); + expect(row).toContain("5474cbf"); + }); + + it("replaces a stopped repository's absent value with the reason", async () => { + const output = await run(stoppedCoverage); + + const row = coverageRow(output); + expect(row).toContain("Stopped receiving reports"); + expect(row).toContain("8752dbd"); + // No percentage exists in a Stopped payload, and "N/A" would say less. + expect(row).not.toContain("%"); + expect(row).not.toContain("N/A"); + }); + + it("names the gate consequence only when a coverage goal is set", async () => { + const withGoal = await run(stoppedCoverage); + expect(coverageRow(withGoal)).toContain( + "coverage gate no longer enforced", + ); + + const withoutGoal = await run(stoppedCoverage, { goals: {} }); + expect(coverageRow(withoutGoal)).not.toContain("gate"); + }); + + it("distinguishes a repository that never had coverage", async () => { + const output = await run({ status: "None" }); + expect(coverageRow(output)).toContain("Not set up"); + }); + + it("reads the Analysis row's coverage state from the status", async () => { + // The bug this fixes: a waiting repository reports a stale percentage, so + // the old heuristic ("a coverage number is present") read it as healthy + // and the Analysis row said nothing at all. + const waiting = await run(waitingCoverage); + expect(waiting).toContain("Waiting for coverage reports..."); + + const stopped = await run(stoppedCoverage); + expect(stopped).toContain("Stopped receiving coverage reports"); + expect(stopped).not.toContain("Missing coverage reports"); + + const upToDate = await run({ coveragePercentage: 78, status: "UpToDate" }); + expect(upToDate).not.toContain("coverage reports"); + }); + + it("shows no coverage hint at all when the API sends no status", async () => { + // The accepted trade-off of dropping the heuristic, pinned explicitly: + // the API leaves `status` undefined on a large share of repositories, and + // for those the Analysis row used to be able to say "Missing coverage + // reports". It now says nothing, which is the honest reading of an absent + // status — but it is a deliberate behavior removal, so assert it rather + // than let it drift back in unnoticed. + const output = await run({ coveragePercentage: 78 }); + + expect(output).toContain("Finished"); + expect(output).not.toContain("Missing coverage reports"); + expect(output).not.toContain("Waiting for coverage reports"); + expect(output).not.toContain("Stopped receiving"); + // And the Metrics row is the plain metric, with nothing appended. + expect(coverageRow(output)).toContain("78.0%"); + expect(coverageRow(output)).not.toContain("Not set up"); + }); + + it("never calls listCoverageReports — the status supersedes it", async () => { + await run(waitingCoverage); + // The state used to be inferred from this call; it now rides along on the + // analysis response, which is also what makes it reach repository tokens. + expect(RepositoryService.listCoverageReports).not.toHaveBeenCalled(); + }); + + it("includes the coverage status fields in JSON output", async () => { + await run(waitingCoverage, { json: true }); + + expect(lastJson().repository.coverage).toEqual({ + coveragePercentage: 19, + status: "Waiting", + lastCommitWithCoverage: "5474cbf195db8f6fb0704d2bc9a8dc4e16065dc9", + statusUpdatedAt: "2026-09-10T10:44:04.440743Z", + valueUpdatedAt: "2026-09-10T01:13:16.102431Z", + }); + }); + + it("emits only the status for a repository that never had coverage", async () => { + await run({ status: "None" }, { json: true }); + + // pickDeep drops undefined, so nothing is invented for the absent fields. + expect(lastJson().repository.coverage).toEqual({ status: "None" }); + }); + }); + it("should fail when CODACY_API_TOKEN is not set", async () => { delete process.env.CODACY_API_TOKEN; @@ -966,7 +1124,7 @@ describe("repository command", () => { .join("\n"); } - it("skips the pull request and coverage calls entirely", async () => { + it("skips the pull request call entirely", async () => { mockWhitelistedDashboardCalls(); const program = createProgram(); @@ -975,9 +1133,8 @@ describe("repository command", () => { "--repository-token", "rt", ]); - // Both are outside a repository token's scope: don't even try. + // Outside a repository token's scope: don't even try. expect(AnalysisService.listRepositoryPullRequests).not.toHaveBeenCalled(); - expect(RepositoryService.listCoverageReports).not.toHaveBeenCalled(); // The whitelisted calls still run, so the dashboard is still worth showing. expect(AnalysisService.getRepositoryWithAnalysis).toHaveBeenCalled(); expect(AnalysisService.issuesOverview).toHaveBeenCalled(); @@ -1015,16 +1172,15 @@ describe("repository command", () => { const parsed = JSON.parse(calls[calls.length - 1][0]); // Present and iterable, so `jq '.pullRequests[]'` and `| length` still work. expect(parsed.pullRequests).toEqual([]); - // Coverage is listed too: skipping it silently suppresses the - // "missing coverage reports" state, so consumers need to know. - expect(parsed.unavailable).toEqual(["pullRequests", "coverageReports"]); + // Pull requests are the only section a repository token can't reach — + // coverage state now rides along on the whitelisted analysis call. + expect(parsed.unavailable).toEqual(["pullRequests"]); // The fields the auto-configuration skill reads are unaffected. expect(parsed.repository.fileCount).toBe(83); expect(parsed.repository.repository.standards).toBeDefined(); // Same reason as above: `unavailable` follows the token kind, so assert // the call really was skipped rather than merely reported as skipped. expect(AnalysisService.listRepositoryPullRequests).not.toHaveBeenCalled(); - expect(RepositoryService.listCoverageReports).not.toHaveBeenCalled(); }); it("still supports --reanalyze", async () => { diff --git a/src/commands/repository.ts b/src/commands/repository.ts index b3d22d7..000e93d 100644 --- a/src/commands/repository.ts +++ b/src/commands/repository.ts @@ -28,6 +28,7 @@ import { colorByGate, formatDelta, formatPrCoverage, + formatRepoCoverageDetail, formatPrIssues, formatAnalysisStatus, prQualityMetric, @@ -53,8 +54,6 @@ import { Count } from "../api/client/models/Count"; function printAbout( data: RepositoryWithAnalysis, headCommit: Commit | null, - expectsCoverage: boolean, - hasCoverageData: boolean, ): void { printSection("About"); const repo = data.repository; @@ -80,8 +79,10 @@ function printAbout( commitSha: commit.sha, startedAnalysis: commit.startedAnalysis, endedAnalysis: commit.endedAnalysis, - expectsCoverage, - hasCoverageData, + // The API's own coverage state, so no heuristic is needed here — see + // `formatAnalysisStatus`. The Metrics section spells the same state + // out with its dates and commit; this row just names it. + coverageStatus: data.coverage?.status, }), }); } else { @@ -131,10 +132,9 @@ function printMetrics(data: RepositoryWithAnalysis): void { } table.push({ Issues: `${issuesDisplay} (${issuesKloc} / kLoC)` }); table.push({ - Coverage: colorMetric( - data.coverage?.coveragePercentage, + Coverage: formatRepoCoverageDetail( + data.coverage, goals?.minCoveragePercentage, - "min", ), }); table.push({ @@ -165,10 +165,6 @@ function noPullRequests(): { data: PullRequestWithAnalysis[]; pagination: undefi return { data: [], pagination: undefined }; } -function noCoverageReports(): { data: { hasCoverageOverview: boolean } } { - return { data: { hasCoverageOverview: false } }; -} - function printPullRequests(pullRequests: PullRequestWithAnalysis[]): void { const open = pullRequests.filter( (pr) => @@ -532,14 +528,13 @@ Examples: const format = getOutputFormat(this); const spinner = ora("Fetching repository details...").start(); - // Pull requests and coverage reports are outside a repository token's - // scope — Codacy rejects them as if no token had been sent. Skip the - // requests rather than firing two we know will fail, and keep .catch() - // on the pull request call so an account token that lacks access - // degrades the same way instead of losing the whole dashboard (its three - // sibling calls were already guarded). + // Pull requests are outside a repository token's scope — Codacy + // rejects them as if no token had been sent. Skip the request rather + // than firing one we know will fail, and keep .catch() on it so an + // account token that lacks access degrades the same way instead of + // losing the whole dashboard (its siblings were already guarded). let prsUnavailable = auth.kind !== "account-token"; - const [repoResponse, prsResponse, issuesResponse, commitsResponse, coverageReportsResponse] = await Promise.all([ + const [repoResponse, prsResponse, issuesResponse, commitsResponse] = await Promise.all([ AnalysisService.getRepositoryWithAnalysis( provider, organization, @@ -564,14 +559,6 @@ Examples: undefined, 1, ).catch(() => ({ data: [] })), - fetchIfAccountToken(auth, noCoverageReports(), () => - RepositoryService.listCoverageReports( - provider, - organization, - repository, - 1, - ).catch(() => noCoverageReports()), - ), ]); spinner.stop(); @@ -580,15 +567,8 @@ Examples: const pullRequests = prsResponse.data; const issuesCounts = issuesResponse.data.counts; const headCommit = (commitsResponse as any).data[0]?.commit ?? null; - const expectsCoverage = !!(coverageReportsResponse as any).data?.hasCoverageOverview; - const hasCoverageData = data.coverage?.coveragePercentage !== undefined; - const unavailableSections = [ - ...(prsUnavailable ? ["pullRequests"] : []), - // Only skipped, never merely failed — listCoverageReports is guarded - // by fetchIfAccountToken alone. - ...(auth.kind === "account-token" ? [] : ["coverageReports"]), - ]; + const unavailableSections = prsUnavailable ? ["pullRequests"] : []; if (format === "json") { printJson(pickDeep({ @@ -600,12 +580,6 @@ Examples: // and `| length` keep working. `unavailable` is what distinguishes // "no open pull requests" from "couldn't look"; pickDeep drops // undefined, so it stays absent whenever the data is real. - // - // Coverage reports are listed too even though no coverage key is - // projected: skipping them forces `expectsCoverage` false, which - // silently suppresses the "missing/waiting for coverage reports" - // state. Without this a repo that *is* configured for coverage but - // has uploaded none is indistinguishable from a healthy one. pullRequests, issuesOverview: issuesCounts, unavailable: unavailableSections.length ? unavailableSections : undefined, @@ -630,6 +604,10 @@ Examples: "repository.loc", "repository.fileCount", "repository.coverage.coveragePercentage", + "repository.coverage.status", + "repository.coverage.lastCommitWithCoverage", + "repository.coverage.statusUpdatedAt", + "repository.coverage.valueUpdatedAt", "repository.complexFilesPercentage", "repository.duplicationPercentage", "repository.goals", @@ -643,7 +621,7 @@ Examples: return; } - printAbout(data, headCommit, expectsCoverage, hasCoverageData); + printAbout(data, headCommit); printSetup(data); printMetrics(data); if (prsUnavailable) { diff --git a/src/utils/formatting.test.ts b/src/utils/formatting.test.ts index d975f8c..700e8c6 100644 --- a/src/utils/formatting.test.ts +++ b/src/utils/formatting.test.ts @@ -16,7 +16,14 @@ import { formatPrIssues, prQualityMetric, hasAnyPrCoverage, + colorMetric, + coverageStatusGlyph, + coverageStatusLegend, + coverageStatusNote, + formatRepoCoverageCell, + formatRepoCoverageDetail, } from "./formatting"; +import { formatFriendlyDate } from "./output"; // Mock ansis to return raw text for easier testing vi.mock("ansis", () => ({ @@ -72,7 +79,7 @@ describe("formatAnalysisStatus", () => { expect(result).toContain("abc1234"); }); - it("should show 'Waiting for coverage reports...' when coverage expected within 3h", () => { + it("should show 'Waiting for coverage reports...' within 3h (pull-request fallback heuristic)", () => { const recentEnd = new Date(Date.now() - 60 * 60 * 1000).toISOString(); // 1h ago const result = formatAnalysisStatus({ commitSha: "cov1234567890", @@ -85,7 +92,7 @@ describe("formatAnalysisStatus", () => { expect(result).toContain("cov1234"); }); - it("should show 'Missing coverage reports' when coverage expected after 3h", () => { + it("should show 'Missing coverage reports' after 3h (pull-request fallback heuristic)", () => { const oldEnd = new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(); // 4h ago const result = formatAnalysisStatus({ commitSha: "old1234567890", @@ -98,6 +105,74 @@ describe("formatAnalysisStatus", () => { expect(result).toContain("old1234"); }); + it("shows 'Waiting for coverage reports...' from an authoritative Waiting status", () => { + const result = formatAnalysisStatus({ + commitSha: "wait123456789", + startedAnalysis: "2025-06-15T10:00:00Z", + endedAnalysis: "2025-06-15T10:05:00Z", + coverageStatus: "Waiting", + // The bug this fixes: a waiting repository still reports a (stale) + // percentage, so the heuristic reads it as healthy and says nothing. + // The authoritative status has to win over that. + expectsCoverage: true, + hasCoverageData: true, + }); + expect(result).toContain("Waiting for coverage reports..."); + expect(result).toContain("wait123"); + }); + + it("shows 'Stopped receiving coverage reports' from an authoritative Stopped status", () => { + const result = formatAnalysisStatus({ + commitSha: "stop123456789", + startedAnalysis: "2025-06-15T10:00:00Z", + endedAnalysis: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(), + coverageStatus: "Stopped", + expectsCoverage: true, + hasCoverageData: false, + }); + expect(result).toContain("Stopped receiving coverage reports"); + // The vaguer heuristic wording must not leak through. + expect(result).not.toContain("Missing coverage reports"); + }); + + it("appends nothing for an authoritative UpToDate or None status", () => { + for (const coverageStatus of ["UpToDate", "None"] as const) { + const result = formatAnalysisStatus({ + commitSha: "ok01234567890", + startedAnalysis: "2025-06-15T10:00:00Z", + endedAnalysis: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(), + coverageStatus, + // Would otherwise trip the heuristic into "Missing coverage reports". + expectsCoverage: true, + hasCoverageData: false, + }); + expect(result).toContain("Finished"); + expect(result).not.toContain("coverage"); + } + }); + + it("falls back to the heuristic when no coverage status is available", () => { + const result = formatAnalysisStatus({ + commitSha: "old1234567890", + startedAnalysis: "2025-06-15T10:00:00Z", + endedAnalysis: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(), + coverageStatus: undefined, + expectsCoverage: true, + hasCoverageData: false, + }); + expect(result).toContain("Missing coverage reports"); + }); + + it("appends no coverage state when neither source says anything", () => { + const result = formatAnalysisStatus({ + commitSha: "bare123456789", + startedAnalysis: "2025-06-15T10:00:00Z", + endedAnalysis: "2025-06-15T10:05:00Z", + }); + expect(result).toContain("Finished"); + expect(result).not.toContain("coverage"); + }); + it("should show 'Never' when no analysis data", () => { const result = formatAnalysisStatus({ commitSha: "abc1234567890", @@ -515,3 +590,251 @@ describe("formatDependencyChainsBlock", () => { ); }); }); + +// Fixtures mirror the four real payload shapes the API returns, which differ in +// more than just `status`: `Waiting` carries a *stale* percentage (from +// `lastCommitWithCoverage`, with `valueUpdatedAt` older than `statusUpdatedAt`), +// `Stopped` carries no percentage at all, and `None` carries nothing but the +// status. A large share of repositories also come back with no `status` — that +// is the common path, not an edge case, so it gets its own fixture. +const covUpToDate = { + coveragePercentage: 81, + coveragePercentageWithDecimals: 81.52, + status: "UpToDate" as const, + lastCommitWithCoverage: "92abbe5d63c88211ed2f2c7dfba261b0f4cbd3b9", + statusUpdatedAt: "2026-09-10T11:46:23.542085Z", + valueUpdatedAt: "2026-09-10T11:46:23.542085Z", +}; +const covWaiting = { + coveragePercentage: 19, + coveragePercentageWithDecimals: 19.42, + status: "Waiting" as const, + lastCommitWithCoverage: "5474cbf195db8f6fb0704d2bc9a8dc4e16065dc9", + statusUpdatedAt: "2026-09-10T10:44:04.440743Z", + valueUpdatedAt: "2026-09-10T01:13:16.102431Z", +}; +const covStopped = { + status: "Stopped" as const, + lastCommitWithCoverage: "8752dbd2bef1fab22db1197ae5e87de371ff2ead", + statusUpdatedAt: "2026-08-26T11:08:31.090599Z", +}; +const covNone = { status: "None" as const }; +const covNoStatus = { coveragePercentage: 85 }; +// `Waiting` with nothing to show. Observed payloads always carry a stale +// percentage, but `coveragePercentage` is documented as present only for the +// latest commit — which a waiting repository by definition doesn't have — so +// the renderers must not promise a value they aren't showing. +const covWaitingNoValue = { + status: "Waiting" as const, + lastCommitWithCoverage: "5474cbf195db8f6fb0704d2bc9a8dc4e16065dc9", + statusUpdatedAt: "2026-09-10T10:44:04.440743Z", + valueUpdatedAt: "2026-09-10T01:13:16.102431Z", +}; + +describe("coverageStatusGlyph", () => { + it("marks only the two states worth flagging", () => { + expect(coverageStatusGlyph(covWaiting)).toBe("⋯"); + expect(coverageStatusGlyph(covStopped)).toBe("⊘"); + }); + + it("returns undefined for every other state", () => { + expect(coverageStatusGlyph(covUpToDate)).toBeUndefined(); + expect(coverageStatusGlyph(covNone)).toBeUndefined(); + expect(coverageStatusGlyph(covNoStatus)).toBeUndefined(); + expect(coverageStatusGlyph(undefined)).toBeUndefined(); + }); +}); + +describe("formatRepoCoverageCell", () => { + it("appends the stale marker to a waiting repository's last known value", () => { + expect(formatRepoCoverageCell(covWaiting, 60)).toBe("19.0% ⋯"); + }); + + it("shows the stopped marker alone — there is no percentage to show", () => { + const cell = formatRepoCoverageCell(covStopped, 60); + expect(cell).toBe("⊘"); + expect(cell).not.toContain("%"); + expect(cell).not.toContain("N/A"); + }); + + it("renders an up-to-date repository exactly as before", () => { + expect(formatRepoCoverageCell(covUpToDate, 60)).toBe( + colorMetric(81, 60, "min"), + ); + }); + + it("degrades to the plain metric when the status is absent", () => { + // The common path: no status at all, or no coverage object. + expect(formatRepoCoverageCell(covNoStatus, 60)).toBe( + colorMetric(85, 60, "min"), + ); + expect(formatRepoCoverageCell(undefined, 60)).toBe( + colorMetric(undefined, 60, "min"), + ); + expect(formatRepoCoverageCell(covNone, 60)).toBe( + colorMetric(undefined, 60, "min"), + ); + }); + + it("keeps threshold coloring on a waiting repository's stale value", () => { + // The glyph is what marks the value stale; the color still answers + // "is this repository above its coverage goal", same as every other row. + expect(formatRepoCoverageCell(covWaiting, 60)).toContain( + colorMetric(19, 60, "min"), + ); + }); +}); + +describe("coverageStatusLegend", () => { + it("explains nothing when there is nothing to explain", () => { + expect( + coverageStatusLegend([covUpToDate, covNone, covNoStatus, undefined]), + ).toEqual([]); + }); + + it("explains only the statuses present in the listing", () => { + const waitingOnly = coverageStatusLegend([covUpToDate, covWaiting]); + expect(waitingOnly).toHaveLength(1); + expect(waitingOnly[0]).toContain("⋯"); + + const stoppedOnly = coverageStatusLegend([covStopped, covNone]); + expect(stoppedOnly).toHaveLength(1); + expect(stoppedOnly[0]).toContain("⊘"); + }); + + it("promises a last known value only when one is actually shown", () => { + // The legend explains the marker in the cell next to it; with no + // percentage rendered, "showing the last known value" would be a lie. + const withValue = coverageStatusLegend([covWaiting]); + expect(withValue[0]).toContain("showing the last known value"); + + const withoutValue = coverageStatusLegend([covWaitingNoValue]); + expect(withoutValue).toHaveLength(1); + expect(withoutValue[0]).toContain("no coverage report for the latest commit"); + expect(withoutValue[0]).not.toContain("last known value"); + + // One repository in the listing having a value is enough to warrant it. + expect( + coverageStatusLegend([covWaitingNoValue, covWaiting])[0], + ).toContain("showing the last known value"); + }); + + it("deduplicates across a mixed listing", () => { + const legend = coverageStatusLegend([ + covUpToDate, covWaiting, covStopped, covWaiting, covNone, + covStopped, covNoStatus, undefined, + ]); + expect(legend).toHaveLength(2); + expect(legend[0]).toContain("⋯"); + expect(legend[1]).toContain("⊘"); + }); +}); + +describe("coverageStatusNote", () => { + it("says a waiting repository's value is stale, and where it came from", () => { + const note = coverageStatusNote(covWaiting); + expect(note).toContain("Not reported yet for the latest commit"); + expect(note).toContain("value from"); + expect(note).toContain(formatFriendlyDate(covWaiting.valueUpdatedAt)); + expect(note).toContain("5474cbf"); + // Truncated to 7 characters, like every other commit in the CLI. + expect(note).not.toContain("5474cbf1"); + }); + + it("omits the provenance clause when there is no value to attribute", () => { + // Same rule as the legend: don't describe where a number came from when no + // number is on screen. + const note = coverageStatusNote(covWaitingNoValue); + expect(note).toBe("Not reported yet for the latest commit"); + expect(note).not.toContain("value from"); + }); + + it("omits the provenance clause when the value has no timestamp", () => { + const note = coverageStatusNote({ ...covWaiting, valueUpdatedAt: undefined }); + expect(note).toBe("Not reported yet for the latest commit"); + }); + + it("says when a stopped repository stopped, and its last report", () => { + const note = coverageStatusNote(covStopped); + expect(note).toContain("Stopped receiving reports"); + expect(note).toContain(formatFriendlyDate(covStopped.statusUpdatedAt)); + expect(note).toContain("last report"); + expect(note).toContain("8752dbd"); + }); + + it("degrades to a bare sentence when a stopped payload carries nothing else", () => { + expect(coverageStatusNote({ status: "Stopped" })).toBe( + "Stopped receiving reports", + ); + }); + + it("names the gate consequence only when a coverage gate is configured", () => { + expect(coverageStatusNote(covStopped, { gateConfigured: true })).toContain( + "coverage gate no longer enforced", + ); + expect(coverageStatusNote(covStopped, { gateConfigured: false })).not.toContain( + "gate", + ); + expect(coverageStatusNote(covStopped)).not.toContain("gate"); + }); + + it("distinguishes 'never set up' from an uncomputed metric", () => { + expect(coverageStatusNote(covNone)).toBe("Not set up"); + }); + + it("says nothing when there is nothing to say", () => { + expect(coverageStatusNote(covUpToDate)).toBeUndefined(); + expect(coverageStatusNote(covNoStatus)).toBeUndefined(); + expect(coverageStatusNote(undefined)).toBeUndefined(); + }); + + it("neutralizes control characters in the commit SHA (CWE-150)", () => { + // ansis is mocked to identity in this file, so any ESC in the result can + // only have come from the payload. Sanitizing before the 7-char slice also + // stops the slice from ending mid-escape-sequence. + const esc = String.fromCharCode(27); + const note = coverageStatusNote({ + ...covStopped, + lastCommitWithCoverage: `abc${esc}[31mdef0123456789`, + }); + expect(note).not.toContain(esc); + }); +}); + +describe("formatRepoCoverageDetail", () => { + it("shows a waiting repository's value and why it is stale", () => { + const detail = formatRepoCoverageDetail(covWaiting, 60); + expect(detail).toContain(colorMetric(19, 60, "min")); + expect(detail).toContain("Not reported yet for the latest commit"); + }); + + it("shows the note alone for the states that have no percentage", () => { + const stopped = formatRepoCoverageDetail(covStopped, 60); + expect(stopped).toContain("Stopped receiving reports"); + expect(stopped).not.toContain("%"); + expect(stopped).not.toContain("N/A"); + + expect(formatRepoCoverageDetail(covNone, 60)).toBe("Not set up"); + }); + + it("passes the gate consequence through from the threshold", () => { + expect(formatRepoCoverageDetail(covStopped, 60)).toContain( + "coverage gate no longer enforced", + ); + expect(formatRepoCoverageDetail(covStopped, undefined)).not.toContain("gate"); + }); + + it("is byte-identical to the plain metric when there is no status to add", () => { + // The regression guard: everything without a Waiting/Stopped/None status + // must render exactly as it did before this field existed. + expect(formatRepoCoverageDetail(covUpToDate, 60)).toBe( + colorMetric(81, 60, "min"), + ); + expect(formatRepoCoverageDetail(covNoStatus, 60)).toBe( + colorMetric(85, 60, "min"), + ); + expect(formatRepoCoverageDetail(undefined, 60)).toBe( + colorMetric(undefined, 60, "min"), + ); + }); +}); diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts index fe87e50..4c6f718 100644 --- a/src/utils/formatting.ts +++ b/src/utils/formatting.ts @@ -11,6 +11,8 @@ import { SeverityLevel } from "../api/client/models/SeverityLevel"; import { Pattern } from "../api/client/models/Pattern"; import { ConfiguredPattern } from "../api/client/models/ConfiguredPattern"; import { CodeBlockLine } from "../api/client/models/CodeBlockLine"; +import { Coverage } from "../api/client/models/Coverage"; +import { CoverageStatus } from "../api/client/models/CoverageStatus"; import { CveRecord } from "./cve"; import { AnalysisTool } from "../api/client/models/AnalysisTool"; import { Tool } from "../api/client/models/Tool"; @@ -448,6 +450,190 @@ export function colorMetric( return value < threshold ? ansis.red(display) : ansis.green(display); } +// ── Repository coverage status (`Coverage.status`) ─────────────────────────── +// +// Only `Waiting` and `Stopped` are decorated — the same two the SPA flags. +// `UpToDate`, `None`, an undefined `status` and an absent `coverage` object all +// render exactly as they did before this field existed (the API leaves `status` +// undefined on a large share of repositories, so that path is the common one, +// not an edge case). +// +// Markers are dim glyphs, no emojis: `⋯` is already this CLI's "not final yet" +// marker in `formatStandards`, and `⊘` (U+2298) is in the same Mathematical +// Operators block as the `⊙` public-repository marker, which renders reliably +// across terminals (see `commands/tree-view.ts`). +const COVERAGE_WAITING_GLYPH = "⋯"; +const COVERAGE_STOPPED_GLYPH = "⊘"; + +/** + * Which statuses carry a marker, and which glyph. **Exhaustive over + * `CoverageStatus` on purpose:** a new member arriving from + * `npm run update-api` fails to compile *here*, rather than silently rendering + * as "nothing to flag" in every renderer below. `coverageStatusNote` and + * `coverageAnalysisSuffix` keep their own switches — their prose differs too + * much per state to share a table — so this is the single place a widened + * union surfaces. Start from here when one does. + */ +const COVERAGE_STATUS_GLYPH: Record = { + UpToDate: null, + Waiting: COVERAGE_WAITING_GLYPH, + Stopped: COVERAGE_STOPPED_GLYPH, + None: null, +}; + +/** Dim table glyph for a coverage status, or undefined when there's nothing to flag. */ +export function coverageStatusGlyph(coverage?: Coverage): string | undefined { + // Indexing with a status the table doesn't know (a value from a newer API + // than this build) yields undefined, so an unrecognized state degrades to + // "no marker" rather than throwing. + const glyph = coverage?.status && COVERAGE_STATUS_GLYPH[coverage.status]; + return glyph ? ansis.dim(glyph) : undefined; +} + +/** + * Coverage cell for the `repositories` table: the threshold-colored percentage + * as before, plus a dim glyph when the value is stale (`Waiting` — the number + * comes from `lastCommitWithCoverage`, not the latest commit). + * + * `Stopped` shows the glyph alone. The API sends no percentage in that state, + * and suppressing it unconditionally means a future payload that *does* carry + * a stale value can't reintroduce a number nobody should read. + */ +export function formatRepoCoverageCell( + coverage: Coverage | undefined, + threshold: number | undefined, +): string { + const glyph = coverageStatusGlyph(coverage); + // `&& glyph` rather than a non-null assertion: were the marker ever to go + // missing, falling through prints the ordinary "N/A" instead of the literal + // string "undefined". + if (coverage?.status === "Stopped" && glyph) return glyph; + const value = colorMetric(coverage?.coveragePercentage, threshold, "min"); + return glyph ? `${value} ${glyph}` : value; +} + +/** Whether a coverage payload actually carries a percentage to render. */ +function hasCoverageValue(coverage: Coverage | undefined): boolean { + return ( + coverage?.coveragePercentage !== undefined && + coverage?.coveragePercentage !== null + ); +} + +/** + * Legend lines explaining the glyphs, for only the statuses actually present in + * a listing — an organization with healthy coverage everywhere pays nothing. + * Returns an empty array when there is nothing to explain. + */ +export function coverageStatusLegend( + coverages: Array, +): string[] { + const waiting = coverages.filter((c) => c?.status === "Waiting"); + const present = new Set(coverages.map((c) => c?.status)); + const lines: string[] = []; + if (waiting.length > 0) { + // A `Waiting` payload normally carries a stale percentage from + // `lastCommitWithCoverage`, but the field is documented as present only for + // the latest commit — so only claim a last known value when one is + // actually rendered, instead of explaining a number that isn't there. + const showsValue = waiting.some((c) => hasCoverageValue(c)); + const suffix = showsValue ? " — showing the last known value" : ""; + lines.push( + ansis.dim( + `${COVERAGE_WAITING_GLYPH} no coverage report for the latest commit yet${suffix}`, + ), + ); + } + if (present.has("Stopped")) { + lines.push( + ansis.dim(`${COVERAGE_STOPPED_GLYPH} stopped receiving coverage reports`), + ); + } + return lines; +} + +/** + * The coverage status spelled out in words, for the `repository` dashboard's + * Metrics section where there is room for a sentence. Returns undefined when + * there is nothing to say (`UpToDate`, undefined status, absent coverage). + * + * Colors follow `formatAnalysisStatus`: blueBright = transient/in-flight, + * yellow = needs attention, dim = nothing there. + * + * `gateConfigured` adds the consequence of a stopped repository — a red + * coverage number on a repository whose coverage gate is no longer enforced is + * actively misleading. + */ +export function coverageStatusNote( + coverage: Coverage | undefined, + opts: { gateConfigured?: boolean } = {}, +): string | undefined { + // Commit SHAs come from the API but are repository-derived, so sanitize + // before slicing — a slice must not be able to end mid-escape-sequence. + const shortSha = (sha?: string) => sanitizeText(sha ?? "").substring(0, 7); + + switch (coverage?.status) { + case "Waiting": { + // The values are present but stale: they come from the last commit that + // did receive a report, not from the latest commit. + const commit = coverage.lastCommitWithCoverage + ? ` (${shortSha(coverage.lastCommitWithCoverage)})` + : ""; + // Gated on the percentage as well as the timestamp: with no value on + // screen, "value from ..." would describe something the row never shows. + const from = + hasCoverageValue(coverage) && coverage.valueUpdatedAt + ? ` — value from ${formatFriendlyDate(coverage.valueUpdatedAt)}${commit}` + : ""; + return ansis.blueBright(`Not reported yet for the latest commit${from}`); + } + case "Stopped": { + const when = coverage.statusUpdatedAt + ? ` ${formatFriendlyDate(coverage.statusUpdatedAt)}` + : ""; + const last = coverage.lastCommitWithCoverage + ? ` — last report ${shortSha(coverage.lastCommitWithCoverage)}` + : ""; + const gate = opts.gateConfigured + ? " — coverage gate no longer enforced" + : ""; + return ansis.yellow(`Stopped receiving reports${when}${last}${gate}`); + } + case "None": + // Distinguishable from a metric Codacy simply didn't compute, which is + // all a bare "N/A" could ever say. + return ansis.dim("Not set up"); + default: + return undefined; // UpToDate | undefined + } +} + +/** + * Coverage row for the `repository` dashboard's Metrics section: the + * threshold-colored percentage followed by the status note, or the note alone + * for the two states that have no percentage to show. + */ +export function formatRepoCoverageDetail( + coverage: Coverage | undefined, + threshold: number | undefined, +): string { + const note = coverageStatusNote(coverage, { + gateConfigured: threshold !== undefined, + }); + // `Stopped` carries no percentage and `None` never had one; in both cases the + // note says strictly more than a bare "N/A" would. + // `&& note` for the same reason as the cell's `&& glyph`: degrade to the + // ordinary metric rather than risk printing the string "undefined". + if ( + (coverage?.status === "Stopped" || coverage?.status === "None") && + note + ) { + return note; + } + const value = colorMetric(coverage?.coveragePercentage, threshold, "min"); + return note ? `${value} ${note}` : value; +} + /** * Color a value string based on gate status (green if passing, red if failing). * Falls back to no coloring if gate status is unknown. @@ -1056,6 +1242,49 @@ export async function resolveToolUuids( const COVERAGE_REPORTS_WAIT_HOURS = 3; +/** + * The coverage half of the analysis status: which coverage state, if any, to + * append after a finished analysis. + * + * Two sources, in priority order: + * + * 1. `coverageStatus` — the API's own `Coverage.status`, available on the + * repository endpoints. Authoritative, so it wins outright. + * 2. The heuristic below — "a coverage overview exists but this commit has no + * coverage number", with a 3-hour grace period. Only pull requests still + * need it: `PullRequestCoverage`/`DiffCoverage` carry no status field. + * + * The heuristic is wrong for `Waiting`, which is why (1) exists: a waiting + * repository still reports a percentage (a stale one, from the last commit that + * did receive a report), so `hasCoverageData` is true and the heuristic reads + * the repository as healthy. + */ +function coverageAnalysisSuffix(opts: { + coverageStatus?: CoverageStatus; + expectsCoverage?: boolean; + hasCoverageData?: boolean; + endedAnalysis: string; +}): string | undefined { + const { coverageStatus, expectsCoverage, hasCoverageData, endedAnalysis } = opts; + + if (coverageStatus) { + if (coverageStatus === "Waiting") { + return ansis.blueBright("Waiting for coverage reports..."); + } + if (coverageStatus === "Stopped") { + return ansis.yellow("Stopped receiving coverage reports"); + } + return undefined; // UpToDate | None + } + + if (!expectsCoverage || hasCoverageData) return undefined; + + const hoursSinceFinish = differenceInHours(new Date(), parseISO(endedAnalysis)); + return hoursSinceFinish <= COVERAGE_REPORTS_WAIT_HOURS + ? ansis.blueBright("Waiting for coverage reports...") + : ansis.yellow("Missing coverage reports"); +} + /** * Format the analysis status string for a commit (used by repository and pull-request commands). * @@ -1063,9 +1292,7 @@ const COVERAGE_REPORTS_WAIT_HOURS = 3; * - Being analyzed = startedAnalysis exists and (no endedAnalysis OR startedAnalysis > endedAnalysis) * - If being analyzed + has previous endedAnalysis: "Finished {date} ({sha}) — Reanalysis in progress..." * - If being analyzed + no previous finish: "In progress... ({sha})" - * - If finished + expects coverage but no data: - * - ≤3h: "Finished {date} ({sha}) — Waiting for coverage reports..." - * - >3h: "Finished {date} ({sha}) — Missing coverage reports" + * - If finished, a coverage state may be appended — see `coverageAnalysisSuffix` * - If finished normally: "Finished {date} ({sha})" * - No analysis data: dim "Never" */ @@ -1073,10 +1300,23 @@ export function formatAnalysisStatus(opts: { commitSha: string; startedAnalysis?: string; endedAnalysis?: string; - expectsCoverage: boolean; - hasCoverageData: boolean; + /** + * Authoritative `Coverage.status` — repository endpoints only. Takes + * precedence over the `expectsCoverage`/`hasCoverageData` heuristic below. + */ + coverageStatus?: CoverageStatus; + /** Heuristic fallback, pull requests only. See `coverageAnalysisSuffix`. */ + expectsCoverage?: boolean; + hasCoverageData?: boolean; }): string { - const { commitSha, startedAnalysis, endedAnalysis, expectsCoverage, hasCoverageData } = opts; + const { + commitSha, + startedAnalysis, + endedAnalysis, + coverageStatus, + expectsCoverage, + hasCoverageData, + } = opts; const shortSha = commitSha.substring(0, 7); if (!startedAnalysis && !endedAnalysis) { @@ -1096,15 +1336,14 @@ export function formatAnalysisStatus(opts: { const finishedDate = formatFriendlyDate(endedAnalysis); const base = `Finished ${finishedDate} (${shortSha})`; - if (expectsCoverage && !hasCoverageData) { - const hoursSinceFinish = differenceInHours(new Date(), parseISO(endedAnalysis)); - if (hoursSinceFinish <= COVERAGE_REPORTS_WAIT_HOURS) { - return `${base} — ${ansis.blueBright("Waiting for coverage reports...")}`; - } - return `${base} — ${ansis.yellow("Missing coverage reports")}`; - } + const coverageSuffix = coverageAnalysisSuffix({ + coverageStatus, + expectsCoverage, + hasCoverageData, + endedAnalysis, + }); - return base; + return coverageSuffix ? `${base} — ${coverageSuffix}` : base; } return ansis.dim("Never");