diff --git a/.changeset/issue-analytics-renamed-lanes.md b/.changeset/issue-analytics-renamed-lanes.md new file mode 100644 index 0000000000..e4a231a6ed --- /dev/null +++ b/.changeset/issue-analytics-renamed-lanes.md @@ -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'`. diff --git a/packages/core/src/__tests__/postgres/issue-analytics-renamed-lanes.pg.test.ts b/packages/core/src/__tests__/postgres/issue-analytics-renamed-lanes.pg.test.ts new file mode 100644 index 0000000000..041d5a9135 --- /dev/null +++ b/packages/core/src/__tests__/postgres/issue-analytics-renamed-lanes.pg.test.ts @@ -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 { + const RENAME: Record = { + 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 { + 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); + }); + } +}); diff --git a/packages/core/src/github-issue-analytics.ts b/packages/core/src/github-issue-analytics.ts index 87640cf3c0..892fa3053c 100644 --- a/packages/core/src/github-issue-analytics.ts +++ b/packages/core/src/github-issue-analytics.ts @@ -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 { 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 { + 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>; const fixedRows: FixedIssueRow[] = fixedRaw.map((r) => ({ id: String(r.id), diff --git a/packages/core/src/gitlab-issue-analytics.ts b/packages/core/src/gitlab-issue-analytics.ts index aead26db3b..edd23e2de7 100644 --- a/packages/core/src/gitlab-issue-analytics.ts +++ b/packages/core/src/gitlab-issue-analytics.ts @@ -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 { 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 { + 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>; const fixedRows: FixedIssueRow[] = fixedRaw.map((r) => ({ id: String(r.id), diff --git a/packages/dashboard/src/routes/register-command-center-routes.ts b/packages/dashboard/src/routes/register-command-center-routes.ts index cf45a02e3a..fd2387e56e 100644 --- a/packages/dashboard/src/routes/register-command-center-routes.ts +++ b/packages/dashboard/src/routes/register-command-center-routes.ts @@ -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; diff --git a/scripts/lib/sql-column-literals-baseline.json b/scripts/lib/sql-column-literals-baseline.json index 4f96a88639..43890a1b3a 100644 --- a/scripts/lib/sql-column-literals-baseline.json +++ b/scripts/lib/sql-column-literals-baseline.json @@ -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,