From 863e202cb0f2b2f6ec355af33c23bd26df19dc91 Mon Sep 17 00:00:00 2001 From: Daniel Martin <56157528+dmartinochoa@users.noreply.github.com> Date: Tue, 19 May 2026 14:42:36 +0200 Subject: [PATCH 1/2] feat: pipelineCheck.scanWorkspace + refresh-as-scan (R10, R15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-v0.2.0 `scan-workspace` branch was stranded with merge conflicts in package.json and src/extension.ts. This commit re-applies the work cleanly on top of current main, taking advantage of the v0.2.0 infrastructure (providers.ts as the single source of truth, the shared test stub, the title-case command convention). What it does - New `pipelineCheck.scanWorkspace` command walks every CI/config file in the workspace (matching the same patterns the LSP's documentSelector uses), opens each via openTextDocument so the LSP's didOpen pipeline produces diagnostics, and lets the Findings panel re-render as publishes arrive. Progress toast with cancellation; per-file failures are counted but never abort the whole scan. - `Refresh Findings` (the title-bar button) now triggers a real scan instead of re-rendering the tree from already-published diagnostics. Matches the user's mental model: a refresh icon should fetch fresh data, not re-paint stale data. (R10) - `onCommand:pipelineCheck.scanWorkspace` activation event is auto-generated by VS Code from the contributes.commands entry — no explicit declaration needed in activationEvents. (R15 closed by VS Code's own behaviour.) What it ties together - workspaceScan.ts imports TRIGGER_PATTERNS from providers.ts, so the documentSelector, the activationEvents, and the workspace scan all read from one list. No drift. - The scan command appears at three surfaces: a `$(play)` button on the Findings view title bar (group navigation@0, leftmost), the Command Palette, and a link in the Findings welcome state. Files - src/workspaceScan.ts: re-applied with TRIGGER_PATTERNS import. - src/workspaceScan.test.ts: 8 tests for buildScanGlob + formatSummary (the pattern-list assertion was redundant with providers.test.ts — dropped). - src/extension.ts: registerCommand for both scanWorkspace and the re-pointed findings.refresh. - package.json: command, view/title button (group 0, leftmost), Command Palette entry, welcome-state link. - CHANGELOG.md: new Unreleased block with R10 + R15 entries. - src/test/integration/activation.test.ts: command-registration expected list grows by pipelineCheck.scanWorkspace. Total: 107 unit tests pass (was 99). Lint, compile, smoke, integration-compile all green. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 26 ++++++ package.json | 13 ++- src/extension.ts | 14 ++- src/test/integration/activation.test.ts | 1 + src/workspaceScan.test.ts | 63 +++++++++++++ src/workspaceScan.ts | 118 ++++++++++++++++++++++++ 6 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 src/workspaceScan.test.ts create mode 100644 src/workspaceScan.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c3ba549..dd9a3c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,32 @@ versions follow [SemVer](https://semver.org/). > section **above** Unreleased, or remove the Unreleased block for the > release commit. Otherwise the GitHub release ships boilerplate. +## [Unreleased] + +### Added + +- **`Pipeline-Check: Scan Workspace` command.** Walks every CI/config + file in the workspace (matching the same patterns the LSP's + `documentSelector` uses), opens each via + `vscode.workspace.openTextDocument` so the LSP's `didOpen` pipeline + picks them up, and lets the Findings panel re-render from the + diagnostic stream as scans complete. Progress toast with + cancellation; partial failures (read errors, unsupported encodings) + are counted but don't abort the scan. Surfaced from a `$(play)` + button on the Findings view title bar, the Command Palette, and a + link in the Findings welcome state. (R10, R15) + +### Changed + +- **"Refresh Findings" now triggers a real scan** instead of just + re-painting the tree from already-published diagnostics. Matches + the user's mental model: clicking a refresh icon should fetch new + data, not re-render stale data. (R10) +- **`SCAN_PATTERNS` removed in favour of `TRIGGER_PATTERNS`** from + `providers.ts`. The single source of truth for which files are + CI-relevant now drives the documentSelector, the activationEvents, + and the workspace scan — three surfaces that used to drift apart. + ## [0.2.0] — 2026-05-19 Closes 24 of 29 items from the 2026-05-19 in-depth UX/code review. diff --git a/package.json b/package.json index 3b72605..41eacef 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "viewsWelcome": [ { "view": "pipelineCheck.findings", - "contents": "Pipeline-Check scans CI/CD configurations against OWASP Top 10 CI/CD risks and 14 other compliance frameworks.\n\nRequires Python + `pipeline-check[lsp]` on PATH.\n[Copy install command](command:pipelineCheck.copyInstallCommand)\n\nOpen a workflow, Dockerfile, or other supported config and findings will appear here.\n\nPress `Alt+F8` (or `Shift+Alt+F8`) to step through findings in the editor.\n\n---\n\nNot seeing expected findings?\n[Restart Language Server](command:pipelineCheck.restart) · [Open Log](command:pipelineCheck.showLog)" + "contents": "Pipeline-Check scans CI/CD configurations against OWASP Top 10 CI/CD risks and 14 other compliance frameworks.\n\nRequires Python + `pipeline-check[lsp]` on PATH.\n[Copy install command](command:pipelineCheck.copyInstallCommand)\n\n[Scan workspace](command:pipelineCheck.scanWorkspace) to populate this panel without opening every file by hand. Otherwise findings show up as you open supported configs.\n\nPress `Alt+F8` (or `Shift+Alt+F8`) to step through findings in the editor.\n\n---\n\nNot seeing expected findings?\n[Restart Language Server](command:pipelineCheck.restart) · [Open Log](command:pipelineCheck.showLog)" } ], "commands": [ @@ -104,6 +104,12 @@ "title": "Copy LSP Install Command", "category": "Pipeline-Check" }, + { + "command": "pipelineCheck.scanWorkspace", + "title": "Scan Workspace", + "category": "Pipeline-Check", + "icon": "$(play)" + }, { "command": "pipelineCheck.findings.refresh", "title": "Refresh Findings", @@ -151,6 +157,11 @@ ], "menus": { "view/title": [ + { + "command": "pipelineCheck.scanWorkspace", + "when": "view == pipelineCheck.findings", + "group": "navigation@0" + }, { "command": "pipelineCheck.findings.changeGrouping", "when": "view == pipelineCheck.findings", diff --git a/src/extension.ts b/src/extension.ts index 08d135d..03d095a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -29,6 +29,7 @@ import { } from "./providers"; import { filterByThreshold } from "./severityFilter"; import { registerStatusBar } from "./statusBar"; +import { scanWorkspace } from "./workspaceScan"; // Group-mode options offered by the Findings panel's "Change // Grouping" button. Labels are user-facing; descriptions are the @@ -283,8 +284,19 @@ export async function activate( ); context.subscriptions.push( findingsView, + // Workspace scan: open every candidate file so the LSP runs its + // didOpen pipeline on each. Findings panel updates as the server + // publishes; no extra state to manage. + vscode.commands.registerCommand("pipelineCheck.scanWorkspace", () => + scanWorkspace(), + ), + // "Refresh Findings" was historically a tree-only re-render. Now + // that we have a real scan command, refresh runs an actual scan so + // the button matches the user's mental model — clicking "refresh" + // should fetch fresh data, not re-paint stale data. The tree + // updates automatically as scan publishes arrive (R10). vscode.commands.registerCommand("pipelineCheck.findings.refresh", () => - findingsProvider.refresh(), + scanWorkspace(), ), vscode.commands.registerCommand( "pipelineCheck.findings.changeGrouping", diff --git a/src/test/integration/activation.test.ts b/src/test/integration/activation.test.ts index 351b6dd..9db8645 100644 --- a/src/test/integration/activation.test.ts +++ b/src/test/integration/activation.test.ts @@ -34,6 +34,7 @@ suite("Pipeline-Check — activation", () => { "pipelineCheck.restart", "pipelineCheck.showLog", "pipelineCheck.copyInstallCommand", + "pipelineCheck.scanWorkspace", "pipelineCheck.findings.refresh", "pipelineCheck.findings.changeGrouping", "pipelineCheck.findings.copyRuleId", diff --git a/src/workspaceScan.test.ts b/src/workspaceScan.test.ts new file mode 100644 index 0000000..c7653e1 --- /dev/null +++ b/src/workspaceScan.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("vscode", async () => { + const { vscodeStub } = await import("./__testStubs__/vscode"); + return vscodeStub(); +}); + +import { TRIGGER_PATTERNS } from "./providers"; +import { buildScanGlob, formatSummary } from "./workspaceScan"; + +// Pure-logic surface of workspaceScan.ts. The async scan itself is +// hard to unit-test without the VS Code host (findFiles + progress + +// openTextDocument), but everything below covers the bits where a +// regression would silently change behaviour: the buildScanGlob shape +// and the user-facing summary copy. The pattern list itself is +// asserted in providers.test.ts (single source of truth) so we don't +// re-verify it here. + +describe("buildScanGlob", () => { + it("wraps the patterns in a single brace-glob VS Code accepts", () => { + expect(buildScanGlob(["**/a", "**/b"])).toBe("{**/a,**/b}"); + }); + + it("defaults to TRIGGER_PATTERNS when no argument is passed", () => { + expect(buildScanGlob()).toBe(`{${TRIGGER_PATTERNS.join(",")}}`); + }); + + it("handles a single pattern without dangling commas", () => { + expect(buildScanGlob(["**/x"])).toBe("{**/x}"); + }); +}); + +describe("formatSummary", () => { + it("clean run: 'scanned N files'", () => { + expect(formatSummary({ scanned: 5, failed: 0, cancelled: false })).toBe( + "Pipeline-Check: scanned 5 files.", + ); + }); + + it("singular form for one file", () => { + expect(formatSummary({ scanned: 1, failed: 0, cancelled: false })).toBe( + "Pipeline-Check: scanned 1 file.", + ); + }); + + it("reports failures separately from scans", () => { + expect(formatSummary({ scanned: 4, failed: 1, cancelled: false })).toBe( + "Pipeline-Check: scanned 4 files (1 failed).", + ); + }); + + it("cancelled run carries the partial count", () => { + expect(formatSummary({ scanned: 3, failed: 0, cancelled: true })).toBe( + "Pipeline-Check: scan cancelled after 3 files (0 failed).", + ); + }); + + it("zero-scan clean run still reads naturally", () => { + expect(formatSummary({ scanned: 0, failed: 0, cancelled: false })).toBe( + "Pipeline-Check: scanned 0 files.", + ); + }); +}); diff --git a/src/workspaceScan.ts b/src/workspaceScan.ts new file mode 100644 index 0000000..9bd49d9 --- /dev/null +++ b/src/workspaceScan.ts @@ -0,0 +1,118 @@ +// Workspace-wide scan, surfaced as the `pipelineCheck.scanWorkspace` +// command. The LSP server analyzes files on `didOpen`, so a "scan +// everything" boils down to enumerating the candidate file set and +// loading each document. The Findings panel reads from the diagnostic +// stream the server publishes back, so the tree fills in as each scan +// completes — no separate state to manage, and no extra serializer to +// keep in sync with the upstream CLI's JSON output. +// +// The candidate patterns come from providers.ts so the +// documentSelector, activationEvents, and this scan stay in lockstep +// (R14 — single source of truth). + +import * as vscode from "vscode"; + +import { TRIGGER_PATTERNS } from "./providers"; + +// Common heavy directories that should never carry a real workflow file +// (we still match `.github/workflows/*` if it lives under one, but +// dependency caches and build artefacts cost us nothing to skip). +const EXCLUDE_GLOB = + "**/{node_modules,.git,dist,out,target,build,.venv,venv,.tox,.cache}/**"; + +/** Combine the candidate patterns into a single brace-glob VS Code accepts. */ +export function buildScanGlob( + patterns: readonly string[] = TRIGGER_PATTERNS, +): string { + return `{${patterns.join(",")}}`; +} + +export interface ScanResult { + readonly scanned: number; + readonly failed: number; + readonly cancelled: boolean; +} + +/** + * Walk the workspace, open every candidate document, and let the LSP's + * `didOpen` pipeline produce diagnostics. Returns a summary the caller + * surfaces via a toast. + * + * Files that fail to load (read errors, unsupported encodings) are + * counted but never abort the scan — one bad file shouldn't hide + * findings in the other 49. + */ +export async function scanWorkspace(): Promise { + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length === 0) { + void vscode.window.showInformationMessage( + "Pipeline-Check: open a workspace folder before scanning.", + ); + return { scanned: 0, failed: 0, cancelled: false }; + } + + const uris = await vscode.workspace.findFiles(buildScanGlob(), EXCLUDE_GLOB); + + if (uris.length === 0) { + void vscode.window.showInformationMessage( + "Pipeline-Check: no scannable files found in this workspace.", + ); + return { scanned: 0, failed: 0, cancelled: false }; + } + + let scanned = 0; + let failed = 0; + let cancelled = false; + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Pipeline-Check: scanning workspace", + cancellable: true, + }, + async (progress, token) => { + const step = 100 / uris.length; + for (const uri of uris) { + if (token.isCancellationRequested) { + cancelled = true; + break; + } + progress.report({ + message: `${scanned + failed + 1}/${uris.length} · ${vscode.workspace.asRelativePath(uri)}`, + increment: step, + }); + try { + // Loading the document drives the LSP's `didOpen`. The + // server picks up the file, publishes diagnostics, and the + // Findings panel re-renders from the diagnostic stream. + // openTextDocument does not steal editor focus — it only + // makes the document part of `workspace.textDocuments`. + await vscode.workspace.openTextDocument(uri); + scanned += 1; + } catch { + failed += 1; + } + } + }, + ); + + const summary = formatSummary({ scanned, failed, cancelled }); + if (cancelled || failed > 0) { + void vscode.window.showWarningMessage(summary); + } else { + void vscode.window.showInformationMessage(summary); + } + return { scanned, failed, cancelled }; +} + +/** Human-readable summary of a scan run. Exported for unit testing. */ +export function formatSummary(r: ScanResult): string { + const file = (n: number) => `${n} file${n === 1 ? "" : "s"}`; + if (r.cancelled) { + return `Pipeline-Check: scan cancelled after ${file(r.scanned)} (${r.failed} failed).`; + } + if (r.failed > 0) { + return `Pipeline-Check: scanned ${file(r.scanned)} (${r.failed} failed).`; + } + return `Pipeline-Check: scanned ${file(r.scanned)}.`; +} From 0a6927aedf12168df4e212aa6b155357a3f92ca4 Mon Sep 17 00:00:00 2001 From: Daniel Martin <56157528+dmartinochoa@users.noreply.github.com> Date: Tue, 19 May 2026 14:55:36 +0200 Subject: [PATCH 2/2] ux: status bar severity color + what's-new notification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on scan-workspace-v2 (#19). Two UX improvements from the post-v0.2.0 review. 1. Status bar severity color - pickBackgroundColor returns statusBarItem.errorBackground (red) when CRITICAL is present, statusBarItem.warningBackground (yellow) for HIGH-without-CRITICAL, undefined for MEDIUM/LOW/INFO. Same ThemeColor tokens ESLint and Error Lens use, so the visual language reads correctly across themes. - The default fg colour for medium/low/info keeps a "1 medium" workspace from shouting. 2. What's-new notification (src/whatsNew.ts) - First activation after a version bump shows a one-time toast. "See release notes" opens the matching GitHub release URL via vscode.env.openExternal. - isUpgrade compares the manifest version against the value stashed in globalState; strips a leading 'v' and any pre-release suffix (-rc.1) so a stable release after an rc doesn't re-trigger. - The seen-version persists BEFORE the notification fires, so a missed dismissal doesn't loop next launch. - composeMessage and isUpgrade are pure helpers; the test file pins every documented invariant. Also riding along (user-intentional edits already in the working tree) - package.json: dropped onStartupFinished from activationEvents — the activity-bar slot only appears in CI-relevant workspaces, matching the status bar's already-quieter visibility policy. - package.json: expanded untrustedWorkspaces.description to explain the machine-overridable scope on serverCommand/serverArgs. Better copy for the trust prompt VS Code shows on first open. - README.md: Socket supply-chain badge added. Tests - statusBar.test.ts: 5 new tests for pickBackgroundColor (critical / high-without-critical / medium-only / clean / mixed). Updated the inline vscode stub to expose ThemeColor. - whatsNew.test.ts (new): 17 tests across isUpgrade (first install, major/minor/patch upgrades, downgrade, equal, pre-release strip, leading-v strip, malformed prev), composeMessage (version interpolation, mentions every surface), and showWhatsNewIfUpgraded (shows on first install, skips on match, persists before showing, opens URL on click, doesn't open on dismiss, supports custom openExternal for tests). Total: 129 unit tests (was 107 on PR #19; +5 status bar, +17 whatsNew). Lint, compile, smoke all green. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 13 +++ README.md | 1 + package.json | 3 +- src/extension.ts | 8 ++ src/statusBar.test.ts | 96 ++++++++++++++++++-- src/statusBar.ts | 25 ++++++ src/whatsNew.test.ts | 202 ++++++++++++++++++++++++++++++++++++++++++ src/whatsNew.ts | 90 +++++++++++++++++++ 8 files changed, 429 insertions(+), 9 deletions(-) create mode 100644 src/whatsNew.test.ts create mode 100644 src/whatsNew.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dd9a3c0..02c17a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,19 @@ versions follow [SemVer](https://semver.org/). ### Added +- **Status bar background colour reflects severity.** A workspace with + any CRITICAL finding tints the bar to `statusBarItem.errorBackground` + (red in the default themes); a workspace with HIGH but no CRITICAL + tints to `statusBarItem.warningBackground` (yellow). MEDIUM / LOW / + INFO keep the default fg colour so a "1 medium" workspace doesn't + shout. Same ThemeColor tokens ESLint and Error Lens use, so the + visual language reads correctly to any existing VS Code user. +- **"What's new" notification on upgrade.** First activation after a + version bump shows a one-time toast — "Pipeline-Check 0.X.Y is here + …" — with a "See release notes" button that opens the matching + GitHub release. Persists the seen-version *before* showing so a + missed dismissal doesn't loop next launch. Suppressed when the + stored version equals the manifest version (same launch). - **`Pipeline-Check: Scan Workspace` command.** Walks every CI/config file in the workspace (matching the same patterns the LSP's `documentSelector` uses), opens each via diff --git a/README.md b/README.md index 27b429c..8cbacc5 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![VS Code Marketplace](https://vsmarketplacebadges.dev/version-short/greylag-ci.pipeline-check.svg)](https://marketplace.visualstudio.com/items?itemName=greylag-ci.pipeline-check) [![Open VSX](https://img.shields.io/open-vsx/v/greylag-ci/pipeline-check?label=open%20vsx)](https://open-vsx.org/extension/greylag-ci/pipeline-check) [![Installs](https://vsmarketplacebadges.dev/installs-short/greylag-ci.pipeline-check.svg)](https://marketplace.visualstudio.com/items?itemName=greylag-ci.pipeline-check) +[![Socket](https://socket.dev/api/badge/openvsx/package/greylag-ci.pipeline-check)](https://socket.dev/openvsx/package/greylag-ci.pipeline-check/overview) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![CodeRabbit](https://img.shields.io/coderabbit/prs/github/greylag-ci/pipeline-check-vscode?labelColor=171717&color=FF570A&label=CodeRabbit+Reviews)](https://coderabbit.ai) diff --git a/package.json b/package.json index 41eacef..93c74c6 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,6 @@ "homepage": "https://github.com/greylag-ci/pipeline-check-vscode#readme", "main": "./dist/extension.js", "activationEvents": [ - "onStartupFinished", "workspaceContains:**/.github/workflows/*.yml", "workspaceContains:**/.github/workflows/*.yaml", "workspaceContains:**/.gitlab-ci.yml", @@ -59,7 +58,7 @@ "capabilities": { "untrustedWorkspaces": { "supported": "limited", - "description": "Pipeline-Check spawns the configured Python interpreter to analyze workflow files. In untrusted workspaces the extension stays inactive until the workspace is trusted." + "description": "Pipeline-Check spawns the configured Python interpreter (defaults to `python -m pipeline_check.lsp`) to analyze workflow files. The `serverCommand` and `serverArgs` settings — the only inputs that influence what gets executed — are scoped `machine-overridable`, so a workspace cannot silently override them; VS Code surfaces an explicit prompt before any workspace-level value is applied. No other workspace-controlled input reaches a process-spawning code path." }, "virtualWorkspaces": false }, diff --git a/src/extension.ts b/src/extension.ts index 03d095a..5991c95 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -30,6 +30,7 @@ import { import { filterByThreshold } from "./severityFilter"; import { registerStatusBar } from "./statusBar"; import { scanWorkspace } from "./workspaceScan"; +import { showWhatsNewIfUpgraded } from "./whatsNew"; // Group-mode options offered by the Findings panel's "Change // Grouping" button. Labels are user-facing; descriptions are the @@ -376,6 +377,13 @@ export async function activate( ); await startClient(); + + // Fire-and-forget the one-time "what's new" toast for users who + // just upgraded. Detached so a not-yet-dismissed notification never + // blocks activation (same lesson as the LSP-failure toast). The + // function persists the seen-version before showing, so a missed + // notification doesn't repeat next launch. + void showWhatsNewIfUpgraded(context, context.extension.packageJSON.version); } export async function deactivate(): Promise { diff --git a/src/statusBar.test.ts b/src/statusBar.test.ts index 7bed029..261184e 100644 --- a/src/statusBar.test.ts +++ b/src/statusBar.test.ts @@ -2,13 +2,21 @@ import { describe, it, expect, vi } from "vitest"; // statusBar.ts imports `vscode` for the runtime wiring; the pure // helpers (formatStatusBarText, formatStatusBarTooltip, -// countDiagnostics) don't touch it but the module-level import has to -// resolve. Tiny stub covers it. -vi.mock("vscode", () => ({ - StatusBarAlignment: { Left: 1, Right: 2 }, - window: {}, - languages: {}, -})); +// countDiagnostics, pickBackgroundColor) don't touch it but the +// module-level import has to resolve. Tiny stub covers it; ThemeColor +// is a class so `new vscode.ThemeColor(id)` works and tests can read +// `.id` off the result. +vi.mock("vscode", () => { + class ThemeColor { + constructor(public readonly id: string) {} + } + return { + ThemeColor, + StatusBarAlignment: { Left: 1, Right: 2 }, + window: {}, + languages: {}, + }; +}); import { countDiagnostics, @@ -218,3 +226,77 @@ describe("formatStatusBarTooltip", () => { expect(tip).not.toContain("Alt+F8"); }); }); + +describe("pickBackgroundColor", () => { + // The stub vscode module returns `{ id }` as the ThemeColor — the + // tests check the colour by id rather than relying on identity. + // We need ThemeColor to be available in the stub for this test; + // statusBar.test.ts's existing minimal stub doesn't include it. + // Below we re-import the function through the same minimal stub + // (vi.mock at the top of this file maps `vscode` to the inline + // object), so we read .id off whatever shape it returns. + + // Pull the function lazily so the vi.mock at the top is already in + // place when it resolves. + async function pick(c: import("./statusBar").SeverityCounts) { + const mod = await import("./statusBar"); + return mod.pickBackgroundColor(c); + } + + it("returns the error-background token when CRITICAL is present", async () => { + const bg = (await pick({ + CRITICAL: 1, + HIGH: 0, + MEDIUM: 0, + LOW: 0, + INFO: 0, + })) as { id: string } | undefined; + expect(bg?.id).toBe("statusBarItem.errorBackground"); + }); + + it("CRITICAL outranks HIGH for the colour choice", async () => { + const bg = (await pick({ + CRITICAL: 1, + HIGH: 5, + MEDIUM: 0, + LOW: 0, + INFO: 0, + })) as { id: string } | undefined; + expect(bg?.id).toBe("statusBarItem.errorBackground"); + }); + + it("returns the warning-background token when HIGH (but no CRITICAL) is present", async () => { + const bg = (await pick({ + CRITICAL: 0, + HIGH: 3, + MEDIUM: 0, + LOW: 0, + INFO: 0, + })) as { id: string } | undefined; + expect(bg?.id).toBe("statusBarItem.warningBackground"); + }); + + it("returns undefined when only MEDIUM / LOW / INFO are present", async () => { + expect( + await pick({ + CRITICAL: 0, + HIGH: 0, + MEDIUM: 4, + LOW: 9, + INFO: 2, + }), + ).toBeUndefined(); + }); + + it("returns undefined on a clean workspace", async () => { + expect( + await pick({ + CRITICAL: 0, + HIGH: 0, + MEDIUM: 0, + LOW: 0, + INFO: 0, + }), + ).toBeUndefined(); + }); +}); diff --git a/src/statusBar.ts b/src/statusBar.ts index 705437a..abc3684 100644 --- a/src/statusBar.ts +++ b/src/statusBar.ts @@ -154,6 +154,30 @@ function readSeverity(diag: vscode.Diagnostic): SeverityName { } } +/** + * Pick the status bar's background color from the per-severity tally. + * + * - any CRITICAL → `statusBarItem.errorBackground` (red) + * - any HIGH → `statusBarItem.warningBackground` (yellow) + * - everything else → `undefined` (default fg, blends with the bar) + * + * The two named ThemeColor tokens are VS Code's standard status-bar + * severity colors — ESLint and Error Lens use the same ones, so the + * visual language reads correctly to existing VS Code users without + * any per-theme custom CSS. + */ +export function pickBackgroundColor( + c: SeverityCounts, +): vscode.ThemeColor | undefined { + if (c.CRITICAL > 0) { + return new vscode.ThemeColor("statusBarItem.errorBackground"); + } + if (c.HIGH > 0) { + return new vscode.ThemeColor("statusBarItem.warningBackground"); + } + return undefined; +} + // File patterns that suggest the current workspace is worth showing // the status bar in. Mirrors providers.ts's TRIGGER_PATTERNS — kept // inline here so the status bar can ship without a circular import @@ -198,6 +222,7 @@ export function registerStatusBar( item.accessibilityInformation = { label: formatStatusBarAccessibilityLabel(counts), }; + item.backgroundColor = pickBackgroundColor(counts); const total = counts.CRITICAL + counts.HIGH + counts.MEDIUM + counts.LOW + counts.INFO; if (total > 0) relevant = true; diff --git a/src/whatsNew.test.ts b/src/whatsNew.test.ts new file mode 100644 index 0000000..49ea2bd --- /dev/null +++ b/src/whatsNew.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// whatsNew.ts touches vscode.window.showInformationMessage and +// vscode.env.openExternal at runtime. The unit-test stub captures +// calls so we can assert which button was offered and whether the +// release-notes URL was opened. +vi.mock("vscode", () => { + const calls: { + showInformationMessage: Array<{ message: string; actions: string[] }>; + openExternal: string[]; + } = { + showInformationMessage: [], + openExternal: [], + }; + // Resolves with the value tests stash on `globalThis.__nextChoice`. + ( + globalThis as { __whatsNewCalls?: typeof calls } + ).__whatsNewCalls = calls; + + class Uri { + constructor(public readonly raw: string) {} + static parse(s: string) { + return new Uri(s); + } + } + return { + Uri, + window: { + showInformationMessage: (message: string, ...actions: string[]) => { + calls.showInformationMessage.push({ message, actions }); + const next = (globalThis as { __nextChoice?: string }).__nextChoice; + return Promise.resolve(next); + }, + }, + env: { + openExternal: (uri: { raw: string }) => { + calls.openExternal.push(uri.raw); + return Promise.resolve(true); + }, + }, + }; +}); + +import { + composeMessage, + isUpgrade, + showWhatsNewIfUpgraded, +} from "./whatsNew"; + +function fakeContext(stored?: string) { + const state: Record = {}; + if (stored !== undefined) state["pipelineCheck.lastSeenVersion"] = stored; + return { + globalState: { + get(key: string): T | undefined { + return state[key] as T | undefined; + }, + async update(key: string, value: unknown): Promise { + state[key] = value; + }, + }, + } as unknown as import("vscode").ExtensionContext; +} + +function getCalls() { + return ( + globalThis as { + __whatsNewCalls?: { + showInformationMessage: Array<{ message: string; actions: string[] }>; + openExternal: string[]; + }; + } + ).__whatsNewCalls!; +} + +beforeEach(() => { + const c = getCalls(); + c.showInformationMessage.length = 0; + c.openExternal.length = 0; + (globalThis as { __nextChoice?: string }).__nextChoice = undefined; +}); + +// ─── isUpgrade ───────────────────────────────────────────────────── + +describe("isUpgrade", () => { + it("returns true on first install (no stored version)", () => { + expect(isUpgrade(undefined, "0.2.0")).toBe(true); + }); + + it("returns true when next > prev on major", () => { + expect(isUpgrade("0.9.9", "1.0.0")).toBe(true); + }); + + it("returns true when next > prev on minor", () => { + expect(isUpgrade("0.1.5", "0.2.0")).toBe(true); + }); + + it("returns true on a patch bump", () => { + expect(isUpgrade("0.2.0", "0.2.1")).toBe(true); + }); + + it("returns false on equal versions", () => { + expect(isUpgrade("0.2.0", "0.2.0")).toBe(false); + }); + + it("returns false on a downgrade", () => { + expect(isUpgrade("0.2.0", "0.1.5")).toBe(false); + }); + + it("strips pre-release suffixes so v0.2.0-rc.1 doesn't re-trigger after v0.2.0 stable", () => { + expect(isUpgrade("0.2.0-rc.1", "0.2.0")).toBe(false); + expect(isUpgrade("0.2.0", "0.2.0-rc.2")).toBe(false); + }); + + it("strips a leading 'v' if present", () => { + expect(isUpgrade("v0.1.0", "0.2.0")).toBe(true); + }); + + it("treats malformed prev (empty / garbage) as 'older than anything'", () => { + expect(isUpgrade("", "0.0.1")).toBe(true); + }); +}); + +// ─── composeMessage ──────────────────────────────────────────────── + +describe("composeMessage", () => { + it("interpolates the version into the message", () => { + expect(composeMessage("0.2.0")).toContain("0.2.0"); + }); + + it("mentions every new headline surface", () => { + const msg = composeMessage("0.2.0"); + expect(msg).toContain("Findings panel"); + expect(msg).toContain("status bar"); + expect(msg).toContain("CodeLens"); + expect(msg).toContain("Alt+F8"); + }); +}); + +// ─── showWhatsNewIfUpgraded ───────────────────────────────────────── + +describe("showWhatsNewIfUpgraded", () => { + it("shows the toast on first install", async () => { + const ctx = fakeContext(undefined); + await showWhatsNewIfUpgraded(ctx, "0.2.0"); + expect(getCalls().showInformationMessage).toHaveLength(1); + expect(getCalls().showInformationMessage[0].actions).toEqual([ + "See release notes", + "Got it", + ]); + }); + + it("does NOT show the toast when versions match", async () => { + const ctx = fakeContext("0.2.0"); + await showWhatsNewIfUpgraded(ctx, "0.2.0"); + expect(getCalls().showInformationMessage).toHaveLength(0); + }); + + it("persists the new version before the toast resolves (so a missed click doesn't re-trigger next launch)", async () => { + const ctx = fakeContext("0.1.5"); + // Don't pick anything; let the promise resolve with undefined. + await showWhatsNewIfUpgraded(ctx, "0.2.0"); + expect(ctx.globalState.get("pipelineCheck.lastSeenVersion")).toBe( + "0.2.0", + ); + }); + + it('opens the release-notes URL when the user picks "See release notes"', async () => { + (globalThis as { __nextChoice?: string }).__nextChoice = + "See release notes"; + const ctx = fakeContext("0.1.5"); + await showWhatsNewIfUpgraded(ctx, "0.2.0"); + expect(getCalls().openExternal).toEqual([ + "https://github.com/greylag-ci/pipeline-check-vscode/releases/tag/v0.2.0", + ]); + }); + + it('does not open anything when the user picks "Got it"', async () => { + (globalThis as { __nextChoice?: string }).__nextChoice = "Got it"; + const ctx = fakeContext("0.1.5"); + await showWhatsNewIfUpgraded(ctx, "0.2.0"); + expect(getCalls().openExternal).toEqual([]); + }); + + it("allows the caller to override the URL opener (for tests / telemetry)", async () => { + (globalThis as { __nextChoice?: string }).__nextChoice = + "See release notes"; + const ctx = fakeContext("0.1.5"); + const visited: string[] = []; + await showWhatsNewIfUpgraded(ctx, "0.2.0", { + openExternal: async (url) => { + visited.push(url); + return true; + }, + }); + expect(visited).toEqual([ + "https://github.com/greylag-ci/pipeline-check-vscode/releases/tag/v0.2.0", + ]); + // And the default opener stayed unused. + expect(getCalls().openExternal).toEqual([]); + }); +}); diff --git a/src/whatsNew.ts b/src/whatsNew.ts new file mode 100644 index 0000000..a8df9a1 --- /dev/null +++ b/src/whatsNew.ts @@ -0,0 +1,90 @@ +// "What's new in this version" one-time notification. Fires the first +// time the user activates a freshly-installed major/minor upgrade so +// they can find out the new surfaces (Findings panel, status bar, +// CodeLens, Alt+F8 nav, etc.) without reading the CHANGELOG. +// +// The check compares the running `extension.packageJSON.version` +// against the value stashed in `context.globalState` from the prior +// activation. On a first install or after a real upgrade, the values +// differ and the notification fires; otherwise it's silent. Patch +// bumps (0.2.0 → 0.2.1) also trigger because patches sometimes fix +// user-visible bugs; if the noise turns out to be excessive we can +// gate to minor / major bumps only. + +import * as vscode from "vscode"; + +const STATE_KEY = "pipelineCheck.lastSeenVersion"; + +/** + * Compare two semver strings — returns true if `next` is later than + * `prev`, false otherwise (including equal). Tolerates undefined / + * malformed prev (treated as "older than anything"). Strips + * pre-release suffixes (`-rc.1`) to keep the comparison about the + * semver core. + */ +export function isUpgrade(prev: string | undefined, next: string): boolean { + if (!prev) return true; + const parse = (v: string) => + v + .replace(/^v/, "") + .split("-")[0] + .split(".") + .map((s) => parseInt(s, 10) || 0); + const a = parse(prev); + const b = parse(next); + for (let i = 0; i < 3; i++) { + const av = a[i] ?? 0; + const bv = b[i] ?? 0; + if (bv > av) return true; + if (bv < av) return false; + } + return false; +} + +/** + * Build the notification message. Exported for unit testing; the + * `version` is whatever the manifest reports at activation time. + */ +export function composeMessage(version: string): string { + return `Pipeline-Check ${version} is here. The Findings panel, status bar item, inline CodeLens, and Alt+F8 navigation are new — see what changed?`; +} + +/** + * Surface the upgrade notification asynchronously. Returns the chosen + * action (or undefined when there's nothing to show / the user + * dismissed). The function never throws on missing globalState — a + * fresh extension host with no state still gets the first-install + * notification. + */ +export async function showWhatsNewIfUpgraded( + context: vscode.ExtensionContext, + manifestVersion: string, + options: { + /** Override the default "open release notes" target. Tests pass a noop. */ + openExternal?: (url: string) => Thenable; + } = {}, +): Promise { + const prev = context.globalState.get(STATE_KEY); + if (!isUpgrade(prev, manifestVersion)) { + return undefined; + } + // Persist the new version first so a notification that gets ignored + // (user clicks elsewhere, VS Code closes) still doesn't repeat next + // session. The cost of "user missed the notification once" is lower + // than the cost of "user sees it every launch until they engage". + await context.globalState.update(STATE_KEY, manifestVersion); + + const SEE_RELEASE = "See release notes"; + const DISMISS = "Got it"; + const choice = await vscode.window.showInformationMessage( + composeMessage(manifestVersion), + SEE_RELEASE, + DISMISS, + ); + if (choice === SEE_RELEASE) { + const url = `https://github.com/greylag-ci/pipeline-check-vscode/releases/tag/v${manifestVersion}`; + const open = options.openExternal ?? ((u) => vscode.env.openExternal(vscode.Uri.parse(u))); + await open(url); + } + return choice; +}