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
This commit is contained in:
gsxdsm
2026-06-18 16:18:52 -07:00
parent 11c4120705
commit 504305e1cb
14 changed files with 796 additions and 2 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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<string, unknown>,
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 }]);
});
});

View File

@@ -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<string, { filed: number; fixed: number }>,
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<string, { filed: number; fixed: number }>,
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<string, { filed: number; fixed: number }>();
const byRepo = new Map<string, { filed: number; fixed: number }>();
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);
}),
};
}

View File

@@ -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,

View File

@@ -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 <ProductivityArea range={range} />;
case "ecosystem":
return <EcosystemArea range={range} />;
case "github":
return <GithubArea range={range} />;
case "signals":
return <SignalsArea range={range} />;
case "mission-control":

View File

@@ -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 () => {

View File

@@ -102,6 +102,18 @@ function activityFixture(overrides: Partial<Record<"sessions" | "messages" | "ac
const emptyActivityFixture = () =>
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(<CommandCenter />);
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(<CommandCenter />);
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(<CommandCenter />);
const overviewTab = screen.getByTestId("command-center-tab-overview");

View File

@@ -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<GithubIssueAnalytics>(
"/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 (
<AreaShell
testId="github"
isLoading={isLoading}
error={error}
isEmpty={isEmpty}
emptyMessage={t("commandCenter.github.empty", "No GitHub issue activity in the selected range.")}
>
<div className="cc-area-section">
<h3 className="cc-area-section-title">{t("commandCenter.github.totalsTitle", "GitHub issue flow")}</h3>
<div className="cc-stat-grid">
<div className="card cc-stat-card" data-testid="cc-github-filed">
<div className="cc-stat-label">{t("commandCenter.github.filed", "Filed by Fusion")}</div>
<div className="cc-stat-value">{formatCount(filed)}</div>
</div>
<div className="card cc-stat-card" data-testid="cc-github-fixed">
<div className="cc-stat-label">{t("commandCenter.github.fixed", "Fixed by Fusion")}</div>
<div className="cc-stat-value">{formatCount(fixed)}</div>
<span className="cc-stat-sub">
{t("commandCenter.github.fixedApproximation", "Uses done tasks updated in range")}
</span>
</div>
<div className="card cc-stat-card" data-testid="cc-github-net">
<div className="cc-stat-label">{t("commandCenter.github.net", "Net")}</div>
<div className="cc-stat-value">{formatCount(net)}</div>
</div>
</div>
</div>
{hasDailyTrend ? (
<div className="cc-area-section" data-testid="cc-github-daily-trend">
<h3 className="cc-area-section-title">{t("commandCenter.github.dailyTrend", "Filed vs fixed trend")}</h3>
<div className="cc-stat-grid">
<div className="card cc-stat-card">
<div className="cc-stat-label">{t("commandCenter.github.filedTrend", "Filed")}</div>
<Sparkline
values={filedValues}
max={maxDaily}
ariaLabel={t("commandCenter.github.filedTrend", "Filed")}
/>
</div>
<div className="card cc-stat-card">
<div className="cc-stat-label">{t("commandCenter.github.fixedTrend", "Fixed")}</div>
<Sparkline
values={fixedValues}
max={maxDaily}
ariaLabel={t("commandCenter.github.fixedTrend", "Fixed")}
/>
</div>
</div>
</div>
) : null}
{hasRepoBreakdown ? (
<div className="cc-area-section" data-testid="cc-github-by-repo">
<h3 className="cc-area-section-title">{t("commandCenter.github.byRepo", "By repository")}</h3>
<Bar data={repoBars} ariaLabel={t("commandCenter.github.byRepo", "By repository")} />
</div>
) : null}
</AreaShell>
);
}

View File

@@ -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(<GithubArea range={range7d} />);
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(<GithubArea range={range7d} />);
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(<GithubArea range={range7d} />);
expect(screen.getByTestId("cc-area-github-loading")).toBeTruthy();
unmount();
apiMock.mockRejectedValueOnce(new Error("github failed"));
render(<GithubArea range={range7d} />);
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(<GithubArea range={range7d} />);
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(<GithubArea range={customRange("2026-06-10", "2026-06-01")} />);
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)"));

View File

@@ -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",
];

View File

@@ -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);
});

View File

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

View File

@@ -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