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:
|
Key server capabilities:
|
||||||
- REST APIs for tasks, git, GitHub, agents, missions, planning, automations/routines, settings
|
- 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
|
- 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
|
- 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)
|
- 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.
|
- `/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
|
- `legacy` = recovered via legacy task-id/subject matching
|
||||||
- `ambiguous` = manual reconciliation where historical task-id attribution could be misleading
|
- `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 files-changed sources of truth
|
||||||
|
|
||||||
Done-task file-count surfaces intentionally distinguish three data sources:
|
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.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 `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). |
|
| `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. |
|
||||||
| `activityLog` | Per-project activity/event log with timestamp/type/task indexes. |
|
| `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). |
|
| `archivedTasks` | Archived task snapshots (compact JSON payload + archive timestamp). |
|
||||||
| `automations` | Scheduled automation definitions, run state, and run history. |
|
| `automations` | Scheduled automation definitions, run state, and run history. |
|
||||||
| `agents` | Agent registry/state/task assignment metadata. |
|
| `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 };
|
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(122);
|
expect(db.getSchemaVersion()).toBe(123);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
@@ -783,7 +783,7 @@ describe("schema migration", () => {
|
|||||||
sourceIssueUrl: "https://github.com/runfusion/fusion/issues/10",
|
sourceIssueUrl: "https://github.com/runfusion/fusion/issues/10",
|
||||||
sourceIssueClosedAt: null,
|
sourceIssueClosedAt: null,
|
||||||
});
|
});
|
||||||
expect(db.getSchemaVersion()).toBe(122);
|
expect(db.getSchemaVersion()).toBe(123);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
@@ -816,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(122);
|
expect(db.getSchemaVersion()).toBe(123);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
@@ -866,7 +866,7 @@ describe("schema migration", () => {
|
|||||||
reviewerContextRetryCount: 0,
|
reviewerContextRetryCount: 0,
|
||||||
reviewerFallbackRetryCount: 0,
|
reviewerFallbackRetryCount: 0,
|
||||||
});
|
});
|
||||||
expect(db.getSchemaVersion()).toBe(122);
|
expect(db.getSchemaVersion()).toBe(123);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
@@ -895,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(122);
|
expect(db.getSchemaVersion()).toBe(123);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
@@ -936,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(122);
|
expect(db.getSchemaVersion()).toBe(123);
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -970,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(122);
|
expect(db.getSchemaVersion()).toBe(123);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
@@ -1007,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(122);
|
expect(db.getSchemaVersion()).toBe(123);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
@@ -1068,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(122);
|
expect(db.getSchemaVersion()).toBe(123);
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1106,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(122);
|
expect(db.getSchemaVersion()).toBe(123);
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1188,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(122);
|
expect(db.getSchemaVersion()).toBe(123);
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1220,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(122);
|
expect(db.getSchemaVersion()).toBe(123);
|
||||||
db.close();
|
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 }>;
|
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(122);
|
expect(db.getSchemaVersion()).toBe(123);
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1287,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(122);
|
expect(db.getSchemaVersion()).toBe(123);
|
||||||
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(122);
|
expect(db.getSchemaVersion()).toBe(123);
|
||||||
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(122);
|
expect(reopened.getSchemaVersion()).toBe(123);
|
||||||
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(122);
|
expect(db.getSchemaVersion()).toBe(123);
|
||||||
});
|
});
|
||||||
|
|
||||||
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(122);
|
expect(db.getSchemaVersion()).toBe(123);
|
||||||
});
|
});
|
||||||
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(122);
|
expect(db.getSchemaVersion()).toBe(123);
|
||||||
|
|
||||||
// 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(122);
|
expect(db.getSchemaVersion()).toBe(123);
|
||||||
|
|
||||||
// Re-init should not fail
|
// Re-init should not fail
|
||||||
db.init();
|
db.init();
|
||||||
expect(db.getSchemaVersion()).toBe(122);
|
expect(db.getSchemaVersion()).toBe(123);
|
||||||
|
|
||||||
// Re-init should not fail
|
// Re-init should not fail
|
||||||
db.init();
|
db.init();
|
||||||
expect(db.getSchemaVersion()).toBe(122);
|
expect(db.getSchemaVersion()).toBe(123);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
@@ -1531,7 +1531,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
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 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(122);
|
expect(db.getSchemaVersion()).toBe(123);
|
||||||
|
|
||||||
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(122);
|
expect(db.getSchemaVersion()).toBe(123);
|
||||||
|
|
||||||
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);
|
||||||
@@ -1923,7 +1923,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
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 }>;
|
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");
|
||||||
@@ -1997,7 +1997,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
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 }>;
|
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" }]);
|
||||||
@@ -2021,7 +2021,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
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 }>;
|
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" }]);
|
||||||
@@ -2125,7 +2125,7 @@ describe("schema migrations", () => {
|
|||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
// Verify version bumped to 29
|
// Verify version bumped to 29
|
||||||
expect(db.getSchemaVersion()).toBe(122);
|
expect(db.getSchemaVersion()).toBe(123);
|
||||||
|
|
||||||
// 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 }>;
|
||||||
@@ -2308,6 +2308,48 @@ describe("schema migrations", () => {
|
|||||||
db.close();
|
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", () => {
|
it("migration v74 adds tokenUsageCacheWriteTokens without data loss", () => {
|
||||||
tmpDir = makeTmpDir();
|
tmpDir = makeTmpDir();
|
||||||
const fusionDir = join(tmpDir, ".fusion");
|
const fusionDir = join(tmpDir, ".fusion");
|
||||||
@@ -2344,7 +2386,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
localDb.init();
|
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 }>;
|
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");
|
||||||
|
|
||||||
@@ -2655,7 +2697,7 @@ describe("createDatabase factory", () => {
|
|||||||
const db = createDatabase(fusionDir);
|
const db = createDatabase(fusionDir);
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(122);
|
expect(db.getSchemaVersion()).toBe(123);
|
||||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
@@ -2809,7 +2851,7 @@ describe("migration v77 task token budget columns", () => {
|
|||||||
|
|
||||||
migrated = new Database(fusion);
|
migrated = new Database(fusion);
|
||||||
migrated.init();
|
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 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);
|
||||||
@@ -2840,7 +2882,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(122);
|
expect(fresh.getSchemaVersion()).toBe(123);
|
||||||
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),
|
||||||
);
|
);
|
||||||
@@ -2868,7 +2910,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(122);
|
expect(migrated.getSchemaVersion()).toBe(123);
|
||||||
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),
|
||||||
);
|
);
|
||||||
@@ -2894,7 +2936,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(122);
|
expect(fresh.getSchemaVersion()).toBe(123);
|
||||||
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;
|
||||||
@@ -2928,7 +2970,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(122);
|
expect(migrated.getSchemaVersion()).toBe(123);
|
||||||
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;
|
||||||
@@ -2954,7 +2996,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(122);
|
expect(fresh.getSchemaVersion()).toBe(123);
|
||||||
const tables = new Set(
|
const tables = new Set(
|
||||||
(
|
(
|
||||||
fresh
|
fresh
|
||||||
@@ -3007,7 +3049,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(122);
|
expect(migrated.getSchemaVersion()).toBe(123);
|
||||||
const tables = new Set(
|
const tables = new Set(
|
||||||
(
|
(
|
||||||
migrated
|
migrated
|
||||||
@@ -3066,7 +3108,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(122);
|
expect(migrated.getSchemaVersion()).toBe(123);
|
||||||
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 }>;
|
||||||
@@ -3093,7 +3135,7 @@ describe("migration v67 drops orphan project auth tables", () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
fresh.init();
|
fresh.init();
|
||||||
expect(fresh.getSchemaVersion()).toBe(122);
|
expect(fresh.getSchemaVersion()).toBe(123);
|
||||||
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 }>;
|
||||||
|
|||||||
@@ -14,13 +14,19 @@ function insertTaskWithFiles(db: Database, id: string, files: string[], updatedA
|
|||||||
).run(id, updatedAt, updatedAt, JSON.stringify(files));
|
).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(
|
db.prepare(
|
||||||
`INSERT INTO task_commit_associations
|
`INSERT INTO task_commit_associations
|
||||||
(id, taskLineageId, taskIdSnapshot, commitSha, commitSubject, authoredAt,
|
(id, taskLineageId, taskIdSnapshot, commitSha, commitSubject, authoredAt,
|
||||||
matchedBy, confidence, createdAt, updatedAt)
|
matchedBy, confidence, additions, deletions, createdAt, updatedAt)
|
||||||
VALUES (?, 'lin-1', 't-1', ?, 'subj', ?, 'canonical-lineage-trailer', 'canonical', ?, ?)`,
|
VALUES (?, 'lin-1', 't-1', ?, 'subj', ?, 'canonical-lineage-trailer', 'canonical', ?, ?, ?, ?)`,
|
||||||
).run(id, sha, authoredAt, authoredAt, authoredAt);
|
).run(id, sha, authoredAt, stats.additions ?? null, stats.deletions ?? null, authoredAt, authoredAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertPr(db: Database, id: string, createdAtMs: number): void {
|
function insertPr(db: Database, id: string, createdAtMs: number): void {
|
||||||
@@ -74,13 +80,44 @@ describe("productivity-analytics", () => {
|
|||||||
expect(result.pullRequests).toBe(2);
|
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");
|
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, {});
|
const result = aggregateProductivityAnalytics(db, {});
|
||||||
expect(result.loc).toEqual({ value: null, unavailable: true });
|
expect(result.loc).toEqual({ value: null, unavailable: true });
|
||||||
expect(result.loc.value).not.toBe(0);
|
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", () => {
|
it("empty range returns zeroed structures, not nulls", () => {
|
||||||
insertTaskWithFiles(db, "t1", ["src/a.ts"], "2026-03-01T00:00:00.000Z");
|
insertTaskWithFiles(db, "t1", ["src/a.ts"], "2026-03-01T00:00:00.000Z");
|
||||||
insertCommit(db, "c1", "sha1", "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 insertLogEntryWithTimestamp = (...args: any[]) => (harness as any).insertLogEntryWithTimestamp(...args);
|
||||||
const taskDir = (taskId: string) => join(rootDir, ".fusion", "tasks", taskId);
|
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", () => {
|
describe("upsertTask regression coverage", () => {
|
||||||
it("creates tasks successfully on a fresh database schema", async () => {
|
it("creates tasks successfully on a fresh database schema", async () => {
|
||||||
const freshRoot = makeTmpDir();
|
const freshRoot = makeTmpDir();
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export function isFts5CorruptionError(error: unknown): boolean {
|
|||||||
|
|
||||||
// ── Schema Definition ────────────────────────────────────────────────
|
// ── Schema Definition ────────────────────────────────────────────────
|
||||||
|
|
||||||
const SCHEMA_VERSION = 122;
|
const SCHEMA_VERSION = 123;
|
||||||
|
|
||||||
const TASKS_FTS_AUTOMERGE = 8;
|
const TASKS_FTS_AUTOMERGE = 8;
|
||||||
const TASKS_FTS_CRISISMERGE = 16;
|
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')),
|
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')),
|
confidence TEXT NOT NULL CHECK (confidence IN ('canonical', 'legacy', 'ambiguous')),
|
||||||
note TEXT,
|
note TEXT,
|
||||||
|
additions INTEGER,
|
||||||
|
deletions INTEGER,
|
||||||
createdAt TEXT NOT NULL,
|
createdAt TEXT NOT NULL,
|
||||||
updatedAt TEXT NOT NULL,
|
updatedAt TEXT NOT NULL,
|
||||||
UNIQUE(taskLineageId, commitSha, matchedBy)
|
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
|
* Productivity analytics: files modified (count + language distribution) from
|
||||||
* `tasks.modifiedFiles`, commit associations from `task_commit_associations`,
|
* `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
|
* **LOC availability.** Fusion persists nullable `additions`/`deletions` on
|
||||||
* stats (the `task_commit_associations` schema has no additions/deletions
|
* `task_commit_associations` when merge paths can capture git shortstat output.
|
||||||
* columns). LOC is therefore reported as the documented unavailable sentinel —
|
* LOC is reported as a real value only when at least one in-range association
|
||||||
* `{ value: null, unavailable: true }` — **never `0`**, so a missing data source
|
* has non-null stats. If the range has no recorded stats, the documented
|
||||||
* is never mistaken for "zero lines changed". When a diff-stats source is added,
|
* unavailable sentinel — `{ value: null, unavailable: true }` — is preserved,
|
||||||
* fill {@link LocSummary.value} and clear `unavailable`.
|
* **never `0`**, so missing historical data is not mistaken for "zero lines
|
||||||
|
* changed".
|
||||||
*
|
*
|
||||||
* Inclusivity: `from`/`to` bounds are inclusive. Tasks are filtered by
|
* Inclusivity: `from`/`to` bounds are inclusive. Tasks are filtered by
|
||||||
* `updatedAt` (the last time the task — and therefore its modifiedFiles — was
|
* `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
|
* LOC summary. `value` is null and `unavailable` true when no in-range commit
|
||||||
* source exists — never `0`.
|
* association has diff stats — never `0` for unknown data.
|
||||||
*/
|
*/
|
||||||
export interface LocSummary {
|
export interface LocSummary {
|
||||||
value: number | null;
|
value: number | null;
|
||||||
@@ -51,7 +52,7 @@ export interface ProductivityAnalytics {
|
|||||||
commits: number;
|
commits: number;
|
||||||
/** Rows in `pull_requests` in range. */
|
/** Rows in `pull_requests` in range. */
|
||||||
pullRequests: number;
|
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;
|
loc: LocSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +60,13 @@ interface CountRow {
|
|||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CommitStatsRow {
|
||||||
|
count: number;
|
||||||
|
additions: number | null;
|
||||||
|
deletions: number | null;
|
||||||
|
statsRows: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface ModifiedFilesRow {
|
interface ModifiedFilesRow {
|
||||||
modifiedFiles: string | null;
|
modifiedFiles: string | null;
|
||||||
}
|
}
|
||||||
@@ -73,8 +81,8 @@ function languageOf(path: string): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Aggregate productivity metrics over a date range. Empty range yields zeroed
|
* Aggregate productivity metrics over a date range. Empty range yields zeroed
|
||||||
* structures (not nulls); LOC is always the unavailable sentinel until a
|
* structures (not nulls); LOC remains the unavailable sentinel unless at least
|
||||||
* diff-stats source is wired.
|
* one in-range commit association carries diff stats.
|
||||||
*/
|
*/
|
||||||
export function aggregateProductivityAnalytics(
|
export function aggregateProductivityAnalytics(
|
||||||
db: Database,
|
db: Database,
|
||||||
@@ -135,13 +143,20 @@ export function aggregateProductivityAnalytics(
|
|||||||
}
|
}
|
||||||
const commitWhere =
|
const commitWhere =
|
||||||
commitClauses.length > 0 ? `WHERE ${commitClauses.join(" AND ")}` : "";
|
commitClauses.length > 0 ? `WHERE ${commitClauses.join(" AND ")}` : "";
|
||||||
const commits = (
|
const commitStats = db
|
||||||
db
|
.prepare(
|
||||||
.prepare(
|
`SELECT
|
||||||
`SELECT COUNT(*) AS count FROM task_commit_associations ${commitWhere}`,
|
COUNT(*) AS count,
|
||||||
)
|
SUM(additions) AS additions,
|
||||||
.get(...commitParams) as CountRow
|
SUM(deletions) AS deletions,
|
||||||
).count;
|
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
|
// Pull requests. `pull_requests.createdAt` is an INTEGER epoch-ms column, so
|
||||||
// convert the ISO bounds to epoch ms for comparison.
|
// convert the ISO bounds to epoch ms for comparison.
|
||||||
@@ -169,7 +184,6 @@ export function aggregateProductivityAnalytics(
|
|||||||
byLanguage,
|
byLanguage,
|
||||||
commits,
|
commits,
|
||||||
pullRequests,
|
pullRequests,
|
||||||
// No commit diff-stats source yet — unavailable, never 0.
|
loc,
|
||||||
loc: { value: null, unavailable: true },
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -560,6 +560,8 @@ interface TaskCommitAssociationRow {
|
|||||||
matchedBy: TaskCommitAssociationMatchSource;
|
matchedBy: TaskCommitAssociationMatchSource;
|
||||||
confidence: TaskCommitAssociationConfidence;
|
confidence: TaskCommitAssociationConfidence;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
|
additions: number | null;
|
||||||
|
deletions: number | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -16202,14 +16204,16 @@ ${notificationsSection}`;
|
|||||||
});
|
});
|
||||||
this.db.prepare(
|
this.db.prepare(
|
||||||
`INSERT INTO task_commit_associations
|
`INSERT INTO task_commit_associations
|
||||||
(id, taskLineageId, taskIdSnapshot, commitSha, commitSubject, authoredAt, matchedBy, confidence, note, createdAt, updatedAt)
|
(id, taskLineageId, taskIdSnapshot, commitSha, commitSubject, authoredAt, matchedBy, confidence, note, additions, deletions, createdAt, updatedAt)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(taskLineageId, commitSha, matchedBy) DO UPDATE SET
|
ON CONFLICT(taskLineageId, commitSha, matchedBy) DO UPDATE SET
|
||||||
taskIdSnapshot = excluded.taskIdSnapshot,
|
taskIdSnapshot = excluded.taskIdSnapshot,
|
||||||
commitSubject = excluded.commitSubject,
|
commitSubject = excluded.commitSubject,
|
||||||
authoredAt = excluded.authoredAt,
|
authoredAt = excluded.authoredAt,
|
||||||
confidence = excluded.confidence,
|
confidence = excluded.confidence,
|
||||||
note = excluded.note,
|
note = excluded.note,
|
||||||
|
additions = excluded.additions,
|
||||||
|
deletions = excluded.deletions,
|
||||||
updatedAt = excluded.updatedAt`,
|
updatedAt = excluded.updatedAt`,
|
||||||
).run(
|
).run(
|
||||||
association.id,
|
association.id,
|
||||||
@@ -16221,6 +16225,8 @@ ${notificationsSection}`;
|
|||||||
association.matchedBy,
|
association.matchedBy,
|
||||||
association.confidence,
|
association.confidence,
|
||||||
association.note ?? null,
|
association.note ?? null,
|
||||||
|
association.additions ?? null,
|
||||||
|
association.deletions ?? null,
|
||||||
association.createdAt,
|
association.createdAt,
|
||||||
association.updatedAt,
|
association.updatedAt,
|
||||||
);
|
);
|
||||||
@@ -16231,7 +16237,12 @@ ${notificationsSection}`;
|
|||||||
const rows = this.db.prepare(
|
const rows = this.db.prepare(
|
||||||
`SELECT * FROM task_commit_associations WHERE taskLineageId = ? ORDER BY authoredAt DESC, createdAt DESC`,
|
`SELECT * FROM task_commit_associations WHERE taskLineageId = ? ORDER BY authoredAt DESC, createdAt DESC`,
|
||||||
).all(lineageId) as TaskCommitAssociationRow[];
|
).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(
|
async replaceLegacyTaskCommitAssociations(
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export function normalizeTaskCommitAssociation(
|
|||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
note: row.note?.trim() || undefined,
|
note: row.note?.trim() || undefined,
|
||||||
|
additions: row.additions ?? undefined,
|
||||||
|
deletions: row.deletions ?? undefined,
|
||||||
confidence: row.confidence ?? classifyTaskCommitAssociationConfidence(row.matchedBy),
|
confidence: row.confidence ?? classifyTaskCommitAssociationConfidence(row.matchedBy),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4387,6 +4387,8 @@ export interface TaskCommitAssociation {
|
|||||||
matchedBy: TaskCommitAssociationMatchSource;
|
matchedBy: TaskCommitAssociationMatchSource;
|
||||||
confidence: TaskCommitAssociationConfidence;
|
confidence: TaskCommitAssociationConfidence;
|
||||||
note?: string;
|
note?: string;
|
||||||
|
additions?: number;
|
||||||
|
deletions?: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: 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,
|
authoredAt: mergedAt,
|
||||||
matchedBy: "canonical-lineage-trailer",
|
matchedBy: "canonical-lineage-trailer",
|
||||||
confidence: "canonical",
|
confidence: "canonical",
|
||||||
|
additions: insertions,
|
||||||
|
deletions,
|
||||||
}).catch(() => undefined);
|
}).catch(() => undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3833,7 +3833,7 @@ async function generateAiMergeSubject(
|
|||||||
* is a denormalized convenience for lineage lookups, not a correctness
|
* is a denormalized convenience for lineage lookups, not a correctness
|
||||||
* invariant, so a missed write must not block the merge.
|
* invariant, so a missed write must not block the merge.
|
||||||
*/
|
*/
|
||||||
async function recordCommitAssociationFromHead(
|
export async function recordCommitAssociationFromHead(
|
||||||
store: TaskStore,
|
store: TaskStore,
|
||||||
rootDir: string,
|
rootDir: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
@@ -3857,6 +3857,17 @@ async function recordCommitAssociationFromHead(
|
|||||||
);
|
);
|
||||||
return;
|
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({
|
await store.upsertTaskCommitAssociation({
|
||||||
taskLineageId: lineageId,
|
taskLineageId: lineageId,
|
||||||
taskIdSnapshot: taskId,
|
taskIdSnapshot: taskId,
|
||||||
@@ -3865,6 +3876,8 @@ async function recordCommitAssociationFromHead(
|
|||||||
authoredAt,
|
authoredAt,
|
||||||
matchedBy: "canonical-lineage-trailer",
|
matchedBy: "canonical-lineage-trailer",
|
||||||
confidence: "canonical",
|
confidence: "canonical",
|
||||||
|
additions,
|
||||||
|
deletions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10385,6 +10398,8 @@ export async function aiMergeTask(
|
|||||||
authoredAt: mergeDetails.mergedAt ?? new Date().toISOString(),
|
authoredAt: mergeDetails.mergedAt ?? new Date().toISOString(),
|
||||||
matchedBy: "canonical-lineage-trailer",
|
matchedBy: "canonical-lineage-trailer",
|
||||||
confidence: "canonical",
|
confidence: "canonical",
|
||||||
|
additions: mergeDetails.insertions,
|
||||||
|
deletions: mergeDetails.deletions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user