FN-6674: backfill GitHub source issue close times

Add an opt-in sweep to populate historical GitHub source issue close timestamps from GitHub.

- Add a project-scoped backfill endpoint with offset/limit pagination for missing sourceIssueClosedAt values.
- Implement reconciler logic that fetches real closed_at values, skips open or already-filled tasks, and logs per-task failures.
- Cover the route and reconciler backfill behavior with tests, plus document the manual sweep and release note.

Files changed:
 .../fn-6674-source-issue-closed-at-backfill.md     |   5 +
 docs/dashboard-guide.md                            |   2 +-
 docs/storage.md                                    |   2 +-
 .../github-source-issue-reconciler.test.ts         | 105 ++++++++++++++++++++
 .../__tests__/register-git-github.backfill.test.ts | 110 +++++++++++++++++++++
 .../dashboard/src/github-tracking-reconciler.ts    |  71 +++++++++++++
 .../dashboard/src/routes/register-git-github.ts    |  31 ++++++
 7 files changed, 324 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-6674
Fusion-Task-Lineage: 5231b107-79f3-4326-9093-c70182bc0dc8
This commit is contained in:
gsxdsm
2026-06-18 19:12:58 -07:00
parent af31f7ddfa
commit 9b396b6378
7 changed files with 324 additions and 2 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add an optional project-scoped GitHub source-issue closed-at backfill endpoint that fills historical imported tasks with real GitHub `closed_at` values for more accurate Fixed by Fusion analytics.

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. 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, 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.
- **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.

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. It has no historical backfill: legacy rows remain `NULL` until the GitHub source-issue reconciler either closes the linked issue itself or observes GitHub's `closed_at`/`closedAt` value. 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; 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.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

