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>
This commit is contained in:
gsxdsm
2026-07-30 16:53:12 -07:00
committed by GitHub
parent 216632bd3a
commit 890e1f87e7
6 changed files with 203 additions and 8 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: GitHub and GitLab issue panels now count resolved issues on renamed boards.
category: fix
dev: `aggregateGithubIssueAnalytics` and `aggregateGitlabIssueAnalytics` take an optional lane store and resolve the complete columns via `resolveProjectColumnsForRoles`; their resolved-issue queries previously filtered on the literal `'done'`.

View File

@@ -0,0 +1,138 @@
/*
FNXC:WorkflowResolvedColumns 2026-07-30-20:30 (issue panels reported nothing fixed on a renamed board):
`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.
WHY NO EXISTING CHECK SAW IT. The lifecycle census parses TypeScript comparisons; the id lives inside
a SQL string. The sweep that converted these files' TypeScript guards left the queries alone and both
files scored as converted.
BOTH PROVIDERS ARE COVERED because they are copies, and a copy is exactly what gets half-fixed. The
two files carry the same query with only the provider literal differing, so a change applied to one
and not the other type-checks, passes that provider's test, and leaves the other silently broken.
*/
import { it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest";
import { sql } from "drizzle-orm";
import {
pgDescribe,
createSharedPgTaskStoreTestHarness,
type SharedPgTaskStoreHarness,
} from "../../__test-utils__/pg-test-harness.js";
import { aggregateGithubIssueAnalytics } from "../../github-issue-analytics.js";
import { aggregateGitlabIssueAnalytics } from "../../gitlab-issue-analytics.js";
import { BUILTIN_CODING_WORKFLOW_IR } from "../../index.js";
const IN_RANGE = "2026-06-15T12:00:00.000Z";
const RANGE = { from: "2026-06-01T00:00:00.000Z", to: "2026-06-30T23:59:59.999Z" };
pgDescribe("issue analytics under a renamed board vocabulary", () => {
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
prefix: "fusion_issue_analytics_lanes",
});
beforeAll(h.beforeAll);
beforeEach(h.beforeEach);
afterEach(h.afterEach);
afterAll(h.afterAll);
async function seedRenamedWorkflow(): Promise<void> {
const RENAME: Record<string, string> = {
todo: "drafting",
"in-progress": "building",
"in-review": "checking",
done: "shipped",
};
const rename = (id: string | undefined) => (id && RENAME[id]) ?? id;
const ir = JSON.parse(JSON.stringify(BUILTIN_CODING_WORKFLOW_IR)) as {
id: string;
nodes?: { column?: string }[];
columns?: { id: string }[];
};
ir.id = "custom:renamed-issue-analytics";
for (const node of ir.nodes ?? []) node.column = rename(node.column);
for (const column of ir.columns ?? []) column.id = rename(column.id) as string;
const ids = (ir.columns ?? []).map((column) => column.id);
expect(ids).toContain("shipped");
expect(ids).not.toContain("done");
await h.store().createWorkflowDefinition({ name: "Renamed", kind: "workflow", ir } as never);
}
/** An imported issue task resting in `lane`, closed inside the query range. */
async function seedResolvedIssue(provider: "github" | "gitlab", lane: string): Promise<void> {
const store = h.store();
const id = `KB-${provider.toUpperCase()}`;
await store.createTaskWithReservedId(
{ description: id, column: "todo" },
{ taskId: id, createdAt: IN_RANGE, updatedAt: IN_RANGE, applyDefaultWorkflowSteps: false },
);
/* Seeded directly: these provider-tracking columns are not settable through updateTask, and the
query keys on source_issue_closed_at falling inside the range. */
await h.adminDb().execute(sql`
UPDATE project.tasks
SET "column" = ${lane},
source_issue_provider = ${provider},
source_issue_repository = 'acme/widgets',
source_issue_number = 42,
source_issue_url = 'https://example.invalid/42',
source_issue_closed_at = ${IN_RANGE},
updated_at = ${IN_RANGE}
WHERE id = ${id}`);
store.taskCache.delete(id);
}
const layer = () => Object.assign(h.layer(), { projectId: "p1" });
/* Both providers, both vocabularies. The renamed rows are the defect; the default rows are the
control that proves a generally broken aggregator cannot hide behind them. */
const CASES = [
{ provider: "github" as const, run: aggregateGithubIssueAnalytics },
{ provider: "gitlab" as const, run: aggregateGitlabIssueAnalytics },
];
for (const { provider, run } of CASES) {
it(`${provider}: default vocabulary counts a resolved issue`, async () => {
await seedResolvedIssue(provider, "done");
const result = await run(layer(), RANGE, h.store());
expect(result.fixed).toBe(1);
});
it(`${provider}: renamed vocabulary counts a resolved issue`, async () => {
await seedRenamedWorkflow();
await seedResolvedIssue(provider, "shipped");
const result = await run(layer(), RANGE, h.store());
expect(result.fixed).toBe(1);
});
/*
The paired negative: resolving real lanes must not degrade into "every column is complete". An
issue whose task is still being worked is not fixed — otherwise the panel overstates resolution,
which is worse than understating it because a plausible number invites no scrutiny.
*/
it(`${provider}: renamed vocabulary does NOT count an issue still in the WIP lane`, async () => {
await seedRenamedWorkflow();
await seedResolvedIssue(provider, "building");
const result = await run(layer(), RANGE, h.store());
expect(result.fixed).toBe(0);
});
it(`${provider}: without a lane store, the legacy id still answers`, async () => {
await seedResolvedIssue(provider, "done");
const result = await run(layer(), RANGE);
expect(result.fixed).toBe(1);
});
}
});

