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.tsxBlame453 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>
98 </div>
99 <div class="form-group">
100 <label>Visibility</label>
101 <div class="visibility-options">
102 <label class="visibility-option">
103 <input
104 type="radio"
105 name="visibility"
106 value="public"
107 checked={!repo.isPrivate}
108 />
109 <div class="vis-label">Public</div>
110 </label>
111 <label class="visibility-option">
112 <input
113 type="radio"
114 name="visibility"
115 value="private"
116 checked={repo.isPrivate}
117 />
118 <div class="vis-label">Private</div>
119 </label>
120 </div>
121 </div>
122 <button type="submit" class="btn btn-primary">
123 Save changes
124 </button>
125 </form>
126
127 <div
71cd5ecClaude128 style="margin-top: 32px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
129 >
130 <h3 style="margin-bottom: 8px">Template repository</h3>
131 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
132 {repo.isTemplate
133 ? "This repository is a template. Users can click \u201cUse this template\u201d to create a new repository with the same files."
134 : "Mark this repository as a template so others can seed new repositories from its files."}
135 </p>
136 <form
137 method="POST"
138 action={`/${ownerName}/${repoName}/settings/template`}
139 >
140 <input
141 type="hidden"
142 name="template"
143 value={repo.isTemplate ? "0" : "1"}
144 />
145 <button type="submit" class="btn">
146 {repo.isTemplate
147 ? "Unmark as template"
148 : "Mark as template"}
149 </button>
150 </form>
151 </div>
152
153 <div
154 style="margin-top: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
155 >
156 <h3 style="margin-bottom: 8px">Transfer ownership</h3>
157 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
158 Transfer this repository to another user. The new owner can
159 accept or decline the transfer by attempting to view it.
160 </p>
161 <form
162 method="POST"
163 action={`/${ownerName}/${repoName}/settings/transfer`}
164 onsubmit="return confirm('Transfer this repository? The new owner will have full control.')"
165 >
166 <input
167 type="text"
168 name="new_owner"
169 placeholder="new-owner-username"
170 required
171 style="width:60%"
172 />{" "}
173 <button type="submit" class="btn">
174 Transfer
175 </button>
176 </form>
177 </div>
178
179 <div
180 style="margin-top: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
181 >
182 <h3 style="margin-bottom: 8px">
183 {repo.isArchived ? "Unarchive repository" : "Archive repository"}
184 </h3>
185 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
186 {repo.isArchived
187 ? "This repository is archived and read-only. Unarchive to allow pushes and issue/PR activity again."
188 : "Mark this repository as archived. It will become read-only — no pushes, no new issues or PRs. You can unarchive at any time."}
189 </p>
190 <form
191 method="POST"
192 action={`/${ownerName}/${repoName}/settings/archive`}
193 >
194 <input
195 type="hidden"
196 name="archive"
197 value={repo.isArchived ? "0" : "1"}
198 />
199 <button type="submit" class="btn">
200 {repo.isArchived ? "Unarchive" : "Archive"} this repository
201 </button>
202 </form>
203 </div>
204
205 <div
206 style="margin-top: 20px; padding: 20px; border: 1px solid var(--red); border-radius: var(--radius)"
79136bbClaude207 >
208 <h3 style="color: var(--red); margin-bottom: 12px">Danger zone</h3>
209 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
210 Permanently delete this repository and all its data.
211 </p>
212 <form
213 method="POST"
214 action={`/${ownerName}/${repoName}/settings/delete`}
215 onsubmit="return confirm('Are you sure? This cannot be undone.')"
216 >
217 <button type="submit" class="btn btn-danger">
218 Delete this repository
219 </button>
220 </form>
221 </div>
222 </div>
223 </Layout>
224 );
225});
226
227// Save settings
228repoSettings.post("/:owner/:repo/settings", requireAuth, async (c) => {
229 const { owner: ownerName, repo: repoName } = c.req.param();
230 const user = c.get("user")!;
231 const body = await c.req.parseBody();
232
233 const [owner] = await db
234 .select()
235 .from(users)
236 .where(eq(users.username, ownerName))
237 .limit(1);
238
239 if (!owner || owner.id !== user.id) {
240 return c.redirect(`/${ownerName}/${repoName}`);
241 }
242
243 await db
244 .update(repositories)
245 .set({
246 description: String(body.description || "").trim() || null,
247 defaultBranch: String(body.default_branch || "main"),
248 isPrivate: body.visibility === "private",
249 updatedAt: new Date(),
250 })
251 .where(
252 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
253 );
254
255 return c.redirect(
256 `/${ownerName}/${repoName}/settings?success=Settings+saved`
257 );
258});
259
71cd5ecClaude260// Toggle template flag
261repoSettings.post(
262 "/:owner/:repo/settings/template",
263 requireAuth,
264 async (c) => {
265 const { owner: ownerName, repo: repoName } = c.req.param();
266 const user = c.get("user")!;
267 const body = await c.req.parseBody();
268 const [owner] = await db
269 .select()
270 .from(users)
271 .where(eq(users.username, ownerName))
272 .limit(1);
273 if (!owner || owner.id !== user.id) {
274 return c.redirect(`/${ownerName}/${repoName}`);
275 }
276 const target = String(body.template || "1") === "1";
277 await db
278 .update(repositories)
279 .set({ isTemplate: target, updatedAt: new Date() })
280 .where(
281 and(
282 eq(repositories.ownerId, owner.id),
283 eq(repositories.name, repoName)
284 )
285 );
286 return c.redirect(
287 `/${ownerName}/${repoName}/settings?success=${
288 target ? "Marked+as+template" : "Unmarked+as+template"
289 }`
290 );
291 }
292);
293
294// Transfer repository to a new owner (by username)
295repoSettings.post(
296 "/:owner/:repo/settings/transfer",
297 requireAuth,
298 async (c) => {
299 const { owner: ownerName, repo: repoName } = c.req.param();
300 const user = c.get("user")!;
301 const body = await c.req.parseBody();
302 const newOwnerName = String(body.new_owner || "").trim();
303 if (!newOwnerName) {
304 return c.redirect(
305 `/${ownerName}/${repoName}/settings?error=New+owner+required`
306 );
307 }
308 const [owner] = await db
309 .select()
310 .from(users)
311 .where(eq(users.username, ownerName))
312 .limit(1);
313 if (!owner || owner.id !== user.id) {
314 return c.redirect(`/${ownerName}/${repoName}`);
315 }
316 const [newOwner] = await db
317 .select()
318 .from(users)
319 .where(eq(users.username, newOwnerName))
320 .limit(1);
321 if (!newOwner) {
322 return c.redirect(
323 `/${ownerName}/${repoName}/settings?error=User+not+found`
324 );
325 }
326 if (newOwner.id === owner.id) {
327 return c.redirect(
328 `/${ownerName}/${repoName}/settings?error=Same+owner`
329 );
330 }
331 // Reject if new owner already has a repo by this name
332 const [conflict] = await db
333 .select()
334 .from(repositories)
335 .where(
336 and(
337 eq(repositories.ownerId, newOwner.id),
338 eq(repositories.name, repoName)
339 )
340 )
341 .limit(1);
342 if (conflict) {
343 return c.redirect(
344 `/${ownerName}/${repoName}/settings?error=Target+owner+already+has+a+repo+by+that+name`
345 );
346 }
347 const [repo] = await db
348 .select()
349 .from(repositories)
350 .where(
351 and(
352 eq(repositories.ownerId, owner.id),
353 eq(repositories.name, repoName)
354 )
355 )
356 .limit(1);
357 if (!repo) return c.notFound();
358 await db
359 .update(repositories)
360 .set({ ownerId: newOwner.id, orgId: null, updatedAt: new Date() })
361 .where(eq(repositories.id, repo.id));
362 await db.insert(repoTransfers).values({
363 repositoryId: repo.id,
364 fromOwnerId: owner.id,
365 fromOrgId: repo.orgId,
366 toOwnerId: newOwner.id,
367 toOrgId: null,
368 initiatedBy: user.id,
369 });
370 return c.redirect(`/${newOwnerName}/${repoName}`);
371 }
372);
373
374// Archive / unarchive repository
375repoSettings.post(
376 "/:owner/:repo/settings/archive",
377 requireAuth,
378 async (c) => {
379 const { owner: ownerName, repo: repoName } = c.req.param();
380 const user = c.get("user")!;
381 const body = await c.req.parseBody();
382 const [owner] = await db
383 .select()
384 .from(users)
385 .where(eq(users.username, ownerName))
386 .limit(1);
387 if (!owner || owner.id !== user.id) {
388 return c.redirect(`/${ownerName}/${repoName}`);
389 }
390 const target = String(body.archive || "1") === "1";
391 await db
392 .update(repositories)
393 .set({ isArchived: target, updatedAt: new Date() })
394 .where(
395 and(
396 eq(repositories.ownerId, owner.id),
397 eq(repositories.name, repoName)
398 )
399 );
400 return c.redirect(
401 `/${ownerName}/${repoName}/settings?success=${
402 target ? "Repository+archived" : "Repository+unarchived"
403 }`
404 );
405 }
406);
407
79136bbClaude408// Delete repository
409repoSettings.post(
410 "/:owner/:repo/settings/delete",
411 requireAuth,
412 async (c) => {
413 const { owner: ownerName, repo: repoName } = c.req.param();
414 const user = c.get("user")!;
415
416 const [owner] = await db
417 .select()
418 .from(users)
419 .where(eq(users.username, ownerName))
420 .limit(1);
421
422 if (!owner || owner.id !== user.id) {
423 return c.redirect(`/${ownerName}/${repoName}`);
424 }
425
426 const [repo] = await db
427 .select()
428 .from(repositories)
429 .where(
430 and(
431 eq(repositories.ownerId, owner.id),
432 eq(repositories.name, repoName)
433 )
434 )
435 .limit(1);
436
437 if (!repo) return c.redirect(`/${ownerName}`);
438
439 // Delete from disk
440 try {
441 await rm(repo.diskPath, { recursive: true, force: true });
442 } catch {
443 // Disk cleanup best-effort
444 }
445
446 // Delete from DB (cascades to stars, issues, etc.)
447 await db.delete(repositories).where(eq(repositories.id, repo.id));
448
449 return c.redirect(`/${ownerName}`);
450 }
451);
452
453export default repoSettings;