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