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, 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, 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(); const byProject = new Map(); 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, }; }