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>
335 lines
13 KiB
TypeScript
335 lines
13 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:CommandCenterGithub 2026-06-18-00:00:
|
|
* Command Center GitHub issue analytics must derive filed/fixed counts only from the project-scoped local task store. "Filed" means a task has `githubTracking.issue`; "fixed" means an imported GitHub source issue task is currently in the `done` column. Fixed trends use the exact persisted `sourceIssueClosedAt` when available, fall back to the `updatedAt` completion approximation only when it is absent, and never fabricate a close date.
|
|
*
|
|
* FNXC:CommandCenterGithub 2026-06-21-00:00:
|
|
* Resolved issue details expose one local task-store row for every in-range fixed GitHub source issue so the Command Center can show which source issues were completed. `resolvedAtExact` is true only when the persisted `sourceIssueClosedAt` provided the timestamp; false means the row used the same `updatedAt` approximation as the fixed aggregate.
|
|
*/
|
|
|
|
export interface GithubIssueAnalyticsQuery {
|
|
/** ISO-8601 lower bound (inclusive). */
|
|
from?: string;
|
|
/** ISO-8601 upper bound (inclusive). */
|
|
to?: string;
|
|
}
|
|
|
|
export interface GithubIssueDailyPoint {
|
|
/** UTC date, `YYYY-MM-DD`. */
|
|
date: string;
|
|
/** Fusion-created GitHub issues filed on this date. */
|
|
filed: number;
|
|
/** Imported GitHub issue tasks completed on this date. */
|
|
fixed: number;
|
|
}
|
|
|
|
export interface GithubIssueRepoBreakdown {
|
|
/** Repository key, usually `owner/repo`; `(unknown)` when historical data lacks it. */
|
|
repo: string;
|
|
filed: number;
|
|
fixed: number;
|
|
}
|
|
|
|
export interface GithubResolvedIssue {
|
|
/** Fusion task that resolved the imported GitHub source issue. */
|
|
taskId: string;
|
|
/** Fusion task title at aggregation time. */
|
|
taskTitle: string;
|
|
/** Repository key, usually `owner/repo`; `(unknown)` when historical data lacks it. */
|
|
repo: string;
|
|
/** GitHub issue number when the imported source issue stored one. */
|
|
issueNumber: number | null;
|
|
/** Source GitHub issue 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 GithubIssueAnalytics {
|
|
from: string | null;
|
|
to: string | null;
|
|
/** Fusion-created GitHub issues in range. Undated tracked issues are included because no date can be honestly inferred. */
|
|
filed: number;
|
|
/** Imported GitHub issue 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: GithubIssueDailyPoint[];
|
|
/** Filed/fixed counts grouped by repository, descending by total activity. */
|
|
byRepo: GithubIssueRepoBreakdown[];
|
|
/** Imported GitHub source issues completed in range, most-recently resolved first. */
|
|
resolved: GithubResolvedIssue[];
|
|
}
|
|
|
|
interface GithubTrackingRow {
|
|
githubTracking: 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 TrackedIssueLike {
|
|
number?: unknown;
|
|
owner?: unknown;
|
|
repo?: unknown;
|
|
createdAt?: unknown;
|
|
}
|
|
|
|
interface GithubTrackingLike {
|
|
issue?: TrackedIssueLike;
|
|
}
|
|
|
|
function isInRange(iso: string, query: GithubIssueAnalyticsQuery): 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 repoFromIssue(issue: TrackedIssueLike): string {
|
|
const owner = typeof issue.owner === "string" ? issue.owner.trim() : "";
|
|
const repo = typeof issue.repo === "string" ? issue.repo.trim() : "";
|
|
if (owner && repo) return `${owner}/${repo}`;
|
|
if (repo) return repo;
|
|
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 addRepo(
|
|
byRepo: Map<string, { filed: number; fixed: number }>,
|
|
repo: string,
|
|
kind: "filed" | "fixed",
|
|
): void {
|
|
const current = byRepo.get(repo) ?? { filed: 0, fixed: 0 };
|
|
current[kind] += 1;
|
|
byRepo.set(repo, current);
|
|
}
|
|
|
|
/**
|
|
* Aggregate locally persisted GitHub issue analytics for the Command Center.
|
|
* Empty ranges return zeroed structures, never null collections. Bounds are
|
|
* inclusive. Malformed historical `githubTracking` JSON is ignored rather than
|
|
* failing the entire analytics request.
|
|
*
|
|
* FNXC:PostgresCommandCenterAnalytics 2026-06-28-09:30:
|
|
* 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 (github_tracking is jsonb — already parsed — and the
|
|
* source_issue_* columns are snake_case); the sync SQLite branch is unchanged.
|
|
*/
|
|
export async function aggregateGithubIssueAnalytics(
|
|
dbOrLayer: Database | AsyncDataLayer,
|
|
query: GithubIssueAnalyticsQuery = {},
|
|
/*
|
|
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 github 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<GithubIssueAnalytics> {
|
|
if ("ping" in dbOrLayer) {
|
|
return aggregateGithubIssueAnalyticsAsync(dbOrLayer, query, laneStore);
|
|
}
|
|
const db = dbOrLayer as Database;
|
|
|
|
const filedRows = db
|
|
.prepare(
|
|
"SELECT githubTracking FROM tasks WHERE githubTracking IS NOT NULL AND githubTracking NOT IN ('', '{}')",
|
|
)
|
|
.all() as GithubTrackingRow[];
|
|
|
|
// Sync rows store githubTracking as a JSON string; parse (skip malformed).
|
|
const filedTrackings: GithubTrackingLike[] = [];
|
|
for (const row of filedRows) {
|
|
if (!row.githubTracking) continue;
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(row.githubTracking);
|
|
} catch {
|
|
continue;
|
|
}
|
|
filedTrackings.push(parsed as GithubTrackingLike);
|
|
}
|
|
|
|
const fixedRows = db
|
|
.prepare(
|
|
`SELECT id, title, sourceIssueRepository, sourceIssueNumber, sourceIssueUrl, sourceIssueClosedAt, updatedAt FROM tasks WHERE sourceIssueProvider = 'github' AND "column" = 'done'`,
|
|
)
|
|
.all() as FixedIssueRow[];
|
|
|
|
return buildGithubIssueAnalytics(filedTrackings, fixedRows, query);
|
|
}
|
|
|
|
/**
|
|
* FNXC:PostgresCommandCenterAnalytics 2026-06-28-09:30:
|
|
* PostgreSQL fetch path for {@link aggregateGithubIssueAnalytics}. github_tracking
|
|
* is jsonb (postgres-js returns it already parsed, so no JSON.parse), and the
|
|
* `github_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 aggregateGithubIssueAnalyticsAsync(
|
|
layer: AsyncDataLayer,
|
|
query: GithubIssueAnalyticsQuery,
|
|
laneStore?: ProjectLaneVocabularyStore,
|
|
): Promise<GithubIssueAnalytics> {
|
|
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 github_tracking AS "githubTracking" FROM project.tasks
|
|
WHERE github_tracking IS NOT NULL AND github_tracking::text <> '{}'`,
|
|
)) as Array<{ githubTracking: unknown }>;
|
|
const filedTrackings: GithubTrackingLike[] = [];
|
|
for (const row of filedRaw) {
|
|
if (row.githubTracking == null) continue;
|
|
filedTrackings.push(row.githubTracking as GithubTrackingLike);
|
|
}
|
|
|
|
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 = 'github' 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 buildGithubIssueAnalytics(filedTrackings, fixedRows, query);
|
|
}
|
|
|
|
/**
|
|
* FNXC:PostgresCommandCenterAnalytics 2026-06-28-09:30:
|
|
* Pure GitHub-issue aggregation shared by the sync (SQLite) and async
|
|
* (PostgreSQL) fetch paths. Takes already-parsed `githubTracking` objects and
|
|
* the fixed-issue rows so both backends produce identical filed/fixed/daily/
|
|
* byRepo/resolved shapes.
|
|
*/
|
|
function buildGithubIssueAnalytics(
|
|
filedTrackings: GithubTrackingLike[],
|
|
fixedRows: FixedIssueRow[],
|
|
query: GithubIssueAnalyticsQuery,
|
|
): GithubIssueAnalytics {
|
|
const daily = new Map<string, { filed: number; fixed: number }>();
|
|
const byRepo = new Map<string, { filed: number; fixed: number }>();
|
|
|
|
let filed = 0;
|
|
for (const tracking of filedTrackings) {
|
|
const issue = tracking.issue;
|
|
if (!issue || typeof issue.number !== "number" || !Number.isFinite(issue.number)) continue;
|
|
|
|
const createdAt = typeof issue.createdAt === "string" ? issue.createdAt : undefined;
|
|
const hasUsableDate = createdAt !== undefined && dayKey(createdAt) !== null;
|
|
if (hasUsableDate && !isInRange(createdAt, query)) continue;
|
|
|
|
filed += 1;
|
|
const repo = repoFromIssue(issue);
|
|
addRepo(byRepo, repo, "filed");
|
|
if (hasUsableDate && createdAt !== undefined) {
|
|
const day = dayKey(createdAt);
|
|
if (day !== null) addDaily(daily, day, "filed");
|
|
}
|
|
}
|
|
|
|
let fixed = 0;
|
|
const resolved: GithubResolvedIssue[] = [];
|
|
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 repo = row.sourceIssueRepository?.trim() || "(unknown)";
|
|
addRepo(byRepo, repo, "fixed");
|
|
const day = dayKey(fixedDate);
|
|
if (day !== null) addDaily(daily, day, "fixed");
|
|
resolved.push({
|
|
taskId: row.id,
|
|
taskTitle: row.title ?? "",
|
|
repo,
|
|
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)),
|
|
byRepo: [...byRepo.entries()]
|
|
.map(([repo, counts]) => ({ repo, filed: counts.filed, fixed: counts.fixed }))
|
|
.sort((a, b) => {
|
|
const total = b.filed + b.fixed - (a.filed + a.fixed);
|
|
return total !== 0 ? total : a.repo.localeCompare(b.repo);
|
|
}),
|
|
resolved,
|
|
};
|
|
}
|