FN-6675: add GitHub close-time backfill action

Add a Command Center operator path to backfill exact GitHub source-issue close times.

- Add a dashboard API client for the paginated GitHub source-issue closed-at backfill endpoint.
- Add a GitHub Command Center button, progress/error/result state, and styling for manual backfills.
- Cover backfill success, empty, error, and pagination behavior with area tests and document the operator flow.

Files changed:
 .../fn-6675-github-closed-at-backfill-dashboard.md |   5 +
 docs/dashboard-guide.md                            |   4 +-
 docs/storage.md                                    |   2 +-
 packages/dashboard/app/api/legacy.ts               |  25 ++++
 .../components/command-center/CommandCenter.css    |  39 ++++++
 .../components/command-center/areas/GithubArea.tsx | 117 ++++++++++++++++-
 .../command-center/areas/__tests__/areas.test.tsx  | 140 ++++++++++++++++++++-
 7 files changed, 326 insertions(+), 6 deletions(-)

Fusion-Task-Id: FN-6675

Fusion-Task-Lineage: 09e43b60-b9db-421f-a131-740a0d3164fe
This commit is contained in:
gsxdsm
2026-06-18 19:50:58 -07:00
parent 21d8076ae2
commit 20597901a0
7 changed files with 326 additions and 6 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add a Command Center GitHub affordance for operators to run the historical source-issue closed-at backfill and review accumulated scanned, filled, skipped, and error counts.

View File

@@ -670,7 +670,7 @@ Features:
- **Productivity** separates outcome counters (commits and pull requests) from volume proxies such as modified files, lines changed, and files by language.
- **Team** shows a per-agent analytics table plus tokens-by-agent and tasks-done-by-agent charts. 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. Decorative chart reveal motion uses duration tokens and is disabled for reduced-motion users.
- **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 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, an operator can run the project-scoped manual `POST /api/git/github/backfill-source-issue-closed-at` endpoint with optional `{ "offset": 0, "limit": 200 }` batches until `hasMore` is false; the endpoint fetches real GitHub `closed_at` values once, fills only missing `sourceIssueClosedAt` values, and never runs automatically. The area shows filed/fixed/net stat cards, filed-vs-fixed daily sparklines, and a by-repository bar breakdown.
- **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, filed-vs-fixed daily sparklines, and a by-repository bar breakdown.
- **Signals** shows external signal totals, open/resolved counts, MTTR, and source/severity breakdowns when signal sources are connected.
- **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 trend sparklines, and charts tasks by column plus agents by state with the shared Command Center chart primitives. 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. Motion-heavy accents respect reduced-motion preferences.
@@ -682,7 +682,7 @@ Rendering invariants:
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.
- GitHub issue analytics is local and additive: empty filed/fixed totals keep the stat cards and historical backfill button available while omitting empty chart shells; malformed historical `githubTracking` JSON is skipped instead of breaking the Command Center.
- Team analytics renders its shared loading/error/empty states for null or zero-agent responses, omits empty chart shells for zero-value datasets, and keeps the Command Center tab panel as the mobile scroll owner.
- System telemetry keeps the previous snapshot visible during refresh failures, renders a first-sample CPU `Sampling…` state without NaN values, shows zero-value task/agent bars for empty collections, and keeps the Command Center tab panel as the mobile scroll owner.
- 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.

View File

