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:
5
.changeset/fn-6666-source-issue-closed-at.md
Normal file
5
.changeset/fn-6666-source-issue-closed-at.md
Normal 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.
|
||||||
@@ -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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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)`.
|
- 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)`.
|
||||||
|
|||||||
@@ -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.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.
|
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). |
|
| `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. |
|
| `workflow_steps` | Workflow step definitions (`prompt`/`script`) with phase, template metadata, and model overrides. |
|
||||||
|
|||||||
@@ -596,6 +596,7 @@ describe("migrateFromLegacy", () => {
|
|||||||
externalIssueId: "I_kgDOExample",
|
externalIssueId: "I_kgDOExample",
|
||||||
issueNumber: 10,
|
issueNumber: 10,
|
||||||
url: "https://github.com/test/issues/1",
|
url: "https://github.com/test/issues/1",
|
||||||
|
closedAt: "2026-06-18T12:00:00.000Z",
|
||||||
},
|
},
|
||||||
breakIntoSubtasks: true,
|
breakIntoSubtasks: true,
|
||||||
enabledWorkflowSteps: ["WS-001", "WS-002"],
|
enabledWorkflowSteps: ["WS-001", "WS-002"],
|
||||||
@@ -645,6 +646,7 @@ describe("migrateFromLegacy", () => {
|
|||||||
expect(row.sourceIssueExternalIssueId).toBe("I_kgDOExample");
|
expect(row.sourceIssueExternalIssueId).toBe("I_kgDOExample");
|
||||||
expect(row.sourceIssueNumber).toBe(10);
|
expect(row.sourceIssueNumber).toBe(10);
|
||||||
expect(row.sourceIssueUrl).toBe("https://github.com/test/issues/1");
|
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(row.breakIntoSubtasks).toBe(1);
|
||||||
expect(JSON.parse(row.enabledWorkflowSteps)).toEqual(["WS-001", "WS-002"]);
|
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 };
|
const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null };
|
||||||
expect(row.deletedAt).toBeNull();
|
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();
|
db.close();
|
||||||
});
|
});
|
||||||
@@ -752,7 +816,7 @@ describe("schema migration", () => {
|
|||||||
{ id: "WS-001", mode: "prompt", gateMode: "advisory" },
|
{ id: "WS-001", mode: "prompt", gateMode: "advisory" },
|
||||||
{ id: "WS-002", mode: "script", gateMode: "advisory" },
|
{ id: "WS-002", mode: "script", gateMode: "advisory" },
|
||||||
]);
|
]);
|
||||||
expect(db.getSchemaVersion()).toBe(120);
|
expect(db.getSchemaVersion()).toBe(122);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
@@ -802,7 +866,7 @@ describe("schema migration", () => {
|
|||||||
reviewerContextRetryCount: 0,
|
reviewerContextRetryCount: 0,
|
||||||
reviewerFallbackRetryCount: 0,
|
reviewerFallbackRetryCount: 0,
|
||||||
});
|
});
|
||||||
expect(db.getSchemaVersion()).toBe(120);
|
expect(db.getSchemaVersion()).toBe(122);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
@@ -831,7 +895,7 @@ describe("schema migration", () => {
|
|||||||
|
|
||||||
const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>;
|
const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>;
|
||||||
expect(columns.map((column) => column.name)).toContain("acceptanceCriteria");
|
expect(columns.map((column) => column.name)).toContain("acceptanceCriteria");
|
||||||
expect(db.getSchemaVersion()).toBe(120);
|
expect(db.getSchemaVersion()).toBe(122);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
@@ -872,7 +936,7 @@ describe("schema migration", () => {
|
|||||||
const missionColumns = db.prepare("PRAGMA table_info(missions)").all() as Array<{ name: string }>;
|
const missionColumns = db.prepare("PRAGMA table_info(missions)").all() as Array<{ name: string }>;
|
||||||
expect(missionColumns.map((column) => column.name)).toContain("autoMerge");
|
expect(missionColumns.map((column) => column.name)).toContain("autoMerge");
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(120);
|
expect(db.getSchemaVersion()).toBe(122);
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -906,7 +970,7 @@ describe("schema migration", () => {
|
|||||||
{ id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" },
|
{ id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" },
|
||||||
{ id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" },
|
{ id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" },
|
||||||
]);
|
]);
|
||||||
expect(db.getSchemaVersion()).toBe(120);
|
expect(db.getSchemaVersion()).toBe(122);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
@@ -943,7 +1007,7 @@ describe("schema migration", () => {
|
|||||||
|
|
||||||
const indexes = db.prepare("PRAGMA index_list(mission_goals)").all() as Array<{ name: string }>;
|
const indexes = db.prepare("PRAGMA index_list(mission_goals)").all() as Array<{ name: string }>;
|
||||||
expect(indexes.some((index) => index.name === "idxMissionGoalsGoalId")).toBe(true);
|
expect(indexes.some((index) => index.name === "idxMissionGoalsGoalId")).toBe(true);
|
||||||
expect(db.getSchemaVersion()).toBe(120);
|
expect(db.getSchemaVersion()).toBe(122);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
@@ -1004,7 +1068,7 @@ describe("schema migration", () => {
|
|||||||
expect(customFieldsColumn).toBeDefined();
|
expect(customFieldsColumn).toBeDefined();
|
||||||
expect(customFieldsColumn?.dflt_value).toBe("'{}'");
|
expect(customFieldsColumn?.dflt_value).toBe("'{}'");
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(120);
|
expect(db.getSchemaVersion()).toBe(122);
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1042,7 +1106,7 @@ describe("schema migration", () => {
|
|||||||
const indexes = db.prepare("PRAGMA index_list(workflow_settings)").all() as Array<{ name: string }>;
|
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(indexes.some((index) => index.name === "idx_workflow_settings_project")).toBe(true);
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(120);
|
expect(db.getSchemaVersion()).toBe(122);
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1124,7 +1188,7 @@ describe("schema migration", () => {
|
|||||||
expect(indexNames).toContain("idx_cli_sessions_chatSessionId");
|
expect(indexNames).toContain("idx_cli_sessions_chatSessionId");
|
||||||
expect(indexNames).toContain("idx_cli_sessions_project_state");
|
expect(indexNames).toContain("idx_cli_sessions_project_state");
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(120);
|
expect(db.getSchemaVersion()).toBe(122);
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1156,7 +1220,7 @@ describe("schema migration", () => {
|
|||||||
.all() as Array<{ name: string }>;
|
.all() as Array<{ name: string }>;
|
||||||
expect(columns.map((column) => column.name)).toContain("cliExecutorAdapterId");
|
expect(columns.map((column) => column.name)).toContain("cliExecutorAdapterId");
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(120);
|
expect(db.getSchemaVersion()).toBe(122);
|
||||||
db.close();
|
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 }>;
|
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(tables.map((row) => row.name)).toContain("cli_sessions");
|
||||||
expect(db.getSchemaVersion()).toBe(120);
|
expect(db.getSchemaVersion()).toBe(122);
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1223,20 +1287,20 @@ describe("schema migration", () => {
|
|||||||
.get() as { migrated_fragment_id: string | null };
|
.get() as { migrated_fragment_id: string | null };
|
||||||
expect(stepRow.migrated_fragment_id).toBeNull();
|
expect(stepRow.migrated_fragment_id).toBeNull();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(120);
|
expect(db.getSchemaVersion()).toBe(122);
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("migration 109 is idempotent on re-init", () => {
|
it("migration 109 is idempotent on re-init", () => {
|
||||||
const db = new Database(fusionDir);
|
const db = new Database(fusionDir);
|
||||||
db.init();
|
db.init();
|
||||||
expect(db.getSchemaVersion()).toBe(120);
|
expect(db.getSchemaVersion()).toBe(122);
|
||||||
db.close();
|
db.close();
|
||||||
|
|
||||||
// Re-open the same on-disk DB: already at 109, the 109 block must be a no-op.
|
// Re-open the same on-disk DB: already at 109, the 109 block must be a no-op.
|
||||||
const reopened = new Database(fusionDir);
|
const reopened = new Database(fusionDir);
|
||||||
reopened.init();
|
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 }>;
|
const workflowColumns = reopened.prepare("PRAGMA table_info(workflows)").all() as Array<{ name: string }>;
|
||||||
expect(workflowColumns.filter((c) => c.name === "kind")).toHaveLength(1);
|
expect(workflowColumns.filter((c) => c.name === "kind")).toHaveLength(1);
|
||||||
const stepColumns = reopened.prepare("PRAGMA table_info(workflow_steps)").all() as Array<{ name: string }>;
|
const stepColumns = reopened.prepare("PRAGMA table_info(workflow_steps)").all() as Array<{ name: string }>;
|
||||||
|
|||||||
@@ -334,7 +334,7 @@ describe("Database", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("seeds schema version", () => {
|
it("seeds schema version", () => {
|
||||||
expect(db.getSchemaVersion()).toBe(120);
|
expect(db.getSchemaVersion()).toBe(122);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => {
|
it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => {
|
||||||
@@ -393,7 +393,7 @@ describe("Database", () => {
|
|||||||
|
|
||||||
it("is idempotent - calling init() twice does not fail", () => {
|
it("is idempotent - calling init() twice does not fail", () => {
|
||||||
expect(() => db.init()).not.toThrow();
|
expect(() => db.init()).not.toThrow();
|
||||||
expect(db.getSchemaVersion()).toBe(120);
|
expect(db.getSchemaVersion()).toBe(122);
|
||||||
});
|
});
|
||||||
it("does not overwrite existing config on re-init", () => {
|
it("does not overwrite existing config on re-init", () => {
|
||||||
// Update the config
|
// Update the config
|
||||||
@@ -1463,7 +1463,7 @@ describe("schema migrations", () => {
|
|||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
|
// 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
|
// Verify new columns exist and existing data is intact
|
||||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
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);
|
const db = new Database(fusionDir);
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(120);
|
expect(db.getSchemaVersion()).toBe(122);
|
||||||
|
|
||||||
// Re-init should not fail
|
// Re-init should not fail
|
||||||
db.init();
|
db.init();
|
||||||
expect(db.getSchemaVersion()).toBe(120);
|
expect(db.getSchemaVersion()).toBe(122);
|
||||||
|
|
||||||
// Re-init should not fail
|
// Re-init should not fail
|
||||||
db.init();
|
db.init();
|
||||||
expect(db.getSchemaVersion()).toBe(120);
|
expect(db.getSchemaVersion()).toBe(122);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
@@ -1531,7 +1531,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
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 cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||||
expect(cols.map((col) => col.name)).toContain("priority");
|
expect(cols.map((col) => col.name)).toContain("priority");
|
||||||
@@ -1572,7 +1572,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
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 cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||||
const colNames = cols.map((col) => col.name);
|
const colNames = cols.map((col) => col.name);
|
||||||
@@ -1644,7 +1644,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
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 cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||||
const colNames = cols.map((col) => col.name);
|
const colNames = cols.map((col) => col.name);
|
||||||
@@ -1653,6 +1653,7 @@ describe("schema migrations", () => {
|
|||||||
expect(colNames).toContain("sourceIssueExternalIssueId");
|
expect(colNames).toContain("sourceIssueExternalIssueId");
|
||||||
expect(colNames).toContain("sourceIssueNumber");
|
expect(colNames).toContain("sourceIssueNumber");
|
||||||
expect(colNames).toContain("sourceIssueUrl");
|
expect(colNames).toContain("sourceIssueUrl");
|
||||||
|
expect(colNames).toContain("sourceIssueClosedAt");
|
||||||
|
|
||||||
const task = db.prepare(`
|
const task = db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -1660,7 +1661,8 @@ describe("schema migrations", () => {
|
|||||||
sourceIssueRepository,
|
sourceIssueRepository,
|
||||||
sourceIssueExternalIssueId,
|
sourceIssueExternalIssueId,
|
||||||
sourceIssueNumber,
|
sourceIssueNumber,
|
||||||
sourceIssueUrl
|
sourceIssueUrl,
|
||||||
|
sourceIssueClosedAt
|
||||||
FROM tasks
|
FROM tasks
|
||||||
WHERE id = 'FN-3'
|
WHERE id = 'FN-3'
|
||||||
`).get() as Record<string, null>;
|
`).get() as Record<string, null>;
|
||||||
@@ -1670,10 +1672,47 @@ describe("schema migrations", () => {
|
|||||||
expect(task.sourceIssueExternalIssueId).toBeNull();
|
expect(task.sourceIssueExternalIssueId).toBeNull();
|
||||||
expect(task.sourceIssueNumber).toBeNull();
|
expect(task.sourceIssueNumber).toBeNull();
|
||||||
expect(task.sourceIssueUrl).toBeNull();
|
expect(task.sourceIssueUrl).toBeNull();
|
||||||
|
expect(task.sourceIssueClosedAt).toBeNull();
|
||||||
|
|
||||||
db.close();
|
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", () => {
|
it("reconciles missing columns across all SCHEMA_SQL tables even when schemaVersion is current", () => {
|
||||||
tmpDir = makeTmpDir();
|
tmpDir = makeTmpDir();
|
||||||
const fusionDir = join(tmpDir, ".fusion");
|
const fusionDir = join(tmpDir, ".fusion");
|
||||||
@@ -1884,7 +1923,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
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 }>;
|
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
|
||||||
expect(cols.map((col) => col.name)).toContain("attachments");
|
expect(cols.map((col) => col.name)).toContain("attachments");
|
||||||
@@ -1958,7 +1997,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
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 }>;
|
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" }]);
|
expect(tables).toEqual([{ name: "agentRatings" }]);
|
||||||
@@ -1982,7 +2021,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
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 }>;
|
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" }]);
|
expect(tables).toEqual([{ name: "mission_events" }]);
|
||||||
@@ -2086,7 +2125,7 @@ describe("schema migrations", () => {
|
|||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
// Verify version bumped to 29
|
// Verify version bumped to 29
|
||||||
expect(db.getSchemaVersion()).toBe(120);
|
expect(db.getSchemaVersion()).toBe(122);
|
||||||
|
|
||||||
// Verify new columns exist and existing data is intact
|
// Verify new columns exist and existing data is intact
|
||||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||||
@@ -2305,7 +2344,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
localDb.init();
|
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 }>;
|
const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||||
expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens");
|
expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens");
|
||||||
|
|
||||||
@@ -2616,7 +2655,7 @@ describe("createDatabase factory", () => {
|
|||||||
const db = createDatabase(fusionDir);
|
const db = createDatabase(fusionDir);
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(120);
|
expect(db.getSchemaVersion()).toBe(122);
|
||||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
@@ -2770,7 +2809,7 @@ describe("migration v77 task token budget columns", () => {
|
|||||||
|
|
||||||
migrated = new Database(fusion);
|
migrated = new Database(fusion);
|
||||||
migrated.init();
|
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 rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||||
const names = new Set(rows.map((row) => row.name));
|
const names = new Set(rows.map((row) => row.name));
|
||||||
expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true);
|
expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true);
|
||||||
@@ -2801,7 +2840,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => {
|
|||||||
const fresh = new Database(fusion);
|
const fresh = new Database(fusion);
|
||||||
try {
|
try {
|
||||||
fresh.init();
|
fresh.init();
|
||||||
expect(fresh.getSchemaVersion()).toBe(120);
|
expect(fresh.getSchemaVersion()).toBe(122);
|
||||||
const names = new Set(
|
const names = new Set(
|
||||||
(fresh.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name),
|
(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 = new Database(fusion);
|
||||||
migrated.init();
|
migrated.init();
|
||||||
expect(migrated.getSchemaVersion()).toBe(120);
|
expect(migrated.getSchemaVersion()).toBe(122);
|
||||||
const names = new Set(
|
const names = new Set(
|
||||||
(migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name),
|
(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);
|
const fresh = new Database(fusion);
|
||||||
try {
|
try {
|
||||||
fresh.init();
|
fresh.init();
|
||||||
expect(fresh.getSchemaVersion()).toBe(120);
|
expect(fresh.getSchemaVersion()).toBe(122);
|
||||||
const table = fresh
|
const table = fresh
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'")
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'")
|
||||||
.get() as { name: string } | undefined;
|
.get() as { name: string } | undefined;
|
||||||
@@ -2889,7 +2928,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => {
|
|||||||
|
|
||||||
migrated = new Database(fusion);
|
migrated = new Database(fusion);
|
||||||
migrated.init();
|
migrated.init();
|
||||||
expect(migrated.getSchemaVersion()).toBe(120);
|
expect(migrated.getSchemaVersion()).toBe(122);
|
||||||
const table = migrated
|
const table = migrated
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'")
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'")
|
||||||
.get() as { name: string } | undefined;
|
.get() as { name: string } | undefined;
|
||||||
@@ -2915,7 +2954,7 @@ describe("migration v120 adds deployments + incidents tables (U13)", () => {
|
|||||||
const fresh = new Database(fusion);
|
const fresh = new Database(fusion);
|
||||||
try {
|
try {
|
||||||
fresh.init();
|
fresh.init();
|
||||||
expect(fresh.getSchemaVersion()).toBe(120);
|
expect(fresh.getSchemaVersion()).toBe(122);
|
||||||
const tables = new Set(
|
const tables = new Set(
|
||||||
(
|
(
|
||||||
fresh
|
fresh
|
||||||
@@ -2968,7 +3007,7 @@ describe("migration v120 adds deployments + incidents tables (U13)", () => {
|
|||||||
// creation while table + row assertions still pass. Assert the real index
|
// creation while table + row assertions still pass. Assert the real index
|
||||||
// names the v120 migration creates (idxDeployments*, idxIncidents*) so that
|
// names the v120 migration creates (idxDeployments*, idxIncidents*) so that
|
||||||
// regression is caught.
|
// regression is caught.
|
||||||
expect(migrated.getSchemaVersion()).toBe(120);
|
expect(migrated.getSchemaVersion()).toBe(122);
|
||||||
const tables = new Set(
|
const tables = new Set(
|
||||||
(
|
(
|
||||||
migrated
|
migrated
|
||||||
@@ -3027,7 +3066,7 @@ describe("migration v67 drops orphan project auth tables", () => {
|
|||||||
|
|
||||||
migrated = new Database(fusion);
|
migrated = new Database(fusion);
|
||||||
migrated.init();
|
migrated.init();
|
||||||
expect(migrated.getSchemaVersion()).toBe(120);
|
expect(migrated.getSchemaVersion()).toBe(122);
|
||||||
const tables = migrated
|
const tables = migrated
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
||||||
.all() as Array<{ name: string }>;
|
.all() as Array<{ name: string }>;
|
||||||
@@ -3054,7 +3093,7 @@ describe("migration v67 drops orphan project auth tables", () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
fresh.init();
|
fresh.init();
|
||||||
expect(fresh.getSchemaVersion()).toBe(120);
|
expect(fresh.getSchemaVersion()).toBe(122);
|
||||||
const tables = fresh
|
const tables = fresh
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
||||||
.all() as Array<{ name: string }>;
|
.all() as Array<{ name: string }>;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ function insertSourceIssueTask(
|
|||||||
repository: string;
|
repository: string;
|
||||||
column: string;
|
column: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
closedAt?: string | null;
|
||||||
issueNumber?: number;
|
issueNumber?: number;
|
||||||
},
|
},
|
||||||
): void {
|
): void {
|
||||||
@@ -41,8 +42,8 @@ function insertSourceIssueTask(
|
|||||||
`INSERT INTO tasks (
|
`INSERT INTO tasks (
|
||||||
id, description, "column", createdAt, updatedAt,
|
id, description, "column", createdAt, updatedAt,
|
||||||
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId,
|
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId,
|
||||||
sourceIssueNumber, sourceIssueUrl
|
sourceIssueNumber, sourceIssueUrl, sourceIssueClosedAt
|
||||||
) VALUES (?, 'desc', ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, 'desc', ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
).run(
|
).run(
|
||||||
id,
|
id,
|
||||||
opts.column,
|
opts.column,
|
||||||
@@ -53,6 +54,7 @@ function insertSourceIssueTask(
|
|||||||
String(opts.issueNumber ?? 1),
|
String(opts.issueNumber ?? 1),
|
||||||
opts.issueNumber ?? 1,
|
opts.issueNumber ?? 1,
|
||||||
`https://example.test/${id}`,
|
`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", () => {
|
it("returns zeroed structures for an empty range", () => {
|
||||||
insertTrackedIssue(db, "filed", {
|
insertTrackedIssue(db, "filed", {
|
||||||
owner: "acme",
|
owner: "acme",
|
||||||
|
|||||||
@@ -225,11 +225,11 @@ async function migrateTasks(fusionDir: string, db: Database): Promise<void> {
|
|||||||
error, summary, thinkingLevel, createdAt, updatedAt,
|
error, summary, thinkingLevel, createdAt, updatedAt,
|
||||||
columnMovedAt, dependencies, steps, log, attachments, steeringComments,
|
columnMovedAt, dependencies, steps, log, attachments, steeringComments,
|
||||||
comments, workflowStepResults, prInfo, issueInfo,
|
comments, workflowStepResults, prInfo, issueInfo,
|
||||||
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
|
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl, sourceIssueClosedAt,
|
||||||
mergeDetails, breakIntoSubtasks, noCommitsExpected, enabledWorkflowSteps, modifiedFiles, sliceId
|
mergeDetails, breakIntoSubtasks, noCommitsExpected, enabledWorkflowSteps, modifiedFiles, sliceId
|
||||||
) VALUES (
|
) VALUES (
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -293,6 +293,7 @@ async function migrateTasks(fusionDir: string, db: Database): Promise<void> {
|
|||||||
task.sourceIssue?.externalIssueId ?? null,
|
task.sourceIssue?.externalIssueId ?? null,
|
||||||
task.sourceIssue?.issueNumber ?? null,
|
task.sourceIssue?.issueNumber ?? null,
|
||||||
task.sourceIssue?.url ?? null,
|
task.sourceIssue?.url ?? null,
|
||||||
|
task.sourceIssue?.closedAt ?? null,
|
||||||
toJsonNullable(task.mergeDetails),
|
toJsonNullable(task.mergeDetails),
|
||||||
task.breakIntoSubtasks ? 1 : 0,
|
task.breakIntoSubtasks ? 1 : 0,
|
||||||
task.noCommitsExpected ? 1 : 0,
|
task.noCommitsExpected ? 1 : 0,
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export function isFts5CorruptionError(error: unknown): boolean {
|
|||||||
|
|
||||||
// ── Schema Definition ────────────────────────────────────────────────
|
// ── Schema Definition ────────────────────────────────────────────────
|
||||||
|
|
||||||
const SCHEMA_VERSION = 121;
|
const SCHEMA_VERSION = 122;
|
||||||
|
|
||||||
const TASKS_FTS_AUTOMERGE = 8;
|
const TASKS_FTS_AUTOMERGE = 8;
|
||||||
const TASKS_FTS_CRISISMERGE = 16;
|
const TASKS_FTS_CRISISMERGE = 16;
|
||||||
@@ -316,6 +316,7 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|||||||
sourceIssueExternalIssueId TEXT,
|
sourceIssueExternalIssueId TEXT,
|
||||||
sourceIssueNumber INTEGER,
|
sourceIssueNumber INTEGER,
|
||||||
sourceIssueUrl TEXT,
|
sourceIssueUrl TEXT,
|
||||||
|
sourceIssueClosedAt TEXT,
|
||||||
mergeDetails TEXT,
|
mergeDetails TEXT,
|
||||||
breakIntoSubtasks INTEGER DEFAULT 0,
|
breakIntoSubtasks INTEGER DEFAULT 0,
|
||||||
noCommitsExpected 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");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Database } from "./db.js";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* FNXC:CommandCenterGithub 2026-06-18-00:00:
|
* 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 {
|
export interface GithubIssueAnalyticsQuery {
|
||||||
@@ -33,7 +33,7 @@ export interface GithubIssueAnalytics {
|
|||||||
to: string | null;
|
to: string | null;
|
||||||
/** Fusion-created GitHub issues in range. Undated tracked issues are included because no date can be honestly inferred. */
|
/** Fusion-created GitHub issues in range. Undated tracked issues are included because no date can be honestly inferred. */
|
||||||
filed: number;
|
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;
|
fixed: number;
|
||||||
/** Filed minus fixed. */
|
/** Filed minus fixed. */
|
||||||
net: number;
|
net: number;
|
||||||
@@ -49,6 +49,7 @@ interface GithubTrackingRow {
|
|||||||
|
|
||||||
interface FixedIssueRow {
|
interface FixedIssueRow {
|
||||||
sourceIssueRepository: string | null;
|
sourceIssueRepository: string | null;
|
||||||
|
sourceIssueClosedAt: string | null;
|
||||||
updatedAt: 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
|
const fixedRows = db
|
||||||
.prepare(
|
.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;
|
let fixed = 0;
|
||||||
for (const row of fixedRows) {
|
for (const row of fixedRows) {
|
||||||
|
const fixedDate = row.sourceIssueClosedAt ?? row.updatedAt;
|
||||||
|
if (fixedDate === null || !isInRange(fixedDate, query)) continue;
|
||||||
|
|
||||||
fixed += 1;
|
fixed += 1;
|
||||||
const repo = row.sourceIssueRepository?.trim() || "(unknown)";
|
const repo = row.sourceIssueRepository?.trim() || "(unknown)";
|
||||||
addRepo(byRepo, repo, "fixed");
|
addRepo(byRepo, repo, "fixed");
|
||||||
if (row.updatedAt) {
|
const day = dayKey(fixedDate);
|
||||||
const day = dayKey(row.updatedAt);
|
if (day !== null) addDaily(daily, day, "fixed");
|
||||||
if (day !== null) addDaily(daily, day, "fixed");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ interface TaskRow {
|
|||||||
sourceIssueExternalIssueId: string | null;
|
sourceIssueExternalIssueId: string | null;
|
||||||
sourceIssueNumber: number | null;
|
sourceIssueNumber: number | null;
|
||||||
sourceIssueUrl: string | null;
|
sourceIssueUrl: string | null;
|
||||||
|
sourceIssueClosedAt: string | null;
|
||||||
mergeDetails: string | null;
|
mergeDetails: string | null;
|
||||||
breakIntoSubtasks: number | null;
|
breakIntoSubtasks: number | null;
|
||||||
noCommitsExpected: number | null;
|
noCommitsExpected: number | null;
|
||||||
@@ -414,6 +415,7 @@ const TASK_COLUMN_DESCRIPTORS: TaskColumnDescriptor[] = [
|
|||||||
defineTaskColumn("sourceIssueExternalIssueId", (task) => task.sourceIssue?.externalIssueId ?? null),
|
defineTaskColumn("sourceIssueExternalIssueId", (task) => task.sourceIssue?.externalIssueId ?? null),
|
||||||
defineTaskColumn("sourceIssueNumber", (task) => task.sourceIssue?.issueNumber ?? null),
|
defineTaskColumn("sourceIssueNumber", (task) => task.sourceIssue?.issueNumber ?? null),
|
||||||
defineTaskColumn("sourceIssueUrl", (task) => task.sourceIssue?.url ?? null),
|
defineTaskColumn("sourceIssueUrl", (task) => task.sourceIssue?.url ?? null),
|
||||||
|
defineTaskColumn("sourceIssueClosedAt", (task) => task.sourceIssue?.closedAt ?? null),
|
||||||
defineTaskColumn("mergeDetails", (task) => toJsonNullable(task.mergeDetails)),
|
defineTaskColumn("mergeDetails", (task) => toJsonNullable(task.mergeDetails)),
|
||||||
defineTaskColumn("breakIntoSubtasks", (task) => task.breakIntoSubtasks ? 1 : 0),
|
defineTaskColumn("breakIntoSubtasks", (task) => task.breakIntoSubtasks ? 1 : 0),
|
||||||
defineTaskColumn("noCommitsExpected", (task) => task.noCommitsExpected ? 1 : 0),
|
defineTaskColumn("noCommitsExpected", (task) => task.noCommitsExpected ? 1 : 0),
|
||||||
@@ -2068,6 +2070,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
externalIssueId: row.sourceIssueExternalIssueId,
|
externalIssueId: row.sourceIssueExternalIssueId,
|
||||||
issueNumber: row.sourceIssueNumber,
|
issueNumber: row.sourceIssueNumber,
|
||||||
url: row.sourceIssueUrl ?? undefined,
|
url: row.sourceIssueUrl ?? undefined,
|
||||||
|
closedAt: row.sourceIssueClosedAt ?? undefined,
|
||||||
};
|
};
|
||||||
})(),
|
})(),
|
||||||
mergeDetails: fromJson<import("./types.js").MergeDetails>(row.mergeDetails),
|
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",
|
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride",
|
||||||
"createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt",
|
"createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt",
|
||||||
"dependencies", "steps", "customFields", "comments", "review", "reviewState", "workflowStepResults", "steeringComments",
|
"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",
|
"breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles",
|
||||||
"missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
|
"missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
|
||||||
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
|
"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",
|
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride",
|
||||||
"createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt",
|
"createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt",
|
||||||
"dependencies", "steps", "customFields", "attachments", "steeringComments",
|
"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",
|
"breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles",
|
||||||
"missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
|
"missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
|
||||||
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
|
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
|
||||||
|
|||||||
@@ -1179,6 +1179,12 @@ export interface TaskSourceIssue {
|
|||||||
issueNumber: number;
|
issueNumber: number;
|
||||||
/** Optional canonical URL to the source issue. */
|
/** Optional canonical URL to the source issue. */
|
||||||
url?: string;
|
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 {
|
export interface BatchStatusRequest {
|
||||||
|
|||||||
@@ -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 type { TaskStore } from "@fusion/core";
|
||||||
import { GitHubTrackingReconciler, RECONCILE_CONCURRENCY_LIMIT } from "../github-tracking-reconciler.js";
|
import { GitHubTrackingReconciler, RECONCILE_CONCURRENCY_LIMIT } from "../github-tracking-reconciler.js";
|
||||||
|
|
||||||
@@ -34,6 +34,7 @@ function createStore(options: {
|
|||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue({ tasks: options.reconcileCandidates ?? [], hasMore: options.reconcileHasMore ?? false }),
|
.mockResolvedValue({ tasks: options.reconcileCandidates ?? [], hasMore: options.reconcileHasMore ?? false }),
|
||||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||||
|
updateTask: vi.fn().mockResolvedValue(undefined),
|
||||||
getSettings: vi.fn().mockResolvedValue(options.settings ?? { githubAuthMode: "token", githubAuthToken: "ghp_test" }),
|
getSettings: vi.fn().mockResolvedValue(options.settings ?? { githubAuthMode: "token", githubAuthToken: "ghp_test" }),
|
||||||
getGlobalSettingsStore: vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) })),
|
getGlobalSettingsStore: vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) })),
|
||||||
} as unknown as TaskStore;
|
} as unknown as TaskStore;
|
||||||
@@ -44,6 +45,10 @@ describe("GitHubTrackingReconciler", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it("closes open issues for done-column tracked tasks", async () => {
|
it("closes open issues for done-column tracked tasks", async () => {
|
||||||
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
|
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
|
||||||
mockGetIssue.mockResolvedValue({ state: "open" });
|
mockGetIssue.mockResolvedValue({ state: "open" });
|
||||||
@@ -164,6 +169,78 @@ describe("GitHubTrackingReconciler", () => {
|
|||||||
expect(result).toMatchObject({ scanned: 3, closed: 3, skipped: 0, errors: 0 });
|
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 () => {
|
it("skips source issue reconciliation when close-on-done is disabled", async () => {
|
||||||
const store = createStore({
|
const store = createStore({
|
||||||
settings: { githubCloseSourceIssueOnDone: false, githubAuthMode: "token", githubAuthToken: "ghp_test" },
|
settings: { githubCloseSourceIssueOnDone: false, githubAuthMode: "token", githubAuthToken: "ghp_test" },
|
||||||
@@ -173,6 +250,7 @@ describe("GitHubTrackingReconciler", () => {
|
|||||||
const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store);
|
const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store);
|
||||||
|
|
||||||
expect(mockSetIssueState).not.toHaveBeenCalled();
|
expect(mockSetIssueState).not.toHaveBeenCalled();
|
||||||
|
expect((store.updateTask as any)).not.toHaveBeenCalled();
|
||||||
expect(result).toEqual({ scanned: 1, closed: 0, skipped: 1, errors: 0 });
|
expect(result).toEqual({ scanned: 1, closed: 0, skipped: 1, errors: 0 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1091,6 +1091,7 @@ describe("GitHubClient", () => {
|
|||||||
url: "https://github.com/owner/repo/issues/1",
|
url: "https://github.com/owner/repo/issues/1",
|
||||||
state: "OPEN",
|
state: "OPEN",
|
||||||
stateReason: "reopened",
|
stateReason: "reopened",
|
||||||
|
closedAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await client.getIssue("owner", "repo", 1);
|
const result = await client.getIssue("owner", "repo", 1);
|
||||||
@@ -1098,14 +1099,51 @@ describe("GitHubClient", () => {
|
|||||||
expect(mockRunGhJsonAsync).toHaveBeenCalledWith([
|
expect(mockRunGhJsonAsync).toHaveBeenCalledWith([
|
||||||
"issue", "view", "1",
|
"issue", "view", "1",
|
||||||
"--repo", "owner/repo",
|
"--repo", "owner/repo",
|
||||||
"--json", "number,title,body,url,state,stateReason",
|
"--json", "number,title,body,url,state,stateReason,closedAt",
|
||||||
]);
|
]);
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result?.number).toBe(1);
|
expect(result?.number).toBe(1);
|
||||||
expect(result?.state).toBe("open");
|
expect(result?.state).toBe("open");
|
||||||
expect(result?.stateReason).toBe("reopened");
|
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 () => {
|
it("returns null for non-existent issues", async () => {
|
||||||
mockRunGhJsonAsync.mockRejectedValue(
|
mockRunGhJsonAsync.mockRejectedValue(
|
||||||
new Error("HTTP 404: not found")
|
new Error("HTTP 404: not found")
|
||||||
@@ -1126,6 +1164,59 @@ describe("GitHubClient", () => {
|
|||||||
expect(result).toBeNull();
|
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 () => {
|
it("falls back to REST API when gh CLI fails and token is available", async () => {
|
||||||
mockRunGhJsonAsync.mockRejectedValue(new Error("gh failed"));
|
mockRunGhJsonAsync.mockRejectedValue(new Error("gh failed"));
|
||||||
|
|
||||||
@@ -1140,6 +1231,7 @@ describe("GitHubClient", () => {
|
|||||||
html_url: "https://github.com/owner/repo/issues/1",
|
html_url: "https://github.com/owner/repo/issues/1",
|
||||||
state: "open",
|
state: "open",
|
||||||
state_reason: null,
|
state_reason: null,
|
||||||
|
closed_at: null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
global.fetch = mockFetch as any;
|
global.fetch = mockFetch as any;
|
||||||
@@ -1148,6 +1240,7 @@ describe("GitHubClient", () => {
|
|||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalled();
|
expect(mockFetch).toHaveBeenCalled();
|
||||||
expect(result?.number).toBe(1);
|
expect(result?.number).toBe(1);
|
||||||
|
expect(result?.closedAt).toBeUndefined();
|
||||||
|
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { resolveGithubTrackingAuth } from "./github-auth.js";
|
||||||
import { GitHubClient } from "./github.js";
|
import { GitHubClient } from "./github.js";
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ export class GitHubTrackingReconciler {
|
|||||||
const repository = sourceIssue?.repository ?? "";
|
const repository = sourceIssue?.repository ?? "";
|
||||||
const [owner, repo] = repository.split("/");
|
const [owner, repo] = repository.split("/");
|
||||||
const issueNumber = sourceIssue?.issueNumber;
|
const issueNumber = sourceIssue?.issueNumber;
|
||||||
if (!owner || !repo || !Number.isInteger(issueNumber)) {
|
if (!sourceIssue || !owner || !repo || !Number.isInteger(issueNumber)) {
|
||||||
skipped += 1;
|
skipped += 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -101,13 +101,23 @@ export class GitHubTrackingReconciler {
|
|||||||
const issueNumberValue = issueNumber as number;
|
const issueNumberValue = issueNumber as number;
|
||||||
try {
|
try {
|
||||||
const linkedIssue = await client.getIssue(owner, repo, issueNumberValue);
|
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;
|
skipped += 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stateReason = task.column === "archived" && !task.executionCompletedAt ? "not_planned" : "completed";
|
const stateReason = task.column === "archived" && !task.executionCompletedAt ? "not_planned" : "completed";
|
||||||
await client.setIssueState(owner, repo, issueNumberValue, "closed", stateReason);
|
await client.setIssueState(owner, repo, issueNumberValue, "closed", stateReason);
|
||||||
|
if (!sourceIssue.closedAt) {
|
||||||
|
await persistSourceIssueClosedAt(store, task.id, sourceIssue, new Date().toISOString());
|
||||||
|
}
|
||||||
closed += 1;
|
closed += 1;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors += 1;
|
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> {
|
async function runWithConcurrencyLimit<T>(items: T[], limit: number, worker: (item: T) => Promise<void>): Promise<void> {
|
||||||
const queue = [...items];
|
const queue = [...items];
|
||||||
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||||
|
|||||||
@@ -3308,6 +3308,7 @@ export class GitHubClient {
|
|||||||
html_url: string;
|
html_url: string;
|
||||||
state: "open" | "closed";
|
state: "open" | "closed";
|
||||||
stateReason?: "completed" | "not_planned" | "reopened";
|
stateReason?: "completed" | "not_planned" | "reopened";
|
||||||
|
closedAt?: string;
|
||||||
} | null> {
|
} | null> {
|
||||||
if (this.hasGhAuth()) {
|
if (this.hasGhAuth()) {
|
||||||
try {
|
try {
|
||||||
@@ -3337,6 +3338,7 @@ export class GitHubClient {
|
|||||||
html_url: string;
|
html_url: string;
|
||||||
state: "open" | "closed";
|
state: "open" | "closed";
|
||||||
stateReason?: "completed" | "not_planned" | "reopened";
|
stateReason?: "completed" | "not_planned" | "reopened";
|
||||||
|
closedAt?: string;
|
||||||
} | null> {
|
} | null> {
|
||||||
try {
|
try {
|
||||||
const issue = await runGhJsonAsync<{
|
const issue = await runGhJsonAsync<{
|
||||||
@@ -3346,10 +3348,11 @@ export class GitHubClient {
|
|||||||
url: string;
|
url: string;
|
||||||
state: "OPEN" | "CLOSED";
|
state: "OPEN" | "CLOSED";
|
||||||
stateReason?: "completed" | "not_planned" | "reopened";
|
stateReason?: "completed" | "not_planned" | "reopened";
|
||||||
|
closedAt?: string | null;
|
||||||
}>([
|
}>([
|
||||||
"issue", "view", String(number),
|
"issue", "view", String(number),
|
||||||
"--repo", `${owner}/${repo}`,
|
"--repo", `${owner}/${repo}`,
|
||||||
"--json", "number,title,body,url,state,stateReason",
|
"--json", "number,title,body,url,state,stateReason,closedAt",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -3359,6 +3362,7 @@ export class GitHubClient {
|
|||||||
html_url: issue.url,
|
html_url: issue.url,
|
||||||
state: this.mapGhIssueState(issue.state),
|
state: this.mapGhIssueState(issue.state),
|
||||||
stateReason: issue.stateReason,
|
stateReason: issue.stateReason,
|
||||||
|
closedAt: normalizeIssueClosedAt(issue.closedAt),
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// gh issue view returns error if the issue is actually a PR
|
// gh issue view returns error if the issue is actually a PR
|
||||||
@@ -3383,6 +3387,7 @@ export class GitHubClient {
|
|||||||
html_url: string;
|
html_url: string;
|
||||||
state: "open" | "closed";
|
state: "open" | "closed";
|
||||||
stateReason?: "completed" | "not_planned" | "reopened";
|
stateReason?: "completed" | "not_planned" | "reopened";
|
||||||
|
closedAt?: string;
|
||||||
} | null> {
|
} | null> {
|
||||||
const url = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${number}`;
|
const url = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${number}`;
|
||||||
const headers = this.buildHeaders();
|
const headers = this.buildHeaders();
|
||||||
@@ -3403,6 +3408,7 @@ export class GitHubClient {
|
|||||||
html_url: string;
|
html_url: string;
|
||||||
state: string;
|
state: string;
|
||||||
state_reason?: "completed" | "not_planned" | "reopened";
|
state_reason?: "completed" | "not_planned" | "reopened";
|
||||||
|
closed_at?: string | null;
|
||||||
pull_request?: unknown;
|
pull_request?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3418,6 +3424,7 @@ export class GitHubClient {
|
|||||||
body: data.body,
|
body: data.body,
|
||||||
state: this.mapIssueState(data.state),
|
state: this.mapIssueState(data.state),
|
||||||
stateReason: data.state_reason ?? undefined,
|
stateReason: data.state_reason ?? undefined,
|
||||||
|
closedAt: normalizeIssueClosedAt(data.closed_at),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3975,6 +3982,17 @@ function normalizeBadgeBatchPayload(
|
|||||||
return response;
|
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(
|
function isGraphQlBatchPullRequest(
|
||||||
resource: GraphQlBatchPullRequest | GraphQlBatchIssue,
|
resource: GraphQlBatchPullRequest | GraphQlBatchIssue,
|
||||||
): resource is GraphQlBatchPullRequest {
|
): resource is GraphQlBatchPullRequest {
|
||||||
|
|||||||
Reference in New Issue
Block a user