Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

releases.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.

releases.tsxBlame497 lines · 2 contributors
3ef4c9dClaude1/**
2 * Releases — tagged snapshots with AI-generated changelogs.
3 *
4 * GET /:owner/:repo/releases — list
5 * GET /:owner/:repo/releases/new — create form (tag + target + AI notes)
6 * POST /:owner/:repo/releases — create release + git tag + changelog
7 * GET /:owner/:repo/releases/:tag — view single release
8 * POST /:owner/:repo/releases/:tag/delete — owner-only delete (also removes git tag)
9 *
10 * Publishing a release fans out `release_published` notifications to starrers.
11 */
12
13import { Hono } from "hono";
14import { eq, and, desc } from "drizzle-orm";
15import { db } from "../db";
16import {
17 releases,
18 repositories,
19 users,
20 stars,
21 repoSettings,
22} from "../db/schema";
23import { Layout } from "../views/layout";
24import { RepoHeader, RepoNav } from "../views/components";
25import { softAuth, requireAuth } from "../middleware/auth";
26import type { AuthEnv } from "../middleware/auth";
febd4f0Claude27import { requireRepoAccess } from "../middleware/repo-access";
3ef4c9dClaude28import {
29 listBranches,
30 listTags,
31 createTag,
32 deleteTag,
33 resolveRef,
34 commitsBetween,
35 getDefaultBranch,
36} from "../git/repository";
37import { generateChangelog } from "../lib/ai-generators";
38import { notifyMany, audit } from "../lib/notify";
39import { renderMarkdown } from "../lib/markdown";
40import { getUnreadCount } from "../lib/unread";
41
42const releasesRoute = new Hono<AuthEnv>();
43releasesRoute.use("*", softAuth);
44
45async function loadRepo(owner: string, repo: string) {
46 const [row] = await db
47 .select({
48 id: repositories.id,
49 name: repositories.name,
50 defaultBranch: repositories.defaultBranch,
51 ownerId: repositories.ownerId,
52 starCount: repositories.starCount,
53 forkCount: repositories.forkCount,
54 forkedFromId: repositories.forkedFromId,
55 })
56 .from(repositories)
57 .innerJoin(users, eq(repositories.ownerId, users.id))
58 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
59 .limit(1);
60 return row;
61}
62
febd4f0Claude63releasesRoute.get("/:owner/:repo/releases", requireRepoAccess("read"), async (c) => {
3ef4c9dClaude64 const user = c.get("user");
65 const { owner, repo } = c.req.param();
66 const repoRow = await loadRepo(owner, repo);
67 if (!repoRow) return c.notFound();
68
69 const rows = await db
70 .select({
71 id: releases.id,
72 tag: releases.tag,
73 name: releases.name,
74 body: releases.body,
75 targetCommit: releases.targetCommit,
76 isDraft: releases.isDraft,
77 isPrerelease: releases.isPrerelease,
78 createdAt: releases.createdAt,
79 publishedAt: releases.publishedAt,
80 authorName: users.username,
81 })
82 .from(releases)
83 .innerJoin(users, eq(releases.authorId, users.id))
84 .where(eq(releases.repositoryId, repoRow.id))
85 .orderBy(desc(releases.createdAt));
86
87 const unread = user ? await getUnreadCount(user.id) : 0;
88
89 return c.html(
90 <Layout
91 title={`Releases — ${owner}/${repo}`}
92 user={user}
93 notificationCount={unread}
94 >
95 <RepoHeader
96 owner={owner}
97 repo={repo}
98 starCount={repoRow.starCount}
99 forkCount={repoRow.forkCount}
100 currentUser={user?.username || null}
101 />
102 <RepoNav owner={owner} repo={repo} active="releases" />
103 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
104 <h3>Releases</h3>
105 {user && user.id === repoRow.ownerId && (
106 <a href={`/${owner}/${repo}/releases/new`} class="btn btn-primary">
107 + Draft release
108 </a>
109 )}
110 </div>
111
112 {rows.length === 0 ? (
113 <div class="empty-state">
114 <h2>No releases yet</h2>
115 <p>Tag a commit and share a changelog with your users.</p>
116 </div>
117 ) : (
118 <div>
119 {rows.map((r, i) => (
120 <div class="release-card">
121 <div class="release-header">
122 <div>
123 <span class="release-name">
124 <a href={`/${owner}/${repo}/releases/${encodeURIComponent(r.tag)}`}>
125 {r.name}
126 </a>
127 </span>
128 {i === 0 && !r.isDraft && !r.isPrerelease && (
129 <span class="release-tag release-latest" style="margin-left: 8px">
130 Latest
131 </span>
132 )}
133 {r.isDraft && (
134 <span class="badge" style="margin-left: 8px; color: var(--yellow); border-color: var(--yellow)">
135 Draft
136 </span>
137 )}
138 {r.isPrerelease && (
139 <span class="badge" style="margin-left: 8px; color: var(--accent); border-color: var(--accent)">
140 Pre-release
141 </span>
142 )}
143 </div>
144 <span class="release-tag">{r.tag}</span>
145 </div>
146 <div style="color: var(--text-muted); font-size: 13px; margin-bottom: 8px">
147 {r.authorName} released{" "}
148 {new Date(r.publishedAt || r.createdAt).toLocaleDateString()}
149 {" · "}
150 <a href={`/${owner}/${repo}/commit/${r.targetCommit}`}>
151 {r.targetCommit.slice(0, 7)}
152 </a>
153 </div>
154 {r.body && (
155 <div
156 class="markdown-body"
157 style="font-size: 13px; max-height: 200px; overflow: hidden; position: relative"
158 dangerouslySetInnerHTML={{
159 __html: renderMarkdown(r.body.slice(0, 600) + (r.body.length > 600 ? " …" : "")),
160 }}
161 ></div>
162 )}
163 {user && user.id === repoRow.ownerId && (
164 <form
e7e240eClaude165 method="post"
3ef4c9dClaude166 action={`/${owner}/${repo}/releases/${encodeURIComponent(r.tag)}/delete`}
167 style="margin-top: 12px"
168 onsubmit="return confirm('Delete this release?')"
169 >
170 <button type="submit" class="btn btn-sm btn-danger">
171 Delete
172 </button>
173 </form>
174 )}
175 </div>
176 ))}
177 </div>
178 )}
179 </Layout>
180 );
181});
182
febd4f0Claude183releasesRoute.get("/:owner/:repo/releases/new", requireAuth, requireRepoAccess("write"), async (c) => {
3ef4c9dClaude184 const user = c.get("user")!;
185 const { owner, repo } = c.req.param();
186 const repoRow = await loadRepo(owner, repo);
187 if (!repoRow) return c.notFound();
188 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/releases`);
189
190 const branches = await listBranches(owner, repo);
191 const tags = await listTags(owner, repo);
192 const unread = await getUnreadCount(user.id);
193 const error = c.req.query("error");
194
195 return c.html(
196 <Layout
197 title={`Draft release — ${owner}/${repo}`}
198 user={user}
199 notificationCount={unread}
200 >
201 <RepoHeader
202 owner={owner}
203 repo={repo}
204 starCount={repoRow.starCount}
205 forkCount={repoRow.forkCount}
206 currentUser={user.username}
207 />
208 <RepoNav owner={owner} repo={repo} active="releases" />
209 <h3>Draft a new release</h3>
210 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
211 <form
e7e240eClaude212 method="post"
3ef4c9dClaude213 action={`/${owner}/${repo}/releases`}
214 style="max-width: 700px"
215 >
216 <div class="form-group">
217 <label>Tag</label>
218 <input
219 type="text"
220 name="tag"
221 required
222 placeholder="v1.0.0"
223 pattern="[A-Za-z0-9._\\-]+"
63c60ebcopilot-swe-agent[bot]224 aria-label="Tag"
3ef4c9dClaude225 />
226 </div>
227 <div class="form-group">
228 <label>Target branch / commit</label>
63c60ebcopilot-swe-agent[bot]229 <select name="target" aria-label="Target branch">
3ef4c9dClaude230 {branches.map((b) => (
231 <option value={b} selected={b === repoRow.defaultBranch}>
232 {b}
233 </option>
234 ))}
235 </select>
236 </div>
237 <div class="form-group">
238 <label>Release name</label>
63c60ebcopilot-swe-agent[bot]239 <input type="text" name="name" required placeholder="v1.0.0 — the big one" aria-label="Release name" />
3ef4c9dClaude240 </div>
241 <div class="form-group">
242 <label>Previous tag (for AI changelog)</label>
63c60ebcopilot-swe-agent[bot]243 <select name="previousTag" aria-label="Previous tag">
3ef4c9dClaude244 <option value="">(auto — last tag)</option>
245 {tags.map((t) => (
246 <option value={t.name}>{t.name}</option>
247 ))}
248 </select>
249 </div>
250 <div class="form-group">
251 <label>Notes (leave blank for AI-generated)</label>
252 <textarea name="body" rows={10} placeholder="Markdown supported. Leave blank to have Claude generate a grouped changelog from commits."></textarea>
253 </div>
254 <div style="display: flex; gap: 12px">
255 <label style="display: flex; align-items: center; gap: 6px; font-size: 14px">
256 <input type="checkbox" name="isPrerelease" value="1" />
257 Pre-release
258 </label>
259 <label style="display: flex; align-items: center; gap: 6px; font-size: 14px">
260 <input type="checkbox" name="isDraft" value="1" />
261 Save as draft
262 </label>
263 </div>
264 <button type="submit" class="btn btn-primary" style="margin-top: 16px">
265 Publish release
266 </button>
267 </form>
268 </Layout>
269 );
270});
271
febd4f0Claude272releasesRoute.post("/:owner/:repo/releases", requireAuth, requireRepoAccess("write"), async (c) => {
3ef4c9dClaude273 const user = c.get("user")!;
274 const { owner, repo } = c.req.param();
275 const repoRow = await loadRepo(owner, repo);
276 if (!repoRow) return c.notFound();
277 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/releases`);
278
279 const body = await c.req.parseBody();
280 const tag = String(body.tag || "").trim();
281 const name = String(body.name || "").trim() || tag;
282 const target = String(body.target || repoRow.defaultBranch).trim();
283 const previousTag = String(body.previousTag || "").trim();
284 const notes = String(body.body || "").trim();
285 const isDraft = !!body.isDraft;
286 const isPrerelease = !!body.isPrerelease;
287
288 if (!tag || !/^[A-Za-z0-9._\-]+$/.test(tag)) {
289 return c.redirect(
290 `/${owner}/${repo}/releases/new?error=Invalid+tag+name`
291 );
292 }
293
294 const sha = await resolveRef(owner, repo, target);
295 if (!sha) {
296 return c.redirect(
297 `/${owner}/${repo}/releases/new?error=Could+not+resolve+target`
298 );
299 }
300
301 // Determine previous tag for changelog
302 let autoPrev = previousTag;
303 if (!autoPrev) {
304 const tags = await listTags(owner, repo);
305 autoPrev = tags[0]?.name || "";
306 }
307
308 // Generate changelog body if none provided
309 let finalBody = notes;
310 const [settings] = await db
311 .select()
312 .from(repoSettings)
313 .where(eq(repoSettings.repositoryId, repoRow.id))
314 .limit(1);
315 const aiEnabled = settings ? settings.aiChangelogEnabled : true;
316
317 if (!finalBody && aiEnabled) {
318 const commits = await commitsBetween(owner, repo, autoPrev || null, sha);
319 finalBody = await generateChangelog(`${owner}/${repo}`, autoPrev || null, tag, commits);
320 }
321
322 // Create the git tag (best-effort — if it already exists we reuse)
323 const existing = await resolveRef(owner, repo, `refs/tags/${tag}`);
324 if (!existing) {
325 await createTag(owner, repo, tag, sha, name || tag);
326 }
327
328 // Persist release
329 let releaseId = "";
330 try {
331 const [row] = await db
332 .insert(releases)
333 .values({
334 repositoryId: repoRow.id,
335 authorId: user.id,
336 tag,
337 name,
338 body: finalBody,
339 targetCommit: sha,
340 isDraft,
341 isPrerelease,
342 publishedAt: isDraft ? null : new Date(),
343 })
344 .returning();
345 releaseId = row?.id || "";
346 } catch (err) {
347 console.error("[releases] insert failed:", err);
348 return c.redirect(
349 `/${owner}/${repo}/releases/new?error=Tag+already+published`
350 );
351 }
352
353 // Notify starrers (only on publish)
354 if (!isDraft) {
355 try {
356 const starUsers = await db
357 .select({ userId: stars.userId })
358 .from(stars)
359 .where(eq(stars.repositoryId, repoRow.id));
360 await notifyMany(
361 starUsers.map((s) => s.userId).filter((id) => id !== user.id),
362 {
363 kind: "release_published",
364 title: `${owner}/${repo} ${tag} released`,
365 body: name,
366 url: `/${owner}/${repo}/releases/${encodeURIComponent(tag)}`,
367 repositoryId: repoRow.id,
368 }
369 );
370 } catch {
371 /* ignore */
372 }
373 }
374
375 await audit({
376 userId: user.id,
377 repositoryId: repoRow.id,
378 action: "release.publish",
379 targetType: "release",
380 targetId: releaseId,
381 metadata: { tag, target, isDraft, isPrerelease },
382 });
383
384 return c.redirect(`/${owner}/${repo}/releases/${encodeURIComponent(tag)}`);
385});
386
febd4f0Claude387releasesRoute.get("/:owner/:repo/releases/:tag", requireRepoAccess("read"), async (c) => {
3ef4c9dClaude388 const user = c.get("user");
389 const { owner, repo } = c.req.param();
390 const tag = decodeURIComponent(c.req.param("tag"));
391 const repoRow = await loadRepo(owner, repo);
392 if (!repoRow) return c.notFound();
393
394 const [release] = await db
395 .select({
396 id: releases.id,
397 tag: releases.tag,
398 name: releases.name,
399 body: releases.body,
400 targetCommit: releases.targetCommit,
401 isDraft: releases.isDraft,
402 isPrerelease: releases.isPrerelease,
403 createdAt: releases.createdAt,
404 publishedAt: releases.publishedAt,
405 authorName: users.username,
406 })
407 .from(releases)
408 .innerJoin(users, eq(releases.authorId, users.id))
409 .where(
410 and(eq(releases.repositoryId, repoRow.id), eq(releases.tag, tag))
411 )
412 .limit(1);
413 if (!release) return c.notFound();
414
415 const unread = user ? await getUnreadCount(user.id) : 0;
416
417 return c.html(
418 <Layout
419 title={`${release.name} — ${owner}/${repo}`}
420 user={user}
421 notificationCount={unread}
422 >
423 <RepoHeader
424 owner={owner}
425 repo={repo}
426 starCount={repoRow.starCount}
427 forkCount={repoRow.forkCount}
428 currentUser={user?.username || null}
429 />
430 <RepoNav owner={owner} repo={repo} active="releases" />
431 <div style="margin-bottom: 8px">
432 <a href={`/${owner}/${repo}/releases`}>{"\u2190"} All releases</a>
433 </div>
434 <div class="release-card">
435 <div class="release-header">
436 <span class="release-name">{release.name}</span>
437 <span class="release-tag">{release.tag}</span>
438 </div>
439 <div style="color: var(--text-muted); font-size: 13px; margin-bottom: 16px">
440 {release.authorName} released{" "}
441 {new Date(release.publishedAt || release.createdAt).toLocaleString()}
442 {" · "}
443 <a href={`/${owner}/${repo}/commit/${release.targetCommit}`}>
444 {release.targetCommit.slice(0, 7)}
445 </a>
446 {" · "}
447 <a
448 href={`/${owner}/${repo}/archive/${encodeURIComponent(release.tag)}.zip`}
449 >
450 Download
451 </a>
452 </div>
453 {release.body && (
454 <div
455 class="markdown-body"
456 dangerouslySetInnerHTML={{
457 __html: renderMarkdown(release.body),
458 }}
459 ></div>
460 )}
461 </div>
462 </Layout>
463 );
464});
465
466releasesRoute.post(
467 "/:owner/:repo/releases/:tag/delete",
468 requireAuth,
febd4f0Claude469 requireRepoAccess("write"),
3ef4c9dClaude470 async (c) => {
471 const user = c.get("user")!;
472 const { owner, repo } = c.req.param();
473 const tag = decodeURIComponent(c.req.param("tag"));
474 const repoRow = await loadRepo(owner, repo);
475 if (!repoRow) return c.notFound();
476 if (repoRow.ownerId !== user.id) {
477 return c.redirect(`/${owner}/${repo}/releases`);
478 }
479
480 await db
481 .delete(releases)
482 .where(
483 and(eq(releases.repositoryId, repoRow.id), eq(releases.tag, tag))
484 );
485 await deleteTag(owner, repo, tag);
486 await audit({
487 userId: user.id,
488 repositoryId: repoRow.id,
489 action: "release.delete",
490 targetType: "release",
491 metadata: { tag },
492 });
493 return c.redirect(`/${owner}/${repo}/releases`);
494 }
495);
496
497export default releasesRoute;