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.tsxBlame481 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>
23d1a81Claude72 <p style="margin-bottom: 16px">
73 <a href={`/${ownerName}/${repoName}/settings/collaborators`}>
74 Manage collaborators →
75 </a>
76 </p>
79136bbClaude77 {success && (
bb0f894Claude78 <Alert variant="success">{decodeURIComponent(success)}</Alert>
79136bbClaude79 )}
80 {error && (
bb0f894Claude81 <Alert variant="error">{decodeURIComponent(error)}</Alert>
79136bbClaude82 )}
83
0316dbbClaude84 <Form
e7e240eClaude85 method="post"
79136bbClaude86 action={`/${ownerName}/${repoName}/settings`}
87 >
bb0f894Claude88 <FormGroup label="Description" htmlFor="description">
89 <Input
79136bbClaude90 name="description"
bb0f894Claude91 id="description"
79136bbClaude92 value={repo.description || ""}
93 placeholder="A short description"
94 />
bb0f894Claude95 </FormGroup>
96 <FormGroup label="Default branch" htmlFor="default_branch">
97 <Select name="default_branch" id="default_branch" value={repo.defaultBranch}>
79136bbClaude98 {branches.length === 0 ? (
99 <option value={repo.defaultBranch}>
100 {repo.defaultBranch}
101 </option>
102 ) : (
103 branches.map((b) => (
104 <option value={b} selected={b === repo.defaultBranch}>
105 {b}
106 </option>
107 ))
108 )}
bb0f894Claude109 </Select>
110 </FormGroup>
111 <FormGroup label="Visibility">
79136bbClaude112 <div class="visibility-options">
113 <label class="visibility-option">
114 <input
115 type="radio"
116 name="visibility"
117 value="public"
118 checked={!repo.isPrivate}
119 />
120 <div class="vis-label">Public</div>
121 </label>
122 <label class="visibility-option">
123 <input
124 type="radio"
125 name="visibility"
126 value="private"
127 checked={repo.isPrivate}
128 />
129 <div class="vis-label">Private</div>
130 </label>
131 </div>
bb0f894Claude132 </FormGroup>
133 <Button type="submit" variant="primary">
79136bbClaude134 Save changes
bb0f894Claude135 </Button>
136 </Form>
79136bbClaude137
138 <div
71cd5ecClaude139 style="margin-top: 32px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
14c3cc8Claude140 >
141 <h3 style="margin-bottom: 8px">Spec to PR (experimental)</h3>
142 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
143 Paste a plain-English feature spec and let Claude draft a pull
144 request for you. PRs are opened as drafts — review every line
145 before merging.
146 </p>
147 <a
148 href={`/${ownerName}/${repoName}/spec`}
149 class="btn"
150 >
151 Open Spec to PR
152 </a>
153 </div>
154
155 <div
156 style="margin-top: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
71cd5ecClaude157 >
158 <h3 style="margin-bottom: 8px">Template repository</h3>
159 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
160 {repo.isTemplate
161 ? "This repository is a template. Users can click \u201cUse this template\u201d to create a new repository with the same files."
162 : "Mark this repository as a template so others can seed new repositories from its files."}
163 </p>
164 <form
e7e240eClaude165 method="post"
71cd5ecClaude166 action={`/${ownerName}/${repoName}/settings/template`}
167 >
168 <input
169 type="hidden"
170 name="template"
171 value={repo.isTemplate ? "0" : "1"}
172 />
173 <button type="submit" class="btn">
174 {repo.isTemplate
175 ? "Unmark as template"
176 : "Mark as template"}
177 </button>
178 </form>
179 </div>
180
181 <div
182 style="margin-top: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
183 >
184 <h3 style="margin-bottom: 8px">Transfer ownership</h3>
185 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
186 Transfer this repository to another user. The new owner can
187 accept or decline the transfer by attempting to view it.
188 </p>
189 <form
e7e240eClaude190 method="post"
71cd5ecClaude191 action={`/${ownerName}/${repoName}/settings/transfer`}
192 onsubmit="return confirm('Transfer this repository? The new owner will have full control.')"
193 >
194 <input
195 type="text"
196 name="new_owner"
197 placeholder="new-owner-username"
198 required
199 style="width:60%"
200 />{" "}
201 <button type="submit" class="btn">
202 Transfer
203 </button>
204 </form>
205 </div>
206
207 <div
208 style="margin-top: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
209 >
210 <h3 style="margin-bottom: 8px">
211 {repo.isArchived ? "Unarchive repository" : "Archive repository"}
212 </h3>
213 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
214 {repo.isArchived
215 ? "This repository is archived and read-only. Unarchive to allow pushes and issue/PR activity again."
216 : "Mark this repository as archived. It will become read-only — no pushes, no new issues or PRs. You can unarchive at any time."}
217 </p>
218 <form
e7e240eClaude219 method="post"
71cd5ecClaude220 action={`/${ownerName}/${repoName}/settings/archive`}
221 >
222 <input
223 type="hidden"
224 name="archive"
225 value={repo.isArchived ? "0" : "1"}
226 />
227 <button type="submit" class="btn">
228 {repo.isArchived ? "Unarchive" : "Archive"} this repository
229 </button>
230 </form>
231 </div>
232
233 <div
234 style="margin-top: 20px; padding: 20px; border: 1px solid var(--red); border-radius: var(--radius)"
79136bbClaude235 >
236 <h3 style="color: var(--red); margin-bottom: 12px">Danger zone</h3>
bb0f894Claude237 <Text size={14} muted style="display:block;margin-bottom:12px">
79136bbClaude238 Permanently delete this repository and all its data.
bb0f894Claude239 </Text>
79136bbClaude240 <form
e7e240eClaude241 method="post"
79136bbClaude242 action={`/${ownerName}/${repoName}/settings/delete`}
243 onsubmit="return confirm('Are you sure? This cannot be undone.')"
244 >
bb0f894Claude245 <Button type="submit" variant="danger">
79136bbClaude246 Delete this repository
bb0f894Claude247 </Button>
79136bbClaude248 </form>
249 </div>
bb0f894Claude250 </Container>
79136bbClaude251 </Layout>
252 );
253});
254
255// Save settings
256repoSettings.post("/:owner/:repo/settings", requireAuth, async (c) => {
257 const { owner: ownerName, repo: repoName } = c.req.param();
258 const user = c.get("user")!;
259 const body = await c.req.parseBody();
260
261 const [owner] = await db
262 .select()
263 .from(users)
264 .where(eq(users.username, ownerName))
265 .limit(1);
266
267 if (!owner || owner.id !== user.id) {
268 return c.redirect(`/${ownerName}/${repoName}`);
269 }
270
271 await db
272 .update(repositories)
273 .set({
274 description: String(body.description || "").trim() || null,
275 defaultBranch: String(body.default_branch || "main"),
276 isPrivate: body.visibility === "private",
277 updatedAt: new Date(),
278 })
279 .where(
280 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
281 );
282
283 return c.redirect(
284 `/${ownerName}/${repoName}/settings?success=Settings+saved`
285 );
286});
287
71cd5ecClaude288// Toggle template flag
289repoSettings.post(
290 "/:owner/:repo/settings/template",
291 requireAuth,
292 async (c) => {
293 const { owner: ownerName, repo: repoName } = c.req.param();
294 const user = c.get("user")!;
295 const body = await c.req.parseBody();
296 const [owner] = await db
297 .select()
298 .from(users)
299 .where(eq(users.username, ownerName))
300 .limit(1);
301 if (!owner || owner.id !== user.id) {
302 return c.redirect(`/${ownerName}/${repoName}`);
303 }
304 const target = String(body.template || "1") === "1";
305 await db
306 .update(repositories)
307 .set({ isTemplate: target, updatedAt: new Date() })
308 .where(
309 and(
310 eq(repositories.ownerId, owner.id),
311 eq(repositories.name, repoName)
312 )
313 );
314 return c.redirect(
315 `/${ownerName}/${repoName}/settings?success=${
316 target ? "Marked+as+template" : "Unmarked+as+template"
317 }`
318 );
319 }
320);
321
322// Transfer repository to a new owner (by username)
323repoSettings.post(
324 "/:owner/:repo/settings/transfer",
325 requireAuth,
326 async (c) => {
327 const { owner: ownerName, repo: repoName } = c.req.param();
328 const user = c.get("user")!;
329 const body = await c.req.parseBody();
330 const newOwnerName = String(body.new_owner || "").trim();
331 if (!newOwnerName) {
332 return c.redirect(
333 `/${ownerName}/${repoName}/settings?error=New+owner+required`
334 );
335 }
336 const [owner] = await db
337 .select()
338 .from(users)
339 .where(eq(users.username, ownerName))
340 .limit(1);
341 if (!owner || owner.id !== user.id) {
342 return c.redirect(`/${ownerName}/${repoName}`);
343 }
344 const [newOwner] = await db
345 .select()
346 .from(users)
347 .where(eq(users.username, newOwnerName))
348 .limit(1);
349 if (!newOwner) {
350 return c.redirect(
351 `/${ownerName}/${repoName}/settings?error=User+not+found`
352 );
353 }
354 if (newOwner.id === owner.id) {
355 return c.redirect(
356 `/${ownerName}/${repoName}/settings?error=Same+owner`
357 );
358 }
359 // Reject if new owner already has a repo by this name
360 const [conflict] = await db
361 .select()
362 .from(repositories)
363 .where(
364 and(
365 eq(repositories.ownerId, newOwner.id),
366 eq(repositories.name, repoName)
367 )
368 )
369 .limit(1);
370 if (conflict) {
371 return c.redirect(
372 `/${ownerName}/${repoName}/settings?error=Target+owner+already+has+a+repo+by+that+name`
373 );
374 }
375 const [repo] = await db
376 .select()
377 .from(repositories)
378 .where(
379 and(
380 eq(repositories.ownerId, owner.id),
381 eq(repositories.name, repoName)
382 )
383 )
384 .limit(1);
385 if (!repo) return c.notFound();
386 await db
387 .update(repositories)
388 .set({ ownerId: newOwner.id, orgId: null, updatedAt: new Date() })
389 .where(eq(repositories.id, repo.id));
390 await db.insert(repoTransfers).values({
391 repositoryId: repo.id,
392 fromOwnerId: owner.id,
393 fromOrgId: repo.orgId,
394 toOwnerId: newOwner.id,
395 toOrgId: null,
396 initiatedBy: user.id,
397 });
398 return c.redirect(`/${newOwnerName}/${repoName}`);
399 }
400);
401
402// Archive / unarchive repository
403repoSettings.post(
404 "/:owner/:repo/settings/archive",
405 requireAuth,
406 async (c) => {
407 const { owner: ownerName, repo: repoName } = c.req.param();
408 const user = c.get("user")!;
409 const body = await c.req.parseBody();
410 const [owner] = await db
411 .select()
412 .from(users)
413 .where(eq(users.username, ownerName))
414 .limit(1);
415 if (!owner || owner.id !== user.id) {
416 return c.redirect(`/${ownerName}/${repoName}`);
417 }
418 const target = String(body.archive || "1") === "1";
419 await db
420 .update(repositories)
421 .set({ isArchived: target, updatedAt: new Date() })
422 .where(
423 and(
424 eq(repositories.ownerId, owner.id),
425 eq(repositories.name, repoName)
426 )
427 );
428 return c.redirect(
429 `/${ownerName}/${repoName}/settings?success=${
430 target ? "Repository+archived" : "Repository+unarchived"
431 }`
432 );
433 }
434);
435
79136bbClaude436// Delete repository
437repoSettings.post(
438 "/:owner/:repo/settings/delete",
439 requireAuth,
440 async (c) => {
441 const { owner: ownerName, repo: repoName } = c.req.param();
442 const user = c.get("user")!;
443
444 const [owner] = await db
445 .select()
446 .from(users)
447 .where(eq(users.username, ownerName))
448 .limit(1);
449
450 if (!owner || owner.id !== user.id) {
451 return c.redirect(`/${ownerName}/${repoName}`);
452 }
453
454 const [repo] = await db
455 .select()
456 .from(repositories)
457 .where(
458 and(
459 eq(repositories.ownerId, owner.id),
460 eq(repositories.name, repoName)
461 )
462 )
463 .limit(1);
464
465 if (!repo) return c.redirect(`/${ownerName}`);
466
467 // Delete from disk
468 try {
469 await rm(repo.diskPath, { recursive: true, force: true });
470 } catch {
471 // Disk cleanup best-effort
472 }
473
474 // Delete from DB (cascades to stars, issues, etc.)
475 await db.delete(repositories).where(eq(repositories.id, repo.id));
476
477 return c.redirect(`/${ownerName}`);
478 }
479);
480
481export default repoSettings;