kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { and, eq, sql } from "drizzle-orm";
2import db from "../database";
3import {
4 columnTable,
5 integrationTable,
6 projectTable,
7 taskTable,
8 workflowRuleTable,
9} from "../database/schema";
10
11const DEFAULT_COLUMNS = [
12 { name: "To Do", slug: "to-do", position: 0, isFinal: false },
13 { name: "In Progress", slug: "in-progress", position: 1, isFinal: false },
14 { name: "In Review", slug: "in-review", position: 2, isFinal: false },
15 { name: "Done", slug: "done", position: 3, isFinal: true },
16];
17
18const EVENT_MAPPING: Record<string, string> = {
19 onBranchPush: "branch_push",
20 onPROpen: "pr_opened",
21 onPRMerge: "pr_merged",
22};
23
24export async function migrateColumns() {
25 console.log("🔄 Starting column migration...");
26
27 const projects = await db.select().from(projectTable);
28
29 if (projects.length === 0) {
30 console.log("No projects found, skipping column migration");
31 return;
32 }
33
34 for (const project of projects) {
35 const projectColumns = await db
36 .select({
37 id: columnTable.id,
38 slug: columnTable.slug,
39 })
40 .from(columnTable)
41 .where(eq(columnTable.projectId, project.id));
42
43 const columnMap = new Map<string, string>(
44 projectColumns.map((column) => [column.slug, column.id]),
45 );
46
47 // Only seed missing default slugs for legacy projects that have no columns yet.
48 // If the project already has columns, missing slugs are intentional (user removed them);
49 // re-inserting on every startup would undo deletions after each API restart.
50 if (projectColumns.length === 0) {
51 for (const defaultColumn of DEFAULT_COLUMNS) {
52 if (columnMap.has(defaultColumn.slug)) {
53 continue;
54 }
55
56 const [inserted] = await db
57 .insert(columnTable)
58 .values({
59 projectId: project.id,
60 name: defaultColumn.name,
61 slug: defaultColumn.slug,
62 position: defaultColumn.position,
63 isFinal: defaultColumn.isFinal,
64 })
65 .returning({ id: columnTable.id, slug: columnTable.slug });
66
67 if (inserted) {
68 columnMap.set(inserted.slug, inserted.id);
69 }
70 }
71 }
72
73 for (const [slug, columnId] of columnMap) {
74 await db
75 .update(taskTable)
76 .set({ columnId })
77 .where(
78 sql`${taskTable.projectId} = ${project.id}
79 AND ${taskTable.status} = ${slug}
80 AND ${taskTable.columnId} IS DISTINCT FROM ${columnId}`,
81 );
82 }
83
84 const integrations = await db.query.integrationTable.findMany({
85 where: eq(integrationTable.projectId, project.id),
86 });
87
88 for (const integration of integrations) {
89 if (
90 (integration.type !== "github" && integration.type !== "gitea") ||
91 !integration.isActive
92 ) {
93 continue;
94 }
95
96 const forgeType = integration.type as "github" | "gitea";
97
98 try {
99 const config = JSON.parse(integration.config);
100 const transitions = config.statusTransitions || {};
101
102 for (const [configKey, eventType] of Object.entries(EVENT_MAPPING)) {
103 const targetSlug = transitions[configKey];
104 if (!targetSlug) continue;
105
106 const targetColumnId = columnMap.get(targetSlug);
107 if (!targetColumnId) continue;
108
109 await ensureMigrationWorkflowRule(
110 project.id,
111 forgeType,
112 eventType as string,
113 targetColumnId,
114 );
115 }
116
117 // Add default rules for issue events
118 const todoColumnId = columnMap.get("to-do");
119 const doneColumnId = columnMap.get("done");
120
121 if (todoColumnId) {
122 await ensureMigrationWorkflowRule(
123 project.id,
124 forgeType,
125 "issue_opened",
126 todoColumnId,
127 );
128 }
129
130 if (doneColumnId) {
131 await ensureMigrationWorkflowRule(
132 project.id,
133 forgeType,
134 "issue_closed",
135 doneColumnId,
136 );
137 }
138 } catch {
139 console.error(
140 `Failed to migrate workflow rules for integration ${integration.id}`,
141 );
142 }
143 }
144 }
145
146 console.log(
147 `✅ Column migration complete! Migrated ${projects.length} projects`,
148 );
149}
150
151async function ensureMigrationWorkflowRule(
152 projectId: string,
153 integrationType: "github" | "gitea",
154 eventType: string,
155 columnId: string,
156) {
157 const existing = await db.query.workflowRuleTable.findFirst({
158 where: and(
159 eq(workflowRuleTable.projectId, projectId),
160 eq(workflowRuleTable.integrationType, integrationType),
161 eq(workflowRuleTable.eventType, eventType),
162 ),
163 });
164
165 if (existing) {
166 return;
167 }
168
169 await db.insert(workflowRuleTable).values({
170 projectId,
171 integrationType,
172 eventType,
173 columnId,
174 });
175}