@@ -28,6 +28,7 @@ function createStore(listTasks: Array<Record<string, unknown>>, settings: Record
listTasksForGithubTrackingReconcile: vi.fn().mockResolvedValue({ tasks: [], hasMore: false }),
getSettings: vi.fn().mockResolvedValue(settings),
getGlobalSettingsStore: vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) })),
updateTask: vi.fn().mockResolvedValue(undefined),
logEntry: vi.fn().mockResolvedValue(undefined),
} as unknown as TaskStore;
}
@@ -106,3 +107,107 @@ describe("GitHubTrackingReconciler.reconcileSourceIssues", () => {
expect(mockSetIssueState).not.toHaveBeenCalled();
});
});
describe("GitHubTrackingReconciler.backfillSourceIssueClosedAt", () => {
beforeEach(() => {
vi.clearAllMocks();
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
mockGetIssue.mockResolvedValue({ state: "open" });
});
it("persists a real GitHub closed_at for done GitHub source issues missing closedAt", async () => {
const closedAt = "2026-06-18T12:34:56.000Z";
mockGetIssue.mockResolvedValueOnce({ state: "closed", closedAt });
const sourceIssue = { provider: "github", repository: "owner/repo", issueNumber: 4, url: "https://github.com/owner/repo/issues/4" };
const store = createStore([{ id: "FN-1", column: "done", sourceIssue }]);
const result = await new GitHubTrackingReconciler().backfillSourceIssueClosedAt(store);
expect(result).toEqual({ scanned: 1, filled: 1, skipped: 0, errors: 0, hasMore: false });
expect(mockGetIssue).toHaveBeenCalledWith("owner", "repo", 4);
expect((store.updateTask as any)).toHaveBeenCalledWith("FN-1", { sourceIssue: { ...sourceIssue, closedAt } });
});
it("skips open issues without writing", async () => {
mockGetIssue.mockResolvedValueOnce({ state: "open" });
const store = createStore([{ id: "FN-2", column: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 2 } }]);
const result = await new GitHubTrackingReconciler().backfillSourceIssueClosedAt(store);
expect(result).toEqual({ scanned: 1, filled: 0, skipped: 1, errors: 0, hasMore: false });
expect((store.updateTask as any)).not.toHaveBeenCalled();
});
it("skips closed issues with no usable closedAt without fabricating a timestamp", async () => {
mockGetIssue.mockResolvedValueOnce({ state: "closed" });
const store = createStore([{ id: "FN-3", column: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 3 } }]);
const result = await new GitHubTrackingReconciler().backfillSourceIssueClosedAt(store);
expect(result.skipped).toBe(1);
expect(result.filled).toBe(0);
expect((store.updateTask as any)).not.toHaveBeenCalled();
});
it("excludes tasks that already have sourceIssue.closedAt from the scan", async () => {
const store = createStore([{ id: "FN-4", column: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 4, closedAt: "2026-06-01T00:00:00.000Z" } }]);
const result = await new GitHubTrackingReconciler().backfillSourceIssueClosedAt(store);
expect(result).toEqual({ scanned: 0, filled: 0, skipped: 0, errors: 0, hasMore: false });
expect(mockGetIssue).not.toHaveBeenCalled();
expect((store.updateTask as any)).not.toHaveBeenCalled();
});
it("ignores non-github, non-done, and missing sourceIssue tasks", async () => {
const store = createStore([
{ id: "FN-5", column: "todo", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 5 } },
{ id: "FN-6", column: "done", sourceIssue: { provider: "jira", repository: "owner/repo", issueNumber: 6 } },
{ id: "FN-7", column: "done" },
]);
const result = await new GitHubTrackingReconciler().backfillSourceIssueClosedAt(store);
expect(result).toEqual({ scanned: 0, filled: 0, skipped: 0, errors: 0, hasMore: false });
expect(mockGetIssue).not.toHaveBeenCalled();
});
it("logs getIssue errors per task without throwing", async () => {
mockGetIssue.mockRejectedValueOnce(new Error("boom"));
const store = createStore([{ id: "FN-8", column: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 8 } }]);
const result = await new GitHubTrackingReconciler().backfillSourceIssueClosedAt(store);
expect(result).toEqual({ scanned: 1, filled: 0, skipped: 0, errors: 1, hasMore: false });
expect((store.logEntry as any)).toHaveBeenCalledWith("FN-8", "Failed to backfill GitHub source issue closed-at", "boom");
expect((store.updateTask as any)).not.toHaveBeenCalled();
});
it("returns all-skipped and logs when auth resolution fails", async () => {
mockResolveGithubTrackingAuth.mockReturnValueOnce({ ok: false, message: "no auth" });
const store = createStore([{ id: "FN-9", column: "archived", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 9 } }]);
const result = await new GitHubTrackingReconciler().backfillSourceIssueClosedAt(store);
expect(result).toEqual({ scanned: 1, filled: 0, skipped: 1, errors: 0, hasMore: false });
expect((store.logEntry as any)).toHaveBeenCalledWith("FN-9", "Skipped GitHub source issue closed-at backfill", "no auth");
expect(mockGetIssue).not.toHaveBeenCalled();
});
it("applies offset and limit pagination with hasMore", async () => {
const closedAt = "2026-06-18T13:00:00.000Z";
mockGetIssue.mockResolvedValueOnce({ state: "closed", closedAt });
const store = createStore([
{ id: "FN-10", column: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 10 } },
{ id: "FN-11", column: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 11 } },
{ id: "FN-12", column: "archived", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 12 } },
]);
const result = await new GitHubTrackingReconciler().backfillSourceIssueClosedAt(store, { offset: 1, limit: 1 });
expect(result).toEqual({ scanned: 1, filled: 1, skipped: 0, errors: 0, hasMore: true });
expect(mockGetIssue).toHaveBeenCalledTimes(1);
expect(mockGetIssue).toHaveBeenCalledWith("owner", "repo", 11);
expect((store.updateTask as any)).toHaveBeenCalledWith("FN-11", { sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 11, closedAt } });
});
});

View File

