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