Files
fusion/packages/core/src/gitlab-issue-analytics.ts
gsxdsm c49a933fb0 FN-7428: add GitLab tracking coverage and reconciliation support
Add GitLab tracking persistence, analytics, and reconciliation coverage across core, dashboard, and CLI paths.

- Store GitLab tracking and closed-at metadata when importing issues and merge requests from CLI, extension tools, and dashboard routes.
- Add GitLab source-issue analytics, Command Center GitLab signal surfaces, CSV export fields, and a closed-at backfill route.
- Cover GitLab tracking storage, reconciliation, CLI imports, dashboard import UI, route behavior, and Command Center signals with focused tests.

Files changed:
 .changeset/fn-7428-gitlab-import-tracking.md       |   7 +
 .../__tests__/extension-gitlab-tracking.test.ts    | 140 ++++++++++++
 .../__tests__/task-command-gitlab-import.test.ts   |  18 +-
 packages/cli/src/commands/task.ts                  |   1 +
 packages/cli/src/extension.ts                      |   2 +-
 .../src/__tests__/gitlab-issue-analytics.test.ts   | 243 +++++++++++++++++++++
 .../__tests__/gitlab-source-issue-storage.test.ts  | 128 +++++++++++
 .../store-gitlab-tracking-reconcile.test.ts        | 125 +++++++++++
 packages/core/src/gitlab-issue-analytics.ts        | 227 +++++++++++++++++++
 packages/core/src/index.ts                         |   8 +
 packages/core/src/store.ts                         |  21 +-
 .../__tests__/GitHubImportModal.test.tsx           |  46 ++++
 .../components/__tests__/gitlabTracking.test.tsx   |  43 ++++
 .../components/command-center/CommandCenter.tsx    |   5 +
 .../components/command-center/areas/GitlabArea.tsx | 126 +++++++++++
 .../areas/__tests__/areas.gitlab-signals.test.tsx  |  83 +++++++
 .../gitlab-source-issue-reconciler.test.ts         | 187 ++++++++++++++++
 .../register-command-center-routes.test.ts         |  52 +++++
 .../__tests__/register-git-gitlab.backfill.test.ts | 116 ++++++++++
 .../dashboard/src/__tests__/routes-gitlab.test.ts  |  59 +++--
 packages/dashboard/src/command-center-csv.ts       |  21 ++
 .../src/gitlab-source-issue-reconciler.ts          | 104 +++++++++
 packages/dashboard/src/gitlab.ts                   |  16 +-
 .../src/routes/register-command-center-routes.ts   |  25 +++
 packages/dashboard/src/routes/register-gitlab.ts   |  29 +++
 25 files changed, 1800 insertions(+), 32 deletions(-)

Fusion-Task-Id: FN-7428

Fusion-Task-Lineage: 7b0ebb5c-c6e7-42a9-a339-b2dd1d4e263a

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-02 12:34:35 -07:00

228 lines
7.6 KiB
TypeScript

