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.tsxBlame459 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";
bb0f894Claude15import {
16 Container,
17 Form,
18 FormGroup,
19 Input,
20 Select,
21 Button,
22 Alert,
23 EmptyState,
24 Text,
25} from "../views/ui";
79136bbClaude26
27const repoSettings = new Hono<AuthEnv>();
28
29repoSettings.use("*", softAuth);
30
31// Settings page
32repoSettings.get("/:owner/:repo/settings", requireAuth, async (c) => {
33 const { owner: ownerName, repo: repoName } = c.req.param();
34 const user = c.get("user")!;
35 const success = c.req.query("success");
36 const error = c.req.query("error");
37
38 const [owner] = await db
39 .select()
40 .from(users)
41 .where(eq(users.username, ownerName))
42 .limit(1);
43
44 if (!owner || owner.id !== user.id) {
45 return c.html(
46 <Layout title="Unauthorized" user={user}>
bb0f894Claude47 <EmptyState title="Unauthorized">
79136bbClaude48 <p>Only the repository owner can access settings.</p>
bb0f894Claude49 </EmptyState>
79136bbClaude50 </Layout>,
51 403
52 );
53 }
54
55 const [repo] = await db
56 .select()
57 .from(repositories)
58 .where(
59 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
60 )
61 .limit(1);
62
63 if (!repo) return c.notFound();
64
65 const branches = await listBranches(ownerName, repoName);
66
67 return c.html(
68 <Layout title={`Settings — ${ownerName}/${repoName}`} user={user}>
69 <RepoHeader owner={ownerName} repo={repoName} />
bb0f894Claude70 <Container maxWidth={600}>
79136bbClaude71 <h2 style="margin-bottom: 20px">Repository settings</h2>
72 {success && (
bb0f894Claude73 <Alert variant="success">{decodeURIComponent(success)}</Alert>
79136bbClaude74 )}
75 {error && (
bb0f894Claude76 <Alert variant="error">{decodeURIComponent(error)}</Alert>
79136bbClaude77 )}
78
0316dbbClaude79 <Form
e7e240eClaude80 method="post"
79136bbClaude81 action={`/${ownerName}/${repoName}/settings`}
82 >
bb0f894Claude83 <FormGroup label="Description" htmlFor="description">
84 <Input
79136bbClaude85 name="description"
bb0f894Claude86 id="description"
79136bbClaude87 value={repo.description || ""}
88 placeholder="A short description"
89 />
bb0f894Claude90 </FormGroup>
91 <FormGroup label="Default branch" htmlFor="default_branch">
92 <Select name="default_branch" id="default_branch" value={repo.defaultBranch}>
79136bbClaude93 {branches.length === 0 ? (
94 <option value={repo.defaultBranch}>
95 {repo.defaultBranch}
96 </option>
97 ) : (
98 branches.map((b) => (
99 <option value={b} selected={b === repo.defaultBranch}>
100 {b}
101 </option>
102 ))
103 )}
bb0f894Claude104 </Select>
105 </FormGroup>
106 <FormGroup label="Visibility">
79136bbClaude107 <div class="visibility-options">
108 <label class="visibility-option">
109 <input
110 type="radio"
111 name="visibility"
112 value="public"
113 checked={!repo.isPrivate}
114 />
115 <div class="vis-label">Public</div>
116 </label>
117 <label class="visibility-option">
118 <input
119 type="radio"
120 name="visibility"
121 value="private"
122 checked={repo.isPrivate}
123 />
124 <div class="vis-label">Private</div>
125 </label>
126 </div>
bb0f894Claude127 </FormGroup>
128 <Button type="submit" variant="primary">
79136bbClaude129 Save changes
bb0f894Claude130 </Button>
131 </Form>
79136bbClaude132
133 <div
71cd5ecClaude134 style="margin-top: 32px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
135 >
136 <h3 style="margin-bottom: 8px">Template repository</h3>
137 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
138 {repo.isTemplate
139 ? "This repository is a template. Users can click \u201cUse this template\u201d to create a new repository with the same files."
140 : "Mark this repository as a template so others can seed new repositories from its files."}
141 </p>
142 <form
e7e240eClaude143 method="post"
71cd5ecClaude144 action={`/${ownerName}/${repoName}/settings/template`}
145 >
146 <input
147 type="hidden"
148 name="template"
149 value={repo.isTemplate ? "0" : "1"}
150 />
151 <button type="submit" class="btn">
152 {repo.isTemplate
153 ? "Unmark as template"
154 : "Mark as template"}
155 </button>
156 </form>
157 </div>
158
159 <div
160 style="margin-top: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
161 >
162 <h3 style="margin-bottom: 8px">Transfer ownership</h3>
163 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
164 Transfer this repository to another user. The new owner can
165 accept or decline the transfer by attempting to view it.
166 </p>
167 <form
e7e240eClaude168 method="post"
71cd5ecClaude169 action={`/${ownerName}/${repoName}/settings/transfer`}
170 onsubmit="return confirm('Transfer this repository? The new owner will have full control.')"
171 >
172 <input
173 type="text"
174 name="new_owner"
175 placeholder="new-owner-username"
176 required
177 style="width:60%"
178 />{" "}
179 <button type="submit" class="btn">
180 Transfer
181 </button>
182 </form>
183 </div>
184
185 <div
186 style="margin-top: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
187 >
188 <h3 style="margin-bottom: 8px">
189 {repo.isArchived ? "Unarchive repository" : "Archive repository"}
190 </h3>
191 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
192 {repo.isArchived
193 ? "This repository is archived and read-only. Unarchive to allow pushes and issue/PR activity again."
194 : "Mark this repository as archived. It will become read-only — no pushes, no new issues or PRs. You can unarchive at any time."}
195 </p>
196 <form
e7e240eClaude197 method="post"
71cd5ecClaude198 action={`/${ownerName}/${repoName}/settings/archive`}
199 >
200 <input
201 type="hidden"
202 name="archive"
203 value={repo.isArchived ? "0" : "1"}
204 />
205 <button type="submit" class="btn">
206 {repo.isArchived ? "Unarchive" : "Archive"} this repository
207 </button>
208 </form>
209 </div>
210
211 <div
212 style="margin-top: 20px; padding: 20px; border: 1px solid var(--red); border-radius: var(--radius)"
79136bbClaude213 >
214 <h3 style="color: var(--red); margin-bottom: 12px">Danger zone</h3>
bb0f894Claude215 <Text size={14} muted style="display:block;margin-bottom:12px">
79136bbClaude216 Permanently delete this repository and all its data.
bb0f894Claude217 </Text>
79136bbClaude218 <form
e7e240eClaude219 method="post"
79136bbClaude220 action={`/${ownerName}/${repoName}/settings/delete`}
221 onsubmit="return confirm('Are you sure? This cannot be undone.')"
222 >
bb0f894Claude223 <Button type="submit" variant="danger">
79136bbClaude224 Delete this repository
bb0f894Claude225 </Button>
79136bbClaude226 </form>
227 </div>
bb0f894Claude228 </Container>
79136bbClaude229 </Layout>
230 );
231});
232
233// Save settings
234repoSettings.post("/:owner/:repo/settings", requireAuth, async (c) => {
235 const { owner: ownerName, repo: repoName } = c.req.param();
236 const user = c.get("user")!;
237 const body = await c.req.parseBody();
238
239 const [owner] = await db
240 .select()
241 .from(users)
242 .where(eq(users.username, ownerName))
243 .limit(1);
244
245 if (!owner || owner.id !== user.id) {
246 return c.redirect(`/${ownerName}/${repoName}`);
247 }
248
249 await db
250 .update(repositories)
251 .set({
252 description: String(body.description || "").trim() || null,
253 defaultBranch: String(body.default_branch || "main"),
254 isPrivate: body.visibility === "private",
255 updatedAt: new Date(),
256 })
257 .where(
258 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
259 );
260
261 return c.redirect(
262 `/${ownerName}/${repoName}/settings?success=Settings+saved`
263 );
264});
265
71cd5ecClaude266// Toggle template flag
267repoSettings.post(
268 "/:owner/:repo/settings/template",
269 requireAuth,
270 async (c) => {
271 const { owner: ownerName, repo: repoName } = c.req.param();
272 const user = c.get("user")!;
273 const body = await c.req.parseBody();
274 const [owner] = await db
275 .select()
276 .from(users)
277 .where(eq(users.username, ownerName))
278 .limit(1);
279 if (!owner || owner.id !== user.id) {
280 return c.redirect(`/${ownerName}/${repoName}`);
281 }
282 const target = String(body.template || "1") === "1";
283 await db
284 .update(repositories)
285 .set({ isTemplate: target, updatedAt: new Date() })
286 .where(
287 and(
288 eq(repositories.ownerId, owner.id),
289 eq(repositories.name, repoName)
290 )
291 );
292 return c.redirect(
293 `/${ownerName}/${repoName}/settings?success=${
294 target ? "Marked+as+template" : "Unmarked+as+template"
295 }`
296 );
297 }
298);
299
300// Transfer repository to a new owner (by username)
301repoSettings.post(
302 "/:owner/:repo/settings/transfer",
303 requireAuth,
304 async (c) => {
305 const { owner: ownerName, repo: repoName } = c.req.param();
306 const user = c.get("user")!;
307 const body = await c.req.parseBody();
308 const newOwnerName = String(body.new_owner || "").trim();
309 if (!newOwnerName) {
310 return c.redirect(
311 `/${ownerName}/${repoName}/settings?error=New+owner+required`
312 );
313 }
314 const [owner] = await db
315 .select()
316 .from(users)
317 .where(eq(users.username, ownerName))
318 .limit(1);
319 if (!owner || owner.id !== user.id) {
320 return c.redirect(`/${ownerName}/${repoName}`);
321 }
322 const [newOwner] = await db
323 .select()
324 .from(users)
325 .where(eq(users.username, newOwnerName))
326 .limit(1);
327 if (!newOwner) {
328 return c.redirect(
329 `/${ownerName}/${repoName}/settings?error=User+not+found`
330 );
331 }
332 if (newOwner.id === owner.id) {
333 return c.redirect(
334 `/${ownerName}/${repoName}/settings?error=Same+owner`
335 );
336 }
337 // Reject if new owner already has a repo by this name
338 const [conflict] = await db
339 .select()
340 .from(repositories)
341 .where(
342 and(
343 eq(repositories.ownerId, newOwner.id),
344 eq(repositories.name, repoName)
345 )
346 )
347 .limit(1);
348 if (conflict) {
349 return c.redirect(
350 `/${ownerName}/${repoName}/settings?error=Target+owner+already+has+a+repo+by+that+name`
351 );
352 }
353 const [repo] = await db
354 .select()
355 .from(repositories)
356 .where(
357 and(
358 eq(repositories.ownerId, owner.id),
359 eq(repositories.name, repoName)
360 )
361 )
362 .limit(1);
363 if (!repo) return c.notFound();
364 await db
365 .update(repositories)
366 .set({ ownerId: newOwner.id, orgId: null, updatedAt: new Date() })
367 .where(eq(repositories.id, repo.id));
368 await db.insert(repoTransfers).values({
369 repositoryId: repo.id,
370 fromOwnerId: owner.id,
371 fromOrgId: repo.orgId,
372 toOwnerId: newOwner.id,
373 toOrgId: null,
374 initiatedBy: user.id,
375 });
376 return c.redirect(`/${newOwnerName}/${repoName}`);
377 }
378);
379
380// Archive / unarchive repository
381repoSettings.post(
382 "/:owner/:repo/settings/archive",
383 requireAuth,
384 async (c) => {
385 const { owner: ownerName, repo: repoName } = c.req.param();
386 const user = c.get("user")!;
387 const body = await c.req.parseBody();
388 const [owner] = await db
389 .select()
390 .from(users)
391 .where(eq(users.username, ownerName))
392 .limit(1);
393 if (!owner || owner.id !== user.id) {
394 return c.redirect(`/${ownerName}/${repoName}`);
395 }
396 const target = String(body.archive || "1") === "1";
397 await db
398 .update(repositories)
399 .set({ isArchived: target, updatedAt: new Date() })
400 .where(
401 and(
402 eq(repositories.ownerId, owner.id),
403 eq(repositories.name, repoName)
404 )
405 );
406 return c.redirect(
407 `/${ownerName}/${repoName}/settings?success=${
408 target ? "Repository+archived" : "Repository+unarchived"
409 }`
410 );
411 }
412);
413
79136bbClaude414// Delete repository
415repoSettings.post(
416 "/:owner/:repo/settings/delete",
417 requireAuth,
418 async (c) => {
419 const { owner: ownerName, repo: repoName } = c.req.param();
420 const user = c.get("user")!;
421
422 const [owner] = await db
423 .select()
424 .from(users)
425 .where(eq(users.username, ownerName))
426 .limit(1);
427
428 if (!owner || owner.id !== user.id) {
429 return c.redirect(`/${ownerName}/${repoName}`);
430 }
431
432 const [repo] = await db
433 .select()
434 .from(repositories)
435 .where(
436 and(
437 eq(repositories.ownerId, owner.id),
438 eq(repositories.name, repoName)
439 )
440 )
441 .limit(1);
442
443 if (!repo) return c.redirect(`/${ownerName}`);
444
445 // Delete from disk
446 try {
447 await rm(repo.diskPath, { recursive: true, force: true });
448 } catch {
449 // Disk cleanup best-effort
450 }
451
452 // Delete from DB (cascades to stars, issues, etc.)
453 await db.delete(repositories).where(eq(repositories.id, repo.id));
454
455 return c.redirect(`/${ownerName}`);
456 }
457);
458
459export default repoSettings;