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.

at 39e2dfae265f26c8d6d888a560f50ab2d5d58b3f 504 lines 12 kB view raw
1import { and, eq, inArray, max, notInArray } from "drizzle-orm"; 2import { HTTPException } from "hono/http-exception"; 3import db from "../../database"; 4import { 5 activityTable, 6 integrationTable, 7 labelTable, 8 projectTable, 9 taskTable, 10} from "../../database/schema"; 11import { publishEvent } from "../../events"; 12import type { GiteaConfig } from "../../plugins/gitea/config"; 13import { extractTaskNumberGitea } from "../../plugins/gitea/utils/branch-matcher"; 14import { 15 createGiteaClient, 16 type GiteaIssue, 17 type GiteaLabel, 18 type GiteaPullRequest, 19} from "../../plugins/gitea/utils/gitea-api"; 20import { 21 createExternalLink, 22 findExternalLink, 23} from "../../plugins/github/services/link-manager"; 24import { findTaskByNumber } from "../../plugins/github/services/task-service"; 25import { 26 extractIssuePriority, 27 extractIssueStatus, 28} from "../../plugins/github/utils/extract-priority"; 29import { formatTaskDescriptionFromIssue } from "../../plugins/github/utils/format"; 30 31type ImportResult = { 32 imported: number; 33 updated: number; 34 skipped: number; 35 errors?: string[]; 36}; 37 38type LabelLike = { name?: string }; 39 40function toPriorityLabels(labels: GiteaLabel[]): LabelLike[] { 41 return labels.map((label) => ({ name: label.name })); 42} 43 44export async function importGiteaIssues( 45 projectId: string, 46): Promise<ImportResult> { 47 const errors: string[] = []; 48 let imported = 0; 49 let updated = 0; 50 let skipped = 0; 51 52 const project = await db.query.projectTable.findFirst({ 53 where: eq(projectTable.id, projectId), 54 }); 55 56 if (!project) { 57 throw new HTTPException(404, { message: "Project not found" }); 58 } 59 60 const integration = await db.query.integrationTable.findFirst({ 61 where: and( 62 eq(integrationTable.projectId, projectId), 63 eq(integrationTable.type, "gitea"), 64 ), 65 }); 66 67 if (!integration) { 68 throw new HTTPException(404, { message: "Gitea integration not found" }); 69 } 70 71 if (!integration.isActive) { 72 throw new HTTPException(400, { 73 message: "Gitea integration is not active", 74 }); 75 } 76 77 let config: GiteaConfig; 78 try { 79 config = JSON.parse(integration.config) as GiteaConfig; 80 } catch (error) { 81 const message = error instanceof Error ? error.message : String(error); 82 console.warn("Invalid Gitea integration config JSON", { 83 integrationId: integration.id, 84 error, 85 }); 86 throw new HTTPException(400, { 87 message: `Invalid Gitea integration config: ${message}`, 88 }); 89 } 90 91 if (!config.accessToken || !config.baseUrl) { 92 throw new HTTPException(400, { 93 message: "Gitea access token or base URL not configured", 94 }); 95 } 96 97 const client = createGiteaClient(config); 98 99 const allIssues: GiteaIssue[] = []; 100 let page = 1; 101 102 while (true) { 103 const issues = await client.listIssues( 104 config.repositoryOwner, 105 config.repositoryName, 106 page, 107 "open", 108 ); 109 110 if (issues.length === 0) break; 111 112 const issuesOnly = issues.filter((issue) => !issue.pull_request); 113 allIssues.push(...issuesOnly); 114 115 if (issues.length < 100) break; 116 page++; 117 } 118 119 for (const issue of allIssues) { 120 try { 121 const result = await importSingleIssue( 122 issue, 123 integration.id, 124 projectId, 125 project.workspaceId, 126 config, 127 client, 128 ); 129 130 if (result === "imported") { 131 imported++; 132 } else if (result === "updated") { 133 updated++; 134 } else { 135 skipped++; 136 } 137 } catch (error) { 138 const errorMessage = 139 error instanceof Error ? error.message : String(error); 140 errors.push(`Issue #${issue.number}: ${errorMessage}`); 141 } 142 } 143 144 const allPRs: GiteaPullRequest[] = []; 145 page = 1; 146 147 while (true) { 148 const pulls = await client.listPulls( 149 config.repositoryOwner, 150 config.repositoryName, 151 page, 152 ); 153 154 if (pulls.length === 0) break; 155 156 allPRs.push(...pulls); 157 158 if (pulls.length < 100) break; 159 page++; 160 } 161 162 for (const pr of allPRs) { 163 try { 164 if (!pr.head?.ref) { 165 continue; 166 } 167 await linkPullRequestToTask( 168 { 169 ...pr, 170 head: { ref: pr.head.ref }, 171 }, 172 integration.id, 173 projectId, 174 project.slug, 175 config, 176 ); 177 } catch (error) { 178 const errorMessage = 179 error instanceof Error ? error.message : String(error); 180 errors.push(`PR #${pr.number}: ${errorMessage}`); 181 } 182 } 183 184 return { 185 imported, 186 updated, 187 skipped, 188 ...(errors.length > 0 ? { errors } : {}), 189 }; 190} 191 192async function importSingleIssue( 193 issue: GiteaIssue, 194 integrationId: string, 195 projectId: string, 196 workspaceId: string, 197 config: GiteaConfig, 198 client: ReturnType<typeof createGiteaClient>, 199): Promise<"imported" | "updated" | "skipped"> { 200 const existingLink = await findExternalLink( 201 integrationId, 202 "issue", 203 issue.number.toString(), 204 ); 205 206 const labels = issue.labels ?? []; 207 const adaptedLabels = toPriorityLabels(labels); 208 const priority = extractIssuePriority(adaptedLabels); 209 const status = extractIssueStatus(adaptedLabels); 210 211 if (existingLink) { 212 const updateData: Record<string, unknown> = { 213 title: issue.title, 214 description: formatTaskDescriptionFromIssue(issue.body), 215 }; 216 217 if (priority) updateData.priority = priority; 218 if (status) updateData.status = status; 219 220 await db 221 .update(taskTable) 222 .set(updateData) 223 .where(eq(taskTable.id, existingLink.taskId)); 224 225 await importLabelsForTask(labels, existingLink.taskId, workspaceId); 226 227 await importCommentsForTask( 228 issue.number, 229 existingLink.taskId, 230 config, 231 client, 232 ); 233 234 return "updated"; 235 } 236 237 const createdTask = await db.transaction(async (tx) => { 238 const [lockedProject] = await tx 239 .select() 240 .from(projectTable) 241 .where(eq(projectTable.id, projectId)) 242 .for("update"); 243 244 if (!lockedProject) { 245 throw new Error("Project not found"); 246 } 247 248 const [result] = await tx 249 .select({ maxNumber: max(taskTable.number) }) 250 .from(taskTable) 251 .where(eq(taskTable.projectId, projectId)); 252 253 const nextNumber = (result?.maxNumber ?? 0) + 1; 254 255 const taskValues: typeof taskTable.$inferInsert = { 256 projectId, 257 userId: null, 258 title: issue.title, 259 description: formatTaskDescriptionFromIssue(issue.body), 260 status: status || "to-do", 261 priority: priority || null, 262 number: nextNumber, 263 }; 264 265 const [created] = await tx.insert(taskTable).values(taskValues).returning(); 266 267 if (!created) { 268 throw new Error("Failed to create task"); 269 } 270 271 return created; 272 }); 273 274 await createExternalLink({ 275 taskId: createdTask.id, 276 integrationId, 277 resourceType: "issue", 278 externalId: issue.number.toString(), 279 url: issue.html_url, 280 title: issue.title, 281 metadata: { 282 state: issue.state, 283 createdFrom: "gitea-import", 284 author: issue.user?.login ?? issue.user?.username, 285 }, 286 }); 287 288 await importLabelsForTask(labels, createdTask.id, workspaceId); 289 290 await importCommentsForTask(issue.number, createdTask.id, config, client); 291 292 await publishEvent("task.created", { 293 ...createdTask, 294 taskId: createdTask.id, 295 userId: createdTask.userId ?? "", 296 type: "task", 297 content: null, 298 source: "gitea-import", 299 integrationId, 300 externalId: issue.number.toString(), 301 }); 302 303 return "imported"; 304} 305 306async function importLabelsForTask( 307 issueLabels: GiteaIssue["labels"], 308 taskId: string, 309 workspaceId: string, 310): Promise<void> { 311 const nonSystemLabels = (issueLabels ?? []) 312 .map((label) => { 313 if (typeof label === "string") { 314 return { name: label, color: "#6B7280" }; 315 } 316 return { 317 name: label.name, 318 color: label.color 319 ? `#${String(label.color).replace(/^#/, "")}` 320 : "#6B7280", 321 }; 322 }) 323 .filter( 324 (label) => 325 label.name && 326 !label.name.startsWith("priority:") && 327 !label.name.startsWith("status:"), 328 ) as Array<{ name: string; color: string }>; 329 330 const expectedNames = nonSystemLabels.map((label) => label.name); 331 332 if (expectedNames.length > 0) { 333 await db 334 .delete(labelTable) 335 .where( 336 and( 337 eq(labelTable.taskId, taskId), 338 notInArray(labelTable.name, expectedNames), 339 ), 340 ); 341 } else { 342 await db.delete(labelTable).where(eq(labelTable.taskId, taskId)); 343 } 344 345 const existingLabelsOnTask = await db.query.labelTable.findMany({ 346 where: 347 expectedNames.length > 0 348 ? and( 349 eq(labelTable.taskId, taskId), 350 inArray(labelTable.name, expectedNames), 351 ) 352 : eq(labelTable.taskId, taskId), 353 }); 354 355 for (const labelData of nonSystemLabels) { 356 const existingLabelOnTask = existingLabelsOnTask.find( 357 (label) => label.name === labelData.name, 358 ); 359 360 if (existingLabelOnTask) { 361 continue; 362 } 363 364 const existingWorkspaceLabel = await db.query.labelTable.findFirst({ 365 where: and( 366 eq(labelTable.workspaceId, workspaceId), 367 eq(labelTable.name, labelData.name), 368 ), 369 }); 370 371 const colorToUse = existingWorkspaceLabel?.color || labelData.color; 372 373 await db 374 .insert(labelTable) 375 .values({ 376 name: labelData.name, 377 color: colorToUse, 378 taskId, 379 workspaceId, 380 }) 381 .onConflictDoNothing({ 382 target: [labelTable.taskId, labelTable.name], 383 }); 384 } 385} 386 387async function importCommentsForTask( 388 issueNumber: number, 389 taskId: string, 390 config: GiteaConfig, 391 client: ReturnType<typeof createGiteaClient>, 392): Promise<void> { 393 const allComments: Array<{ 394 id: number; 395 body: string; 396 html_url: string; 397 user?: { login?: string; username?: string; avatar_url?: string } | null; 398 }> = []; 399 let page = 1; 400 401 while (true) { 402 const comments = await client.listIssueComments( 403 config.repositoryOwner, 404 config.repositoryName, 405 issueNumber, 406 page, 407 100, 408 ); 409 410 if (comments.length === 0) break; 411 412 allComments.push(...comments); 413 414 if (comments.length < 100) break; 415 page++; 416 } 417 418 for (const comment of allComments) { 419 const username = comment.user?.login ?? comment.user?.username ?? ""; 420 if (username.endsWith("[bot]")) { 421 continue; 422 } 423 424 await db 425 .insert(activityTable) 426 .values({ 427 taskId, 428 type: "comment", 429 content: comment.body, 430 externalUserName: username || "Unknown", 431 externalUserAvatar: comment.user?.avatar_url ?? null, 432 externalSource: "gitea", 433 externalUrl: comment.html_url, 434 eventData: { 435 externalCommentId: comment.id, 436 }, 437 }) 438 .onConflictDoNothing({ 439 target: [ 440 activityTable.taskId, 441 activityTable.externalSource, 442 activityTable.externalUrl, 443 ], 444 }); 445 } 446} 447 448async function linkPullRequestToTask( 449 pr: { 450 number: number; 451 title: string; 452 body: string | null; 453 html_url: string; 454 state: string; 455 head: { ref: string }; 456 user?: { login?: string; username?: string; avatar_url?: string } | null; 457 }, 458 integrationId: string, 459 projectId: string, 460 projectSlug: string, 461 config: GiteaConfig, 462): Promise<void> { 463 const taskNumber = extractTaskNumberGitea( 464 pr.head.ref, 465 pr.title, 466 pr.body ?? undefined, 467 config, 468 projectSlug, 469 ); 470 471 if (!taskNumber) { 472 return; 473 } 474 475 const task = await findTaskByNumber(projectId, taskNumber); 476 477 if (!task) { 478 return; 479 } 480 481 const existingLink = await findExternalLink( 482 integrationId, 483 "pull_request", 484 pr.number.toString(), 485 ); 486 487 if (existingLink) { 488 return; 489 } 490 491 await createExternalLink({ 492 taskId: task.id, 493 integrationId, 494 resourceType: "pull_request", 495 externalId: pr.number.toString(), 496 url: pr.html_url, 497 title: pr.title, 498 metadata: { 499 state: pr.state, 500 branch: pr.head.ref, 501 author: pr.user?.login ?? pr.user?.username, 502 }, 503 }); 504}