Files
fusion/packages/core/src/gitlab-issue-analytics.ts
gsxdsm 890e1f87e7 fix(core): issue panels reported nothing fixed on a renamed board (#2871)
Fourth and last of the lane-bound analytics sites from #2839, after
#2864, #2866 and #2870.

## The defect

`aggregateGithubIssueAnalytics` and its GitLab twin filtered their
resolved-issue query on `"column" = 'done'`. On a renamed board that
matches nothing, so `fixed` is **zero**, the resolved-issue list is
empty, and `net` reports every filed issue as still outstanding — while
the team closes issues all week. Nothing errors.

Same fix as the previous three: resolve per **project** via
`resolveProjectColumnsForRoles`, bind an `IN` list, thread the store
from each Command Center caller so the parameters have suppliers
immediately.

## Both providers in one change, deliberately

These two files are **copies** — same query, only the provider literal
differs — and a copy is exactly what gets half-fixed. Converting one and
not the other type-checks, passes that provider's test, and leaves the
second silently broken with no signal anywhere. The suite runs every
case against both, so the pair cannot drift.

## Measured

Reverted, exactly the two renamed cases fail — **one per provider** —
while both default-vocabulary controls, both WIP-lane negatives, and
both omitted-store legacy cases stay green:

```
✓ github: default vocabulary counts a resolved issue
× github: renamed vocabulary counts a resolved issue
✓ github: renamed vocabulary does NOT count an issue still in the WIP lane
✓ github: without a lane store, the legacy id still answers
✓ gitlab: default vocabulary counts a resolved issue
× gitlab: renamed vocabulary counts a resolved issue
✓ gitlab: renamed vocabulary does NOT count an issue still in the WIP lane
✓ gitlab: without a lane store, the legacy id still answers
  Tests  2 failed | 6 passed (8)
```

That the failures are symmetric is itself the check on the copy-paste
risk.

## Scope

The sync SQLite arms keep their literals: they throw in backend mode and
have no production caller, the same dead-arm conclusion as
`cleanupStaleMergeQueueRowsImpl` on #2839.

## Verification

`pnpm test:gate` green · Command Center + GitLab issue analytics suites
10/10 · `tsc` core 0, dashboard 0 · lint 0 · changeset included.

---

**This closes the lane-bound half of #2839.** All 14 sites the
hand-review identified as genuinely vocabulary-bound are now converted
across four PRs. What remains there is the 11 `!= 'archived'`
exclusions, which are probably correct as literals — archiving writes
`task.column = 'archived'` unconditionally as a state rather than a lane
— plus one dead SQLite arm. Those need per-site judgment, not
conversion.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 16:53:12 -07:00

334 lines
12 KiB
TypeScript

import { sql } from "drizzle-orm";
import type { Database } from "./db.js";
import { resolveProjectColumnsForRoles, type ProjectLaneVocabularyStore } from "./project-lane-vocabulary.js";
import type { AsyncDataLayer } from "./postgres/data-layer.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.
* Empty ranges return zeroed structures, never null collections. Bounds are
* inclusive. Malformed historical `gitlabTracking` JSON is ignored rather than
* failing the entire analytics request.
*
* FNXC:PostgresCutover 2026-07-04-00:00:
* Now accepts a `Database | AsyncDataLayer` and is async. In backend
* (PostgreSQL) mode it branches on `"ping" in dbOrLayer` and reads the real
* `project.tasks` rows (gitlab_tracking is jsonb — already parsed — and the
* source_issue_* columns are snake_case); the sync SQLite branch is unchanged.
* Mirrors aggregateGithubIssueAnalytics.
*/
export async function aggregateGitlabIssueAnalytics(
dbOrLayer: Database | AsyncDataLayer,
query: GitlabIssueAnalyticsQuery = {},
/*
FNXC:WorkflowResolvedColumns 2026-07-30-20:10:
The store, used ONLY to resolve which columns carry the `complete` trait.
The resolved-issue query filtered on `"column" = 'done'`, an id inside a SQL string that the
lifecycle census cannot see. On a renamed board it matches nothing, so the gitlab panel reports
zero issues resolved and an empty resolved-issue list while the team closes issues all week — and
the close-rate figure derived from it reads 0%.
Resolved per PROJECT: this aggregates a whole project, so the union of the complete columns across
its workflows is the right set and a bound IN list is enough.
Omitted, the legacy id answers, so an unconverted caller is byte-identical.
*/
laneStore?: ProjectLaneVocabularyStore,
): Promise<GitlabIssueAnalytics> {
if ("ping" in dbOrLayer) {
return aggregateGitlabIssueAnalyticsAsync(dbOrLayer, query, laneStore);
}
const db = dbOrLayer as Database;
const filedRows = db
.prepare(
"SELECT gitlabTracking FROM tasks WHERE gitlabTracking IS NOT NULL AND gitlabTracking NOT IN ('', '{}')",
)
.all() as GitlabTrackingRow[];
// Sync rows store gitlabTracking as a JSON string; parse (skip malformed).
const filedTrackings: GitlabTrackingLike[] = [];
for (const row of filedRows) {
if (!row.gitlabTracking) continue;
let parsed: unknown;
try {
parsed = JSON.parse(row.gitlabTracking);
} catch {
continue;
}
filedTrackings.push(parsed as GitlabTrackingLike);
}
const fixedRows = db
.prepare(
`SELECT id, title, sourceIssueRepository, sourceIssueNumber, sourceIssueUrl, sourceIssueClosedAt, updatedAt FROM tasks WHERE sourceIssueProvider = 'gitlab' AND "column" = 'done'`,
)
.all() as FixedIssueRow[];
return buildGitlabIssueAnalytics(filedTrackings, fixedRows, query);
}
/**
* FNXC:PostgresCutover 2026-07-04-00:00:
* PostgreSQL fetch path for {@link aggregateGitlabIssueAnalytics}. gitlab_tracking
* is jsonb (postgres-js returns it already parsed, so no JSON.parse), and the
* `gitlab_tracking::text <> '{}'` predicate mirrors the sync `NOT IN ('', '{}')`
* empty-object skip. Fixed-issue columns are aliased back to their camelCase
* row shape; source_issue_number coerces to number|null.
*/
async function aggregateGitlabIssueAnalyticsAsync(
layer: AsyncDataLayer,
query: GitlabIssueAnalyticsQuery,
laneStore?: ProjectLaneVocabularyStore,
): Promise<GitlabIssueAnalytics> {
const completeLanes = laneStore
? [...await resolveProjectColumnsForRoles(laneStore, ["complete"])]
: ["done"];
/* An IN list of bound parameters, not `= ANY(${array})`: drizzle expands a JS array in a template
into a tuple, which PostgreSQL rejects for ANY. Each id stays a parameter. */
const completeIn = sql.join(completeLanes.map((lane) => sql`${lane}`), sql`, `);
const filedRaw = (await layer.db.execute(
sql`SELECT gitlab_tracking AS "gitlabTracking" FROM project.tasks
WHERE gitlab_tracking IS NOT NULL AND gitlab_tracking::text <> '{}'`,
)) as Array<{ gitlabTracking: unknown }>;
const filedTrackings: GitlabTrackingLike[] = [];
for (const row of filedRaw) {
if (row.gitlabTracking == null) continue;
filedTrackings.push(row.gitlabTracking as GitlabTrackingLike);
}
const fixedRaw = (await layer.db.execute(
sql`SELECT
id,
title,
source_issue_repository AS "sourceIssueRepository",
source_issue_number AS "sourceIssueNumber",
source_issue_url AS "sourceIssueUrl",
source_issue_closed_at AS "sourceIssueClosedAt",
updated_at AS "updatedAt"
FROM project.tasks
WHERE source_issue_provider = 'gitlab' AND "column" IN (${completeIn})`,
)) as Array<Record<string, unknown>>;
const fixedRows: FixedIssueRow[] = fixedRaw.map((r) => ({
id: String(r.id),
title: (r.title as string | null) ?? null,
sourceIssueRepository: (r.sourceIssueRepository as string | null) ?? null,
sourceIssueNumber: r.sourceIssueNumber == null ? null : Number(r.sourceIssueNumber),
sourceIssueUrl: (r.sourceIssueUrl as string | null) ?? null,
sourceIssueClosedAt: (r.sourceIssueClosedAt as string | null) ?? null,
updatedAt: (r.updatedAt as string | null) ?? null,
}));
return buildGitlabIssueAnalytics(filedTrackings, fixedRows, query);
}
/**
* FNXC:PostgresCutover 2026-07-04-00:00:
* Pure GitLab-issue aggregation shared by the sync (SQLite) and async
* (PostgreSQL) fetch paths. Takes already-parsed `gitlabTracking` objects and
* the fixed-issue rows so both backends produce identical filed/fixed/daily/
* byProject/resolved shapes.
*/
function buildGitlabIssueAnalytics(
filedTrackings: GitlabTrackingLike[],
fixedRows: FixedIssueRow[],
query: GitlabIssueAnalyticsQuery,
): GitlabIssueAnalytics {
const daily = new Map<string, { filed: number; fixed: number }>();
const byProject = new Map<string, { filed: number; fixed: number }>();
let filed = 0;
for (const tracking of filedTrackings) {
const item = tracking.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");
}
}
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,
};
}