FN-6704: capture merge LOC for productivity analytics
Capture merge-time diff stats so Command Center Productivity can report real Lines changed when available. - Add nullable additions/deletions columns to task commit associations with migration and storage normalization. - Persist git shortstat counts from merge association paths without blocking merges when stats are unavailable. - Aggregate Productivity LOC from recorded commit stats while preserving the unavailable sentinel for historical unknowns. - Cover migration, store upsert, analytics, and merger association stats behavior with tests and documentation. Files changed: .changeset/fn-6704-command-center-loc.md | 5 ++ docs/architecture.md | 4 +- docs/storage.md | 3 + packages/core/src/__tests__/db-migrate.test.ts | 32 ++++---- packages/core/src/__tests__/db.test.ts | 90 ++++++++++++++++------ .../src/__tests__/productivity-analytics.test.ts | 47 +++++++++-- packages/core/src/__tests__/store-upsert.test.ts | 40 ++++++++++ packages/core/src/db.ts | 14 +++- packages/core/src/productivity-analytics.ts | 56 +++++++++----- packages/core/src/store.ts | 17 +++- packages/core/src/task-lineage.ts | 2 + packages/core/src/types.ts | 2 + .../merger-commit-association-stats.test.ts | 90 ++++++++++++++++++++++ packages/engine/src/merger-ai.ts | 2 + packages/engine/src/merger.ts | 17 +++- 15 files changed, 349 insertions(+), 72 deletions(-) Fusion-Task-Id: FN-6704 Fusion-Task-Lineage: f079fecb-eade-4318-bc55-23aa48097b4a
This commit is contained in:
5
.changeset/fn-6704-command-center-loc.md
Normal file
5
.changeset/fn-6704-command-center-loc.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Populate Command Center Productivity Lines changed from merge-time commit association diff stats when available.
|
||||
@@ -855,7 +855,7 @@ Operator setup + troubleshooting guide: **[Remote Access runbook](./remote-acces
|
||||
Key server capabilities:
|
||||
- REST APIs for tasks, git, GitHub, agents, missions, planning, automations/routines, settings
|
||||
- System stats snapshot and vitest process controls APIs (`GET /api/system-stats`, `POST /api/kill-vitest`) exposing dashboard process/system telemetry (including app CPU percentage and host memory rendered as numeric values, radial gauges, and trend sparklines in the Command Center System area), task/agent aggregates, and manual vitest process termination
|
||||
- Command Center analytics APIs (`GET /api/command-center/tokens`, `/tools`, `/activity`, `/productivity`, `/team`, `/github`, `/signals`, `/live`) are project-scoped dashboard routes; `/signals` aggregates real local `incidents` rows for total/open/resolved counts, MTTR, and source/severity/status breakdowns and returns honest empty/unavailable sentinels instead of synthetic signal volume.
|
||||
- Command Center analytics APIs (`GET /api/command-center/tokens`, `/tools`, `/activity`, `/productivity`, `/team`, `/github`, `/signals`, `/live`) are project-scoped dashboard routes. `/productivity` reads Lines changed from nullable `task_commit_associations.additions`/`deletions` merge-time diff stats and keeps the unavailable sentinel when no in-range association has stats. `/signals` aggregates real local `incidents` rows for total/open/resolved counts, MTTR, and source/severity/status breakdowns and returns honest empty/unavailable sentinels instead of synthetic signal volume.
|
||||
- Remote access APIs (`/api/remote/*`) for provider config, activation, tunnel lifecycle, status, token issuance, authenticated URL generation, and QR payload generation
|
||||
- Operational runbook (prereqs/security/troubleshooting): [`docs/remote-access.md`](./remote-access.md)
|
||||
- `/api/remote/tunnel/start`, `/api/remote/tunnel/stop`, and `/api/remote/tunnel/kill-external` cover tunnel lifecycle and external funnel cleanup.
|
||||
@@ -1458,6 +1458,8 @@ Dashboard session-diff route registration (`packages/dashboard/src/routes/regist
|
||||
- `legacy` = recovered via legacy task-id/subject matching
|
||||
- `ambiguous` = manual reconciliation where historical task-id attribution could be misleading
|
||||
|
||||
Commit associations also carry optional `additions`/`deletions` shortstat counts captured by merge paths. These nullable fields are the Command Center Productivity LOC source: analytics sum additions + deletions only when at least one in-range row has stats, and preserve the `—` unavailable sentinel when all matching rows are `NULL` so unknown historical data is never rendered as `0`.
|
||||
|
||||
### Done-task files-changed sources of truth
|
||||
|
||||
Done-task file-count surfaces intentionally distinguish three data sources:
|
||||
|
||||
@@ -391,9 +391,12 @@ The `tasks.githubTracking` JSON column stores per-task GitHub tracking state (`e
|
||||
The `tasks.sourceIssueClosedAt` column (migration 122) backs `TaskSourceIssue.closedAt`, a nullable ISO-8601 timestamp for the originating external issue's real close time. Going forward, the GitHub source-issue reconciler fills it when it closes the linked issue itself or observes GitHub's `closed_at`/`closedAt` value. Historical GitHub-imported `done`/`archived` rows that still have `NULL` can be filled retroactively by the optional manual `POST /api/git/github/backfill-source-issue-closed-at` sweep, now exposed as **Backfill exact close times** in the Command Center GitHub area's Fixed by Fusion card. The sweep is idempotent, paginated, writes only real GitHub `closed_at` values, reports `scanned`/`filled`/`skipped`/`errors`, and never overwrites an existing timestamp or runs automatically. 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 `task_commit_associations.additions` and `task_commit_associations.deletions` columns (migration 123) store nullable merge-time git shortstat counts for the associated commit. Command Center Productivity uses `SUM(additions + deletions)` as the Lines changed source when at least one in-range association has non-null stats. `NULL` means stats were unknown or unavailable for that association, not zero; ranges with no non-null stats keep the unavailable `—` sentinel instead of reporting `0`.
|
||||
| `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. |
|
||||
| `activityLog` | Per-project activity/event log with timestamp/type/task indexes. |
|
||||
| `task_commit_associations` | Commit-to-task-lineage associations for canonical and legacy landed-commit attribution. Includes nullable `additions`/`deletions` diff-stat columns captured at merge time for Command Center Productivity LOC; `NULL` means stats unknown, not zero. |
|
||||
| `archivedTasks` | Archived task snapshots (compact JSON payload + archive timestamp). |
|
||||
| `automations` | Scheduled automation definitions, run state, and run history. |
|
||||
| `agents` | Agent registry/state/task assignment metadata. |
|
||||
|
||||
@@ -721,7 +721,7 @@ 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(122);
|
||||
expect(db.getSchemaVersion()).toBe(123);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -783,7 +783,7 @@ describe("schema migration", () => {
|
||||
sourceIssueUrl: "https://github.com/runfusion/fusion/issues/10",
|
||||
sourceIssueClosedAt: null,
|
||||
});
|
||||
expect(db.getSchemaVersion()).toBe(122);
|
||||
expect(db.getSchemaVersion()).toBe(123);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -816,7 +816,7 @@ describe("schema migration", () => {
|
||||
{ id: "WS-001", mode: "prompt", gateMode: "advisory" },
|
||||
{ id: "WS-002", mode: "script", gateMode: "advisory" },
|
||||
]);
|
||||
expect(db.getSchemaVersion()).toBe(122);
|
||||
expect(db.getSchemaVersion()).toBe(123);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -866,7 +866,7 @@ describe("schema migration", () => {
|
||||
reviewerContextRetryCount: 0,
|
||||
reviewerFallbackRetryCount: 0,
|
||||
});
|
||||
expect(db.getSchemaVersion()).toBe(122);
|
||||
expect(db.getSchemaVersion()).toBe(123);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -895,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(122);
|
||||
expect(db.getSchemaVersion()).toBe(123);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -936,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(122);
|
||||
expect(db.getSchemaVersion()).toBe(123);
|
||||
db.close();
|
||||
});
|
||||
|
||||
@@ -970,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(122);
|
||||
expect(db.getSchemaVersion()).toBe(123);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -1007,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(122);
|
||||
expect(db.getSchemaVersion()).toBe(123);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -1068,7 +1068,7 @@ describe("schema migration", () => {
|
||||
expect(customFieldsColumn).toBeDefined();
|
||||
expect(customFieldsColumn?.dflt_value).toBe("'{}'");
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(122);
|
||||
expect(db.getSchemaVersion()).toBe(123);
|
||||
db.close();
|
||||
});
|
||||
|
||||
@@ -1106,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(122);
|
||||
expect(db.getSchemaVersion()).toBe(123);
|
||||
db.close();
|
||||
});
|
||||
|
||||
@@ -1188,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(122);
|
||||
expect(db.getSchemaVersion()).toBe(123);
|
||||
db.close();
|
||||
});
|
||||
|
||||
@@ -1220,7 +1220,7 @@ describe("schema migration", () => {
|
||||
.all() as Array<{ name: string }>;
|
||||
expect(columns.map((column) => column.name)).toContain("cliExecutorAdapterId");
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(122);
|
||||
expect(db.getSchemaVersion()).toBe(123);
|
||||
db.close();
|
||||
});
|
||||
|
||||
@@ -1230,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(122);
|
||||
expect(db.getSchemaVersion()).toBe(123);
|
||||
db.close();
|
||||
});
|
||||
|
||||
@@ -1287,20 +1287,20 @@ describe("schema migration", () => {
|
||||
.get() as { migrated_fragment_id: string | null };
|
||||
expect(stepRow.migrated_fragment_id).toBeNull();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(122);
|
||||
expect(db.getSchemaVersion()).toBe(123);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("migration 109 is idempotent on re-init", () => {
|
||||
const db = new Database(fusionDir);
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(122);
|
||||
expect(db.getSchemaVersion()).toBe(123);
|
||||
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(122);
|
||||
expect(reopened.getSchemaVersion()).toBe(123);
|
||||
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 }>;
|
||||
|
||||
@@ -334,7 +334,7 @@ describe("Database", () => {
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(122);
|
||||
expect(db.getSchemaVersion()).toBe(123);
|
||||
});
|
||||
|
||||
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(122);
|
||||
expect(db.getSchemaVersion()).toBe(123);
|
||||
});
|
||||
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(122);
|
||||
expect(db.getSchemaVersion()).toBe(123);
|
||||
|
||||
// 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(122);
|
||||
expect(db.getSchemaVersion()).toBe(123);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(122);
|
||||
expect(db.getSchemaVersion()).toBe(123);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(122);
|
||||
expect(db.getSchemaVersion()).toBe(123);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -1531,7 +1531,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(122);
|
||||
expect(db.getSchemaVersion()).toBe(123);
|
||||
|
||||
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(122);
|
||||
expect(db.getSchemaVersion()).toBe(123);
|
||||
|
||||
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(122);
|
||||
expect(db.getSchemaVersion()).toBe(123);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((col) => col.name);
|
||||
@@ -1923,7 +1923,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(122);
|
||||
expect(db.getSchemaVersion()).toBe(123);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
|
||||
expect(cols.map((col) => col.name)).toContain("attachments");
|
||||
@@ -1997,7 +1997,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(122);
|
||||
expect(db.getSchemaVersion()).toBe(123);
|
||||
|
||||
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" }]);
|
||||
@@ -2021,7 +2021,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(122);
|
||||
expect(db.getSchemaVersion()).toBe(123);
|
||||
|
||||
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" }]);
|
||||
@@ -2125,7 +2125,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29
|
||||
expect(db.getSchemaVersion()).toBe(122);
|
||||
expect(db.getSchemaVersion()).toBe(123);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -2308,6 +2308,48 @@ describe("schema migrations", () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("migration v123 adds nullable task commit association diff-stat columns", () => {
|
||||
tmpDir = makeTmpDir();
|
||||
const fusionDir = join(tmpDir, ".fusion");
|
||||
const localDb = new Database(fusionDir);
|
||||
|
||||
localDb.exec(`
|
||||
CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT);
|
||||
CREATE TABLE IF NOT EXISTS task_commit_associations (
|
||||
id TEXT PRIMARY KEY,
|
||||
taskLineageId TEXT NOT NULL,
|
||||
taskIdSnapshot TEXT NOT NULL,
|
||||
commitSha TEXT NOT NULL,
|
||||
commitSubject TEXT NOT NULL,
|
||||
authoredAt TEXT NOT NULL,
|
||||
matchedBy TEXT NOT NULL,
|
||||
confidence TEXT NOT NULL,
|
||||
note TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
UNIQUE(taskLineageId, commitSha, matchedBy)
|
||||
);
|
||||
`);
|
||||
localDb.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '122')");
|
||||
localDb.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
|
||||
localDb.exec(`INSERT INTO task_commit_associations
|
||||
(id, taskLineageId, taskIdSnapshot, commitSha, commitSubject, authoredAt, matchedBy, confidence, createdAt, updatedAt)
|
||||
VALUES ('assoc-1', 'lin-1', 'FN-6704', 'abc123', 'subject', '2026-06-19T00:00:00.000Z', 'canonical-lineage-trailer', 'canonical', '2026-06-19T00:00:00.000Z', '2026-06-19T00:00:00.000Z')`);
|
||||
|
||||
localDb.init();
|
||||
|
||||
expect(localDb.getSchemaVersion()).toBe(123);
|
||||
const columns = localDb.prepare("PRAGMA table_info(task_commit_associations)").all() as Array<{ name: string; notnull: number; dflt_value: string | null }>;
|
||||
const additions = columns.find((column) => column.name === "additions");
|
||||
const deletions = columns.find((column) => column.name === "deletions");
|
||||
expect(additions).toMatchObject({ notnull: 0, dflt_value: null });
|
||||
expect(deletions).toMatchObject({ notnull: 0, dflt_value: null });
|
||||
const row = localDb.prepare("SELECT additions, deletions FROM task_commit_associations WHERE id = 'assoc-1'").get() as { additions: number | null; deletions: number | null };
|
||||
expect(row).toEqual({ additions: null, deletions: null });
|
||||
|
||||
localDb.close();
|
||||
});
|
||||
|
||||
it("migration v74 adds tokenUsageCacheWriteTokens without data loss", () => {
|
||||
tmpDir = makeTmpDir();
|
||||
const fusionDir = join(tmpDir, ".fusion");
|
||||
@@ -2344,7 +2386,7 @@ describe("schema migrations", () => {
|
||||
|
||||
localDb.init();
|
||||
|
||||
expect(localDb.getSchemaVersion()).toBe(122);
|
||||
expect(localDb.getSchemaVersion()).toBe(123);
|
||||
const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens");
|
||||
|
||||
@@ -2655,7 +2697,7 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(fusionDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(122);
|
||||
expect(db.getSchemaVersion()).toBe(123);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
@@ -2809,7 +2851,7 @@ describe("migration v77 task token budget columns", () => {
|
||||
|
||||
migrated = new Database(fusion);
|
||||
migrated.init();
|
||||
expect(migrated.getSchemaVersion()).toBe(122);
|
||||
expect(migrated.getSchemaVersion()).toBe(123);
|
||||
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);
|
||||
@@ -2840,7 +2882,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => {
|
||||
const fresh = new Database(fusion);
|
||||
try {
|
||||
fresh.init();
|
||||
expect(fresh.getSchemaVersion()).toBe(122);
|
||||
expect(fresh.getSchemaVersion()).toBe(123);
|
||||
const names = new Set(
|
||||
(fresh.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name),
|
||||
);
|
||||
@@ -2868,7 +2910,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => {
|
||||
|
||||
migrated = new Database(fusion);
|
||||
migrated.init();
|
||||
expect(migrated.getSchemaVersion()).toBe(122);
|
||||
expect(migrated.getSchemaVersion()).toBe(123);
|
||||
const names = new Set(
|
||||
(migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name),
|
||||
);
|
||||
@@ -2894,7 +2936,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => {
|
||||
const fresh = new Database(fusion);
|
||||
try {
|
||||
fresh.init();
|
||||
expect(fresh.getSchemaVersion()).toBe(122);
|
||||
expect(fresh.getSchemaVersion()).toBe(123);
|
||||
const table = fresh
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'")
|
||||
.get() as { name: string } | undefined;
|
||||
@@ -2928,7 +2970,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => {
|
||||
|
||||
migrated = new Database(fusion);
|
||||
migrated.init();
|
||||
expect(migrated.getSchemaVersion()).toBe(122);
|
||||
expect(migrated.getSchemaVersion()).toBe(123);
|
||||
const table = migrated
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'")
|
||||
.get() as { name: string } | undefined;
|
||||
@@ -2954,7 +2996,7 @@ describe("migration v120 adds deployments + incidents tables (U13)", () => {
|
||||
const fresh = new Database(fusion);
|
||||
try {
|
||||
fresh.init();
|
||||
expect(fresh.getSchemaVersion()).toBe(122);
|
||||
expect(fresh.getSchemaVersion()).toBe(123);
|
||||
const tables = new Set(
|
||||
(
|
||||
fresh
|
||||
@@ -3007,7 +3049,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(122);
|
||||
expect(migrated.getSchemaVersion()).toBe(123);
|
||||
const tables = new Set(
|
||||
(
|
||||
migrated
|
||||
@@ -3066,7 +3108,7 @@ describe("migration v67 drops orphan project auth tables", () => {
|
||||
|
||||
migrated = new Database(fusion);
|
||||
migrated.init();
|
||||
expect(migrated.getSchemaVersion()).toBe(122);
|
||||
expect(migrated.getSchemaVersion()).toBe(123);
|
||||
const tables = migrated
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
||||
.all() as Array<{ name: string }>;
|
||||
@@ -3093,7 +3135,7 @@ describe("migration v67 drops orphan project auth tables", () => {
|
||||
|
||||
try {
|
||||
fresh.init();
|
||||
expect(fresh.getSchemaVersion()).toBe(122);
|
||||
expect(fresh.getSchemaVersion()).toBe(123);
|
||||
const tables = fresh
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
||||
.all() as Array<{ name: string }>;
|
||||
|
||||
@@ -14,13 +14,19 @@ function insertTaskWithFiles(db: Database, id: string, files: string[], updatedA
|
||||
).run(id, updatedAt, updatedAt, JSON.stringify(files));
|
||||
}
|
||||
|
||||
function insertCommit(db: Database, id: string, sha: string, authoredAt: string): void {
|
||||
function insertCommit(
|
||||
db: Database,
|
||||
id: string,
|
||||
sha: string,
|
||||
authoredAt: string,
|
||||
stats: { additions?: number | null; deletions?: number | null } = {},
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT INTO task_commit_associations
|
||||
(id, taskLineageId, taskIdSnapshot, commitSha, commitSubject, authoredAt,
|
||||
matchedBy, confidence, createdAt, updatedAt)
|
||||
VALUES (?, 'lin-1', 't-1', ?, 'subj', ?, 'canonical-lineage-trailer', 'canonical', ?, ?)`,
|
||||
).run(id, sha, authoredAt, authoredAt, authoredAt);
|
||||
matchedBy, confidence, additions, deletions, createdAt, updatedAt)
|
||||
VALUES (?, 'lin-1', 't-1', ?, 'subj', ?, 'canonical-lineage-trailer', 'canonical', ?, ?, ?, ?)`,
|
||||
).run(id, sha, authoredAt, stats.additions ?? null, stats.deletions ?? null, authoredAt, authoredAt);
|
||||
}
|
||||
|
||||
function insertPr(db: Database, id: string, createdAtMs: number): void {
|
||||
@@ -74,13 +80,44 @@ describe("productivity-analytics", () => {
|
||||
expect(result.pullRequests).toBe(2);
|
||||
});
|
||||
|
||||
it("reports LOC as unavailable (null + unavailable:true), never 0", () => {
|
||||
it("reports LOC as unavailable (null + unavailable:true), never 0 when no stats exist", () => {
|
||||
insertTaskWithFiles(db, "t1", ["src/a.ts"], "2026-03-01T00:00:00.000Z");
|
||||
insertCommit(db, "c-null", "sha-null", "2026-03-01T00:00:00.000Z");
|
||||
const result = aggregateProductivityAnalytics(db, {});
|
||||
expect(result.loc).toEqual({ value: null, unavailable: true });
|
||||
expect(result.loc.value).not.toBe(0);
|
||||
});
|
||||
|
||||
it("sums additions and deletions into LOC when commit stats exist", () => {
|
||||
insertCommit(db, "c1", "sha1", "2026-03-01T00:00:00.000Z", { additions: 10, deletions: 5 });
|
||||
insertCommit(db, "c2", "sha2", "2026-03-02T00:00:00.000Z", { additions: 3, deletions: 2 });
|
||||
insertCommit(db, "c-old", "sha-old", "2025-01-01T00:00:00.000Z", { additions: 100, deletions: 100 });
|
||||
|
||||
const result = aggregateProductivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" });
|
||||
expect(result.commits).toBe(2);
|
||||
expect(result.loc).toEqual({ value: 20, unavailable: false });
|
||||
});
|
||||
|
||||
it("keeps the LOC sentinel when in-range commit rows have only null stats", () => {
|
||||
insertCommit(db, "c1", "sha1", "2026-03-01T00:00:00.000Z");
|
||||
insertCommit(db, "c2", "sha2", "2026-03-02T00:00:00.000Z", { additions: null, deletions: null });
|
||||
|
||||
const result = aggregateProductivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" });
|
||||
expect(result.commits).toBe(2);
|
||||
expect(result.loc).toEqual({ value: null, unavailable: true });
|
||||
expect(result.loc.value).not.toBe(0);
|
||||
});
|
||||
|
||||
it("sums only valued LOC rows while allowing partial commit-stat coverage", () => {
|
||||
insertCommit(db, "c-null", "sha-null", "2026-03-01T00:00:00.000Z");
|
||||
insertCommit(db, "c-additions", "sha-additions", "2026-03-02T00:00:00.000Z", { additions: 7 });
|
||||
insertCommit(db, "c-deletions", "sha-deletions", "2026-03-03T00:00:00.000Z", { deletions: 4 });
|
||||
|
||||
const result = aggregateProductivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" });
|
||||
expect(result.commits).toBe(3);
|
||||
expect(result.loc).toEqual({ value: 11, unavailable: false });
|
||||
});
|
||||
|
||||
it("empty range returns zeroed structures, not nulls", () => {
|
||||
insertTaskWithFiles(db, "t1", ["src/a.ts"], "2026-03-01T00:00:00.000Z");
|
||||
insertCommit(db, "c1", "sha1", "2026-03-01T00:00:00.000Z");
|
||||
|
||||
@@ -35,6 +35,46 @@ describe("TaskStore", () => {
|
||||
const insertLogEntryWithTimestamp = (...args: any[]) => (harness as any).insertLogEntryWithTimestamp(...args);
|
||||
const taskDir = (taskId: string) => join(rootDir, ".fusion", "tasks", taskId);
|
||||
|
||||
describe("task commit association diff stats", () => {
|
||||
it("round-trips nullable additions and deletions without coercing unknown stats to zero", async () => {
|
||||
const withStats = await store.upsertTaskCommitAssociation({
|
||||
taskLineageId: "lineage-loc-stats",
|
||||
taskIdSnapshot: "FN-6704",
|
||||
commitSha: "abc123",
|
||||
commitSubject: "feat: capture stats",
|
||||
authoredAt: "2026-06-19T00:00:00.000Z",
|
||||
matchedBy: "canonical-lineage-trailer",
|
||||
confidence: "canonical",
|
||||
additions: 12,
|
||||
deletions: 3,
|
||||
});
|
||||
expect(withStats.additions).toBe(12);
|
||||
expect(withStats.deletions).toBe(3);
|
||||
|
||||
await store.upsertTaskCommitAssociation({
|
||||
taskLineageId: "lineage-loc-stats",
|
||||
taskIdSnapshot: "FN-6704",
|
||||
commitSha: "def456",
|
||||
commitSubject: "fix: unknown stats",
|
||||
authoredAt: "2026-06-19T01:00:00.000Z",
|
||||
matchedBy: "canonical-lineage-trailer",
|
||||
confidence: "canonical",
|
||||
});
|
||||
|
||||
const associations = await store.getTaskCommitAssociationsByLineageId("lineage-loc-stats");
|
||||
const persistedWithStats = associations.find((association) => association.commitSha === "abc123");
|
||||
const persistedUnknownStats = associations.find((association) => association.commitSha === "def456");
|
||||
expect(persistedWithStats).toMatchObject({ additions: 12, deletions: 3 });
|
||||
expect(persistedUnknownStats?.additions).toBeUndefined();
|
||||
expect(persistedUnknownStats?.deletions).toBeUndefined();
|
||||
|
||||
const rawUnknown = (store as any).db.prepare(
|
||||
`SELECT additions, deletions FROM task_commit_associations WHERE commitSha = ?`,
|
||||
).get("def456") as { additions: number | null; deletions: number | null };
|
||||
expect(rawUnknown).toEqual({ additions: null, deletions: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("upsertTask regression coverage", () => {
|
||||
it("creates tasks successfully on a fresh database schema", async () => {
|
||||
const freshRoot = makeTmpDir();
|
||||
|
||||
@@ -162,7 +162,7 @@ export function isFts5CorruptionError(error: unknown): boolean {
|
||||
|
||||
// ── Schema Definition ────────────────────────────────────────────────
|
||||
|
||||
const SCHEMA_VERSION = 122;
|
||||
const SCHEMA_VERSION = 123;
|
||||
|
||||
const TASKS_FTS_AUTOMERGE = 8;
|
||||
const TASKS_FTS_CRISISMERGE = 16;
|
||||
@@ -476,6 +476,8 @@ CREATE TABLE IF NOT EXISTS task_commit_associations (
|
||||
matchedBy TEXT NOT NULL CHECK (matchedBy IN ('canonical-lineage-trailer', 'legacy-task-id-trailer', 'legacy-subject', 'manual-reconciliation')),
|
||||
confidence TEXT NOT NULL CHECK (confidence IN ('canonical', 'legacy', 'ambiguous')),
|
||||
note TEXT,
|
||||
additions INTEGER,
|
||||
deletions INTEGER,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
UNIQUE(taskLineageId, commitSha, matchedBy)
|
||||
@@ -4965,6 +4967,16 @@ export class Database {
|
||||
});
|
||||
}
|
||||
|
||||
// Migration 123: nullable merge-time diff stats for Command Center LOC analytics.
|
||||
// FNXC:CommandCenterProductivity 2026-06-19-00:00:
|
||||
// Productivity LOC must distinguish unknown historical commit stats from real zero-line commits. Store merge-time additions/deletions as nullable columns with no default; null means stats were unavailable, not zero.
|
||||
if (version < 123) {
|
||||
this.applyMigration(123, () => {
|
||||
this.addColumnIfMissing("task_commit_associations", "additions", "INTEGER");
|
||||
this.addColumnIfMissing("task_commit_associations", "deletions", "INTEGER");
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,14 +3,15 @@ import type { Database } from "./db.js";
|
||||
/**
|
||||
* Productivity analytics: files modified (count + language distribution) from
|
||||
* `tasks.modifiedFiles`, commit associations from `task_commit_associations`,
|
||||
* pull requests from `pull_requests`, and LOC from commit diff stats.
|
||||
* pull requests from `pull_requests`, and LOC from merge-time commit diff stats.
|
||||
*
|
||||
* **LOC availability.** Fusion does not currently persist commit diff line
|
||||
* stats (the `task_commit_associations` schema has no additions/deletions
|
||||
* columns). LOC is therefore reported as the documented unavailable sentinel —
|
||||
* `{ value: null, unavailable: true }` — **never `0`**, so a missing data source
|
||||
* is never mistaken for "zero lines changed". When a diff-stats source is added,
|
||||
* fill {@link LocSummary.value} and clear `unavailable`.
|
||||
* **LOC availability.** Fusion persists nullable `additions`/`deletions` on
|
||||
* `task_commit_associations` when merge paths can capture git shortstat output.
|
||||
* LOC is reported as a real value only when at least one in-range association
|
||||
* has non-null stats. If the range has no recorded stats, the documented
|
||||
* unavailable sentinel — `{ value: null, unavailable: true }` — is preserved,
|
||||
* **never `0`**, so missing historical data is not mistaken for "zero lines
|
||||
* changed".
|
||||
*
|
||||
* Inclusivity: `from`/`to` bounds are inclusive. Tasks are filtered by
|
||||
* `updatedAt` (the last time the task — and therefore its modifiedFiles — was
|
||||
@@ -32,8 +33,8 @@ export interface LanguageCount {
|
||||
}
|
||||
|
||||
/**
|
||||
* LOC summary. `value` is null and `unavailable` true until a commit diff-stats
|
||||
* source exists — never `0`.
|
||||
* LOC summary. `value` is null and `unavailable` true when no in-range commit
|
||||
* association has diff stats — never `0` for unknown data.
|
||||
*/
|
||||
export interface LocSummary {
|
||||
value: number | null;
|
||||
@@ -51,7 +52,7 @@ export interface ProductivityAnalytics {
|
||||
commits: number;
|
||||
/** Rows in `pull_requests` in range. */
|
||||
pullRequests: number;
|
||||
/** LOC from commit diff stats — unavailable until a source exists. */
|
||||
/** LOC from commit association diff stats when at least one in-range row has stats. */
|
||||
loc: LocSummary;
|
||||
}
|
||||
|
||||
@@ -59,6 +60,13 @@ interface CountRow {
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface CommitStatsRow {
|
||||
count: number;
|
||||
additions: number | null;
|
||||
deletions: number | null;
|
||||
statsRows: number;
|
||||
}
|
||||
|
||||
interface ModifiedFilesRow {
|
||||
modifiedFiles: string | null;
|
||||
}
|
||||
@@ -73,8 +81,8 @@ function languageOf(path: string): string {
|
||||
|
||||
/**
|
||||
* Aggregate productivity metrics over a date range. Empty range yields zeroed
|
||||
* structures (not nulls); LOC is always the unavailable sentinel until a
|
||||
* diff-stats source is wired.
|
||||
* structures (not nulls); LOC remains the unavailable sentinel unless at least
|
||||
* one in-range commit association carries diff stats.
|
||||
*/
|
||||
export function aggregateProductivityAnalytics(
|
||||
db: Database,
|
||||
@@ -135,13 +143,20 @@ export function aggregateProductivityAnalytics(
|
||||
}
|
||||
const commitWhere =
|
||||
commitClauses.length > 0 ? `WHERE ${commitClauses.join(" AND ")}` : "";
|
||||
const commits = (
|
||||
db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count FROM task_commit_associations ${commitWhere}`,
|
||||
)
|
||||
.get(...commitParams) as CountRow
|
||||
).count;
|
||||
const commitStats = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
COUNT(*) AS count,
|
||||
SUM(additions) AS additions,
|
||||
SUM(deletions) AS deletions,
|
||||
COUNT(CASE WHEN additions IS NOT NULL OR deletions IS NOT NULL THEN 1 END) AS statsRows
|
||||
FROM task_commit_associations ${commitWhere}`,
|
||||
)
|
||||
.get(...commitParams) as CommitStatsRow;
|
||||
const commits = commitStats.count;
|
||||
const loc: LocSummary = commitStats.statsRows > 0
|
||||
? { value: (commitStats.additions ?? 0) + (commitStats.deletions ?? 0), unavailable: false }
|
||||
: { value: null, unavailable: true };
|
||||
|
||||
// Pull requests. `pull_requests.createdAt` is an INTEGER epoch-ms column, so
|
||||
// convert the ISO bounds to epoch ms for comparison.
|
||||
@@ -169,7 +184,6 @@ export function aggregateProductivityAnalytics(
|
||||
byLanguage,
|
||||
commits,
|
||||
pullRequests,
|
||||
// No commit diff-stats source yet — unavailable, never 0.
|
||||
loc: { value: null, unavailable: true },
|
||||
loc,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -560,6 +560,8 @@ interface TaskCommitAssociationRow {
|
||||
matchedBy: TaskCommitAssociationMatchSource;
|
||||
confidence: TaskCommitAssociationConfidence;
|
||||
note: string | null;
|
||||
additions: number | null;
|
||||
deletions: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -16202,14 +16204,16 @@ ${notificationsSection}`;
|
||||
});
|
||||
this.db.prepare(
|
||||
`INSERT INTO task_commit_associations
|
||||
(id, taskLineageId, taskIdSnapshot, commitSha, commitSubject, authoredAt, matchedBy, confidence, note, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(id, taskLineageId, taskIdSnapshot, commitSha, commitSubject, authoredAt, matchedBy, confidence, note, additions, deletions, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(taskLineageId, commitSha, matchedBy) DO UPDATE SET
|
||||
taskIdSnapshot = excluded.taskIdSnapshot,
|
||||
commitSubject = excluded.commitSubject,
|
||||
authoredAt = excluded.authoredAt,
|
||||
confidence = excluded.confidence,
|
||||
note = excluded.note,
|
||||
additions = excluded.additions,
|
||||
deletions = excluded.deletions,
|
||||
updatedAt = excluded.updatedAt`,
|
||||
).run(
|
||||
association.id,
|
||||
@@ -16221,6 +16225,8 @@ ${notificationsSection}`;
|
||||
association.matchedBy,
|
||||
association.confidence,
|
||||
association.note ?? null,
|
||||
association.additions ?? null,
|
||||
association.deletions ?? null,
|
||||
association.createdAt,
|
||||
association.updatedAt,
|
||||
);
|
||||
@@ -16231,7 +16237,12 @@ ${notificationsSection}`;
|
||||
const rows = this.db.prepare(
|
||||
`SELECT * FROM task_commit_associations WHERE taskLineageId = ? ORDER BY authoredAt DESC, createdAt DESC`,
|
||||
).all(lineageId) as TaskCommitAssociationRow[];
|
||||
return rows.map((row) => normalizeTaskCommitAssociation({ ...row, note: row.note ?? undefined }));
|
||||
return rows.map((row) => normalizeTaskCommitAssociation({
|
||||
...row,
|
||||
note: row.note ?? undefined,
|
||||
additions: row.additions ?? undefined,
|
||||
deletions: row.deletions ?? undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
async replaceLegacyTaskCommitAssociations(
|
||||
|
||||
@@ -42,6 +42,8 @@ export function normalizeTaskCommitAssociation(
|
||||
return {
|
||||
...row,
|
||||
note: row.note?.trim() || undefined,
|
||||
additions: row.additions ?? undefined,
|
||||
deletions: row.deletions ?? undefined,
|
||||
confidence: row.confidence ?? classifyTaskCommitAssociationConfidence(row.matchedBy),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4387,6 +4387,8 @@ export interface TaskCommitAssociation {
|
||||
matchedBy: TaskCommitAssociationMatchSource;
|
||||
confidence: TaskCommitAssociationConfidence;
|
||||
note?: string;
|
||||
additions?: number;
|
||||
deletions?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { execSync, spawnSync } from "node:child_process";
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import { recordCommitAssociationFromHead } from "../merger.js";
|
||||
|
||||
const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
|
||||
const describeIfGit = hasGit ? describe : describe.skip;
|
||||
|
||||
function git(repo: string, command: string): string {
|
||||
return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
||||
}
|
||||
|
||||
function makeRepo(): string {
|
||||
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-commit-assoc-"));
|
||||
git(repo, "git init -b main");
|
||||
git(repo, "git config user.email fusion@example.com");
|
||||
git(repo, "git config user.name Fusion");
|
||||
writeFileSync(join(repo, "file.txt"), "one\ntwo\n");
|
||||
git(repo, "git add file.txt");
|
||||
git(repo, "git commit -m 'initial commit'");
|
||||
writeFileSync(join(repo, "file.txt"), "one\ntwo\nthree\nfour\n");
|
||||
git(repo, "git add file.txt");
|
||||
git(repo, "git commit -m 'update file'");
|
||||
return repo;
|
||||
}
|
||||
|
||||
function makeStore(): Pick<TaskStore, "upsertTaskCommitAssociation"> {
|
||||
return {
|
||||
upsertTaskCommitAssociation: vi.fn(async (association) => ({
|
||||
id: "assoc-1",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...association,
|
||||
})),
|
||||
} as Pick<TaskStore, "upsertTaskCommitAssociation">;
|
||||
}
|
||||
|
||||
describeIfGit("recordCommitAssociationFromHead", () => {
|
||||
const cleanup: string[] = [];
|
||||
let originalPath: string | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPath !== undefined) process.env.PATH = originalPath;
|
||||
while (cleanup.length > 0) {
|
||||
rmSync(cleanup.pop()!, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("persists HEAD diff stats as additions and deletions", async () => {
|
||||
const repo = makeRepo();
|
||||
cleanup.push(repo);
|
||||
const store = makeStore();
|
||||
|
||||
await recordCommitAssociationFromHead(store as TaskStore, repo, "FN-6704", "lineage-1");
|
||||
|
||||
expect(store.upsertTaskCommitAssociation).toHaveBeenCalledWith(expect.objectContaining({
|
||||
taskLineageId: "lineage-1",
|
||||
taskIdSnapshot: "FN-6704",
|
||||
commitSubject: "update file",
|
||||
additions: 2,
|
||||
deletions: 0,
|
||||
}));
|
||||
});
|
||||
|
||||
it("persists the association without stats when shortstat capture fails", async () => {
|
||||
const repo = makeRepo();
|
||||
const fakeBin = mkdtempSync(join(tmpdir(), "fusion-fake-git-"));
|
||||
cleanup.push(repo, fakeBin);
|
||||
const realGit = execSync("command -v git", { encoding: "utf-8" }).trim();
|
||||
const fakeGit = join(fakeBin, "git");
|
||||
writeFileSync(fakeGit, `#!/bin/sh\nif [ "$1" = "show" ] && [ "$2" = "--shortstat" ]; then\n echo shortstat failed >&2\n exit 42\nfi\nexec ${realGit} "$@"\n`);
|
||||
chmodSync(fakeGit, 0o755);
|
||||
originalPath = process.env.PATH;
|
||||
process.env.PATH = `${fakeBin}:${originalPath ?? ""}`;
|
||||
const store = makeStore();
|
||||
|
||||
await recordCommitAssociationFromHead(store as TaskStore, repo, "FN-6704", "lineage-1");
|
||||
|
||||
expect(store.upsertTaskCommitAssociation).toHaveBeenCalledWith(expect.objectContaining({
|
||||
taskLineageId: "lineage-1",
|
||||
taskIdSnapshot: "FN-6704",
|
||||
commitSubject: "update file",
|
||||
additions: undefined,
|
||||
deletions: undefined,
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -1327,6 +1327,8 @@ async function finalizeMerged(
|
||||
authoredAt: mergedAt,
|
||||
matchedBy: "canonical-lineage-trailer",
|
||||
confidence: "canonical",
|
||||
additions: insertions,
|
||||
deletions,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3833,7 +3833,7 @@ async function generateAiMergeSubject(
|
||||
* is a denormalized convenience for lineage lookups, not a correctness
|
||||
* invariant, so a missed write must not block the merge.
|
||||
*/
|
||||
async function recordCommitAssociationFromHead(
|
||||
export async function recordCommitAssociationFromHead(
|
||||
store: TaskStore,
|
||||
rootDir: string,
|
||||
taskId: string,
|
||||
@@ -3857,6 +3857,17 @@ async function recordCommitAssociationFromHead(
|
||||
);
|
||||
return;
|
||||
}
|
||||
let additions: number | undefined;
|
||||
let deletions: number | undefined;
|
||||
try {
|
||||
const shortstat = (await execAsync("git show --shortstat --format= HEAD", { cwd: rootDir })).stdout;
|
||||
const parsed = parseShortstatSummary(shortstat);
|
||||
additions = parsed.insertions;
|
||||
deletions = parsed.deletions;
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
mergerLog.warn(`${taskId}: commit-association diff stats unavailable; persisting lineage without LOC stats (${message})`);
|
||||
}
|
||||
await store.upsertTaskCommitAssociation({
|
||||
taskLineageId: lineageId,
|
||||
taskIdSnapshot: taskId,
|
||||
@@ -3865,6 +3876,8 @@ async function recordCommitAssociationFromHead(
|
||||
authoredAt,
|
||||
matchedBy: "canonical-lineage-trailer",
|
||||
confidence: "canonical",
|
||||
additions,
|
||||
deletions,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10385,6 +10398,8 @@ export async function aiMergeTask(
|
||||
authoredAt: mergeDetails.mergedAt ?? new Date().toISOString(),
|
||||
matchedBy: "canonical-lineage-trailer",
|
||||
confidence: "canonical",
|
||||
additions: mergeDetails.insertions,
|
||||
deletions: mergeDetails.deletions,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user