From f9002bb7e3571a85233276940ab292da8178c803 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Fri, 28 Aug 2026 16:57:02 -0300 Subject: [PATCH] feat(jira): mirror the board's own columns instead of asking for a mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last piece: an org whose board is its own gets its columns from Jira, under the names its team gave them, with each card in the column Jira puts it in. Both come from one call. `getBoardColumns` already returns each column with the statuses it groups — Jira knows the mapping, so it is read rather than configured. That the mapping screen ever existed is the mistake this removes: it asked a team to restate, by hand and per lane, something their tracker already knew, and it is what made our vocabulary leak into everything downstream. `replaceAll` now keeps a column the tracker dropped when it still holds cards, appended after the mirrored set. That is the obligation the foreign key created, and honouring it is better than the alternatives: RESTRICT refuses the delete, and moving someone's cards to a column Studio picked is a worse answer than showing a column their tracker no longer has. It leaves on its own once the last card is out. Studio's own board is untouched: it still reads the hand-written mapping, since its lanes are ours and Jira has no idea what they mean. --- apps/api/src/jira/sync.ts | 61 ++++++++++++++++--- apps/api/src/storage/task-board-columns.ts | 60 +++++++++++++++--- .../board-handler.integration.test.ts | 42 +++++++++++++ 3 files changed, 148 insertions(+), 15 deletions(-) diff --git a/apps/api/src/jira/sync.ts b/apps/api/src/jira/sync.ts index d831afec2d..28c88b33ce 100644 --- a/apps/api/src/jira/sync.ts +++ b/apps/api/src/jira/sync.ts @@ -24,6 +24,7 @@ * its issue is next updated in Jira. */ +import { orgFlagEnabled } from "@decocms/shared/organization/schema"; import { boardFor } from "@/tools/task-board/board-handler"; import { SUPER_AGENT_ASSIGNEE_ID } from "@decocms/shared/task-board"; import type { StudioContext } from "@/core/studio-context"; @@ -31,7 +32,6 @@ import type { OrgJiraIntegration, TaskBoardItem, TaskBoardItemPriority, - TaskBoardItemStatus, } from "@/storage/types"; import { reactToSuperAgentDelegation } from "@/tools/task-board/enqueue-super-agent"; import { emitTaskBoardUpdated } from "@/tools/task-board/run-reactions"; @@ -129,6 +129,41 @@ export async function syncJiraIntegrationSafe( * Jira's clock, so it can sit ahead of ours, and a negative window emits * `updated >= --25m` — a JQL 400 on every tick until the clocks converge. */ +/** + * Make the org's board look like its Jira board, and hand back the reverse + * index the pull needs: Jira status name → the column that groups it. + * + * Both come from the same call. Jira's board configuration already says which + * statuses live in which column, so the mapping is read rather than + * configured — and the columns Studio renders are the ones the team sees in + * Jira, under the names they gave them. + */ +async function mirrorBoardColumns( + ctx: StudioContext, + integration: OrgJiraIntegration, + boardId: string, +): Promise> { + const client = new JiraClient( + integration.siteUrl, + integration.email, + integration.apiToken, + ); + const columns = await client.getBoardColumns(boardId); + await ctx.storage.boardColumns.replaceAll( + integration.organizationId, + columns.map((column) => ({ key: column.name, title: column.name })), + ); + const index = new Map(); + for (const column of columns) { + // First column wins, as it does for the hand-written mapping: a Jira status + // in two columns would otherwise make a card's lane depend on iteration. + for (const status of column.statuses) { + if (!index.has(status)) index.set(status, column.name); + } + } + return index; +} + export function buildJql( integration: OrgJiraIntegration, scopeJql: string, @@ -497,10 +532,24 @@ async function runSync( if (!boardId) { throw new Error("No Jira board selected"); } - // One reverse index for the whole sync rather than a scan per issue. - const laneOf = laneIndex(integration.statusMapping); + const settings = await ctx.storage.organizationSettings.get( + integration.organizationId, + ); + const orgOwnedColumns = orgFlagEnabled(settings?.flags, "org_board_columns"); + + // One reverse index for the whole sync rather than a scan per issue. On a + // board the org owns it is DERIVED from the board's own configuration — + // Jira already knows which statuses each of its columns groups, so asking + // anyone to restate that by hand was the mapping screen's whole mistake. + const laneOf = orgOwnedColumns + ? await mirrorBoardColumns(ctx, integration, boardId) + : laneIndex(integration.statusMapping); if (laneOf.size === 0) { - throw new Error("No status mapping configured"); + throw new Error( + orgOwnedColumns + ? "This Jira board has no columns to mirror" + : "No status mapping configured", + ); } // First connect / scope change (migration 184) or an unfinished rescan (migration 186). @@ -566,9 +615,7 @@ async function runSync( throw new Error(`Unparseable updated on ${issue.key}`); } - const status = laneOf.get(issue.fields.status.name) as - | TaskBoardItemStatus - | undefined; + const status = laneOf.get(issue.fields.status.name); if (!status || !isCardIssue(issue)) { if (!status && isCardIssue(issue)) { unmapped.add(issue.fields.status.name); diff --git a/apps/api/src/storage/task-board-columns.ts b/apps/api/src/storage/task-board-columns.ts index a7a4dab2c7..ee7cde9853 100644 --- a/apps/api/src/storage/task-board-columns.ts +++ b/apps/api/src/storage/task-board-columns.ts @@ -43,19 +43,53 @@ export class BoardColumnStorage { return await this.db.transaction().execute(async (tx) => { const existing = await tx .selectFrom("task_board_columns") - .select(["key", "role"]) + .select(["key", "title", "role"]) .where("organization_id", "=", organizationId) .execute(); const roleOf = new Map(existing.map((r) => [r.key, r.role])); - await tx - .deleteFrom("task_board_columns") - .where("organization_id", "=", organizationId) - .execute(); + // A column the tracker dropped that still holds cards is kept, appended + // after the mirrored set. Deleting it is refused by the foreign key, and + // rightly: moving someone's cards somewhere we picked is a worse answer + // than showing a column their tracker no longer has. It disappears on the + // first sync after the last card leaves it. + const mirrored = new Set(columns.map((column) => column.key)); + const orphaned = existing.filter((row) => !mirrored.has(row.key)); + const occupied = orphaned.length + ? await tx + .selectFrom("task_board_items") + .select("status") + .distinct() + .where("organization_id", "=", organizationId) + .where( + "status", + "in", + orphaned.map((row) => row.key), + ) + .execute() + : []; + const keep = new Set(occupied.map((row) => row.status)); - if (columns.length === 0) return []; + const drop = orphaned + .filter((row) => !keep.has(row.key)) + .map((row) => row.key); + if (drop.length > 0) { + await tx + .deleteFrom("task_board_columns") + .where("organization_id", "=", organizationId) + .where("key", "in", drop) + .execute(); + } - const rows = columns.map((column, position) => ({ + const all = [ + ...columns, + ...orphaned + .filter((row) => keep.has(row.key)) + .map((row) => ({ key: row.key, title: row.title })), + ]; + if (all.length === 0) return []; + + const rows = all.map((column, position) => ({ id: `tbc_${organizationId}_${column.key}`, organization_id: organizationId, key: column.key, @@ -63,7 +97,17 @@ export class BoardColumnStorage { position, role: roleOf.get(column.key) ?? null, })); - await tx.insertInto("task_board_columns").values(rows).execute(); + await tx + .insertInto("task_board_columns") + .values(rows) + .onConflict((oc) => + oc.columns(["organization_id", "key"]).doUpdateSet((eb) => ({ + title: eb.ref("excluded.title"), + position: eb.ref("excluded.position"), + updated_at: new Date(), + })), + ) + .execute(); return rows.map(({ key, title, position, role }) => ({ key, title, diff --git a/apps/api/src/tools/task-board/board-handler.integration.test.ts b/apps/api/src/tools/task-board/board-handler.integration.test.ts index 1a547801f2..aeffd68d05 100644 --- a/apps/api/src/tools/task-board/board-handler.integration.test.ts +++ b/apps/api/src/tools/task-board/board-handler.integration.test.ts @@ -7,6 +7,8 @@ * runs an agent on every card that moves. */ +import { sql } from "kysely"; +import { TaskBoardStorage } from "@/storage/task-board"; import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { CANONICAL_COLUMN_KEYS } from "@decocms/shared/task-board"; import type { StudioDatabase } from "@/database"; @@ -23,10 +25,12 @@ import { LANE_RANK } from "./lanes"; const ORG = "org_bh_1"; const OTHER = "org_bh_2"; const ORG_M = "org_bh_m"; +const USER_M = "user_bh_m"; let database: StudioDatabase; let automations: ColumnAutomationStorage; let boardColumns: BoardColumnStorage; +let taskBoard: TaskBoardStorage; /** One pool for the file. `connectTestPgDatabase` hands back a shared instance, * so a per-describe lifecycle would close it out from under the next one. */ @@ -40,8 +44,13 @@ beforeAll(async () => { .values({ id, name: id, slug: id.replace(/_/g, "-"), createdAt: now }) .execute(); } + await sql` + INSERT INTO "user" (id, email, "emailVerified", name, "createdAt", "updatedAt") + VALUES (${USER_M}, ${"bhm@test.test"}, false, ${USER_M}, ${now}, ${now}) + `.execute(database.db); automations = new ColumnAutomationStorage(database.db); boardColumns = new BoardColumnStorage(database.db); + taskBoard = new TaskBoardStorage(database.db); }); afterAll(async () => { @@ -184,6 +193,39 @@ describe("boardHandler — a board whose columns are the org's own", () => { expect(await board().archiveColumn()).toBe(null); }); + /** + * The obligation the foreign key creates. A column the tracker dropped that + * still holds cards cannot be deleted — RESTRICT refuses — and moving those + * cards somewhere Studio picked is a worse answer than showing a column the + * tracker no longer has. It goes to the end and leaves on its own once empty. + */ + it("keeps a dropped column that still holds cards, and drops it once empty", async () => { + await boardColumns.replaceAll(ORG_M, [ + { key: "BACKLOG", title: "Backlog" }, + { key: "Retired", title: "Retired" }, + ]); + const card = await taskBoard.create({ + organizationId: ORG_M, + title: "left behind", + status: "Retired", + by: USER_M, + }); + + await boardColumns.replaceAll(ORG_M, [ + { key: "BACKLOG", title: "Backlog" }, + ]); + expect((await board().columns()).map((c) => c.key)).toEqual([ + "BACKLOG", + "Retired", + ]); + + await taskBoard.update(card.id, ORG_M, { status: "BACKLOG" }, USER_M); + await boardColumns.replaceAll(ORG_M, [ + { key: "BACKLOG", title: "Backlog" }, + ]); + expect((await board().columns()).map((c) => c.key)).toEqual(["BACKLOG"]); + }); + it("runs a rule hung on a column the tracker named", async () => { await automations.upsert(ORG_M, "Code Review", "Review it."); expect((await board().automationFor("Code Review"))?.prompt).toBe(