repos.tsx
Raw
1import { readFileSync, rmSync } from "node:fs";
2import path from "node:path";
3import { Elysia, t } from "elysia";
4import { fileTypeFromBuffer } from "file-type";
5import { COMMITTER_EMAIL, COMMITTER_NAME } from "../config.ts";
6import {
7 ALLOWED_SIGNERS_PATH,
8 COMMITS_PER_PAGE,
9 REPOS_PER_PAGE,
10 VALID_REPO_NAME_RE,
11} from "../constants.ts";
12import { db, type LabelRow } from "../db/index.ts";
13import { redirect } from "../lib/redirect.ts";
14import { requireAdmin, resolveSession } from "../middleware/session.ts";
15import { git, repoPath } from "../services/git.ts";
16import {
17 hasBinaryContent,
18 prepareDiff,
19 serveFile,
20} from "../services/highlightWorker.ts";
21import { renderMarkdown } from "../services/markdown.ts";
22import { ensureRepoRecord, repoDiskExists } from "../services/repoSync.ts";
23import { html } from "../views/render.tsx";
24import { CommitDetail } from "../views/repos/CommitDetail.tsx";
25import { CommitLog } from "../views/repos/CommitLog.tsx";
26import { FileBlob } from "../views/repos/FileBlob.tsx";
27import { FileEdit } from "../views/repos/FileEdit.tsx";
28import { FileTree } from "../views/repos/FileTree.tsx";
29import { NewRepo } from "../views/repos/NewRepo.tsx";
30import { RepoHome } from "../views/repos/RepoHome.tsx";
31import { RepoList } from "../views/repos/RepoList.tsx";
32import { RepoSettings } from "../views/repos/RepoSettings.tsx";
33
34async function getRepo(name: string, isAdmin: boolean) {
35 if (!repoDiskExists(name)) return null;
36 const repo = await ensureRepoRecord(name);
37 if (repo.is_private && !isAdmin) return null;
38 return repo;
39}
40
41async function mimeForContent(
42 filename: string,
43 content: Buffer,
44): Promise<string> {
45 const result = await fileTypeFromBuffer(content);
46 if (result) return result.mime;
47
48 const typeFromName = Bun.file(filename).type;
49 if (typeFromName !== "application/octet-stream") {
50 return typeFromName;
51 }
52
53 return hasBinaryContent(content.subarray(0, 8000))
54 ? "application/octet-stream"
55 : "text/plain; charset=utf-8";
56}
57
58async function readReadme(
59 repo: string,
60 ref: string,
61 dir = "",
62): Promise<{ content: Buffer; filename: string } | null> {
63 const prefix = dir ? `${dir}/` : "";
64 const names = ["README.md", "readme.md", "README", "readme"];
65 const results = await Promise.all(
66 names.map((n) => git.show(repo, ref, `${prefix}${n}`)),
67 );
68 for (let i = 0; i < results.length; i++) {
69 if (results[i]) {
70 return {
71 content: results[i] as Buffer,
72 filename: `${prefix}${names[i]}`,
73 };
74 }
75 }
76 return null;
77}
78
79export const repoRoutes = new Elysia()
80 .get("/allowed_signers", () => {
81 return new Response(readFileSync(ALLOWED_SIGNERS_PATH), {
82 headers: { "Content-Type": "text/plain; charset=utf-8" },
83 });
84 })
85 .guard({
86 cookie: t.Cookie({
87 session: t.Optional(t.String()),
88 repo_sort: t.Optional(t.String()),
89 }),
90 })
91 .post(
92 "/sort",
93 ({ body }) => {
94 const sort = body.sort === "name" ? "name" : "created";
95 return redirect(
96 "/",
97 `repo_sort=${sort}; Path=/; SameSite=Lax; Max-Age=${365 * 24 * 60 * 60}`,
98 );
99 },
100 { body: t.Object({ sort: t.String() }) },
101 )
102 .get(
103 "/",
104 async ({ cookie, query }) => {
105 const user = await resolveSession(cookie.session.value);
106 const search = query.q?.trim() || undefined;
107 const page = Math.max(1, query.page ?? 1);
108 const sort = cookie.repo_sort.value === "name" ? "name" : "created";
109
110 const isAdmin = user?.isAdmin ?? false;
111
112 const countResult = await db
113 .selectFrom("repositories")
114 .select(db.fn.countAll<number>().as("count"))
115 .where((eb) =>
116 isAdmin
117 ? eb.or([
118 eb("is_private", "=", 0),
119 eb("is_private", "=", 1),
120 ])
121 : eb("is_private", "=", 0),
122 )
123 .$if(!!search, (qb) =>
124 qb.where((eb) =>
125 eb.or([
126 eb("name", "like", `%${search}%`),
127 eb("description", "like", `%${search}%`),
128 ]),
129 ),
130 )
131 .executeTakeFirst();
132
133 const totalCount = Number(countResult?.count ?? 0);
134 const totalPages = Math.max(
135 1,
136 Math.ceil(totalCount / REPOS_PER_PAGE),
137 );
138 const safePage = Math.min(page, totalPages);
139
140 const repos = await db
141 .selectFrom("repositories")
142 .selectAll()
143 .where((eb) =>
144 isAdmin
145 ? eb.or([
146 eb("is_private", "=", 0),
147 eb("is_private", "=", 1),
148 ])
149 : eb("is_private", "=", 0),
150 )
151 .$if(!!search, (qb) =>
152 qb.where((eb) =>
153 eb.or([
154 eb("name", "like", `%${search}%`),
155 eb("description", "like", `%${search}%`),
156 ]),
157 ),
158 )
159 .orderBy("is_pinned", "desc")
160 .$if(sort === "name", (qb) => qb.orderBy("name", "asc"))
161 .$if(sort === "created", (qb) =>
162 qb.orderBy("created_at", "desc"),
163 )
164 .limit(REPOS_PER_PAGE)
165 .offset((safePage - 1) * REPOS_PER_PAGE)
166 .execute();
167
168 const searchParam = search
169 ? `&q=${encodeURIComponent(search)}`
170 : "";
171 const pagination = {
172 page: safePage,
173 totalPages,
174 pageUrlTemplate: `/?page={page}${searchParam}`,
175 };
176
177 return html(
178 <RepoList
179 user={user}
180 repos={repos}
181 search={search}
182 sort={sort}
183 pagination={pagination}
184 />,
185 );
186 },
187 {
188 query: t.Object({
189 q: t.Optional(t.String()),
190 page: t.Optional(t.Numeric()),
191 }),
192 },
193 )
194
195 .get("/new", async ({ cookie }) => {
196 const user = await resolveSession(cookie.session.value);
197 const deny = requireAdmin(user);
198 if (deny) return deny;
199 return html(<NewRepo user={user!} />);
200 })
201
202 .post(
203 "/new",
204 async ({ body, cookie }) => {
205 const user = await resolveSession(cookie.session.value);
206 const deny = requireAdmin(user);
207 if (deny) return deny;
208
209 const { name, description, is_private, default_branch } = body;
210
211 if (!VALID_REPO_NAME_RE.test(name)) {
212 return html(
213 <NewRepo user={user!} error="Invalid repository name" />,
214 );
215 }
216
217 const branch = (default_branch?.trim() || "main").replace(
218 /[^a-zA-Z0-9._/-]/g,
219 "",
220 );
221
222 const existing = await db
223 .selectFrom("repositories")
224 .select("id")
225 .where("name", "=", name)
226 .executeTakeFirst();
227 if (existing) {
228 return html(
229 <NewRepo
230 user={user!}
231 error="Repository name already taken"
232 />,
233 );
234 }
235
236 const now = new Date().toISOString();
237 await db
238 .insertInto("repositories")
239 .values({
240 name,
241 description: description || null,
242 is_private: is_private === "1" ? 1 : 0,
243 default_branch: branch,
244 created_at: now,
245 })
246 .execute();
247
248 // Initialise the git repo after the DB record is committed. If
249 // git.init fails we roll back the DB record so the two stay in sync.
250 try {
251 await git.init(name, branch);
252 } catch (err) {
253 await db
254 .deleteFrom("repositories")
255 .where("name", "=", name)
256 .execute();
257 throw err;
258 }
259 return new Response(null, {
260 status: 302,
261 headers: { Location: `/${name}` },
262 });
263 },
264 {
265 body: t.Object({
266 name: t.String(),
267 description: t.Optional(t.String()),
268 is_private: t.Optional(t.String()),
269 default_branch: t.Optional(t.String()),
270 }),
271 },
272 )
273
274 .get("/:repo", async ({ params, cookie }) => {
275 const user = await resolveSession(cookie.session.value);
276 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
277 if (!repo) return new Response("Not found", { status: 404 });
278
279 const hasContent = await git.hasCommits(repo.name);
280 let readmeHtml: string | null = null;
281 let readmePath: string | undefined;
282 let entries: Awaited<ReturnType<typeof git.lsTree>> = [];
283 let branches: string[] = [];
284
285 if (hasContent) {
286 const [lsResult, branchResult, resolved] = await Promise.all([
287 git.lsTree(repo.name, repo.default_branch),
288 git.branches(repo.name),
289 git.resolveRef(repo.name, repo.default_branch),
290 ]);
291 entries = lsResult;
292 branches = branchResult;
293 const readme = await readReadme(repo.name, repo.default_branch);
294 if (readme) {
295 const key = resolved
296 ? `readme:${repo.name}:${resolved}:`
297 : undefined;
298 readmeHtml = renderMarkdown(
299 readme.content.toString("utf-8"),
300 key,
301 {
302 repo: repo.name,
303 ref: repo.default_branch,
304 dir: "",
305 },
306 );
307 readmePath = readme.filename;
308 }
309 }
310
311 return html(
312 <RepoHome
313 user={user}
314 repo={repo}
315 entries={entries}
316 readmeHtml={readmeHtml}
317 readmePath={readmePath}
318 hasContent={hasContent}
319 branches={branches}
320 />,
321 );
322 })
323
324 .get(
325 "/:repo/branch-switch",
326 async ({ params, query, cookie }) => {
327 const user = await resolveSession(cookie.session.value);
328 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
329 if (!repo) return new Response("Not found", { status: 404 });
330
331 const ref = query.rev?.trim();
332 if (!ref)
333 return new Response(null, {
334 status: 302,
335 headers: { Location: `/${repo.name}` },
336 });
337
338 const view = query.view;
339 const subpath = query.path ?? "";
340
341 if (view === "commits") {
342 return new Response(null, {
343 status: 302,
344 headers: { Location: `/${repo.name}/commits/${ref}` },
345 });
346 }
347 if (view === "blob" && subpath) {
348 return new Response(null, {
349 status: 302,
350 headers: {
351 Location: `/${repo.name}/blob/${ref}/${subpath}`,
352 },
353 });
354 }
355 const location = subpath
356 ? `/${repo.name}/tree/${ref}/${subpath}`
357 : `/${repo.name}/tree/${ref}`;
358 return new Response(null, {
359 status: 302,
360 headers: { Location: location },
361 });
362 },
363 {
364 query: t.Object({
365 rev: t.Optional(t.String()),
366 view: t.Optional(t.String()),
367 path: t.Optional(t.String()),
368 }),
369 },
370 )
371
372 .get("/:repo/tree/:ref", async ({ params, cookie }) => {
373 const user = await resolveSession(cookie.session.value);
374 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
375 if (!repo) return new Response("Not found", { status: 404 });
376
377 const resolved = await git.resolveRef(repo.name, params.ref);
378 if (!resolved) return new Response("Not found", { status: 404 });
379
380 const [entries, branches] = await Promise.all([
381 git.lsTree(repo.name, params.ref),
382 git.branches(repo.name),
383 ]);
384 const readme = await readReadme(repo.name, params.ref);
385 const readmeHtml = readme
386 ? renderMarkdown(
387 readme.content.toString("utf-8"),
388 `readme:${repo.name}:${resolved}:`,
389 { repo: repo.name, ref: params.ref, dir: "" },
390 )
391 : null;
392 return html(
393 <FileTree
394 user={user}
395 repo={repo}
396 ref={params.ref}
397 subpath=""
398 entries={entries}
399 branches={branches}
400 readmeHtml={readmeHtml}
401 readmePath={readme?.filename}
402 />,
403 );
404 })
405
406 .get("/:repo/tree/:ref/*", async ({ params, cookie }) => {
407 const user = await resolveSession(cookie.session.value);
408 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
409 if (!repo) return new Response("Not found", { status: 404 });
410
411 const resolved = await git.resolveRef(repo.name, params.ref);
412 if (!resolved) return new Response("Not found", { status: 404 });
413
414 const subpath = decodeURIComponent(params["*"]);
415 const [entries, branches] = await Promise.all([
416 git.lsTree(repo.name, params.ref, subpath),
417 git.branches(repo.name),
418 ]);
419 if (entries.length === 0) {
420 // Could be a file — redirect to blob
421 return new Response(null, {
422 status: 302,
423 headers: {
424 Location: `/${repo.name}/blob/${params.ref}/${subpath}`,
425 },
426 });
427 }
428 const readme = await readReadme(repo.name, params.ref, subpath);
429 const readmeHtml = readme
430 ? renderMarkdown(
431 readme.content.toString("utf-8"),
432 `readme:${repo.name}:${resolved}:${subpath}`,
433 { repo: repo.name, ref: params.ref, dir: subpath },
434 )
435 : null;
436 return html(
437 <FileTree
438 user={user}
439 repo={repo}
440 ref={params.ref}
441 subpath={subpath}
442 entries={entries}
443 branches={branches}
444 readmeHtml={readmeHtml}
445 readmePath={readme?.filename}
446 />,
447 );
448 })
449
450 .get("/:repo/blob/:ref/*", async ({ params, cookie }) => {
451 const user = await resolveSession(cookie.session.value);
452 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
453 if (!repo) return new Response("Not found", { status: 404 });
454
455 const filePath = decodeURIComponent(params["*"]);
456 const [content, branches, commitSHA] = await Promise.all([
457 git.show(repo.name, params.ref, filePath),
458 git.branches(repo.name),
459 git.resolveRef(repo.name, params.ref),
460 ]);
461 if (!content || !commitSHA)
462 return new Response("Not found", { status: 404 });
463
464 const filename = path.basename(filePath);
465 const [view, markdownHtml] = await Promise.all([
466 serveFile(
467 content,
468 filename,
469 `${repo.name}:${commitSHA}:${filePath}`,
470 ),
471 /\.mdx?$/i.test(filename)
472 ? Promise.resolve(
473 renderMarkdown(
474 content.toString("utf-8"),
475 `${repo.name}:${commitSHA}:${filePath}`,
476 {
477 repo: repo.name,
478 ref: params.ref,
479 dir:
480 path.dirname(filePath) === "."
481 ? ""
482 : path.dirname(filePath),
483 },
484 ),
485 )
486 : Promise.resolve(undefined),
487 ]);
488 return html(
489 <FileBlob
490 user={user}
491 repo={repo}
492 ref={params.ref}
493 filePath={filePath}
494 view={view}
495 branches={branches}
496 markdownHtml={markdownHtml}
497 />,
498 );
499 })
500
501 .get("/:repo/raw/:ref/*", async ({ params, cookie, request }) => {
502 const user = await resolveSession(cookie.session.value);
503 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
504 if (!repo) return new Response("Not found", { status: 404 });
505
506 const filePath = decodeURIComponent(params["*"]);
507 const content = await git.show(repo.name, params.ref, filePath);
508 if (!content) return new Response("Not found", { status: 404 });
509
510 const filename = path.basename(filePath);
511 const contentType = await mimeForContent(filename, content);
512 const total = content.length;
513
514 const rangeHeader = request.headers.get("Range");
515 if (rangeHeader) {
516 const match = rangeHeader.match(/bytes=(\d*)-(\d*)/);
517 if (match) {
518 const start = match[1] ? parseInt(match[1], 10) : 0;
519 const end = match[2] ? parseInt(match[2], 10) : total - 1;
520 const clampedEnd = Math.min(end, total - 1);
521 return new Response(content.subarray(start, clampedEnd + 1), {
522 status: 206,
523 headers: {
524 "Content-Type": contentType,
525 "Content-Range": `bytes ${start}-${clampedEnd}/${total}`,
526 "Accept-Ranges": "bytes",
527 "Content-Length": String(clampedEnd - start + 1),
528 },
529 });
530 }
531 }
532
533 return new Response(content, {
534 headers: {
535 "Content-Type": contentType,
536 "Content-Disposition": `inline; filename="${filename}"`,
537 "Content-Length": String(total),
538 "Accept-Ranges": "bytes",
539 },
540 });
541 })
542
543 .get("/:repo/edit/:ref/*", async ({ params, cookie }) => {
544 const user = await resolveSession(cookie.session.value);
545 const deny = requireAdmin(user);
546 if (deny) return deny;
547 const repo = await getRepo(params.repo, true);
548 if (!repo) return new Response("Not found", { status: 404 });
549
550 const filePath = decodeURIComponent(params["*"]);
551 const branches = await git.branches(repo.name);
552 if (!branches.includes(params.ref))
553 return new Response("Not found", { status: 404 });
554
555 const content = await git.show(repo.name, params.ref, filePath);
556 if (!content) return new Response("Not found", { status: 404 });
557
558 if (hasBinaryContent(content.subarray(0, 8000)))
559 return new Response("Not found", { status: 404 });
560
561 return html(
562 <FileEdit
563 user={user!}
564 repo={repo}
565 ref={params.ref}
566 filePath={filePath}
567 content={content.toString("utf-8")}
568 />,
569 );
570 })
571
572 .post(
573 "/:repo/edit/:ref/*",
574 async ({ params, body, cookie }) => {
575 const user = await resolveSession(cookie.session.value);
576 const deny = requireAdmin(user);
577 if (deny) return deny;
578 const repo = await getRepo(params.repo, true);
579 if (!repo) return new Response("Not found", { status: 404 });
580
581 const filePath = decodeURIComponent(params["*"]);
582 const branches = await git.branches(repo.name);
583 if (!branches.includes(params.ref))
584 return new Response("Not found", { status: 404 });
585
586 const message =
587 body.message?.trim() || `Edited ${path.basename(filePath)}`;
588 const content = (body.content ?? "").replaceAll("\r\n", "\n");
589
590 const commit = await git.editFile(
591 repo.name,
592 params.ref,
593 filePath,
594 content,
595 message,
596 COMMITTER_NAME,
597 COMMITTER_EMAIL,
598 );
599
600 return new Response(null, {
601 status: 302,
602 headers: {
603 Location: `/${repo.name}/commit/${commit}`,
604 },
605 });
606 },
607 {
608 body: t.Object({
609 content: t.Optional(t.String()),
610 message: t.Optional(t.String()),
611 }),
612 },
613 )
614
615 .get(
616 "/:repo/commits/:ref",
617 async ({ params, cookie, query }) => {
618 const user = await resolveSession(cookie.session.value);
619 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
620 if (!repo) return new Response("Not found", { status: 404 });
621
622 // Cursor-based pagination — O(1) regardless of history depth.
623 // `after` = SHA of the last commit on the previous page (resume cursor).
624 // `prev` = the `after` value used on the page that linked here, so we can
625 // reconstruct a "← Newer" link without a full history traversal.
626 const after = query.after?.trim() || null;
627 const prev = query.prev?.trim() || null;
628
629 const [rawCommits, branches] = await Promise.all([
630 // When `after` is set: start at that SHA and skip it (--skip=1 is O(1)),
631 // then fetch LIMIT+1 to detect whether another page exists.
632 after
633 ? git.log(repo.name, after, COMMITS_PER_PAGE + 1, 1)
634 : git.log(repo.name, params.ref, COMMITS_PER_PAGE + 1, 0),
635 git.branches(repo.name),
636 ]);
637
638 const hasNext = rawCommits.length > COMMITS_PER_PAGE;
639 const commits = rawCommits.slice(0, COMMITS_PER_PAGE);
640
641 // Build cursor URLs.
642 // "Older" advances past the last commit on this page.
643 // "Newer" goes back one page using the `prev` cursor saved in the URL,
644 // or to the first page if we're on page 2.
645 const base = `/${repo.name}/commits/${params.ref}`;
646 const olderUrl = hasNext
647 ? `${base}?after=${commits[commits.length - 1]?.hash}&prev=${after ?? ""}`
648 : null;
649 const newerUrl = after
650 ? prev
651 ? `${base}?after=${prev}`
652 : base
653 : null;
654
655 return html(
656 <CommitLog
657 user={user}
658 repo={repo}
659 ref={params.ref}
660 commits={commits}
661 branches={branches}
662 olderUrl={olderUrl}
663 newerUrl={newerUrl}
664 />,
665 );
666 },
667 {
668 query: t.Object({
669 after: t.Optional(t.String()),
670 prev: t.Optional(t.String()),
671 }),
672 },
673 )
674
675 .get("/:repo/commit/:sha", async ({ params, cookie }) => {
676 const user = await resolveSession(cookie.session.value);
677 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
678 if (!repo) return new Response("Not found", { status: 404 });
679
680 const [meta, rawDiff] = await Promise.all([
681 git.commitMeta(repo.name, params.sha),
682 git.diff(repo.name, params.sha),
683 ]);
684 if (!meta) return new Response("Commit not found", { status: 404 });
685 const files = await prepareDiff(
686 rawDiff,
687 `commit:${repo.name}:${params.sha}`,
688 repo.name,
689 );
690 return html(
691 <CommitDetail
692 user={user}
693 repo={repo}
694 sha={params.sha}
695 meta={meta}
696 files={files}
697 />,
698 );
699 })
700
701 .get("/:repo/settings", async ({ params, query, cookie }) => {
702 const user = await resolveSession(cookie.session.value);
703 const deny = requireAdmin(user);
704 if (deny) return deny;
705 const repo = await getRepo(params.repo, true);
706 if (!repo) return new Response("Not found", { status: 404 });
707 const branches = await git.branches(repo.name);
708 const labels = await db
709 .selectFrom("labels")
710 .selectAll()
711 .where("repo_id", "=", repo.id)
712 .orderBy("name", "asc")
713 .execute();
714 const success = typeof query.success === "string" ? query.success : undefined;
715 const error = typeof query.error === "string" ? query.error : undefined;
716 return html(
717 <RepoSettings
718 user={user!}
719 repo={repo}
720 branches={branches}
721 labels={labels}
722 success={success}
723 error={error}
724 />,
725 );
726 })
727
728 .post(
729 "/:repo/settings",
730 async ({ params, body, cookie }) => {
731 const user = await resolveSession(cookie.session.value);
732 const deny = requireAdmin(user);
733 if (deny) return deny;
734 const repo = await getRepo(params.repo, true);
735 if (!repo) return new Response("Not found", { status: 404 });
736
737 const {
738 description,
739 is_private,
740 is_pinned,
741 default_branch,
742 issue_template,
743 patch_template,
744 } = body;
745
746 const branches = await git.branches(repo.name);
747 const newBranch = default_branch?.trim() || repo.default_branch;
748
749 // Validate the selected branch exists (only if repo has commits)
750 if (branches.length > 0 && !branches.includes(newBranch)) {
751 return redirect(
752 `/${repo.name}/settings?error=${encodeURIComponent(`Branch "${newBranch}" does not exist.`)}`,
753 );
754 }
755
756 await db
757 .updateTable("repositories")
758 .set({
759 description: description?.trim() || null,
760 is_private: is_private === "1" ? 1 : 0,
761 is_pinned: is_pinned === "1" ? 1 : 0,
762 default_branch: newBranch,
763 issue_template: issue_template?.trim() || null,
764 patch_template: patch_template?.trim() || null,
765 })
766 .where("id", "=", repo.id)
767 .execute();
768
769 // Keep git HEAD in sync if the branch actually exists
770 if (branches.includes(newBranch)) {
771 await git.setHead(repo.name, newBranch).catch(() => {});
772 }
773
774 return redirect(`/${repo.name}/settings?success=Settings+saved.`);
775 },
776 {
777 body: t.Object({
778 description: t.Optional(t.String()),
779 is_private: t.Optional(t.String()),
780 is_pinned: t.Optional(t.String()),
781 default_branch: t.Optional(t.String()),
782 issue_template: t.Optional(t.String()),
783 patch_template: t.Optional(t.String()),
784 }),
785 },
786 )
787
788 .post("/:repo/settings/delete", async ({ params, cookie }) => {
789 const user = await resolveSession(cookie.session.value);
790 const deny = requireAdmin(user);
791 if (deny) return deny;
792 const repo = await getRepo(params.repo, true);
793 if (!repo) return new Response("Not found", { status: 404 });
794
795 // Remove the on-disk repo first. If this fails (e.g. permission error),
796 // we abort before touching the DB so the repo remains accessible.
797 rmSync(repoPath(repo.name), { recursive: true, force: true });
798 await db.deleteFrom("repositories").where("id", "=", repo.id).execute();
799
800 return new Response(null, { status: 302, headers: { Location: "/" } });
801 })
802
803 .post(
804 "/:repo/settings/labels",
805 async ({ params, body, cookie }) => {
806 const user = await resolveSession(cookie.session.value);
807 const deny = requireAdmin(user);
808 if (deny) return deny;
809 const repo = await getRepo(params.repo, true);
810 if (!repo) return new Response("Not found", { status: 404 });
811
812 const name = body.name?.trim();
813 const color = body.color?.trim();
814
815 if (!name || name.length > 50) {
816 return redirect(
817 `/${repo.name}/settings?error=${encodeURIComponent("Label name must be 1–50 characters.")}`,
818 );
819 }
820 if (!color || !/^#[0-9a-fA-F]{6}$/.test(color)) {
821 return redirect(
822 `/${repo.name}/settings?error=${encodeURIComponent("Invalid color.")}`,
823 );
824 }
825
826 try {
827 await db
828 .insertInto("labels")
829 .values({
830 repo_id: repo.id,
831 name,
832 color,
833 created_at: new Date().toISOString(),
834 })
835 .execute();
836 } catch {
837 return redirect(
838 `/${repo.name}/settings?error=${encodeURIComponent("A label with that name already exists.")}`,
839 );
840 }
841
842 return redirect(`/${repo.name}/settings?success=Label+created.`);
843 },
844 {
845 body: t.Object({
846 name: t.String(),
847 color: t.String(),
848 }),
849 },
850 )
851
852 .post(
853 "/:repo/settings/labels/delete",
854 async ({ params, body, cookie }) => {
855 const user = await resolveSession(cookie.session.value);
856 const deny = requireAdmin(user);
857 if (deny) return deny;
858 const repo = await getRepo(params.repo, true);
859 if (!repo) return new Response("Not found", { status: 404 });
860
861 const label = await db
862 .selectFrom("labels")
863 .select(["id", "repo_id"])
864 .where("id", "=", body.id)
865 .executeTakeFirst();
866
867 if (!label || label.repo_id !== repo.id) {
868 return redirect(
869 `/${repo.name}/settings?error=${encodeURIComponent("Label not found.")}`,
870 );
871 }
872
873 await db.deleteFrom("labels").where("id", "=", body.id).execute();
874
875 return redirect(`/${repo.name}/settings?success=Label+deleted.`);
876 },
877 {
878 body: t.Object({ id: t.Numeric() }),
879 },
880 );
881