kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(api): harden gitea integration imports

Tin 9db74f96 5f7c47c7

+272 -100
+35
apps/api/drizzle/0020_gitea_dedup_guards.sql
··· 1 + DELETE FROM "activity" a 2 + USING "activity" b 3 + WHERE a.ctid < b.ctid 4 + AND a.task_id = b.task_id 5 + AND a.external_source = b.external_source 6 + AND a.external_url = b.external_url 7 + AND a.external_url IS NOT NULL; 8 + 9 + DELETE FROM "label" a 10 + USING "label" b 11 + WHERE a.ctid < b.ctid 12 + AND a.task_id = b.task_id 13 + AND a.name = b.name 14 + AND a.task_id IS NOT NULL; 15 + 16 + DELETE FROM "label" a 17 + USING "label" b 18 + WHERE a.ctid < b.ctid 19 + AND a.workspace_id = b.workspace_id 20 + AND a.name = b.name 21 + AND a.task_id IS NULL 22 + AND b.task_id IS NULL 23 + AND a.workspace_id IS NOT NULL; 24 + 25 + ALTER TABLE "activity" 26 + ADD CONSTRAINT "activity_task_external_source_external_url_unique" 27 + UNIQUE ("task_id", "external_source", "external_url"); 28 + 29 + ALTER TABLE "label" 30 + ADD CONSTRAINT "label_task_name_unique" 31 + UNIQUE ("task_id", "name"); 32 + 33 + CREATE UNIQUE INDEX "label_workspace_name_unique" 34 + ON "label" ("workspace_id", "name") 35 + WHERE "task_id" IS NULL;
+7
apps/api/drizzle/meta/_journal.json
··· 141 141 "when": 1774823200346, 142 142 "tag": "0019_sloppy_meteorite", 143 143 "breakpoints": true 144 + }, 145 + { 146 + "idx": 20, 147 + "version": "7", 148 + "when": 1774908000000, 149 + "tag": "0020_gitea_dedup_guards", 150 + "breakpoints": true 144 151 } 145 152 ] 146 153 }
+57 -37
apps/api/src/database/schema.ts
··· 1 1 import { createId } from "@paralleldrive/cuid2"; 2 - import { relations } from "drizzle-orm"; 2 + import { relations, sql } from "drizzle-orm"; 3 3 import { 4 4 boolean, 5 5 index, ··· 9 9 text, 10 10 timestamp, 11 11 unique, 12 + uniqueIndex, 12 13 } from "drizzle-orm/pg-core"; 13 14 14 15 export const userTable = pgTable("user", { ··· 320 321 createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), 321 322 }); 322 323 323 - export const activityTable = pgTable("activity", { 324 - id: text("id") 325 - .$defaultFn(() => createId()) 326 - .primaryKey(), 327 - taskId: text("task_id") 328 - .notNull() 329 - .references(() => taskTable.id, { 324 + export const activityTable = pgTable( 325 + "activity", 326 + { 327 + id: text("id") 328 + .$defaultFn(() => createId()) 329 + .primaryKey(), 330 + taskId: text("task_id") 331 + .notNull() 332 + .references(() => taskTable.id, { 333 + onDelete: "cascade", 334 + onUpdate: "cascade", 335 + }), 336 + type: text("type").notNull(), 337 + createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), 338 + userId: text("user_id").references(() => userTable.id, { 330 339 onDelete: "cascade", 331 340 onUpdate: "cascade", 332 341 }), 333 - type: text("type").notNull(), 334 - createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), 335 - userId: text("user_id").references(() => userTable.id, { 336 - onDelete: "cascade", 337 - onUpdate: "cascade", 338 - }), 339 - content: text("content"), 340 - eventData: jsonb("event_data"), 341 - externalUserName: text("external_user_name"), 342 - externalUserAvatar: text("external_user_avatar"), 343 - externalSource: text("external_source"), 344 - externalUrl: text("external_url"), 345 - }); 342 + content: text("content"), 343 + eventData: jsonb("event_data"), 344 + externalUserName: text("external_user_name"), 345 + externalUserAvatar: text("external_user_avatar"), 346 + externalSource: text("external_source"), 347 + externalUrl: text("external_url"), 348 + }, 349 + (table) => [ 350 + unique("activity_task_external_source_external_url_unique").on( 351 + table.taskId, 352 + table.externalSource, 353 + table.externalUrl, 354 + ), 355 + ], 356 + ); 346 357 347 358 export const assetTable = pgTable( 348 359 "asset", ··· 390 401 ], 391 402 ); 392 403 393 - export const labelTable = pgTable("label", { 394 - id: text("id") 395 - .$defaultFn(() => createId()) 396 - .primaryKey(), 397 - name: text("name").notNull(), 398 - color: text("color").notNull(), 399 - createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), 400 - taskId: text("task_id").references(() => taskTable.id, { 401 - onDelete: "cascade", 402 - onUpdate: "cascade", 403 - }), 404 - workspaceId: text("workspace_id").references(() => workspaceTable.id, { 405 - onDelete: "cascade", 406 - onUpdate: "cascade", 407 - }), 408 - }); 404 + export const labelTable = pgTable( 405 + "label", 406 + { 407 + id: text("id") 408 + .$defaultFn(() => createId()) 409 + .primaryKey(), 410 + name: text("name").notNull(), 411 + color: text("color").notNull(), 412 + createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), 413 + taskId: text("task_id").references(() => taskTable.id, { 414 + onDelete: "cascade", 415 + onUpdate: "cascade", 416 + }), 417 + workspaceId: text("workspace_id").references(() => workspaceTable.id, { 418 + onDelete: "cascade", 419 + onUpdate: "cascade", 420 + }), 421 + }, 422 + (table) => [ 423 + unique("label_task_name_unique").on(table.taskId, table.name), 424 + uniqueIndex("label_workspace_name_unique") 425 + .on(table.workspaceId, table.name) 426 + .where(sql`${table.taskId} is null`), 427 + ], 428 + ); 409 429 410 430 export const notificationTable = pgTable("notification", { 411 431 id: text("id")
+31 -6
apps/api/src/gitea-integration/controllers/create-gitea-integration.ts
··· 46 46 47 47 let resolvedToken = accessToken?.trim() ?? ""; 48 48 if (!resolvedToken && existingIntegration) { 49 - const prev = JSON.parse(existingIntegration.config) as GiteaConfig; 50 - resolvedToken = prev.accessToken; 49 + try { 50 + const prev = JSON.parse(existingIntegration.config) as GiteaConfig; 51 + resolvedToken = prev.accessToken; 52 + } catch (error) { 53 + console.warn("Failed to parse existing Gitea integration config", { 54 + integrationId: existingIntegration.id, 55 + error, 56 + }); 57 + } 51 58 } 52 59 53 60 if (!resolvedToken) { ··· 91 98 if (error instanceof HTTPException) { 92 99 throw error; 93 100 } 101 + console.warn( 102 + "Skipping invalid Gitea integration config during conflict check", 103 + { 104 + integrationId: integration.id, 105 + error, 106 + }, 107 + ); 108 + throw error; 94 109 } 95 110 } 96 111 97 - const webhookSecret = existingIntegration 98 - ? ((JSON.parse(existingIntegration.config) as GiteaConfig).webhookSecret ?? 99 - randomBytes(24).toString("hex")) 100 - : randomBytes(24).toString("hex"); 112 + let webhookSecret = randomBytes(24).toString("hex"); 113 + if (existingIntegration) { 114 + try { 115 + const previousConfig = JSON.parse( 116 + existingIntegration.config, 117 + ) as GiteaConfig; 118 + webhookSecret = previousConfig.webhookSecret ?? webhookSecret; 119 + } catch (error) { 120 + console.warn("Failed to parse existing Gitea config for webhook secret", { 121 + integrationId: existingIntegration.id, 122 + error, 123 + }); 124 + } 125 + } 101 126 102 127 const config: GiteaConfig = getDefaultGiteaConfig( 103 128 normalizedBase,
+44 -26
apps/api/src/gitea-integration/controllers/import-gitea-issues.ts
··· 8 8 projectTable, 9 9 taskTable, 10 10 } from "../../database/schema"; 11 + import { publishEvent } from "../../events"; 11 12 import type { GiteaConfig } from "../../plugins/gitea/config"; 12 13 import { extractTaskNumberGitea } from "../../plugins/gitea/utils/branch-matcher"; 13 14 import { 14 15 createGiteaClient, 15 16 type GiteaIssue, 17 + type GiteaLabel, 16 18 type GiteaPullRequest, 17 19 } from "../../plugins/gitea/utils/gitea-api"; 18 20 import { ··· 33 35 skipped: number; 34 36 errors?: string[]; 35 37 }; 38 + 39 + type LabelLike = { name?: string }; 40 + 41 + function toPriorityLabels(labels: GiteaLabel[]): LabelLike[] { 42 + return labels.map((label) => ({ name: label.name })); 43 + } 36 44 37 45 export async function importGiteaIssues( 38 46 projectId: string, ··· 185 193 ); 186 194 187 195 const labels = issue.labels ?? []; 188 - const priority = extractIssuePriority(labels as never); 189 - const status = extractIssueStatus(labels as never); 196 + const adaptedLabels = toPriorityLabels(labels); 197 + const priority = extractIssuePriority(adaptedLabels); 198 + const status = extractIssueStatus(adaptedLabels); 190 199 191 200 if (existingLink) { 192 201 const updateData: Record<string, unknown> = { ··· 253 262 254 263 await importCommentsForTask(issue.number, createdTask.id, config, client); 255 264 265 + await publishEvent("task.created", { 266 + ...createdTask, 267 + taskId: createdTask.id, 268 + userId: createdTask.userId ?? "", 269 + type: "task", 270 + content: null, 271 + source: "gitea-import", 272 + integrationId, 273 + externalId: issue.number.toString(), 274 + }); 275 + 256 276 return "imported"; 257 277 } 258 278 ··· 339 359 client: ReturnType<typeof createGiteaClient>, 340 360 ): Promise<void> { 341 361 const allComments: Array<{ 362 + id: number; 342 363 body: string; 343 364 html_url: string; 344 365 user?: { login?: string; username?: string; avatar_url?: string } | null; ··· 362 383 page++; 363 384 } 364 385 365 - const existingActivities = await db.query.activityTable.findMany({ 366 - where: and( 367 - eq(activityTable.taskId, taskId), 368 - eq(activityTable.externalSource, "gitea"), 369 - ), 370 - }); 371 - 372 - const existingExternalUrls = new Set( 373 - existingActivities.filter((a) => a.externalUrl).map((a) => a.externalUrl), 374 - ); 375 - 376 386 for (const comment of allComments) { 377 387 const username = comment.user?.login ?? comment.user?.username ?? ""; 378 388 if (username.endsWith("[bot]")) { 379 389 continue; 380 390 } 381 391 382 - if (existingExternalUrls.has(comment.html_url)) { 383 - continue; 384 - } 385 - 386 - await db.insert(activityTable).values({ 387 - taskId, 388 - type: "comment", 389 - content: comment.body, 390 - externalUserName: username || "Unknown", 391 - externalUserAvatar: comment.user?.avatar_url ?? null, 392 - externalSource: "gitea", 393 - externalUrl: comment.html_url, 394 - }); 392 + await db 393 + .insert(activityTable) 394 + .values({ 395 + taskId, 396 + type: "comment", 397 + content: comment.body, 398 + externalUserName: username || "Unknown", 399 + externalUserAvatar: comment.user?.avatar_url ?? null, 400 + externalSource: "gitea", 401 + externalUrl: comment.html_url, 402 + eventData: { 403 + externalCommentId: comment.id, 404 + }, 405 + }) 406 + .onConflictDoNothing({ 407 + target: [ 408 + activityTable.taskId, 409 + activityTable.externalSource, 410 + activityTable.externalUrl, 411 + ], 412 + }); 395 413 } 396 414 } 397 415
+97 -25
apps/api/src/plugins/gitea/utils/gitea-api.ts
··· 62 62 token: string, 63 63 path: string, 64 64 init?: RequestInit, 65 - ): Promise<T> { 65 + ): Promise<T | undefined> { 66 66 const root = normalizeGiteaBaseUrl(baseUrl); 67 67 const url = `${root}/api/v1${path.startsWith("/") ? path : `/${path}`}`; 68 68 const res = await fetch(url, { ··· 79 79 } 80 80 81 81 if (res.status === 204 || text === "") { 82 - return undefined as T; 82 + return undefined; 83 83 } 84 84 85 85 try { ··· 101 101 `/repos/${encodeURIComponent(o)}/${encodeURIComponent(r)}`; 102 102 103 103 return { 104 - async getRepo(repositoryOwner: string, repositoryName: string) { 105 - return giteaFetch<{ 104 + async getRepo( 105 + repositoryOwner: string, 106 + repositoryName: string, 107 + ): Promise<{ 108 + name: string; 109 + owner: { login?: string; username?: string }; 110 + html_url: string; 111 + private: boolean; 112 + permissions?: { admin?: boolean; push?: boolean; pull?: boolean }; 113 + }> { 114 + const repo = await giteaFetch<{ 106 115 name: string; 107 116 owner: { login?: string; username?: string }; 108 117 html_url: string; 109 118 private: boolean; 110 119 permissions?: { admin?: boolean; push?: boolean; pull?: boolean }; 111 120 }>(baseUrl, accessToken, owner(repositoryOwner, repositoryName)); 121 + if (!repo) { 122 + throw new GiteaApiError("Gitea repository response was empty", 500); 123 + } 124 + return repo; 112 125 }, 113 126 114 - async listUserRepos(page = 1, limit = 50) { 115 - return giteaFetch< 127 + async listUserRepos( 128 + page = 1, 129 + limit = 50, 130 + ): Promise< 131 + Array<{ 132 + id: number; 133 + name: string; 134 + full_name: string; 135 + owner: { login?: string; username?: string }; 136 + private: boolean; 137 + html_url: string; 138 + }> 139 + > { 140 + const repos = await giteaFetch< 116 141 Array<{ 117 142 id: number; 118 143 name: string; ··· 122 147 html_url: string; 123 148 }> 124 149 >(baseUrl, accessToken, `/user/repos?page=${page}&limit=${limit}`); 150 + if (!repos) { 151 + throw new GiteaApiError("Gitea repositories response was empty", 500); 152 + } 153 + return repos; 125 154 }, 126 155 127 156 async createIssue( 128 157 repositoryOwner: string, 129 158 repositoryName: string, 130 159 body: { title: string; body?: string | null; closed?: boolean }, 131 - ) { 132 - return giteaFetch<GiteaIssue>( 160 + ): Promise<GiteaIssue> { 161 + const issue = await giteaFetch<GiteaIssue>( 133 162 baseUrl, 134 163 accessToken, 135 164 `${owner(repositoryOwner, repositoryName)}/issues`, ··· 138 167 body: JSON.stringify(body), 139 168 }, 140 169 ); 170 + if (!issue) { 171 + throw new GiteaApiError("Gitea create issue response was empty", 500); 172 + } 173 + return issue; 141 174 }, 142 175 143 176 async updateIssue( ··· 145 178 repositoryName: string, 146 179 index: number, 147 180 body: Record<string, unknown>, 148 - ) { 149 - return giteaFetch<GiteaIssue>( 181 + ): Promise<GiteaIssue> { 182 + const issue = await giteaFetch<GiteaIssue>( 150 183 baseUrl, 151 184 accessToken, 152 185 `${owner(repositoryOwner, repositoryName)}/issues/${index}`, ··· 155 188 body: JSON.stringify(body), 156 189 }, 157 190 ); 191 + if (!issue) { 192 + throw new GiteaApiError("Gitea update issue response was empty", 500); 193 + } 194 + return issue; 158 195 }, 159 196 160 197 async listIssueComments( ··· 163 200 index: number, 164 201 page: number, 165 202 limit: number, 166 - ) { 167 - return giteaFetch<GiteaComment[]>( 203 + ): Promise<GiteaComment[]> { 204 + const comments = await giteaFetch<GiteaComment[]>( 168 205 baseUrl, 169 206 accessToken, 170 207 `${owner(repositoryOwner, repositoryName)}/issues/${index}/comments?page=${page}&limit=${limit}`, 171 208 ); 209 + if (!comments) { 210 + throw new GiteaApiError("Gitea comments response was empty", 500); 211 + } 212 + return comments; 172 213 }, 173 214 174 215 async createIssueComment( ··· 176 217 repositoryName: string, 177 218 index: number, 178 219 body: string, 179 - ) { 180 - return giteaFetch<GiteaComment>( 220 + ): Promise<GiteaComment> { 221 + const comment = await giteaFetch<GiteaComment>( 181 222 baseUrl, 182 223 accessToken, 183 224 `${owner(repositoryOwner, repositoryName)}/issues/${index}/comments`, ··· 186 227 body: JSON.stringify({ body }), 187 228 }, 188 229 ); 230 + if (!comment) { 231 + throw new GiteaApiError("Gitea create comment response was empty", 500); 232 + } 233 + return comment; 189 234 }, 190 235 191 - async listLabels(repositoryOwner: string, repositoryName: string) { 192 - return giteaFetch<GiteaLabel[]>( 236 + async listLabels( 237 + repositoryOwner: string, 238 + repositoryName: string, 239 + ): Promise<GiteaLabel[]> { 240 + const labels = await giteaFetch<GiteaLabel[]>( 193 241 baseUrl, 194 242 accessToken, 195 243 `${owner(repositoryOwner, repositoryName)}/labels`, 196 244 ); 245 + if (!labels) { 246 + throw new GiteaApiError("Gitea labels response was empty", 500); 247 + } 248 + return labels; 197 249 }, 198 250 199 251 async createLabel( ··· 201 253 repositoryName: string, 202 254 name: string, 203 255 color: string, 204 - ) { 205 - return giteaFetch<GiteaLabel>( 256 + ): Promise<GiteaLabel> { 257 + const label = await giteaFetch<GiteaLabel>( 206 258 baseUrl, 207 259 accessToken, 208 260 `${owner(repositoryOwner, repositoryName)}/labels`, ··· 214 266 }), 215 267 }, 216 268 ); 269 + if (!label) { 270 + throw new GiteaApiError("Gitea create label response was empty", 500); 271 + } 272 + return label; 217 273 }, 218 274 219 275 async addLabelsToIssue( ··· 271 327 repositoryOwner: string, 272 328 repositoryName: string, 273 329 index: number, 274 - ) { 275 - return giteaFetch<GiteaIssue>( 330 + ): Promise<GiteaIssue> { 331 + const issue = await giteaFetch<GiteaIssue>( 276 332 baseUrl, 277 333 accessToken, 278 334 `${owner(repositoryOwner, repositoryName)}/issues/${index}`, 279 335 ); 336 + if (!issue) { 337 + throw new GiteaApiError("Gitea issue response was empty", 500); 338 + } 339 + return issue; 280 340 }, 281 341 282 342 async listIssues( ··· 284 344 repositoryName: string, 285 345 page: number, 286 346 state: "open" | "closed" | "all", 287 - ) { 288 - return giteaFetch<GiteaIssue[]>( 347 + ): Promise<GiteaIssue[]> { 348 + const issues = await giteaFetch<GiteaIssue[]>( 289 349 baseUrl, 290 350 accessToken, 291 351 `${owner(repositoryOwner, repositoryName)}/issues?state=${state}&page=${page}&limit=100`, 292 352 ); 353 + if (!issues) { 354 + throw new GiteaApiError("Gitea issues response was empty", 500); 355 + } 356 + return issues; 293 357 }, 294 358 295 359 async listPulls( 296 360 repositoryOwner: string, 297 361 repositoryName: string, 298 362 page: number, 299 - ) { 300 - return giteaFetch<GiteaPullRequest[]>( 363 + ): Promise<GiteaPullRequest[]> { 364 + const pulls = await giteaFetch<GiteaPullRequest[]>( 301 365 baseUrl, 302 366 accessToken, 303 367 `${owner(repositoryOwner, repositoryName)}/pulls?state=open&page=${page}&limit=100`, 304 368 ); 369 + if (!pulls) { 370 + throw new GiteaApiError("Gitea pull requests response was empty", 500); 371 + } 372 + return pulls; 305 373 }, 306 374 }; 307 375 } 308 376 309 377 export async function verifyGiteaToken(baseUrl: string, token: string) { 310 - return giteaFetch<{ id: number; login: string }>( 378 + const user = await giteaFetch<{ id: number; login: string }>( 311 379 normalizeGiteaBaseUrl(baseUrl), 312 380 token, 313 381 "/user", 314 382 ); 383 + if (!user) { 384 + throw new GiteaApiError("Gitea user response was empty", 500); 385 + } 386 + return user; 315 387 }
+1 -6
apps/api/src/plugins/gitea/utils/labels.ts
··· 24 24 name: string, 25 25 ): Promise<number> { 26 26 const { repositoryOwner, repositoryName } = config; 27 - let labels: GiteaLabel[]; 28 - try { 29 - labels = await client.listLabels(repositoryOwner, repositoryName); 30 - } catch { 31 - labels = []; 32 - } 27 + const labels = await client.listLabels(repositoryOwner, repositoryName); 33 28 34 29 const found = labels.find((l) => l.name === name); 35 30 if (found) {