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.tsxBlame486 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";
febd4f0Claude13import { requireRepoAccess } from "../middleware/repo-access";
79136bbClaude14import { listBranches } from "../git/repository";
15import { rm } from "fs/promises";
bb0f894Claude16import {
17 Container,
18 Form,
19 FormGroup,
20 Input,
21 Select,
22 Button,
23 Alert,
24 EmptyState,
25 Text,
26} from "../views/ui";
79136bbClaude27
28const repoSettings = new Hono<AuthEnv>();
29
30repoSettings.use("*", softAuth);
31
32// Settings page
febd4f0Claude33repoSettings.get("/:owner/:repo/settings", requireAuth, requireRepoAccess("admin"), async (c) => {
79136bbClaude34 const { owner: ownerName, repo: repoName } = c.req.param();
35 const user = c.get("user")!;
36 const success = c.req.query("success");
37 const error = c.req.query("error");
38
39 const [owner] = await db
40 .select()
41 .from(users)
42 .where(eq(users.username, ownerName))
43 .limit(1);
44
45 if (!owner || owner.id !== user.id) {
46 return c.html(
47 <Layout title="Unauthorized" user={user}>
bb0f894Claude48 <EmptyState title="Unauthorized">
79136bbClaude49 <p>Only the repository owner can access settings.</p>
bb0f894Claude50 </EmptyState>
79136bbClaude51 </Layout>,
52 403
53 );
54 }
55
56 const [repo] = await db
57 .select()
58 .from(repositories)
59 .where(
60 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
61 )
62 .limit(1);
63
64 if (!repo) return c.notFound();
65
66 const branches = await listBranches(ownerName, repoName);
67
68 return c.html(
69 <Layout title={`Settings — ${ownerName}/${repoName}`} user={user}>
70 <RepoHeader owner={ownerName} repo={repoName} />
bb0f894Claude71 <Container maxWidth={600}>
79136bbClaude72 <h2 style="margin-bottom: 20px">Repository settings</h2>
23d1a81Claude73 <p style="margin-bottom: 16px">
74 <a href={`/${ownerName}/${repoName}/settings/collaborators`}>
75 Manage collaborators →
76 </a>
77 </p>
79136bbClaude78 {success && (
bb0f894Claude79 <Alert variant="success">{decodeURIComponent(success)}</Alert>
79136bbClaude80 )}
81 {error && (
bb0f894Claude82 <Alert variant="error">{decodeURIComponent(error)}</Alert>
79136bbClaude83 )}
84
0316dbbClaude85 <Form
e7e240eClaude86 method="post"
79136bbClaude87 action={`/${ownerName}/${repoName}/settings`}
88 >
bb0f894Claude89 <FormGroup label="Description" htmlFor="description">
90 <Input
79136bbClaude91 name="description"
bb0f894Claude92 id="description"
79136bbClaude93 value={repo.description || ""}
94 placeholder="A short description"
95 />
bb0f894Claude96 </FormGroup>
97 <FormGroup label="Default branch" htmlFor="default_branch">
98 <Select name="default_branch" id="default_branch" value={repo.defaultBranch}>
79136bbClaude99 {branches.length === 0 ? (
100 <option value={repo.defaultBranch}>
101 {repo.defaultBranch}
102 </option>
103 ) : (
104 branches.map((b) => (
105 <option value={b} selected={b === repo.defaultBranch}>
106 {b}
107 </option>
108 ))
109 )}
bb0f894Claude110 </Select>
111 </FormGroup>
112 <FormGroup label="Visibility">
79136bbClaude113 <div class="visibility-options">
114 <label class="visibility-option">
115 <input
116 type="radio"
117 name="visibility"
118 value="public"
119 checked={!repo.isPrivate}
120 />
121 <div class="vis-label">Public</div>
122 </label>
123 <label class="visibility-option">
124 <input
125 type="radio"
126 name="visibility"
127 value="private"
128 checked={repo.isPrivate}
129 />
130 <div class="vis-label">Private</div>
131 </label>
132 </div>
bb0f894Claude133 </FormGroup>
134 <Button type="submit" variant="primary">
79136bbClaude135 Save changes
bb0f894Claude136 </Button>
137 </Form>
79136bbClaude138
139 <div
71cd5ecClaude140 style="margin-top: 32px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
14c3cc8Claude141 >
142 <h3 style="margin-bottom: 8px">Spec to PR (experimental)</h3>
143 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
144 Paste a plain-English feature spec and let Claude draft a pull
145 request for you. PRs are opened as drafts — review every line
146 before merging.
147 </p>
148 <a
149 href={`/${ownerName}/${repoName}/spec`}
150 class="btn"
151 >
152 Open Spec to PR
153 </a>
154 </div>
155
156 <div
157 style="margin-top: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
71cd5ecClaude158 >
159 <h3 style="margin-bottom: 8px">Template repository</h3>
160 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
161 {repo.isTemplate
162 ? "This repository is a template. Users can click \u201cUse this template\u201d to create a new repository with the same files."
163 : "Mark this repository as a template so others can seed new repositories from its files."}
164 </p>
165 <form
e7e240eClaude166 method="post"
71cd5ecClaude167 action={`/${ownerName}/${repoName}/settings/template`}
168 >
169 <input
170 type="hidden"
171 name="template"
172 value={repo.isTemplate ? "0" : "1"}
173 />
174 <button type="submit" class="btn">
175 {repo.isTemplate
176 ? "Unmark as template"
177 : "Mark as template"}
178 </button>
179 </form>
180 </div>
181
182 <div
183 style="margin-top: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
184 >
185 <h3 style="margin-bottom: 8px">Transfer ownership</h3>
186 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
187 Transfer this repository to another user. The new owner can
188 accept or decline the transfer by attempting to view it.
189 </p>
190 <form
e7e240eClaude191 method="post"
71cd5ecClaude192 action={`/${ownerName}/${repoName}/settings/transfer`}
193 onsubmit="return confirm('Transfer this repository? The new owner will have full control.')"
194 >
195 <input
196 type="text"
197 name="new_owner"
198 placeholder="new-owner-username"
199 required
200 style="width:60%"
201 />{" "}
202 <button type="submit" class="btn">
203 Transfer
204 </button>
205 </form>
206 </div>
207
208 <div
209 style="margin-top: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
210 >
211 <h3 style="margin-bottom: 8px">
212 {repo.isArchived ? "Unarchive repository" : "Archive repository"}
213 </h3>
214 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
215 {repo.isArchived
216 ? "This repository is archived and read-only. Unarchive to allow pushes and issue/PR activity again."
217 : "Mark this repository as archived. It will become read-only — no pushes, no new issues or PRs. You can unarchive at any time."}
218 </p>
219 <form
e7e240eClaude220 method="post"
71cd5ecClaude221 action={`/${ownerName}/${repoName}/settings/archive`}
222 >
223 <input
224 type="hidden"
225 name="archive"
226 value={repo.isArchived ? "0" : "1"}
227 />
228 <button type="submit" class="btn">
229 {repo.isArchived ? "Unarchive" : "Archive"} this repository
230 </button>
231 </form>
232 </div>
233
234 <div
235 style="margin-top: 20px; padding: 20px; border: 1px solid var(--red); border-radius: var(--radius)"
79136bbClaude236 >
237 <h3 style="color: var(--red); margin-bottom: 12px">Danger zone</h3>
bb0f894Claude238 <Text size={14} muted style="display:block;margin-bottom:12px">
79136bbClaude239 Permanently delete this repository and all its data.
bb0f894Claude240 </Text>
79136bbClaude241 <form
e7e240eClaude242 method="post"
79136bbClaude243 action={`/${ownerName}/${repoName}/settings/delete`}
244 onsubmit="return confirm('Are you sure? This cannot be undone.')"
245 >
bb0f894Claude246 <Button type="submit" variant="danger">
79136bbClaude247 Delete this repository
bb0f894Claude248 </Button>
79136bbClaude249 </form>
250 </div>
bb0f894Claude251 </Container>
79136bbClaude252 </Layout>
253 );
254});
255
256// Save settings
febd4f0Claude257repoSettings.post("/:owner/:repo/settings", requireAuth, requireRepoAccess("admin"), async (c) => {
79136bbClaude258 const { owner: ownerName, repo: repoName } = c.req.param();
259 const user = c.get("user")!;
260 const body = await c.req.parseBody();
261
262 const [owner] = await db
263 .select()
264 .from(users)
265 .where(eq(users.username, ownerName))
266 .limit(1);
267
268 if (!owner || owner.id !== user.id) {
269 return c.redirect(`/${ownerName}/${repoName}`);
270 }
271
272 await db
273 .update(repositories)
274 .set({
275 description: String(body.description || "").trim() || null,
276 defaultBranch: String(body.default_branch || "main"),
277 isPrivate: body.visibility === "private",
278 updatedAt: new Date(),
279 })
280 .where(
281 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
282 );
283
284 return c.redirect(
285 `/${ownerName}/${repoName}/settings?success=Settings+saved`
286 );
287});
288
71cd5ecClaude289// Toggle template flag
290repoSettings.post(
291 "/:owner/:repo/settings/template",
292 requireAuth,
febd4f0Claude293 requireRepoAccess("admin"),
71cd5ecClaude294 async (c) => {
295 const { owner: ownerName, repo: repoName } = c.req.param();
296 const user = c.get("user")!;
297 const body = await c.req.parseBody();
298 const [owner] = await db
299 .select()
300 .from(users)
301 .where(eq(users.username, ownerName))
302 .limit(1);
303 if (!owner || owner.id !== user.id) {
304 return c.redirect(`/${ownerName}/${repoName}`);
305 }
306 const target = String(body.template || "1") === "1";
307 await db
308 .update(repositories)
309 .set({ isTemplate: target, updatedAt: new Date() })
310 .where(
311 and(
312 eq(repositories.ownerId, owner.id),
313 eq(repositories.name, repoName)
314 )
315 );
316 return c.redirect(
317 `/${ownerName}/${repoName}/settings?success=${
318 target ? "Marked+as+template" : "Unmarked+as+template"
319 }`
320 );
321 }
322);
323
324// Transfer repository to a new owner (by username)
325repoSettings.post(
326 "/:owner/:repo/settings/transfer",
327 requireAuth,
febd4f0Claude328 requireRepoAccess("admin"),
71cd5ecClaude329 async (c) => {
330 const { owner: ownerName, repo: repoName } = c.req.param();
331 const user = c.get("user")!;
332 const body = await c.req.parseBody();
333 const newOwnerName = String(body.new_owner || "").trim();
334 if (!newOwnerName) {
335 return c.redirect(
336 `/${ownerName}/${repoName}/settings?error=New+owner+required`
337 );
338 }
339 const [owner] = await db
340 .select()
341 .from(users)
342 .where(eq(users.username, ownerName))
343 .limit(1);
344 if (!owner || owner.id !== user.id) {
345 return c.redirect(`/${ownerName}/${repoName}`);
346 }
347 const [newOwner] = await db
348 .select()
349 .from(users)
350 .where(eq(users.username, newOwnerName))
351 .limit(1);
352 if (!newOwner) {
353 return c.redirect(
354 `/${ownerName}/${repoName}/settings?error=User+not+found`
355 );
356 }
357 if (newOwner.id === owner.id) {
358 return c.redirect(
359 `/${ownerName}/${repoName}/settings?error=Same+owner`
360 );
361 }
362 // Reject if new owner already has a repo by this name
363 const [conflict] = await db
364 .select()
365 .from(repositories)
366 .where(
367 and(
368 eq(repositories.ownerId, newOwner.id),
369 eq(repositories.name, repoName)
370 )
371 )
372 .limit(1);
373 if (conflict) {
374 return c.redirect(
375 `/${ownerName}/${repoName}/settings?error=Target+owner+already+has+a+repo+by+that+name`
376 );
377 }
378 const [repo] = await db
379 .select()
380 .from(repositories)
381 .where(
382 and(
383 eq(repositories.ownerId, owner.id),
384 eq(repositories.name, repoName)
385 )
386 )
387 .limit(1);
388 if (!repo) return c.notFound();
389 await db
390 .update(repositories)
391 .set({ ownerId: newOwner.id, orgId: null, updatedAt: new Date() })
392 .where(eq(repositories.id, repo.id));
393 await db.insert(repoTransfers).values({
394 repositoryId: repo.id,
395 fromOwnerId: owner.id,
396 fromOrgId: repo.orgId,
397 toOwnerId: newOwner.id,
398 toOrgId: null,
399 initiatedBy: user.id,
400 });
401 return c.redirect(`/${newOwnerName}/${repoName}`);
402 }
403);
404
405// Archive / unarchive repository
406repoSettings.post(
407 "/:owner/:repo/settings/archive",
408 requireAuth,
febd4f0Claude409 requireRepoAccess("admin"),
71cd5ecClaude410 async (c) => {
411 const { owner: ownerName, repo: repoName } = c.req.param();
412 const user = c.get("user")!;
413 const body = await c.req.parseBody();
414 const [owner] = await db
415 .select()
416 .from(users)
417 .where(eq(users.username, ownerName))
418 .limit(1);
419 if (!owner || owner.id !== user.id) {
420 return c.redirect(`/${ownerName}/${repoName}`);
421 }
422 const target = String(body.archive || "1") === "1";
423 await db
424 .update(repositories)
425 .set({ isArchived: target, updatedAt: new Date() })
426 .where(
427 and(
428 eq(repositories.ownerId, owner.id),
429 eq(repositories.name, repoName)
430 )
431 );
432 return c.redirect(
433 `/${ownerName}/${repoName}/settings?success=${
434 target ? "Repository+archived" : "Repository+unarchived"
435 }`
436 );
437 }
438);
439
79136bbClaude440// Delete repository
441repoSettings.post(
442 "/:owner/:repo/settings/delete",
443 requireAuth,
febd4f0Claude444 requireRepoAccess("admin"),
79136bbClaude445 async (c) => {
446 const { owner: ownerName, repo: repoName } = c.req.param();
447 const user = c.get("user")!;
448
449 const [owner] = await db
450 .select()
451 .from(users)
452 .where(eq(users.username, ownerName))
453 .limit(1);
454
455 if (!owner || owner.id !== user.id) {
456 return c.redirect(`/${ownerName}/${repoName}`);
457 }
458
459 const [repo] = await db
460 .select()
461 .from(repositories)
462 .where(
463 and(
464 eq(repositories.ownerId, owner.id),
465 eq(repositories.name, repoName)
466 )
467 )
468 .limit(1);
469
470 if (!repo) return c.redirect(`/${ownerName}`);
471
472 // Delete from disk
473 try {
474 await rm(repo.diskPath, { recursive: true, force: true });
475 } catch {
476 // Disk cleanup best-effort
477 }
478
479 // Delete from DB (cascades to stars, issues, etc.)
480 await db.delete(repositories).where(eq(repositories.id, repo.id));
481
482 return c.redirect(`/${ownerName}`);
483 }
484);
485
486export default repoSettings;