kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { and, eq } from "drizzle-orm";
2import db from "../../../database";
3import { columnTable, projectTable, taskTable } from "../../../database/schema";
4import getNextTaskNumber from "../../../task/controllers/get-next-task-number";
5import type { GitHubConfig } from "../config";
6import { createExternalLink, findExternalLink } from "../services/link-manager";
7import { findAllIntegrationsByRepo } from "../services/task-service";
8import {
9 extractIssuePriority,
10 extractIssueStatus,
11} from "../utils/extract-priority";
12import { formatTaskDescriptionFromIssue } from "../utils/format";
13import { getGithubApp } from "../utils/github-app";
14import { addLabelsToIssue } from "../utils/labels";
15import { resolveTargetStatus } from "../utils/resolve-column";
16
17type IssueOpenedPayload = {
18 action: string;
19 issue: {
20 number: number;
21 title: string;
22 body: string | null;
23 html_url: string;
24 labels?: Array<string | { name?: string }>;
25 user: { login: string } | null;
26 };
27 repository: {
28 owner: { login: string };
29 name: string;
30 full_name: string;
31 };
32};
33
34export async function handleIssueOpened(payload: IssueOpenedPayload) {
35 const githubApp = getGithubApp();
36 if (!githubApp) {
37 return;
38 }
39
40 const { issue, repository } = payload;
41
42 const integrations = await findAllIntegrationsByRepo(
43 repository.owner.login,
44 repository.name,
45 );
46
47 if (integrations.length === 0) {
48 return;
49 }
50
51 for (const integration of integrations) {
52 const config = JSON.parse(integration.config) as GitHubConfig;
53 const projectId = integration.projectId;
54
55 const priority = extractIssuePriority(issue.labels);
56 const status = extractIssueStatus(issue.labels);
57
58 const existingLink = await findExternalLink(
59 integration.id,
60 "issue",
61 issue.number.toString(),
62 );
63
64 if (existingLink) {
65 console.log(
66 `Issue #${issue.number} already linked to task ${existingLink.taskId} in project ${projectId}, skipping`,
67 );
68 continue;
69 }
70
71 const nextTaskNumber = await getNextTaskNumber(projectId);
72
73 const resolvedStatus = await resolveTargetStatus(
74 projectId,
75 "issue_opened",
76 status || "to-do",
77 );
78
79 const targetStatus = resolvedStatus;
80 const targetColumn = await db.query.columnTable.findFirst({
81 where: and(
82 eq(columnTable.projectId, projectId),
83 eq(columnTable.slug, targetStatus),
84 ),
85 });
86
87 const taskValues: typeof taskTable.$inferInsert = {
88 projectId,
89 userId: null,
90 title: issue.title,
91 description: formatTaskDescriptionFromIssue(issue.body),
92 status: targetStatus,
93 columnId: targetColumn?.id ?? null,
94 priority: null,
95 number: nextTaskNumber + 1,
96 };
97
98 if (priority) taskValues.priority = priority;
99
100 const [createdTask] = await db
101 .insert(taskTable)
102 .values(taskValues)
103 .returning();
104
105 if (!createdTask) {
106 console.error("Failed to create task from GitHub issue");
107 continue;
108 }
109
110 await createExternalLink({
111 taskId: createdTask.id,
112 integrationId: integration.id,
113 resourceType: "issue",
114 externalId: issue.number.toString(),
115 url: issue.html_url,
116 title: issue.title,
117 metadata: {
118 state: "open",
119 createdFrom: "github",
120 author: issue.user?.login,
121 },
122 });
123
124 const project = await db.query.projectTable.findFirst({
125 where: eq(projectTable.id, projectId),
126 });
127
128 if (!project) {
129 console.error("Project not found for task linking comment");
130 continue;
131 }
132
133 const clientUrl = process.env.KANEO_CLIENT_URL || "http://localhost:5173";
134 const taskUrl = `${clientUrl}/dashboard/workspace/${project.workspaceId}/project/${projectId}/task/${createdTask.id}`;
135 const taskIdentifier = `${project.slug.toUpperCase()}-${createdTask.number}`;
136
137 try {
138 let installationId = config.installationId;
139 if (!installationId) {
140 const { data: installation } =
141 await githubApp.octokit.rest.apps.getRepoInstallation({
142 owner: repository.owner.login,
143 repo: repository.name,
144 });
145 installationId = installation.id;
146 }
147
148 const octokit = await githubApp.getInstallationOctokit(installationId);
149
150 const existingLabels =
151 issue.labels
152 ?.map((label) => (typeof label === "string" ? label : label.name))
153 .filter(Boolean) || [];
154
155 const labelsToAdd: string[] = [];
156
157 if (priority && !existingLabels.includes(`priority:${priority}`)) {
158 labelsToAdd.push(`priority:${priority}`);
159 }
160
161 if (status && !existingLabels.includes(`status:${status}`)) {
162 labelsToAdd.push(`status:${status}`);
163 }
164
165 if (labelsToAdd.length > 0) {
166 await addLabelsToIssue(
167 octokit,
168 repository.owner.login,
169 repository.name,
170 issue.number,
171 labelsToAdd,
172 );
173 }
174
175 if (config.commentTaskLinkOnGitHubIssue !== false) {
176 await octokit.rest.issues.createComment({
177 owner: repository.owner.login,
178 repo: repository.name,
179 issue_number: issue.number,
180 body: `[${taskIdentifier}](${taskUrl})`,
181 });
182 }
183 } catch (error) {
184 console.error("Failed to process GitHub issue:", error);
185 }
186 }
187}