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; +}