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:
@@ -31,29 +31,32 @@ function insertSourceIssueTask(
|
||||
id: string,
|
||||
opts: {
|
||||
provider: string;
|
||||
repository: string;
|
||||
repository: string | null;
|
||||
column: string;
|
||||
updatedAt: string;
|
||||
closedAt?: string | null;
|
||||
issueNumber?: number;
|
||||
issueNumber?: number | null;
|
||||
url?: string | null;
|
||||
title?: string | null;
|
||||
},
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT INTO tasks (
|
||||
id, description, "column", createdAt, updatedAt,
|
||||
id, title, description, "column", createdAt, updatedAt,
|
||||
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId,
|
||||
sourceIssueNumber, sourceIssueUrl, sourceIssueClosedAt
|
||||
) VALUES (?, 'desc', ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
) VALUES (?, ?, 'desc', ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
id,
|
||||
opts.title ?? null,
|
||||
opts.column,
|
||||
opts.updatedAt,
|
||||
opts.updatedAt,
|
||||
opts.provider,
|
||||
opts.repository,
|
||||
String(opts.issueNumber ?? 1),
|
||||
opts.issueNumber ?? 1,
|
||||
`https://example.test/${id}`,
|
||||
opts.issueNumber === undefined ? 1 : opts.issueNumber,
|
||||
opts.url === undefined ? `https://example.test/${id}` : opts.url,
|
||||
opts.closedAt ?? null,
|
||||
);
|
||||
}
|
||||
@@ -149,6 +152,99 @@ describe("github-issue-analytics", () => {
|
||||
{ repo: "acme/alpha", filed: 2, 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", () => {
|
||||
@@ -248,6 +344,7 @@ describe("github-issue-analytics", () => {
|
||||
net: 0,
|
||||
daily: [],
|
||||
byRepo: [],
|
||||
resolved: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ 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. 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 {
|
||||
@@ -28,6 +31,23 @@ export interface GithubIssueRepoBreakdown {
|
||||
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 {
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
@@ -41,6 +61,8 @@ export interface GithubIssueAnalytics {
|
||||
daily: GithubIssueDailyPoint[];
|
||||
/** Filed/fixed counts grouped by repository, descending by total activity. */
|
||||
byRepo: GithubIssueRepoBreakdown[];
|
||||
/** Imported GitHub source issues completed in range, most-recently resolved first. */
|
||||
resolved: GithubResolvedIssue[];
|
||||
}
|
||||
|
||||
interface GithubTrackingRow {
|
||||
@@ -48,7 +70,11 @@ interface GithubTrackingRow {
|
||||
}
|
||||
|
||||
interface FixedIssueRow {
|
||||
id: string;
|
||||
title: string | null;
|
||||
sourceIssueRepository: string | null;
|
||||
sourceIssueNumber: number | null;
|
||||
sourceIssueUrl: string | null;
|
||||
sourceIssueClosedAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
@@ -153,12 +179,14 @@ export function aggregateGithubIssueAnalytics(
|
||||
|
||||
const fixedRows = db
|
||||
.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[];
|
||||
|
||||
let fixed = 0;
|
||||
const resolved: GithubResolvedIssue[] = [];
|
||||
for (const row of fixedRows) {
|
||||
const hasExactResolvedAt = row.sourceIssueClosedAt !== null;
|
||||
const fixedDate = row.sourceIssueClosedAt ?? row.updatedAt;
|
||||
if (fixedDate === null || !isInRange(fixedDate, query)) continue;
|
||||
|
||||
@@ -167,8 +195,22 @@ export function aggregateGithubIssueAnalytics(
|
||||
addRepo(byRepo, repo, "fixed");
|
||||
const day = dayKey(fixedDate);
|
||||
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 {
|
||||
from: query.from ?? null,
|
||||
to: query.to ?? null,
|
||||
@@ -184,5 +226,6 @@ export function aggregateGithubIssueAnalytics(
|
||||
const total = b.filed + b.fixed - (a.filed + a.fixed);
|
||||
return total !== 0 ? total : a.repo.localeCompare(b.repo);
|
||||
}),
|
||||
resolved,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -600,6 +600,7 @@ export type {
|
||||
GithubIssueAnalyticsQuery,
|
||||
GithubIssueDailyPoint,
|
||||
GithubIssueRepoBreakdown,
|
||||
GithubResolvedIssue,
|
||||
} from "./github-issue-analytics.js";
|
||||
export { aggregateSignalsAnalytics } from "./signals-analytics.js";
|
||||
export type {
|
||||
|
||||
@@ -189,7 +189,7 @@ function populatedSignalsFixture() {
|
||||
}
|
||||
|
||||
function emptyGithubFixture() {
|
||||
return { filed: 0, fixed: 0, net: 0, daily: [], byRepo: [] };
|
||||
return { filed: 0, fixed: 0, net: 0, daily: [], byRepo: [], resolved: [] };
|
||||
}
|
||||
|
||||
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/activity")) return Promise.resolve(populated ? populatedActivityFixture() : emptyActivityFixture());
|
||||
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/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());
|
||||
|
||||
@@ -147,6 +147,7 @@ function githubFixture(filed = 0, fixed = 0) {
|
||||
net: filed - fixed,
|
||||
daily: filed || fixed ? [{ date: "2026-06-08", filed, fixed }] : [],
|
||||
byRepo: filed || fixed ? [{ repo: "acme/alpha", filed, fixed }] : [],
|
||||
resolved: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/*
|
||||
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.
|
||||
|
||||
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 { useTranslation } from "react-i18next";
|
||||
@@ -21,6 +24,12 @@ const GITHUB_SOURCE_ISSUE_BACKFILL_MAX_BATCHES = 1000;
|
||||
|
||||
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 }) {
|
||||
const { t } = useTranslation("app");
|
||||
const { data, isLoading, error } = useAnalyticsArea<GithubIssueAnalytics>(
|
||||
@@ -80,6 +89,7 @@ export function GithubArea({ range }: { range: DateRange }) {
|
||||
|
||||
const daily = useMemo(() => data?.daily ?? [], [data?.daily]);
|
||||
const byRepo = useMemo(() => data?.byRepo ?? [], [data?.byRepo]);
|
||||
const resolved = useMemo(() => data?.resolved ?? [], [data?.resolved]);
|
||||
const filedValues = useMemo(() => daily.map((d) => d.filed), [daily]);
|
||||
const fixedValues = useMemo(() => daily.map((d) => d.fixed), [daily]);
|
||||
const maxDaily = useMemo(
|
||||
@@ -124,6 +134,7 @@ export function GithubArea({ range }: { range: DateRange }) {
|
||||
const hasDailyTrend = daily.length > 0;
|
||||
const hasIssueFlowPie = filed + fixed > 0;
|
||||
const hasRepoBreakdown = repoBars.length > 0;
|
||||
const hasResolvedIssues = resolved.length > 0;
|
||||
const backfillStatusClass = backfillError || (backfillResult?.errors ?? 0) > 0
|
||||
? "cc-github-backfill-status--error"
|
||||
: isBackfilling
|
||||
@@ -253,6 +264,62 @@ export function GithubArea({ range }: { range: DateRange }) {
|
||||
<Bar data={repoBars} ariaLabel={t("commandCenter.github.byRepo", "By repository")} />
|
||||
</div>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ function githubFixture() {
|
||||
{ repo: "acme/alpha", filed: 4, fixed: 1 },
|
||||
{ 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();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
apiMock.mockResolvedValue({ ...githubFixture(), filed: 0, fixed: 0, net: 0, daily: [], byRepo: [] });
|
||||
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) {
|
||||
db.prepare(
|
||||
`INSERT INTO tasks (
|
||||
id, description, "column", createdAt, updatedAt,
|
||||
id, title, 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', ?, ?, ?, ?)`,
|
||||
sourceIssueNumber, sourceIssueUrl, sourceIssueClosedAt
|
||||
) VALUES (?, ?, 'desc', 'done', '2026-03-03T00:00:00.000Z', '2026-03-03T00:00:00.000Z',
|
||||
'github', ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
`${opts.prefix}-fixed-${i}`,
|
||||
`Resolve ${opts.repo}#${i + 100}`,
|
||||
opts.repo,
|
||||
String(i + 100),
|
||||
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).toHaveProperty("daily");
|
||||
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 });
|
||||
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("daily");
|
||||
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 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 });
|
||||
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 () => {
|
||||
@@ -508,6 +534,33 @@ describe("register-command-center-routes", () => {
|
||||
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 () => {
|
||||
// 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.
|
||||
|
||||
@@ -182,21 +182,52 @@ export function productivityAnalyticsToTable(
|
||||
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(
|
||||
result: GithubIssueAnalytics,
|
||||
): 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) => [
|
||||
"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(["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 };
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
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 = [
|
||||
// Critical HTTP/server behavior: auth, task/project/settings mutation,
|
||||
|
||||
Reference in New Issue
Block a user