@@ -388,7 +388,7 @@ FN-5240/FN-5241/FN-5242 establish the handoff invariant: the only legal executor
The `tasks.githubTracking` JSON column stores per-task GitHub tracking state (`enabled`, optional `repoOverride`, linked issue metadata, and `unlinkedAt`). It is additive and default-off; imported-source issue metadata remains in `issueInfo` / `sourceIssue`. Behavior wiring (issue creation/lifecycle sync and UI surfacing) lands in FN-3870/FN-3873/FN-3874.
The `tasks.sourceIssueClosedAt` column (migration 122) backs `TaskSourceIssue.closedAt`, a nullable ISO-8601 timestamp for the originating external issue's real close time. Going forward, the GitHub source-issue reconciler fills it when it closes the linked issue itself or observes GitHub's `closed_at`/`closedAt` value. Historical GitHub-imported `done`/`archived` rows that still have `NULL` can be filled retroactively by the optional manual `POST /api/git/github/backfill-source-issue-closed-at` sweep; the sweep is idempotent, paginated, writes only real GitHub `closed_at` values, and never overwrites an existing timestamp. Command Center "Fixed by Fusion" analytics read this exact timestamp when available and fall back to `updatedAt` only when it has not been observed.
The `tasks.sourceIssueClosedAt` column (migration 122) backs `TaskSourceIssue.closedAt`, a nullable ISO-8601 timestamp for the originating external issue's real close time. Going forward, the GitHub source-issue reconciler fills it when it closes the linked issue itself or observes GitHub's `closed_at`/`closedAt` value. Historical GitHub-imported `done`/`archived` rows that still have `NULL` can be filled retroactively by the optional manual `POST /api/git/github/backfill-source-issue-closed-at` sweep, now exposed as **Backfill exact close times** in the Command Center GitHub area's Fixed by Fusion card. The sweep is idempotent, paginated, writes only real GitHub `closed_at` values, reports `scanned`/`filled`/`skipped`/`errors`, and never overwrites an existing timestamp or runs automatically. Command Center "Fixed by Fusion" analytics read this exact timestamp when available and fall back to `updatedAt` only when it has not been observed.
The `tasks.tokenUsage*` columns store cumulative per-task token usage for analytics. `tokenUsageModelProvider` and `tokenUsageModelId` are analytics-only snapshots of the actually-used runtime model recorded when usage is accumulated; they let Command Center group and price resolved-via-settings usage by provider/model without writing the task-level `modelProvider` / `modelId` own-model override fields that control future model resolution. Cost attribution reads the snapshot first and falls back to the legacy own-model columns for pre-snapshot rows.
| `config` | Single-row project configuration (`nextId`, settings payload, workflow step counters). |

View File