@@ -0,0 +1,110 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TaskStore } from "@fusion/core";
import { createServer } from "../server.js";
import { request as performRequest } from "../test-request.js";
import { GitHubTrackingReconciler, RECONCILE_SCAN_LIMIT } from "../github-tracking-reconciler.js";
import * as projectStoreResolver from "../project-store-resolver.js";
function createStore(name: string): TaskStore {
return {
getRootDir: vi.fn().mockReturnValue(`/tmp/${name}`),
getFusionDir: vi.fn().mockReturnValue(`/tmp/${name}/.fusion`),
listTasks: vi.fn().mockResolvedValue([]),
listTasksForGithubTrackingReconcile: vi.fn().mockResolvedValue({ tasks: [], hasMore: false }),
getSettings: vi.fn().mockResolvedValue({}),
getGlobalSettingsStore: vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) })),
logEntry: vi.fn().mockResolvedValue(undefined),
updateTask: vi.fn().mockResolvedValue(undefined),
getDatabase: vi.fn().mockReturnValue({
exec: vi.fn(),
prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), get: vi.fn(), all: vi.fn().mockReturnValue([]) }),
}),
getMissionStore: vi.fn().mockReturnValue({ listMissions: vi.fn().mockReturnValue([]) }),
} as unknown as TaskStore;
}
describe("POST /api/git/github/backfill-source-issue-closed-at", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("returns the reconciler backfill result", async () => {
const store = createStore("default");
const result = { scanned: 2, filled: 1, skipped: 1, errors: 0, hasMore: false };
const backfill = vi.spyOn(GitHubTrackingReconciler.prototype, "backfillSourceIssueClosedAt").mockResolvedValue(result);
const app = createServer(store);
const response = await performRequest(app, "POST", "/api/git/github/backfill-source-issue-closed-at", "{}", { "content-type": "application/json" });
expect(response.status).toBe(200);
expect(response.body).toEqual(result);
expect(backfill).toHaveBeenCalledWith(store, { offset: 0, limit: RECONCILE_SCAN_LIMIT });
});
it("uses the scoped project store from projectId", async () => {
const defaultStore = createStore("default");
const storeA = createStore("proj-a");
const storeB = createStore("proj-b");
vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockImplementation(async (projectId: string) => {
if (projectId === "proj-a") return storeA;
if (projectId === "proj-b") return storeB;
return defaultStore;
});
const backfill = vi.spyOn(GitHubTrackingReconciler.prototype, "backfillSourceIssueClosedAt")
.mockResolvedValue({ scanned: 0, filled: 0, skipped: 0, errors: 0, hasMore: false });
const app = createServer(defaultStore);
const response = await performRequest(
app,
"POST",
"/api/git/github/backfill-source-issue-closed-at",
JSON.stringify({ projectId: "proj-a" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith("proj-a");
expect(backfill).toHaveBeenCalledWith(storeA, { offset: 0, limit: RECONCILE_SCAN_LIMIT });
expect(backfill).not.toHaveBeenCalledWith(storeB, expect.anything());
});
it("validates offset and clamps limit to the reconcile scan limit", async () => {
const store = createStore("default");
const backfill = vi.spyOn(GitHubTrackingReconciler.prototype, "backfillSourceIssueClosedAt")
.mockResolvedValue({ scanned: 0, filled: 0, skipped: 0, errors: 0, hasMore: false });
const app = createServer(store);
const clamped = await performRequest(
app,
"POST",
"/api/git/github/backfill-source-issue-closed-at",
JSON.stringify({ offset: 5, limit: RECONCILE_SCAN_LIMIT + 99 }),
{ "content-type": "application/json" },
);
const invalid = await performRequest(
app,
"POST",
"/api/git/github/backfill-source-issue-closed-at",
JSON.stringify({ offset: -1 }),
{ "content-type": "application/json" },
);
expect(clamped.status).toBe(200);
expect(backfill).toHaveBeenCalledWith(store, { offset: 5, limit: RECONCILE_SCAN_LIMIT });
expect(invalid.status).toBe(400);
expect(invalid.body.error).toContain("offset must be a non-negative integer");
});
it("surfaces reconciler failures as the standard API error shape", async () => {
const store = createStore("default");
vi.spyOn(GitHubTrackingReconciler.prototype, "backfillSourceIssueClosedAt").mockRejectedValue(new Error("boom"));
const app = createServer(store);
const response = await performRequest(app, "POST", "/api/git/github/backfill-source-issue-closed-at", "{}", { "content-type": "application/json" });
expect(response.status).toBe(500);
expect(response.body.error).toBe("boom");
});
});

View File

@@ -132,6 +132,77 @@ export class GitHubTrackingReconciler {
return { scanned: tasks.length, closed, skipped, errors };
}
/**
* FNXC:GithubSourceIssueBackfill 2026-06-18-18:53:
* Historical GitHub-imported tasks need an optional one-time sweep that fills missing `sourceIssueClosedAt` from real GitHub `closed_at` values only. Keep this path decoupled from analytics so Command Center aggregation never performs network calls, and keep it idempotent by excluding already-filled tasks and never fabricating timestamps.
*/
async backfillSourceIssueClosedAt(
store: TaskStore,
options?: { offset?: number; limit?: number },
): Promise<{ scanned: number; filled: number; skipped: number; errors: number; hasMore: boolean }> {
const listedTasks = await store.listTasks({ slim: false, includeArchived: true });
const offset = Number.isInteger(options?.offset) && (options?.offset ?? 0) > 0 ? options?.offset ?? 0 : 0;
const limit = Number.isInteger(options?.limit) && (options?.limit ?? RECONCILE_SCAN_LIMIT) >= 0
? Math.min(options?.limit ?? RECONCILE_SCAN_LIMIT, RECONCILE_SCAN_LIMIT)
: RECONCILE_SCAN_LIMIT;
const matchingTasks = (Array.isArray(listedTasks) ? listedTasks : [])
.filter((task) => (task.column === "done" || task.column === "archived")
&& task.sourceIssue?.provider === "github"
&& !task.sourceIssue?.closedAt);
const tasks = matchingTasks.slice(offset, offset + limit);
const hasMore = offset + limit < matchingTasks.length;
const projectSettings = ((await store.getSettings()) ?? {}) as Pick<ProjectSettings, "githubAuthMode" | "githubAuthToken">;
const globalSettings = (await store.getGlobalSettingsStore?.()?.getSettings?.() ?? {}) as Pick<GlobalSettings, never>;
const resolution = resolveGithubTrackingAuth({ projectSettings, globalSettings });
if (!resolution.ok) {
for (const task of tasks) {
await store.logEntry(task.id, "Skipped GitHub source issue closed-at backfill", resolution.message);
}
return { scanned: tasks.length, filled: 0, skipped: tasks.length, errors: 0, hasMore };
}
const client = resolution.auth.mode === "token"
? new GitHubClient({ token: resolution.auth.token, forceMode: "token" })
: new GitHubClient({ forceMode: "gh-cli" });
let filled = 0;
let skipped = 0;
let errors = 0;
await runWithConcurrencyLimit(tasks, RECONCILE_CONCURRENCY_LIMIT, async (task) => {
const sourceIssue = task.sourceIssue;
const repository = sourceIssue?.repository ?? "";
const [owner, repo] = repository.split("/");
const issueNumber = sourceIssue?.issueNumber;
if (!sourceIssue || !owner || !repo || !Number.isInteger(issueNumber)) {
skipped += 1;
return;
}
try {
const linkedIssue = await client.getIssue(owner, repo, issueNumber as number);
const closedAt = typeof linkedIssue?.closedAt === "string" ? linkedIssue.closedAt.trim() : "";
if (linkedIssue?.state !== "closed" || closedAt.length === 0) {
skipped += 1;
return;
}
await store.updateTask(task.id, { sourceIssue: { ...sourceIssue, closedAt } });
filled += 1;
} catch (error) {
errors += 1;
await store.logEntry(
task.id,
"Failed to backfill GitHub source issue closed-at",
error instanceof Error ? error.message : String(error),
);
}
});
return { scanned: tasks.length, filled, skipped, errors, hasMore };
}
async reconcileDeletedAndArchived(
store: TaskStore,
options?: { offset?: number; limit?: number },

View File

@@ -2610,6 +2610,37 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
});
}
/**
* POST /api/git/github/backfill-source-issue-closed-at
* FNXC:GithubSourceIssueBackfill 2026-06-18-18:53:
* Historical source-issue closed-at backfills are opt-in manual sweeps, not periodic reconciliation work. The route is project-scoped, accepts offset/limit pagination, clamps batches to RECONCILE_SCAN_LIMIT, and returns { scanned, filled, skipped, errors, hasMore } so callers can iterate until hasMore is false without analytics-time network calls.
*/
router.post("/git/github/backfill-source-issue-closed-at", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const body = (req.body ?? {}) as { offset?: unknown; limit?: unknown };
const offset = body.offset === undefined ? 0 : Number(body.offset);
const limit = body.limit === undefined ? RECONCILE_SCAN_LIMIT : Number(body.limit);
if (!Number.isInteger(offset) || offset < 0) {
throw badRequest("offset must be a non-negative integer");
}
if (!Number.isInteger(limit) || limit < 0) {
throw badRequest("limit must be a non-negative integer");
}
const result = await new GitHubTrackingReconciler().backfillSourceIssueClosedAt(scopedStore, {
offset,
limit: Math.min(limit, RECONCILE_SCAN_LIMIT),
});
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* GET /api/git/remotes
* Returns GitHub remotes from the current git repository.