From d5467d67eff05eadf01d660c4e96139fb706ade9 Mon Sep 17 00:00:00 2001 From: Alejandro Rizzo Date: Thu, 10 Sep 2026 16:05:59 +0100 Subject: [PATCH 1/3] feat: surface coverage status in repositories and repository The API now returns a CoverageStatus (None/UpToDate/Waiting/Stopped) on Coverage, so the CLI can tell apart three situations that previously looked identical: a stale percentage, a repository that stopped receiving reports, and one that never had coverage at all. Mirrors codacy-spa#3110. The field rides on Coverage, embedded only in RepositoryWithAnalysis, so `repositories` and `repository` are the only commands that can show it. `repositories` marks a Waiting value with a dim glyph and replaces a Stopped one (the API sends no percentage) with its own, explained by a legend that only lists the statuses actually present. `repository`'s Metrics row spells the state out with dates and commit, and notes when a stopped repository's coverage gate is no longer enforced. Also replaces formatAnalysisStatus's coverage heuristic for `repository`. It inferred state from a separate listCoverageReports call plus "is a percentage present", which was wrong for Waiting -- a waiting repository still reports a stale percentage, so the row stayed silent in exactly the case worth surfacing. Reading the real status drops that request, and because getRepositoryWithAnalysis is whitelisted for repository tokens while listCoverageReports is not, coverage state reaches repository-token users for the first time. `pull-request` keeps the heuristic: its coverage models carry no status field. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/coverage-status-in-repo-metrics.md | 28 ++ README.md | 2 +- SPECS/README.md | 5 +- SPECS/commands/analysis.md | 64 +++- SPECS/commands/pull-request.md | 2 +- SPECS/commands/repositories.md | 16 +- SPECS/commands/repository.md | 22 +- SPECS/repository-tokens.md | 29 +- src/commands/AGENTS.md | 60 +++- src/commands/repositories.test.ts | 137 ++++++++ src/commands/repositories.ts | 50 ++- src/commands/repository.test.ts | 158 +++++++++- src/commands/repository.ts | 60 ++-- src/utils/formatting.test.ts | 292 +++++++++++++++++- src/utils/formatting.ts | 228 +++++++++++++- 15 files changed, 1025 insertions(+), 128 deletions(-) create mode 100644 .changeset/coverage-status-in-repo-metrics.md 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/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..8737208 100644 --- a/src/commands/AGENTS.md +++ b/src/commands/AGENTS.md @@ -97,11 +97,55 @@ 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. + +- **Only `Waiting` and `Stopped` are decorated** (the same two the SPA flags). + `UpToDate`, `None`, 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. The `formatRepoCoverage*` unit tests assert byte-identical output + against `colorMetric` for precisely this reason. +- **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 +156,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 +255,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/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..98468d3 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,149 @@ 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("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 +1106,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 +1115,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 +1154,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..a0ade79 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,216 @@ 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 }; + +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("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 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..f7bdd47 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,151 @@ 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 = "⊘"; + +/** Dim table glyph for a coverage status, or undefined when there's nothing to flag. */ +export function coverageStatusGlyph(coverage?: Coverage): string | undefined { + switch (coverage?.status) { + case "Waiting": + return ansis.dim(COVERAGE_WAITING_GLYPH); + case "Stopped": + return ansis.dim(COVERAGE_STOPPED_GLYPH); + default: + return undefined; // UpToDate | None | 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); + if (coverage?.status === "Stopped") return glyph!; + const value = colorMetric(coverage?.coveragePercentage, threshold, "min"); + return glyph ? `${value} ${glyph}` : value; +} + +/** + * 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 present = new Set(coverages.map((c) => c?.status)); + const lines: string[] = []; + if (present.has("Waiting")) { + lines.push( + ansis.dim( + `${COVERAGE_WAITING_GLYPH} no coverage report for the latest commit yet — showing the last known value`, + ), + ); + } + 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)})` + : ""; + const from = 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. + if (coverage?.status === "Stopped" || coverage?.status === "None") { + 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 +1203,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 +1253,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 +1261,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 +1297,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"); From a20d3806c87f27fd23bd6988ccd9a327be7d3070 Mon Sep 17 00:00:00 2001 From: Alejandro Rizzo Date: Thu, 10 Sep 2026 16:20:18 +0100 Subject: [PATCH 2/3] fix: don't promise a coverage value that isn't rendered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a pre-review pass over the branch. A `Waiting` payload with no `coveragePercentage` rendered as "N/A ⋯" under a legend reading "showing the last known value", and as "N/A Not reported yet ... — value from 11h ago" in the detail view — both describing a number that isn't on screen. Observed payloads always carry a stale percentage, but the field is documented as present only for the latest commit, which a waiting repository by definition doesn't have, so gate both claims on a value actually being there rather than on the status alone. Also replaces the two non-null assertions with `&& glyph` / `&& note` guards, so a missing marker degrades to the ordinary metric instead of printing the string "undefined", and adds COVERAGE_STATUS_GLYPH as a Record. The four renderers all fall through to "render nothing", so a new status member from `npm run update-api` would have compiled clean and silently disappeared; now it fails to compile at the Record. AGENTS.md claimed `None` renders "exactly as before", which is true of the table but not the detail view — the same PR deliberately renders it as "Not set up". Corrected, since that file is loaded as project instructions and would have talked the next agent out of intended behavior. Co-Authored-By: Claude Opus 5 (1M context) --- src/commands/AGENTS.md | 20 ++++++++-- src/utils/formatting.test.ts | 35 ++++++++++++++++++ src/utils/formatting.ts | 71 ++++++++++++++++++++++++++++-------- 3 files changed, 106 insertions(+), 20 deletions(-) diff --git a/src/commands/AGENTS.md b/src/commands/AGENTS.md index 8737208..8ba8bb1 100644 --- a/src/commands/AGENTS.md +++ b/src/commands/AGENTS.md @@ -106,12 +106,24 @@ on `Coverage`, which is embedded only in `RepositoryWithAnalysis` — so only `PullRequestCoverage`/`DiffCoverage`; neither carries a status, and there is nothing to add there. -- **Only `Waiting` and `Stopped` are decorated** (the same two the SPA flags). - `UpToDate`, `None`, an undefined `status` and an absent `coverage` object must +- **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. The `formatRepoCoverage*` unit tests assert byte-identical output - against `colorMetric` for precisely this reason. + 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 diff --git a/src/utils/formatting.test.ts b/src/utils/formatting.test.ts index a0ade79..700e8c6 100644 --- a/src/utils/formatting.test.ts +++ b/src/utils/formatting.test.ts @@ -620,6 +620,16 @@ const covStopped = { }; 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", () => { @@ -692,6 +702,23 @@ describe("coverageStatusLegend", () => { 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, @@ -714,6 +741,14 @@ describe("coverageStatusNote", () => { 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"); diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts index f7bdd47..4c6f718 100644 --- a/src/utils/formatting.ts +++ b/src/utils/formatting.ts @@ -465,16 +465,29 @@ export function colorMetric( 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 { - switch (coverage?.status) { - case "Waiting": - return ansis.dim(COVERAGE_WAITING_GLYPH); - case "Stopped": - return ansis.dim(COVERAGE_STOPPED_GLYPH); - default: - return undefined; // UpToDate | None | 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; } /** @@ -491,11 +504,22 @@ export function formatRepoCoverageCell( threshold: number | undefined, ): string { const glyph = coverageStatusGlyph(coverage); - if (coverage?.status === "Stopped") return glyph!; + // `&& 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. @@ -504,12 +528,19 @@ export function formatRepoCoverageCell( 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 (present.has("Waiting")) { + 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 — showing the last known value`, + `${COVERAGE_WAITING_GLYPH} no coverage report for the latest commit yet${suffix}`, ), ); } @@ -548,9 +579,12 @@ export function coverageStatusNote( const commit = coverage.lastCommitWithCoverage ? ` (${shortSha(coverage.lastCommitWithCoverage)})` : ""; - const from = coverage.valueUpdatedAt - ? ` — value from ${formatFriendlyDate(coverage.valueUpdatedAt)}${commit}` - : ""; + // 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": { @@ -588,8 +622,13 @@ export function formatRepoCoverageDetail( }); // `Stopped` carries no percentage and `None` never had one; in both cases the // note says strictly more than a bare "N/A" would. - if (coverage?.status === "Stopped" || coverage?.status === "None") { - return note!; + // `&& 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; From 6770d254e74f2d4e76dbb4c8ab4fe440b78f4cc4 Mon Sep 17 00:00:00 2001 From: Alejandro Rizzo Date: Thu, 10 Sep 2026 16:23:57 +0100 Subject: [PATCH 3/3] test: pin the two behaviors the Codacy review flagged as unverified Both gaps were real. `pull-request` is now `formatAnalysisStatus`'s only caller of the expectsCoverage/hasCoverageData heuristic, but its tests only ever mocked hasCoverageOverview: false, so the hint path was never exercised at the command level and nothing would have failed if someone deleted the heuristic outright. Adds a test that drives it past the 3h grace period and asserts "Missing coverage reports", pinning the threshold rather than just that some hint appears. The status-less dashboard case was only covered incidentally (the shared repo fixture happens to carry no status). Since "no coverage hint when status is undefined" is a deliberate behavior removal, it now has an explicit test rather than resting on a fixture detail. Also broadens the test-file bullet in the Codacy review instructions. It already said length and duplication findings on *.test.ts are expected, but the reviewer suggested splitting formatting.test.ts by theme, which the wording didn't cover. Tests are co-located one-per-module by design, so a helper's tests belong beside its source however long that file grows. Co-Authored-By: Claude Opus 5 (1M context) --- .codacy/instructions/review.md | 6 +++- src/commands/pull-request.test.ts | 52 +++++++++++++++++++++++++++++++ src/commands/repository.test.ts | 18 +++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) 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/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/repository.test.ts b/src/commands/repository.test.ts index 98468d3..3263e67 100644 --- a/src/commands/repository.test.ts +++ b/src/commands/repository.test.ts @@ -632,6 +632,24 @@ describe("repository command", () => { 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