@@ -7196,6 +7196,14 @@ export interface KillVitestResponse {
pids: number[];
}
export interface GithubSourceIssueClosedAtBackfillResult {
scanned: number;
filled: number;
skipped: number;
errors: number;
hasMore: boolean;
}
export function fetchSystemStats(projectId?: string): Promise<SystemStatsResponse> {
return api<SystemStatsResponse>(withProjectId("/system-stats", projectId));
}
@@ -7206,6 +7214,23 @@ export function killVitestProcesses(projectId?: string): Promise<KillVitestRespo
});
}
/**
* FNXC:GithubSourceIssueBackfill 2026-06-18-19:20:
* Thin client for the FN-6674 manual source-issue closed-at backfill endpoint. Callers own bounded pagination until `hasMore === false`; this helper keeps the GitHub lookup in the explicit operator action path and out of analytics/render-time data loading.
*/
export function apiBackfillGithubSourceIssueClosedAt(
options: { offset?: number; limit?: number } = {},
projectId?: string,
): Promise<GithubSourceIssueClosedAtBackfillResult> {
return api<GithubSourceIssueClosedAtBackfillResult>(
withProjectId("/git/github/backfill-source-issue-closed-at", projectId),
{
method: "POST",
body: JSON.stringify({ offset: options.offset, limit: options.limit }),
},
);
}
/** Fetch unified activity feed */
export function fetchActivityFeed(options?: FeedOptions): Promise<ActivityFeedEntry[]> {
const params = new URLSearchParams();

View File

@@ -129,6 +129,45 @@ Command Center chart/stat surfaces share one tokenized card rhythm so new chart
text-align: center;
}
/*
FNXC:CommandCenterGithub 2026-06-18-19:27:
The GitHub closed-at backfill control lives inside the existing Fixed by Fusion card and must use tokenized spacing/status colors so the operator result row stays readable on desktop and mobile without creating a separate layout surface.
*/
.cc-github-backfill-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
.cc-github-backfill-status {
display: flex;
flex-direction: column;
gap: var(--space-1);
color: var(--text-muted);
font-size: var(--font-size-xs);
}
.cc-github-backfill-status--error {
color: var(--color-error);
}
.cc-github-backfill-status--warning {
color: var(--color-warning);
}
@media (max-width: 768px) {
.cc-github-backfill-actions {
align-items: stretch;
flex-direction: column;
}
.cc-github-backfill-actions .btn {
justify-content: center;
inline-size: 100%;
}
}
/*
FNXC:CommandCenterStyling 2026-06-18-00:00:
Command Center live/chart containers must shrink inside the mobile tabpanel without creating a second scroll owner or clipping chart height (FN-6664).

View File

@@ -2,9 +2,12 @@
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 { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { RefreshCw } from "lucide-react";
import type { GithubIssueAnalytics } from "@fusion/core";
import { apiBackfillGithubSourceIssueClosedAt } from "../../../api/legacy";
import type { GithubSourceIssueClosedAtBackfillResult } from "../../../api/legacy";
import type { DateRange } from "../DateRangePicker";
import { Bar } from "../charts/Bar";
import { Sparkline } from "../charts/Sparkline";
@@ -12,12 +15,67 @@ import { AreaShell } from "./AreaShell";
import { useAnalyticsArea } from "./useAnalyticsArea";
import { formatCount } from "./areaShared";
const GITHUB_SOURCE_ISSUE_BACKFILL_LIMIT = 100;
const GITHUB_SOURCE_ISSUE_BACKFILL_MAX_BATCHES = 1000;
type BackfillAggregate = Omit<GithubSourceIssueClosedAtBackfillResult, "hasMore">;
export function GithubArea({ range }: { range: DateRange }) {
const { t } = useTranslation("app");
const { data, isLoading, error } = useAnalyticsArea<GithubIssueAnalytics>(
"/command-center/github",
range,
);
const [isBackfilling, setIsBackfilling] = useState(false);
const [backfillResult, setBackfillResult] = useState<BackfillAggregate | null>(null);
const [backfillError, setBackfillError] = useState<string | null>(null);
const handleBackfill = useCallback(async () => {
if (isBackfilling) return;
setIsBackfilling(true);
setBackfillResult(null);
setBackfillError(null);
try {
let offset = 0;
const aggregate: BackfillAggregate = { scanned: 0, filled: 0, skipped: 0, errors: 0 };
for (let batch = 0; batch < GITHUB_SOURCE_ISSUE_BACKFILL_MAX_BATCHES; batch += 1) {
const result = await apiBackfillGithubSourceIssueClosedAt({
offset,
limit: GITHUB_SOURCE_ISSUE_BACKFILL_LIMIT,
});
aggregate.scanned += result.scanned;
aggregate.filled += result.filled;
aggregate.skipped += result.skipped;
aggregate.errors += result.errors;
if (!result.hasMore) {
setBackfillResult(aggregate);
return;
}
offset += GITHUB_SOURCE_ISSUE_BACKFILL_LIMIT;
}
setBackfillResult(aggregate);
setBackfillError(
t(
"commandCenter.github.backfillMaxBatches",
"Backfill stopped after the safety limit; rerun after checking server logs.",
),
);
} catch (err) {
setBackfillError(
err instanceof Error
? err.message
: t("commandCenter.github.backfillFailed", "Failed to backfill GitHub source issue close times"),
);
} finally {
setIsBackfilling(false);
}
}, [isBackfilling, t]);
const daily = useMemo(() => data?.daily ?? [], [data?.daily]);
const byRepo = useMemo(() => data?.byRepo ?? [], [data?.byRepo]);
@@ -46,17 +104,25 @@ export function GithubArea({ range }: { range: DateRange }) {
const isEmpty = !data || (filed === 0 && fixed === 0);
const hasDailyTrend = daily.length > 0;
const hasRepoBreakdown = repoBars.length > 0;
const backfillStatusClass = backfillError || (backfillResult?.errors ?? 0) > 0
? "cc-github-backfill-status--error"
: isBackfilling
? "cc-github-backfill-status--warning"
: "";
return (
<AreaShell
testId="github"
isLoading={isLoading}
error={error}
isEmpty={isEmpty}
isEmpty={false}
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>
{isEmpty ? (
<span className="cc-stat-sub">{t("commandCenter.github.empty", "No GitHub issue activity in the selected range.")}</span>
) : null}
<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>
@@ -68,6 +134,53 @@ export function GithubArea({ range }: { range: DateRange }) {
<span className="cc-stat-sub">
{t("commandCenter.github.fixedApproximation", "Uses done tasks updated in range")}
</span>
{/*
FNXC:CommandCenterGithub 2026-06-18-19:24:
This backfill is an explicit operator action that paginates the FN-6674 endpoint until `hasMore === false`, then surfaces scanned/filled/skipped/errors. Keep GitHub network fetches here in the click handler only; Command Center analytics and render-time data loading must stay backed by local task-store data.
*/}
<div className="cc-github-backfill-actions">
<button
type="button"
className="btn"
data-testid="cc-github-backfill-button"
onClick={() => void handleBackfill()}
disabled={isBackfilling}
>
<RefreshCw className={isBackfilling ? "spin" : undefined} />
<span>
{isBackfilling
? t("commandCenter.github.backfillBusy", "Backfilling close times…")
: t("commandCenter.github.backfillButton", "Backfill exact close times")}
</span>
</button>
</div>
{isBackfilling || backfillResult || backfillError ? (
<div
className={`cc-github-backfill-status ${backfillStatusClass}`.trim()}
data-testid="cc-github-backfill-result"
role="status"
>
{isBackfilling ? (
<span>{t("commandCenter.github.backfillPending", "Backfill is running in paginated batches.")}</span>
) : null}
{backfillError ? <span>{backfillError}</span> : null}
{backfillResult ? (
<span>
{backfillResult.scanned === 0 && backfillResult.filled === 0
? t(
"commandCenter.github.backfillNothing",
"Nothing to backfill. Scanned {{scanned}}, filled {{filled}}, skipped {{skipped}}, errors {{errors}}.",
backfillResult,
)
: t(
"commandCenter.github.backfillResult",
"Backfill complete. Scanned {{scanned}}, filled {{filled}}, skipped {{skipped}}, errors {{errors}}.",
backfillResult,
)}
</span>
) : null}
</div>
) : null}
</div>
<div className="card cc-stat-card" data-testid="cc-github-net">
<div className="cc-stat-label">{t("commandCenter.github.net", "Net")}</div>