View File

@@ -1,5 +1,6 @@
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";
/**
@@ -149,9 +150,24 @@ function addRepo(
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);
return aggregateGithubIssueAnalyticsAsync(dbOrLayer, query, laneStore);
}
const db = dbOrLayer as Database;
@@ -194,7 +210,14 @@ export async function aggregateGithubIssueAnalytics(
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 <> '{}'`,
@@ -215,7 +238,7 @@ async function aggregateGithubIssueAnalyticsAsync(
source_issue_closed_at AS "sourceIssueClosedAt",
updated_at AS "updatedAt"
FROM project.tasks
WHERE source_issue_provider = 'github' AND "column" = 'done'`,
WHERE source_issue_provider = 'github' AND "column" IN (${completeIn})`,
)) as Array<Record<string, unknown>>;
const fixedRows: FixedIssueRow[] = fixedRaw.map((r) => ({
id: String(r.id),

View File

@@ -1,5 +1,6 @@
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";
/**
@@ -148,9 +149,24 @@ function addProject(
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);
return aggregateGitlabIssueAnalyticsAsync(dbOrLayer, query, laneStore);
}
const db = dbOrLayer as Database;
@@ -193,7 +209,14 @@ export async function aggregateGitlabIssueAnalytics(
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 <> '{}'`,
@@ -214,7 +237,7 @@ async function aggregateGitlabIssueAnalyticsAsync(
source_issue_closed_at AS "sourceIssueClosedAt",
updated_at AS "updatedAt"
FROM project.tasks
WHERE source_issue_provider = 'gitlab' AND "column" = 'done'`,
WHERE source_issue_provider = 'gitlab' AND "column" IN (${completeIn})`,
)) as Array<Record<string, unknown>>;
const fixedRows: FixedIssueRow[] = fixedRaw.map((r) => ({
id: String(r.id),

View File

@@ -544,7 +544,9 @@ async function resolveColumnFlagsByName(
const result = await aggregateGithubIssueAnalytics(requireAsyncLayer(store, "Command Center GitHub analytics"), {
from: range.from,
to: range.to,
});
/* FNXC:WorkflowResolvedColumns 2026-07-30-20:15: store supplied so resolved-issue counts use
the board's complete lanes instead of the literal 'done'. */
}, store);
if (wantsCsv(req.query)) {
sendCsv(res, "command-center-github.csv", githubIssueAnalyticsToTable(result));
return;
@@ -569,7 +571,9 @@ async function resolveColumnFlagsByName(
const result = await aggregateGitlabIssueAnalytics(requireAsyncLayer(store, "Command Center GitLab analytics"), {
from: range.from,
to: range.to,
});
/* FNXC:WorkflowResolvedColumns 2026-07-30-20:15: store supplied so resolved-issue counts use
the board's complete lanes instead of the literal 'done'. */
}, store);
if (wantsCsv(req.query)) {
sendCsv(res, "command-center-gitlab.csv", gitlabIssueAnalyticsToTable(result));
return;

View File

@@ -1,8 +1,8 @@
{
"packages/core/src/async-mission-store-queries.ts": 1,
"packages/core/src/async-mission-store.ts": 2,
"packages/core/src/github-issue-analytics.ts": 2,
"packages/core/src/gitlab-issue-analytics.ts": 2,
"packages/core/src/github-issue-analytics.ts": 1,
"packages/core/src/gitlab-issue-analytics.ts": 1,
"packages/core/src/mission-store.ts": 1,
"packages/core/src/productivity-analytics.ts": 1,
"packages/core/src/task-store/async-archive-lineage.ts": 3,