kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
at main 441 lines 10 kB view raw
1import { and, eq } 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 type { GitHubConfig } from "../../plugins/github/config"; 12import { 13 createExternalLink, 14 findExternalLink, 15} from "../../plugins/github/services/link-manager"; 16import { findTaskByNumber } from "../../plugins/github/services/task-service"; 17import { extractTaskNumber } from "../../plugins/github/utils/branch-matcher"; 18import { 19 extractIssuePriority, 20 extractIssueStatus, 21} from "../../plugins/github/utils/extract-priority"; 22import { formatTaskDescriptionFromIssue } from "../../plugins/github/utils/format"; 23import { getInstallationOctokit } from "../../plugins/github/utils/github-app"; 24import getNextTaskNumber from "../../task/controllers/get-next-task-number"; 25 26type ImportResult = { 27 imported: number; 28 updated: number; 29 skipped: number; 30 errors?: string[]; 31}; 32 33type GitHubIssue = { 34 number: number; 35 title: string; 36 body: string | null; 37 html_url: string; 38 state: string; 39 labels: Array<{ name?: string; color?: string } | string>; 40 user: { login: string; avatar_url: string } | null; 41 pull_request?: unknown; 42}; 43 44type GitHubComment = { 45 id: number; 46 body: string; 47 html_url: string; 48 user: { login: string; avatar_url: string } | null; 49 created_at: string; 50}; 51 52type GitHubPullRequest = { 53 number: number; 54 title: string; 55 body: string | null; 56 html_url: string; 57 state: string; 58 head: { ref: string }; 59 user: { login: string; avatar_url: string } | null; 60}; 61 62export async function importIssues(projectId: string): Promise<ImportResult> { 63 const errors: string[] = []; 64 let imported = 0; 65 let updated = 0; 66 let skipped = 0; 67 68 const project = await db.query.projectTable.findFirst({ 69 where: eq(projectTable.id, projectId), 70 }); 71 72 if (!project) { 73 throw new HTTPException(404, { message: "Project not found" }); 74 } 75 76 const integration = await db.query.integrationTable.findFirst({ 77 where: and( 78 eq(integrationTable.projectId, projectId), 79 eq(integrationTable.type, "github"), 80 ), 81 }); 82 83 if (!integration) { 84 throw new HTTPException(404, { message: "GitHub integration not found" }); 85 } 86 87 if (!integration.isActive) { 88 throw new HTTPException(400, { 89 message: "GitHub integration is not active", 90 }); 91 } 92 93 const config = JSON.parse(integration.config) as GitHubConfig; 94 95 if (!config.installationId) { 96 throw new HTTPException(400, { 97 message: "GitHub installation ID not configured", 98 }); 99 } 100 101 const octokit = await getInstallationOctokit(config.installationId); 102 103 const allIssues: GitHubIssue[] = []; 104 let page = 1; 105 const perPage = 100; 106 107 while (true) { 108 const { data: issues } = await octokit.rest.issues.listForRepo({ 109 owner: config.repositoryOwner, 110 repo: config.repositoryName, 111 state: "open", 112 per_page: perPage, 113 page, 114 }); 115 116 if (issues.length === 0) break; 117 118 const issuesOnly = issues.filter( 119 (issue) => !issue.pull_request, 120 ) as GitHubIssue[]; 121 allIssues.push(...issuesOnly); 122 123 if (issues.length < perPage) break; 124 page++; 125 } 126 127 for (const issue of allIssues) { 128 try { 129 const result = await importSingleIssue( 130 issue, 131 integration.id, 132 projectId, 133 project.workspaceId, 134 config, 135 octokit, 136 ); 137 138 if (result === "imported") { 139 imported++; 140 } else if (result === "updated") { 141 updated++; 142 } else { 143 skipped++; 144 } 145 } catch (error) { 146 const errorMessage = 147 error instanceof Error ? error.message : String(error); 148 errors.push(`Issue #${issue.number}: ${errorMessage}`); 149 } 150 } 151 152 const allPRs: GitHubPullRequest[] = []; 153 page = 1; 154 155 while (true) { 156 const { data: pulls } = await octokit.rest.pulls.list({ 157 owner: config.repositoryOwner, 158 repo: config.repositoryName, 159 state: "open", 160 per_page: perPage, 161 page, 162 }); 163 164 if (pulls.length === 0) break; 165 166 allPRs.push(...(pulls as GitHubPullRequest[])); 167 168 if (pulls.length < perPage) break; 169 page++; 170 } 171 172 for (const pr of allPRs) { 173 try { 174 await linkPullRequestToTask( 175 pr, 176 integration.id, 177 projectId, 178 project.slug, 179 config, 180 ); 181 } catch (error) { 182 const errorMessage = 183 error instanceof Error ? error.message : String(error); 184 errors.push(`PR #${pr.number}: ${errorMessage}`); 185 } 186 } 187 188 return { 189 imported, 190 updated, 191 skipped, 192 ...(errors.length > 0 ? { errors } : {}), 193 }; 194} 195 196async function importSingleIssue( 197 issue: GitHubIssue, 198 integrationId: string, 199 projectId: string, 200 workspaceId: string, 201 config: GitHubConfig, 202 octokit: Awaited<ReturnType<typeof getInstallationOctokit>>, 203): Promise<"imported" | "updated" | "skipped"> { 204 const existingLink = await findExternalLink( 205 integrationId, 206 "issue", 207 issue.number.toString(), 208 ); 209 210 const priority = extractIssuePriority(issue.labels); 211 const status = extractIssueStatus(issue.labels); 212 213 if (existingLink) { 214 const updateData: Record<string, unknown> = { 215 title: issue.title, 216 description: formatTaskDescriptionFromIssue(issue.body), 217 }; 218 219 if (priority) updateData.priority = priority; 220 if (status) updateData.status = status; 221 222 await db 223 .update(taskTable) 224 .set(updateData) 225 .where(eq(taskTable.id, existingLink.taskId)); 226 227 await importLabelsForTask(issue.labels, existingLink.taskId, workspaceId); 228 229 await importCommentsForTask( 230 issue.number, 231 existingLink.taskId, 232 config, 233 octokit, 234 ); 235 236 return "updated"; 237 } 238 239 const nextTaskNumber = await getNextTaskNumber(projectId); 240 241 const taskValues: typeof taskTable.$inferInsert = { 242 projectId, 243 userId: null, 244 title: issue.title, 245 description: formatTaskDescriptionFromIssue(issue.body), 246 status: status || "to-do", 247 priority: priority || null, 248 number: nextTaskNumber + 1, 249 }; 250 251 const [createdTask] = await db 252 .insert(taskTable) 253 .values(taskValues) 254 .returning(); 255 256 if (!createdTask) { 257 throw new Error("Failed to create task"); 258 } 259 260 await createExternalLink({ 261 taskId: createdTask.id, 262 integrationId, 263 resourceType: "issue", 264 externalId: issue.number.toString(), 265 url: issue.html_url, 266 title: issue.title, 267 metadata: { 268 state: issue.state, 269 createdFrom: "github-import", 270 author: issue.user?.login, 271 }, 272 }); 273 274 await importLabelsForTask(issue.labels, createdTask.id, workspaceId); 275 276 await importCommentsForTask(issue.number, createdTask.id, config, octokit); 277 278 return "imported"; 279} 280 281async function importLabelsForTask( 282 issueLabels: GitHubIssue["labels"], 283 taskId: string, 284 workspaceId: string, 285): Promise<void> { 286 const nonSystemLabels = issueLabels 287 .map((label) => { 288 if (typeof label === "string") { 289 return { name: label, color: "#6B7280" }; 290 } 291 return { 292 name: label.name, 293 color: label.color ? `#${label.color}` : "#6B7280", 294 }; 295 }) 296 .filter( 297 (label) => 298 label.name && 299 !label.name.startsWith("priority:") && 300 !label.name.startsWith("status:"), 301 ) as Array<{ name: string; color: string }>; 302 303 for (const labelData of nonSystemLabels) { 304 const existingLabelOnTask = await db.query.labelTable.findFirst({ 305 where: and( 306 eq(labelTable.taskId, taskId), 307 eq(labelTable.name, labelData.name), 308 ), 309 }); 310 311 if (existingLabelOnTask) { 312 continue; 313 } 314 315 const existingWorkspaceLabel = await db.query.labelTable.findFirst({ 316 where: and( 317 eq(labelTable.workspaceId, workspaceId), 318 eq(labelTable.name, labelData.name), 319 ), 320 }); 321 322 const colorToUse = existingWorkspaceLabel?.color || labelData.color; 323 324 await db.insert(labelTable).values({ 325 name: labelData.name, 326 color: colorToUse, 327 taskId, 328 workspaceId, 329 }); 330 } 331} 332 333async function importCommentsForTask( 334 issueNumber: number, 335 taskId: string, 336 config: GitHubConfig, 337 octokit: Awaited<ReturnType<typeof getInstallationOctokit>>, 338): Promise<void> { 339 const allComments: GitHubComment[] = []; 340 let page = 1; 341 const perPage = 100; 342 343 while (true) { 344 const { data: comments } = await octokit.rest.issues.listComments({ 345 owner: config.repositoryOwner, 346 repo: config.repositoryName, 347 issue_number: issueNumber, 348 per_page: perPage, 349 page, 350 }); 351 352 if (comments.length === 0) break; 353 354 allComments.push(...(comments as GitHubComment[])); 355 356 if (comments.length < perPage) break; 357 page++; 358 } 359 360 const existingActivities = await db.query.activityTable.findMany({ 361 where: and( 362 eq(activityTable.taskId, taskId), 363 eq(activityTable.externalSource, "github"), 364 ), 365 }); 366 367 const existingExternalUrls = new Set( 368 existingActivities.filter((a) => a.externalUrl).map((a) => a.externalUrl), 369 ); 370 371 for (const comment of allComments) { 372 const username = comment.user?.login ?? ""; 373 if (username.endsWith("[bot]")) { 374 continue; 375 } 376 377 if (existingExternalUrls.has(comment.html_url)) { 378 continue; 379 } 380 381 await db.insert(activityTable).values({ 382 taskId, 383 type: "comment", 384 content: comment.body, 385 externalUserName: comment.user?.login ?? "Unknown", 386 externalUserAvatar: comment.user?.avatar_url ?? null, 387 externalSource: "github", 388 externalUrl: comment.html_url, 389 }); 390 } 391} 392 393async function linkPullRequestToTask( 394 pr: GitHubPullRequest, 395 integrationId: string, 396 projectId: string, 397 projectSlug: string, 398 config: GitHubConfig, 399): Promise<void> { 400 const taskNumber = extractTaskNumber( 401 pr.head.ref, 402 pr.title, 403 pr.body ?? undefined, 404 config, 405 projectSlug, 406 ); 407 408 if (!taskNumber) { 409 return; 410 } 411 412 const task = await findTaskByNumber(projectId, taskNumber); 413 414 if (!task) { 415 return; 416 } 417 418 const existingLink = await findExternalLink( 419 integrationId, 420 "pull_request", 421 pr.number.toString(), 422 ); 423 424 if (existingLink) { 425 return; 426 } 427 428 await createExternalLink({ 429 taskId: task.id, 430 integrationId, 431 resourceType: "pull_request", 432 externalId: pr.number.toString(), 433 url: pr.html_url, 434 title: pr.title, 435 metadata: { 436 state: pr.state, 437 branch: pr.head.ref, 438 author: pr.user?.login, 439 }, 440 }); 441}