Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 54 additions & 7 deletions apps/api/src/jira/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@
* 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";
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";
Expand Down Expand Up @@ -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<Map<string, string>> {
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<string, string>();
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,
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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);
Expand Down
60 changes: 52 additions & 8 deletions apps/api/src/storage/task-board-columns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,27 +43,71 @@ 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,
title: column.title,
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,
Expand Down
42 changes: 42 additions & 0 deletions apps/api/src/tools/task-board/board-handler.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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. */
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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(
Expand Down
Loading