kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { eq } from "drizzle-orm";
2import db from "../../../database";
3import { externalLinkTable } from "../../../database/schema";
4import { getInstallationOctokit } from "./github-app";
5
6const namedColorToHex: Record<string, string> = {
7 red: "EF4444",
8 orange: "F97316",
9 amber: "F59E0B",
10 yellow: "EAB308",
11 lime: "84CC16",
12 green: "22C55E",
13 emerald: "10B981",
14 teal: "14B8A6",
15 cyan: "06B6D4",
16 sky: "0EA5E9",
17 blue: "3B82F6",
18 indigo: "6366F1",
19 violet: "8B5CF6",
20 purple: "A855F7",
21 fuchsia: "D946EF",
22 pink: "EC4899",
23 rose: "F43F5E",
24 gray: "6B7280",
25 slate: "64748B",
26 zinc: "71717A",
27 neutral: "737373",
28 stone: "78716C",
29};
30
31function toHexColor(color: string): string {
32 const lower = color.toLowerCase().replace(/^#/, "");
33 if (namedColorToHex[lower]) {
34 return namedColorToHex[lower];
35 }
36 if (/^[0-9a-f]{6}$/i.test(lower)) {
37 return lower;
38 }
39 if (/^[0-9a-f]{3}$/i.test(lower)) {
40 const [r, g, b] = lower.split("");
41 return `${r}${r}${g}${g}${b}${b}`;
42 }
43 return "6B7280";
44}
45
46async function getGitHubContext(taskId: string) {
47 const externalLink = await db.query.externalLinkTable.findFirst({
48 where: eq(externalLinkTable.taskId, taskId),
49 with: {
50 integration: true,
51 },
52 });
53
54 if (!externalLink || externalLink.resourceType !== "issue") {
55 return null;
56 }
57
58 const integration = externalLink.integration;
59 if (!integration || integration.type !== "github") {
60 return null;
61 }
62
63 let config: {
64 repositoryOwner: string;
65 repositoryName: string;
66 installationId?: number;
67 };
68 try {
69 config = JSON.parse(integration.config);
70 } catch {
71 return null;
72 }
73
74 if (!config.installationId) {
75 return null;
76 }
77
78 const octokit = await getInstallationOctokit(config.installationId);
79 if (!octokit) {
80 return null;
81 }
82
83 return {
84 octokit,
85 owner: config.repositoryOwner,
86 repo: config.repositoryName,
87 issueNumber: Number.parseInt(externalLink.externalId, 10),
88 };
89}
90
91export async function syncLabelToGitHub(
92 taskId: string,
93 labelName: string,
94 labelColor: string,
95) {
96 const ctx = await getGitHubContext(taskId);
97 if (!ctx) return;
98
99 const { octokit, owner, repo, issueNumber } = ctx;
100 const color = toHexColor(labelColor);
101
102 try {
103 await octokit.rest.issues.getLabel({
104 owner,
105 repo,
106 name: labelName,
107 });
108 } catch {
109 try {
110 await octokit.rest.issues.createLabel({
111 owner,
112 repo,
113 name: labelName,
114 color,
115 });
116 } catch (createError) {
117 console.error(
118 `Failed to create label "${labelName}" in GitHub:`,
119 createError,
120 );
121 return;
122 }
123 }
124
125 try {
126 await octokit.rest.issues.addLabels({
127 owner,
128 repo,
129 issue_number: issueNumber,
130 labels: [labelName],
131 });
132 } catch (error) {
133 console.error(`Failed to add label "${labelName}" to GitHub issue:`, error);
134 }
135}
136
137export async function removeLabelFromGitHub(taskId: string, labelName: string) {
138 const ctx = await getGitHubContext(taskId);
139 if (!ctx) return;
140
141 const { octokit, owner, repo, issueNumber } = ctx;
142
143 try {
144 await octokit.rest.issues.removeLabel({
145 owner,
146 repo,
147 issue_number: issueNumber,
148 name: labelName,
149 });
150 } catch (error) {
151 console.error(
152 `Failed to remove label "${labelName}" from GitHub issue:`,
153 error,
154 );
155 }
156}