From fd81246c36e7240bc03f4a67387df560706c8e9d Mon Sep 17 00:00:00 2001 From: Pedro Pereira Date: Wed, 23 Sep 2026 15:56:57 +0100 Subject: [PATCH] feat: Show image tag usage and per-image tag count OD-748 images shows per-image tag counts and org tag usage vs. limit (OD-724, API 57.6.4). The --keep-latest budget warning reads that limit instead of a hardcoded 1,000. --- .changeset/images-tag-usage.md | 5 +++ SPECS/README.md | 2 +- SPECS/commands/images.md | 23 ++++++-------- package.json | 2 +- src/commands/AGENTS.md | 11 ++++--- src/commands/image.test.ts | 57 +++++++++++++++++++++++++++++++--- src/commands/image.ts | 44 +++++++++++--------------- src/commands/images.test.ts | 51 ++++++++++++++++++++++++++++-- src/commands/images.ts | 36 +++++++++++++++------ src/utils/formatting.test.ts | 9 ++++++ src/utils/formatting.ts | 8 +++++ 11 files changed, 183 insertions(+), 65 deletions(-) create mode 100644 .changeset/images-tag-usage.md diff --git a/.changeset/images-tag-usage.md b/.changeset/images-tag-usage.md new file mode 100644 index 0000000..4b83651 --- /dev/null +++ b/.changeset/images-tag-usage.md @@ -0,0 +1,5 @@ +--- +"@codacy/codacy-cloud-cli": minor +--- + +`images` now shows how many tags each image holds, and the organization's image tag usage against its limit. `--output json` includes `tagCount` per image. The `image --delete --keep-latest` warning now uses the organization's actual tag limit instead of assuming 1,000. diff --git a/SPECS/README.md b/SPECS/README.md index dd96b69..b4eb236 100644 --- a/SPECS/README.md +++ b/SPECS/README.md @@ -8,7 +8,6 @@ This is the single source of truth for all project tasks and specs. | Task | Spec | Notes | |---|---|---| -| Tag count on `ImageSummary` | [images.md](commands/images.md) | **Backend**: add a tag count to `listOrganizationImages`' response so `images` can show a Tags column without one extra request per image. Deriving it client-side was deliberately dropped from OD-710's first PR | | Resolve the per-image tag budget | [images.md](commands/images.md) | The cap is org-wide and counts image × tag rows; `--keep-latest` is per image, so the safe ceiling is `cap ÷ images`. Four options, none chosen: `_main_/projects/container-tagging-guidance/research/per-image-tag-budget.md`. The CLI warns today; it does not solve it | ## Command Inventory @@ -49,6 +48,7 @@ This is the single source of truth for all project tasks and specs. | Date | What was done | |---|---| +| 2026-09-23 | (OD-748) `images` shows a **Tags** column (per-image `tagCount`) and an `Image tags: X of Y used` line under the header, read from `listOrganizationImages`' new `usage` object (OD-724, API 57.6.4 — `fetch-api` bumped from 57.4.17). Still one request per page. The line turns red at the cap, where new tags are rejected. Exact figures, not `formatCount`. `--output json` stays an array and gains `tagCount` only; `usage` is not in the JSON, since adding it would change the top-level shape The `image --delete --keep-latest` budget warning now reads that same `usage.limit` from the image-count request it already made, in place of the hardcoded `DEFAULT_ORG_TAG_CAP` (1,000), and drops its "this CLI cannot read the value in force" disclaimer Both commands tolerate a response without `usage`/`tagCount` (an API behind the client), and the exact-figure formatter is now shared as `formatExactCount` (5 new tests, 784 total) | | 2026-09-22 | (OD-710) **Fix: path parameters are now escaped per segment.** Every `image` subcommand 404'd against a namespaced image name — `codacy/codacy-website`, the shape of all seven images in `gh/codacy` — because the generated client falls back to `encodeURI` when `OpenAPI.ENCODE_PATH` is unset, and `encodeURI` leaves `/` intact by design: it encodes whole URLs, not the segments they are built from. The value expanded into two segments and hit a route that does not exist; verified against the API, where the raw slash returns 404 and `%2F` returns 200. `src/utils/api-path.ts` exports `encodePathSegment` (`encodeURIComponent`) and `src/index.ts` installs it beside `OpenAPI.BASE` — the generated client is untouched, so `npm run update-api` cannot undo it. The encoder is global, which is correct rather than incidental: `{branchName}` and `{filePath}` carry slashes for the same reason, though no shipped command sends either as a path parameter today, so nothing else changes shape. Confirmed end to end against `gh/codacy` after the fix: `images` lists 7, `image codacy/codacy-website` lists 84 tags, `--delete --keep-latest 10 --dry-run` reports 74 of 84 (5 new tests, 767 total) | | 2026-02-17 | Project setup: Vitest, `--output json`, `src/index.ts` cleaned up | | 2026-02-17 | `info` command + tests (4 tests) | diff --git a/SPECS/commands/images.md b/SPECS/commands/images.md index aaa9ed3..d39642b 100644 --- a/SPECS/commands/images.md +++ b/SPECS/commands/images.md @@ -57,15 +57,13 @@ cross-cutting `repository-token-refusals.test.ts`, not per-command suites. |---|---| | `-n, --limit ` | max images to return (default 100, max 1000) | -Columns: Image, Latest Tag, Last Upload, Last Generated. JSON projects the same -four fields. +Columns: Image, Tags, Latest Tag, Last Upload, Last Generated. JSON projects +the same five fields (`tagCount` for Tags). -**No tag count here, deliberately.** It is the number an org at the cap actually -wants — "which image is holding 85 tags" — but `ImageSummary` doesn't carry one, -and deriving it client-side costs one extra request per image. It is being added -to the endpoint server-side instead (pending task in `SPECS/README.md`); when it -lands it becomes a column with no extra call. Until then the per-image count is -what `codacy image ` shows. Do not reintroduce a fan-out here. +Above the table: `Image tags: of used`, from the response's +`usage` (OD-724), red once usage reaches the limit. Both the per-image +`tagCount` and `usage` come with the listing itself, so this stays one request +per page. Do not reintroduce a per-image fan-out to `listImageTags`. ## `image ` (alias `img`) @@ -246,12 +244,9 @@ It **warns, it does not refuse**, and it does not pick between the four options that file leaves open — that design is unowned, and a CLI warning is the smallest thing that honours the rule without pre-empting it. -`DEFAULT_ORG_TAG_CAP` is 1,000 but **the cap is configuration** -(`sbom.image.max-image-tags-per-org`, `reference.conf:115`; the test default is -100) and no endpoint exposes the value in force, so the copy says "default" and -admits the CLI cannot read it. If an endpoint ever returns it, read it instead. -A `--cap ` flag is the obvious escape hatch; not added because nobody asked -for it. +The cap is configuration (`sbom.image.max-image-tags-per-org`), so it is read +from the same response's `usage.limit` (OD-724), never hardcoded. If that lookup +fails, the warning is skipped rather than guessed. ## Sanitization diff --git a/package.json b/package.json index 4b31794..aaca4ca 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "prepublishOnly": "npm run update-api && npm run build", "start": "npx ts-node src/index.ts", "start:dist": "node dist/index.js", - "fetch-api": "curl https://artifacts.codacy.com/api/codacy-api/57.5.12/apiv3-bundled.yaml -o ./api-v3/api-swagger.yaml --create-dirs", + "fetch-api": "curl https://artifacts.codacy.com/api/codacy-api/57.6.4/apiv3-bundled.yaml -o ./api-v3/api-swagger.yaml --create-dirs", "generate-api": "rm -rf ./src/api/client && openapi --input ./api-v3/api-swagger.yaml --output ./src/api/client --useUnionTypes --indent 2 --client fetch", "update-api": "npm run fetch-api && npm run generate-api", "check-types": "tsc --noEmit" diff --git a/src/commands/AGENTS.md b/src/commands/AGENTS.md index 92559c0..c750751 100644 --- a/src/commands/AGENTS.md +++ b/src/commands/AGENTS.md @@ -474,11 +474,12 @@ the parts that constrain future edits: the repository-token whitelist, so both call `resolveAccountAuth(this, …)` and refuse before any request. Their refusal cases live in the cross-cutting `repository-token-refusals.test.ts`, not in their own suites. -- **`images` must stay one request per page.** The tag count is the number an - org at the 1000-tag cap wants, but `ImageSummary` doesn't carry one and - deriving it costs an extra request per image. It is being added server-side - (pending task in `SPECS/README.md`) — don't reintroduce a client-side fan-out - to fake it in the meantime. +- **`images` must stay one request per page.** Per-image `tagCount` and the + org-wide `usage` (tags vs. limit) both come on `listOrganizationImages` — + never fan out to `listImageTags` per image. Treat both as optional at runtime + even though the client types them required: a CLI released ahead of the API + must still list images, just without the usage line and counts. Tag figures + go through `formatExactCount` (`utils/formatting.ts`), never `formatCount`. - **`--delete` is the action, `--tag` is the scope.** Same split as `issues --ignore` and its filters: one verb, narrowed by the same flag that narrows the read. `--tag` alone shows that tag; `--delete` alone takes the diff --git a/src/commands/image.test.ts b/src/commands/image.test.ts index feb3979..a9f4edf 100644 --- a/src/commands/image.test.ts +++ b/src/commands/image.test.ts @@ -575,6 +575,7 @@ describe("image command", () => { vi.mocked(SbomService.listOrganizationImages).mockResolvedValue({ data: [], pagination: { total: 7 }, + usage: { imageTags: 70, limit: 1000 }, } as any); vi.mocked(SbomService.deleteImageTag).mockResolvedValue(undefined as any); }); @@ -688,10 +689,12 @@ describe("image command", () => { expect(output).toContain("Dry run"); }); - it("warns when n x the org's image count exceeds the default cap", async () => { + it("warns when n x the org's image count exceeds its tag limit", async () => { + // Not 1,000, so a hardcoded fallback cap can't pass this. vi.mocked(SbomService.listOrganizationImages).mockResolvedValue({ data: [], pagination: { total: 212 }, + usage: { imageTags: 400, limit: 1500 }, } as any); vi.mocked(SbomService.listImageTags).mockResolvedValue({ data: tagsUploadedOn([1, 2, 3]), @@ -708,9 +711,9 @@ describe("image command", () => { // Never a constant n without the image count beside it. expect(output).toContain("this organization has 212 images"); expect(output).toContain("holds 2,120 image tags"); - // Exact figures, not formatCount's "2.1k"/"1k". - expect(output).toContain("cap of 1,000"); - expect(output).toContain("allows 4 per image"); + // Exact figures, not formatCount's "2.1k"/"1.5k". + expect(output).toContain("cap of 1,500"); + expect(output).toContain("allows 7 per image"); }); it("stays quiet about the budget when n x images fits the cap", async () => { @@ -725,7 +728,27 @@ describe("image command", () => { "--delete", "--keep-latest", "10", "--dry-run", ]); - expect(getAllOutput()).not.toContain("above the default organization cap"); + expect(getAllOutput()).not.toContain("above the organization cap"); + }); + + it("skips the budget warning, not the cleanup, when the budget lookup fails", async () => { + vi.mocked(SbomService.listOrganizationImages).mockRejectedValue( + new Error("boom"), + ); + vi.mocked(SbomService.listImageTags).mockResolvedValue({ + data: tagsUploadedOn([1, 2, 3]), + pagination: {}, + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "image", "gh", "test-org", "my-service", + "--delete", "--keep-latest", "1", "--dry-run", + ]); + + const output = getAllOutput(); + expect(output).toContain("Dry run"); + expect(output).not.toContain("organization cap"); }); it("carries on past a failed delete and exits non-zero", async () => { @@ -988,6 +1011,30 @@ describe("image command", () => { }); }); + it("keeps the image count when the response has no usage", async () => { + // An API older than the client: no `usage`, so no budget warning, but the + // image count it does carry must still be reported. + vi.mocked(SbomService.listOrganizationImages).mockResolvedValue({ + data: [], + pagination: { total: 7 }, + } as any); + vi.mocked(SbomService.listImageTags).mockResolvedValue({ + data: tagsUploadedOn([1, 2, 3]), + pagination: {}, + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "--output", "json", + "image", "gh", "test-org", "my-service", + "--delete", "--keep-latest", "1", "--dry-run", + ]); + + const result = JSON.parse(getAllOutput()); + expect(result.organizationImageCount).toBe(7); + expect(result.warning).toBeUndefined(); + }); + it("outputs one JSON document naming the deletions that actually happened", async () => { vi.mocked(SbomService.listImageTags).mockResolvedValue({ data: tagsUploadedOn([1, 2, 3]), diff --git a/src/commands/image.ts b/src/commands/image.ts index 55a5b6e..b0a1a0f 100644 --- a/src/commands/image.ts +++ b/src/commands/image.ts @@ -15,6 +15,7 @@ import { printPaginationWarning, } from "../utils/output"; import { confirmAction } from "../utils/prompt"; +import { formatExactCount as exact } from "../utils/formatting"; import { sanitizeText } from "../utils/sanitize"; import { formatCount, printSection } from "../utils/formatting"; import { SbomService } from "../api/client/services/SbomService"; @@ -23,16 +24,6 @@ import type { ImageTagSummary } from "../api/client/models/ImageTagSummary"; const MAX_LIMIT = 1000; const PAGE_SIZE = 100; -/** - * The organization-wide image-tag cap this CLI assumes when warning about - * `--keep-latest`. It is **configuration, not a constant** - * (`sbom.image.max-image-tags-per-org`, `reference.conf:115`; the test default - * is 100) and no API endpoint exposes the value in force, so the warning says - * "default" rather than stating it as fact. If an endpoint ever returns it, - * read it instead of this. - */ -const DEFAULT_ORG_TAG_CAP = 1000; - const ABORT_HINT = "Pass --skip-confirmation (-y) to bypass this prompt in CI or scripts."; @@ -515,7 +506,8 @@ function parseKeepLatest(value: string): number { } /** - * How many images the organization holds, or `undefined` when the lookup fails. + * How many images the organization holds and its tag limit, or `undefined` + * when the lookup fails. * * Only used to qualify `--keep-latest`: the cap is organization-wide and counts * image x tag rows, while this flag is per image, so `n` is only safe as @@ -523,10 +515,10 @@ function parseKeepLatest(value: string): number { * more than double it for a 212-image one. One request (`limit: 1`, for * `pagination.total`), never a fan-out. */ -async function fetchImageCount( +async function fetchOrgImageBudget( provider: string, organization: string, -): Promise { +): Promise<{ imageCount?: number; tagLimit?: number } | undefined> { try { const response = await SbomService.listOrganizationImages( provider, @@ -534,7 +526,10 @@ async function fetchImageCount( undefined, // cursor 1, ); - return response.pagination?.total; + return { + imageCount: response.pagination?.total, + tagLimit: response.usage?.limit, + }; } catch { return undefined; } @@ -551,23 +546,19 @@ async function fetchImageCount( function orgBudgetWarning( keepLatest: number, imageCount: number | undefined, + tagLimit: number | undefined, ): string | undefined { - if (imageCount === undefined || imageCount === 0) return undefined; + if (!imageCount || tagLimit === undefined) return undefined; const projected = keepLatest * imageCount; - if (projected <= DEFAULT_ORG_TAG_CAP) return undefined; + if (projected <= tagLimit) return undefined; - const safePerImage = Math.floor(DEFAULT_ORG_TAG_CAP / imageCount); - // Exact numbers, not `formatCount`: its abbreviation turns the cap everyone - // quotes into "1k" and the projection into "2.1k", which is the wrong - // register for the two figures the reader is being asked to compare. - const exact = (n: number) => n.toLocaleString("en-US"); + const safePerImage = Math.floor(tagLimit / imageCount); return ( `Warning: this organization has ${exact(imageCount)} ${pluralize("image", imageCount)}. ` + `Keeping ${exact(keepLatest)} tags on each holds ${exact(projected)} image tags, ` + - `above the default organization cap of ${exact(DEFAULT_ORG_TAG_CAP)} — past which new tags are ` + + `above the organization cap of ${exact(tagLimit)} — past which new tags are ` + `rejected and those images stop being scanned. ` + - `At this image count the cap allows ${exact(safePerImage)} per image. ` + - `(The cap is configurable; this CLI cannot read the value in force.)` + `At this image count the cap allows ${exact(safePerImage)} per image.` ); } @@ -604,7 +595,8 @@ async function planKeepLatest( // Every page: a tag on page 3 is just as deletable as one on page 1, and a // partial view would silently keep tags the user asked to remove. const { tags } = await fetchTags(provider, organization, image); - const imageCount = await fetchImageCount(provider, organization); + const budget = await fetchOrgImageBudget(provider, organization); + const imageCount = budget?.imageCount; spinner.stop(); // Newest first. `uploadedAt` is when Codacy received the SBOM, which is what @@ -619,7 +611,7 @@ async function planKeepLatest( kept: ordered.slice(0, keepLatest), doomed: ordered.slice(keepLatest), imageCount, - budgetWarning: orgBudgetWarning(keepLatest, imageCount), + budgetWarning: orgBudgetWarning(keepLatest, imageCount, budget?.tagLimit), }; } diff --git a/src/commands/images.test.ts b/src/commands/images.test.ts index 11b9f1c..7d14740 100644 --- a/src/commands/images.test.ts +++ b/src/commands/images.test.ts @@ -24,6 +24,7 @@ function getAllOutput(): string { function mockImage(overrides: Record = {}) { return { imageName: "my-service", + tagCount: 12, latestTag: "1.2.3", lastSbomUploaded: "2025-06-14T10:00:00Z", lastSbomGenerated: "2025-06-14T09:00:00Z", @@ -31,6 +32,8 @@ function mockImage(overrides: Record = {}) { }; } +const usage = { imageTags: 750, limit: 1000 }; + describe("images command", () => { beforeEach(() => { vi.clearAllMocks(); @@ -45,6 +48,7 @@ describe("images command", () => { mockImage({ imageName: "worker", latestTag: "sha-abc" }), ], pagination: { total: 2 }, + usage, } as any); const program = createProgram(); @@ -62,21 +66,54 @@ describe("images command", () => { expect(output).toContain("my-service"); expect(output).toContain("worker"); expect(output).toContain("1.2.3"); + expect(output).toContain("Image tags: 750 of 1,000 used"); + const row = output.split("\n").find((line) => line.includes("my-service"))!; + expect(row).toContain("12"); + }); + + it("flags an organization at the tag cap", async () => { + vi.mocked(SbomService.listOrganizationImages).mockResolvedValue({ + data: [mockImage()], + pagination: {}, + usage: { imageTags: 1000, limit: 1000 }, + } as any); + + const program = createProgram(); + await program.parseAsync(["node", "test", "images", "gh", "test-org"]); + + expect(getAllOutput()).toContain( + "Image tags: 1,000 of 1,000 used — new tags will be rejected", + ); + }); + + it("still lists images when the API omits usage and tag counts", async () => { + // A response from an API older than the client this was generated from. + vi.mocked(SbomService.listOrganizationImages).mockResolvedValue({ + data: [mockImage({ tagCount: undefined })], + pagination: {}, + } as any); + + const program = createProgram(); + await program.parseAsync(["node", "test", "images", "gh", "test-org"]); + + const output = getAllOutput(); + expect(output).toContain("my-service"); + expect(output).not.toContain("Image tags:"); + expect(output).not.toContain("undefined"); }); it("never fans out to the tags endpoint", async () => { - // The tag count is being added to `ImageSummary` server-side; this listing - // must stay one request. See the pending task in SPECS/README.md. + // The tag count comes from `ImageSummary`; this listing must stay one request. vi.mocked(SbomService.listOrganizationImages).mockResolvedValue({ data: [mockImage(), mockImage({ imageName: "worker" })], pagination: {}, + usage, } as any); const program = createProgram(); await program.parseAsync(["node", "test", "images", "gh", "test-org"]); expect(SbomService.listImageTags).not.toHaveBeenCalled(); - expect(getAllOutput()).not.toContain("Tags"); }); it("renders a dim dash for missing values", async () => { @@ -89,6 +126,7 @@ describe("images command", () => { }), ], pagination: {}, + usage, } as any); const program = createProgram(); @@ -110,10 +148,12 @@ describe("images command", () => { .mockResolvedValueOnce({ data: [mockImage()], pagination: { cursor: "next", total: 300 }, + usage, } as any) .mockResolvedValueOnce({ data: [mockImage({ imageName: "worker" })], pagination: { cursor: "more", total: 300 }, + usage, } as any); const program = createProgram(); @@ -146,6 +186,7 @@ describe("images command", () => { mockImage({ imageName: `svc-${i}` }), ), pagination: { cursor: "next", total: 99999 }, + usage, } as any; }) as any, ); @@ -166,6 +207,7 @@ describe("images command", () => { vi.mocked(SbomService.listOrganizationImages).mockResolvedValue({ data: [], pagination: {}, + usage, } as any); const program = createProgram(); @@ -178,6 +220,7 @@ describe("images command", () => { vi.mocked(SbomService.listOrganizationImages).mockResolvedValue({ data: [mockImage()], pagination: {}, + usage, } as any); const program = createProgram(); @@ -188,6 +231,7 @@ describe("images command", () => { expect(JSON.parse(getAllOutput())).toEqual([ { imageName: "my-service", + tagCount: 12, latestTag: "1.2.3", lastSbomUploaded: "2025-06-14T10:00:00Z", lastSbomGenerated: "2025-06-14T09:00:00Z", @@ -204,6 +248,7 @@ describe("images command", () => { }), ], pagination: {}, + usage, } as any); const program = createProgram(); diff --git a/src/commands/images.ts b/src/commands/images.ts index 077ae6b..7579a2c 100644 --- a/src/commands/images.ts +++ b/src/commands/images.ts @@ -13,9 +13,10 @@ import { printPaginationWarning, } from "../utils/output"; import { sanitizeText } from "../utils/sanitize"; -import { formatCount } from "../utils/formatting"; +import { formatCount, formatExactCount as exact } from "../utils/formatting"; import { SbomService } from "../api/client/services/SbomService"; import type { ImageSummary } from "../api/client/models/ImageSummary"; +import type { ImagesUsage } from "../api/client/models/ImagesUsage"; /** Matches `findings` — the API's own page size, and its ceiling. */ const MAX_LIMIT = 1000; @@ -76,8 +77,14 @@ async function fetchImages( provider: string, organization: string, limit: number, -): Promise<{ images: ImageSummary[]; cursor?: string; total?: number }> { +): Promise<{ + images: ImageSummary[]; + usage?: ImagesUsage; + cursor?: string; + total?: number; +}> { let images: ImageSummary[] = []; + let usage: ImagesUsage | undefined; let cursor: string | undefined; let total: number | undefined; @@ -89,22 +96,19 @@ async function fetchImages( Math.min(limit, PAGE_SIZE), ); images.push(...response.data); + usage = response.usage; cursor = response.pagination?.cursor; total = response.pagination?.total ?? total; } while (cursor && images.length < limit); if (images.length > limit) images = images.slice(0, limit); - return { images, cursor, total }; + return { images, usage, cursor, total }; } function renderImagesTable(images: ImageSummary[]): string { - // No tag count here: `ImageSummary` doesn't carry one, and deriving it - // would mean one extra request per image. It is being added server-side - // instead — see the pending task in SPECS/README.md. Until then, the - // per-image tag count is what `codacy image ` shows. const table = createTable({ - head: ["Image", "Latest Tag", "Last Upload", "Last Generated"], + head: ["Image", "Tags", "Latest Tag", "Last Upload", "Last Generated"], }); for (const image of images) { @@ -112,6 +116,7 @@ function renderImagesTable(images: ImageSummary[]): string { // upload) and reach the terminal — neutralize before styling. table.push([ sanitizeText(image.imageName), + image.tagCount === undefined ? ansis.dim("-") : exact(image.tagCount), image.latestTag ? sanitizeText(image.latestTag) : ansis.dim("-"), image.lastSbomUploaded ? formatFriendlyDate(image.lastSbomUploaded) @@ -137,7 +142,7 @@ async function listImages( ); const spinner = ora("Fetching images...").start(); - const { images, cursor, total } = await fetchImages( + const { images, usage, cursor, total } = await fetchImages( provider, organization, limit, @@ -149,7 +154,7 @@ async function listImages( return; } - printImages(provider, organization, images, total); + printImages(provider, organization, images, usage, total); printPaginationWarning( cursor ? { cursor, limit: images.length } : undefined, @@ -161,6 +166,7 @@ function printImages( provider: string, organization: string, images: ImageSummary[], + usage: ImagesUsage | undefined, total: number | undefined, ): void { if (images.length === 0) { @@ -178,6 +184,8 @@ function printImages( `\nImages for ${organization} (${provider}) — Found ${formatCount(imageTotal)} ${pluralize("image", imageTotal)}\n`, ), ); + // Optional at runtime: an API older than the one this client was built from omits it. + if (usage) console.log(formatUsage(usage) + "\n"); console.log(renderImagesTable(images)); console.log( ansis.dim( @@ -186,10 +194,18 @@ function printImages( ); } +function formatUsage({ imageTags, limit }: ImagesUsage): string { + const line = `Image tags: ${exact(imageTags)} of ${exact(limit)} used`; + return imageTags >= limit + ? ansis.red(`${line} — new tags will be rejected until some are deleted`) + : line; +} + /** The fields `--output json` promises, and only those. */ function projectImage(image: ImageSummary) { return pickDeep(image, [ "imageName", + "tagCount", "latestTag", "lastSbomUploaded", "lastSbomGenerated", diff --git a/src/utils/formatting.test.ts b/src/utils/formatting.test.ts index 700e8c6..e654cd1 100644 --- a/src/utils/formatting.test.ts +++ b/src/utils/formatting.test.ts @@ -10,6 +10,7 @@ import { formatDependencyChainsBlock, formatGrade, formatCountCell, + formatExactCount, formatCoverageCell, formatDelta, formatPrCoverage, @@ -290,6 +291,14 @@ describe("formatGrade", () => { }); }); +describe("formatExactCount", () => { + it("writes the count in full, never abbreviated", () => { + expect(formatExactCount(1000)).toBe("1,000"); + expect(formatExactCount(2120)).toBe("2,120"); + expect(formatExactCount(7)).toBe("7"); + }); +}); + describe("formatCountCell", () => { it("abbreviates a count", () => { expect(formatCountCell(1200)).toBe("1.2k"); diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts index bc095e7..5a60588 100644 --- a/src/utils/formatting.ts +++ b/src/utils/formatting.ts @@ -348,6 +348,14 @@ export function formatCount(n: number): string { return numeral(n).format("0.[0]a"); } +/** + * Format a count in full with thousands separators (1000 → "1,000"). For + * figures compared against a limit, where `formatCount`'s "1k" hides the gap. + */ +export function formatExactCount(n: number): string { + return n.toLocaleString("en-US"); +} + /** * Color a quality grade letter: A/B green, C yellow, D/E/F red, anything else * uncolored. Returns "N/A" when no grade is available. Codacy folder/file