FN-6666: store GitHub issue closure timestamps

Persist GitHub source issue closure timestamps so Fixed by Fusion analytics can use exact close dates.

- add nullable sourceIssueClosedAt storage, migration coverage, serialization, and task source issue typing
- persist observed or newly closed GitHub issue closed timestamps during source issue reconciliation
- update GitHub issue analytics to prefer sourceIssueClosedAt with updatedAt fallback and cover the behavior in tests
- document the stored timestamp and publish a patch changeset

Files changed:
 .changeset/fn-6666-source-issue-closed-at.md       |  5 ++
 docs/dashboard-guide.md                            |  2 +-
 docs/storage.md                                    |  2 +
 packages/core/src/__tests__/db-migrate.test.ts     | 94 +++++++++++++++++----
 packages/core/src/__tests__/db.test.ts             | 89 ++++++++++++++------
 .../src/__tests__/github-issue-analytics.test.ts   | 47 ++++++++++-
 packages/core/src/db-migrate.ts                    |  5 +-
 packages/core/src/db.ts                            | 12 ++-
 packages/core/src/github-issue-analytics.ts        | 28 +++----
 packages/core/src/store.ts                         |  7 +-
 packages/core/src/types.ts                         |  6 ++
 .../__tests__/github-tracking-reconciler.test.ts   | 80 +++++++++++++++++-
 packages/dashboard/src/__tests__/github.test.ts    | 95 +++++++++++++++++++++-
 .../dashboard/src/github-tracking-reconciler.ts    | 37 ++++++++-
 packages/dashboard/src/github.ts                   | 20 ++++-
 15 files changed, 457 insertions(+), 72 deletions(-)

Fusion-Task-Id: FN-6666

Fusion-Task-Lineage: 0fa8e824-14de-400e-b2a3-aa55d126e218
This commit is contained in:
gsxdsm
2026-06-18 18:48:05 -07:00
parent 36f1feef31
commit 94a081f4f3
15 changed files with 457 additions and 72 deletions

View File

@@ -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.

View File

@@ -670,7 +670,7 @@ Features:
- **Productivity** separates outcome counters (commits and pull requests) from volume proxies such as modified files, lines changed, and files by language.
- **Team** shows a per-agent analytics table plus tokens-by-agent and tasks-done-by-agent charts. Metrics come only from the project-scoped `tasks` and `agents` tables: token totals and estimated cost are summed from the `tokenUsage*` columns by `assignedAgentId`, files changed counts parsed `tasks.modifiedFiles` paths, tasks done counts `column = 'done'` moves in the selected range, and in-progress / in-review values reflect current task columns. Agent name, role, and live state come from the `agents` table; deleted-agent task history falls back to the raw agent id instead of crashing. The tab uses `/api/command-center/team`, adds no schema, never calls GitHub, and intentionally leaves per-agent issues filed/fixed to FN-6653. Decorative chart reveal motion uses duration tokens and is disabled for reduced-motion users.
- **Ecosystem** shows active model breadth and per-model task activity; unavailable plugin-activation metrics render as unavailable rather than zero.
- **GitHub** shows local GitHub issue flow for the selected range: **Filed by Fusion** counts tasks with a persisted `githubTracking.issue`, **Fixed by Fusion** counts tasks imported from GitHub source issues (`sourceIssueProvider = "github"`) that are currently in `done`, using 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)`.

View File

@@ -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. |

View File

@@ -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 }>;

View File

@@ -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<string, null>;
@@ -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 }>;

View File

@@ -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",

View File

@@ -225,11 +225,11 @@ async function migrateTasks(fusionDir: string, db: Database): Promise<void> {
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<void> {
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,

View File

@@ -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");
});
}
}
/**

View File

@@ -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 {

View File

@@ -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<TaskStoreEvents> {
externalIssueId: row.sourceIssueExternalIssueId,
issueNumber: row.sourceIssueNumber,
url: row.sourceIssueUrl ?? undefined,
closedAt: row.sourceIssueClosedAt ?? undefined,
};
})(),
mergeDetails: fromJson<import("./types.js").MergeDetails>(row.mergeDetails),
@@ -2488,7 +2491,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"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<TaskStoreEvents> {
"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",

View File

@@ -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 {

View File

@@ -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 });
});
});

View File

@@ -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();
});

View File

@@ -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<void> {
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<T>(items: T[], limit: number, worker: (item: T) => Promise<void>): Promise<void> {
const queue = [...items];
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {

View File

@@ -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 {