diff --git a/.changeset/fn-6666-source-issue-closed-at.md b/.changeset/fn-6666-source-issue-closed-at.md new file mode 100644 index 0000000000..b8a3a463cb --- /dev/null +++ b/.changeset/fn-6666-source-issue-closed-at.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Persist GitHub source issue closure timestamps and use them for exact Command Center "Fixed by Fusion" date bucketing, falling back to task `updatedAt` only when the real close time has not been observed. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index c0e78c80f5..59dc288228 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 task `updatedAt` as the documented completion-time approximation because Fusion does not persist a separate source-issue closed timestamp. The area shows filed/fixed/net stat cards, filed-vs-fixed daily sparklines, and a by-repository bar breakdown; it never calls GitHub, the `gh` CLI, or any external network source. +- **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. - **Signals** shows external signal totals, open/resolved counts, MTTR, and source/severity breakdowns when signal sources are connected. - **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. - CSV exports are available from the analytics endpoints with `?format=csv`. The Activity CSV includes daily `agentRuns` values plus summary rows for `(agentRuns.total)`, `(agentRuns.active)`, `(agentRuns.completed)`, and `(agentRuns.failed)`. diff --git a/docs/storage.md b/docs/storage.md index 6da4f12538..a042b320ec 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -388,6 +388,8 @@ 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.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). | | `workflow_steps` | Workflow step definitions (`prompt`/`script`) with phase, template metadata, and model overrides. | diff --git a/packages/core/src/__tests__/db-migrate.test.ts b/packages/core/src/__tests__/db-migrate.test.ts index d75d8e49d2..f48ad76ed5 100644 --- a/packages/core/src/__tests__/db-migrate.test.ts +++ b/packages/core/src/__tests__/db-migrate.test.ts @@ -596,6 +596,7 @@ describe("migrateFromLegacy", () => { externalIssueId: "I_kgDOExample", issueNumber: 10, url: "https://github.com/test/issues/1", + closedAt: "2026-06-18T12:00:00.000Z", }, breakIntoSubtasks: true, enabledWorkflowSteps: ["WS-001", "WS-002"], @@ -645,6 +646,7 @@ describe("migrateFromLegacy", () => { expect(row.sourceIssueExternalIssueId).toBe("I_kgDOExample"); expect(row.sourceIssueNumber).toBe(10); expect(row.sourceIssueUrl).toBe("https://github.com/test/issues/1"); + expect(row.sourceIssueClosedAt).toBe("2026-06-18T12:00:00.000Z"); expect(row.breakIntoSubtasks).toBe(1); expect(JSON.parse(row.enabledWorkflowSteps)).toEqual(["WS-001", "WS-002"]); }); @@ -719,7 +721,69 @@ describe("schema migration", () => { const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null }; expect(row.deletedAt).toBeNull(); - expect(db.getSchemaVersion()).toBe(120); + expect(db.getSchemaVersion()).toBe(122); + + db.close(); + }); + + it("adds sourceIssueClosedAt when migrating from schema version 121 without data loss", () => { + const db = new Database(fusionDir); + db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)"); + db.exec(` + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + description TEXT NOT NULL, + "column" TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL, + sourceIssueProvider TEXT, + sourceIssueRepository TEXT, + sourceIssueExternalIssueId TEXT, + sourceIssueNumber INTEGER, + sourceIssueUrl TEXT, + tokenUsageModelProvider TEXT, + tokenUsageModelId TEXT + ) + `); + db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '121')"); + db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')"); + db.exec(` + INSERT INTO tasks ( + id, description, "column", createdAt, updatedAt, + sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, + sourceIssueNumber, sourceIssueUrl + ) VALUES ( + 'FN-source', 'legacy source issue', 'done', '2025-01-01T00:00:00.000Z', '2025-01-02T00:00:00.000Z', + 'github', 'runfusion/fusion', 'I_kgDOExample', 10, 'https://github.com/runfusion/fusion/issues/10' + ) + `); + + db.init(); + + const columns = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; + expect(columns.map((column) => column.name)).toContain("sourceIssueClosedAt"); + + const row = db.prepare(` + SELECT sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, + sourceIssueNumber, sourceIssueUrl, sourceIssueClosedAt + FROM tasks WHERE id = 'FN-source' + `).get() as { + sourceIssueProvider: string; + sourceIssueRepository: string; + sourceIssueExternalIssueId: string; + sourceIssueNumber: number; + sourceIssueUrl: string; + sourceIssueClosedAt: string | null; + }; + expect(row).toEqual({ + sourceIssueProvider: "github", + sourceIssueRepository: "runfusion/fusion", + sourceIssueExternalIssueId: "I_kgDOExample", + sourceIssueNumber: 10, + sourceIssueUrl: "https://github.com/runfusion/fusion/issues/10", + sourceIssueClosedAt: null, + }); + expect(db.getSchemaVersion()).toBe(122); db.close(); }); @@ -752,7 +816,7 @@ describe("schema migration", () => { { id: "WS-001", mode: "prompt", gateMode: "advisory" }, { id: "WS-002", mode: "script", gateMode: "advisory" }, ]); - expect(db.getSchemaVersion()).toBe(120); + expect(db.getSchemaVersion()).toBe(122); db.close(); }); @@ -802,7 +866,7 @@ describe("schema migration", () => { reviewerContextRetryCount: 0, reviewerFallbackRetryCount: 0, }); - expect(db.getSchemaVersion()).toBe(120); + expect(db.getSchemaVersion()).toBe(122); db.close(); }); @@ -831,7 +895,7 @@ describe("schema migration", () => { const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("acceptanceCriteria"); - expect(db.getSchemaVersion()).toBe(120); + expect(db.getSchemaVersion()).toBe(122); db.close(); }); @@ -872,7 +936,7 @@ describe("schema migration", () => { const missionColumns = db.prepare("PRAGMA table_info(missions)").all() as Array<{ name: string }>; expect(missionColumns.map((column) => column.name)).toContain("autoMerge"); - expect(db.getSchemaVersion()).toBe(120); + expect(db.getSchemaVersion()).toBe(122); db.close(); }); @@ -906,7 +970,7 @@ describe("schema migration", () => { { id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" }, { id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" }, ]); - expect(db.getSchemaVersion()).toBe(120); + expect(db.getSchemaVersion()).toBe(122); db.close(); }); @@ -943,7 +1007,7 @@ describe("schema migration", () => { const indexes = db.prepare("PRAGMA index_list(mission_goals)").all() as Array<{ name: string }>; expect(indexes.some((index) => index.name === "idxMissionGoalsGoalId")).toBe(true); - expect(db.getSchemaVersion()).toBe(120); + expect(db.getSchemaVersion()).toBe(122); db.close(); }); @@ -1004,7 +1068,7 @@ describe("schema migration", () => { expect(customFieldsColumn).toBeDefined(); expect(customFieldsColumn?.dflt_value).toBe("'{}'"); - expect(db.getSchemaVersion()).toBe(120); + expect(db.getSchemaVersion()).toBe(122); db.close(); }); @@ -1042,7 +1106,7 @@ describe("schema migration", () => { const indexes = db.prepare("PRAGMA index_list(workflow_settings)").all() as Array<{ name: string }>; expect(indexes.some((index) => index.name === "idx_workflow_settings_project")).toBe(true); - expect(db.getSchemaVersion()).toBe(120); + expect(db.getSchemaVersion()).toBe(122); db.close(); }); @@ -1124,7 +1188,7 @@ describe("schema migration", () => { expect(indexNames).toContain("idx_cli_sessions_chatSessionId"); expect(indexNames).toContain("idx_cli_sessions_project_state"); - expect(db.getSchemaVersion()).toBe(120); + expect(db.getSchemaVersion()).toBe(122); db.close(); }); @@ -1156,7 +1220,7 @@ describe("schema migration", () => { .all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("cliExecutorAdapterId"); - expect(db.getSchemaVersion()).toBe(120); + expect(db.getSchemaVersion()).toBe(122); db.close(); }); @@ -1166,7 +1230,7 @@ describe("schema migration", () => { const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>; expect(tables.map((row) => row.name)).toContain("cli_sessions"); - expect(db.getSchemaVersion()).toBe(120); + expect(db.getSchemaVersion()).toBe(122); db.close(); }); @@ -1223,20 +1287,20 @@ describe("schema migration", () => { .get() as { migrated_fragment_id: string | null }; expect(stepRow.migrated_fragment_id).toBeNull(); - expect(db.getSchemaVersion()).toBe(120); + expect(db.getSchemaVersion()).toBe(122); db.close(); }); it("migration 109 is idempotent on re-init", () => { const db = new Database(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(120); + expect(db.getSchemaVersion()).toBe(122); db.close(); // Re-open the same on-disk DB: already at 109, the 109 block must be a no-op. const reopened = new Database(fusionDir); reopened.init(); - expect(reopened.getSchemaVersion()).toBe(120); + expect(reopened.getSchemaVersion()).toBe(122); const workflowColumns = reopened.prepare("PRAGMA table_info(workflows)").all() as Array<{ name: string }>; expect(workflowColumns.filter((c) => c.name === "kind")).toHaveLength(1); const stepColumns = reopened.prepare("PRAGMA table_info(workflow_steps)").all() as Array<{ name: string }>; diff --git a/packages/core/src/__tests__/db.test.ts b/packages/core/src/__tests__/db.test.ts index 70895c5da3..e92550b846 100644 --- a/packages/core/src/__tests__/db.test.ts +++ b/packages/core/src/__tests__/db.test.ts @@ -334,7 +334,7 @@ describe("Database", () => { }); it("seeds schema version", () => { - expect(db.getSchemaVersion()).toBe(120); + expect(db.getSchemaVersion()).toBe(122); }); it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => { @@ -393,7 +393,7 @@ describe("Database", () => { it("is idempotent - calling init() twice does not fail", () => { expect(() => db.init()).not.toThrow(); - expect(db.getSchemaVersion()).toBe(120); + expect(db.getSchemaVersion()).toBe(122); }); it("does not overwrite existing config on re-init", () => { // Update the config @@ -1463,7 +1463,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 (includes v1→v2 through v26→v29) - expect(db.getSchemaVersion()).toBe(120); + expect(db.getSchemaVersion()).toBe(122); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -1488,15 +1488,15 @@ describe("schema migrations", () => { const db = new Database(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(120); + expect(db.getSchemaVersion()).toBe(122); // Re-init should not fail db.init(); - expect(db.getSchemaVersion()).toBe(120); + expect(db.getSchemaVersion()).toBe(122); // Re-init should not fail db.init(); - expect(db.getSchemaVersion()).toBe(120); + expect(db.getSchemaVersion()).toBe(122); db.close(); }); @@ -1531,7 +1531,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(120); + expect(db.getSchemaVersion()).toBe(122); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("priority"); @@ -1572,7 +1572,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(120); + expect(db.getSchemaVersion()).toBe(122); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1644,7 +1644,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(120); + expect(db.getSchemaVersion()).toBe(122); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1653,6 +1653,7 @@ describe("schema migrations", () => { expect(colNames).toContain("sourceIssueExternalIssueId"); expect(colNames).toContain("sourceIssueNumber"); expect(colNames).toContain("sourceIssueUrl"); + expect(colNames).toContain("sourceIssueClosedAt"); const task = db.prepare(` SELECT @@ -1660,7 +1661,8 @@ describe("schema migrations", () => { sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, - sourceIssueUrl + sourceIssueUrl, + sourceIssueClosedAt FROM tasks WHERE id = 'FN-3' `).get() as Record; @@ -1670,10 +1672,47 @@ describe("schema migrations", () => { expect(task.sourceIssueExternalIssueId).toBeNull(); expect(task.sourceIssueNumber).toBeNull(); expect(task.sourceIssueUrl).toBeNull(); + expect(task.sourceIssueClosedAt).toBeNull(); db.close(); }); + it("round-trips source issue closedAt through TaskStore serialization", async () => { + const rootDir = makeTmpDir(); + const globalDir = join(rootDir, ".fusion-global"); + const store = new TaskStore(rootDir, globalDir); + await store.init(); + try { + const closedAt = "2026-06-18T15:30:00.000Z"; + const created = await store.createTask({ + description: "source issue closedAt round trip", + sourceIssue: { + provider: "github", + repository: "runfusion/fusion", + externalIssueId: "I_kwDOBogus", + issueNumber: 42, + url: "https://github.com/runfusion/fusion/issues/42", + closedAt, + }, + }); + + const row = store.getDatabase().prepare("SELECT sourceIssueClosedAt FROM tasks WHERE id = ?").get(created.id) as { sourceIssueClosedAt: string | null }; + expect(row.sourceIssueClosedAt).toBe(closedAt); + + const reloaded = await store.getTask(created.id); + expect(reloaded.sourceIssue).toEqual({ + provider: "github", + repository: "runfusion/fusion", + externalIssueId: "I_kwDOBogus", + issueNumber: 42, + url: "https://github.com/runfusion/fusion/issues/42", + closedAt, + }); + } finally { + store.close(); + } + }); + it("reconciles missing columns across all SCHEMA_SQL tables even when schemaVersion is current", () => { tmpDir = makeTmpDir(); const fusionDir = join(tmpDir, ".fusion"); @@ -1884,7 +1923,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(120); + expect(db.getSchemaVersion()).toBe(122); const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("attachments"); @@ -1958,7 +1997,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(120); + expect(db.getSchemaVersion()).toBe(122); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>; expect(tables).toEqual([{ name: "agentRatings" }]); @@ -1982,7 +2021,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(120); + expect(db.getSchemaVersion()).toBe(122); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>; expect(tables).toEqual([{ name: "mission_events" }]); @@ -2086,7 +2125,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 - expect(db.getSchemaVersion()).toBe(120); + expect(db.getSchemaVersion()).toBe(122); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -2305,7 +2344,7 @@ describe("schema migrations", () => { localDb.init(); - expect(localDb.getSchemaVersion()).toBe(120); + expect(localDb.getSchemaVersion()).toBe(122); const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens"); @@ -2616,7 +2655,7 @@ describe("createDatabase factory", () => { const db = createDatabase(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(120); + expect(db.getSchemaVersion()).toBe(122); expect(db.getLastModified()).toBeGreaterThan(0); db.close(); @@ -2770,7 +2809,7 @@ describe("migration v77 task token budget columns", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(120); + expect(migrated.getSchemaVersion()).toBe(122); const rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const names = new Set(rows.map((row) => row.name)); expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true); @@ -2801,7 +2840,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => { const fresh = new Database(fusion); try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(120); + expect(fresh.getSchemaVersion()).toBe(122); const names = new Set( (fresh.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), ); @@ -2829,7 +2868,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(120); + expect(migrated.getSchemaVersion()).toBe(122); const names = new Set( (migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), ); @@ -2855,7 +2894,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => { const fresh = new Database(fusion); try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(120); + expect(fresh.getSchemaVersion()).toBe(122); const table = fresh .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") .get() as { name: string } | undefined; @@ -2889,7 +2928,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(120); + expect(migrated.getSchemaVersion()).toBe(122); const table = migrated .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") .get() as { name: string } | undefined; @@ -2915,7 +2954,7 @@ describe("migration v120 adds deployments + incidents tables (U13)", () => { const fresh = new Database(fusion); try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(120); + expect(fresh.getSchemaVersion()).toBe(122); const tables = new Set( ( fresh @@ -2968,7 +3007,7 @@ describe("migration v120 adds deployments + incidents tables (U13)", () => { // creation while table + row assertions still pass. Assert the real index // names the v120 migration creates (idxDeployments*, idxIncidents*) so that // regression is caught. - expect(migrated.getSchemaVersion()).toBe(120); + expect(migrated.getSchemaVersion()).toBe(122); const tables = new Set( ( migrated @@ -3027,7 +3066,7 @@ describe("migration v67 drops orphan project auth tables", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(120); + expect(migrated.getSchemaVersion()).toBe(122); const tables = migrated .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; @@ -3054,7 +3093,7 @@ describe("migration v67 drops orphan project auth tables", () => { try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(120); + expect(fresh.getSchemaVersion()).toBe(122); const tables = fresh .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; diff --git a/packages/core/src/__tests__/github-issue-analytics.test.ts b/packages/core/src/__tests__/github-issue-analytics.test.ts index dbbdca5e45..c141cf8456 100644 --- a/packages/core/src/__tests__/github-issue-analytics.test.ts +++ b/packages/core/src/__tests__/github-issue-analytics.test.ts @@ -34,6 +34,7 @@ function insertSourceIssueTask( repository: string; column: string; updatedAt: string; + closedAt?: string | null; issueNumber?: number; }, ): void { @@ -41,8 +42,8 @@ function insertSourceIssueTask( `INSERT INTO tasks ( id, description, "column", createdAt, updatedAt, sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, - sourceIssueNumber, sourceIssueUrl - ) VALUES (?, 'desc', ?, ?, ?, ?, ?, ?, ?, ?)`, + sourceIssueNumber, sourceIssueUrl, sourceIssueClosedAt + ) VALUES (?, 'desc', ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( id, opts.column, @@ -53,6 +54,7 @@ function insertSourceIssueTask( String(opts.issueNumber ?? 1), opts.issueNumber ?? 1, `https://example.test/${id}`, + opts.closedAt ?? null, ); } @@ -177,6 +179,47 @@ describe("github-issue-analytics", () => { ]); }); + it("prefers source issue closedAt over updatedAt for fixed range and daily buckets", () => { + insertSourceIssueTask(db, "closed-in-range-updated-outside", { + provider: "github", + repository: "acme/alpha", + column: "done", + updatedAt: "2026-03-01T00:00:00.000Z", + closedAt: "2026-04-02T10:00:00.000Z", + issueNumber: 31, + }); + insertSourceIssueTask(db, "closed-outside-updated-in-range", { + provider: "github", + repository: "acme/alpha", + column: "done", + updatedAt: "2026-04-03T10:00:00.000Z", + closedAt: "2026-03-31T23:59:59.999Z", + issueNumber: 32, + }); + insertSourceIssueTask(db, "no-closedAt-falls-back", { + provider: "github", + repository: "acme/beta", + column: "done", + updatedAt: "2026-04-03T10:00:00.000Z", + issueNumber: 33, + }); + + const result = aggregateGithubIssueAnalytics(db, { + from: "2026-04-01T00:00:00.000Z", + to: "2026-04-03T23:59:59.999Z", + }); + + expect(result.fixed).toBe(2); + expect(result.daily).toEqual([ + { date: "2026-04-02", filed: 0, fixed: 1 }, + { date: "2026-04-03", filed: 0, fixed: 1 }, + ]); + expect(result.byRepo).toEqual([ + { repo: "acme/alpha", filed: 0, fixed: 1 }, + { repo: "acme/beta", filed: 0, fixed: 1 }, + ]); + }); + it("returns zeroed structures for an empty range", () => { insertTrackedIssue(db, "filed", { owner: "acme", diff --git a/packages/core/src/db-migrate.ts b/packages/core/src/db-migrate.ts index 21ab682d3a..30d10cab8e 100644 --- a/packages/core/src/db-migrate.ts +++ b/packages/core/src/db-migrate.ts @@ -225,11 +225,11 @@ async function migrateTasks(fusionDir: string, db: Database): Promise { error, summary, thinkingLevel, createdAt, updatedAt, columnMovedAt, dependencies, steps, log, attachments, steeringComments, comments, workflowStepResults, prInfo, issueInfo, - sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl, + sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl, sourceIssueClosedAt, mergeDetails, breakIntoSubtasks, noCommitsExpected, enabledWorkflowSteps, modifiedFiles, sliceId ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) `); @@ -293,6 +293,7 @@ async function migrateTasks(fusionDir: string, db: Database): Promise { task.sourceIssue?.externalIssueId ?? null, task.sourceIssue?.issueNumber ?? null, task.sourceIssue?.url ?? null, + task.sourceIssue?.closedAt ?? null, toJsonNullable(task.mergeDetails), task.breakIntoSubtasks ? 1 : 0, task.noCommitsExpected ? 1 : 0, diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index e214f6fc70..4b45956af5 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -162,7 +162,7 @@ export function isFts5CorruptionError(error: unknown): boolean { // ── Schema Definition ──────────────────────────────────────────────── -const SCHEMA_VERSION = 121; +const SCHEMA_VERSION = 122; const TASKS_FTS_AUTOMERGE = 8; const TASKS_FTS_CRISISMERGE = 16; @@ -316,6 +316,7 @@ CREATE TABLE IF NOT EXISTS tasks ( sourceIssueExternalIssueId TEXT, sourceIssueNumber INTEGER, sourceIssueUrl TEXT, + sourceIssueClosedAt TEXT, mergeDetails TEXT, breakIntoSubtasks INTEGER DEFAULT 0, noCommitsExpected INTEGER DEFAULT 0, @@ -4955,6 +4956,15 @@ export class Database { }); } + // Migration 122: source-issue closure timestamp for exact Fixed by Fusion analytics. + // Additive and nullable with no historical backfill; legacy rows deserialize with + // TaskSourceIssue.closedAt undefined until the GitHub reconciler observes a real close time. + if (version < 122) { + this.applyMigration(122, () => { + this.addColumnIfMissing("tasks", "sourceIssueClosedAt", "TEXT"); + }); + } + } /** diff --git a/packages/core/src/github-issue-analytics.ts b/packages/core/src/github-issue-analytics.ts index bdc5b19307..f5bc55796f 100644 --- a/packages/core/src/github-issue-analytics.ts +++ b/packages/core/src/github-issue-analytics.ts @@ -2,7 +2,7 @@ import type { Database } from "./db.js"; /** * FNXC:CommandCenterGithub 2026-06-18-00:00: - * Command Center GitHub issue analytics must derive filed/fixed counts only from the project-scoped local task store. "Filed" means a task has `githubTracking.issue`; "fixed" means an imported GitHub source issue task is currently in the `done` column. Fusion does not persist a source issue closed timestamp, so fixed trends use `updatedAt` as the documented completion approximation and never fabricate a close date. + * Command Center GitHub issue analytics must derive filed/fixed counts only from the project-scoped local task store. "Filed" means a task has `githubTracking.issue`; "fixed" means an imported GitHub source issue task is currently in the `done` column. Fixed trends use the exact persisted `sourceIssueClosedAt` when available, fall back to the `updatedAt` completion approximation only when it is absent, and never fabricate a close date. */ export interface GithubIssueAnalyticsQuery { @@ -33,7 +33,7 @@ export interface GithubIssueAnalytics { to: string | null; /** Fusion-created GitHub issues in range. Undated tracked issues are included because no date can be honestly inferred. */ filed: number; - /** Imported GitHub issue tasks currently in `done`, filtered by `updatedAt` as the completion approximation. */ + /** Imported GitHub issue tasks currently in `done`, filtered by exact `sourceIssueClosedAt` when present with `updatedAt` fallback. */ fixed: number; /** Filed minus fixed. */ net: number; @@ -49,6 +49,7 @@ interface GithubTrackingRow { interface FixedIssueRow { sourceIssueRepository: string | null; + sourceIssueClosedAt: string | null; updatedAt: string | null; } @@ -150,31 +151,22 @@ export function aggregateGithubIssueAnalytics( } } - const fixedClauses = ["sourceIssueProvider = 'github'", "\"column\" = 'done'"]; - const fixedParams: string[] = []; - if (query.from !== undefined) { - fixedClauses.push("updatedAt >= ?"); - fixedParams.push(query.from); - } - if (query.to !== undefined) { - fixedClauses.push("updatedAt <= ?"); - fixedParams.push(query.to); - } const fixedRows = db .prepare( - `SELECT sourceIssueRepository, updatedAt FROM tasks WHERE ${fixedClauses.join(" AND ")}`, + `SELECT sourceIssueRepository, sourceIssueClosedAt, updatedAt FROM tasks WHERE sourceIssueProvider = 'github' AND "column" = 'done'`, ) - .all(...fixedParams) as FixedIssueRow[]; + .all() as FixedIssueRow[]; let fixed = 0; for (const row of fixedRows) { + const fixedDate = row.sourceIssueClosedAt ?? row.updatedAt; + if (fixedDate === null || !isInRange(fixedDate, query)) continue; + fixed += 1; const repo = row.sourceIssueRepository?.trim() || "(unknown)"; addRepo(byRepo, repo, "fixed"); - if (row.updatedAt) { - const day = dayKey(row.updatedAt); - if (day !== null) addDaily(daily, day, "fixed"); - } + const day = dayKey(fixedDate); + if (day !== null) addDaily(daily, day, "fixed"); } return { diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 7586887bf9..d3400dec7f 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -264,6 +264,7 @@ interface TaskRow { sourceIssueExternalIssueId: string | null; sourceIssueNumber: number | null; sourceIssueUrl: string | null; + sourceIssueClosedAt: string | null; mergeDetails: string | null; breakIntoSubtasks: number | null; noCommitsExpected: number | null; @@ -414,6 +415,7 @@ const TASK_COLUMN_DESCRIPTORS: TaskColumnDescriptor[] = [ defineTaskColumn("sourceIssueExternalIssueId", (task) => task.sourceIssue?.externalIssueId ?? null), defineTaskColumn("sourceIssueNumber", (task) => task.sourceIssue?.issueNumber ?? null), defineTaskColumn("sourceIssueUrl", (task) => task.sourceIssue?.url ?? null), + defineTaskColumn("sourceIssueClosedAt", (task) => task.sourceIssue?.closedAt ?? null), defineTaskColumn("mergeDetails", (task) => toJsonNullable(task.mergeDetails)), defineTaskColumn("breakIntoSubtasks", (task) => task.breakIntoSubtasks ? 1 : 0), defineTaskColumn("noCommitsExpected", (task) => task.noCommitsExpected ? 1 : 0), @@ -2068,6 +2070,7 @@ export class TaskStore extends EventEmitter { externalIssueId: row.sourceIssueExternalIssueId, issueNumber: row.sourceIssueNumber, url: row.sourceIssueUrl ?? undefined, + closedAt: row.sourceIssueClosedAt ?? undefined, }; })(), mergeDetails: fromJson(row.mergeDetails), @@ -2488,7 +2491,7 @@ export class TaskStore extends EventEmitter { "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt", "dependencies", "steps", "customFields", "comments", "review", "reviewState", "workflowStepResults", "steeringComments", - "attachments", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails", + "attachments", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "sourceIssueClosedAt", "mergeDetails", "breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles", "missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource", "sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata", @@ -2537,7 +2540,7 @@ export class TaskStore extends EventEmitter { "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt", "dependencies", "steps", "customFields", "attachments", "steeringComments", - "comments", "review", "reviewState", "workflowStepResults", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails", + "comments", "review", "reviewState", "workflowStepResults", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "sourceIssueClosedAt", "mergeDetails", "breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles", "missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource", "sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata", diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d9c969dd22..e4959547dd 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1179,6 +1179,12 @@ export interface TaskSourceIssue { issueNumber: number; /** Optional canonical URL to the source issue. */ url?: string; + /** + * FNXC:GithubSourceIssueAnalytics 2026-06-18-17:56: + * Command Center "Fixed by Fusion" analytics need the real source-issue closure time when Fusion closed or observed the issue, replacing the prior `updatedAt` completion approximation when exact data is available. + * ISO-8601 timestamp for when the source issue was closed; absent when the issue has never been observed closed. + */ + closedAt?: string; } export interface BatchStatusRequest { diff --git a/packages/dashboard/src/__tests__/github-tracking-reconciler.test.ts b/packages/dashboard/src/__tests__/github-tracking-reconciler.test.ts index bca46d01e1..14bcd80da2 100644 --- a/packages/dashboard/src/__tests__/github-tracking-reconciler.test.ts +++ b/packages/dashboard/src/__tests__/github-tracking-reconciler.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { TaskStore } from "@fusion/core"; import { GitHubTrackingReconciler, RECONCILE_CONCURRENCY_LIMIT } from "../github-tracking-reconciler.js"; @@ -34,6 +34,7 @@ function createStore(options: { .fn() .mockResolvedValue({ tasks: options.reconcileCandidates ?? [], hasMore: options.reconcileHasMore ?? false }), logEntry: vi.fn().mockResolvedValue(undefined), + updateTask: vi.fn().mockResolvedValue(undefined), getSettings: vi.fn().mockResolvedValue(options.settings ?? { githubAuthMode: "token", githubAuthToken: "ghp_test" }), getGlobalSettingsStore: vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) })), } as unknown as TaskStore; @@ -44,6 +45,10 @@ describe("GitHubTrackingReconciler", () => { vi.clearAllMocks(); }); + afterEach(() => { + vi.useRealTimers(); + }); + it("closes open issues for done-column tracked tasks", async () => { mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } }); mockGetIssue.mockResolvedValue({ state: "open" }); @@ -164,6 +169,78 @@ describe("GitHubTrackingReconciler", () => { expect(result).toMatchObject({ scanned: 3, closed: 3, skipped: 0, errors: 0 }); }); + it("persists the current close time after closing an open source issue", async () => { + vi.useFakeTimers({ now: new Date("2026-06-18T10:00:00.000Z") }); + mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } }); + mockGetIssue.mockResolvedValue({ state: "open" }); + const sourceIssue = { provider: "github", repository: "o/r", issueNumber: 1 }; + const store = createStore({ + settings: sourceSettings, + listTasks: [{ id: "FN-1", column: "done", sourceIssue }], + }); + + const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store); + + expect(mockSetIssueState).toHaveBeenCalledWith("o", "r", 1, "closed", "completed"); + expect((store.updateTask as any)).toHaveBeenCalledWith("FN-1", { + sourceIssue: { ...sourceIssue, closedAt: "2026-06-18T10:00:00.000Z" }, + }); + expect(result).toMatchObject({ closed: 1, skipped: 0, errors: 0 }); + }); + + it("backfills already-closed source issues with the GitHub closedAt without reclosing", async () => { + mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } }); + mockGetIssue.mockResolvedValue({ state: "closed", closedAt: "2026-06-01T12:00:00Z" }); + const sourceIssue = { provider: "github", repository: "o/r", issueNumber: 7 }; + const store = createStore({ + settings: sourceSettings, + listTasks: [{ id: "FN-7", column: "done", sourceIssue }], + }); + + const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store); + + expect(mockSetIssueState).not.toHaveBeenCalled(); + expect((store.updateTask as any)).toHaveBeenCalledWith("FN-7", { + sourceIssue: { ...sourceIssue, closedAt: "2026-06-01T12:00:00Z" }, + }); + expect(result).toMatchObject({ closed: 0, skipped: 1, errors: 0 }); + }); + + it("does not overwrite an existing source issue closedAt", async () => { + mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } }); + mockGetIssue.mockResolvedValue({ state: "closed", closedAt: "2026-06-01T12:00:00Z" }); + const store = createStore({ + settings: sourceSettings, + listTasks: [{ + id: "FN-8", + column: "done", + sourceIssue: { provider: "github", repository: "o/r", issueNumber: 8, closedAt: "2026-01-01T00:00:00.000Z" }, + }], + }); + + const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store); + + expect(mockSetIssueState).not.toHaveBeenCalled(); + expect((store.updateTask as any)).not.toHaveBeenCalled(); + expect(result).toMatchObject({ closed: 0, skipped: 1, errors: 0 }); + }); + + it("logs but does not fail when persisting a source issue closedAt fails", async () => { + vi.useFakeTimers({ now: new Date("2026-06-18T10:00:00.000Z") }); + mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } }); + mockGetIssue.mockResolvedValue({ state: "open" }); + const store = createStore({ + settings: sourceSettings, + listTasks: [{ id: "FN-9", column: "done", sourceIssue: { provider: "github", repository: "o/r", issueNumber: 9 } }], + }); + (store.updateTask as any).mockRejectedValueOnce(new Error("db locked")); + + const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store); + + expect(result).toMatchObject({ closed: 1, errors: 0 }); + expect((store.logEntry as any)).toHaveBeenCalledWith("FN-9", "Failed to persist GitHub source issue closed timestamp", "db locked"); + }); + it("skips source issue reconciliation when close-on-done is disabled", async () => { const store = createStore({ settings: { githubCloseSourceIssueOnDone: false, githubAuthMode: "token", githubAuthToken: "ghp_test" }, @@ -173,6 +250,7 @@ describe("GitHubTrackingReconciler", () => { const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store); expect(mockSetIssueState).not.toHaveBeenCalled(); + expect((store.updateTask as any)).not.toHaveBeenCalled(); expect(result).toEqual({ scanned: 1, closed: 0, skipped: 1, errors: 0 }); }); }); diff --git a/packages/dashboard/src/__tests__/github.test.ts b/packages/dashboard/src/__tests__/github.test.ts index d0246e96e7..cc38d4c0f8 100644 --- a/packages/dashboard/src/__tests__/github.test.ts +++ b/packages/dashboard/src/__tests__/github.test.ts @@ -1091,6 +1091,7 @@ describe("GitHubClient", () => { url: "https://github.com/owner/repo/issues/1", state: "OPEN", stateReason: "reopened", + closedAt: null, }); const result = await client.getIssue("owner", "repo", 1); @@ -1098,14 +1099,51 @@ describe("GitHubClient", () => { expect(mockRunGhJsonAsync).toHaveBeenCalledWith([ "issue", "view", "1", "--repo", "owner/repo", - "--json", "number,title,body,url,state,stateReason", + "--json", "number,title,body,url,state,stateReason,closedAt", ]); expect(result).not.toBeNull(); expect(result?.number).toBe(1); expect(result?.state).toBe("open"); expect(result?.stateReason).toBe("reopened"); + expect(result?.closedAt).toBeUndefined(); }); + it("parses closedAt from gh CLI issue view", async () => { + mockRunGhJsonAsync.mockResolvedValue({ + number: 2, + title: "Closed Issue", + body: "Done", + url: "https://github.com/owner/repo/issues/2", + state: "CLOSED", + stateReason: "completed", + closedAt: "2026-06-01T12:00:00Z", + }); + + const result = await client.getIssue("owner", "repo", 2); + + expect(result?.state).toBe("closed"); + expect(result?.closedAt).toBe("2026-06-01T12:00:00Z"); + }); + + it.each([null, "", "0001-01-01T00:00:00Z", "not-a-date"])( + "normalizes unusable gh CLI closedAt value %s to undefined", + async (closedAt) => { + mockRunGhJsonAsync.mockResolvedValue({ + number: 3, + title: "Open Issue", + body: "Open", + url: "https://github.com/owner/repo/issues/3", + state: "OPEN", + stateReason: "reopened", + closedAt, + }); + + const result = await client.getIssue("owner", "repo", 3); + + expect(result?.closedAt).toBeUndefined(); + }, + ); + it("returns null for non-existent issues", async () => { mockRunGhJsonAsync.mockRejectedValue( new Error("HTTP 404: not found") @@ -1126,6 +1164,59 @@ describe("GitHubClient", () => { expect(result).toBeNull(); }); + it("parses closedAt from REST issue responses", async () => { + mockRunGhJsonAsync.mockRejectedValue(new Error("gh failed")); + + const clientWithToken = new GitHubClient("ghp_token"); + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ + number: 2, + title: "API Issue", + body: "API body", + html_url: "https://github.com/owner/repo/issues/2", + state: "closed", + state_reason: "completed", + closed_at: "2026-06-01T12:00:00Z", + }), + }); + global.fetch = mockFetch as any; + + const result = await clientWithToken.getIssue("owner", "repo", 2); + + expect(result?.state).toBe("closed"); + expect(result?.closedAt).toBe("2026-06-01T12:00:00Z"); + + vi.restoreAllMocks(); + }); + + it("normalizes REST sentinel closed_at to undefined", async () => { + mockRunGhJsonAsync.mockRejectedValue(new Error("gh failed")); + + const clientWithToken = new GitHubClient("ghp_token"); + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ + number: 3, + title: "API Issue", + body: "API body", + html_url: "https://github.com/owner/repo/issues/3", + state: "open", + state_reason: null, + closed_at: "0001-01-01T00:00:00Z", + }), + }); + global.fetch = mockFetch as any; + + const result = await clientWithToken.getIssue("owner", "repo", 3); + + expect(result?.closedAt).toBeUndefined(); + + vi.restoreAllMocks(); + }); + it("falls back to REST API when gh CLI fails and token is available", async () => { mockRunGhJsonAsync.mockRejectedValue(new Error("gh failed")); @@ -1140,6 +1231,7 @@ describe("GitHubClient", () => { html_url: "https://github.com/owner/repo/issues/1", state: "open", state_reason: null, + closed_at: null, }), }); global.fetch = mockFetch as any; @@ -1148,6 +1240,7 @@ describe("GitHubClient", () => { expect(mockFetch).toHaveBeenCalled(); expect(result?.number).toBe(1); + expect(result?.closedAt).toBeUndefined(); vi.restoreAllMocks(); }); diff --git a/packages/dashboard/src/github-tracking-reconciler.ts b/packages/dashboard/src/github-tracking-reconciler.ts index 2d46243516..a5bce8577a 100644 --- a/packages/dashboard/src/github-tracking-reconciler.ts +++ b/packages/dashboard/src/github-tracking-reconciler.ts @@ -1,4 +1,4 @@ -import type { GlobalSettings, ProjectSettings, TaskStore } from "@fusion/core"; +import type { GlobalSettings, ProjectSettings, TaskSourceIssue, TaskStore } from "@fusion/core"; import { resolveGithubTrackingAuth } from "./github-auth.js"; import { GitHubClient } from "./github.js"; @@ -93,7 +93,7 @@ export class GitHubTrackingReconciler { const repository = sourceIssue?.repository ?? ""; const [owner, repo] = repository.split("/"); const issueNumber = sourceIssue?.issueNumber; - if (!owner || !repo || !Number.isInteger(issueNumber)) { + if (!sourceIssue || !owner || !repo || !Number.isInteger(issueNumber)) { skipped += 1; return; } @@ -101,13 +101,23 @@ export class GitHubTrackingReconciler { const issueNumberValue = issueNumber as number; try { const linkedIssue = await client.getIssue(owner, repo, issueNumberValue); - if (!linkedIssue || linkedIssue.state === "closed") { + if (!linkedIssue) { + skipped += 1; + return; + } + if (linkedIssue.state === "closed") { + if (!sourceIssue.closedAt && linkedIssue.closedAt) { + await persistSourceIssueClosedAt(store, task.id, sourceIssue, linkedIssue.closedAt); + } skipped += 1; return; } const stateReason = task.column === "archived" && !task.executionCompletedAt ? "not_planned" : "completed"; await client.setIssueState(owner, repo, issueNumberValue, "closed", stateReason); + if (!sourceIssue.closedAt) { + await persistSourceIssueClosedAt(store, task.id, sourceIssue, new Date().toISOString()); + } closed += 1; } catch (error) { errors += 1; @@ -187,6 +197,27 @@ export class GitHubTrackingReconciler { } } +/** + * FNXC:GithubSourceIssueAnalytics 2026-06-18-18:19: + * Source-issue reconciliation is the authenticated path that can know real GitHub closure times; persist that exact timestamp idempotently and treat write failures as best-effort worker log entries instead of fabricating or overwriting analytics data. + */ +async function persistSourceIssueClosedAt( + store: TaskStore, + taskId: string, + sourceIssue: TaskSourceIssue, + closedAt: string, +): Promise { + try { + await store.updateTask(taskId, { sourceIssue: { ...sourceIssue, closedAt } }); + } catch (error) { + await store.logEntry( + taskId, + "Failed to persist GitHub source issue closed timestamp", + error instanceof Error ? error.message : String(error), + ); + } +} + async function runWithConcurrencyLimit(items: T[], limit: number, worker: (item: T) => Promise): Promise { const queue = [...items]; const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { diff --git a/packages/dashboard/src/github.ts b/packages/dashboard/src/github.ts index efd5406b46..4735647e93 100644 --- a/packages/dashboard/src/github.ts +++ b/packages/dashboard/src/github.ts @@ -3308,6 +3308,7 @@ export class GitHubClient { html_url: string; state: "open" | "closed"; stateReason?: "completed" | "not_planned" | "reopened"; + closedAt?: string; } | null> { if (this.hasGhAuth()) { try { @@ -3337,6 +3338,7 @@ export class GitHubClient { html_url: string; state: "open" | "closed"; stateReason?: "completed" | "not_planned" | "reopened"; + closedAt?: string; } | null> { try { const issue = await runGhJsonAsync<{ @@ -3346,10 +3348,11 @@ export class GitHubClient { url: string; state: "OPEN" | "CLOSED"; stateReason?: "completed" | "not_planned" | "reopened"; + closedAt?: string | null; }>([ "issue", "view", String(number), "--repo", `${owner}/${repo}`, - "--json", "number,title,body,url,state,stateReason", + "--json", "number,title,body,url,state,stateReason,closedAt", ]); return { @@ -3359,6 +3362,7 @@ export class GitHubClient { html_url: issue.url, state: this.mapGhIssueState(issue.state), stateReason: issue.stateReason, + closedAt: normalizeIssueClosedAt(issue.closedAt), }; } catch (err) { // gh issue view returns error if the issue is actually a PR @@ -3383,6 +3387,7 @@ export class GitHubClient { html_url: string; state: "open" | "closed"; stateReason?: "completed" | "not_planned" | "reopened"; + closedAt?: string; } | null> { const url = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${number}`; const headers = this.buildHeaders(); @@ -3403,6 +3408,7 @@ export class GitHubClient { html_url: string; state: string; state_reason?: "completed" | "not_planned" | "reopened"; + closed_at?: string | null; pull_request?: unknown; }; @@ -3418,6 +3424,7 @@ export class GitHubClient { body: data.body, state: this.mapIssueState(data.state), stateReason: data.state_reason ?? undefined, + closedAt: normalizeIssueClosedAt(data.closed_at), }; } @@ -3975,6 +3982,17 @@ function normalizeBadgeBatchPayload( return response; } +/** + * FNXC:GithubSourceIssueAnalytics 2026-06-18-18:10: + * GitHub source-issue reconciliation must only persist real closure timestamps, so `getIssue()` surfaces provider close times while normalizing absent and sentinel values to undefined for open or not-yet-observed issues. + */ +function normalizeIssueClosedAt(value: string | null | undefined): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + if (!trimmed || trimmed.startsWith("0001-01-01T00:00:00")) return undefined; + return Number.isFinite(Date.parse(trimmed)) ? trimmed : undefined; +} + function isGraphQlBatchPullRequest( resource: GraphQlBatchPullRequest | GraphQlBatchIssue, ): resource is GraphQlBatchPullRequest {