FN-6722: expose resolved GitHub issues
Expose resolved GitHub issue details in Command Center analytics and exports. - Add resolved issue rows to local GitHub issue analytics with exact-vs-approximate timestamp metadata. - Render a resolved issues table in the Command Center GitHub area without empty links when issue URLs are missing. - Include resolved issue details in CSV export, route coverage, dashboard tests, docs, and the release changeset. - Quarantine the unrelated dashboard dev-server process timer flake observed during workspace verification under the deletion ratchet. Files changed: ...fn-6722-command-center-resolved-issue-detail.md | 5 + docs/dashboard-guide.md | 3 +- .../src/__tests__/github-issue-analytics.test.ts | 109 +++++++++++++++++++-- packages/core/src/github-issue-analytics.ts | 45 ++++++++- packages/core/src/index.ts | 1 + .../__tests__/CommandCenter.mobile-scroll.test.tsx | 4 +- .../__tests__/CommandCenter.test.tsx | 1 + .../components/command-center/areas/GithubArea.tsx | 67 +++++++++++++ .../command-center/areas/__tests__/areas.test.tsx | 55 +++++++++++ .../register-command-center-routes.test.ts | 61 +++++++++++- packages/dashboard/src/command-center-csv.ts | 39 +++++++- packages/dashboard/vitest.config.ts | 7 +- scripts/lib/test-quarantine.json | 5 + 13 files changed, 382 insertions(+), 20 deletions(-) Fusion-Task-Id: FN-6722 Fusion-Task-Lineage: 9fcbd8c2-f1bc-4b86-b5f5-593e5fd0e675
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add a Command Center GitHub resolved-issues detail list and expose the resolved issue rows in the GitHub analytics endpoint payload and CSV export.
|
||||||
@@ -690,7 +690,8 @@ Features:
|
|||||||
- **Productivity** separates outcome counters (commits and pull requests), task-duration stats, and volume proxies such as modified files, lines changed, and files by language. The task-duration block counts done tasks completed in the selected range and shows average, median, p90, and total active execution time from `cumulativeActiveMs`; when no qualifying duration data exists, duration values render the unavailable `—` sentinel rather than `0`. It keeps the files-by-language bar and adds a language-share pie from `ProductivityAnalytics.byLanguage`. There is intentionally no productivity line chart because the current productivity response has no per-day throughput or completion time series; no new endpoint is called.
|
- **Productivity** separates outcome counters (commits and pull requests), task-duration stats, and volume proxies such as modified files, lines changed, and files by language. The task-duration block counts done tasks completed in the selected range and shows average, median, p90, and total active execution time from `cumulativeActiveMs`; when no qualifying duration data exists, duration values render the unavailable `—` sentinel rather than `0`. It keeps the files-by-language bar and adds a language-share pie from `ProductivityAnalytics.byLanguage`. There is intentionally no productivity line chart because the current productivity response has no per-day throughput or completion time series; no new endpoint is called.
|
||||||
- **Team** shows the read-only agent org chart, heartbeat pause/resume backed by the existing `enginePaused` setting, a per-agent analytics table, tokens-by-agent and tasks-done-by-agent charts, and a real token-share pie from the same per-agent token totals. The org chart is styled by Command Center's Team CSS, not lazy Agents view CSS, and org nodes show only agent names so role/title description/meta text does not clutter Team operations. Metrics come only from the project-scoped `tasks` and `agents` tables: token totals and estimated cost are summed from the `tokenUsage*` columns by `assignedAgentId`, files changed counts parsed `tasks.modifiedFiles` paths, tasks done counts `column = 'done'` moves in the selected range, and in-progress / in-review values reflect current task columns. Agent name, role, and live state come from the `agents` table; deleted-agent task history falls back to the raw agent id instead of crashing. The tab uses `/api/command-center/team`, adds no schema, never calls GitHub, and intentionally leaves per-agent issues filed/fixed to FN-6653. Team has no per-day analytics series today, so it intentionally does not render a line chart or fabricate a trend. Decorative chart reveal motion uses duration tokens and is disabled for reduced-motion users.
|
- **Team** shows the read-only agent org chart, heartbeat pause/resume backed by the existing `enginePaused` setting, a per-agent analytics table, tokens-by-agent and tasks-done-by-agent charts, and a real token-share pie from the same per-agent token totals. The org chart is styled by Command Center's Team CSS, not lazy Agents view CSS, and org nodes show only agent names so role/title description/meta text does not clutter Team operations. Metrics come only from the project-scoped `tasks` and `agents` tables: token totals and estimated cost are summed from the `tokenUsage*` columns by `assignedAgentId`, files changed counts parsed `tasks.modifiedFiles` paths, tasks done counts `column = 'done'` moves in the selected range, and in-progress / in-review values reflect current task columns. Agent name, role, and live state come from the `agents` table; deleted-agent task history falls back to the raw agent id instead of crashing. The tab uses `/api/command-center/team`, adds no schema, never calls GitHub, and intentionally leaves per-agent issues filed/fixed to FN-6653. Team has no per-day analytics series today, so it intentionally does not render a line chart or fabricate a trend. Decorative chart reveal motion uses duration tokens and is disabled for reduced-motion users.
|
||||||
- **Ecosystem** shows active model breadth, per-model task activity, and real plugin activations for the selected range. Plugin activation counts come from project-scoped plugin/extension load events via `/api/command-center/plugin-activations`; if no activation rows exist in range, the metric renders unavailable (`—`) rather than fabricating zero. The tab still reuses the tokens analytics endpoint grouped by model, adds a task-share-by-model pie from `TokenAnalytics.groups`, and renders a tokens/tasks trend line when `TokenAnalytics.series` buckets are present; if series buckets are absent, no synthetic trend is shown.
|
- **Ecosystem** shows active model breadth, per-model task activity, and real plugin activations for the selected range. Plugin activation counts come from project-scoped plugin/extension load events via `/api/command-center/plugin-activations`; if no activation rows exist in range, the metric renders unavailable (`—`) rather than fabricating zero. The tab still reuses the tokens analytics endpoint grouped by model, adds a task-share-by-model pie from `TokenAnalytics.groups`, and renders a tokens/tasks trend line when `TokenAnalytics.series` buckets are present; if series buckets are absent, no synthetic trend is shown.
|
||||||
- **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 the persisted `sourceIssueClosedAt` / `TaskSourceIssue.closedAt` close time when the reconciler has observed it. Rows that predate the field or have not been observed closed fall back to task `updatedAt` as the documented completion-time approximation; Fusion never fabricates a close timestamp and this analytics path never calls GitHub, the `gh` CLI, or any external network source. To make historical fixed dates exact, use **Backfill exact close times** in the Fixed by Fusion card; the dashboard calls the project-scoped manual `POST /api/git/github/backfill-source-issue-closed-at` endpoint in `{ offset, limit }` batches until `hasMore` is false, then surfaces the accumulated `scanned`, `filled`, `skipped`, and `errors` counts. The endpoint fetches real GitHub `closed_at` values once, fills only missing `sourceIssueClosedAt` values, and never runs automatically or from analytics-time rendering. The area shows filed/fixed/net stat cards, a filed-vs-fixed pie, a filed/fixed recharts trend line, existing daily sparklines, and a by-repository bar breakdown.
|
<!-- FNXC:CommandCenter 2026-06-21-07:07: FN-6722 requires the GitHub area to expose a resolved-issue detail list from local task-store analytics only, with exact close timestamps flagged when reconciliation populated `sourceIssueClosedAt` and approximation called out otherwise. -->
|
||||||
|
- **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 the persisted `sourceIssueClosedAt` / `TaskSourceIssue.closedAt` close time when the reconciler has observed it. Rows that predate the field or have not been observed closed fall back to task `updatedAt` as the documented completion-time approximation; Fusion never fabricates a close timestamp and this analytics path never calls GitHub, the `gh` CLI, or any external network source. To make historical fixed dates exact, use **Backfill exact close times** in the Fixed by Fusion card; the dashboard calls the project-scoped manual `POST /api/git/github/backfill-source-issue-closed-at` endpoint in `{ offset, limit }` batches until `hasMore` is false, then surfaces the accumulated `scanned`, `filled`, `skipped`, and `errors` counts. The endpoint fetches real GitHub `closed_at` values once, fills only missing `sourceIssueClosedAt` values, and never runs automatically or from analytics-time rendering. The area shows filed/fixed/net stat cards, a filed-vs-fixed pie, a filed/fixed recharts trend line, existing daily sparklines, a by-repository bar breakdown, and a **Resolved issues** detail list. Resolved rows include the Fusion task, repository, source issue number, optional issue link, resolved timestamp, and whether that timestamp is exact (`sourceIssueClosedAt`) or the documented `updatedAt` approximation; missing issue URLs render as plain text rather than empty anchors or click targets. The same resolved rows are available from the GitHub analytics payload as `resolved` and from the CSV export.
|
||||||
- **Signals** is backed by the project-scoped `/api/command-center/signals` endpoint, which aggregates real rows from the local `incidents` table. It shows total/open/resolved counts, MTTR when resolved incidents have enough timestamps, and source/severity/status breakdowns; an empty incidents table renders honest zero counts with MTTR unavailable rather than fabricated signal volume. It adds an open-vs-resolved status pie from the same response. Signals has no per-day series today, so it intentionally does not render a line chart or fabricate a trend. External connectors that ingest third-party signals into incidents are tracked separately in FN-6706.
|
- **Signals** is backed by the project-scoped `/api/command-center/signals` endpoint, which aggregates real rows from the local `incidents` table. It shows total/open/resolved counts, MTTR when resolved incidents have enough timestamps, and source/severity/status breakdowns; an empty incidents table renders honest zero counts with MTTR unavailable rather than fabricated signal volume. It adds an open-vs-resolved status pie from the same response. Signals has no per-day series today, so it intentionally does not render a line chart or fabricate a trend. External connectors that ingest third-party signals into incidents are tracked separately in FN-6706.
|
||||||
- **System** is the canonical system-telemetry destination. It reuses `GET /api/system-stats` with no new endpoint, renders live radial gauges for app CPU, host memory, and heap usage, keeps a small client-side rolling buffer for CPU/memory/heap trend sparklines, adds a recharts CPU/memory/heap line from that same rolling buffer, and adds a task-by-column pie alongside the existing tasks-by-column and agents-by-state bars. The Vitest process count, manual kill confirmation, auto-kill toggle, threshold controls, and last-auto-kill timestamp moved here unchanged; the standalone System Stats modal and its desktop Header/mobile More affordances were removed.
|
- **System** is the canonical system-telemetry destination. It reuses `GET /api/system-stats` with no new endpoint, renders live radial gauges for app CPU, host memory, and heap usage, keeps a small client-side rolling buffer for CPU/memory/heap trend sparklines, adds a recharts CPU/memory/heap line from that same rolling buffer, and adds a task-by-column pie alongside the existing tasks-by-column and agents-by-state bars. The Vitest process count, manual kill confirmation, auto-kill toggle, threshold controls, and last-auto-kill timestamp moved here unchanged; the standalone System Stats modal and its desktop Header/mobile More affordances were removed.
|
||||||
- **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. No additional pie or line chart is rendered because the live SDLC funnel already visualizes the panel's only quantitative distribution (`snapshot.columns`), while sessions/nodes are live control lists rather than categorical analytics. Motion-heavy accents respect reduced-motion preferences.
|
- **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. No additional pie or line chart is rendered because the live SDLC funnel already visualizes the panel's only quantitative distribution (`snapshot.columns`), while sessions/nodes are live control lists rather than categorical analytics. Motion-heavy accents respect reduced-motion preferences.
|
||||||
|
|||||||
@@ -31,29 +31,32 @@ function insertSourceIssueTask(
|
|||||||
id: string,
|
id: string,
|
||||||
opts: {
|
opts: {
|
||||||
provider: string;
|
provider: string;
|
||||||
repository: string;
|
repository: string | null;
|
||||||
column: string;
|
column: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
closedAt?: string | null;
|
closedAt?: string | null;
|
||||||
issueNumber?: number;
|
issueNumber?: number | null;
|
||||||
|
url?: string | null;
|
||||||
|
title?: string | null;
|
||||||
},
|
},
|
||||||
): void {
|
): void {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO tasks (
|
`INSERT INTO tasks (
|
||||||
id, description, "column", createdAt, updatedAt,
|
id, title, description, "column", createdAt, updatedAt,
|
||||||
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId,
|
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId,
|
||||||
sourceIssueNumber, sourceIssueUrl, sourceIssueClosedAt
|
sourceIssueNumber, sourceIssueUrl, sourceIssueClosedAt
|
||||||
) VALUES (?, 'desc', ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, 'desc', ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
).run(
|
).run(
|
||||||
id,
|
id,
|
||||||
|
opts.title ?? null,
|
||||||
opts.column,
|
opts.column,
|
||||||
opts.updatedAt,
|
opts.updatedAt,
|
||||||
opts.updatedAt,
|
opts.updatedAt,
|
||||||
opts.provider,
|
opts.provider,
|
||||||
opts.repository,
|
opts.repository,
|
||||||
String(opts.issueNumber ?? 1),
|
String(opts.issueNumber ?? 1),
|
||||||
opts.issueNumber ?? 1,
|
opts.issueNumber === undefined ? 1 : opts.issueNumber,
|
||||||
`https://example.test/${id}`,
|
opts.url === undefined ? `https://example.test/${id}` : opts.url,
|
||||||
opts.closedAt ?? null,
|
opts.closedAt ?? null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -149,6 +152,99 @@ describe("github-issue-analytics", () => {
|
|||||||
{ repo: "acme/alpha", filed: 2, fixed: 1 },
|
{ repo: "acme/alpha", filed: 2, fixed: 1 },
|
||||||
{ repo: "acme/beta", filed: 1, fixed: 1 },
|
{ repo: "acme/beta", filed: 1, fixed: 1 },
|
||||||
]);
|
]);
|
||||||
|
expect(result.resolved).toHaveLength(result.fixed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns resolved issue details for in-range done GitHub source tasks", () => {
|
||||||
|
insertSourceIssueTask(db, "resolved-exact-later", {
|
||||||
|
provider: "github",
|
||||||
|
repository: "acme/alpha",
|
||||||
|
column: "done",
|
||||||
|
updatedAt: "2026-04-01T00:00:00.000Z",
|
||||||
|
closedAt: "2026-04-03T10:00:00.000Z",
|
||||||
|
issueNumber: 42,
|
||||||
|
url: "https://github.com/acme/alpha/issues/42",
|
||||||
|
title: "Fix alpha crash",
|
||||||
|
});
|
||||||
|
insertSourceIssueTask(db, "resolved-fallback", {
|
||||||
|
provider: "github",
|
||||||
|
repository: null,
|
||||||
|
column: "done",
|
||||||
|
updatedAt: "2026-04-02T10:00:00.000Z",
|
||||||
|
closedAt: null,
|
||||||
|
issueNumber: null,
|
||||||
|
url: null,
|
||||||
|
title: "Resolve historical import",
|
||||||
|
});
|
||||||
|
insertSourceIssueTask(db, "resolved-exact-tie", {
|
||||||
|
provider: "github",
|
||||||
|
repository: "acme/beta",
|
||||||
|
column: "done",
|
||||||
|
updatedAt: "2026-04-01T00:00:00.000Z",
|
||||||
|
closedAt: "2026-04-03T10:00:00.000Z",
|
||||||
|
issueNumber: 43,
|
||||||
|
url: "https://github.com/acme/beta/issues/43",
|
||||||
|
title: "Fix beta crash",
|
||||||
|
});
|
||||||
|
insertSourceIssueTask(db, "closed-out-of-range", {
|
||||||
|
provider: "github",
|
||||||
|
repository: "acme/old",
|
||||||
|
column: "done",
|
||||||
|
updatedAt: "2026-04-02T10:00:00.000Z",
|
||||||
|
closedAt: "2026-03-31T23:59:59.999Z",
|
||||||
|
issueNumber: 44,
|
||||||
|
});
|
||||||
|
insertSourceIssueTask(db, "not-done-source", {
|
||||||
|
provider: "github",
|
||||||
|
repository: "acme/alpha",
|
||||||
|
column: "todo",
|
||||||
|
updatedAt: "2026-04-03T10:00:00.000Z",
|
||||||
|
issueNumber: 45,
|
||||||
|
});
|
||||||
|
insertSourceIssueTask(db, "not-github-source", {
|
||||||
|
provider: "gitlab",
|
||||||
|
repository: "acme/alpha",
|
||||||
|
column: "done",
|
||||||
|
updatedAt: "2026-04-03T10:00:00.000Z",
|
||||||
|
issueNumber: 46,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = aggregateGithubIssueAnalytics(db, {
|
||||||
|
from: "2026-04-01T00:00:00.000Z",
|
||||||
|
to: "2026-04-03T23:59:59.999Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.fixed).toBe(3);
|
||||||
|
expect(result.resolved).toEqual([
|
||||||
|
{
|
||||||
|
taskId: "resolved-exact-later",
|
||||||
|
taskTitle: "Fix alpha crash",
|
||||||
|
repo: "acme/alpha",
|
||||||
|
issueNumber: 42,
|
||||||
|
url: "https://github.com/acme/alpha/issues/42",
|
||||||
|
resolvedAt: "2026-04-03T10:00:00.000Z",
|
||||||
|
resolvedAtExact: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
taskId: "resolved-exact-tie",
|
||||||
|
taskTitle: "Fix beta crash",
|
||||||
|
repo: "acme/beta",
|
||||||
|
issueNumber: 43,
|
||||||
|
url: "https://github.com/acme/beta/issues/43",
|
||||||
|
resolvedAt: "2026-04-03T10:00:00.000Z",
|
||||||
|
resolvedAtExact: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
taskId: "resolved-fallback",
|
||||||
|
taskTitle: "Resolve historical import",
|
||||||
|
repo: "(unknown)",
|
||||||
|
issueNumber: null,
|
||||||
|
url: null,
|
||||||
|
resolvedAt: "2026-04-02T10:00:00.000Z",
|
||||||
|
resolvedAtExact: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.resolved).toHaveLength(result.fixed);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats range bounds as inclusive", () => {
|
it("treats range bounds as inclusive", () => {
|
||||||
@@ -248,6 +344,7 @@ describe("github-issue-analytics", () => {
|
|||||||
net: 0,
|
net: 0,
|
||||||
daily: [],
|
daily: [],
|
||||||
byRepo: [],
|
byRepo: [],
|
||||||
|
resolved: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import type { Database } from "./db.js";
|
|||||||
/**
|
/**
|
||||||
* FNXC:CommandCenterGithub 2026-06-18-00:00:
|
* 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. Fixed trends use the exact persisted `sourceIssueClosedAt` when available, fall back to the `updatedAt` completion approximation only when it is absent, and never fabricate a close date.
|
* 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. Fixed trends use the exact persisted `sourceIssueClosedAt` when available, fall back to the `updatedAt` completion approximation only when it is absent, and never fabricate a close date.
|
||||||
|
*
|
||||||
|
* FNXC:CommandCenterGithub 2026-06-21-00:00:
|
||||||
|
* Resolved issue details expose one local task-store row for every in-range fixed GitHub source issue so the Command Center can show which source issues were completed. `resolvedAtExact` is true only when the persisted `sourceIssueClosedAt` provided the timestamp; false means the row used the same `updatedAt` approximation as the fixed aggregate.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface GithubIssueAnalyticsQuery {
|
export interface GithubIssueAnalyticsQuery {
|
||||||
@@ -28,6 +31,23 @@ export interface GithubIssueRepoBreakdown {
|
|||||||
fixed: number;
|
fixed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GithubResolvedIssue {
|
||||||
|
/** Fusion task that resolved the imported GitHub source issue. */
|
||||||
|
taskId: string;
|
||||||
|
/** Fusion task title at aggregation time. */
|
||||||
|
taskTitle: string;
|
||||||
|
/** Repository key, usually `owner/repo`; `(unknown)` when historical data lacks it. */
|
||||||
|
repo: string;
|
||||||
|
/** GitHub issue number when the imported source issue stored one. */
|
||||||
|
issueNumber: number | null;
|
||||||
|
/** Source GitHub issue URL when available. */
|
||||||
|
url: string | null;
|
||||||
|
/** ISO timestamp used for range filtering and ordering. */
|
||||||
|
resolvedAt: string;
|
||||||
|
/** True when `sourceIssueClosedAt` supplied `resolvedAt`; false when `updatedAt` was used. */
|
||||||
|
resolvedAtExact: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GithubIssueAnalytics {
|
export interface GithubIssueAnalytics {
|
||||||
from: string | null;
|
from: string | null;
|
||||||
to: string | null;
|
to: string | null;
|
||||||
@@ -41,6 +61,8 @@ export interface GithubIssueAnalytics {
|
|||||||
daily: GithubIssueDailyPoint[];
|
daily: GithubIssueDailyPoint[];
|
||||||
/** Filed/fixed counts grouped by repository, descending by total activity. */
|
/** Filed/fixed counts grouped by repository, descending by total activity. */
|
||||||
byRepo: GithubIssueRepoBreakdown[];
|
byRepo: GithubIssueRepoBreakdown[];
|
||||||
|
/** Imported GitHub source issues completed in range, most-recently resolved first. */
|
||||||
|
resolved: GithubResolvedIssue[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GithubTrackingRow {
|
interface GithubTrackingRow {
|
||||||
@@ -48,7 +70,11 @@ interface GithubTrackingRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface FixedIssueRow {
|
interface FixedIssueRow {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
sourceIssueRepository: string | null;
|
sourceIssueRepository: string | null;
|
||||||
|
sourceIssueNumber: number | null;
|
||||||
|
sourceIssueUrl: string | null;
|
||||||
sourceIssueClosedAt: string | null;
|
sourceIssueClosedAt: string | null;
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
}
|
}
|
||||||
@@ -153,12 +179,14 @@ export function aggregateGithubIssueAnalytics(
|
|||||||
|
|
||||||
const fixedRows = db
|
const fixedRows = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT sourceIssueRepository, sourceIssueClosedAt, updatedAt FROM tasks WHERE sourceIssueProvider = 'github' AND "column" = 'done'`,
|
`SELECT id, title, sourceIssueRepository, sourceIssueNumber, sourceIssueUrl, sourceIssueClosedAt, updatedAt FROM tasks WHERE sourceIssueProvider = 'github' AND "column" = 'done'`,
|
||||||
)
|
)
|
||||||
.all() as FixedIssueRow[];
|
.all() as FixedIssueRow[];
|
||||||
|
|
||||||
let fixed = 0;
|
let fixed = 0;
|
||||||
|
const resolved: GithubResolvedIssue[] = [];
|
||||||
for (const row of fixedRows) {
|
for (const row of fixedRows) {
|
||||||
|
const hasExactResolvedAt = row.sourceIssueClosedAt !== null;
|
||||||
const fixedDate = row.sourceIssueClosedAt ?? row.updatedAt;
|
const fixedDate = row.sourceIssueClosedAt ?? row.updatedAt;
|
||||||
if (fixedDate === null || !isInRange(fixedDate, query)) continue;
|
if (fixedDate === null || !isInRange(fixedDate, query)) continue;
|
||||||
|
|
||||||
@@ -167,8 +195,22 @@ export function aggregateGithubIssueAnalytics(
|
|||||||
addRepo(byRepo, repo, "fixed");
|
addRepo(byRepo, repo, "fixed");
|
||||||
const day = dayKey(fixedDate);
|
const day = dayKey(fixedDate);
|
||||||
if (day !== null) addDaily(daily, day, "fixed");
|
if (day !== null) addDaily(daily, day, "fixed");
|
||||||
|
resolved.push({
|
||||||
|
taskId: row.id,
|
||||||
|
taskTitle: row.title ?? "",
|
||||||
|
repo,
|
||||||
|
issueNumber: typeof row.sourceIssueNumber === "number" ? row.sourceIssueNumber : null,
|
||||||
|
url: row.sourceIssueUrl?.trim() || null,
|
||||||
|
resolvedAt: fixedDate,
|
||||||
|
resolvedAtExact: hasExactResolvedAt,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolved.sort((a, b) => {
|
||||||
|
const byDate = Date.parse(b.resolvedAt) - Date.parse(a.resolvedAt);
|
||||||
|
return byDate !== 0 ? byDate : a.taskId.localeCompare(b.taskId);
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
from: query.from ?? null,
|
from: query.from ?? null,
|
||||||
to: query.to ?? null,
|
to: query.to ?? null,
|
||||||
@@ -184,5 +226,6 @@ export function aggregateGithubIssueAnalytics(
|
|||||||
const total = b.filed + b.fixed - (a.filed + a.fixed);
|
const total = b.filed + b.fixed - (a.filed + a.fixed);
|
||||||
return total !== 0 ? total : a.repo.localeCompare(b.repo);
|
return total !== 0 ? total : a.repo.localeCompare(b.repo);
|
||||||
}),
|
}),
|
||||||
|
resolved,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -600,6 +600,7 @@ export type {
|
|||||||
GithubIssueAnalyticsQuery,
|
GithubIssueAnalyticsQuery,
|
||||||
GithubIssueDailyPoint,
|
GithubIssueDailyPoint,
|
||||||
GithubIssueRepoBreakdown,
|
GithubIssueRepoBreakdown,
|
||||||
|
GithubResolvedIssue,
|
||||||
} from "./github-issue-analytics.js";
|
} from "./github-issue-analytics.js";
|
||||||
export { aggregateSignalsAnalytics } from "./signals-analytics.js";
|
export { aggregateSignalsAnalytics } from "./signals-analytics.js";
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ function populatedSignalsFixture() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function emptyGithubFixture() {
|
function emptyGithubFixture() {
|
||||||
return { filed: 0, fixed: 0, net: 0, daily: [], byRepo: [] };
|
return { filed: 0, fixed: 0, net: 0, daily: [], byRepo: [], resolved: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyTeamFixture() {
|
function emptyTeamFixture() {
|
||||||
@@ -268,7 +268,7 @@ function mockOverviewApi({ populated = false }: { populated?: boolean } = {}) {
|
|||||||
if (path.startsWith("/command-center/tools")) return Promise.resolve(populated ? populatedToolsFixture() : emptyToolsFixture());
|
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/activity")) return Promise.resolve(populated ? populatedActivityFixture() : emptyActivityFixture());
|
||||||
if (path.startsWith("/command-center/productivity")) return Promise.resolve(populated ? populatedProductivityFixture() : emptyProductivityFixture());
|
if (path.startsWith("/command-center/productivity")) return Promise.resolve(populated ? populatedProductivityFixture() : emptyProductivityFixture());
|
||||||
if (path.startsWith("/command-center/github")) return Promise.resolve(populated ? { filed: 3, fixed: 1, net: 2, daily: [{ date: "2026-06-18", filed: 3, fixed: 1 }], byRepo: [{ repo: "acme/repo", filed: 3, fixed: 1 }] } : emptyGithubFixture());
|
if (path.startsWith("/command-center/github")) return Promise.resolve(populated ? { filed: 3, fixed: 1, net: 2, daily: [{ date: "2026-06-18", filed: 3, fixed: 1 }], byRepo: [{ repo: "acme/repo", filed: 3, fixed: 1 }], resolved: [{ taskId: "FN-100", taskTitle: "Fix mobile crash", repo: "acme/repo", issueNumber: 1, url: "https://github.com/acme/repo/issues/1", resolvedAt: "2026-06-18T10:00:00.000Z", resolvedAtExact: true }] } : emptyGithubFixture());
|
||||||
if (path.startsWith("/command-center/team")) return Promise.resolve(populated ? populatedTeamFixture() : emptyTeamFixture());
|
if (path.startsWith("/command-center/team")) return Promise.resolve(populated ? populatedTeamFixture() : emptyTeamFixture());
|
||||||
if (path.startsWith("/command-center/signals")) return Promise.resolve(populated ? populatedSignalsFixture() : { totalSignals: 0, open: 0, resolved: 0, mttr: { value: null, unavailable: true }, bySource: [], bySeverity: [] });
|
if (path.startsWith("/command-center/signals")) return Promise.resolve(populated ? populatedSignalsFixture() : { totalSignals: 0, open: 0, resolved: 0, mttr: { value: null, unavailable: true }, bySource: [], bySeverity: [] });
|
||||||
if (path === "/system-stats") return Promise.resolve(systemStatsFixture());
|
if (path === "/system-stats") return Promise.resolve(systemStatsFixture());
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ function githubFixture(filed = 0, fixed = 0) {
|
|||||||
net: filed - fixed,
|
net: filed - fixed,
|
||||||
daily: filed || fixed ? [{ date: "2026-06-08", filed, fixed }] : [],
|
daily: filed || fixed ? [{ date: "2026-06-08", filed, fixed }] : [],
|
||||||
byRepo: filed || fixed ? [{ repo: "acme/alpha", filed, fixed }] : [],
|
byRepo: filed || fixed ? [{ repo: "acme/alpha", filed, fixed }] : [],
|
||||||
|
resolved: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
/*
|
/*
|
||||||
FNXC:CommandCenterGithub 2026-06-18-00:00:
|
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.
|
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.
|
||||||
|
|
||||||
|
FNXC:CommandCenterGithub 2026-06-21-03:28:
|
||||||
|
FN-6722 adds a resolved-issues detail list so operators can see which imported GitHub source issues were completed in the selected range. The UI must render only rows returned by the local task-store analytics endpoint, link out only when `sourceIssueUrl` exists, and mark `updatedAt` fallback dates as approximate.
|
||||||
*/
|
*/
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -21,6 +24,12 @@ const GITHUB_SOURCE_ISSUE_BACKFILL_MAX_BATCHES = 1000;
|
|||||||
|
|
||||||
type BackfillAggregate = Omit<GithubSourceIssueClosedAtBackfillResult, "hasMore">;
|
type BackfillAggregate = Omit<GithubSourceIssueClosedAtBackfillResult, "hasMore">;
|
||||||
|
|
||||||
|
function formatResolvedAt(value: string, fallback: string): string {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return fallback;
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
export function GithubArea({ range }: { range: DateRange }) {
|
export function GithubArea({ range }: { range: DateRange }) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const { data, isLoading, error } = useAnalyticsArea<GithubIssueAnalytics>(
|
const { data, isLoading, error } = useAnalyticsArea<GithubIssueAnalytics>(
|
||||||
@@ -80,6 +89,7 @@ export function GithubArea({ range }: { range: DateRange }) {
|
|||||||
|
|
||||||
const daily = useMemo(() => data?.daily ?? [], [data?.daily]);
|
const daily = useMemo(() => data?.daily ?? [], [data?.daily]);
|
||||||
const byRepo = useMemo(() => data?.byRepo ?? [], [data?.byRepo]);
|
const byRepo = useMemo(() => data?.byRepo ?? [], [data?.byRepo]);
|
||||||
|
const resolved = useMemo(() => data?.resolved ?? [], [data?.resolved]);
|
||||||
const filedValues = useMemo(() => daily.map((d) => d.filed), [daily]);
|
const filedValues = useMemo(() => daily.map((d) => d.filed), [daily]);
|
||||||
const fixedValues = useMemo(() => daily.map((d) => d.fixed), [daily]);
|
const fixedValues = useMemo(() => daily.map((d) => d.fixed), [daily]);
|
||||||
const maxDaily = useMemo(
|
const maxDaily = useMemo(
|
||||||
@@ -124,6 +134,7 @@ export function GithubArea({ range }: { range: DateRange }) {
|
|||||||
const hasDailyTrend = daily.length > 0;
|
const hasDailyTrend = daily.length > 0;
|
||||||
const hasIssueFlowPie = filed + fixed > 0;
|
const hasIssueFlowPie = filed + fixed > 0;
|
||||||
const hasRepoBreakdown = repoBars.length > 0;
|
const hasRepoBreakdown = repoBars.length > 0;
|
||||||
|
const hasResolvedIssues = resolved.length > 0;
|
||||||
const backfillStatusClass = backfillError || (backfillResult?.errors ?? 0) > 0
|
const backfillStatusClass = backfillError || (backfillResult?.errors ?? 0) > 0
|
||||||
? "cc-github-backfill-status--error"
|
? "cc-github-backfill-status--error"
|
||||||
: isBackfilling
|
: isBackfilling
|
||||||
@@ -253,6 +264,62 @@ export function GithubArea({ range }: { range: DateRange }) {
|
|||||||
<Bar data={repoBars} ariaLabel={t("commandCenter.github.byRepo", "By repository")} />
|
<Bar data={repoBars} ariaLabel={t("commandCenter.github.byRepo", "By repository")} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{hasResolvedIssues ? (
|
||||||
|
<div className="cc-area-section" data-testid="cc-github-resolved">
|
||||||
|
<h3 className="cc-area-section-title">{t("commandCenter.github.resolvedTitle", "Resolved issues")}</h3>
|
||||||
|
<div className="cc-table-wrap">
|
||||||
|
<table className="cc-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">{t("commandCenter.github.resolvedIssue", "Issue")}</th>
|
||||||
|
<th scope="col">{t("commandCenter.github.resolvedTask", "Resolving task")}</th>
|
||||||
|
<th scope="col">{t("commandCenter.github.resolvedAt", "Resolved at")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{resolved.map((issue) => {
|
||||||
|
const issueRef = issue.issueNumber === null ? issue.repo : `${issue.repo}#${issue.issueNumber}`;
|
||||||
|
const resolvedAt = formatResolvedAt(
|
||||||
|
issue.resolvedAt,
|
||||||
|
t("commandCenter.github.resolvedAtUnknown", "Unknown"),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<tr key={issue.taskId}>
|
||||||
|
<td>
|
||||||
|
{issue.url ? (
|
||||||
|
<a
|
||||||
|
href={issue.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={t("commandCenter.github.openResolvedIssue", "Open GitHub issue {{issue}}", { issue: issueRef })}
|
||||||
|
>
|
||||||
|
{issueRef}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span>{issueRef}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span>{issue.taskTitle || issue.taskId}</span>{" "}
|
||||||
|
<span className="cc-stat-sub">({issue.taskId})</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span>{resolvedAt}</span>{" "}
|
||||||
|
{!issue.resolvedAtExact ? (
|
||||||
|
<span className="cc-stat-sub">
|
||||||
|
{t("commandCenter.github.resolvedApprox", "approx")}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</AreaShell>
|
</AreaShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ function githubFixture() {
|
|||||||
{ repo: "acme/alpha", filed: 4, fixed: 1 },
|
{ repo: "acme/alpha", filed: 4, fixed: 1 },
|
||||||
{ repo: "acme/beta", filed: 1, fixed: 2 },
|
{ repo: "acme/beta", filed: 1, fixed: 2 },
|
||||||
],
|
],
|
||||||
|
resolved: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1264,6 +1265,60 @@ describe("GithubArea", () => {
|
|||||||
expect(within(repoChart).getByLabelText("acme/alpha: 4 filed / 1 fixed")).toBeTruthy();
|
expect(within(repoChart).getByLabelText("acme/alpha: 4 filed / 1 fixed")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders resolved issues with safe outbound links and approximation labels", async () => {
|
||||||
|
apiMock.mockResolvedValue({
|
||||||
|
...githubFixture(),
|
||||||
|
resolved: [
|
||||||
|
{
|
||||||
|
taskId: "FN-100",
|
||||||
|
taskTitle: "Fix alpha crash",
|
||||||
|
repo: "acme/alpha",
|
||||||
|
issueNumber: 123,
|
||||||
|
url: "https://github.com/acme/alpha/issues/123",
|
||||||
|
resolvedAt: "2026-06-10T12:34:56.000Z",
|
||||||
|
resolvedAtExact: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
taskId: "FN-101",
|
||||||
|
taskTitle: "Patch unknown import",
|
||||||
|
repo: "(unknown)",
|
||||||
|
issueNumber: null,
|
||||||
|
url: null,
|
||||||
|
resolvedAt: "2026-06-09T08:00:00.000Z",
|
||||||
|
resolvedAtExact: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<GithubArea range={range7d} />);
|
||||||
|
|
||||||
|
const section = await screen.findByTestId("cc-github-resolved");
|
||||||
|
expect(section.textContent).toContain("Resolved issues");
|
||||||
|
expect(section.textContent).toContain("acme/alpha#123");
|
||||||
|
expect(section.textContent).toContain("Fix alpha crash");
|
||||||
|
expect(section.textContent).toContain("FN-100");
|
||||||
|
expect(section.textContent).toContain("(unknown)");
|
||||||
|
expect(section.textContent).toContain("Patch unknown import");
|
||||||
|
expect(section.textContent).toContain("approx");
|
||||||
|
expect(section.textContent).toContain("2026");
|
||||||
|
|
||||||
|
const linkedIssue = within(section).getByRole("link", { name: "Open GitHub issue acme/alpha#123" });
|
||||||
|
expect(linkedIssue.getAttribute("href")).toBe("https://github.com/acme/alpha/issues/123");
|
||||||
|
expect(linkedIssue.getAttribute("target")).toBe("_blank");
|
||||||
|
expect(linkedIssue.getAttribute("rel")).toBe("noopener noreferrer");
|
||||||
|
expect(within(section).queryByRole("link", { name: /unknown/i })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the resolved issues section for an empty resolved list", async () => {
|
||||||
|
apiMock.mockResolvedValue({ ...githubFixture(), resolved: [] });
|
||||||
|
|
||||||
|
render(<GithubArea range={range7d} />);
|
||||||
|
|
||||||
|
await screen.findByTestId("cc-area-github");
|
||||||
|
expect(screen.queryByTestId("cc-github-resolved")).toBeNull();
|
||||||
|
expect(screen.getByTestId("cc-area-github").textContent).not.toContain("NaN");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders the empty state without empty chart shells", async () => {
|
it("renders the empty state without empty chart shells", async () => {
|
||||||
apiMock.mockResolvedValue({ ...githubFixture(), filed: 0, fixed: 0, net: 0, daily: [], byRepo: [] });
|
apiMock.mockResolvedValue({ ...githubFixture(), filed: 0, fixed: 0, net: 0, daily: [], byRepo: [] });
|
||||||
render(<GithubArea range={range7d} />);
|
render(<GithubArea range={range7d} />);
|
||||||
|
|||||||
@@ -141,17 +141,19 @@ function seedGithubIssueMetrics(db: Database, opts: { prefix: string; repo: stri
|
|||||||
for (let i = 0; i < opts.fixed; i += 1) {
|
for (let i = 0; i < opts.fixed; i += 1) {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO tasks (
|
`INSERT INTO tasks (
|
||||||
id, description, "column", createdAt, updatedAt,
|
id, title, description, "column", createdAt, updatedAt,
|
||||||
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId,
|
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId,
|
||||||
sourceIssueNumber, sourceIssueUrl
|
sourceIssueNumber, sourceIssueUrl, sourceIssueClosedAt
|
||||||
) VALUES (?, 'desc', 'done', '2026-03-03T00:00:00.000Z', '2026-03-03T00:00:00.000Z',
|
) VALUES (?, ?, 'desc', 'done', '2026-03-03T00:00:00.000Z', '2026-03-03T00:00:00.000Z',
|
||||||
'github', ?, ?, ?, ?)`,
|
'github', ?, ?, ?, ?, ?)`,
|
||||||
).run(
|
).run(
|
||||||
`${opts.prefix}-fixed-${i}`,
|
`${opts.prefix}-fixed-${i}`,
|
||||||
|
`Resolve ${opts.repo}#${i + 100}`,
|
||||||
opts.repo,
|
opts.repo,
|
||||||
String(i + 100),
|
String(i + 100),
|
||||||
i + 100,
|
i + 100,
|
||||||
`https://github.com/${opts.repo}/issues/${i + 100}`,
|
`https://github.com/${opts.repo}/issues/${i + 100}`,
|
||||||
|
"2026-03-03T12:00:00.000Z",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -333,6 +335,18 @@ describe("register-command-center-routes", () => {
|
|||||||
expect(github.body).toMatchObject({ filed: 2, fixed: 1, net: 1 });
|
expect(github.body).toMatchObject({ filed: 2, fixed: 1, net: 1 });
|
||||||
expect(github.body).toHaveProperty("daily");
|
expect(github.body).toHaveProperty("daily");
|
||||||
expect(github.body).toHaveProperty("byRepo");
|
expect(github.body).toHaveProperty("byRepo");
|
||||||
|
expect(github.body).toHaveProperty("resolved");
|
||||||
|
expect((github.body as { resolved: unknown[] }).resolved).toEqual([
|
||||||
|
{
|
||||||
|
taskId: "FN-A-fixed-0",
|
||||||
|
taskTitle: "Resolve acme/alpha#100",
|
||||||
|
repo: "acme/alpha",
|
||||||
|
issueNumber: 100,
|
||||||
|
url: "https://github.com/acme/alpha/issues/100",
|
||||||
|
resolvedAt: "2026-03-03T12:00:00.000Z",
|
||||||
|
resolvedAtExact: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
seedSignalMetrics(dbA, { prefix: "SIG-A", source: "sentry", open: 1, resolved: 1 });
|
seedSignalMetrics(dbA, { prefix: "SIG-A", source: "sentry", open: 1, resolved: 1 });
|
||||||
const signals = await request(app, "GET", `/api/command-center/signals?${range}&projectId=proj-a`);
|
const signals = await request(app, "GET", `/api/command-center/signals?${range}&projectId=proj-a`);
|
||||||
@@ -463,12 +477,24 @@ describe("register-command-center-routes", () => {
|
|||||||
expect(invalid.body).toHaveProperty("fixed");
|
expect(invalid.body).toHaveProperty("fixed");
|
||||||
expect(invalid.body).toHaveProperty("daily");
|
expect(invalid.body).toHaveProperty("daily");
|
||||||
expect(invalid.body).toHaveProperty("byRepo");
|
expect(invalid.body).toHaveProperty("byRepo");
|
||||||
|
expect(invalid.body).toHaveProperty("resolved");
|
||||||
|
|
||||||
const range = "from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z";
|
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 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`);
|
const b = await request(app, "GET", `/api/command-center/github?${range}&projectId=proj-b`);
|
||||||
expect(a.body).toMatchObject({ filed: 2, fixed: 1 });
|
expect(a.body).toMatchObject({ filed: 2, fixed: 1 });
|
||||||
expect(b.body).toMatchObject({ filed: 5, fixed: 4 });
|
expect(b.body).toMatchObject({ filed: 5, fixed: 4 });
|
||||||
|
expect((a.body as { resolved: Array<{ repo: string; taskId: string }> }).resolved).toHaveLength(1);
|
||||||
|
expect((a.body as { resolved: Array<{ repo: string; taskId: string }> }).resolved[0]).toMatchObject({
|
||||||
|
repo: "acme/alpha",
|
||||||
|
taskId: "FN-A-fixed-0",
|
||||||
|
});
|
||||||
|
expect((a.body as { resolved: Array<{ repo: string }> }).resolved).not.toContainEqual(expect.objectContaining({ repo: "acme/beta" }));
|
||||||
|
expect((b.body as { resolved: Array<{ repo: string; taskId: string }> }).resolved).toHaveLength(4);
|
||||||
|
expect((b.body as { resolved: Array<{ repo: string; taskId: string }> }).resolved[0]).toMatchObject({
|
||||||
|
repo: "acme/beta",
|
||||||
|
taskId: "FN-B-fixed-0",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("?format=csv returns well-formed CSV with attachment header", async () => {
|
it("?format=csv returns well-formed CSV with attachment header", async () => {
|
||||||
@@ -508,6 +534,33 @@ describe("register-command-center-routes", () => {
|
|||||||
expect(lines[1]).toContain(",0,");
|
expect(lines[1]).toContain(",0,");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("?format=csv includes GitHub resolved issue detail rows", async () => {
|
||||||
|
seedGithubIssueMetrics(dbA, { prefix: "FN-A", repo: "acme/alpha", filed: 1, fixed: 1 });
|
||||||
|
seedGithubIssueMetrics(dbB, { prefix: "FN-B", repo: "acme/beta", filed: 1, fixed: 1 });
|
||||||
|
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&format=csv`,
|
||||||
|
);
|
||||||
|
const b = await request(
|
||||||
|
app,
|
||||||
|
"GET",
|
||||||
|
`/api/command-center/github?${range}&projectId=proj-b&format=csv`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(a.status).toBe(200);
|
||||||
|
expect(a.headers["content-disposition"]).toBe(
|
||||||
|
'attachment; filename="command-center-github.csv"',
|
||||||
|
);
|
||||||
|
expect(a.body as string).toContain("section,key,filed,fixed,net,taskId,taskTitle,resolvedAt,resolvedAtExact,url");
|
||||||
|
expect(a.body as string).toContain("resolved,acme/alpha#100,,,,FN-A-fixed-0,Resolve acme/alpha#100,2026-03-03T12:00:00.000Z,true,https://github.com/acme/alpha/issues/100");
|
||||||
|
expect(a.body as string).not.toContain("acme/beta#100");
|
||||||
|
expect(b.body as string).toContain("resolved,acme/beta#100,,,,FN-B-fixed-0");
|
||||||
|
expect(b.body as string).not.toContain("acme/alpha#100");
|
||||||
|
});
|
||||||
|
|
||||||
it("?format=csv RFC-4180 quotes values with commas/quotes/newlines", async () => {
|
it("?format=csv RFC-4180 quotes values with commas/quotes/newlines", async () => {
|
||||||
// Seed a task whose model id contains a comma + quote + newline so the
|
// Seed a task whose model id contains a comma + quote + newline so the
|
||||||
// groupBy=model group key forces RFC-4180 quoting through the export path.
|
// groupBy=model group key forces RFC-4180 quoting through the export path.
|
||||||
|
|||||||
@@ -182,21 +182,52 @@ export function productivityAnalyticsToTable(
|
|||||||
return { header, rows };
|
return { header, rows };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GitHub issue analytics → CSV. Daily rows plus repo and summary rows. */
|
/** GitHub issue analytics → CSV. Daily, repo, resolved detail, and summary rows. */
|
||||||
export function githubIssueAnalyticsToTable(
|
export function githubIssueAnalyticsToTable(
|
||||||
result: GithubIssueAnalytics,
|
result: GithubIssueAnalytics,
|
||||||
): CsvTable {
|
): CsvTable {
|
||||||
const header = ["section", "key", "filed", "fixed", "net"];
|
const header = [
|
||||||
|
"section",
|
||||||
|
"key",
|
||||||
|
"filed",
|
||||||
|
"fixed",
|
||||||
|
"net",
|
||||||
|
"taskId",
|
||||||
|
"taskTitle",
|
||||||
|
"resolvedAt",
|
||||||
|
"resolvedAtExact",
|
||||||
|
"url",
|
||||||
|
];
|
||||||
const rows: CsvCell[][] = result.daily.map((d) => [
|
const rows: CsvCell[][] = result.daily.map((d) => [
|
||||||
"daily",
|
"daily",
|
||||||
d.date,
|
d.date,
|
||||||
d.filed,
|
d.filed,
|
||||||
d.fixed,
|
d.fixed,
|
||||||
d.filed - d.fixed,
|
d.filed - d.fixed,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
]);
|
]);
|
||||||
for (const repo of result.byRepo) {
|
for (const repo of result.byRepo) {
|
||||||
rows.push(["repo", repo.repo, repo.filed, repo.fixed, repo.filed - repo.fixed]);
|
rows.push(["repo", repo.repo, repo.filed, repo.fixed, repo.filed - repo.fixed, "", "", "", "", ""]);
|
||||||
}
|
}
|
||||||
rows.push(["summary", "total", result.filed, result.fixed, result.net]);
|
for (const issue of result.resolved) {
|
||||||
|
const key = issue.issueNumber === null ? issue.repo : `${issue.repo}#${issue.issueNumber}`;
|
||||||
|
rows.push([
|
||||||
|
"resolved",
|
||||||
|
key,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
issue.taskId,
|
||||||
|
issue.taskTitle,
|
||||||
|
issue.resolvedAt,
|
||||||
|
issue.resolvedAtExact,
|
||||||
|
issue.url,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
rows.push(["summary", "total", result.filed, result.fixed, result.net, "", "", "", "", ""]);
|
||||||
return { header, rows };
|
return { header, rows };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -289,9 +289,12 @@ Keep it out of this exclude list so loaded API shards keep exercising cross-tab
|
|||||||
|
|
||||||
FNXC:DashboardTestQuarantine 2026-06-19-16:50:
|
FNXC:DashboardTestQuarantine 2026-06-19-16:50:
|
||||||
FN-6743 rescued QuickEntryBox's third quarantine cycle by replacing the ref-gated post-submit focus effect with a resolved-submit focus trigger and broadening desktop/mobile submit coverage.
|
FN-6743 rescued QuickEntryBox's third quarantine cycle by replacing the ref-gated post-submit focus effect with a resolved-submit focus trigger and broadening desktop/mobile submit coverage.
|
||||||
Keep QuickEntryBox out of this exclude list so the dashboard app lanes exercise Enter, Save, duplicate-confirmed creation, mobile non-focus, and failure-preserves-draft focus invariants.
|
Keep QuickEntryBox out of this list so the dashboard app lanes exercise Enter, Save, duplicate-confirmed creation, mobile non-focus, and failure-preserves-draft focus invariants.
|
||||||
|
|
||||||
|
FNXC:DashboardTestQuarantine 2026-06-21-06:50:
|
||||||
|
FN-6722 workspace verification observed dev-server-process time out only in the broad dashboard API backfill shard while the isolated file passed immediately. Quarantine the process/timer race under the deletion ratchet instead of widening waits or changing unrelated Command Center behavior.
|
||||||
*/
|
*/
|
||||||
const quarantinedDashboardTests: string[] = [];
|
const quarantinedDashboardTests: string[] = ["src/__tests__/dev-server-process.test.ts"];
|
||||||
|
|
||||||
const qualityApiTests = [
|
const qualityApiTests = [
|
||||||
// Critical HTTP/server behavior: auth, task/project/settings mutation,
|
// Critical HTTP/server behavior: auth, task/project/settings mutation,
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
{
|
{
|
||||||
"$comment": "Flaky-test quarantine ledger (deletion ratchet — see AGENTS.md 'Flaky tests: quarantine on sight' and docs/testing.md 'Quarantine ledger and the deletion ratchet'). A test observed failing without a corresponding real bug is quarantined ON SIGHT: add an entry here AND a matching one-line `exclude` entry in that package's vitest config, in the same commit. Every entry needs a non-empty `reason` (link the failing run) and a `quarantinedAt` date — the entry expires 14 days later, at which point the test file is DELETED unless someone rescues it with evidence it catches real regressions plus a root-cause fix (never appeasement). There is deliberately no loader module and no automation around this file: it is a dated record, the vitest config exclude is the mechanism, and the sweep is policy executed by whoever touches the suite.",
|
"$comment": "Flaky-test quarantine ledger (deletion ratchet — see AGENTS.md 'Flaky tests: quarantine on sight' and docs/testing.md 'Quarantine ledger and the deletion ratchet'). A test observed failing without a corresponding real bug is quarantined ON SIGHT: add an entry here AND a matching one-line `exclude` entry in that package's vitest config, in the same commit. Every entry needs a non-empty `reason` (link the failing run) and a `quarantinedAt` date — the entry expires 14 days later, at which point the test file is DELETED unless someone rescues it with evidence it catches real regressions plus a root-cause fix (never appeasement). There is deliberately no loader module and no automation around this file: it is a dated record, the vitest config exclude is the mechanism, and the sweep is policy executed by whoever touches the suite.",
|
||||||
"entries": [
|
"entries": [
|
||||||
|
{
|
||||||
|
"file": "packages/dashboard/src/__tests__/dev-server-process.test.ts",
|
||||||
|
"reason": "FN-6722 broad workspace `pnpm test` observed `clears fallback probe timer when URL is detected from logs` time out only in the dashboard-api-quality-backfill shard; isolated rerun passed, so quarantine the timer/process race on sight instead of widening waits or changing unrelated Command Center behavior.",
|
||||||
|
"quarantinedAt": "2026-06-21"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"file": "packages/dashboard/src/__tests__/session-cross-tab.test.ts",
|
"file": "packages/dashboard/src/__tests__/session-cross-tab.test.ts",
|
||||||
"reason": "FN-6690 local workspace `pnpm test` observed ENOTEMPTY while removing the test's temp .fusion directory in dashboard-api-quality-backfill shard; isolated rerun passed, indicating cleanup flake rather than a lazy-view CSS regression.",
|
"reason": "FN-6690 local workspace `pnpm test` observed ENOTEMPTY while removing the test's temp .fusion directory in dashboard-api-quality-backfill shard; isolated rerun passed, indicating cleanup flake rather than a lazy-view CSS regression.",
|
||||||
|
|||||||
Reference in New Issue
Block a user