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.tsxBlame476 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)"
14c3cc8Claude135 >
136 <h3 style="margin-bottom: 8px">Spec to PR (experimental)</h3>
137 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
138 Paste a plain-English feature spec and let Claude draft a pull
139 request for you. PRs are opened as drafts — review every line
140 before merging.
141 </p>
142 <a
143 href={`/${ownerName}/${repoName}/spec`}
144 class="btn"
145 >
146 Open Spec to PR
147 </a>
148 </div>
149
150 <div
151 style="margin-top: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
71cd5ecClaude152 >
153 <h3 style="margin-bottom: 8px">Template repository</h3>
154 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
155 {repo.isTemplate
156 ? "This repository is a template. Users can click \u201cUse this template\u201d to create a new repository with the same files."
157 : "Mark this repository as a template so others can seed new repositories from its files."}
158 </p>
159 <form
e7e240eClaude160 method="post"
71cd5ecClaude161 action={`/${ownerName}/${repoName}/settings/template`}
162 >
163 <input
164 type="hidden"
165 name="template"
166 value={repo.isTemplate ? "0" : "1"}
167 />
168 <button type="submit" class="btn">
169 {repo.isTemplate
170 ? "Unmark as template"
171 : "Mark as template"}
172 </button>
173 </form>
174 </div>
175
176 <div
177 style="margin-top: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
178 >
179 <h3 style="margin-bottom: 8px">Transfer ownership</h3>
180 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
181 Transfer this repository to another user. The new owner can
182 accept or decline the transfer by attempting to view it.
183 </p>
184 <form
e7e240eClaude185 method="post"
71cd5ecClaude186 action={`/${ownerName}/${repoName}/settings/transfer`}
187 onsubmit="return confirm('Transfer this repository? The new owner will have full control.')"
188 >
189 <input
190 type="text"
191 name="new_owner"
192 placeholder="new-owner-username"
193 required
194 style="width:60%"
195 />{" "}
196 <button type="submit" class="btn">
197 Transfer
198 </button>
199 </form>
200 </div>
201
202 <div
203 style="margin-top: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
204 >
205 <h3 style="margin-bottom: 8px">
206 {repo.isArchived ? "Unarchive repository" : "Archive repository"}
207 </h3>
208 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
209 {repo.isArchived
210 ? "This repository is archived and read-only. Unarchive to allow pushes and issue/PR activity again."
211 : "Mark this repository as archived. It will become read-only — no pushes, no new issues or PRs. You can unarchive at any time."}
212 </p>
213 <form
e7e240eClaude214 method="post"
71cd5ecClaude215 action={`/${ownerName}/${repoName}/settings/archive`}
216 >
217 <input
218 type="hidden"
219 name="archive"
220 value={repo.isArchived ? "0" : "1"}
221 />
222 <button type="submit" class="btn">
223 {repo.isArchived ? "Unarchive" : "Archive"} this repository
224 </button>
225 </form>
226 </div>
227
228 <div
229 style="margin-top: 20px; padding: 20px; border: 1px solid var(--red); border-radius: var(--radius)"
79136bbClaude230 >
231 <h3 style="color: var(--red); margin-bottom: 12px">Danger zone</h3>
bb0f894Claude232 <Text size={14} muted style="display:block;margin-bottom:12px">
79136bbClaude233 Permanently delete this repository and all its data.
bb0f894Claude234 </Text>
79136bbClaude235 <form
e7e240eClaude236 method="post"
79136bbClaude237 action={`/${ownerName}/${repoName}/settings/delete`}
238 onsubmit="return confirm('Are you sure? This cannot be undone.')"
239 >
bb0f894Claude240 <Button type="submit" variant="danger">
79136bbClaude241 Delete this repository
bb0f894Claude242 </Button>
79136bbClaude243 </form>
244 </div>
bb0f894Claude245 </Container>
79136bbClaude246 </Layout>
247 );
248});
249
250// Save settings
251repoSettings.post("/:owner/:repo/settings", requireAuth, async (c) => {
252 const { owner: ownerName, repo: repoName } = c.req.param();
253 const user = c.get("user")!;
254 const body = await c.req.parseBody();
255
256 const [owner] = await db
257 .select()
258 .from(users)
259 .where(eq(users.username, ownerName))
260 .limit(1);
261
262 if (!owner || owner.id !== user.id) {
263 return c.redirect(`/${ownerName}/${repoName}`);
264 }
265
266 await db
267 .update(repositories)
268 .set({
269 description: String(body.description || "").trim() || null,
270 defaultBranch: String(body.default_branch || "main"),
271 isPrivate: body.visibility === "private",
272 updatedAt: new Date(),
273 })
274 .where(
275 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
276 );
277
278 return c.redirect(
279 `/${ownerName}/${repoName}/settings?success=Settings+saved`
280 );
281});
282
71cd5ecClaude283// Toggle template flag
284repoSettings.post(
285 "/:owner/:repo/settings/template",
286 requireAuth,
287 async (c) => {
288 const { owner: ownerName, repo: repoName } = c.req.param();
289 const user = c.get("user")!;
290 const body = await c.req.parseBody();
291 const [owner] = await db
292 .select()
293 .from(users)
294 .where(eq(users.username, ownerName))
295 .limit(1);
296 if (!owner || owner.id !== user.id) {
297 return c.redirect(`/${ownerName}/${repoName}`);
298 }
299 const target = String(body.template || "1") === "1";
300 await db
301 .update(repositories)
302 .set({ isTemplate: target, updatedAt: new Date() })
303 .where(
304 and(
305 eq(repositories.ownerId, owner.id),
306 eq(repositories.name, repoName)
307 )
308 );
309 return c.redirect(
310 `/${ownerName}/${repoName}/settings?success=${
311 target ? "Marked+as+template" : "Unmarked+as+template"
312 }`
313 );
314 }
315);
316
317// Transfer repository to a new owner (by username)
318repoSettings.post(
319 "/:owner/:repo/settings/transfer",
320 requireAuth,
321 async (c) => {
322 const { owner: ownerName, repo: repoName } = c.req.param();
323 const user = c.get("user")!;
324 const body = await c.req.parseBody();
325 const newOwnerName = String(body.new_owner || "").trim();
326 if (!newOwnerName) {
327 return c.redirect(
328 `/${ownerName}/${repoName}/settings?error=New+owner+required`
329 );
330 }
331 const [owner] = await db
332 .select()
333 .from(users)
334 .where(eq(users.username, ownerName))
335 .limit(1);
336 if (!owner || owner.id !== user.id) {
337 return c.redirect(`/${ownerName}/${repoName}`);
338 }
339 const [newOwner] = await db
340 .select()
341 .from(users)
342 .where(eq(users.username, newOwnerName))
343 .limit(1);
344 if (!newOwner) {
345 return c.redirect(
346 `/${ownerName}/${repoName}/settings?error=User+not+found`
347 );
348 }
349 if (newOwner.id === owner.id) {
350 return c.redirect(
351 `/${ownerName}/${repoName}/settings?error=Same+owner`
352 );
353 }
354 // Reject if new owner already has a repo by this name
355 const [conflict] = await db
356 .select()
357 .from(repositories)
358 .where(
359 and(
360 eq(repositories.ownerId, newOwner.id),
361 eq(repositories.name, repoName)
362 )
363 )
364 .limit(1);
365 if (conflict) {
366 return c.redirect(
367 `/${ownerName}/${repoName}/settings?error=Target+owner+already+has+a+repo+by+that+name`
368 );
369 }
370 const [repo] = await db
371 .select()
372 .from(repositories)
373 .where(
374 and(
375 eq(repositories.ownerId, owner.id),
376 eq(repositories.name, repoName)
377 )
378 )
379 .limit(1);
380 if (!repo) return c.notFound();
381 await db
382 .update(repositories)
383 .set({ ownerId: newOwner.id, orgId: null, updatedAt: new Date() })
384 .where(eq(repositories.id, repo.id));
385 await db.insert(repoTransfers).values({
386 repositoryId: repo.id,
387 fromOwnerId: owner.id,
388 fromOrgId: repo.orgId,
389 toOwnerId: newOwner.id,
390 toOrgId: null,
391 initiatedBy: user.id,
392 });
393 return c.redirect(`/${newOwnerName}/${repoName}`);
394 }
395);
396
397// Archive / unarchive repository
398repoSettings.post(
399 "/:owner/:repo/settings/archive",
400 requireAuth,
401 async (c) => {
402 const { owner: ownerName, repo: repoName } = c.req.param();
403 const user = c.get("user")!;
404 const body = await c.req.parseBody();
405 const [owner] = await db
406 .select()
407 .from(users)
408 .where(eq(users.username, ownerName))
409 .limit(1);
410 if (!owner || owner.id !== user.id) {
411 return c.redirect(`/${ownerName}/${repoName}`);
412 }
413 const target = String(body.archive || "1") === "1";
414 await db
415 .update(repositories)
416 .set({ isArchived: target, updatedAt: new Date() })
417 .where(
418 and(
419 eq(repositories.ownerId, owner.id),
420 eq(repositories.name, repoName)
421 )
422 );
423 return c.redirect(
424 `/${ownerName}/${repoName}/settings?success=${
425 target ? "Repository+archived" : "Repository+unarchived"
426 }`
427 );
428 }
429);
430
79136bbClaude431// Delete repository
432repoSettings.post(
433 "/:owner/:repo/settings/delete",
434 requireAuth,
435 async (c) => {
436 const { owner: ownerName, repo: repoName } = c.req.param();
437 const user = c.get("user")!;
438
439 const [owner] = await db
440 .select()
441 .from(users)
442 .where(eq(users.username, ownerName))
443 .limit(1);
444
445 if (!owner || owner.id !== user.id) {
446 return c.redirect(`/${ownerName}/${repoName}`);
447 }
448
449 const [repo] = await db
450 .select()
451 .from(repositories)
452 .where(
453 and(
454 eq(repositories.ownerId, owner.id),
455 eq(repositories.name, repoName)
456 )
457 )
458 .limit(1);
459
460 if (!repo) return c.redirect(`/${ownerName}`);
461
462 // Delete from disk
463 try {
464 await rm(repo.diskPath, { recursive: true, force: true });
465 } catch {
466 // Disk cleanup best-effort
467 }
468
469 // Delete from DB (cascades to stars, issues, etc.)
470 await db.delete(repositories).where(eq(repositories.id, repo.id));
471
472 return c.redirect(`/${ownerName}`);
473 }
474);
475
476export default repoSettings;