kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import type { GiteaConfig } from "../config";
2import { normalizeGiteaBaseUrl } from "../config";
3
4export type GiteaLabel = {
5 id: number;
6 name: string;
7 color?: string;
8};
9
10export type GiteaIssue = {
11 id: number;
12 number: number;
13 title: string;
14 body: string | null;
15 html_url: string;
16 state: string;
17 labels?: GiteaLabel[];
18 user?: { login?: string; username?: string; avatar_url?: string } | null;
19 pull_request?: unknown;
20};
21
22export type GiteaComment = {
23 id: number;
24 body: string;
25 html_url: string;
26 user?: { login?: string; username?: string; avatar_url?: string } | null;
27 created_at: string;
28};
29
30export type GiteaPullRequest = {
31 number: number;
32 title: string;
33 body: string | null;
34 html_url: string;
35 state: string;
36 head?: { ref?: string };
37 user?: { login?: string; username?: string; avatar_url?: string } | null;
38 merged?: boolean;
39 merged_at?: string | null;
40};
41
42export class GiteaApiError extends Error {
43 constructor(
44 message: string,
45 public status: number,
46 public body?: string,
47 ) {
48 super(message);
49 this.name = "GiteaApiError";
50 }
51}
52
53function authHeaders(token: string): HeadersInit {
54 return {
55 Authorization: `token ${token}`,
56 "Content-Type": "application/json",
57 };
58}
59
60const GITEA_FETCH_TIMEOUT_MS = 10_000;
61
62export async function giteaFetch<T>(
63 baseUrl: string,
64 token: string,
65 path: string,
66 init?: RequestInit,
67): Promise<T | undefined> {
68 const root = normalizeGiteaBaseUrl(baseUrl);
69 const url = `${root}/api/v1${path.startsWith("/") ? path : `/${path}`}`;
70
71 const controller = new AbortController();
72 let timedOut = false;
73 const timeoutId = setTimeout(() => {
74 timedOut = true;
75 controller.abort();
76 }, GITEA_FETCH_TIMEOUT_MS);
77 if (init?.signal) {
78 if (init.signal.aborted) {
79 controller.abort();
80 } else {
81 init.signal.addEventListener("abort", () => controller.abort(), {
82 once: true,
83 });
84 }
85 }
86
87 try {
88 const res = await fetch(url, {
89 ...init,
90 signal: controller.signal,
91 headers: {
92 ...authHeaders(token),
93 ...init?.headers,
94 },
95 });
96
97 const text = await res.text();
98 clearTimeout(timeoutId);
99
100 if (!res.ok) {
101 throw new GiteaApiError(
102 `Gitea API error ${res.status}`,
103 res.status,
104 text,
105 );
106 }
107
108 if (res.status === 204 || text === "") {
109 return undefined;
110 }
111
112 try {
113 return JSON.parse(text) as T;
114 } catch {
115 throw new GiteaApiError(
116 "Gitea API returned invalid JSON",
117 res.status,
118 text,
119 );
120 }
121 } catch (error) {
122 clearTimeout(timeoutId);
123 if (error instanceof GiteaApiError) {
124 throw error;
125 }
126 if (error instanceof Error && error.name === "AbortError") {
127 if (timedOut) {
128 throw new GiteaApiError(
129 `Gitea request timed out after ${GITEA_FETCH_TIMEOUT_MS}ms`,
130 408,
131 );
132 }
133 throw error;
134 }
135 throw error;
136 }
137}
138
139export function createGiteaClient(
140 config: Pick<GiteaConfig, "baseUrl" | "accessToken">,
141) {
142 const { baseUrl, accessToken } = config;
143 const owner = (o: string, r: string) =>
144 `/repos/${encodeURIComponent(o)}/${encodeURIComponent(r)}`;
145
146 return {
147 async getRepo(
148 repositoryOwner: string,
149 repositoryName: string,
150 ): Promise<{
151 name: string;
152 owner: { login?: string; username?: string };
153 html_url: string;
154 private: boolean;
155 permissions?: { admin?: boolean; push?: boolean; pull?: boolean };
156 }> {
157 const repo = await giteaFetch<{
158 name: string;
159 owner: { login?: string; username?: string };
160 html_url: string;
161 private: boolean;
162 permissions?: { admin?: boolean; push?: boolean; pull?: boolean };
163 }>(baseUrl, accessToken, owner(repositoryOwner, repositoryName));
164 if (!repo) {
165 throw new GiteaApiError("Gitea repository response was empty", 500);
166 }
167 return repo;
168 },
169
170 async listUserRepos(
171 page = 1,
172 limit = 50,
173 ): Promise<
174 Array<{
175 id: number;
176 name: string;
177 full_name: string;
178 owner: { login?: string; username?: string };
179 private: boolean;
180 html_url: string;
181 }>
182 > {
183 const repos = await giteaFetch<
184 Array<{
185 id: number;
186 name: string;
187 full_name: string;
188 owner: { login?: string; username?: string };
189 private: boolean;
190 html_url: string;
191 }>
192 >(baseUrl, accessToken, `/user/repos?page=${page}&limit=${limit}`);
193 if (!repos) {
194 throw new GiteaApiError("Gitea repositories response was empty", 500);
195 }
196 return repos;
197 },
198
199 async createIssue(
200 repositoryOwner: string,
201 repositoryName: string,
202 body: { title: string; body?: string | null; closed?: boolean },
203 ): Promise<GiteaIssue> {
204 const issue = await giteaFetch<GiteaIssue>(
205 baseUrl,
206 accessToken,
207 `${owner(repositoryOwner, repositoryName)}/issues`,
208 {
209 method: "POST",
210 body: JSON.stringify(body),
211 },
212 );
213 if (!issue) {
214 throw new GiteaApiError("Gitea create issue response was empty", 500);
215 }
216 return issue;
217 },
218
219 async updateIssue(
220 repositoryOwner: string,
221 repositoryName: string,
222 index: number,
223 body: Record<string, unknown>,
224 ): Promise<GiteaIssue> {
225 const issue = await giteaFetch<GiteaIssue>(
226 baseUrl,
227 accessToken,
228 `${owner(repositoryOwner, repositoryName)}/issues/${index}`,
229 {
230 method: "PATCH",
231 body: JSON.stringify(body),
232 },
233 );
234 if (!issue) {
235 throw new GiteaApiError("Gitea update issue response was empty", 500);
236 }
237 return issue;
238 },
239
240 async listIssueComments(
241 repositoryOwner: string,
242 repositoryName: string,
243 index: number,
244 page: number,
245 limit: number,
246 ): Promise<GiteaComment[]> {
247 const comments = await giteaFetch<GiteaComment[]>(
248 baseUrl,
249 accessToken,
250 `${owner(repositoryOwner, repositoryName)}/issues/${index}/comments?page=${page}&limit=${limit}`,
251 );
252 if (!comments) {
253 throw new GiteaApiError("Gitea comments response was empty", 500);
254 }
255 return comments;
256 },
257
258 async createIssueComment(
259 repositoryOwner: string,
260 repositoryName: string,
261 index: number,
262 body: string,
263 ): Promise<GiteaComment> {
264 const comment = await giteaFetch<GiteaComment>(
265 baseUrl,
266 accessToken,
267 `${owner(repositoryOwner, repositoryName)}/issues/${index}/comments`,
268 {
269 method: "POST",
270 body: JSON.stringify({ body }),
271 },
272 );
273 if (!comment) {
274 throw new GiteaApiError("Gitea create comment response was empty", 500);
275 }
276 return comment;
277 },
278
279 async listLabels(
280 repositoryOwner: string,
281 repositoryName: string,
282 ): Promise<GiteaLabel[]> {
283 const labels = await giteaFetch<GiteaLabel[]>(
284 baseUrl,
285 accessToken,
286 `${owner(repositoryOwner, repositoryName)}/labels`,
287 );
288 if (!labels) {
289 throw new GiteaApiError("Gitea labels response was empty", 500);
290 }
291 return labels;
292 },
293
294 async createLabel(
295 repositoryOwner: string,
296 repositoryName: string,
297 name: string,
298 color: string,
299 ): Promise<GiteaLabel> {
300 const label = await giteaFetch<GiteaLabel>(
301 baseUrl,
302 accessToken,
303 `${owner(repositoryOwner, repositoryName)}/labels`,
304 {
305 method: "POST",
306 body: JSON.stringify({
307 name,
308 color: color.replace(/^#/, ""),
309 }),
310 },
311 );
312 if (!label) {
313 throw new GiteaApiError("Gitea create label response was empty", 500);
314 }
315 return label;
316 },
317
318 async addLabelsToIssue(
319 repositoryOwner: string,
320 repositoryName: string,
321 index: number,
322 labelIds: number[],
323 ) {
324 if (labelIds.length === 0) return;
325 const MAX_LABELS_PER_REQUEST = 50;
326 const path = `${owner(repositoryOwner, repositoryName)}/issues/${index}/labels`;
327 for (let i = 0; i < labelIds.length; i += MAX_LABELS_PER_REQUEST) {
328 const chunk = labelIds.slice(i, i + MAX_LABELS_PER_REQUEST);
329 await giteaFetch<unknown>(baseUrl, accessToken, path, {
330 method: "POST",
331 body: JSON.stringify({ labels: chunk }),
332 });
333 }
334 },
335
336 async replaceIssueLabels(
337 repositoryOwner: string,
338 repositoryName: string,
339 index: number,
340 labelIds: number[],
341 ) {
342 await giteaFetch<unknown>(
343 baseUrl,
344 accessToken,
345 `${owner(repositoryOwner, repositoryName)}/issues/${index}/labels`,
346 {
347 method: "PUT",
348 body: JSON.stringify({ labels: labelIds }),
349 },
350 );
351 },
352
353 async removeLabelFromIssue(
354 repositoryOwner: string,
355 repositoryName: string,
356 index: number,
357 labelId: number,
358 ) {
359 await giteaFetch<unknown>(
360 baseUrl,
361 accessToken,
362 `${owner(repositoryOwner, repositoryName)}/issues/${index}/labels/${labelId}`,
363 {
364 method: "DELETE",
365 },
366 );
367 },
368
369 async getIssue(
370 repositoryOwner: string,
371 repositoryName: string,
372 index: number,
373 ): Promise<GiteaIssue> {
374 const issue = await giteaFetch<GiteaIssue>(
375 baseUrl,
376 accessToken,
377 `${owner(repositoryOwner, repositoryName)}/issues/${index}`,
378 );
379 if (!issue) {
380 throw new GiteaApiError("Gitea issue response was empty", 500);
381 }
382 return issue;
383 },
384
385 async listIssues(
386 repositoryOwner: string,
387 repositoryName: string,
388 page: number,
389 state: "open" | "closed" | "all",
390 ): Promise<GiteaIssue[]> {
391 const issues = await giteaFetch<GiteaIssue[]>(
392 baseUrl,
393 accessToken,
394 `${owner(repositoryOwner, repositoryName)}/issues?state=${state}&page=${page}&limit=100`,
395 );
396 if (!issues) {
397 throw new GiteaApiError("Gitea issues response was empty", 500);
398 }
399 return issues;
400 },
401
402 async listPulls(
403 repositoryOwner: string,
404 repositoryName: string,
405 page: number,
406 ): Promise<GiteaPullRequest[]> {
407 const pulls = await giteaFetch<GiteaPullRequest[]>(
408 baseUrl,
409 accessToken,
410 `${owner(repositoryOwner, repositoryName)}/pulls?state=open&page=${page}&limit=100`,
411 );
412 if (!pulls) {
413 throw new GiteaApiError("Gitea pull requests response was empty", 500);
414 }
415 return pulls;
416 },
417 };
418}
419
420export async function verifyGiteaToken(baseUrl: string, token: string) {
421 const user = await giteaFetch<{ id: number; login: string }>(
422 normalizeGiteaBaseUrl(baseUrl),
423 token,
424 "/user",
425 );
426 if (!user) {
427 throw new GiteaApiError("Gitea user response was empty", 500);
428 }
429 return user;
430}