View File

@@ -7,8 +7,11 @@ import { render, screen, fireEvent, waitFor, within, act, renderHook } from "@te
// Mock the api() helper so the areas fetch deterministic fixtures.
const apiMock = vi.fn();
const backfillGithubSourceIssueClosedAtMock = vi.fn();
vi.mock("../../../../api/legacy", () => ({
api: (path: string, opts?: RequestInit) => apiMock(path, opts),
apiBackfillGithubSourceIssueClosedAt: (options?: { offset?: number; limit?: number }, projectId?: string) =>
backfillGithubSourceIssueClosedAtMock(options, projectId),
}));
import { TokensArea } from "../TokensArea";
@@ -139,6 +142,7 @@ function activityFixture() {
beforeEach(() => {
apiMock.mockReset();
backfillGithubSourceIssueClosedAtMock.mockReset();
});
afterEach(() => {
@@ -578,7 +582,9 @@ describe("GithubArea", () => {
apiMock.mockResolvedValue({ ...githubFixture(), filed: 0, fixed: 0, net: 0, daily: [], byRepo: [] });
render(<GithubArea range={range7d} />);
await screen.findByTestId("cc-area-github-empty");
await screen.findByTestId("cc-area-github");
expect(screen.getByTestId("cc-area-github").textContent).toContain("No GitHub issue activity");
expect(screen.getByTestId("cc-github-backfill-button")).toBeTruthy();
expect(screen.queryByTestId("cc-github-daily-trend")).toBeNull();
expect(screen.queryByTestId("cc-github-by-repo")).toBeNull();
});
@@ -609,6 +615,138 @@ describe("GithubArea", () => {
render(<GithubArea range={customRange("2026-06-10", "2026-06-01")} />);
await waitFor(() => expect(apiMock).not.toHaveBeenCalled());
});
it("runs a single backfill batch and renders accumulated result counts", async () => {
apiMock.mockResolvedValue(githubFixture());
backfillGithubSourceIssueClosedAtMock.mockResolvedValueOnce({
scanned: 4,
filled: 2,
skipped: 1,
errors: 0,
hasMore: false,
});
render(<GithubArea range={range7d} />);
await screen.findByTestId("cc-area-github");
fireEvent.click(screen.getByTestId("cc-github-backfill-button"));
await screen.findByText(/Backfill complete/i);
expect(backfillGithubSourceIssueClosedAtMock).toHaveBeenCalledWith({ offset: 0, limit: 100 }, undefined);
const result = screen.getByTestId("cc-github-backfill-result");
expect(result.textContent).toContain("Scanned 4, filled 2, skipped 1, errors 0");
});
it("paginates multi-batch backfills with advancing offsets and summed counts", async () => {
apiMock.mockResolvedValue(githubFixture());
backfillGithubSourceIssueClosedAtMock
.mockResolvedValueOnce({ scanned: 100, filled: 4, skipped: 90, errors: 1, hasMore: true })
.mockResolvedValueOnce({ scanned: 25, filled: 3, skipped: 22, errors: 0, hasMore: false });
render(<GithubArea range={range7d} />);
await screen.findByTestId("cc-area-github");
fireEvent.click(screen.getByTestId("cc-github-backfill-button"));
await waitFor(() => expect(backfillGithubSourceIssueClosedAtMock).toHaveBeenCalledTimes(2));
expect(backfillGithubSourceIssueClosedAtMock).toHaveBeenNthCalledWith(1, { offset: 0, limit: 100 }, undefined);
expect(backfillGithubSourceIssueClosedAtMock).toHaveBeenNthCalledWith(2, { offset: 100, limit: 100 }, undefined);
const result = await screen.findByTestId("cc-github-backfill-result");
expect(result.textContent).toContain("Scanned 125, filled 7, skipped 112, errors 1");
expect(result.className).toContain("cc-github-backfill-status--error");
});
it("shows the all-zero backfill as nothing to backfill instead of an error", async () => {
apiMock.mockResolvedValue(githubFixture());
backfillGithubSourceIssueClosedAtMock.mockResolvedValueOnce({
scanned: 0,
filled: 0,
skipped: 0,
errors: 0,
hasMore: false,
});
render(<GithubArea range={range7d} />);
await screen.findByTestId("cc-area-github");
fireEvent.click(screen.getByTestId("cc-github-backfill-button"));
const result = await screen.findByTestId("cc-github-backfill-result");
expect(result.textContent).toContain("Nothing to backfill");
expect(result.className).not.toContain("cc-github-backfill-status--error");
});
it("surfaces nonzero backfill error counts without throwing", async () => {
apiMock.mockResolvedValue(githubFixture());
backfillGithubSourceIssueClosedAtMock.mockResolvedValueOnce({
scanned: 8,
filled: 1,
skipped: 5,
errors: 2,
hasMore: false,
});
render(<GithubArea range={range7d} />);
await screen.findByTestId("cc-area-github");
fireEvent.click(screen.getByTestId("cc-github-backfill-button"));
const result = await screen.findByTestId("cc-github-backfill-result");
expect(result.textContent).toContain("errors 2");
expect(result.className).toContain("cc-github-backfill-status--error");
});
it("captures endpoint failures in local error UI", async () => {
apiMock.mockResolvedValue(githubFixture());
backfillGithubSourceIssueClosedAtMock.mockRejectedValueOnce(new Error("endpoint failed"));
render(<GithubArea range={range7d} />);
await screen.findByTestId("cc-area-github");
fireEvent.click(screen.getByTestId("cc-github-backfill-button"));
const result = await screen.findByTestId("cc-github-backfill-result");
expect(result.textContent).toContain("endpoint failed");
expect(result.className).toContain("cc-github-backfill-status--error");
});
it("disables and guards the button while a backfill is in flight", async () => {
apiMock.mockResolvedValue(githubFixture());
let resolveBackfill: ((value: { scanned: number; filled: number; skipped: number; errors: number; hasMore: boolean }) => void) | null = null;
backfillGithubSourceIssueClosedAtMock.mockImplementationOnce(
() => new Promise((resolve) => {
resolveBackfill = resolve;
}),
);
render(<GithubArea range={range7d} />);
await screen.findByTestId("cc-area-github");
const button = screen.getByTestId("cc-github-backfill-button") as HTMLButtonElement;
fireEvent.click(button);
await waitFor(() => expect(button.disabled).toBe(true));
fireEvent.click(button);
expect(backfillGithubSourceIssueClosedAtMock).toHaveBeenCalledTimes(1);
resolveBackfill?.({ scanned: 1, filled: 1, skipped: 0, errors: 0, hasMore: false });
await screen.findByText(/Backfill complete/i);
});
it("stops a pathological always-has-more response at the max iteration guard", async () => {
apiMock.mockResolvedValue(githubFixture());
backfillGithubSourceIssueClosedAtMock.mockResolvedValue({
scanned: 1,
filled: 0,
skipped: 1,
errors: 0,
hasMore: true,
});
render(<GithubArea range={range7d} />);
await screen.findByTestId("cc-area-github");
fireEvent.click(screen.getByTestId("cc-github-backfill-button"));
const result = await screen.findByText(/safety limit/i);
expect(result.textContent).toContain("safety limit");
expect(backfillGithubSourceIssueClosedAtMock).toHaveBeenCalledTimes(1000);
expect(screen.getByTestId("cc-github-backfill-result").textContent).toContain("Scanned 1000");
});
});
describe("SignalsArea", () => {