diff --git a/.changeset/fn-6674-source-issue-closed-at-backfill.md b/.changeset/fn-6674-source-issue-closed-at-backfill.md new file mode 100644 index 0000000000..2e462831f0 --- /dev/null +++ b/.changeset/fn-6674-source-issue-closed-at-backfill.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index f6b7bae41c..23eccd2e88 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. diff --git a/docs/storage.md b/docs/storage.md index a042b320ec..58e25495b7 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -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). | diff --git a/packages/dashboard/src/__tests__/github-source-issue-reconciler.test.ts b/packages/dashboard/src/__tests__/github-source-issue-reconciler.test.ts index 7f9d6c7e8a..b3f0cdb815 100644 --- a/packages/dashboard/src/__tests__/github-source-issue-reconciler.test.ts +++ b/packages/dashboard/src/__tests__/github-source-issue-reconciler.test.ts @@ -28,6 +28,7 @@ function createStore(listTasks: Array>, 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 } }); + }); +}); diff --git a/packages/dashboard/src/__tests__/register-git-github.backfill.test.ts b/packages/dashboard/src/__tests__/register-git-github.backfill.test.ts new file mode 100644 index 0000000000..391fc5fbc3 --- /dev/null +++ b/packages/dashboard/src/__tests__/register-git-github.backfill.test.ts @@ -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"); + }); +}); diff --git a/packages/dashboard/src/github-tracking-reconciler.ts b/packages/dashboard/src/github-tracking-reconciler.ts index a5bce8577a..d365a4672f 100644 --- a/packages/dashboard/src/github-tracking-reconciler.ts +++ b/packages/dashboard/src/github-tracking-reconciler.ts @@ -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; + const globalSettings = (await store.getGlobalSettingsStore?.()?.getSettings?.() ?? {}) as Pick; + 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 }, diff --git a/packages/dashboard/src/routes/register-git-github.ts b/packages/dashboard/src/routes/register-git-github.ts index 98184e6158..abfa30ac39 100644 --- a/packages/dashboard/src/routes/register-git-github.ts +++ b/packages/dashboard/src/routes/register-git-github.ts @@ -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.