From 504305e1cba7c38378a38f1b6f3f80c1f246a534 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 18 Jun 2026 16:18:52 -0700 Subject: [PATCH] FN-6653: add GitHub issue analytics to Command Center Add Command Center analytics for GitHub issue activity using local task-store data. - Aggregate filed and fixed GitHub issue counts by day and repository in core. - Expose the GitHub analytics endpoint and CSV export fields through dashboard routes. - Add a GitHub Command Center area with trend and repository visualizations. - Cover analytics aggregation, API responses, CSV output, and dashboard rendering with tests. - Document the dashboard GitHub analytics surface and add a patch changeset. Files changed: .../fn-6653-command-center-github-issue-stats.md | 5 + docs/dashboard-guide.md | 2 + .../src/__tests__/github-issue-analytics.test.ts | 242 +++++++++++++++++++++ packages/core/src/github-issue-analytics.ts | 196 +++++++++++++++++ packages/core/src/index.ts | 7 + .../components/command-center/CommandCenter.tsx | 5 + .../__tests__/CommandCenter.mobile-scroll.test.tsx | 10 + .../__tests__/CommandCenter.test.tsx | 35 ++- .../components/command-center/areas/GithubArea.tsx | 111 ++++++++++ .../command-center/areas/__tests__/areas.test.tsx | 73 +++++++ .../register-command-center-routes.auth.test.ts | 1 + .../register-command-center-routes.test.ts | 66 ++++++ packages/dashboard/src/command-center-csv.ts | 20 ++ .../src/routes/register-command-center-routes.ts | 25 +++ 14 files changed, 796 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-6653 Fusion-Task-Lineage: fe1b0484-779e-4602-a7c2-9ba41a84916e --- ...-6653-command-center-github-issue-stats.md | 5 + docs/dashboard-guide.md | 2 + .../__tests__/github-issue-analytics.test.ts | 242 ++++++++++++++++++ packages/core/src/github-issue-analytics.ts | 196 ++++++++++++++ packages/core/src/index.ts | 7 + .../command-center/CommandCenter.tsx | 5 + .../CommandCenter.mobile-scroll.test.tsx | 10 + .../__tests__/CommandCenter.test.tsx | 35 ++- .../command-center/areas/GithubArea.tsx | 111 ++++++++ .../areas/__tests__/areas.test.tsx | 73 ++++++ ...egister-command-center-routes.auth.test.ts | 1 + .../register-command-center-routes.test.ts | 66 +++++ packages/dashboard/src/command-center-csv.ts | 20 ++ .../routes/register-command-center-routes.ts | 25 ++ 14 files changed, 796 insertions(+), 2 deletions(-) create mode 100644 .changeset/fn-6653-command-center-github-issue-stats.md create mode 100644 packages/core/src/__tests__/github-issue-analytics.test.ts create mode 100644 packages/core/src/github-issue-analytics.ts create mode 100644 packages/dashboard/app/components/command-center/areas/GithubArea.tsx diff --git a/.changeset/fn-6653-command-center-github-issue-stats.md b/.changeset/fn-6653-command-center-github-issue-stats.md new file mode 100644 index 0000000000..e4c3c2a66b --- /dev/null +++ b/.changeset/fn-6653-command-center-github-issue-stats.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Add a Command Center GitHub issue analytics endpoint and dashboard area showing issues filed by Fusion, issues fixed by Fusion, net flow, daily trends, and by-repository breakdowns from the local project task store. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 116516488c..e84d046197 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -669,11 +669,13 @@ Features: - **Activity** tracks sessions, messages, active nodes, active agents, and stickiness, then renders live animated line charts for messages/day, active agents/day, active nodes/day, and combined throughput/day (`messages + active agents + active nodes`). These charts reuse the existing activity analytics endpoint, refresh on a bounded 15-second cadence while mounted, keep the previous data visible during refreshes, and disable decorative draw-on motion for reduced-motion users. - **Productivity** separates outcome counters (commits and pull requests) from volume proxies such as modified files, lines changed, and files by language. - **Ecosystem** shows active model breadth and per-model task activity; unavailable plugin-activation metrics render as unavailable rather than zero. +- **GitHub** shows local GitHub issue flow for the selected range: **Filed by Fusion** counts tasks with a persisted `githubTracking.issue`, **Fixed by Fusion** counts tasks imported from GitHub source issues (`sourceIssueProvider = "github"`) that are currently in `done`, using task `updatedAt` as the documented completion-time approximation because Fusion does not persist a separate source-issue closed timestamp. The area shows filed/fixed/net stat cards, filed-vs-fixed daily sparklines, and a by-repository bar breakdown; it never calls GitHub, the `gh` CLI, or any external network source. - **Signals** shows external signal totals, open/resolved counts, MTTR, and source/severity breakdowns when signal sources are connected. - **Mission Control** shows live active sessions/runs/nodes, current sessions and nodes, an animated live activity snapshot, and a live SDLC funnel; when idle it reports that live updates resume when work starts. Motion-heavy accents respect reduced-motion preferences. Data states: - Overview shows a loading state while core analytics settle, then shows `No usage data yet. Run some agents to populate the Command Center.` only after the selected range has settled with no core usage data. +- GitHub issue analytics is local and additive: empty filed/fixed totals render the GitHub area's empty state; malformed historical `githubTracking` JSON is skipped instead of breaking the Command Center. - Signals is best-effort: if the Signals endpoint is absent or no signal source is connected, the Signals area falls back to its empty state and other Command Center metrics remain valid. ## Reliability View diff --git a/packages/core/src/__tests__/github-issue-analytics.test.ts b/packages/core/src/__tests__/github-issue-analytics.test.ts new file mode 100644 index 0000000000..dbbdca5e45 --- /dev/null +++ b/packages/core/src/__tests__/github-issue-analytics.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { Database } from "../db.js"; +import { aggregateGithubIssueAnalytics } from "../github-issue-analytics.js"; + +function insertTrackedIssue( + db: Database, + id: string, + issue: Record, + updatedAt = "2026-04-01T00:00:00.000Z", +): void { + db.prepare( + `INSERT INTO tasks (id, description, "column", createdAt, updatedAt, githubTracking) + VALUES (?, 'desc', 'todo', ?, ?, ?)`, + ).run(id, updatedAt, updatedAt, JSON.stringify({ issue })); +} + +function insertRawGithubTracking(db: Database, id: string, githubTracking: string): void { + db.prepare( + `INSERT INTO tasks (id, description, "column", createdAt, updatedAt, githubTracking) + VALUES (?, 'desc', 'todo', '2026-04-01T00:00:00.000Z', '2026-04-01T00:00:00.000Z', ?)`, + ).run(id, githubTracking); +} + +function insertSourceIssueTask( + db: Database, + id: string, + opts: { + provider: string; + repository: string; + column: string; + updatedAt: string; + issueNumber?: number; + }, +): void { + db.prepare( + `INSERT INTO tasks ( + id, description, "column", createdAt, updatedAt, + sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, + sourceIssueNumber, sourceIssueUrl + ) VALUES (?, 'desc', ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + id, + opts.column, + opts.updatedAt, + opts.updatedAt, + opts.provider, + opts.repository, + String(opts.issueNumber ?? 1), + opts.issueNumber ?? 1, + `https://example.test/${id}`, + ); +} + +describe("github-issue-analytics", () => { + let tmpDir: string; + let db: Database; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "kb-github-issue-analytics-")); + db = new Database(join(tmpDir, ".fusion")); + db.init(); + }); + + afterEach(async () => { + db.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("aggregates filed and fixed issue totals, daily buckets, and repositories", () => { + insertTrackedIssue(db, "filed-a-1", { + owner: "acme", + repo: "alpha", + number: 10, + url: "https://github.com/acme/alpha/issues/10", + createdAt: "2026-04-01T12:00:00.000Z", + }); + insertTrackedIssue(db, "filed-a-2", { + owner: "acme", + repo: "alpha", + number: 11, + url: "https://github.com/acme/alpha/issues/11", + createdAt: "2026-04-02T12:00:00.000Z", + }); + insertTrackedIssue(db, "filed-b-1", { + owner: "acme", + repo: "beta", + number: 12, + url: "https://github.com/acme/beta/issues/12", + createdAt: "2026-04-02T13:00:00.000Z", + }); + insertTrackedIssue(db, "filed-old", { + owner: "acme", + repo: "old", + number: 9, + url: "https://github.com/acme/old/issues/9", + createdAt: "2026-03-01T00:00:00.000Z", + }); + + insertSourceIssueTask(db, "fixed-a", { + provider: "github", + repository: "acme/alpha", + column: "done", + updatedAt: "2026-04-02T20:00:00.000Z", + issueNumber: 20, + }); + insertSourceIssueTask(db, "fixed-b", { + provider: "github", + repository: "acme/beta", + column: "done", + updatedAt: "2026-04-03T20:00:00.000Z", + issueNumber: 21, + }); + insertSourceIssueTask(db, "not-done", { + provider: "github", + repository: "acme/alpha", + column: "todo", + updatedAt: "2026-04-02T20:00:00.000Z", + issueNumber: 22, + }); + insertSourceIssueTask(db, "not-github", { + provider: "gitlab", + repository: "acme/alpha", + column: "done", + updatedAt: "2026-04-02T20:00:00.000Z", + issueNumber: 23, + }); + + const result = aggregateGithubIssueAnalytics(db, { + from: "2026-04-01T00:00:00.000Z", + to: "2026-04-03T23:59:59.999Z", + }); + + expect(result.filed).toBe(3); + expect(result.fixed).toBe(2); + expect(result.net).toBe(1); + expect(result.daily).toEqual([ + { date: "2026-04-01", filed: 1, fixed: 0 }, + { date: "2026-04-02", filed: 2, fixed: 1 }, + { date: "2026-04-03", filed: 0, fixed: 1 }, + ]); + expect(result.byRepo).toEqual([ + { repo: "acme/alpha", filed: 2, fixed: 1 }, + { repo: "acme/beta", filed: 1, fixed: 1 }, + ]); + }); + + it("treats range bounds as inclusive", () => { + insertTrackedIssue(db, "filed-from", { + owner: "acme", + repo: "alpha", + number: 1, + url: "https://github.com/acme/alpha/issues/1", + createdAt: "2026-04-01T00:00:00.000Z", + }); + insertSourceIssueTask(db, "fixed-to", { + provider: "github", + repository: "acme/alpha", + column: "done", + updatedAt: "2026-04-03T00:00:00.000Z", + }); + + const result = aggregateGithubIssueAnalytics(db, { + from: "2026-04-01T00:00:00.000Z", + to: "2026-04-03T00:00:00.000Z", + }); + + expect(result.filed).toBe(1); + expect(result.fixed).toBe(1); + expect(result.daily).toEqual([ + { date: "2026-04-01", filed: 1, fixed: 0 }, + { date: "2026-04-03", filed: 0, fixed: 1 }, + ]); + }); + + it("returns zeroed structures for an empty range", () => { + insertTrackedIssue(db, "filed", { + owner: "acme", + repo: "alpha", + number: 1, + url: "https://github.com/acme/alpha/issues/1", + createdAt: "2026-04-01T00:00:00.000Z", + }); + insertSourceIssueTask(db, "fixed", { + provider: "github", + repository: "acme/alpha", + column: "done", + updatedAt: "2026-04-01T00:00:00.000Z", + }); + + const result = aggregateGithubIssueAnalytics(db, { + from: "2027-01-01T00:00:00.000Z", + to: "2027-01-31T00:00:00.000Z", + }); + + expect(result).toMatchObject({ + from: "2027-01-01T00:00:00.000Z", + to: "2027-01-31T00:00:00.000Z", + filed: 0, + fixed: 0, + net: 0, + daily: [], + byRepo: [], + }); + }); + + it("skips malformed tracking JSON and issue-less rows without throwing", () => { + insertRawGithubTracking(db, "bad-json", "{not json"); + insertRawGithubTracking(db, "empty-object", "{}"); + insertRawGithubTracking(db, "no-issue", JSON.stringify({ enabled: true })); + + expect(() => aggregateGithubIssueAnalytics(db, {})).not.toThrow(); + expect(aggregateGithubIssueAnalytics(db, {})).toMatchObject({ + filed: 0, + fixed: 0, + daily: [], + byRepo: [], + }); + }); + + it("counts undated filed issues in totals without fabricating a daily date", () => { + insertTrackedIssue(db, "undated", { + owner: "acme", + repo: "alpha", + number: 1, + url: "https://github.com/acme/alpha/issues/1", + }); + + const result = aggregateGithubIssueAnalytics(db, { + from: "2026-04-01T00:00:00.000Z", + to: "2026-04-30T00:00:00.000Z", + }); + + expect(result.filed).toBe(1); + expect(result.daily).toEqual([]); + expect(result.byRepo).toEqual([{ repo: "acme/alpha", filed: 1, fixed: 0 }]); + }); +}); diff --git a/packages/core/src/github-issue-analytics.ts b/packages/core/src/github-issue-analytics.ts new file mode 100644 index 0000000000..bdc5b19307 --- /dev/null +++ b/packages/core/src/github-issue-analytics.ts @@ -0,0 +1,196 @@ +import type { Database } from "./db.js"; + +/** + * FNXC:CommandCenterGithub 2026-06-18-00:00: + * Command Center GitHub issue analytics must derive filed/fixed counts only from the project-scoped local task store. "Filed" means a task has `githubTracking.issue`; "fixed" means an imported GitHub source issue task is currently in the `done` column. Fusion does not persist a source issue closed timestamp, so fixed trends use `updatedAt` as the documented completion approximation and never fabricate a close date. + */ + +export interface GithubIssueAnalyticsQuery { + /** ISO-8601 lower bound (inclusive). */ + from?: string; + /** ISO-8601 upper bound (inclusive). */ + to?: string; +} + +export interface GithubIssueDailyPoint { + /** UTC date, `YYYY-MM-DD`. */ + date: string; + /** Fusion-created GitHub issues filed on this date. */ + filed: number; + /** Imported GitHub issue tasks completed on this date. */ + fixed: number; +} + +export interface GithubIssueRepoBreakdown { + /** Repository key, usually `owner/repo`; `(unknown)` when historical data lacks it. */ + repo: string; + filed: number; + fixed: number; +} + +export interface GithubIssueAnalytics { + from: string | null; + to: string | null; + /** Fusion-created GitHub issues in range. Undated tracked issues are included because no date can be honestly inferred. */ + filed: number; + /** Imported GitHub issue tasks currently in `done`, filtered by `updatedAt` as the completion approximation. */ + fixed: number; + /** Filed minus fixed. */ + net: number; + /** Filed/fixed counts grouped by UTC day, ascending. */ + daily: GithubIssueDailyPoint[]; + /** Filed/fixed counts grouped by repository, descending by total activity. */ + byRepo: GithubIssueRepoBreakdown[]; +} + +interface GithubTrackingRow { + githubTracking: string | null; +} + +interface FixedIssueRow { + sourceIssueRepository: string | null; + updatedAt: string | null; +} + +interface TrackedIssueLike { + number?: unknown; + owner?: unknown; + repo?: unknown; + createdAt?: unknown; +} + +interface GithubTrackingLike { + issue?: TrackedIssueLike; +} + +function isInRange(iso: string, query: GithubIssueAnalyticsQuery): boolean { + const t = Date.parse(iso); + if (!Number.isFinite(t)) return false; + if (query.from !== undefined && t < Date.parse(query.from)) return false; + if (query.to !== undefined && t > Date.parse(query.to)) return false; + return true; +} + +function dayKey(iso: string): string | null { + const t = Date.parse(iso); + if (!Number.isFinite(t)) return null; + return new Date(t).toISOString().slice(0, 10); +} + +function repoFromIssue(issue: TrackedIssueLike): string { + const owner = typeof issue.owner === "string" ? issue.owner.trim() : ""; + const repo = typeof issue.repo === "string" ? issue.repo.trim() : ""; + if (owner && repo) return `${owner}/${repo}`; + if (repo) return repo; + return "(unknown)"; +} + +function addDaily( + daily: Map, + date: string, + kind: "filed" | "fixed", +): void { + const current = daily.get(date) ?? { filed: 0, fixed: 0 }; + current[kind] += 1; + daily.set(date, current); +} + +function addRepo( + byRepo: Map, + repo: string, + kind: "filed" | "fixed", +): void { + const current = byRepo.get(repo) ?? { filed: 0, fixed: 0 }; + current[kind] += 1; + byRepo.set(repo, current); +} + +/** + * Aggregate locally persisted GitHub issue analytics for the Command Center. + * Empty ranges return zeroed structures, never null collections. Bounds are + * inclusive. Malformed historical `githubTracking` JSON is ignored rather than + * failing the entire analytics request. + */ +export function aggregateGithubIssueAnalytics( + db: Database, + query: GithubIssueAnalyticsQuery = {}, +): GithubIssueAnalytics { + const daily = new Map(); + const byRepo = new Map(); + + const filedRows = db + .prepare( + "SELECT githubTracking FROM tasks WHERE githubTracking IS NOT NULL AND githubTracking NOT IN ('', '{}')", + ) + .all() as GithubTrackingRow[]; + + let filed = 0; + for (const row of filedRows) { + if (!row.githubTracking) continue; + let parsed: unknown; + try { + parsed = JSON.parse(row.githubTracking); + } catch { + continue; + } + const tracking = parsed as GithubTrackingLike; + const issue = tracking.issue; + if (!issue || typeof issue.number !== "number" || !Number.isFinite(issue.number)) continue; + + const createdAt = typeof issue.createdAt === "string" ? issue.createdAt : undefined; + const hasUsableDate = createdAt !== undefined && dayKey(createdAt) !== null; + if (hasUsableDate && !isInRange(createdAt, query)) continue; + + filed += 1; + const repo = repoFromIssue(issue); + addRepo(byRepo, repo, "filed"); + if (hasUsableDate && createdAt !== undefined) { + const day = dayKey(createdAt); + if (day !== null) addDaily(daily, day, "filed"); + } + } + + const fixedClauses = ["sourceIssueProvider = 'github'", "\"column\" = 'done'"]; + const fixedParams: string[] = []; + if (query.from !== undefined) { + fixedClauses.push("updatedAt >= ?"); + fixedParams.push(query.from); + } + if (query.to !== undefined) { + fixedClauses.push("updatedAt <= ?"); + fixedParams.push(query.to); + } + const fixedRows = db + .prepare( + `SELECT sourceIssueRepository, updatedAt FROM tasks WHERE ${fixedClauses.join(" AND ")}`, + ) + .all(...fixedParams) as FixedIssueRow[]; + + let fixed = 0; + for (const row of fixedRows) { + fixed += 1; + const repo = row.sourceIssueRepository?.trim() || "(unknown)"; + addRepo(byRepo, repo, "fixed"); + if (row.updatedAt) { + const day = dayKey(row.updatedAt); + if (day !== null) addDaily(daily, day, "fixed"); + } + } + + return { + from: query.from ?? null, + to: query.to ?? null, + filed, + fixed, + net: filed - fixed, + daily: [...daily.entries()] + .map(([date, counts]) => ({ date, filed: counts.filed, fixed: counts.fixed })) + .sort((a, b) => a.date.localeCompare(b.date)), + byRepo: [...byRepo.entries()] + .map(([repo, counts]) => ({ repo, filed: counts.filed, fixed: counts.fixed })) + .sort((a, b) => { + const total = b.filed + b.fixed - (a.filed + a.fixed); + return total !== 0 ? total : a.repo.localeCompare(b.repo); + }), + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6bcdce1615..efc8e00949 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -576,6 +576,13 @@ export type { LanguageCount, LocSummary, } from "./productivity-analytics.js"; +export { aggregateGithubIssueAnalytics } from "./github-issue-analytics.js"; +export type { + GithubIssueAnalytics, + GithubIssueAnalyticsQuery, + GithubIssueDailyPoint, + GithubIssueRepoBreakdown, +} from "./github-issue-analytics.js"; export { composeLiveSnapshot } from "./command-center-live.js"; export type { LiveSnapshot, diff --git a/packages/dashboard/app/components/command-center/CommandCenter.tsx b/packages/dashboard/app/components/command-center/CommandCenter.tsx index ff9e66194e..ee63fb4934 100644 --- a/packages/dashboard/app/components/command-center/CommandCenter.tsx +++ b/packages/dashboard/app/components/command-center/CommandCenter.tsx @@ -9,6 +9,7 @@ import { ToolsArea } from "./areas/ToolsArea"; import { ActivityArea } from "./areas/ActivityArea"; import { ProductivityArea } from "./areas/ProductivityArea"; import { EcosystemArea } from "./areas/EcosystemArea"; +import { GithubArea } from "./areas/GithubArea"; import { SignalsArea } from "./areas/SignalsArea"; import { MissionControlPanel } from "./MissionControlPanel"; import { SdlcFunnel } from "./SdlcFunnel"; @@ -26,6 +27,7 @@ type SubViewId = | "activity" | "productivity" | "ecosystem" + | "github" | "signals" | "mission-control"; @@ -43,6 +45,7 @@ function useSubViews(): SubView[] { { id: "activity", label: t("commandCenter.tabs.activity", "Activity") }, { id: "productivity", label: t("commandCenter.tabs.productivity", "Productivity") }, { id: "ecosystem", label: t("commandCenter.tabs.ecosystem", "Ecosystem") }, + { id: "github", label: t("commandCenter.tabs.github", "GitHub") }, { id: "signals", label: t("commandCenter.tabs.signals", "Signals") }, { id: "mission-control", label: t("commandCenter.tabs.missionControl", "Mission Control") }, ]; @@ -422,6 +425,8 @@ export function CommandCenter() { return ; case "ecosystem": return ; + case "github": + return ; case "signals": return ; case "mission-control": diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx index 315e297d23..1699ffc4b3 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx @@ -85,6 +85,10 @@ function populatedToolsFixture() { }; } +function emptyGithubFixture() { + return { filed: 0, fixed: 0, net: 0, daily: [], byRepo: [] }; +} + function populatedActivityFixture() { return { ...emptyActivityFixture(), @@ -113,6 +117,7 @@ function mockOverviewApi({ populated = false }: { populated?: boolean } = {}) { if (path.startsWith("/command-center/tokens")) return Promise.resolve(populated ? populatedTokenFixture() : emptyTokenFixture()); if (path.startsWith("/command-center/tools")) return Promise.resolve(populated ? populatedToolsFixture() : emptyToolsFixture()); if (path.startsWith("/command-center/activity")) return Promise.resolve(populated ? populatedActivityFixture() : emptyActivityFixture()); + if (path.startsWith("/command-center/github")) return Promise.resolve(emptyGithubFixture()); if (path.startsWith("/command-center/signals")) return Promise.resolve({ totalSignals: 0, open: 0, resolved: 0, mttr: { value: null, unavailable: true }, bySource: [], bySeverity: [] }); if (path === "/command-center/live") { return Promise.resolve({ @@ -192,6 +197,11 @@ describe("CommandCenter mobile scroll regression (FN-6595)", () => { const tokensPanel = screen.getByTestId("command-center-panel-tokens"); expect(tokensPanel).toBe(screen.getByRole("tabpanel")); assertScrollOwnerContract(tokensPanel); + + fireEvent.click(screen.getByTestId("command-center-tab-github")); + const githubPanel = screen.getByTestId("command-center-panel-github"); + expect(githubPanel).toBe(screen.getByRole("tabpanel")); + assertScrollOwnerContract(githubPanel); }); it("preserves the mobile scroll owner when the populated Overview charts render", async () => { diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx index 098ccc3283..7676ea576b 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx @@ -102,6 +102,18 @@ function activityFixture(overrides: Partial activityFixture({ sessions: 0, messages: 0, activeNodes: 0, activeAgents: 0, doneInRange: 0 }); +function githubFixture(filed = 0, fixed = 0) { + return { + from: "2026-06-08", + to: null, + filed, + fixed, + net: filed - fixed, + daily: filed || fixed ? [{ date: "2026-06-08", filed, fixed }] : [], + byRepo: filed || fixed ? [{ repo: "acme/alpha", filed, fixed }] : [], + }; +} + function signalsFixture(open = 2) { return { totalSignals: open, @@ -129,12 +141,14 @@ function mockOverviewApi({ tokens = tokenFixture(), tools = toolsFixture(), activity = activityFixture(), + github = githubFixture(), signals = signalsFixture(), live = liveFixture(), }: { tokens?: unknown; tools?: unknown; activity?: unknown; + github?: unknown; signals?: unknown; live?: unknown; } = {}) { @@ -142,6 +156,7 @@ function mockOverviewApi({ if (path.startsWith("/command-center/tokens")) return Promise.resolve(tokens); if (path.startsWith("/command-center/tools")) return Promise.resolve(tools); if (path.startsWith("/command-center/activity")) return Promise.resolve(activity); + if (path.startsWith("/command-center/github")) return Promise.resolve(github); if (path.startsWith("/command-center/signals")) { return signals instanceof Error ? Promise.reject(signals) : Promise.resolve(signals); } @@ -231,6 +246,7 @@ describe("CommandCenter shell", () => { if (path.startsWith("/command-center/tokens")) return Promise.resolve(tokenFixture(tokenTotal)); if (path.startsWith("/command-center/tools")) return Promise.resolve(toolsFixture()); if (path.startsWith("/command-center/activity")) return Promise.resolve(activityFixture()); + if (path.startsWith("/command-center/github")) return Promise.resolve(githubFixture()); if (path.startsWith("/command-center/signals")) return Promise.resolve(signalsFixture(2)); if (path === "/command-center/live") return Promise.resolve(liveFixture([{ column: "in-progress", count: 3 }])); return Promise.reject(new Error(`Unhandled api path: ${path}`)); @@ -309,6 +325,7 @@ describe("CommandCenter shell", () => { return Promise.resolve(activityFixture({ doneInRange: allTime ? 21 : 7, inProgress: allTime ? 99 : 12 })); } if (path.startsWith("/command-center/signals")) return Promise.resolve(signalsFixture(2)); + if (path.startsWith("/command-center/github")) return Promise.resolve(githubFixture()); if (path === "/command-center/live") return Promise.resolve(liveFixture([{ column: "in-progress", count: 4 }])); return Promise.reject(new Error(`Unhandled api path: ${path}`)); }); @@ -419,6 +436,7 @@ describe("CommandCenter shell", () => { if (path.startsWith("/command-center/tools")) return Promise.resolve(populated ? toolsFixture() : toolsFixture(0)); if (path.startsWith("/command-center/activity")) return Promise.resolve(populated ? activityFixture() : emptyActivityFixture()); if (path.startsWith("/command-center/signals")) return Promise.resolve(populated ? signalsFixture() : signalsFixture(0)); + if (path.startsWith("/command-center/github")) return Promise.resolve(githubFixture()); if (path === "/command-center/live") return Promise.resolve(liveFixture([{ column: "in-progress", count: populated ? 3 : 0 }])); return Promise.reject(new Error(`Unhandled api path: ${path}`)); }); @@ -439,8 +457,8 @@ describe("CommandCenter shell", () => { render(); const tablist = screen.getByRole("tablist"); const tabs = within(tablist).getAllByRole("tab"); - // Overview, Tokens, Tools, Activity, Productivity, Ecosystem, Signals, Mission Control. - expect(tabs.length).toBe(8); + // Overview, Tokens, Tools, Activity, Productivity, Ecosystem, GitHub, Signals, Mission Control. + expect(tabs.length).toBe(9); // roving tabindex: exactly one tab is focusable. const focusable = tabs.filter((tab) => tab.getAttribute("tabindex") === "0"); expect(focusable.length).toBe(1); @@ -455,6 +473,19 @@ describe("CommandCenter shell", () => { expect(screen.getByTestId("command-center-panel-tokens")).toBeTruthy(); }); + it("renders and routes the GitHub tab exactly once", async () => { + mockOverviewApi({ github: githubFixture(4, 2) }); + render(); + expect(screen.getAllByTestId("command-center-tab-github")).toHaveLength(1); + + fireEvent.click(screen.getByTestId("command-center-tab-github")); + expect(screen.getByTestId("command-center-tab-github").getAttribute("aria-selected")).toBe("true"); + expect(screen.getByTestId("command-center-panel-github")).toBeTruthy(); + await screen.findByTestId("cc-area-github"); + expect(screen.getByTestId("cc-github-filed").textContent).toContain("4"); + expect(screen.getByTestId("cc-github-fixed").textContent).toContain("2"); + }); + it("supports arrow-key navigation between tabs (roving tabindex)", () => { render(); const overviewTab = screen.getByTestId("command-center-tab-overview"); diff --git a/packages/dashboard/app/components/command-center/areas/GithubArea.tsx b/packages/dashboard/app/components/command-center/areas/GithubArea.tsx new file mode 100644 index 0000000000..b18c78c40b --- /dev/null +++ b/packages/dashboard/app/components/command-center/areas/GithubArea.tsx @@ -0,0 +1,111 @@ +/* +FNXC:CommandCenterGithub 2026-06-18-00:00: +The GitHub Command Center area visualizes only locally persisted task-store data: filed issues come from `githubTracking.issue`, and fixed issues are source-GitHub tasks currently in `done` using `updatedAt` as the documented completion approximation. No GitHub API or `gh` CLI calls belong in this rendering path. +*/ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import type { GithubIssueAnalytics } from "@fusion/core"; +import type { DateRange } from "../DateRangePicker"; +import { Bar } from "../charts/Bar"; +import { Sparkline } from "../charts/Sparkline"; +import { AreaShell } from "./AreaShell"; +import { useAnalyticsArea } from "./useAnalyticsArea"; +import { formatCount } from "./areaShared"; + +export function GithubArea({ range }: { range: DateRange }) { + const { t } = useTranslation("app"); + const { data, isLoading, error } = useAnalyticsArea( + "/command-center/github", + range, + ); + + const daily = useMemo(() => data?.daily ?? [], [data?.daily]); + const byRepo = useMemo(() => data?.byRepo ?? [], [data?.byRepo]); + const filedValues = useMemo(() => daily.map((d) => d.filed), [daily]); + const fixedValues = useMemo(() => daily.map((d) => d.fixed), [daily]); + const maxDaily = useMemo( + () => Math.max(0, ...filedValues, ...fixedValues), + [filedValues, fixedValues], + ); + const repoBars = useMemo( + () => + byRepo.slice(0, 12).map((repo) => ({ + label: repo.repo, + value: repo.filed + repo.fixed, + valueLabel: t("commandCenter.github.repoValue", "{{filed}} filed / {{fixed}} fixed", { + filed: formatCount(repo.filed), + fixed: formatCount(repo.fixed), + }), + })), + [byRepo, t], + ); + + const filed = data?.filed ?? 0; + const fixed = data?.fixed ?? 0; + const net = data?.net ?? filed - fixed; + const isEmpty = !data || (filed === 0 && fixed === 0); + const hasDailyTrend = daily.length > 0; + const hasRepoBreakdown = repoBars.length > 0; + + return ( + +
+

