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.tsxBlame1136 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.
f0b5874Claude11 *
12 * 2026 polish: scoped `.rel-*` class system mirrors `admin-ops.tsx` and
13 * `collaborators.tsx` — eyebrow + display headline, polished cards with
14 * tabular-nums for counts/dates, version pills (mono), gradient hero CTA, and
15 * an orb-lit dashed empty state. RepoHeader / RepoNav are untouched.
3ef4c9dClaude16 */
17
18import { Hono } from "hono";
19import { eq, and, desc } from "drizzle-orm";
20import { db } from "../db";
21import {
22 releases,
23 repositories,
24 users,
25 stars,
26 repoSettings,
27} from "../db/schema";
28import { Layout } from "../views/layout";
29import { RepoHeader, RepoNav } from "../views/components";
30import { softAuth, requireAuth } from "../middleware/auth";
31import type { AuthEnv } from "../middleware/auth";
febd4f0Claude32import { requireRepoAccess } from "../middleware/repo-access";
3ef4c9dClaude33import {
34 listBranches,
35 listTags,
36 createTag,
37 deleteTag,
38 resolveRef,
39 commitsBetween,
40 getDefaultBranch,
41} from "../git/repository";
42import { generateChangelog } from "../lib/ai-generators";
43import { notifyMany, audit } from "../lib/notify";
44import { renderMarkdown } from "../lib/markdown";
45import { getUnreadCount } from "../lib/unread";
46
47const releasesRoute = new Hono<AuthEnv>();
48releasesRoute.use("*", softAuth);
49
50async function loadRepo(owner: string, repo: string) {
51 const [row] = await db
52 .select({
53 id: repositories.id,
54 name: repositories.name,
55 defaultBranch: repositories.defaultBranch,
56 ownerId: repositories.ownerId,
57 starCount: repositories.starCount,
58 forkCount: repositories.forkCount,
59 forkedFromId: repositories.forkedFromId,
60 })
61 .from(repositories)
62 .innerJoin(users, eq(repositories.ownerId, users.id))
63 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
64 .limit(1);
65 return row;
66}
67
f0b5874Claude68// ─── Scoped CSS (.rel-*) ────────────────────────────────────────────────────
69//
70// Every selector prefixed `.rel-*` so it can't leak into surrounding chrome.
71// Tokens come from the layout (--bg-elevated, --border, --text-strong,
72// --space-*, --font-*).
73const relStyles = `
74 .rel-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
75
76 .rel-head {
77 display: flex;
78 align-items: flex-end;
79 justify-content: space-between;
80 gap: var(--space-4);
81 flex-wrap: wrap;
82 margin-bottom: var(--space-5);
83 }
84 .rel-head-text { flex: 1; min-width: 280px; }
85 .rel-eyebrow {
86 display: inline-flex;
87 align-items: center;
88 gap: 8px;
89 text-transform: uppercase;
90 font-family: var(--font-mono);
91 font-size: 11px;
92 letter-spacing: 0.16em;
93 color: var(--text-muted);
94 font-weight: 600;
95 margin-bottom: 10px;
96 }
97 .rel-eyebrow-dot {
98 width: 8px; height: 8px;
99 border-radius: 9999px;
100 background: linear-gradient(135deg, #8c6dff, #36c5d6);
101 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
102 }
103 .rel-title {
104 font-family: var(--font-display);
105 font-size: clamp(24px, 3.4vw, 36px);
106 font-weight: 800;
107 letter-spacing: -0.028em;
108 line-height: 1.1;
109 margin: 0 0 6px;
110 color: var(--text-strong);
111 }
112 .rel-title-grad {
113 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
114 -webkit-background-clip: text;
115 background-clip: text;
116 -webkit-text-fill-color: transparent;
117 color: transparent;
118 }
119 .rel-sub {
120 margin: 0;
121 font-size: 14px;
122 color: var(--text-muted);
123 line-height: 1.5;
124 max-width: 640px;
125 }
126
127 /* Buttons */
128 .rel-btn {
129 display: inline-flex;
130 align-items: center;
131 justify-content: center;
132 gap: 6px;
133 padding: 9px 16px;
134 border-radius: 10px;
135 font-size: 13px;
136 font-weight: 600;
137 text-decoration: none;
138 border: 1px solid transparent;
139 cursor: pointer;
140 font: inherit;
141 line-height: 1;
142 white-space: nowrap;
143 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
144 }
145 .rel-btn-primary {
146 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
147 color: #ffffff;
148 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
149 }
150 .rel-btn-primary:hover {
151 transform: translateY(-1px);
152 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
153 text-decoration: none;
154 color: #ffffff;
155 }
156 .rel-btn-ghost {
157 background: transparent;
158 color: var(--text);
159 border-color: var(--border-strong);
160 }
161 .rel-btn-ghost:hover {
162 background: rgba(140,109,255,0.06);
163 border-color: rgba(140,109,255,0.45);
164 color: var(--text-strong);
165 text-decoration: none;
166 }
167 .rel-btn-danger {
168 background: transparent;
169 color: #fca5a5;
170 border-color: rgba(248,113,113,0.35);
171 }
172 .rel-btn-danger:hover {
173 border-style: dashed;
174 border-color: rgba(248,113,113,0.70);
175 background: rgba(248,113,113,0.06);
176 color: #fecaca;
177 text-decoration: none;
178 }
179
180 /* Banner */
181 .rel-banner {
182 margin-bottom: var(--space-4);
183 padding: 10px 14px;
184 border-radius: 10px;
185 font-size: 13.5px;
186 border: 1px solid rgba(248,113,113,0.40);
187 background: rgba(248,113,113,0.08);
188 color: #fecaca;
189 display: flex;
190 align-items: center;
191 gap: 10px;
192 }
193 .rel-banner-dot { width: 8px; height: 8px; border-radius: 9999px; background: currentColor; }
194
195 /* Crumb */
196 .rel-crumbs {
197 display: flex;
198 align-items: center;
199 gap: 12px;
200 flex-wrap: wrap;
201 margin-bottom: var(--space-4);
202 font-size: 12.5px;
203 }
204 .rel-crumbs a {
205 display: inline-flex;
206 align-items: center;
207 gap: 5px;
208 padding: 6px 11px;
209 background: rgba(255,255,255,0.025);
210 border: 1px solid var(--border);
211 border-radius: 8px;
212 color: var(--text-muted);
213 text-decoration: none;
214 font-weight: 500;
215 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
216 }
217 .rel-crumbs a:hover {
218 border-color: var(--border-strong);
219 color: var(--text-strong);
220 background: rgba(255,255,255,0.04);
221 text-decoration: none;
222 }
223
224 /* Release cards */
225 .rel-list { display: flex; flex-direction: column; gap: 12px; }
226 .rel-card {
227 position: relative;
228 overflow: hidden;
229 background: var(--bg-elevated);
230 border: 1px solid var(--border);
231 border-radius: 14px;
232 padding: var(--space-4) var(--space-5);
233 transition: border-color 120ms ease, background 120ms ease;
234 }
235 .rel-card:hover { border-color: var(--border-strong); }
236 .rel-card.is-latest::before {
237 content: '';
238 position: absolute;
239 top: 0; left: 0; right: 0;
240 height: 2px;
241 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
242 opacity: 0.6;
243 pointer-events: none;
244 }
245 .rel-card-head {
246 display: flex;
247 align-items: flex-start;
248 justify-content: space-between;
249 gap: 14px;
250 flex-wrap: wrap;
251 margin-bottom: 10px;
252 }
253 .rel-card-titlewrap { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; min-width: 0; }
254 .rel-card-name {
255 font-family: var(--font-display);
256 font-weight: 700;
257 font-size: 18px;
258 letter-spacing: -0.015em;
259 color: var(--text-strong);
260 text-decoration: none;
261 }
262 .rel-card-name:hover { color: var(--text-strong); text-decoration: underline; }
263 .rel-tag-pill {
264 display: inline-flex;
265 align-items: center;
266 gap: 4px;
267 padding: 3px 9px;
268 border-radius: 9999px;
269 font-family: var(--font-mono);
270 font-size: 11.5px;
271 font-weight: 600;
272 background: rgba(140,109,255,0.12);
273 color: #c4b5fd;
274 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
275 font-variant-numeric: tabular-nums;
276 }
277 .rel-pill {
278 display: inline-flex;
279 align-items: center;
280 gap: 5px;
281 padding: 3px 9px;
282 border-radius: 9999px;
283 font-size: 11px;
284 font-weight: 600;
285 letter-spacing: 0.02em;
286 text-transform: capitalize;
287 }
288 .rel-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
289 .rel-pill.is-latest {
290 background: rgba(52,211,153,0.14);
291 color: #6ee7b7;
292 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
293 }
294 .rel-pill.is-draft {
295 background: rgba(251,191,36,0.12);
296 color: #fde68a;
297 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.32);
298 }
299 .rel-pill.is-pre {
300 background: rgba(54,197,214,0.14);
301 color: #67e8f9;
302 box-shadow: inset 0 0 0 1px rgba(54,197,214,0.32);
303 }
304 .rel-meta-row {
305 display: flex;
306 align-items: center;
307 gap: 8px;
308 flex-wrap: wrap;
309 font-size: 12px;
310 color: var(--text-muted);
311 font-variant-numeric: tabular-nums;
312 margin-bottom: 10px;
313 }
314 .rel-meta-row a { color: var(--text-muted); }
315 .rel-meta-row a:hover { color: var(--text-strong); }
316 .rel-meta-row code, .rel-meta-row .mono {
317 font-family: var(--font-mono);
318 font-size: 11.5px;
319 padding: 1px 6px;
320 border-radius: 6px;
321 background: rgba(255,255,255,0.04);
322 border: 1px solid var(--border);
323 color: var(--text);
324 }
325 .rel-meta-row .sep { opacity: 0.4; }
326
327 .rel-notes {
328 font-size: 13.5px;
329 color: var(--text);
330 line-height: 1.55;
331 max-height: 220px;
332 overflow: hidden;
333 position: relative;
334 border-top: 1px dashed var(--border);
335 padding-top: 12px;
336 }
337 .rel-notes::after {
338 content: '';
339 position: absolute;
340 bottom: 0; left: 0; right: 0;
341 height: 48px;
342 background: linear-gradient(to top, var(--bg-elevated), transparent);
343 pointer-events: none;
344 }
345 .rel-card-actions { margin-top: 12px; }
346
347 /* Detail card */
348 .rel-detail {
349 position: relative;
350 overflow: hidden;
351 background: var(--bg-elevated);
352 border: 1px solid var(--border);
353 border-radius: 14px;
354 padding: var(--space-5);
355 }
356 .rel-detail::before {
357 content: '';
358 position: absolute;
359 top: 0; left: 0; right: 0;
360 height: 2px;
361 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
362 opacity: 0.55;
363 }
364
365 /* Form */
366 .rel-form-card {
367 background: var(--bg-elevated);
368 border: 1px solid var(--border);
369 border-radius: 14px;
370 padding: var(--space-5);
371 max-width: 720px;
372 position: relative;
373 overflow: hidden;
374 }
375 .rel-form-card::before {
376 content: '';
377 position: absolute;
378 top: 0; left: 0; right: 0;
379 height: 2px;
380 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
381 opacity: 0.55;
382 }
383 .rel-field { margin-bottom: 14px; }
384 .rel-field-label {
385 display: block;
386 font-size: 11.5px;
387 color: var(--text-muted);
388 font-weight: 600;
389 text-transform: uppercase;
390 letter-spacing: 0.06em;
391 margin-bottom: 6px;
392 }
393 .rel-input, .rel-select, .rel-textarea {
394 width: 100%;
395 box-sizing: border-box;
396 padding: 9px 12px;
397 font: inherit;
398 font-size: 13.5px;
399 color: var(--text);
400 background: rgba(255,255,255,0.03);
401 border: 1px solid var(--border-strong);
402 border-radius: 10px;
403 transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
404 }
405 .rel-textarea { font-family: var(--font-mono); font-size: 12.5px; line-height: 1.5; resize: vertical; }
406 .rel-input:focus, .rel-select:focus, .rel-textarea:focus {
407 outline: none;
408 border-color: rgba(140,109,255,0.55);
409 background: rgba(255,255,255,0.05);
410 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
411 }
412 .rel-checks { display: flex; gap: 18px; flex-wrap: wrap; margin-bottom: 16px; font-size: 13px; }
413 .rel-check { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
414
415 /* Empty */
416 .rel-empty {
417 position: relative;
418 overflow: hidden;
419 padding: clamp(32px, 6vw, 60px) clamp(20px, 4vw, 36px);
420 text-align: center;
421 background: var(--bg-elevated);
422 border: 1px dashed var(--border-strong);
423 border-radius: 16px;
424 }
425 .rel-empty-orb {
426 position: absolute;
427 inset: -40% 30% auto 30%;
428 height: 280px;
429 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.10) 45%, transparent 70%);
430 filter: blur(70px);
431 opacity: 0.7;
432 pointer-events: none;
433 z-index: 0;
434 }
435 .rel-empty-inner { position: relative; z-index: 1; }
436 .rel-empty-icon {
437 width: 56px; height: 56px;
438 border-radius: 9999px;
439 background: linear-gradient(135deg, rgba(140,109,255,0.25), rgba(54,197,214,0.20));
440 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40);
441 display: inline-flex;
442 align-items: center;
443 justify-content: center;
444 color: #c4b5fd;
445 margin-bottom: 14px;
446 }
447 .rel-empty-title {
448 font-family: var(--font-display);
449 font-size: 18px;
450 font-weight: 700;
451 margin: 0 0 6px;
452 color: var(--text-strong);
453 }
454 .rel-empty-sub {
455 margin: 0 auto 16px;
456 font-size: 13.5px;
457 color: var(--text-muted);
458 max-width: 420px;
459 line-height: 1.5;
460 }
461`;
462
463function IconTag() {
464 return (
465 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
466 <path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
467 <line x1="7" y1="7" x2="7.01" y2="7" />
468 </svg>
469 );
470}
471function IconPlus() {
472 return (
473 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
474 <line x1="12" y1="5" x2="12" y2="19" />
475 <line x1="5" y1="12" x2="19" y2="12" />
476 </svg>
477 );
478}
479function IconArrowLeft() {
480 return (
481 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
482 <line x1="19" y1="12" x2="5" y2="12" />
483 <polyline points="12 19 5 12 12 5" />
484 </svg>
485 );
486}
487function IconDownload() {
488 return (
489 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
490 <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
491 <polyline points="7 10 12 15 17 10" />
492 <line x1="12" y1="15" x2="12" y2="3" />
493 </svg>
494 );
495}
496
497/** YYYY-MM-DD with tabular-nums. */
498function shortDate(d: Date | string | null | undefined): string {
499 if (!d) return "—";
500 const dt = d instanceof Date ? d : new Date(d);
501 if (Number.isNaN(dt.getTime())) return "—";
502 return dt.toISOString().slice(0, 10);
503}
504
febd4f0Claude505releasesRoute.get("/:owner/:repo/releases", requireRepoAccess("read"), async (c) => {
3ef4c9dClaude506 const user = c.get("user");
507 const { owner, repo } = c.req.param();
508 const repoRow = await loadRepo(owner, repo);
509 if (!repoRow) return c.notFound();
510
511 const rows = await db
512 .select({
513 id: releases.id,
514 tag: releases.tag,
515 name: releases.name,
516 body: releases.body,
517 targetCommit: releases.targetCommit,
518 isDraft: releases.isDraft,
519 isPrerelease: releases.isPrerelease,
520 createdAt: releases.createdAt,
521 publishedAt: releases.publishedAt,
522 authorName: users.username,
523 })
524 .from(releases)
525 .innerJoin(users, eq(releases.authorId, users.id))
526 .where(eq(releases.repositoryId, repoRow.id))
527 .orderBy(desc(releases.createdAt));
528
529 const unread = user ? await getUnreadCount(user.id) : 0;
f0b5874Claude530 const isOwner = !!user && user.id === repoRow.ownerId;
3ef4c9dClaude531
532 return c.html(
533 <Layout
534 title={`Releases — ${owner}/${repo}`}
535 user={user}
536 notificationCount={unread}
537 >
538 <RepoHeader
539 owner={owner}
540 repo={repo}
541 starCount={repoRow.starCount}
542 forkCount={repoRow.forkCount}
543 currentUser={user?.username || null}
544 />
545 <RepoNav owner={owner} repo={repo} active="releases" />
f0b5874Claude546 <div class="rel-wrap">
547 <header class="rel-head">
548 <div class="rel-head-text">
549 <div class="rel-eyebrow">
550 <span class="rel-eyebrow-dot" aria-hidden="true" />
551 Repository · Releases
552 </div>
553 <h1 class="rel-title">
554 <span class="rel-title-grad">Tagged snapshots.</span>
555 </h1>
556 <p class="rel-sub">
557 Cut a versioned release of {owner}/{repo}. AI drafts the
558 changelog from your commit history — you decide what ships.
559 </p>
560 </div>
561 {isOwner && (
562 <a href={`/${owner}/${repo}/releases/new`} class="rel-btn rel-btn-primary">
563 <IconPlus />
564 Draft release
565 </a>
566 )}
567 </header>
3ef4c9dClaude568
f0b5874Claude569 {rows.length === 0 ? (
570 <div class="rel-empty">
571 <div class="rel-empty-orb" aria-hidden="true" />
572 <div class="rel-empty-inner">
573 <div class="rel-empty-icon" aria-hidden="true">
574 <IconTag />
3ef4c9dClaude575 </div>
f0b5874Claude576 <h3 class="rel-empty-title">Tag your first release</h3>
577 <p class="rel-empty-sub">
578 Pick a commit, name a tag like <code>v1.0.0</code>, and let
579 Claude write the changelog. Starrers will be notified the
580 moment it ships.
581 </p>
582 {isOwner && (
583 <a href={`/${owner}/${repo}/releases/new`} class="rel-btn rel-btn-primary">
584 <IconPlus />
585 Draft release
3ef4c9dClaude586 </a>
587 )}
588 </div>
f0b5874Claude589 </div>
590 ) : (
591 <div class="rel-list">
592 {rows.map((r, i) => {
593 const isLatest = i === 0 && !r.isDraft && !r.isPrerelease;
594 return (
595 <article class={`rel-card${isLatest ? " is-latest" : ""}`}>
596 <div class="rel-card-head">
597 <div class="rel-card-titlewrap">
598 <a
599 href={`/${owner}/${repo}/releases/${encodeURIComponent(r.tag)}`}
600 class="rel-card-name"
601 >
602 {r.name}
603 </a>
604 <span class="rel-tag-pill">
605 <IconTag />
606 {r.tag}
607 </span>
608 {isLatest && (
609 <span class="rel-pill is-latest">
610 <span class="dot" aria-hidden="true" />
611 Latest
612 </span>
613 )}
614 {r.isDraft && (
615 <span class="rel-pill is-draft">
616 <span class="dot" aria-hidden="true" />
617 Draft
618 </span>
619 )}
620 {r.isPrerelease && (
621 <span class="rel-pill is-pre">
622 <span class="dot" aria-hidden="true" />
623 Pre-release
624 </span>
625 )}
626 </div>
627 </div>
628 <div class="rel-meta-row">
629 <span>@{r.authorName}</span>
630 <span class="sep">·</span>
631 <span>released {shortDate(r.publishedAt || r.createdAt)}</span>
632 <span class="sep">·</span>
633 <a href={`/${owner}/${repo}/commit/${r.targetCommit}`}>
634 <span class="mono">{r.targetCommit.slice(0, 7)}</span>
635 </a>
636 <span class="sep">·</span>
637 <a href={`/${owner}/${repo}/archive/${encodeURIComponent(r.tag)}.zip`}>
638 <IconDownload /> Download
639 </a>
640 </div>
641 {r.body && (
642 <div
643 class="rel-notes markdown-body"
644 dangerouslySetInnerHTML={{
645 __html: renderMarkdown(r.body.slice(0, 600) + (r.body.length > 600 ? " …" : "")),
646 }}
647 ></div>
648 )}
649 {isOwner && (
650 <div class="rel-card-actions">
651 <form
652 method="post"
653 action={`/${owner}/${repo}/releases/${encodeURIComponent(r.tag)}/delete`}
654 onsubmit="return confirm('Delete this release?')"
655 >
656 <button type="submit" class="rel-btn rel-btn-danger">
657 Delete
658 </button>
659 </form>
660 </div>
661 )}
662 </article>
663 );
664 })}
665 </div>
666 )}
667 </div>
668 <style dangerouslySetInnerHTML={{ __html: relStyles }} />
3ef4c9dClaude669 </Layout>
670 );
671});
672
febd4f0Claude673releasesRoute.get("/:owner/:repo/releases/new", requireAuth, requireRepoAccess("write"), async (c) => {
3ef4c9dClaude674 const user = c.get("user")!;
675 const { owner, repo } = c.req.param();
676 const repoRow = await loadRepo(owner, repo);
677 if (!repoRow) return c.notFound();
678 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/releases`);
679
680 const branches = await listBranches(owner, repo);
681 const tags = await listTags(owner, repo);
682 const unread = await getUnreadCount(user.id);
683 const error = c.req.query("error");
684
685 return c.html(
686 <Layout
687 title={`Draft release — ${owner}/${repo}`}
688 user={user}
689 notificationCount={unread}
690 >
691 <RepoHeader
692 owner={owner}
693 repo={repo}
694 starCount={repoRow.starCount}
695 forkCount={repoRow.forkCount}
696 currentUser={user.username}
697 />
698 <RepoNav owner={owner} repo={repo} active="releases" />
f0b5874Claude699 <div class="rel-wrap">
700 <div class="rel-crumbs">
701 <a href={`/${owner}/${repo}/releases`}>
702 <IconArrowLeft />
703 All releases
704 </a>
3ef4c9dClaude705 </div>
f0b5874Claude706 <header class="rel-head">
707 <div class="rel-head-text">
708 <div class="rel-eyebrow">
709 <span class="rel-eyebrow-dot" aria-hidden="true" />
710 Releases · New
711 </div>
712 <h1 class="rel-title">
713 <span class="rel-title-grad">Draft a release.</span>
714 </h1>
715 <p class="rel-sub">
716 Pick a tag and target, write notes (or leave blank for the AI
717 changelog), then publish.
718 </p>
719 </div>
720 </header>
721
722 {error && (
723 <div class="rel-banner" role="alert">
724 <span class="rel-banner-dot" aria-hidden="true" />
725 {decodeURIComponent(error)}
726 </div>
727 )}
728
729 <form
730 method="post"
731 action={`/${owner}/${repo}/releases`}
732 class="rel-form-card"
733 >
734 <div class="rel-field">
735 <label class="rel-field-label" for="rel-tag">Tag</label>
736 <input
737 class="rel-input"
738 type="text"
739 id="rel-tag"
740 name="tag"
741 required
742 placeholder="v1.0.0"
743 pattern="[A-Za-z0-9._\\-]+"
744 aria-label="Tag"
745 />
746 </div>
747 <div class="rel-field">
748 <label class="rel-field-label" for="rel-target">Target branch / commit</label>
749 <select class="rel-select" id="rel-target" name="target" aria-label="Target branch">
750 {branches.map((b) => (
751 <option value={b} selected={b === repoRow.defaultBranch}>
752 {b}
753 </option>
754 ))}
755 </select>
756 </div>
757 <div class="rel-field">
758 <label class="rel-field-label" for="rel-name">Release name</label>
759 <input
760 class="rel-input"
761 type="text"
762 id="rel-name"
763 name="name"
764 required
765 placeholder="v1.0.0 — the big one"
766 aria-label="Release name"
767 />
768 </div>
769 <div class="rel-field">
770 <label class="rel-field-label" for="rel-prev">Previous tag (for AI changelog)</label>
771 <select class="rel-select" id="rel-prev" name="previousTag" aria-label="Previous tag">
772 <option value="">(auto — last tag)</option>
773 {tags.map((t) => (
774 <option value={t.name}>{t.name}</option>
775 ))}
776 </select>
777 </div>
778 <div class="rel-field">
779 <label class="rel-field-label" for="rel-body">Notes (leave blank for AI-generated)</label>
424eb72Claude780 <div style="display:flex; align-items:center; gap:8px; margin-bottom: 6px;">
781 <button
782 type="button"
783 id="rel-gen-notes"
784 class="rel-btn rel-btn-ghost"
785 aria-label="Generate AI release notes from merged PRs"
786 >
787 Generate notes
788 </button>
789 <span
790 id="rel-gen-notes-status"
791 style="font-size:12px; color: var(--text-muted);"
792 aria-live="polite"
793 ></span>
794 </div>
f0b5874Claude795 <textarea
796 class="rel-textarea"
797 id="rel-body"
798 name="body"
799 rows={10}
424eb72Claude800 placeholder="Markdown supported. Click 'Generate notes' to have Claude draft a polished changelog from every merged PR since the previous tag."
f0b5874Claude801 ></textarea>
802 </div>
803 <div class="rel-checks">
804 <label class="rel-check">
805 <input type="checkbox" name="isPrerelease" value="1" />
806 Pre-release
807 </label>
808 <label class="rel-check">
809 <input type="checkbox" name="isDraft" value="1" />
810 Save as draft
811 </label>
812 </div>
813 <button type="submit" class="rel-btn rel-btn-primary">
814 <IconTag />
815 Publish release
816 </button>
817 </form>
424eb72Claude818 <script
819 dangerouslySetInnerHTML={{
820 __html: `
821 (function(){
822 var btn = document.getElementById('rel-gen-notes');
823 if (!btn) return;
824 var status = document.getElementById('rel-gen-notes-status');
825 var tagInput = document.getElementById('rel-tag');
826 var prevSel = document.getElementById('rel-prev');
827 var bodyArea = document.getElementById('rel-body');
828 btn.addEventListener('click', async function(){
829 var toTag = (tagInput && tagInput.value || '').trim();
830 if (!toTag) {
831 status.textContent = 'Enter a tag name first.';
832 return;
833 }
834 var fromTag = (prevSel && prevSel.value || '').trim() || null;
835 btn.disabled = true;
836 status.textContent = 'Asking Claude…';
837 try {
838 var res = await fetch(${JSON.stringify(`/api/v2/repos/${owner}/${repo}/releases/notes`)}, {
839 method: 'POST',
840 headers: { 'Content-Type': 'application/json' },
841 credentials: 'same-origin',
842 body: JSON.stringify({ to_tag: toTag, from_tag: fromTag }),
843 });
844 if (!res.ok) {
845 var err = await res.text();
846 status.textContent = 'Failed: ' + (err || res.status);
847 return;
848 }
849 var data = await res.json();
850 if (data && typeof data.markdown === 'string') {
851 bodyArea.value = data.markdown;
852 status.textContent = data.aiUsed
853 ? ('Drafted from ' + (data.prCount || 0) + ' PR(s).')
854 : ('Deterministic summary (' + (data.prCount || 0) + ' PR(s) — set ANTHROPIC_API_KEY for polished output).');
855 } else {
856 status.textContent = 'Empty response.';
857 }
858 } catch (e) {
859 status.textContent = 'Network error.';
860 } finally {
861 btn.disabled = false;
862 }
863 });
864 })();
865 `,
866 }}
867 />
f0b5874Claude868 </div>
869 <style dangerouslySetInnerHTML={{ __html: relStyles }} />
3ef4c9dClaude870 </Layout>
871 );
872});
873
febd4f0Claude874releasesRoute.post("/:owner/:repo/releases", requireAuth, requireRepoAccess("write"), async (c) => {
3ef4c9dClaude875 const user = c.get("user")!;
876 const { owner, repo } = c.req.param();
877 const repoRow = await loadRepo(owner, repo);
878 if (!repoRow) return c.notFound();
879 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/releases`);
880
881 const body = await c.req.parseBody();
882 const tag = String(body.tag || "").trim();
883 const name = String(body.name || "").trim() || tag;
884 const target = String(body.target || repoRow.defaultBranch).trim();
885 const previousTag = String(body.previousTag || "").trim();
886 const notes = String(body.body || "").trim();
887 const isDraft = !!body.isDraft;
888 const isPrerelease = !!body.isPrerelease;
889
890 if (!tag || !/^[A-Za-z0-9._\-]+$/.test(tag)) {
891 return c.redirect(
892 `/${owner}/${repo}/releases/new?error=Invalid+tag+name`
893 );
894 }
895
896 const sha = await resolveRef(owner, repo, target);
897 if (!sha) {
898 return c.redirect(
899 `/${owner}/${repo}/releases/new?error=Could+not+resolve+target`
900 );
901 }
902
903 // Determine previous tag for changelog
904 let autoPrev = previousTag;
905 if (!autoPrev) {
906 const tags = await listTags(owner, repo);
907 autoPrev = tags[0]?.name || "";
908 }
909
910 // Generate changelog body if none provided
911 let finalBody = notes;
912 const [settings] = await db
913 .select()
914 .from(repoSettings)
915 .where(eq(repoSettings.repositoryId, repoRow.id))
916 .limit(1);
917 const aiEnabled = settings ? settings.aiChangelogEnabled : true;
918
919 if (!finalBody && aiEnabled) {
920 const commits = await commitsBetween(owner, repo, autoPrev || null, sha);
921 finalBody = await generateChangelog(`${owner}/${repo}`, autoPrev || null, tag, commits);
922 }
923
924 // Create the git tag (best-effort — if it already exists we reuse)
925 const existing = await resolveRef(owner, repo, `refs/tags/${tag}`);
926 if (!existing) {
927 await createTag(owner, repo, tag, sha, name || tag);
928 }
929
930 // Persist release
931 let releaseId = "";
932 try {
933 const [row] = await db
934 .insert(releases)
935 .values({
936 repositoryId: repoRow.id,
937 authorId: user.id,
938 tag,
939 name,
940 body: finalBody,
941 targetCommit: sha,
942 isDraft,
943 isPrerelease,
944 publishedAt: isDraft ? null : new Date(),
945 })
946 .returning();
947 releaseId = row?.id || "";
948 } catch (err) {
949 console.error("[releases] insert failed:", err);
950 return c.redirect(
951 `/${owner}/${repo}/releases/new?error=Tag+already+published`
952 );
953 }
954
955 // Notify starrers (only on publish)
956 if (!isDraft) {
957 try {
958 const starUsers = await db
959 .select({ userId: stars.userId })
960 .from(stars)
961 .where(eq(stars.repositoryId, repoRow.id));
962 await notifyMany(
963 starUsers.map((s) => s.userId).filter((id) => id !== user.id),
964 {
965 kind: "release_published",
966 title: `${owner}/${repo} ${tag} released`,
967 body: name,
968 url: `/${owner}/${repo}/releases/${encodeURIComponent(tag)}`,
969 repositoryId: repoRow.id,
970 }
971 );
972 } catch {
973 /* ignore */
974 }
975 }
976
977 await audit({
978 userId: user.id,
979 repositoryId: repoRow.id,
980 action: "release.publish",
981 targetType: "release",
982 targetId: releaseId,
983 metadata: { tag, target, isDraft, isPrerelease },
984 });
985
986 return c.redirect(`/${owner}/${repo}/releases/${encodeURIComponent(tag)}`);
987});
988
febd4f0Claude989releasesRoute.get("/:owner/:repo/releases/:tag", requireRepoAccess("read"), async (c) => {
3ef4c9dClaude990 const user = c.get("user");
991 const { owner, repo } = c.req.param();
992 const tag = decodeURIComponent(c.req.param("tag"));
993 const repoRow = await loadRepo(owner, repo);
994 if (!repoRow) return c.notFound();
995
996 const [release] = await db
997 .select({
998 id: releases.id,
999 tag: releases.tag,
1000 name: releases.name,
1001 body: releases.body,
1002 targetCommit: releases.targetCommit,
1003 isDraft: releases.isDraft,
1004 isPrerelease: releases.isPrerelease,
1005 createdAt: releases.createdAt,
1006 publishedAt: releases.publishedAt,
1007 authorName: users.username,
1008 })
1009 .from(releases)
1010 .innerJoin(users, eq(releases.authorId, users.id))
1011 .where(
1012 and(eq(releases.repositoryId, repoRow.id), eq(releases.tag, tag))
1013 )
1014 .limit(1);
1015 if (!release) return c.notFound();
1016
1017 const unread = user ? await getUnreadCount(user.id) : 0;
1018
1019 return c.html(
1020 <Layout
1021 title={`${release.name} — ${owner}/${repo}`}
1022 user={user}
1023 notificationCount={unread}
1024 >
1025 <RepoHeader
1026 owner={owner}
1027 repo={repo}
1028 starCount={repoRow.starCount}
1029 forkCount={repoRow.forkCount}
1030 currentUser={user?.username || null}
1031 />
1032 <RepoNav owner={owner} repo={repo} active="releases" />
f0b5874Claude1033 <div class="rel-wrap">
1034 <div class="rel-crumbs">
1035 <a href={`/${owner}/${repo}/releases`}>
1036 <IconArrowLeft />
1037 All releases
3ef4c9dClaude1038 </a>
1039 </div>
f0b5874Claude1040 <header class="rel-head">
1041 <div class="rel-head-text">
1042 <div class="rel-eyebrow">
1043 <span class="rel-eyebrow-dot" aria-hidden="true" />
1044 Releases · {release.tag}
1045 </div>
1046 <h1 class="rel-title">
1047 <span class="rel-title-grad">{release.name}</span>
1048 </h1>
1049 <p class="rel-sub">
1050 Released by @{release.authorName} on {shortDate(release.publishedAt || release.createdAt)}.
1051 </p>
1052 </div>
1053 </header>
1054
1055 <article class="rel-detail">
1056 <div class="rel-card-head">
1057 <div class="rel-card-titlewrap">
1058 <span class="rel-tag-pill">
1059 <IconTag />
1060 {release.tag}
1061 </span>
1062 {release.isDraft && (
1063 <span class="rel-pill is-draft">
1064 <span class="dot" aria-hidden="true" />
1065 Draft
1066 </span>
1067 )}
1068 {release.isPrerelease && (
1069 <span class="rel-pill is-pre">
1070 <span class="dot" aria-hidden="true" />
1071 Pre-release
1072 </span>
1073 )}
1074 </div>
1075 </div>
1076 <div class="rel-meta-row">
1077 <span>@{release.authorName}</span>
1078 <span class="sep">·</span>
1079 <span>released {shortDate(release.publishedAt || release.createdAt)}</span>
1080 <span class="sep">·</span>
1081 <a href={`/${owner}/${repo}/commit/${release.targetCommit}`}>
1082 <span class="mono">{release.targetCommit.slice(0, 7)}</span>
1083 </a>
1084 <span class="sep">·</span>
1085 <a href={`/${owner}/${repo}/archive/${encodeURIComponent(release.tag)}.zip`}>
1086 <IconDownload /> Download
1087 </a>
1088 </div>
1089 {release.body && (
1090 <div
1091 class="markdown-body"
1092 style="border-top: 1px dashed var(--border); padding-top: 14px; margin-top: 4px"
1093 dangerouslySetInnerHTML={{
1094 __html: renderMarkdown(release.body),
1095 }}
1096 ></div>
1097 )}
1098 </article>
3ef4c9dClaude1099 </div>
f0b5874Claude1100 <style dangerouslySetInnerHTML={{ __html: relStyles }} />
3ef4c9dClaude1101 </Layout>
1102 );
1103});
1104
1105releasesRoute.post(
1106 "/:owner/:repo/releases/:tag/delete",
1107 requireAuth,
febd4f0Claude1108 requireRepoAccess("write"),
3ef4c9dClaude1109 async (c) => {
1110 const user = c.get("user")!;
1111 const { owner, repo } = c.req.param();
1112 const tag = decodeURIComponent(c.req.param("tag"));
1113 const repoRow = await loadRepo(owner, repo);
1114 if (!repoRow) return c.notFound();
1115 if (repoRow.ownerId !== user.id) {
1116 return c.redirect(`/${owner}/${repo}/releases`);
1117 }
1118
1119 await db
1120 .delete(releases)
1121 .where(
1122 and(eq(releases.repositoryId, repoRow.id), eq(releases.tag, tag))
1123 );
1124 await deleteTag(owner, repo, tag);
1125 await audit({
1126 userId: user.id,
1127 repositoryId: repoRow.id,
1128 action: "release.delete",
1129 targetType: "release",
1130 metadata: { tag },
1131 });
1132 return c.redirect(`/${owner}/${repo}/releases`);
1133 }
1134);
1135
1136export default releasesRoute;