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