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:
5
.changeset/fn-6674-source-issue-closed-at-backfill.md
Normal file
5
.changeset/fn-6674-source-issue-closed-at-backfill.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -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 } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user