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:
5
.changeset/fn-6653-command-center-github-issue-stats.md
Normal file
5
.changeset/fn-6653-command-center-github-issue-stats.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
242
packages/core/src/__tests__/github-issue-analytics.test.ts
Normal file
242
packages/core/src/__tests__/github-issue-analytics.test.ts
Normal 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 }]);
|
||||
});
|
||||
});
|
||||
196
packages/core/src/github-issue-analytics.ts
Normal file
196
packages/core/src/github-issue-analytics.ts
Normal 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);
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)"));
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user