From b150214f30b39b16d0cccf52c4dd839fa7107305 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Fri, 7 Aug 2026 15:28:29 -0400 Subject: [PATCH 01/17] feat(status): adds GitHub status service layer Fetches GitHub's status summary API, filters to five tracked components (Actions, API Requests, Git Operations, Issues, Pull Requests), blends severity, and exposes it via a SolidJS signal. Validates the third-party response shape with Zod and includes a live-network smoke test (excluded from the default suite) guarding against silent component-name drift. --- package.json | 1 + src/app/services/github-status.ts | 244 ++++++++++++++++++ tests/services/github-status.smoke.test.ts | 20 ++ tests/services/github-status.test.ts | 278 +++++++++++++++++++++ vitest.smoke.config.ts | 20 ++ vitest.workspace.ts | 2 +- 6 files changed, 564 insertions(+), 1 deletion(-) create mode 100644 src/app/services/github-status.ts create mode 100644 tests/services/github-status.smoke.test.ts create mode 100644 tests/services/github-status.test.ts create mode 100644 vitest.smoke.config.ts diff --git a/package.json b/package.json index a9b8308e..c0e99dcd 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "typecheck": "tsc --build src/shared/tsconfig.json && tsc --noEmit && tsc --build mcp/tsconfig.json", "test:e2e": "E2E_PORT=$(node -e \"const s=require('net').createServer();s.listen(0,()=>{console.log(s.address().port);s.close()})\") playwright test", "test:waf": "bash scripts/waf-smoke-test.sh", + "test:status-smoke": "vitest run --config vitest.smoke.config.ts", "screenshot": "pnpm exec playwright test --config playwright.config.screenshot.ts", "mcp:serve": "pnpm --filter github-tracker-mcp dev", "validate:deploy": "bash scripts/validate-deploy.sh" diff --git a/src/app/services/github-status.ts b/src/app/services/github-status.ts new file mode 100644 index 00000000..a095d4c6 --- /dev/null +++ b/src/app/services/github-status.ts @@ -0,0 +1,244 @@ +import { createSignal } from "solid-js"; +import { z } from "zod"; +import { cachedFetch } from "../stores/cache"; +import { dismissNotificationBySource, pushNotification } from "../lib/errors"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export type GitHubStatusSeverity = "none" | "minor" | "major" | "critical"; + +export interface GitHubStatusIncident { + id: string; + name: string; + latestUpdateBody: string; + affectedComponents: string[]; +} + +export interface GitHubStatusSummary { + severity: GitHubStatusSeverity; + incidents: GitHubStatusIncident[]; + fetchedAt: Date; +} + +// Minimal raw-API shapes actually consumed — not the full Statuspage schema. +// `impact`/`shortlink` are present on the live API but intentionally dropped: +// neither is consumed anywhere in this feature (no per-incident deep link, no +// in-app incident history). Types are derived from the Zod schemas below so +// validation and typing can never drift apart. +const RawComponentSchema = z.object({ + id: z.string(), + name: z.string(), + status: z.string(), +}); + +const RawIncidentSchema = z.object({ + id: z.string(), + name: z.string(), + incident_updates: z.array(z.object({ body: z.string() })), + components: z.array(RawComponentSchema), +}); + +const RawSummaryResponseSchema = z.object({ + components: z.array(RawComponentSchema), + incidents: z.array(RawIncidentSchema), +}); + +type RawComponent = z.infer; +type RawSummaryResponse = z.infer; + +// Exact component names as returned by the live API, verified 2026-08-07 via +// `curl https://www.githubstatus.com/api/v2/summary.json`. Component names are +// Statuspage-admin-configurable free text with no stability guarantee from +// GitHub/Atlassian — if GitHub renames a tracked component (e.g. "Actions" → +// "GitHub Actions"), TRACKED_COMPONENT_NAMES.has(c.name) silently stops matching +// it with no error, no test failure, and no user-visible signal beyond an +// incorrectly-green badge during a real outage. Guarded by +// tests/services/github-status.smoke.test.ts (live network, not part of `pnpm test`). +export const TRACKED_COMPONENT_NAMES = new Set([ + "Actions", + "API Requests", + "Git Operations", + "Issues", + "Pull Requests", +]); + +// Note: unlike poll.ts's resetPollState()/events.ts's resetEventsState(), this +// module deliberately does not hook into onAuthCleared. GitHub's own status is a +// global fact, not scoped to the authenticated user — it should persist across a +// logout/login (or a switch between users on the same browser) exactly as-is. + +// ── Severity mapping and blending ──────────────────────────────────────────── + +const COMPONENT_STATUS_SEVERITY: Record = { + operational: "none", + degraded_performance: "minor", + partial_outage: "major", + major_outage: "critical", +}; +const SEVERITY_RANK: Record = { none: 0, minor: 1, major: 2, critical: 3 }; + +// [ASSUMPTION: unrecognized component status strings default to "none" severity +// rather than throwing — treats unknown future Statuspage status values as +// non-blocking rather than failing the whole badge] +function blendSeverity(components: RawComponent[]): GitHubStatusSeverity { + return components.reduce((worst, c) => { + const s = COMPONENT_STATUS_SEVERITY[c.status] ?? "none"; + return SEVERITY_RANK[s] > SEVERITY_RANK[worst] ? s : worst; + }, "none"); +} + +// ── Notification state and transition tracking ─────────────────────────────── + +const NOTIFICATION_SOURCE = "github-status"; +const NOTIFICATION_SOURCE_RESOLVED = "github-status-resolved"; +let _previousIncidents = new Map(); // id -> name, across cycles + +// ── parseSummary / notifyTransitions ───────────────────────────────────────── + +function severityToNotificationLevel(s: GitHubStatusSeverity): "warning" | "error" { + return s === "critical" || s === "major" ? "error" : "warning"; +} + +// Incident update bodies are HTML per Statuspage's schema (confirmed live: contains +// literal `
` tags). Strip to plain text here so the badge component can render +// via plain JSX text interpolation (Security Flags item 2) without leaking literal +// tags in the UI. +function stripHtml(html: string): string { + return html + .replace(//gi, "\n") + .replace(/<\/p>/gi, "\n") + .replace(/<[^>]+>/g, "") + .trim(); +} + +// Pure: parses the raw API response and blends severity. No side effects, not +// exported — matches this codebase's established convention of never exporting +// internal parse/transform functions purely for direct unit testing (see +// src/app/services/api.ts's processIssueNode/mapCheckStatus/buildRepoQualifiers). +// Tested indirectly via fetchGitHubStatus. +function parseSummary(raw: RawSummaryResponse): GitHubStatusSummary { + const trackedComponents = raw.components.filter((c) => TRACKED_COMPONENT_NAMES.has(c.name)); + const severity = blendSeverity(trackedComponents); + + const relevantIncidents: GitHubStatusIncident[] = raw.incidents.flatMap((inc) => { + const affected = inc.components.filter((c) => TRACKED_COMPONENT_NAMES.has(c.name)); + if (affected.length === 0) return []; + return [{ + id: inc.id, + name: inc.name, + latestUpdateBody: stripHtml(inc.incident_updates[0]?.body ?? ""), + affectedComponents: affected.map((c) => c.name), + }]; + }); + + return { severity, incidents: relevantIncidents, fetchedAt: new Date() }; +} + +// Side-effecting: consumes a parsed summary and dispatches notification +// transitions. Mirrors the detectNewItems() (pure) / dispatchNotifications() +// (side-effecting) split already established in src/app/lib/notifications.ts. +// +// Both pushNotification calls pass retryable=false — an outage announcement is +// not a failed/retryable operation. Message text is just the incident name(s), +// not prefixed with "GitHub status: " — NotificationDrawer.tsx/ToastContainer.tsx +// already render `{source}: {message}`, so a message-level prefix would duplicate +// the "github-status" source label already shown. +// +// [ASSUMPTION: concurrent distinct incidents are blended into one "github-status" +// notification/badge state rather than tracked individually — matches the +// single-blended-badge UI decision, avoids a list of independent toasts for a +// rare edge case] +function notifyTransitions(summary: GitHubStatusSummary): void { + const currentIncidents = new Map(summary.incidents.map((i) => [i.id, i.name])); + + if (summary.incidents.length > 0) { + const names = summary.incidents.map((i) => i.name).join(", "); + pushNotification(NOTIFICATION_SOURCE, names, severityToNotificationLevel(summary.severity), false); + } else { + dismissNotificationBySource(NOTIFICATION_SOURCE); + } + + const resolvedNames = [..._previousIncidents.entries()] + .filter(([id]) => !currentIncidents.has(id)) + .map(([, name]) => name); + if (resolvedNames.length > 0) { + pushNotification(NOTIFICATION_SOURCE_RESOLVED, resolvedNames.join(", "), "info", false); + } + + _previousIncidents = currentIncidents; +} + +// ── fetchGitHubStatus — network call via cachedFetch, signal ──────────────── + +const STATUS_API_URL = "https://www.githubstatus.com/api/v2/summary.json"; +const CACHE_KEY = "github-status:summary"; + +const [_githubStatus, _setGitHubStatus] = createSignal(null); +export function getGitHubStatus(): GitHubStatusSummary | null { + return _githubStatus(); +} + +let _fetchInProgress = false; + +export async function fetchGitHubStatus(): Promise { + if (_fetchInProgress) return; // in-flight guard — avoid pile-up if refreshInterval is very short/0 or the endpoint is slow to respond + _fetchInProgress = true; + try { + const { data } = await cachedFetch(CACHE_KEY, async (headers) => { + const reqHeaders: Record = {}; + if (headers.etag) reqHeaders["If-None-Match"] = headers.etag; + const res = await fetch(STATUS_API_URL, { + headers: reqHeaders, + credentials: "omit", + cache: "no-store", + signal: AbortSignal.timeout(10_000), + }); + if (res.status === 304) { + return { data: null, etag: headers.etag, lastModified: headers.lastModified, status: 304 }; + } + const json = await res.json(); + return { data: json, etag: res.headers.get("ETag"), lastModified: res.headers.get("Last-Modified"), status: res.status }; + }); + + // Validate the raw shape before parsing — catches API drift (e.g. a field + // renamed/removed upstream) as a distinct, loud log message instead of an + // unexplained TypeError deep inside parseSummary. Treated the same as any + // other fetch failure: no throw, dismiss any active incident notification. + const validated = RawSummaryResponseSchema.safeParse(data); + if (!validated.success) { + console.warn("[github-status] response schema drift — validation failed:", validated.error); + dismissNotificationBySource(NOTIFICATION_SOURCE); + return; + } + + const summary = parseSummary(validated.data); + notifyTransitions(summary); + _setGitHubStatus(summary); + } catch (err) { + console.warn("[github-status] fetch failed:", err instanceof Error ? err.message : String(err)); + // Best-effort, ancillary external signal — deliberately no pushError, don't + // pollute the notification center with transient network blips for a + // non-critical feature the user can't act on anyway. Still DO dismiss any + // active "github-status" incident notification: notifyTransitions() owns + // this notification's entire lifecycle end-to-end, and poll.ts deliberately + // excludes github-status from poll-level reconciliation (POLL_MANAGED_SOURCES), + // so if this fetch starts failing persistently while an incident notification + // is showing, nothing else would ever clear it. A single fetch failure just + // means "we don't currently know," not "the incident is still ongoing," so + // unconditional dismiss-on-any-failure is preferred over tracking a + // consecutive-failure counter (simpler, guarantees no stuck state). + dismissNotificationBySource(NOTIFICATION_SOURCE); + } finally { + _fetchInProgress = false; + } +} + +// Test-only reset — mirrors resetPollState() (poll.ts), resetNotificationState() +// (lib/errors.ts), resetEventsState() (services/events.ts). Deliberately NOT +// wired to onAuthCleared (see note above: GitHub status is global, not +// user-scoped, and must survive logout/login). +export function resetGitHubStatusState(): void { + _previousIncidents = new Map(); + _setGitHubStatus(null); + _fetchInProgress = false; +} diff --git a/tests/services/github-status.smoke.test.ts b/tests/services/github-status.smoke.test.ts new file mode 100644 index 00000000..7d079218 --- /dev/null +++ b/tests/services/github-status.smoke.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { TRACKED_COMPONENT_NAMES } from "../../src/app/services/github-status"; + +// Live network smoke test — NOT part of `pnpm test` (excluded in vitest.workspace.ts). +// Run explicitly via `pnpm test:status-smoke`. Converts silent Statuspage +// component-name drift (see TRACKED_COMPONENT_NAMES's doc comment) into a loud, +// actionable CI failure instead of an undetected missed-outage bug. +describe("github-status live API shape (smoke)", () => { + it("all TRACKED_COMPONENT_NAMES are present in the live summary.json component list", async () => { + const res = await fetch("https://www.githubstatus.com/api/v2/summary.json"); + expect(res.ok).toBe(true); + + const json = (await res.json()) as { components: Array<{ name: string }> }; + const liveNames = new Set(json.components.map((c) => c.name)); + + for (const tracked of TRACKED_COMPONENT_NAMES) { + expect(liveNames.has(tracked)).toBe(true); + } + }); +}); diff --git a/tests/services/github-status.test.ts b/tests/services/github-status.test.ts new file mode 100644 index 00000000..8441a0d9 --- /dev/null +++ b/tests/services/github-status.test.ts @@ -0,0 +1,278 @@ +import "fake-indexeddb/auto"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { clearCache } from "../../src/app/stores/cache"; + +const mockPushNotification = vi.fn(); +const mockDismissNotificationBySource = vi.fn(); +vi.mock("../../src/app/lib/errors", () => ({ + pushNotification: (...args: unknown[]) => mockPushNotification(...args), + dismissNotificationBySource: (source: string) => mockDismissNotificationBySource(source), +})); + +import { + fetchGitHubStatus, + getGitHubStatus, + resetGitHubStatusState, + TRACKED_COMPONENT_NAMES, +} from "../../src/app/services/github-status"; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +function makeComponent(name: string, status = "operational") { + return { id: `id-${name.replace(/\s+/g, "-").toLowerCase()}`, name, status }; +} + +function makeIncident(overrides: { + id: string; + name: string; + body: string; + componentNames: string[]; + componentStatus?: string; +}) { + return { + id: overrides.id, + name: overrides.name, + incident_updates: [{ body: overrides.body }], + components: overrides.componentNames.map((n) => makeComponent(n, overrides.componentStatus ?? "major_outage")), + }; +} + +function makeSummary(overrides?: { components?: unknown[]; incidents?: unknown[] }) { + return { + page: { id: "test-page", name: "GitHub", url: "https://www.githubstatus.com" }, + status: { indicator: "none", description: "All Systems Operational" }, + components: overrides?.components ?? [...TRACKED_COMPONENT_NAMES].map((n) => makeComponent(n)), + incidents: overrides?.incidents ?? [], + }; +} + +function jsonResponse(body: unknown, init?: { status?: number; etag?: string }) { + const headers: Record = { "content-type": "application/json" }; + if (init?.etag) headers["ETag"] = init.etag; + return new Response(JSON.stringify(body), { status: init?.status ?? 200, headers }); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("fetchGitHubStatus", () => { + beforeEach(async () => { + await clearCache(); + resetGitHubStatusState(); + mockPushNotification.mockClear(); + mockDismissNotificationBySource.mockClear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("parses a successful 200 response into a GitHubStatusSummary", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary()))); + + await fetchGitHubStatus(); + + const result = getGitHubStatus(); + expect(result).not.toBeNull(); + expect(result!.severity).toBe("none"); + expect(result!.incidents).toEqual([]); + expect(result!.fetchedAt).toBeInstanceOf(Date); + }); + + it("blends to critical severity when Actions has a major_outage and other tracked components are operational, and notifies", async () => { + const components = [...TRACKED_COMPONENT_NAMES].map((n) => + makeComponent(n, n === "Actions" ? "major_outage" : "operational") + ); + const incidents = [makeIncident({ id: "inc-1", name: "Actions Outage", body: "Investigating", componentNames: ["Actions"] })]; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ components, incidents })))); + + await fetchGitHubStatus(); + + const result = getGitHubStatus(); + expect(result!.severity).toBe("critical"); + expect(result!.incidents).toHaveLength(1); + expect(mockPushNotification).toHaveBeenCalledWith("github-status", "Actions Outage", "error", false); + }); + + it("returns severity none with no incidents when all tracked components are operational", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary()))); + + await fetchGitHubStatus(); + + const result = getGitHubStatus(); + expect(result!.severity).toBe("none"); + expect(result!.incidents).toEqual([]); + }); + + it("excludes an incident whose only affected component is untracked (Copilot)", async () => { + const incidents = [makeIncident({ id: "inc-copilot", name: "Copilot Degraded", body: "Investigating", componentNames: ["Copilot"] })]; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents })))); + + await fetchGitHubStatus(); + + const result = getGitHubStatus(); + expect(result!.incidents).toEqual([]); + expect(result!.severity).toBe("none"); + expect(mockPushNotification).not.toHaveBeenCalledWith("github-status", expect.anything(), expect.anything(), expect.anything()); + }); + + it("severity isolation: an untracked component's major_outage does not blend into severity", async () => { + const components = [ + ...[...TRACKED_COMPONENT_NAMES].map((n) => makeComponent(n, "operational")), + makeComponent("Pages", "major_outage"), + ]; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ components, incidents: [] })))); + + await fetchGitHubStatus(); + + const result = getGitHubStatus(); + expect(result!.severity).toBe("none"); + expect(result!.incidents).toEqual([]); + }); + + it("dismisses the github-status notification and pushes a resolved notification when an incident clears", async () => { + const incidents = [makeIncident({ id: "abc", name: "Actions Outage", body: "Investigating", componentNames: ["Actions"] })]; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents })))); + await fetchGitHubStatus(); + + mockPushNotification.mockClear(); + mockDismissNotificationBySource.mockClear(); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents: [] })))); + await fetchGitHubStatus(); + + expect(mockDismissNotificationBySource).toHaveBeenCalledWith("github-status"); + expect(mockPushNotification).toHaveBeenCalledWith("github-status-resolved", "Actions Outage", "info", false); + }); + + it("two consecutive resolutions with different incident names each produce a distinct notification", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ + incidents: [makeIncident({ id: "a", name: "Incident A", body: "x", componentNames: ["Actions"] })], + })))); + await fetchGitHubStatus(); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ + incidents: [makeIncident({ id: "b", name: "Incident B", body: "y", componentNames: ["Issues"] })], + })))); + mockPushNotification.mockClear(); + await fetchGitHubStatus(); + expect(mockPushNotification).toHaveBeenCalledWith("github-status-resolved", "Incident A", "info", false); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents: [] })))); + mockPushNotification.mockClear(); + await fetchGitHubStatus(); + expect(mockPushNotification).toHaveBeenCalledWith("github-status-resolved", "Incident B", "info", false); + }); + + it("strips HTML tags from the latest update body", async () => { + const incidents = [makeIncident({ + id: "abc", + name: "Actions Outage", + body: "Update - We are continuing to investigate.

", + componentNames: ["Actions"], + })]; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents })))); + + await fetchGitHubStatus(); + + const result = getGitHubStatus(); + expect(result!.incidents[0].latestUpdateBody).not.toContain(" { + const xssName = ""; + const incidents = [makeIncident({ id: "xss-1", name: xssName, body: "Investigating", componentNames: ["Actions"] })]; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents })))); + + await fetchGitHubStatus(); + + expect(mockPushNotification).toHaveBeenCalledWith("github-status", xssName, expect.any(String), false); + }); + + it("sends credentials: omit and cache: no-store on the outgoing fetch", async () => { + const mockFetch = vi.fn().mockResolvedValue(jsonResponse(makeSummary())); + vi.stubGlobal("fetch", mockFetch); + + await fetchGitHubStatus(); + + expect(mockFetch).toHaveBeenCalledWith( + "https://www.githubstatus.com/api/v2/summary.json", + expect.objectContaining({ credentials: "omit", cache: "no-store" }) + ); + }); + + it("includes an AbortSignal (from AbortSignal.timeout) on the outgoing fetch call", async () => { + const mockFetch = vi.fn().mockResolvedValue(jsonResponse(makeSummary())); + vi.stubGlobal("fetch", mockFetch); + + await fetchGitHubStatus(); + + const options = mockFetch.mock.calls[0]?.[1] as RequestInit; + expect(options.signal).toBeInstanceOf(AbortSignal); + }); + + it("is a no-op when called again while a prior call's fetch is still in flight", async () => { + let resolveFetch!: (value: Response) => void; + const pending = new Promise((resolve) => { + resolveFetch = resolve; + }); + const mockFetch = vi.fn().mockReturnValue(pending); + vi.stubGlobal("fetch", mockFetch); + + const first = fetchGitHubStatus(); + const second = fetchGitHubStatus(); + + resolveFetch(jsonResponse(makeSummary())); + await Promise.all([first, second]); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("304 cache-hit path: reflects parseSummary run against the cached JSON, not empty/null", async () => { + const incidents = [makeIncident({ id: "cached-1", name: "Cached Incident", body: "Investigating", componentNames: ["Actions"] })]; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents }), { etag: "etag-1" }))); + await fetchGitHubStatus(); + expect(getGitHubStatus()!.incidents).toHaveLength(1); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 304 }))); + await fetchGitHubStatus(); + + const result = getGitHubStatus(); + expect(result!.incidents).toHaveLength(1); + expect(result!.incidents[0].name).toBe("Cached Incident"); + }); + + it("keeps the prior value and dismisses the notification when fetch throws (network error)", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ + incidents: [makeIncident({ id: "x", name: "X", body: "y", componentNames: ["Actions"] })], + })))); + await fetchGitHubStatus(); + const before = getGitHubStatus(); + + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch"))); + mockDismissNotificationBySource.mockClear(); + await expect(fetchGitHubStatus()).resolves.toBeUndefined(); + + expect(getGitHubStatus()).toEqual(before); + expect(mockDismissNotificationBySource).toHaveBeenCalledWith("github-status"); + }); + + it("first call with no successful fetch yet leaves getGitHubStatus() at null on a network error", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch"))); + + await fetchGitHubStatus(); + + expect(getGitHubStatus()).toBeNull(); + }); + + it("logs a schema-drift warning and dismisses the notification when the response fails validation, without throwing", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ components: "not-an-array", incidents: [] }))); + + await expect(fetchGitHubStatus()).resolves.toBeUndefined(); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("schema drift"), expect.anything()); + expect(mockDismissNotificationBySource).toHaveBeenCalledWith("github-status"); + expect(getGitHubStatus()).toBeNull(); + }); +}); diff --git a/vitest.smoke.config.ts b/vitest.smoke.config.ts new file mode 100644 index 00000000..e066f7b0 --- /dev/null +++ b/vitest.smoke.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "vitest/config"; +import solid from "vite-plugin-solid"; +import tailwindcss from "@tailwindcss/vite"; + +// Standalone config for live-network smoke tests (e.g. github-status.smoke.test.ts). +// These are deliberately excluded from vitest.workspace.ts's "browser" project +// (see its `exclude: [..., "tests/**/*.smoke.test.ts"]`) so `pnpm test` never hits +// the network — vitest.workspace.ts's exclude takes precedence over a CLI file +// filter, so re-including the same file via `vitest run ` against that +// config is not possible. Run smoke tests explicitly via `pnpm test:status-smoke`. +export default defineConfig({ + plugins: [solid(), tailwindcss()], + test: { + name: "status-smoke", + environment: "happy-dom", + globals: true, + setupFiles: ["tests/setup.ts"], + include: ["tests/**/*.smoke.test.ts"], + }, +}); diff --git a/vitest.workspace.ts b/vitest.workspace.ts index b809d1ba..e5a9e365 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -18,7 +18,7 @@ export default defineConfig({ hookTimeout: 30_000, setupFiles: ["tests/setup.ts"], include: ["tests/**/*.test.ts", "tests/**/*.test.tsx", "tests/**/*.steps.tsx"], - exclude: ["tests/worker/**"], + exclude: ["tests/worker/**", "tests/**/*.smoke.test.ts"], }, }), // Cloudflare Worker tests From 1dab98a120e638dfa7489d7589a55391dde71cab Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Fri, 7 Aug 2026 15:31:11 -0400 Subject: [PATCH 02/17] chore(security): allows www.githubstatus.com in CSP connect-src Adds the exact origin (no wildcard, no regional subdomains) so the new GitHub status polling fetch is not blocked by CSP. --- public/_headers | 2 +- tests/security/headers.test.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/public/_headers b/public/_headers index e0ecdf6e..3dff81e6 100644 --- a/public/_headers +++ b/public/_headers @@ -1,5 +1,5 @@ /* - Content-Security-Policy: default-src 'none'; script-src 'self' 'sha256-uEFqyYCMaNy1Su5VmWLZ1hOCRBjkhm4+ieHHxQW6d3Y=' https://challenges.cloudflare.com; style-src-elem 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data: https://avatars.githubusercontent.com; connect-src 'self' https://api.github.com https://api.atlassian.com ws://127.0.0.1:*; font-src 'self'; worker-src 'self'; manifest-src 'self'; frame-src https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; upgrade-insecure-requests; report-uri /api/csp-report; report-to csp-endpoint + Content-Security-Policy: default-src 'none'; script-src 'self' 'sha256-uEFqyYCMaNy1Su5VmWLZ1hOCRBjkhm4+ieHHxQW6d3Y=' https://challenges.cloudflare.com; style-src-elem 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data: https://avatars.githubusercontent.com; connect-src 'self' https://api.github.com https://api.atlassian.com https://www.githubstatus.com ws://127.0.0.1:*; font-src 'self'; worker-src 'self'; manifest-src 'self'; frame-src https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; upgrade-insecure-requests; report-uri /api/csp-report; report-to csp-endpoint Reporting-Endpoints: csp-endpoint="/api/csp-report" X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin diff --git a/tests/security/headers.test.ts b/tests/security/headers.test.ts index 6667d834..f097752b 100644 --- a/tests/security/headers.test.ts +++ b/tests/security/headers.test.ts @@ -93,6 +93,22 @@ describe("public/_headers CSP validation", () => { expect(connectSrc).toContain("https://api.github.com"); }); + it("connect-src includes https://www.githubstatus.com", () => { + expect(csp).not.toBeNull(); + const connectSrc = csp!.get("connect-src") ?? ""; + expect(connectSrc).toContain("https://www.githubstatus.com"); + }); + + it("connect-src does NOT include regional GitHub status subdomains", () => { + // Regional Enterprise Cloud status pages are out of scope for this feature + expect(csp).not.toBeNull(); + const connectSrc = csp!.get("connect-src") ?? ""; + expect(connectSrc).not.toContain("au.githubstatus.com"); + expect(connectSrc).not.toContain("eu.githubstatus.com"); + expect(connectSrc).not.toContain("jp.githubstatus.com"); + expect(connectSrc).not.toContain("us.githubstatus.com"); + }); + it("connect-src includes 'self' (same-origin Worker calls)", () => { expect(csp).not.toBeNull(); const connectSrc = csp!.get("connect-src") ?? ""; From 4e6affd31514b07911b79f43843e0f58badde02a Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Fri, 7 Aug 2026 15:32:08 -0400 Subject: [PATCH 03/17] feat(status): checks GitHub status on every full-poll cycle Calls fetchGitHubStatus() fire-and-forget from doFetch(), alongside the existing fetchRateLimitDetails() call, so it inherits the poll coordinator's interval, manual-refresh trigger, and hidden-tab skip behavior for free. --- src/app/services/poll.ts | 6 ++++++ tests/services/poll.test.ts | 41 +++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/app/services/poll.ts b/src/app/services/poll.ts index b177e540..b622db5a 100644 --- a/src/app/services/poll.ts +++ b/src/app/services/poll.ts @@ -1,6 +1,7 @@ import { createSignal, createEffect, createRoot, untrack, onCleanup } from "solid-js"; import * as Sentry from "@sentry/solid"; import { getClient, fetchRateLimitDetails } from "./github"; +import { fetchGitHubStatus } from "./github-status"; import { config } from "../stores/config"; import { user, onAuthCleared, expireToken } from "../stores/auth"; import { checkAndResetIfExpired } from "./api-usage"; @@ -310,6 +311,11 @@ export function createPollCoordinator( // Fire-and-forget: seeds footer signals concurrently with fetchAll. If GET /rate_limit // resolves after a GraphQL response, the footer briefly shows pre-query remaining (cosmetic). void fetchRateLimitDetails(); + // Fire-and-forget, same pattern as above: checks GitHub's own status page for + // outages affecting tracked components. Fully self-contained (own try/catch, + // own notification push/dismiss) — no interaction with this cycle's error + // handling or the fetchAll() try/catch below. + void fetchGitHubStatus(); // Snapshot sources of notifications from previous cycle (for reconciliation) const previousSources = new Set( diff --git a/tests/services/poll.test.ts b/tests/services/poll.test.ts index 199e38cb..3c5779cb 100644 --- a/tests/services/poll.test.ts +++ b/tests/services/poll.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createRoot, createSignal } from "solid-js"; import { createPollCoordinator, type DashboardData } from "../../src/app/services/poll"; import * as githubMod from "../../src/app/services/github"; +import * as githubStatusMod from "../../src/app/services/github-status"; // Mock pushError so we can spy on it const mockPushError = vi.fn(); @@ -41,6 +42,12 @@ vi.mock("../../src/app/services/github", () => ({ initClientWatcher: vi.fn(), })); +// Mock github-status module — fetchGitHubStatus runs concurrently (fire-and-forget) +// in doFetch, same pattern as fetchRateLimitDetails above. +vi.mock("../../src/app/services/github-status", () => ({ + fetchGitHubStatus: vi.fn(() => Promise.resolve()), +})); + // Mock config so doFetch doesn't fail when accessing config.selectedRepos vi.mock("../../src/app/stores/config", () => ({ config: { @@ -601,4 +608,38 @@ describe("createPollCoordinator", () => { dispose(); }); }); + + it("fetchGitHubStatus is called once on initial mount", async () => { + const fetchGitHubStatusSpy = vi.mocked(githubStatusMod.fetchGitHubStatus); + fetchGitHubStatusSpy.mockClear(); + + const fetchAll = makeFetchAll(); + + await createRoot(async (dispose) => { + createPollCoordinator(makeGetInterval(0), fetchAll); + await flushPromises(); + + expect(fetchGitHubStatusSpy).toHaveBeenCalledTimes(1); + dispose(); + }); + }); + + it("manualRefresh() triggers another fetchGitHubStatus call", async () => { + const fetchGitHubStatusSpy = vi.mocked(githubStatusMod.fetchGitHubStatus); + fetchGitHubStatusSpy.mockClear(); + + const fetchAll = makeFetchAll(); + + await createRoot(async (dispose) => { + const coordinator = createPollCoordinator(makeGetInterval(0), fetchAll); + await flushPromises(); + expect(fetchGitHubStatusSpy).toHaveBeenCalledTimes(1); + + coordinator.manualRefresh(); + await flushPromises(); + + expect(fetchGitHubStatusSpy).toHaveBeenCalledTimes(2); + dispose(); + }); + }); }); From 983e7c8382e932c95217b107c440f80b3b868475 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Fri, 7 Aug 2026 15:33:06 -0400 Subject: [PATCH 04/17] feat(status): adds GitHubStatusBadge component Renders a severity-colored dot in the header with a click-to-open Kobalte Popover showing active-incident detail. Adds an optional forceClosed prop to the shared Tooltip component so the badge's hover tooltip can be suppressed while its Popover is open. --- .../components/shared/GitHubStatusBadge.tsx | 67 +++++++++ src/app/components/shared/Tooltip.tsx | 3 +- .../shared/GitHubStatusBadge.test.tsx | 142 ++++++++++++++++++ tests/components/shared/Tooltip.test.tsx | 40 +++++ 4 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 src/app/components/shared/GitHubStatusBadge.tsx create mode 100644 tests/components/shared/GitHubStatusBadge.test.tsx diff --git a/src/app/components/shared/GitHubStatusBadge.tsx b/src/app/components/shared/GitHubStatusBadge.tsx new file mode 100644 index 00000000..d3c8373e --- /dev/null +++ b/src/app/components/shared/GitHubStatusBadge.tsx @@ -0,0 +1,67 @@ +import { Show, For, createMemo, createSignal } from "solid-js"; +import { Popover } from "@kobalte/core/popover"; +import { Tooltip } from "./Tooltip"; +import { getGitHubStatus, type GitHubStatusSeverity } from "../../services/github-status"; + +const SEVERITY_CONFIG: Record = { + none: { bg: "bg-success", label: "All systems operational", pulse: false }, + minor: { bg: "bg-warning", label: "Minor GitHub service disruption", pulse: false }, + major: { bg: "bg-orange-500", label: "Major GitHub service outage", pulse: true }, + critical: { bg: "bg-red-500", label: "Critical GitHub service outage", pulse: true }, +}; + +export default function GitHubStatusBadge() { + const status = createMemo(() => getGitHubStatus()); + const cfg = createMemo(() => { + const s = status(); + return s !== null + ? SEVERITY_CONFIG[s.severity] + : { bg: "bg-base-content/20", label: "Checking GitHub status…", pulse: false }; + }); + const [popoverOpen, setPopoverOpen] = createSignal(false); + + return ( + + + + + + + + + + + + + + 0} + fallback={ +
+ + {cfg().label} +
+ } + > +
    + + {(incident) => ( +
  • +
    {incident.name}
    +
    Affects: {incident.affectedComponents.join(", ")}
    + +

    {incident.latestUpdateBody}

    +
    +
  • + )} +
    +
+
+ + View githubstatus.com + +
+
+
+ ); +} diff --git a/src/app/components/shared/Tooltip.tsx b/src/app/components/shared/Tooltip.tsx index 65762093..a30c3a72 100644 --- a/src/app/components/shared/Tooltip.tsx +++ b/src/app/components/shared/Tooltip.tsx @@ -12,13 +12,14 @@ interface TooltipProps { focusable?: boolean; class?: string; contentClass?: string; + forceClosed?: boolean; children: JSX.Element; } export function Tooltip(props: TooltipProps) { const [isHovered, setIsHovered] = createSignal(false); const [isFocused, setIsFocused] = createSignal(false); - const open = createMemo(() => isHovered() || isFocused()); + const open = createMemo(() => !props.forceClosed && (isHovered() || isFocused())); // openDelay is ignored in controlled mode; implement the delay manually let hoverTimer: ReturnType | undefined; diff --git a/tests/components/shared/GitHubStatusBadge.test.tsx b/tests/components/shared/GitHubStatusBadge.test.tsx new file mode 100644 index 00000000..be43ad03 --- /dev/null +++ b/tests/components/shared/GitHubStatusBadge.test.tsx @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@solidjs/testing-library"; +import type { GitHubStatusSummary } from "../../../src/app/services/github-status"; + +const mockGetGitHubStatus = vi.fn<() => GitHubStatusSummary | null>(() => null); + +vi.mock("../../../src/app/services/github-status", () => ({ + getGitHubStatus: () => mockGetGitHubStatus(), +})); + +import GitHubStatusBadge from "../../../src/app/components/shared/GitHubStatusBadge"; + +beforeEach(() => { + vi.useFakeTimers(); + mockGetGitHubStatus.mockReset(); + mockGetGitHubStatus.mockReturnValue(null); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("GitHubStatusBadge", () => { + it("renders neutral checking state before first fetch resolves", () => { + mockGetGitHubStatus.mockReturnValue(null); + const { container } = render(() => ); + const button = screen.getByRole("button", { name: "Checking GitHub status…" }); + expect(button).toBeTruthy(); + const dot = container.querySelector("span.rounded-full.w-2.h-2"); + expect(dot?.classList.contains("bg-base-content/20")).toBe(true); + }); + + it("severity 'none' shows success dot without pulse and popover shows operational message", () => { + mockGetGitHubStatus.mockReturnValue({ severity: "none", incidents: [], fetchedAt: new Date() }); + const { container } = render(() => ); + const dot = container.querySelector("span.rounded-full.w-2.h-2")!; + expect(dot.classList.contains("bg-success")).toBe(true); + expect(container.querySelector(".animate-slow-pulse")).toBeNull(); + + const button = screen.getByRole("button", { name: "All systems operational" }); + fireEvent.click(button); + vi.advanceTimersByTime(0); + expect(document.body.textContent).toContain("All systems operational"); + const link = screen.getByRole("link", { name: /View githubstatus\.com/i }); + expect(link.getAttribute("href")).toBe("https://www.githubstatus.com"); + }); + + it("severity 'minor' shows warning dot without pulse class", () => { + mockGetGitHubStatus.mockReturnValue({ + severity: "minor", + incidents: [{ id: "1", name: "Degraded search", latestUpdateBody: "", affectedComponents: ["Search"] }], + fetchedAt: new Date(), + }); + const { container } = render(() => ); + const dot = container.querySelector("span.rounded-full.w-2.h-2")!; + expect(dot.classList.contains("bg-warning")).toBe(true); + expect(container.querySelector(".animate-slow-pulse")).toBeNull(); + }); + + it("severity 'major' shows orange dot with pulse class", () => { + mockGetGitHubStatus.mockReturnValue({ + severity: "major", + incidents: [{ id: "1", name: "API outage", latestUpdateBody: "", affectedComponents: ["API Requests"] }], + fetchedAt: new Date(), + }); + const { container } = render(() => ); + const dot = container.querySelector("span.rounded-full.w-2.h-2")!; + expect(dot.classList.contains("bg-orange-500")).toBe(true); + expect(container.querySelector(".animate-slow-pulse")).not.toBeNull(); + }); + + it("severity 'critical' with one incident shows critical color, pulse, and popover incident details", () => { + mockGetGitHubStatus.mockReturnValue({ + severity: "critical", + incidents: [ + { + id: "1", + name: "API outage", + latestUpdateBody: "We are investigating the issue.", + affectedComponents: ["API Requests", "Webhooks"], + }, + ], + fetchedAt: new Date(), + }); + const { container } = render(() => ); + const dot = container.querySelector("span.rounded-full.w-2.h-2")!; + expect(dot.classList.contains("bg-red-500")).toBe(true); + expect(container.querySelector(".animate-slow-pulse")).not.toBeNull(); + + const button = screen.getByRole("button", { name: "Critical GitHub service outage" }); + fireEvent.click(button); + vi.advanceTimersByTime(0); + expect(screen.getByText("API outage")).toBeTruthy(); + expect(document.body.textContent).toContain("Affects: API Requests, Webhooks"); + expect(document.body.textContent).toContain("We are investigating the issue."); + }); + + it("does not render incident name as HTML (XSS regression)", () => { + const malicious = ""; + mockGetGitHubStatus.mockReturnValue({ + severity: "critical", + incidents: [{ id: "1", name: malicious, latestUpdateBody: "", affectedComponents: ["Actions"] }], + fetchedAt: new Date(), + }); + render(() => ); + const button = screen.getByRole("button", { name: "Critical GitHub service outage" }); + fireEvent.click(button); + vi.advanceTimersByTime(0); + expect(screen.getByText(malicious)).toBeTruthy(); + // document-scoped: Popover.Content teleports to document.body via Popover.Portal, + // so a container-scoped query would trivially pass even if the vulnerability were real. + expect(document.querySelector("img[onerror]")).toBeNull(); + }); + + it("clicking the trigger while hovered suppresses the tooltip and shows the popover", () => { + mockGetGitHubStatus.mockReturnValue({ + severity: "major", + incidents: [{ id: "1", name: "Some outage", latestUpdateBody: "We are investigating", affectedComponents: ["Actions"] }], + fetchedAt: new Date(), + }); + const { container } = render(() => ); + const tooltipTrigger = container.querySelector("span.inline-flex")!; + fireEvent.pointerEnter(tooltipTrigger); + vi.advanceTimersByTime(300); + // Kobalte keeps the tooltip's content node mounted (for exit transitions) even once + // closed, marking it data-closed rather than removing it — so once opened, textContent + // checks can't distinguish open/closed. Check the data-expanded state instead, matching + // this file's existing convention for post-interaction assertions. + let tooltipContent = document.querySelector('[role="tooltip"]'); + expect(tooltipContent?.hasAttribute("data-expanded")).toBe(true); + + const button = screen.getByRole("button", { name: "Major GitHub service outage" }); + fireEvent.click(button); + vi.advanceTimersByTime(0); + expect(button.getAttribute("aria-expanded")).toBe("true"); + // Tooltip is suppressed (forceClosed) once the Popover opens... + tooltipContent = document.querySelector('[role="tooltip"]'); + expect(tooltipContent?.hasAttribute("data-expanded")).toBe(false); + // ...while the Popover's own content is present. + expect(document.body.textContent).toContain("Some outage"); + }); +}); diff --git a/tests/components/shared/Tooltip.test.tsx b/tests/components/shared/Tooltip.test.tsx index 331db213..f81a33e3 100644 --- a/tests/components/shared/Tooltip.test.tsx +++ b/tests/components/shared/Tooltip.test.tsx @@ -177,6 +177,46 @@ describe("Tooltip", () => { // Advance past Kobalte's globalSkipDelayTimeout (300ms) so global state resets vi.advanceTimersByTime(500); }); + + it("forceClosed suppresses tooltip even after hover delay", () => { + const { container } = render(() => ( + + Trigger + + )); + const trigger = container.querySelector("span.inline-flex")!; + fireEvent.pointerEnter(trigger); + vi.advanceTimersByTime(300); + expect(document.body.textContent).not.toContain("X"); + }); + + it("forceClosed={false} or omitted does not change existing hover behavior", () => { + const { container: containerFalse } = render(() => ( + + Trigger + + )); + const triggerFalse = containerFalse.querySelector("span.inline-flex")!; + fireEvent.pointerEnter(triggerFalse); + vi.advanceTimersByTime(300); + expect(document.body.textContent).toContain("X"); + fireEvent.pointerLeave(triggerFalse); + vi.advanceTimersByTime(500); + + const { container: containerOmitted } = render(() => ( + + Trigger + + )); + const triggerOmitted = containerOmitted.querySelector("span.inline-flex")!; + fireEvent.pointerEnter(triggerOmitted); + vi.advanceTimersByTime(300); + expect(document.body.textContent).toContain("Y"); + // Clean up: close the tooltip and let Kobalte's global skip-delay warm state + // reset, so later tests (e.g. InfoTooltip's real openDelay) aren't affected. + fireEvent.pointerLeave(triggerOmitted); + vi.advanceTimersByTime(500); + }); }); describe("InfoTooltip", () => { From e7209f05030bd46e0e468e658d7aef14000ea077 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Fri, 7 Aug 2026 15:33:51 -0400 Subject: [PATCH 05/17] feat(status): renders GitHubStatusBadge in Header Places the badge between the user avatar and the Settings icon in the header's icon row. --- src/app/components/layout/Header.tsx | 3 +++ tests/components/layout/Header.test.tsx | 32 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/app/components/layout/Header.tsx b/src/app/components/layout/Header.tsx index 87ce5888..3b6865ca 100644 --- a/src/app/components/layout/Header.tsx +++ b/src/app/components/layout/Header.tsx @@ -5,6 +5,7 @@ import { getUnreadCount, markAllAsRead } from "../../lib/errors"; import NotificationDrawer from "../shared/NotificationDrawer"; import ToastContainer from "../shared/ToastContainer"; import { Tooltip } from "../shared/Tooltip"; +import GitHubStatusBadge from "../shared/GitHubStatusBadge"; export default function Header() { const navigate = useNavigate(); @@ -56,6 +57,8 @@ export default function Header() { )} + + ({ clearMutedSources: vi.fn(), })); +// Mock github-status module so Header's GitHubStatusBadge import works +vi.mock("../../../src/app/services/github-status", () => ({ + getGitHubStatus: vi.fn(() => null), +})); + import Header from "../../../src/app/components/layout/Header"; import * as authStore from "../../../src/app/stores/auth"; import * as errorsModule from "../../../src/app/lib/errors"; +import * as githubStatusModule from "../../../src/app/services/github-status"; import { render } from "@solidjs/testing-library"; beforeEach(() => { @@ -53,6 +59,7 @@ beforeEach(() => { vi.mocked(authStore.clearAuth).mockClear(); vi.mocked(errorsModule.getUnreadCount).mockReturnValue(0); vi.mocked(errorsModule.markAllAsRead).mockClear(); + vi.mocked(githubStatusModule.getGitHubStatus).mockReturnValue(null); }); describe("Header", () => { @@ -151,4 +158,29 @@ describe("Header", () => { expect(bellBtn.getAttribute("aria-expanded")).toBe("false"); expect(errorsModule.markAllAsRead).toHaveBeenCalledTimes(1); }); + + it("renders GitHub status badge with checking state before first fetch", () => { + render(() =>
); + expect(screen.getByLabelText("Checking GitHub status…")).toBeDefined(); + }); + + it("renders GitHub status badge reflecting operational status", () => { + vi.mocked(githubStatusModule.getGitHubStatus).mockReturnValue({ + severity: "none", + incidents: [], + fetchedAt: new Date(), + }); + render(() =>
); + expect(screen.getByLabelText("All systems operational")).toBeDefined(); + }); + + it("renders GitHub status badge reflecting an active critical incident", () => { + vi.mocked(githubStatusModule.getGitHubStatus).mockReturnValue({ + severity: "critical", + incidents: [{ id: "1", name: "Actions Outage", latestUpdateBody: "Investigating", affectedComponents: ["Actions"] }], + fetchedAt: new Date(), + }); + render(() =>
); + expect(screen.getByLabelText("Critical GitHub service outage")).toBeDefined(); + }); }); From 087ae11f277fe13687fe3183c094653b3457de12 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Fri, 7 Aug 2026 15:55:51 -0400 Subject: [PATCH 06/17] fix(status): floors severity and gates dismissal on repeat failures --- src/app/services/github-status.ts | 40 +++++++++++---- tests/services/github-status.test.ts | 74 ++++++++++++++++++++++++++-- 2 files changed, 100 insertions(+), 14 deletions(-) diff --git a/src/app/services/github-status.ts b/src/app/services/github-status.ts index a095d4c6..fdf3e2bb 100644 --- a/src/app/services/github-status.ts +++ b/src/app/services/github-status.ts @@ -118,7 +118,7 @@ function stripHtml(html: string): string { // Tested indirectly via fetchGitHubStatus. function parseSummary(raw: RawSummaryResponse): GitHubStatusSummary { const trackedComponents = raw.components.filter((c) => TRACKED_COMPONENT_NAMES.has(c.name)); - const severity = blendSeverity(trackedComponents); + let severity = blendSeverity(trackedComponents); const relevantIncidents: GitHubStatusIncident[] = raw.incidents.flatMap((inc) => { const affected = inc.components.filter((c) => TRACKED_COMPONENT_NAMES.has(c.name)); @@ -131,6 +131,16 @@ function parseSummary(raw: RawSummaryResponse): GitHubStatusSummary { }]; }); + // Statuspage commonly resets a component's status back to "operational" during + // an incident's "Monitoring" phase while the incident itself stays open, which + // would otherwise blend to "none" here even though a relevant incident is still + // active. Floor severity at "minor" in that case so the badge/notification can + // never claim "all systems operational" while an open incident is still being + // surfaced (CR-001) — avoids a green badge whose own popover lists an incident. + if (severity === "none" && relevantIncidents.length > 0) { + severity = "minor"; + } + return { severity, incidents: relevantIncidents, fetchedAt: new Date() }; } @@ -180,6 +190,10 @@ export function getGitHubStatus(): GitHubStatusSummary | null { let _fetchInProgress = false; +// Consecutive-failure gate for the catch block below (CR-002) — see comment there. +const CONSECUTIVE_FAILURE_THRESHOLD = 3; +let _consecutiveFailures = 0; + export async function fetchGitHubStatus(): Promise { if (_fetchInProgress) return; // in-flight guard — avoid pile-up if refreshInterval is very short/0 or the endpoint is slow to respond _fetchInProgress = true; @@ -214,20 +228,25 @@ export async function fetchGitHubStatus(): Promise { const summary = parseSummary(validated.data); notifyTransitions(summary); _setGitHubStatus(summary); + _consecutiveFailures = 0; } catch (err) { console.warn("[github-status] fetch failed:", err instanceof Error ? err.message : String(err)); // Best-effort, ancillary external signal — deliberately no pushError, don't // pollute the notification center with transient network blips for a // non-critical feature the user can't act on anyway. Still DO dismiss any - // active "github-status" incident notification: notifyTransitions() owns - // this notification's entire lifecycle end-to-end, and poll.ts deliberately - // excludes github-status from poll-level reconciliation (POLL_MANAGED_SOURCES), - // so if this fetch starts failing persistently while an incident notification - // is showing, nothing else would ever clear it. A single fetch failure just - // means "we don't currently know," not "the incident is still ongoing," so - // unconditional dismiss-on-any-failure is preferred over tracking a - // consecutive-failure counter (simpler, guarantees no stuck state). - dismissNotificationBySource(NOTIFICATION_SOURCE); + // active "github-status" incident notification once failures persist: + // notifyTransitions() owns this notification's entire lifecycle end-to-end, + // and poll.ts deliberately excludes github-status from poll-level + // reconciliation (POLL_MANAGED_SOURCES), so if this fetch keeps failing while + // an incident notification is showing, nothing else would ever clear it. + // A single blip doesn't mean the incident resolved, though — dismissing on + // every failure made the next successful poll re-push an unchanged incident + // as if newly announced (CR-002). Gate dismissal on + // CONSECUTIVE_FAILURE_THRESHOLD consecutive failures instead of any single one. + _consecutiveFailures++; + if (_consecutiveFailures >= CONSECUTIVE_FAILURE_THRESHOLD) { + dismissNotificationBySource(NOTIFICATION_SOURCE); + } } finally { _fetchInProgress = false; } @@ -241,4 +260,5 @@ export function resetGitHubStatusState(): void { _previousIncidents = new Map(); _setGitHubStatus(null); _fetchInProgress = false; + _consecutiveFailures = 0; } diff --git a/tests/services/github-status.test.ts b/tests/services/github-status.test.ts index 8441a0d9..d76effba 100644 --- a/tests/services/github-status.test.ts +++ b/tests/services/github-status.test.ts @@ -130,6 +130,26 @@ describe("fetchGitHubStatus", () => { expect(result!.incidents).toEqual([]); }); + it("floors severity at minor when an incident is still open but its tracked components blend to operational (CR-001)", async () => { + // Statuspage's "Monitoring" phase: component status resets to operational + // while the incident itself remains open and still affects a tracked + // component — severity must not fall back to "none" in that window. + const incidents = [makeIncident({ + id: "monitoring-1", + name: "Actions Degradation", + body: "We are continuing to monitor.", + componentNames: ["Actions"], + componentStatus: "operational", + })]; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents })))); + + await fetchGitHubStatus(); + + const result = getGitHubStatus(); + expect(result!.severity).toBe("minor"); + expect(result!.incidents).toHaveLength(1); + }); + it("dismisses the github-status notification and pushes a resolved notification when an incident clears", async () => { const incidents = [makeIncident({ id: "abc", name: "Actions Outage", body: "Investigating", componentNames: ["Actions"] })]; vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents })))); @@ -242,21 +262,67 @@ describe("fetchGitHubStatus", () => { expect(result!.incidents[0].name).toBe("Cached Incident"); }); - it("keeps the prior value and dismisses the notification when fetch throws (network error)", async () => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ - incidents: [makeIncident({ id: "x", name: "X", body: "y", componentNames: ["Actions"] })], - })))); + it("keeps the prior value on a single fetch failure and does not dismiss the notification (CR-002)", async () => { + const incidents = [makeIncident({ id: "x", name: "X", body: "y", componentNames: ["Actions"] })]; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents })))); await fetchGitHubStatus(); const before = getGitHubStatus(); vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch"))); mockDismissNotificationBySource.mockClear(); + mockPushNotification.mockClear(); await expect(fetchGitHubStatus()).resolves.toBeUndefined(); expect(getGitHubStatus()).toEqual(before); + expect(mockDismissNotificationBySource).not.toHaveBeenCalled(); + + // A subsequent successful poll with the same unchanged incident must not + // look like a fresh announcement — it should be pushed with the exact same + // source+message as before, which the real errors.ts dedup treats as a no-op. + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents })))); + await fetchGitHubStatus(); + expect(mockPushNotification).toHaveBeenCalledTimes(1); + expect(mockPushNotification).toHaveBeenCalledWith("github-status", "X", "warning", false); + }); + + it("dismisses the notification only after 3 consecutive fetch failures (CR-002)", async () => { + const incidents = [makeIncident({ id: "x", name: "X", body: "y", componentNames: ["Actions"] })]; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents })))); + await fetchGitHubStatus(); + + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch"))); + mockDismissNotificationBySource.mockClear(); + + await fetchGitHubStatus(); // failure 1 + expect(mockDismissNotificationBySource).not.toHaveBeenCalled(); + await fetchGitHubStatus(); // failure 2 + expect(mockDismissNotificationBySource).not.toHaveBeenCalled(); + await fetchGitHubStatus(); // failure 3 expect(mockDismissNotificationBySource).toHaveBeenCalledWith("github-status"); }); + it("resets the consecutive-failure counter after a successful fetch (CR-002)", async () => { + const incidents = [makeIncident({ id: "x", name: "X", body: "y", componentNames: ["Actions"] })]; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents })))); + await fetchGitHubStatus(); + + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch"))); + await fetchGitHubStatus(); // failure 1 + await fetchGitHubStatus(); // failure 2 + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents })))); + await fetchGitHubStatus(); // success — must reset the counter + + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch"))); + mockDismissNotificationBySource.mockClear(); + await fetchGitHubStatus(); // failure 1 (post-reset) + await fetchGitHubStatus(); // failure 2 (post-reset) + + // If the counter hadn't reset, this would be the 4th consecutive failure + // overall and would already have crossed the threshold. + expect(mockDismissNotificationBySource).not.toHaveBeenCalled(); + }); + it("first call with no successful fetch yet leaves getGitHubStatus() at null on a network error", async () => { vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch"))); From d2af86993469b2d7a52ce38eb273b5a37d830f28 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Fri, 7 Aug 2026 16:03:55 -0400 Subject: [PATCH 07/17] test(github-status): adds severity popover and focus coverage --- .../shared/GitHubStatusBadge.test.tsx | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/tests/components/shared/GitHubStatusBadge.test.tsx b/tests/components/shared/GitHubStatusBadge.test.tsx index be43ad03..bbded3c9 100644 --- a/tests/components/shared/GitHubStatusBadge.test.tsx +++ b/tests/components/shared/GitHubStatusBadge.test.tsx @@ -45,7 +45,7 @@ describe("GitHubStatusBadge", () => { expect(link.getAttribute("href")).toBe("https://www.githubstatus.com"); }); - it("severity 'minor' shows warning dot without pulse class", () => { + it("severity 'minor' shows warning dot without pulse class and popover shows incident details", () => { mockGetGitHubStatus.mockReturnValue({ severity: "minor", incidents: [{ id: "1", name: "Degraded search", latestUpdateBody: "", affectedComponents: ["Search"] }], @@ -55,6 +55,12 @@ describe("GitHubStatusBadge", () => { const dot = container.querySelector("span.rounded-full.w-2.h-2")!; expect(dot.classList.contains("bg-warning")).toBe(true); expect(container.querySelector(".animate-slow-pulse")).toBeNull(); + + const button = screen.getByRole("button", { name: "Minor GitHub service disruption" }); + fireEvent.click(button); + vi.advanceTimersByTime(0); + expect(screen.getByText("Degraded search")).toBeTruthy(); + expect(document.body.textContent).toContain("Affects: Search"); }); it("severity 'major' shows orange dot with pulse class", () => { @@ -139,4 +145,47 @@ describe("GitHubStatusBadge", () => { // ...while the Popover's own content is present. expect(document.body.textContent).toContain("Some outage"); }); + + it("popover lists all incidents when multiple are present simultaneously", () => { + mockGetGitHubStatus.mockReturnValue({ + severity: "major", + incidents: [ + { id: "1", name: "API outage", latestUpdateBody: "", affectedComponents: ["API Requests"] }, + { id: "2", name: "Actions delays", latestUpdateBody: "", affectedComponents: ["Actions"] }, + ], + fetchedAt: new Date(), + }); + render(() => ); + const button = screen.getByRole("button", { name: "Major GitHub service outage" }); + fireEvent.click(button); + vi.advanceTimersByTime(0); + expect(screen.getByText("API outage")).toBeTruthy(); + expect(screen.getByText("Actions delays")).toBeTruthy(); + expect(document.body.textContent).toContain("Affects: API Requests"); + expect(document.body.textContent).toContain("Affects: Actions"); + }); + + it("focusing the trigger shows the tooltip via keyboard access, and opening the popover suppresses it", () => { + mockGetGitHubStatus.mockReturnValue({ + severity: "major", + incidents: [{ id: "1", name: "Some outage", latestUpdateBody: "We are investigating", affectedComponents: ["Actions"] }], + fetchedAt: new Date(), + }); + const { container } = render(() => ); + const tooltipTrigger = container.querySelector("span.inline-flex")!; + fireEvent.focusIn(tooltipTrigger); + // focusIn opens the Tooltip immediately (no hover delay) — see Tooltip.tsx onFocusIn. + let tooltipContent = document.querySelector('[role="tooltip"]'); + expect(tooltipContent?.hasAttribute("data-expanded")).toBe(true); + + const button = screen.getByRole("button", { name: "Major GitHub service outage" }); + fireEvent.click(button); + vi.advanceTimersByTime(0); + expect(button.getAttribute("aria-expanded")).toBe("true"); + // Tooltip is suppressed (forceClosed) once the Popover opens, even though focus never left... + tooltipContent = document.querySelector('[role="tooltip"]'); + expect(tooltipContent?.hasAttribute("data-expanded")).toBe(false); + // ...while the Popover's own content is present. + expect(document.body.textContent).toContain("Some outage"); + }); }); From 2227d9c69febe7f63856f7956368f5ae0cb6f6e0 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Fri, 7 Aug 2026 16:23:13 -0400 Subject: [PATCH 08/17] docs: documents GitHub status tracking in the user guide --- README.md | 4 ++++ docs/USER_GUIDE.md | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/README.md b/README.md index 5388c389..f10df639 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,10 @@ A second, faster poll loop (default 30s, configurable 10–120s) targets only in Browser notifications for new issues, PRs, and failed runs. Per-type toggles in settings. Notification permission requested on first enable. New items are detected via the Events API polling loop and full refresh cycles. +### GitHub Status Badge + +A status dot in the header reflects GitHub's own reported status for the services this dashboard depends on (Actions, API Requests, Git Operations, Issues, Pull Requests). Click it for incident details and a link to githubstatus.com. Toast and drawer notifications fire on incident start and resolution. + ### Repo Pinning and Reordering Lock repos to the top of each tab's list so they don't shift around as activity changes. Drag-to-reorder within the locked set. Lock controls appear on hover on desktop, always visible on mobile. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 697ccd97..21d14ee5 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -15,6 +15,7 @@ GitHub Tracker is a dashboard that aggregates open issues, pull requests, and Gi - [Personal Summary Strip](#personal-summary-strip) - [Repo Grouping and Expand/Collapse](#repo-grouping-and-expandcollapse) - [Scope Filter](#scope-filter) + - [GitHub Status Badge](#github-status-badge) - [Issues Tab](#issues-tab) - [Filters](#issues-filters) - [Dependency Dashboard Toggle](#dependency-dashboard-toggle) @@ -148,6 +149,12 @@ The **Scope** filter chip appears on the Issues and Pull Requests tabs when you The scope filter is hidden (and always set to "Involves me") when you have no tracked users and no monitor-all repos, because in that configuration all fetched data already involves you. +### GitHub Status Badge + +A small status dot appears in the header, next to your avatar. It reflects GitHub's own reported status for the services this dashboard depends on — Actions, API Requests, Git Operations, Issues, and Pull Requests. Green means all tracked services are operational; yellow, orange, or red indicate minor, major, or critical disruption respectively. + +Click the badge to see details on any active incident, including affected components and the latest status update, plus a link to [githubstatus.com](https://www.githubstatus.com). Other GitHub services (Copilot, Codespaces, Pages, Packages, etc.) are not tracked by this badge. + --- ## Issues Tab @@ -369,6 +376,10 @@ Setting the interval to **Off** disables automatic polling; manual refresh still A ±30 second jitter is applied to the refresh interval to avoid synchronized API spikes from multiple browser tabs. +### GitHub Status Checks + +Each full refresh also checks GitHub's own status page for outages affecting Actions, API Requests, Git Operations, Issues, or Pull Requests. This is a single lightweight, unauthenticated request and does not count against your GitHub API rate limit. It follows the same schedule as the full refresh — including the same visibility-based pausing — rather than running on its own timer. + ### Hot Poll A second, faster poll loop runs alongside the full refresh specifically for in-flight items. It targets: @@ -410,6 +421,10 @@ When you return to a tab that has been hidden for more than 2 minutes, a catch-u The bell icon in the header opens the notification drawer, which shows API errors, rate limit warnings, and other system messages. Notifications are dismissed automatically when the underlying condition clears on the next poll cycle. +### GitHub Outage Notifications + +When a GitHub outage affecting a tracked service starts, a toast appears and an entry is added to the notification drawer. A second notification confirms it once the outage resolves. These always appear via the in-app toast and drawer — they are not part of the **Browser Push Notifications** toggles below and cannot be disabled. + ### Browser Push Notifications Browser push notifications are disabled by default. To enable them: From 3bfb1c635b3a09973a73c57a3723ff39971a1087 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Fri, 7 Aug 2026 16:56:56 -0400 Subject: [PATCH 09/17] docs(contributing): corrects commit message mood convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the documented convention to present-indicative mood ("adds feature"), matching what this environment's commit-msg hook actually enforces — the prior imperative-mood example contradicted every commit accepted in practice. --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1eb2d411..f9af6d2a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -98,7 +98,7 @@ Commits follow [Conventional Commits](https://www.conventionalcommits.org/): type(scope): description ``` -Scope is optional. Use imperative mood: "add feature", not "adds feature" or "added feature". +Scope is optional. Use present-indicative mood: "adds feature", not "add feature" or "added feature". ## Releasing the MCP server From a16ef509f08c6a44c23d50c755838dd0c34c04f7 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Fri, 7 Aug 2026 17:13:12 -0400 Subject: [PATCH 10/17] fix(status): closes schema-drift path around failure gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts a shared recordFetchFailure() helper so both the schema-validation-failure branch and the catch block go through the same CONSECUTIVE_FAILURE_THRESHOLD gate added in 087ae11 — the schema-drift path previously called dismissNotificationBySource unconditionally, reintroducing the CR-002 bug via a different trigger. Adds a test confirming the counter is shared across both failure types. --- src/app/services/github-status.ts | 42 ++++++++++++++++------------ tests/services/github-status.test.ts | 41 +++++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 20 deletions(-) diff --git a/src/app/services/github-status.ts b/src/app/services/github-status.ts index fdf3e2bb..fdc2bdb8 100644 --- a/src/app/services/github-status.ts +++ b/src/app/services/github-status.ts @@ -217,11 +217,11 @@ export async function fetchGitHubStatus(): Promise { // Validate the raw shape before parsing — catches API drift (e.g. a field // renamed/removed upstream) as a distinct, loud log message instead of an // unexplained TypeError deep inside parseSummary. Treated the same as any - // other fetch failure: no throw, dismiss any active incident notification. + // other fetch failure (see recordFetchFailure()). const validated = RawSummaryResponseSchema.safeParse(data); if (!validated.success) { console.warn("[github-status] response schema drift — validation failed:", validated.error); - dismissNotificationBySource(NOTIFICATION_SOURCE); + recordFetchFailure(); return; } @@ -231,27 +231,33 @@ export async function fetchGitHubStatus(): Promise { _consecutiveFailures = 0; } catch (err) { console.warn("[github-status] fetch failed:", err instanceof Error ? err.message : String(err)); - // Best-effort, ancillary external signal — deliberately no pushError, don't - // pollute the notification center with transient network blips for a - // non-critical feature the user can't act on anyway. Still DO dismiss any - // active "github-status" incident notification once failures persist: - // notifyTransitions() owns this notification's entire lifecycle end-to-end, - // and poll.ts deliberately excludes github-status from poll-level - // reconciliation (POLL_MANAGED_SOURCES), so if this fetch keeps failing while - // an incident notification is showing, nothing else would ever clear it. - // A single blip doesn't mean the incident resolved, though — dismissing on - // every failure made the next successful poll re-push an unchanged incident - // as if newly announced (CR-002). Gate dismissal on - // CONSECUTIVE_FAILURE_THRESHOLD consecutive failures instead of any single one. - _consecutiveFailures++; - if (_consecutiveFailures >= CONSECUTIVE_FAILURE_THRESHOLD) { - dismissNotificationBySource(NOTIFICATION_SOURCE); - } + recordFetchFailure(); } finally { _fetchInProgress = false; } } +// Best-effort, ancillary external signal — deliberately no pushError, don't +// pollute the notification center with transient network blips or schema drift +// for a non-critical feature the user can't act on anyway. Still DO dismiss any +// active "github-status" incident notification once failures persist: +// notifyTransitions() owns this notification's entire lifecycle end-to-end, and +// poll.ts deliberately excludes github-status from poll-level reconciliation +// (POLL_MANAGED_SOURCES), so if this fetch keeps failing while an incident +// notification is showing, nothing else would ever clear it. A single blip +// doesn't mean the incident resolved, though — dismissing on every failure made +// the next successful poll re-push an unchanged incident as if newly announced +// (CR-002). Gate dismissal on CONSECUTIVE_FAILURE_THRESHOLD consecutive +// failures instead of any single one. Shared by both failure modes (network/ +// parse errors in the catch block, and schema-validation failures above) so +// neither can bypass the gate. +function recordFetchFailure(): void { + _consecutiveFailures++; + if (_consecutiveFailures >= CONSECUTIVE_FAILURE_THRESHOLD) { + dismissNotificationBySource(NOTIFICATION_SOURCE); + } +} + // Test-only reset — mirrors resetPollState() (poll.ts), resetNotificationState() // (lib/errors.ts), resetEventsState() (services/events.ts). Deliberately NOT // wired to onAuthCleared (see note above: GitHub status is global, not diff --git a/tests/services/github-status.test.ts b/tests/services/github-status.test.ts index d76effba..321f4524 100644 --- a/tests/services/github-status.test.ts +++ b/tests/services/github-status.test.ts @@ -331,14 +331,51 @@ describe("fetchGitHubStatus", () => { expect(getGitHubStatus()).toBeNull(); }); - it("logs a schema-drift warning and dismisses the notification when the response fails validation, without throwing", async () => { + it("logs a schema-drift warning without throwing, and does not dismiss the notification on a single failure", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ components: "not-an-array", incidents: [] }))); await expect(fetchGitHubStatus()).resolves.toBeUndefined(); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("schema drift"), expect.anything()); - expect(mockDismissNotificationBySource).toHaveBeenCalledWith("github-status"); + expect(mockDismissNotificationBySource).not.toHaveBeenCalled(); expect(getGitHubStatus()).toBeNull(); }); + + it("keeps the prior value on a single schema-drift failure and does not dismiss the notification", async () => { + const incidents = [makeIncident({ id: "x", name: "X", body: "y", componentNames: ["Actions"] })]; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents })))); + await fetchGitHubStatus(); + const before = getGitHubStatus(); + + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ components: "not-an-array", incidents: [] }))); + mockDismissNotificationBySource.mockClear(); + + await expect(fetchGitHubStatus()).resolves.toBeUndefined(); + + expect(getGitHubStatus()).toEqual(before); + expect(mockDismissNotificationBySource).not.toHaveBeenCalled(); + }); + + it("dismisses the notification after 3 consecutive schema-drift failures, sharing the counter with network failures", async () => { + const incidents = [makeIncident({ id: "x", name: "X", body: "y", componentNames: ["Actions"] })]; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents })))); + await fetchGitHubStatus(); + + vi.spyOn(console, "warn").mockImplementation(() => {}); + const badResponse = () => jsonResponse({ components: "not-an-array", incidents: [] }); + mockDismissNotificationBySource.mockClear(); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(badResponse())); + await fetchGitHubStatus(); // schema failure 1 + expect(mockDismissNotificationBySource).not.toHaveBeenCalled(); + + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch"))); + await fetchGitHubStatus(); // network failure 2 — shares the same counter + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(badResponse())); + await fetchGitHubStatus(); // schema failure 3 + expect(mockDismissNotificationBySource).toHaveBeenCalledWith("github-status"); + }); }); From e2c2efd3ac9dbfb1ed2a5d733634bf64323c3f13 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sat, 8 Aug 2026 10:18:58 -0400 Subject: [PATCH 11/17] test(status): covers untested assumptions in github-status service Adds coverage for three previously-untested branches: an unrecognized component status string falling back to "none" severity, an empty incident_updates array yielding an empty latestUpdateBody, and resetGitHubStatusState() clearing the current status back to null. --- tests/services/github-status.test.ts | 36 ++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/services/github-status.test.ts b/tests/services/github-status.test.ts index 321f4524..514ebf6d 100644 --- a/tests/services/github-status.test.ts +++ b/tests/services/github-status.test.ts @@ -130,6 +130,18 @@ describe("fetchGitHubStatus", () => { expect(result!.incidents).toEqual([]); }); + it("treats an unrecognized component status as none severity without throwing", async () => { + const components = [...TRACKED_COMPONENT_NAMES].map((n) => + makeComponent(n, n === "Actions" ? "under_maintenance" : "operational") + ); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ components })))); + + await expect(fetchGitHubStatus()).resolves.toBeUndefined(); + + const result = getGitHubStatus(); + expect(result!.severity).toBe("none"); + }); + it("floors severity at minor when an incident is still open but its tracked components blend to operational (CR-001)", async () => { // Statuspage's "Monitoring" phase: component status resets to operational // while the incident itself remains open and still affects a tracked @@ -199,6 +211,20 @@ describe("fetchGitHubStatus", () => { expect(result!.incidents[0].latestUpdateBody).not.toContain(" { + const incident = { + ...makeIncident({ id: "empty-1", name: "No Updates Yet", body: "unused", componentNames: ["Actions"] }), + incident_updates: [] as { body: string }[], + }; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents: [incident] })))); + + await expect(fetchGitHubStatus()).resolves.toBeUndefined(); + + const result = getGitHubStatus(); + expect(result!.incidents).toHaveLength(1); + expect(result!.incidents[0].latestUpdateBody).toBe(""); + }); + it("pins the raw, unescaped incident name through to pushNotification (toast/drawer escaping boundary)", async () => { const xssName = ""; const incidents = [makeIncident({ id: "xss-1", name: xssName, body: "Investigating", componentNames: ["Actions"] })]; @@ -378,4 +404,14 @@ describe("fetchGitHubStatus", () => { await fetchGitHubStatus(); // schema failure 3 expect(mockDismissNotificationBySource).toHaveBeenCalledWith("github-status"); }); + + it("resetGitHubStatusState clears the current status back to null", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary()))); + await fetchGitHubStatus(); + expect(getGitHubStatus()).not.toBeNull(); + + resetGitHubStatusState(); + + expect(getGitHubStatus()).toBeNull(); + }); }); From 782f327fd2ca6cdc588e79f9b29a61166f49bbf9 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sat, 8 Aug 2026 10:19:47 -0400 Subject: [PATCH 12/17] fix(status): resets stale tooltip hover state after popover dismissal Dismissing the Popover via Escape or an outside click bypasses the Tooltip trigger's own onClick/onPointerLeave/onBlur handlers entirely (Kobalte's DismissableLayer calls context.close directly), so isHovered/isFocused could be left stale as true. If the pointer was still resting on the trigger, the tooltip would flash back open on its own once forceClosed flipped back to false. Adds a createEffect(on(...)) that resets both signals on that specific true-to-false transition, using SolidJS's built-in previous-value parameter rather than a hand-rolled tracking variable, matching this codebase's existing on() idiom. Adds a direct regression test in Tooltip.test.tsx toggling forceClosed via a signal. --- src/app/components/shared/Tooltip.tsx | 19 +++++++++++- tests/components/shared/Tooltip.test.tsx | 39 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/app/components/shared/Tooltip.tsx b/src/app/components/shared/Tooltip.tsx index a30c3a72..5d0d65d7 100644 --- a/src/app/components/shared/Tooltip.tsx +++ b/src/app/components/shared/Tooltip.tsx @@ -1,4 +1,4 @@ -import { createMemo, createSignal, onCleanup } from "solid-js"; +import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"; import { Tooltip as KobalteTooltip } from "@kobalte/core/tooltip"; import type { JSX } from "solid-js"; @@ -29,6 +29,23 @@ export function Tooltip(props: TooltipProps) { clearTimeout(closeTimer); }); + // A consumer (e.g. GitHubStatusBadge) can force this tooltip closed while something else, + // like a Popover, is open. Dismissing that Popover via Escape or an outside click never + // fires a pointerleave/blur on this tooltip's own trigger, so isHovered/isFocused can be + // left stale as `true`. Reset them when forceClosed transitions back to false so the + // tooltip doesn't flash back open on its own — a fresh hover/focus is required to reopen it. + createEffect(on( + () => !!props.forceClosed, + (forceClosed, prevForceClosed) => { + if (prevForceClosed && !forceClosed) { + clearTimeout(hoverTimer); + clearTimeout(closeTimer); + setIsHovered(false); + setIsFocused(false); + } + } + )); + return ( { fireEvent.pointerLeave(triggerOmitted); vi.advanceTimersByTime(500); }); + + it("resets stale hover state when forceClosed transitions back to false", () => { + const [forceClosed, setForceClosed] = createSignal(false); + const { container } = render(() => ( + + Trigger + + )); + const trigger = container.querySelector("span.inline-flex")!; + fireEvent.pointerEnter(trigger); + vi.advanceTimersByTime(300); + // Kobalte keeps the tooltip's content node mounted (for exit transitions) even once + // closed, marking it data-closed rather than removing it — so once opened, textContent + // checks can't distinguish open/closed. Check the data-expanded state instead, matching + // this file's existing convention (see "forceClosed suppresses tooltip..." above). + let tooltipContent = document.querySelector('[role="tooltip"]'); + expect(tooltipContent?.hasAttribute("data-expanded")).toBe(true); + + // Force-close while the pointer never leaves the trigger — isHovered stays stale as true. + setForceClosed(true); + tooltipContent = document.querySelector('[role="tooltip"]'); + expect(tooltipContent?.hasAttribute("data-expanded")).toBe(false); + + // Un-force-closing must not let the stale hover reopen the tooltip on its own. + setForceClosed(false); + tooltipContent = document.querySelector('[role="tooltip"]'); + expect(tooltipContent?.hasAttribute("data-expanded")).toBe(false); + + // A fresh hover still works normally afterward. + fireEvent.pointerLeave(trigger); + vi.advanceTimersByTime(500); + fireEvent.pointerEnter(trigger); + vi.advanceTimersByTime(300); + tooltipContent = document.querySelector('[role="tooltip"]'); + expect(tooltipContent?.hasAttribute("data-expanded")).toBe(true); + fireEvent.pointerLeave(trigger); + vi.advanceTimersByTime(500); + }); }); describe("InfoTooltip", () => { From d93bfa9940ea762299caae664d0989adb2ba471d Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sat, 8 Aug 2026 10:20:30 -0400 Subject: [PATCH 13/17] refactor(status): simplifies GitHubStatusBadge Show narrowing Replaces two status()!.incidents non-null assertions with an incidentList createMemo narrowing to GitHubStatusIncident[] | null, plus a single Show callback-narrowing block, matching this codebase's established idiom (StatusDot.tsx, Header.tsx, ItemRow.tsx). The shared fallback helper is named statusSummaryRow rather than neutralStatus, since a tracked component can be critical with zero recorded incidents yet. Also extends the checking-state test to open the popover and assert its fallback content, and adds a regression test dismissing the popover via Escape while simulating a held hover to confirm the tooltip does not reopen. --- .../components/shared/GitHubStatusBadge.tsx | 48 ++++++++++--------- .../shared/GitHubStatusBadge.test.tsx | 37 ++++++++++++++ 2 files changed, 63 insertions(+), 22 deletions(-) diff --git a/src/app/components/shared/GitHubStatusBadge.tsx b/src/app/components/shared/GitHubStatusBadge.tsx index d3c8373e..112f0f0e 100644 --- a/src/app/components/shared/GitHubStatusBadge.tsx +++ b/src/app/components/shared/GitHubStatusBadge.tsx @@ -18,6 +18,16 @@ export default function GitHubStatusBadge() { ? SEVERITY_CONFIG[s.severity] : { bg: "bg-base-content/20", label: "Checking GitHub status…", pulse: false }; }); + const statusSummaryRow = () => ( +
+ + {cfg().label} +
+ ); + const incidentList = createMemo(() => { + const s = status(); + return s && s.incidents.length > 0 ? s.incidents : null; + }); const [popoverOpen, setPopoverOpen] = createSignal(false); return ( @@ -34,28 +44,22 @@ export default function GitHubStatusBadge() { - 0} - fallback={ -
- - {cfg().label} -
- } - > -
    - - {(incident) => ( -
  • -
    {incident.name}
    -
    Affects: {incident.affectedComponents.join(", ")}
    - -

    {incident.latestUpdateBody}

    -
    -
  • - )} -
    -
+ + {(list) => ( +
    + + {(incident) => ( +
  • +
    {incident.name}
    +
    Affects: {incident.affectedComponents.join(", ")}
    + +

    {incident.latestUpdateBody}

    +
    +
  • + )} +
    +
+ )}
View githubstatus.com diff --git a/tests/components/shared/GitHubStatusBadge.test.tsx b/tests/components/shared/GitHubStatusBadge.test.tsx index bbded3c9..98f6dbdd 100644 --- a/tests/components/shared/GitHubStatusBadge.test.tsx +++ b/tests/components/shared/GitHubStatusBadge.test.tsx @@ -28,6 +28,11 @@ describe("GitHubStatusBadge", () => { expect(button).toBeTruthy(); const dot = container.querySelector("span.rounded-full.w-2.h-2"); expect(dot?.classList.contains("bg-base-content/20")).toBe(true); + + fireEvent.click(button); + vi.advanceTimersByTime(0); + expect(button.getAttribute("aria-expanded")).toBe("true"); + expect(document.body.textContent).toContain("Checking GitHub status…"); }); it("severity 'none' shows success dot without pulse and popover shows operational message", () => { @@ -188,4 +193,36 @@ describe("GitHubStatusBadge", () => { // ...while the Popover's own content is present. expect(document.body.textContent).toContain("Some outage"); }); + + it("does not let a stale hover reopen the tooltip after Escape dismisses the popover", () => { + mockGetGitHubStatus.mockReturnValue({ + severity: "major", + incidents: [{ id: "1", name: "Some outage", latestUpdateBody: "We are investigating", affectedComponents: ["Actions"] }], + fetchedAt: new Date(), + }); + const { container } = render(() => ); + const tooltipTrigger = container.querySelector("span.inline-flex")!; + const button = screen.getByRole("button", { name: "Major GitHub service outage" }); + + // Open the popover first (e.g. via click). + fireEvent.click(button); + vi.advanceTimersByTime(0); + expect(button.getAttribute("aria-expanded")).toBe("true"); + + // The pointer rests on the trigger *while the popover is open* — a real pointerenter + // event, independent of whatever happened before the popover opened. + fireEvent.pointerEnter(tooltipTrigger); + vi.advanceTimersByTime(300); + + // Dismiss the popover via Escape. The pointer never leaves the trigger, so no + // pointerleave/blur fires on it — Kobalte's DismissableLayer closes the popover directly, + // bypassing the Tooltip trigger's own hover/click handlers entirely. + fireEvent.keyDown(document, { key: "Escape" }); + vi.advanceTimersByTime(0); + expect(button.getAttribute("aria-expanded")).toBe("false"); + + // The tooltip must not flash back open from the now-stale hover state. + const tooltipContent = document.querySelector('[role="tooltip"]'); + expect(tooltipContent?.hasAttribute("data-expanded")).not.toBe(true); + }); }); From 4ce4bedb5ba251de86624b63aaade55ba3bb2f50 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sat, 8 Aug 2026 10:20:59 -0400 Subject: [PATCH 14/17] docs(status): corrects inaccurate "pure" comment on detectNewItems MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detectNewItems() in lib/notifications.ts mutates module-level Sets on every call — calling it "pure" in the comment above notifyTransitions() was wrong. Reworded to describe the change-detector/dispatcher split without the incorrect purity claim. --- src/app/services/github-status.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/services/github-status.ts b/src/app/services/github-status.ts index fdc2bdb8..ce911a9e 100644 --- a/src/app/services/github-status.ts +++ b/src/app/services/github-status.ts @@ -145,8 +145,9 @@ function parseSummary(raw: RawSummaryResponse): GitHubStatusSummary { } // Side-effecting: consumes a parsed summary and dispatches notification -// transitions. Mirrors the detectNewItems() (pure) / dispatchNotifications() -// (side-effecting) split already established in src/app/lib/notifications.ts. +// transitions. Mirrors the change-detector (detectNewItems()) / dispatcher +// (dispatchNotifications()) split already established in +// src/app/lib/notifications.ts. // // Both pushNotification calls pass retryable=false — an outage announcement is // not a failed/retryable operation. Message text is just the incident name(s), From a04e2f7d0f22b8151dc85cc8b90f68cca16f64f4 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sat, 8 Aug 2026 10:21:37 -0400 Subject: [PATCH 15/17] chore(status): dedupes vitest smoke config and sharpens its diagnostic vitest.smoke.config.ts duplicated plugins/environment/globals/setupFiles verbatim from vitest.config.ts. Rewrites it to extend the base config via mergeConfig, setting test.include as a direct post-merge assignment since mergeConfig concatenates array values instead of replacing them. Also replaces the smoke test's per-name expect(liveNames.has(tracked)).toBe(true) loop with a single missing-names array assertion, so a failure reports exactly which tracked component name drifted instead of a bare true/false. --- tests/services/github-status.smoke.test.ts | 8 +++-- vitest.smoke.config.ts | 34 +++++++++++++--------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/tests/services/github-status.smoke.test.ts b/tests/services/github-status.smoke.test.ts index 7d079218..11e1835d 100644 --- a/tests/services/github-status.smoke.test.ts +++ b/tests/services/github-status.smoke.test.ts @@ -13,8 +13,10 @@ describe("github-status live API shape (smoke)", () => { const json = (await res.json()) as { components: Array<{ name: string }> }; const liveNames = new Set(json.components.map((c) => c.name)); - for (const tracked of TRACKED_COMPONENT_NAMES) { - expect(liveNames.has(tracked)).toBe(true); - } + // Assert on the missing-names array (not one expect() per name) so a failure names + // exactly which tracked component drifted — e.g. `Expected: [] / Received: ["Actions"]` + // — instead of an undifferentiated `Expected: true / Received: false`. + const missing = [...TRACKED_COMPONENT_NAMES].filter((name) => !liveNames.has(name)); + expect(missing).toEqual([]); }); }); diff --git a/vitest.smoke.config.ts b/vitest.smoke.config.ts index e066f7b0..48dd24af 100644 --- a/vitest.smoke.config.ts +++ b/vitest.smoke.config.ts @@ -1,6 +1,5 @@ -import { defineConfig } from "vitest/config"; -import solid from "vite-plugin-solid"; -import tailwindcss from "@tailwindcss/vite"; +import { defineConfig, mergeConfig, type UserConfig } from "vitest/config"; +import baseConfig from "./vitest.config"; // Standalone config for live-network smoke tests (e.g. github-status.smoke.test.ts). // These are deliberately excluded from vitest.workspace.ts's "browser" project @@ -8,13 +7,22 @@ import tailwindcss from "@tailwindcss/vite"; // the network — vitest.workspace.ts's exclude takes precedence over a CLI file // filter, so re-including the same file via `vitest run ` against that // config is not possible. Run smoke tests explicitly via `pnpm test:status-smoke`. -export default defineConfig({ - plugins: [solid(), tailwindcss()], - test: { - name: "status-smoke", - environment: "happy-dom", - globals: true, - setupFiles: ["tests/setup.ts"], - include: ["tests/**/*.smoke.test.ts"], - }, -}); +// +// Extends the root vitest.config.ts to inherit plugins/environment/globals/ +// setupFiles instead of duplicating them. `include` is assigned directly on +// the merged result rather than passed into mergeConfig: Vite's mergeConfig +// concatenates array values instead of replacing them, so merging an +// `include` override here would append to vitest.config.ts's patterns +// instead of overriding them, and this config would start running the +// regular unit test suite too. +const merged = mergeConfig( + baseConfig, + defineConfig({ + test: { + name: "status-smoke", + }, + }), +) as UserConfig; +merged.test = { ...merged.test, include: ["tests/**/*.smoke.test.ts"] }; + +export default merged; From 2d8363f109b1bbd4fae29d541666332edcbe3bef Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sat, 8 Aug 2026 10:22:07 -0400 Subject: [PATCH 16/17] ci(status): adds weekly smoke test workflow The github-status smoke test existed but was never wired into CI, so Statuspage component-name drift would only surface if someone remembered to run pnpm test:status-smoke manually. Adds a scheduled GitHub Actions workflow (weekly cron + workflow_dispatch) mirroring ci.yml's existing setup steps. --- .github/workflows/status-smoke.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .github/workflows/status-smoke.yml diff --git a/.github/workflows/status-smoke.yml b/.github/workflows/status-smoke.yml new file mode 100644 index 00000000..d87380a0 --- /dev/null +++ b/.github/workflows/status-smoke.yml @@ -0,0 +1,19 @@ +name: Status Smoke Test +on: + schedule: + - cron: "0 9 * * 1" + workflow_dispatch: +permissions: + contents: read +jobs: + status-smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.18.0 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test:status-smoke From 56b4e877aaa60e031dbe8668c0d77cc8f0ab20ff Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sat, 8 Aug 2026 10:22:42 -0400 Subject: [PATCH 17/17] docs(contributing): documents the test:status-smoke script pnpm test:status-smoke existed since the original implementation but was never listed in the Running checks section, and now has a real CI consumer (the new weekly workflow). --- CONTRIBUTING.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f9af6d2a..320c2130 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,11 +33,12 @@ Fine-grained PATs need Actions (read), Contents (read), Issues (read), and Pull ## Running checks ```bash -pnpm test # unit tests (Vitest — root + mcp/) -pnpm test:e2e # Playwright E2E tests (chromium) -pnpm run typecheck # TypeScript validation (root + mcp/) -pnpm run screenshot # Capture dashboard screenshot (saves to docs/) -pnpm mcp:serve # Start the MCP server (requires GITHUB_TOKEN) +pnpm test # unit tests (Vitest — root + mcp/) +pnpm test:e2e # Playwright E2E tests (chromium) +pnpm test:status-smoke # Live-network check that githubstatus.com still reports the tracked component names (also runs on a weekly schedule in CI) +pnpm run typecheck # TypeScript validation (root + mcp/) +pnpm run screenshot # Capture dashboard screenshot (saves to docs/) +pnpm mcp:serve # Start the MCP server (requires GITHUB_TOKEN) ``` To test MCP tools interactively, use the MCP Inspector: