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
51 changes: 51 additions & 0 deletions apps/api/migrations/191-task-board-columns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { type Kysely, sql } from "kysely";

/**
* The columns of a board Studio does not own.
*
* Empty for every org that runs the board Studio ships with — those columns
* are a constant, and rows for them would be identical data that can drift out
* of agreement with the code defining them. Rows exist only once a board is
* mirrored from somewhere else, which is what `dynamic_board_columns` says.
*
* `key` is what a card's `status` holds, the same as it is for the canonical
* set. `role` is what automation keys on, and is null for most columns here:
* a column someone else named means nothing to us until someone says it does.
*/
export async function up(db: Kysely<unknown>): Promise<void> {
await db.schema
.createTable("task_board_columns")
.ifNotExists()
.addColumn("id", "text", (col) => col.primaryKey())
.addColumn("organization_id", "text", (col) => col.notNull())
.addColumn("key", "text", (col) => col.notNull())
.addColumn("title", "text", (col) => col.notNull())
.addColumn("position", "integer", (col) => col.notNull())
.addColumn("role", "text")
.addColumn("created_at", "timestamptz", (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn("updated_at", "timestamptz", (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();

await db.schema
.createIndex("task_board_columns_org_key_uniq")
.ifNotExists()
.on("task_board_columns")
.columns(["organization_id", "key"])
.unique()
.execute();

await db.schema
.createIndex("task_board_columns_org_position_idx")
.ifNotExists()
.on("task_board_columns")
.columns(["organization_id", "position"])
.execute();
}

export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema.dropTable("task_board_columns").ifExists().execute();
}
2 changes: 2 additions & 0 deletions apps/api/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ import * as migration187taskboardcommentthread from "./187-task-board-comment-th
import * as migration188invitationautoaccept from "./188-invitation-auto-accept.ts";
import * as migration189taskboardcolumnautomations from "./189-task-board-column-automations.ts";
import * as migration190taskboardreviewcyclestartedat from "./190-task-board-review-cycle-started-at.ts";
import * as migration191taskboardcolumns from "./191-task-board-columns.ts";

/**
* Core migrations for the Studio application.
Expand Down Expand Up @@ -410,6 +411,7 @@ const migrations: Record<string, Migration> = {
"189-task-board-column-automations": migration189taskboardcolumnautomations,
"190-task-board-review-cycle-started-at":
migration190taskboardreviewcyclestartedat,
"191-task-board-columns": migration191taskboardcolumns,
};

export default migrations;
2 changes: 2 additions & 0 deletions apps/api/src/core/context-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,7 @@ import { OrgRepoSyncStorage } from "@/storage/org-repo-syncs";
import { JiraIntegrationStorage } from "@/storage/jira-integrations";
import { SprintStorage } from "@/storage/sprints";
import { ColumnAutomationStorage } from "@/storage/task-board-column-automations";
import { BoardColumnStorage } from "@/storage/task-board-columns";
import { TaskBoardStorage } from "@/storage/task-board";
import { NotificationStorage } from "@/storage/notifications";
import { OrgFsEntryStorage } from "@/storage/org-fs";
Expand Down Expand Up @@ -1407,6 +1408,7 @@ export async function createStudioContextFactory(
taskBoard: new TaskBoardStorage(config.db),
sprints: new SprintStorage(config.db),
columnAutomations: new ColumnAutomationStorage(config.db),
boardColumns: new BoardColumnStorage(config.db),
notifications: new NotificationStorage(config.db),
orgFsEntries: new OrgFsEntryStorage(config.db),
oauthPkceStates: new OAuthPkceStateStorage(config.db),
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/core/studio-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const createMockContext = (
taskBoard: null as never,
sprints: null as never,
columnAutomations: null as never,
boardColumns: null as never,
notifications: null as never,
orgFsEntries: null as never,
oauthPkceStates: null as never,
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/core/studio-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ import { OrgRepoSyncStorage } from "@/storage/org-repo-syncs";
import { JiraIntegrationStorage } from "@/storage/jira-integrations";
import { SprintStorage } from "@/storage/sprints";
import { ColumnAutomationStorage } from "@/storage/task-board-column-automations";
import { BoardColumnStorage } from "@/storage/task-board-columns";
import type { TaskBoardStorage } from "@/storage/task-board";
import type { NotificationStorage } from "@/storage/notifications";
import type { OrgFsEntryStorage } from "@/storage/org-fs";
Expand Down Expand Up @@ -356,6 +357,7 @@ export interface StudioStorage {
taskBoard: TaskBoardStorage;
sprints: SprintStorage;
columnAutomations: ColumnAutomationStorage;
boardColumns: BoardColumnStorage;
notifications: NotificationStorage;
orgFsEntries: OrgFsEntryStorage;
oauthPkceStates: OAuthPkceStateStorage;
Expand Down
9 changes: 4 additions & 5 deletions apps/api/src/jira/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
* its issue is next updated in Jira.
*/

import { boardHandler } from "@/tools/task-board/board-handler";
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 {
Expand Down Expand Up @@ -245,10 +245,9 @@ async function maybeAutoDelegate(
// The board decides: a column with no rule on it is uneventful. This is also
// what replaced `integration.autoDelegate`, which could only ever mean the
// Super Agent, on To Do, for an org that had Jira.
const automation = await boardHandler(
orgId,
ctx.storage.columnAutomations,
).automationFor(item.status);
const automation = await (await boardFor(ctx, orgId)).automationFor(
item.status,
);
if (!automation) return item;
// Conditional claim, not a plain update: the cron, a webhook wake-up (its
// debounce is per-pod) and a manual JIRA_SYNC_RUN can all be mid-sync on the
Expand Down
90 changes: 90 additions & 0 deletions apps/api/src/storage/task-board-columns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { Kysely } from "kysely";
import type { BoardColumn } from "@decocms/shared/task-board";
import type { Database } from "./types";

/**
* The columns of a board whose columns belong to the org rather than to Studio
* (migration 191). Empty for an org on the canonical board — those columns are
* a constant, not rows.
*/
type Row = {
key: string;
title: string;
position: number;
role: string | null;
};

export class BoardColumnStorage {
constructor(private readonly db: Kysely<Database>) {}

/** One org's columns, left to right. Empty when the board is Studio's own. */
async listByOrg(organizationId: string): Promise<BoardColumn[]> {
const rows = await this.db
.selectFrom("task_board_columns")
.select(["key", "title", "position", "role"])
.where("organization_id", "=", organizationId)
.orderBy("position", "asc")
.execute();
return rows as Row[];
}

/**
* Make this set of columns the board's, left to right in the order given.
*
* Written whole rather than merged: the caller is mirroring a board it does
* not own, so a column that has disappeared upstream has to disappear here.
* A column that survives keeps its `role`, which is ours and not the
* tracker's to reassign.
*/
async replaceAll(
organizationId: string,
columns: { key: string; title: string }[],
): Promise<BoardColumn[]> {
return await this.db.transaction().execute(async (tx) => {
const existing = await tx
.selectFrom("task_board_columns")
.select(["key", "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();

if (columns.length === 0) return [];

const rows = columns.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();
return rows.map(({ key, title, position, role }) => ({
key,
title,
position,
role,
}));
});
}

/** Say what one of this board's columns means to Studio, or unsay it. */
async setRole(
organizationId: string,
key: string,
role: string | null,
): Promise<boolean> {
const result = await this.db
.updateTable("task_board_columns")
.set({ role, updated_at: new Date() })
.where("organization_id", "=", organizationId)
.where("key", "=", key)
.executeTakeFirst();
return (result.numUpdatedRows ?? 0n) > 0n;
}
}
35 changes: 25 additions & 10 deletions apps/api/src/storage/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1719,16 +1719,21 @@ export interface TaskBoardItemTable {
updated_at: ColumnType<Date, Date | string | undefined, Date | string>;
}

/**
* A sprint a card can belong to — an entity, not a window over a cadence (see
* `migrations/182-task-board-sprints-entities.ts`).
*
* `jira_sprint_id` is the mirror's identity: UNIQUE per org, so the pull
* upserts on it and a renamed Jira sprint updates in place instead of
* splitting in two. Null means a sprint this board owns — nothing writes those
* yet.
*/
/** A rule the board runs when a card lands in a column (migration 190). The
/** One column of a board whose columns belong to the org rather than to
* Studio (migration 191). `key` is what a card's `status` holds; `role` is
* what automation keys on, null until someone says what the column means. */
export interface TaskBoardColumnTable {
id: string;
organization_id: string;
key: string;
title: string;
position: number;
role: string | null;
created_at: ColumnType<Date, Date | string | undefined, Date | string>;
updated_at: ColumnType<Date, Date | string | undefined, Date | string>;
}

/** A rule the board runs when a card lands in a column (migration 189). The
* row's existence is the switch; `prompt` null means the Super Agent's own
* instruction. */
export interface TaskBoardColumnAutomationTable {
Expand All @@ -1740,6 +1745,15 @@ export interface TaskBoardColumnAutomationTable {
updated_at: ColumnType<Date, Date | string | undefined, Date | string>;
}

/**
* A sprint a card can belong to — an entity, not a window over a cadence (see
* `migrations/182-task-board-sprints-entities.ts`).
*
* `jira_sprint_id` is the mirror's identity: UNIQUE per org, so the pull
* upserts on it and a renamed Jira sprint updates in place instead of
* splitting in two. Null means a sprint this board owns — nothing writes those
* yet.
*/
export interface TaskBoardSprintTable {
id: string;
organization_id: string;
Expand Down Expand Up @@ -2270,6 +2284,7 @@ export interface Database extends PrivateRegistryDatabase {
task_board_items: TaskBoardItemTable;
task_board_sprints: TaskBoardSprintTable;
task_board_column_automations: TaskBoardColumnAutomationTable;
task_board_columns: TaskBoardColumnTable;
task_board_item_threads: TaskBoardItemThreadTable;
task_board_activity: TaskBoardActivityTable;
task_board_item_prs: TaskBoardItemPrTable;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ describe("Connection Tools", () => {
taskBoard: null as never,
sprints: null as never,
columnAutomations: null as never,
boardColumns: null as never,
notifications: null as never,
orgFsEntries: null as never,
oauthPkceStates: null as never,
Expand Down
7 changes: 2 additions & 5 deletions apps/api/src/tools/task-board/automations.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { z } from "zod";
import { defineTool } from "@/core/define-tool";
import { requireAuth } from "@/core/studio-context";
import { boardHandler } from "./board-handler";
import { boardFor } from "./board-handler";
import { MAX_AUTOMATION_PROMPT_LENGTH } from "./schema";

const AutomationSchema = z.object({
Expand Down Expand Up @@ -65,10 +65,7 @@ export const TASK_BOARD_AUTOMATION_UPSERT = defineTool({

// Rejected here rather than stored and ignored: a rule on a column this
// board does not have never fires, and looks configured to whoever set it.
const columns = await boardHandler(
organizationId,
ctx.storage.columnAutomations,
).columns();
const columns = await (await boardFor(ctx, organizationId)).columns();
if (!columns.some((c) => c.key === input.columnKey)) {
throw new Error(
`This board has no column "${input.columnKey}" — it has ${columns
Expand Down
Loading
Loading