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:
7
.changeset/issue-analytics-renamed-lanes.md
Normal file
7
.changeset/issue-analytics-renamed-lanes.md
Normal 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'`.
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user