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 cd7cada2f86b4e866a15b4323bb8d6d7ab5bba8b 430 lines 11 kB view raw
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}