{t("commandCenter.github.totalsTitle", "GitHub issue flow")}

+
+
+
{t("commandCenter.github.filed", "Filed by Fusion")}
+
{formatCount(filed)}
+
+
+
{t("commandCenter.github.fixed", "Fixed by Fusion")}
+
{formatCount(fixed)}
+ + {t("commandCenter.github.fixedApproximation", "Uses done tasks updated in range")} + +
+
+
{t("commandCenter.github.net", "Net")}
+
{formatCount(net)}
+
+
+
+ + {hasDailyTrend ? ( +
+

{t("commandCenter.github.dailyTrend", "Filed vs fixed trend")}

+
+
+
{t("commandCenter.github.filedTrend", "Filed")}
+ +
+
+
{t("commandCenter.github.fixedTrend", "Fixed")}
+ +
+
+
+ ) : null} + + {hasRepoBreakdown ? ( +
+

{t("commandCenter.github.byRepo", "By repository")}

+ +
+ ) : null} +
+ ); +} diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx index e8818b54a6..1c65d86b71 100644 --- a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx +++ b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx @@ -14,6 +14,7 @@ vi.mock("../../../../api/legacy", () => ({ import { TokensArea } from "../TokensArea"; import { ToolsArea } from "../ToolsArea"; import { ProductivityArea } from "../ProductivityArea"; +import { GithubArea } from "../GithubArea"; import { SignalsArea } from "../SignalsArea"; import { ActivityArea } from "../ActivityArea"; import { useAnalyticsArea } from "../useAnalyticsArea"; @@ -83,6 +84,24 @@ function tokenFixture() { }; } +function githubFixture() { + return { + from: "2026-06-08", + to: null, + filed: 5, + fixed: 3, + net: 2, + daily: [ + { date: "2026-06-08", filed: 2, fixed: 1 }, + { date: "2026-06-09", filed: 3, fixed: 2 }, + ], + byRepo: [ + { repo: "acme/alpha", filed: 4, fixed: 1 }, + { repo: "acme/beta", filed: 1, fixed: 2 }, + ], + }; +} + function activityFixture() { return { from: "2026-06-08", @@ -449,6 +468,60 @@ describe("ProductivityArea", () => { }); }); +describe("GithubArea", () => { + it("renders filed/fixed/net stats, daily trend, and by-repo bars", async () => { + apiMock.mockResolvedValue(githubFixture()); + render(); + + await screen.findByTestId("cc-area-github"); + expect(screen.getByTestId("cc-github-filed").textContent).toContain("5"); + expect(screen.getByTestId("cc-github-fixed").textContent).toContain("3"); + expect(screen.getByTestId("cc-github-net").textContent).toContain("2"); + expect(screen.getByTestId("cc-github-daily-trend")).toBeTruthy(); + expect(screen.getByRole("img", { name: "Filed" })).toBeTruthy(); + expect(screen.getByRole("img", { name: "Fixed" })).toBeTruthy(); + const repoChart = screen.getByRole("list", { name: "By repository" }); + expect(within(repoChart).getByText("acme/alpha")).toBeTruthy(); + expect(within(repoChart).getByLabelText("acme/alpha: 4 filed / 1 fixed")).toBeTruthy(); + }); + + it("renders the empty state without empty chart shells", async () => { + apiMock.mockResolvedValue({ ...githubFixture(), filed: 0, fixed: 0, net: 0, daily: [], byRepo: [] }); + render(); + + await screen.findByTestId("cc-area-github-empty"); + expect(screen.queryByTestId("cc-github-daily-trend")).toBeNull(); + expect(screen.queryByTestId("cc-github-by-repo")).toBeNull(); + }); + + it("renders loading and error states", async () => { + apiMock.mockImplementationOnce(() => new Promise(() => undefined)); + const { unmount } = render(); + expect(screen.getByTestId("cc-area-github-loading")).toBeTruthy(); + unmount(); + + apiMock.mockRejectedValueOnce(new Error("github failed")); + render(); + await screen.findByTestId("cc-area-github-error"); + expect(screen.getByTestId("cc-area-github-error").textContent).toContain("github failed"); + }); + + it("handles undefined chart arrays and zero values without NaN output", async () => { + apiMock.mockResolvedValue({ ...githubFixture(), filed: 1, fixed: 0, net: 1, daily: undefined, byRepo: undefined }); + render(); + + await screen.findByTestId("cc-area-github"); + expect(screen.queryByTestId("cc-github-daily-trend")).toBeNull(); + expect(screen.queryByTestId("cc-github-by-repo")).toBeNull(); + expect(screen.getByTestId("cc-area-github").textContent).not.toContain("NaN"); + }); + + it("rejects an inverted custom range client-side without fetching", async () => { + render(); + await waitFor(() => expect(apiMock).not.toHaveBeenCalled()); + }); +}); + describe("SignalsArea", () => { it("renders the empty state (not an error) when the signals endpoint is missing", async () => { apiMock.mockRejectedValue(new Error("API returned HTML instead of JSON (404)")); diff --git a/packages/dashboard/src/__tests__/register-command-center-routes.auth.test.ts b/packages/dashboard/src/__tests__/register-command-center-routes.auth.test.ts index b7d48b8eb8..f62272a9b2 100644 --- a/packages/dashboard/src/__tests__/register-command-center-routes.auth.test.ts +++ b/packages/dashboard/src/__tests__/register-command-center-routes.auth.test.ts @@ -64,6 +64,7 @@ const ENDPOINTS = [ "/api/command-center/tools", "/api/command-center/activity", "/api/command-center/productivity", + "/api/command-center/github", "/api/command-center/live", ]; diff --git a/packages/dashboard/src/__tests__/register-command-center-routes.test.ts b/packages/dashboard/src/__tests__/register-command-center-routes.test.ts index 20539bf350..1711b293be 100644 --- a/packages/dashboard/src/__tests__/register-command-center-routes.test.ts +++ b/packages/dashboard/src/__tests__/register-command-center-routes.test.ts @@ -49,6 +49,42 @@ function seedDb(db: Database, opts: { taskId: string; model: string; tokens: num }); } +function seedGithubIssueMetrics(db: Database, opts: { prefix: string; repo: string; filed: number; fixed: number }): void { + for (let i = 0; i < opts.filed; i += 1) { + db.prepare( + `INSERT INTO tasks (id, description, "column", createdAt, updatedAt, githubTracking) + VALUES (?, 'desc', 'todo', '2026-03-02T00:00:00.000Z', '2026-03-02T00:00:00.000Z', ?)`, + ).run( + `${opts.prefix}-filed-${i}`, + JSON.stringify({ + issue: { + owner: opts.repo.split("/")[0], + repo: opts.repo.split("/")[1], + number: i + 1, + url: `https://github.com/${opts.repo}/issues/${i + 1}`, + createdAt: "2026-03-02T00:00:00.000Z", + }, + }), + ); + } + for (let i = 0; i < opts.fixed; i += 1) { + db.prepare( + `INSERT INTO tasks ( + id, description, "column", createdAt, updatedAt, + sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, + sourceIssueNumber, sourceIssueUrl + ) VALUES (?, 'desc', 'done', '2026-03-03T00:00:00.000Z', '2026-03-03T00:00:00.000Z', + 'github', ?, ?, ?, ?)`, + ).run( + `${opts.prefix}-fixed-${i}`, + opts.repo, + String(i + 100), + i + 100, + `https://github.com/${opts.repo}/issues/${i + 100}`, + ); + } +} + /** * Build an express app with the registrar mounted, backed by per-project real * DBs. The `getScopedStore` resolves the DB by the `projectId` query param, @@ -180,6 +216,13 @@ describe("register-command-center-routes", () => { expect(prod.status).toBe(200); expect(prod.body).toHaveProperty("loc"); expect(prod.body).toHaveProperty("byLanguage"); + + seedGithubIssueMetrics(dbA, { prefix: "FN-A", repo: "acme/alpha", filed: 2, fixed: 1 }); + const github = await request(app, "GET", `/api/command-center/github?${range}&projectId=proj-a`); + expect(github.status).toBe(200); + expect(github.body).toMatchObject({ filed: 2, fixed: 1, net: 1 }); + expect(github.body).toHaveProperty("daily"); + expect(github.body).toHaveProperty("byRepo"); }); it("returns the live snapshot shape", async () => { @@ -228,6 +271,27 @@ describe("register-command-center-routes", () => { expect((b.body as { totals: { totalTokens: number } }).totals.totalTokens).toBe(1998); }); + it("github endpoint defaults invalid ranges and stays project scoped", async () => { + seedGithubIssueMetrics(dbA, { prefix: "FN-A", repo: "acme/alpha", filed: 2, fixed: 1 }); + seedGithubIssueMetrics(dbB, { prefix: "FN-B", repo: "acme/beta", filed: 5, fixed: 4 }); + const invalid = await request( + app, + "GET", + "/api/command-center/github?from=bad&to=range&projectId=proj-a", + ); + expect(invalid.status).toBe(200); + expect(invalid.body).toHaveProperty("filed"); + expect(invalid.body).toHaveProperty("fixed"); + expect(invalid.body).toHaveProperty("daily"); + expect(invalid.body).toHaveProperty("byRepo"); + + const range = "from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z"; + const a = await request(app, "GET", `/api/command-center/github?${range}&projectId=proj-a`); + const b = await request(app, "GET", `/api/command-center/github?${range}&projectId=proj-b`); + expect(a.body).toMatchObject({ filed: 2, fixed: 1 }); + expect(b.body).toMatchObject({ filed: 5, fixed: 4 }); + }); + it("?format=csv returns well-formed CSV with attachment header", async () => { const res = await request( app, @@ -314,6 +378,7 @@ describe("register-command-center-routes", () => { ["tools", "command-center-tools.csv"], ["activity", "command-center-activity.csv"], ["productivity", "command-center-productivity.csv"], + ["github", "command-center-github.csv"], ]) { const res = await request( app, @@ -405,6 +470,7 @@ describe("vite /api proxy negative-lookahead (proxy verification)", () => { it("proxies the real command-center endpoints to the backend", () => { expect(PROXY_RE.test("/api/command-center/tokens")).toBe(true); expect(PROXY_RE.test("/api/command-center/live")).toBe(true); + expect(PROXY_RE.test("/api/command-center/github")).toBe(true); expect(PROXY_RE.test("/api/command-center/activity?from=x&to=y")).toBe(true); }); diff --git a/packages/dashboard/src/command-center-csv.ts b/packages/dashboard/src/command-center-csv.ts index e15cf5446f..d72578639b 100644 --- a/packages/dashboard/src/command-center-csv.ts +++ b/packages/dashboard/src/command-center-csv.ts @@ -3,6 +3,7 @@ import type { ToolAnalytics, ActivityAnalytics, ProductivityAnalytics, + GithubIssueAnalytics, } from "@fusion/core"; /** @@ -168,3 +169,22 @@ export function productivityAnalyticsToTable( rows.push(["loc", result.loc.value ?? ""]); return { header, rows }; } + +/** GitHub issue analytics → CSV. Daily rows plus repo and summary rows. */ +export function githubIssueAnalyticsToTable( + result: GithubIssueAnalytics, +): CsvTable { + const header = ["section", "key", "filed", "fixed", "net"]; + const rows: CsvCell[][] = result.daily.map((d) => [ + "daily", + d.date, + d.filed, + d.fixed, + d.filed - d.fixed, + ]); + for (const repo of result.byRepo) { + rows.push(["repo", repo.repo, repo.filed, repo.fixed, repo.filed - repo.fixed]); + } + rows.push(["summary", "total", result.filed, result.fixed, result.net]); + return { header, rows }; +} diff --git a/packages/dashboard/src/routes/register-command-center-routes.ts b/packages/dashboard/src/routes/register-command-center-routes.ts index 62ef71b427..b6d2faafda 100644 --- a/packages/dashboard/src/routes/register-command-center-routes.ts +++ b/packages/dashboard/src/routes/register-command-center-routes.ts @@ -3,6 +3,7 @@ import { aggregateToolAnalytics, aggregateActivityAnalytics, aggregateProductivityAnalytics, + aggregateGithubIssueAnalytics, composeLiveSnapshot, type TokenGroupBy, type TokenTimeGranularity, @@ -15,6 +16,7 @@ import { toolAnalyticsToTable, activityAnalyticsToTable, productivityAnalyticsToTable, + githubIssueAnalyticsToTable, type CsvTable, } from "../command-center-csv.js"; import type { ApiRouteRegistrar } from "./types.js"; @@ -238,6 +240,29 @@ export const registerCommandCenterRoutes: ApiRouteRegistrar = (ctx) => { } }); + /** + * GET /api/command-center/github + * GitHub issues filed by Fusion and imported GitHub issues fixed by Fusion. + */ + router.get("/command-center/github", async (req, res) => { + try { + const store = await getScopedStore(req); + const range = resolveRange(req.query); + const result = aggregateGithubIssueAnalytics(store.getDatabase(), { + from: range.from, + to: range.to, + }); + if (wantsCsv(req.query)) { + sendCsv(res, "command-center-github.csv", githubIssueAnalyticsToTable(result)); + return; + } + res.json(result); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err, "Failed to aggregate GitHub issue analytics"); + } + }); + /** * GET /api/command-center/live * Live Mission-Control snapshot (U6a): active sessions/runs/nodes + current