Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

repo-settings.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

repo-settings.tsxBlame461 lines · 1 contributor
79136bbClaude1/**
2 * Repository settings — description, visibility, default branch, danger zone.
3 */
4
5import { Hono } from "hono";
6import { eq, and } from "drizzle-orm";
7import { db } from "../db";
71cd5ecClaude8import { repositories, users, repoTransfers } from "../db/schema";
79136bbClaude9import { Layout } from "../views/layout";
10import { RepoHeader } from "../views/components";
11import { softAuth, requireAuth } from "../middleware/auth";
12import type { AuthEnv } from "../middleware/auth";
13import { listBranches } from "../git/repository";
14import { rm } from "fs/promises";
15
16const repoSettings = new Hono<AuthEnv>();
17
18repoSettings.use("*", softAuth);
19
20// Settings page
21repoSettings.get("/:owner/:repo/settings", requireAuth, async (c) => {
22 const { owner: ownerName, repo: repoName } = c.req.param();
23 const user = c.get("user")!;
24 const success = c.req.query("success");
25 const error = c.req.query("error");
26
27 const [owner] = await db
28 .select()
29 .from(users)
30 .where(eq(users.username, ownerName))
31 .limit(1);
32
33 if (!owner || owner.id !== user.id) {
34 return c.html(
35 <Layout title="Unauthorized" user={user}>
36 <div class="empty-state">
37 <h2>Unauthorized</h2>
38 <p>Only the repository owner can access settings.</p>
39 </div>
40 </Layout>,
41 403
42 );
43 }
44
45 const [repo] = await db
46 .select()
47 .from(repositories)
48 .where(
49 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
50 )
51 .limit(1);
52
53 if (!repo) return c.notFound();
54
55 const branches = await listBranches(ownerName, repoName);
56
57 return c.html(
58 <Layout title={`Settings — ${ownerName}/${repoName}`} user={user}>
59 <RepoHeader owner={ownerName} repo={repoName} />
60 <div style="max-width: 600px">
61 <h2 style="margin-bottom: 20px">Repository settings</h2>
62 {success && (
63 <div class="auth-success">{decodeURIComponent(success)}</div>
64 )}
65 {error && (
66 <div class="auth-error">{decodeURIComponent(error)}</div>
67 )}
68
69 <form
70 method="POST"
71 action={`/${ownerName}/${repoName}/settings`}
72 >
73 <div class="form-group">
74 <label for="description">Description</label>
75 <input
76 type="text"
77 id="description"
78 name="description"
79 value={repo.description || ""}
80 placeholder="A short description"
81 />
82 </div>
83 <div class="form-group">
84 <label for="default_branch">Default branch</label>
85 <select id="default_branch" name="default_branch">
86 {branches.length === 0 ? (
87 <option value={repo.defaultBranch}>
88 {repo.defaultBranch}
89 </option>
90 ) : (
91 branches.map((b) => (
92 <option value={b} selected={b === repo.defaultBranch}>
93 {b}
94 </option>
95 ))
96 )}
97 </select>
c02a55bClaude98 <div style="margin-top: 6px; font-size: 12px; display: flex; gap: 12px">
d6daf49Claude99 <a href={`/${ownerName}/${repoName}/settings/branches`}>
100 Manage branches (rename, …)
101 </a>
c02a55bClaude102 <a href={`/${ownerName}/${repoName}/branches/age`}>
103 Branch staleness report →
104 </a>
d6daf49Claude105 </div>
79136bbClaude106 </div>
107 <div class="form-group">
108 <label>Visibility</label>
109 <div class="visibility-options">
110 <label class="visibility-option">
111 <input
112 type="radio"
113 name="visibility"
114 value="public"
115 checked={!repo.isPrivate}
116 />
117 <div class="vis-label">Public</div>
118 </label>
119 <label class="visibility-option">
120 <input
121 type="radio"
122 name="visibility"
123 value="private"
124 checked={repo.isPrivate}
125 />
126 <div class="vis-label">Private</div>
127 </label>
128 </div>
129 </div>
130 <button type="submit" class="btn btn-primary">
131 Save changes
132 </button>
133 </form>
134
135 <div
71cd5ecClaude136 style="margin-top: 32px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
137 >
138 <h3 style="margin-bottom: 8px">Template repository</h3>
139 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
140 {repo.isTemplate
141 ? "This repository is a template. Users can click \u201cUse this template\u201d to create a new repository with the same files."
142 : "Mark this repository as a template so others can seed new repositories from its files."}
143 </p>
144 <form
145 method="POST"
146 action={`/${ownerName}/${repoName}/settings/template`}
147 >
148 <input
149 type="hidden"
150 name="template"
151 value={repo.isTemplate ? "0" : "1"}
152 />
153 <button type="submit" class="btn">
154 {repo.isTemplate
155 ? "Unmark as template"
156 : "Mark as template"}
157 </button>
158 </form>
159 </div>
160
161 <div
162 style="margin-top: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
163 >
164 <h3 style="margin-bottom: 8px">Transfer ownership</h3>
165 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
166 Transfer this repository to another user. The new owner can
167 accept or decline the transfer by attempting to view it.
168 </p>
169 <form
170 method="POST"
171 action={`/${ownerName}/${repoName}/settings/transfer`}
172 onsubmit="return confirm('Transfer this repository? The new owner will have full control.')"
173 >
174 <input
175 type="text"
176 name="new_owner"
177 placeholder="new-owner-username"
178 required
179 style="width:60%"
180 />{" "}
181 <button type="submit" class="btn">
182 Transfer
183 </button>
184 </form>
185 </div>
186
187 <div
188 style="margin-top: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
189 >
190 <h3 style="margin-bottom: 8px">
191 {repo.isArchived ? "Unarchive repository" : "Archive repository"}
192 </h3>
193 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
194 {repo.isArchived
195 ? "This repository is archived and read-only. Unarchive to allow pushes and issue/PR activity again."
196 : "Mark this repository as archived. It will become read-only — no pushes, no new issues or PRs. You can unarchive at any time."}
197 </p>
198 <form
199 method="POST"
200 action={`/${ownerName}/${repoName}/settings/archive`}
201 >
202 <input
203 type="hidden"
204 name="archive"
205 value={repo.isArchived ? "0" : "1"}
206 />
207 <button type="submit" class="btn">
208 {repo.isArchived ? "Unarchive" : "Archive"} this repository
209 </button>
210 </form>
211 </div>
212
213 <div
214 style="margin-top: 20px; padding: 20px; border: 1px solid var(--red); border-radius: var(--radius)"
79136bbClaude215 >
216 <h3 style="color: var(--red); margin-bottom: 12px">Danger zone</h3>
217 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
218 Permanently delete this repository and all its data.
219 </p>
220 <form
221 method="POST"
222 action={`/${ownerName}/${repoName}/settings/delete`}
223 onsubmit="return confirm('Are you sure? This cannot be undone.')"
224 >
225 <button type="submit" class="btn btn-danger">
226 Delete this repository
227 </button>
228 </form>
229 </div>
230 </div>
231 </Layout>
232 );
233});
234
235// Save settings
236repoSettings.post("/:owner/:repo/settings", requireAuth, async (c) => {
237 const { owner: ownerName, repo: repoName } = c.req.param();
238 const user = c.get("user")!;
239 const body = await c.req.parseBody();
240
241 const [owner] = await db
242 .select()
243 .from(users)
244 .where(eq(users.username, ownerName))
245 .limit(1);
246
247 if (!owner || owner.id !== user.id) {
248 return c.redirect(`/${ownerName}/${repoName}`);
249 }
250
251 await db
252 .update(repositories)
253 .set({
254 description: String(body.description || "").trim() || null,
255 defaultBranch: String(body.default_branch || "main"),
256 isPrivate: body.visibility === "private",
257 updatedAt: new Date(),
258 })
259 .where(
260 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
261 );
262
263 return c.redirect(
264 `/${ownerName}/${repoName}/settings?success=Settings+saved`
265 );
266});
267
71cd5ecClaude268// Toggle template flag
269repoSettings.post(
270 "/:owner/:repo/settings/template",
271 requireAuth,
272 async (c) => {
273 const { owner: ownerName, repo: repoName } = c.req.param();
274 const user = c.get("user")!;
275 const body = await c.req.parseBody();
276 const [owner] = await db
277 .select()
278 .from(users)
279 .where(eq(users.username, ownerName))
280 .limit(1);
281 if (!owner || owner.id !== user.id) {
282 return c.redirect(`/${ownerName}/${repoName}`);
283 }
284 const target = String(body.template || "1") === "1";
285 await db
286 .update(repositories)
287 .set({ isTemplate: target, updatedAt: new Date() })
288 .where(
289 and(
290 eq(repositories.ownerId, owner.id),
291 eq(repositories.name, repoName)
292 )
293 );
294 return c.redirect(
295 `/${ownerName}/${repoName}/settings?success=${
296 target ? "Marked+as+template" : "Unmarked+as+template"
297 }`
298 );
299 }
300);
301
302// Transfer repository to a new owner (by username)
303repoSettings.post(
304 "/:owner/:repo/settings/transfer",
305 requireAuth,
306 async (c) => {
307 const { owner: ownerName, repo: repoName } = c.req.param();
308 const user = c.get("user")!;
309 const body = await c.req.parseBody();
310 const newOwnerName = String(body.new_owner || "").trim();
311 if (!newOwnerName) {
312 return c.redirect(
313 `/${ownerName}/${repoName}/settings?error=New+owner+required`
314 );
315 }
316 const [owner] = await db
317 .select()
318 .from(users)
319 .where(eq(users.username, ownerName))
320 .limit(1);
321 if (!owner || owner.id !== user.id) {
322 return c.redirect(`/${ownerName}/${repoName}`);
323 }
324 const [newOwner] = await db
325 .select()
326 .from(users)
327 .where(eq(users.username, newOwnerName))
328 .limit(1);
329 if (!newOwner) {
330 return c.redirect(
331 `/${ownerName}/${repoName}/settings?error=User+not+found`
332 );
333 }
334 if (newOwner.id === owner.id) {
335 return c.redirect(
336 `/${ownerName}/${repoName}/settings?error=Same+owner`
337 );
338 }
339 // Reject if new owner already has a repo by this name
340 const [conflict] = await db
341 .select()
342 .from(repositories)
343 .where(
344 and(
345 eq(repositories.ownerId, newOwner.id),
346 eq(repositories.name, repoName)
347 )
348 )
349 .limit(1);
350 if (conflict) {
351 return c.redirect(
352 `/${ownerName}/${repoName}/settings?error=Target+owner+already+has+a+repo+by+that+name`
353 );
354 }
355 const [repo] = await db
356 .select()
357 .from(repositories)
358 .where(
359 and(
360 eq(repositories.ownerId, owner.id),
361 eq(repositories.name, repoName)
362 )
363 )
364 .limit(1);
365 if (!repo) return c.notFound();
366 await db
367 .update(repositories)
368 .set({ ownerId: newOwner.id, orgId: null, updatedAt: new Date() })
369 .where(eq(repositories.id, repo.id));
370 await db.insert(repoTransfers).values({
371 repositoryId: repo.id,
372 fromOwnerId: owner.id,
373 fromOrgId: repo.orgId,
374 toOwnerId: newOwner.id,
375 toOrgId: null,
376 initiatedBy: user.id,
377 });
378 return c.redirect(`/${newOwnerName}/${repoName}`);
379 }
380);
381
382// Archive / unarchive repository
383repoSettings.post(
384 "/:owner/:repo/settings/archive",
385 requireAuth,
386 async (c) => {
387 const { owner: ownerName, repo: repoName } = c.req.param();
388 const user = c.get("user")!;
389 const body = await c.req.parseBody();
390 const [owner] = await db
391 .select()
392 .from(users)
393 .where(eq(users.username, ownerName))
394 .limit(1);
395 if (!owner || owner.id !== user.id) {
396 return c.redirect(`/${ownerName}/${repoName}`);
397 }
398 const target = String(body.archive || "1") === "1";
399 await db
400 .update(repositories)
401 .set({ isArchived: target, updatedAt: new Date() })
402 .where(
403 and(
404 eq(repositories.ownerId, owner.id),
405 eq(repositories.name, repoName)
406 )
407 );
408 return c.redirect(
409 `/${ownerName}/${repoName}/settings?success=${
410 target ? "Repository+archived" : "Repository+unarchived"
411 }`
412 );
413 }
414);
415
79136bbClaude416// Delete repository
417repoSettings.post(
418 "/:owner/:repo/settings/delete",
419 requireAuth,
420 async (c) => {
421 const { owner: ownerName, repo: repoName } = c.req.param();
422 const user = c.get("user")!;
423
424 const [owner] = await db
425 .select()
426 .from(users)
427 .where(eq(users.username, ownerName))
428 .limit(1);
429
430 if (!owner || owner.id !== user.id) {
431 return c.redirect(`/${ownerName}/${repoName}`);
432 }
433
434 const [repo] = await db
435 .select()
436 .from(repositories)
437 .where(
438 and(
439 eq(repositories.ownerId, owner.id),
440 eq(repositories.name, repoName)
441 )
442 )
443 .limit(1);
444
445 if (!repo) return c.redirect(`/${ownerName}`);
446
447 // Delete from disk
448 try {
449 await rm(repo.diskPath, { recursive: true, force: true });
450 } catch {
451 // Disk cleanup best-effort
452 }
453
454 // Delete from DB (cascades to stars, issues, etc.)
455 await db.delete(repositories).where(eq(repositories.id, repo.id));
456
457 return c.redirect(`/${ownerName}`);
458 }
459);
460
461export default repoSettings;