import type { Database } from "./db.js";
/**
* FNXC:CommandCenterGitLab 2026-07-02-00:00:
* GitLab analytics must be provider-isolated while sharing the generic sourceIssue storage columns with GitHub. Filed counts read only `gitlabTracking.item`; fixed counts read only `sourceIssueProvider = "gitlab"`, using exact `sourceIssueClosedAt` when present and `updatedAt` only as an approximation fallback.
*/
export interface GitlabIssueAnalyticsQuery {
/** ISO-8601 lower bound (inclusive). */
from?: string;
/** ISO-8601 upper bound (inclusive). */
to?: string;
}
export interface GitlabIssueDailyPoint {
/** UTC date, `YYYY-MM-DD`. */
date: string;
/** Fusion-created GitLab items filed on this date. */
filed: number;
/** Imported GitLab issue/MR tasks completed on this date. */
fixed: number;
}
export interface GitlabIssueProjectBreakdown {
/** GitLab project/group key; `(unknown)` when historical data lacks it. */
project: string;
filed: number;
fixed: number;
}
export interface GitlabResolvedIssue {
/** Fusion task that resolved the imported GitLab source item. */
taskId: string;
/** Fusion task title at aggregation time. */
taskTitle: string;
/** GitLab project/group key; `(unknown)` when historical data lacks it. */
project: string;
/** GitLab issue or merge request IID when stored. */
issueNumber: number | null;
/** Source GitLab item URL when available. */
url: string | null;
/** ISO timestamp used for range filtering and ordering. */
resolvedAt: string;
/** True when `sourceIssueClosedAt` supplied `resolvedAt`; false when `updatedAt` was used. */
resolvedAtExact: boolean;
}
export interface GitlabIssueAnalytics {
from: string | null;
to: string | null;
/** Fusion-created GitLab tracked items in range. Undated tracked items are included because no date can be honestly inferred. */
filed: number;
/** Imported GitLab source tasks currently in `done`, filtered by exact `sourceIssueClosedAt` when present with `updatedAt` fallback. */
fixed: number;
/** Filed minus fixed. */
net: number;
/** Filed/fixed counts grouped by UTC day, ascending. */
daily: GitlabIssueDailyPoint[];
/** Filed/fixed counts grouped by GitLab project/group, descending by total activity. */
byProject: GitlabIssueProjectBreakdown[];
/** Imported GitLab source items completed in range, most-recently resolved first. */
resolved: GitlabResolvedIssue[];
}
interface GitlabTrackingRow {
gitlabTracking: string | null;
}
interface FixedIssueRow {
id: string;
title: string | null;
sourceIssueRepository: string | null;
sourceIssueNumber: number | null;
sourceIssueUrl: string | null;
sourceIssueClosedAt: string | null;
updatedAt: string | null;
}
interface GitlabTrackedItemLike {
iid?: unknown;
projectPath?: unknown;
groupPath?: unknown;
projectId?: unknown;
createdAt?: unknown;
}
interface GitlabTrackingLike {
item?: GitlabTrackedItemLike;
}
function isInRange(iso: string, query: GitlabIssueAnalyticsQuery): boolean {
const t = Date.parse(iso);
if (!Number.isFinite(t)) return false;
if (query.from !== undefined && t < Date.parse(query.from)) return false;
if (query.to !== undefined && t > Date.parse(query.to)) return false;
return true;
}
function dayKey(iso: string): string | null {
const t = Date.parse(iso);
if (!Number.isFinite(t)) return null;
return new Date(t).toISOString().slice(0, 10);
}
function projectFromItem(item: GitlabTrackedItemLike): string {
const projectPath = typeof item.projectPath === "string" ? item.projectPath.trim() : "";
if (projectPath) return projectPath;
const groupPath = typeof item.groupPath === "string" ? item.groupPath.trim() : "";
if (groupPath) return groupPath;
if (typeof item.projectId === "number" && Number.isFinite(item.projectId)) return String(item.projectId);
return "(unknown)";
}
function addDaily(
daily: Map<string, { filed: number; fixed: number }>,
date: string,
kind: "filed" | "fixed",
): void {
const current = daily.get(date) ?? { filed: 0, fixed: 0 };
current[kind] += 1;
daily.set(date, current);
}
function addProject(
byProject: Map<string, { filed: number; fixed: number }>,
project: string,
kind: "filed" | "fixed",
): void {
const current = byProject.get(project) ?? { filed: 0, fixed: 0 };
current[kind] += 1;
byProject.set(project, current);
}
/**
* Aggregate locally persisted GitLab issue and merge-request analytics for the Command Center.
* Bounds are inclusive. Malformed historical `gitlabTracking` JSON is ignored rather than
* failing the entire analytics request.
*/
export function aggregateGitlabIssueAnalytics(
db: Database,
query: GitlabIssueAnalyticsQuery = {},
): GitlabIssueAnalytics {
const daily = new Map<string, { filed: number; fixed: number }>();
const byProject = new Map<string, { filed: number; fixed: number }>();
const filedRows = db
.prepare(
"SELECT gitlabTracking FROM tasks WHERE gitlabTracking IS NOT NULL AND gitlabTracking NOT IN ('', '{}')",
)
.all() as GitlabTrackingRow[];
let filed = 0;
for (const row of filedRows) {
if (!row.gitlabTracking) continue;
let parsed: unknown;
try {
parsed = JSON.parse(row.gitlabTracking);
} catch {
continue;
}
const item = (parsed as GitlabTrackingLike).item;
if (!item || typeof item.iid !== "number" || !Number.isFinite(item.iid)) continue;
const createdAt = typeof item.createdAt === "string" ? item.createdAt : undefined;
const hasUsableDate = createdAt !== undefined && dayKey(createdAt) !== null;
if (hasUsableDate && !isInRange(createdAt, query)) continue;
filed += 1;
const project = projectFromItem(item);
addProject(byProject, project, "filed");
if (hasUsableDate && createdAt !== undefined) {
const day = dayKey(createdAt);
if (day !== null) addDaily(daily, day, "filed");
}
}
const fixedRows = db
.prepare(
`SELECT id, title, sourceIssueRepository, sourceIssueNumber, sourceIssueUrl, sourceIssueClosedAt, updatedAt FROM tasks WHERE sourceIssueProvider = 'gitlab' AND "column" = 'done'`,
)
.all() as FixedIssueRow[];
let fixed = 0;
const resolved: GitlabResolvedIssue[] = [];
for (const row of fixedRows) {
const hasExactResolvedAt = row.sourceIssueClosedAt !== null;
const fixedDate = row.sourceIssueClosedAt ?? row.updatedAt;
if (fixedDate === null || !isInRange(fixedDate, query)) continue;
fixed += 1;
const project = row.sourceIssueRepository?.trim() || "(unknown)";
addProject(byProject, project, "fixed");
const day = dayKey(fixedDate);
if (day !== null) addDaily(daily, day, "fixed");
resolved.push({
taskId: row.id,
taskTitle: row.title ?? "",
project,
issueNumber: typeof row.sourceIssueNumber === "number" ? row.sourceIssueNumber : null,
url: row.sourceIssueUrl?.trim() || null,
resolvedAt: fixedDate,
resolvedAtExact: hasExactResolvedAt,
});
}
resolved.sort((a, b) => {
const byDate = Date.parse(b.resolvedAt) - Date.parse(a.resolvedAt);
return byDate !== 0 ? byDate : a.taskId.localeCompare(b.taskId);
});
return {
from: query.from ?? null,
to: query.to ?? null,
filed,
fixed,
net: filed - fixed,
daily: [...daily.entries()]
.map(([date, counts]) => ({ date, filed: counts.filed, fixed: counts.fixed }))
.sort((a, b) => a.date.localeCompare(b.date)),
byProject: [...byProject.entries()]
.map(([project, counts]) => ({ project, filed: counts.filed, fixed: counts.fixed }))
.sort((a, b) => {
const total = b.filed + b.fixed - (a.filed + a.fixed);
return total !== 0 ? total : a.project.localeCompare(b.project);
}),
resolved,
};
}