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

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

web.tsxBlame1317 lines · 3 contributors
fc1817aClaude1/**
2 * Web UI routes — browse repositories, code, commits, diffs.
06d5ffeClaude3 * Now auth-aware with user profiles, repo creation, stars, and syntax highlighting.
fc1817aClaude4 */
5
6import { Hono } from "hono";
79136bbClaude7import { html } from "hono/html";
8e9f1d9Claude8import { eq, and, desc, inArray, sql } from "drizzle-orm";
06d5ffeClaude9import { db } from "../db";
ea52715copilot-swe-agent[bot]10import { config } from "../lib/config";
3951454Claude11import {
12 users,
13 repositories,
14 stars,
15 commitVerifications,
16} from "../db/schema";
fc1817aClaude17import { Layout } from "../views/layout";
18import {
19 RepoHeader,
20 RepoNav,
21 Breadcrumb,
22 FileTable,
23 CommitList,
24 DiffView,
06d5ffeClaude25 RepoCard,
26 BranchSwitcher,
27 HighlightedCode,
28 PlainCode,
fc1817aClaude29} from "../views/components";
30import {
31 getTree,
32 getBlob,
33 listCommits,
34 getCommit,
35 getCommitFullMessage,
36 getDiff,
37 getReadme,
38 getDefaultBranch,
39 listBranches,
40 repoExists,
06d5ffeClaude41 initBareRepo,
79136bbClaude42 getBlame,
43 getRawBlob,
44 searchCode,
fc1817aClaude45} from "../git/repository";
79136bbClaude46import { renderMarkdown, markdownCss } from "../lib/markdown";
06d5ffeClaude47import { highlightCode } from "../lib/highlight";
48import { softAuth, requireAuth } from "../middleware/auth";
49import type { AuthEnv } from "../middleware/auth";
8f50ed0Claude50import { trackByName } from "../lib/traffic";
534f04aClaude51import { LandingPage, type LandingLiveFeed } from "../views/landing";
52ad8b1Claude52import { computePublicStats, type PublicStats } from "../lib/public-stats";
534f04aClaude53import {
54 listQueuedAiBuildIssues,
55 listRecentAutoMerges,
56 listRecentAiReviews,
57 countAiReviewsSince,
58 listDemoActivityFeed,
59} from "../lib/demo-activity";
fc1817aClaude60
06d5ffeClaude61const web = new Hono<AuthEnv>();
62
63// Soft auth on all web routes — c.get("user") available but may be null
64web.use("*", softAuth);
fc1817aClaude65
66// Home page
06d5ffeClaude67web.get("/", async (c) => {
68 const user = c.get("user");
69
70 if (user) {
0316dbbClaude71 return c.redirect("/dashboard");
06d5ffeClaude72 }
73
8e9f1d9Claude74 let stats: { publicRepos?: number; users?: number } | undefined;
52ad8b1Claude75 let publicStats: PublicStats | null = null;
8e9f1d9Claude76 try {
77 const [repoRow] = await db
78 .select({ n: sql<number>`count(*)::int` })
79 .from(repositories)
80 .where(eq(repositories.isPrivate, false));
81 const [userRow] = await db
82 .select({ n: sql<number>`count(*)::int` })
83 .from(users);
84 stats = {
85 publicRepos: Number(repoRow?.n ?? 0),
86 users: Number(userRow?.n ?? 0),
87 };
88 } catch {
89 stats = undefined;
90 }
91
52ad8b1Claude92 // Block L4 — public stats counters (5-min in-memory cache; never throws).
93 try {
94 publicStats = await computePublicStats();
95 } catch {
96 publicStats = null;
97 }
98
534f04aClaude99 // Block M1 — initial SSR snapshot for the live-now demo feed.
100 // The helpers in lib/demo-activity.ts never throw, but we still wrap
101 // in try/catch so a freak module-level explosion can't take down /.
102 let liveFeed: LandingLiveFeed | null = null;
103 try {
104 const [queued, merges, reviewList, reviewCount, feed] = await Promise.all([
105 listQueuedAiBuildIssues(3),
106 listRecentAutoMerges(3, 24),
107 listRecentAiReviews(3, 24),
108 countAiReviewsSince(24),
109 listDemoActivityFeed(10),
110 ]);
111 liveFeed = {
112 queued: queued.map((i) => ({
113 repo: i.repo,
114 number: i.number,
115 title: i.title,
116 createdAt: i.createdAt,
117 })),
118 merges: merges.map((m) => ({
119 repo: m.repo,
120 number: m.number,
121 title: m.title,
122 mergedAt: m.mergedAt,
123 })),
124 reviews: reviewList.map((r) => ({
125 repo: r.repo,
126 prNumber: r.prNumber,
127 commentSnippet: r.commentSnippet,
128 createdAt: r.createdAt,
129 })),
130 reviewCount,
131 feed: feed.map((e) => ({
132 kind: e.kind,
133 repo: e.repo,
134 ref: e.ref,
135 at: e.at,
136 })),
137 };
138 } catch {
139 liveFeed = null;
140 }
141
fc1817aClaude142 return c.html(
5f2e749Claude143 <Layout
144 user={null}
145 // Block L10 — SEO + Open Graph for the public landing.
146 fullTitle="Gluecron — The git host built around Claude"
147 description="Label an issue. Walk away. Wake up to a merged PR. Gluecron is the AI-native git host with built-in code review, auto-merge, and a Claude-first toolchain."
148 ogTitle="Gluecron — The git host built around Claude"
149 ogDescription="Label an issue. Walk away. Wake up to a merged PR. Gluecron is the AI-native git host with built-in code review, auto-merge, and a Claude-first toolchain."
150 ogType="website"
151 twitterCard="summary_large_image"
152 >
534f04aClaude153 <LandingPage stats={stats} publicStats={publicStats} liveFeed={liveFeed} />
fc1817aClaude154 </Layout>
155 );
156});
157
06d5ffeClaude158// New repository form
159web.get("/new", requireAuth, (c) => {
160 const user = c.get("user")!;
161 const error = c.req.query("error");
162
163 return c.html(
164 <Layout title="New repository" user={user}>
165 <div class="new-repo-form">
166 <h2>Create a new repository</h2>
167 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
001af43Claude168 <form method="post" action="/new">
06d5ffeClaude169 <div class="form-group">
170 <label>Owner</label>
2c3ba6ecopilot-swe-agent[bot]171 <input type="text" value={user.username} disabled aria-label="Owner" class="input-disabled" />
06d5ffeClaude172 </div>
173 <div class="form-group">
174 <label for="name">Repository name</label>
175 <input
176 type="text"
177 id="name"
178 name="name"
179 required
180 pattern="^[a-zA-Z0-9._-]+$"
181 placeholder="my-project"
182 autocomplete="off"
183 />
184 </div>
185 <div class="form-group">
186 <label for="description">Description (optional)</label>
187 <input
188 type="text"
189 id="description"
190 name="description"
191 placeholder="A short description of your repository"
192 />
193 </div>
194 <div class="visibility-options">
195 <label class="visibility-option">
196 <input type="radio" name="visibility" value="public" checked />
197 <div class="vis-label">Public</div>
198 <div class="vis-desc">Anyone can see this repository</div>
199 </label>
200 <label class="visibility-option">
201 <input type="radio" name="visibility" value="private" />
202 <div class="vis-label">Private</div>
203 <div class="vis-desc">Only you can see this repository</div>
204 </label>
205 </div>
206 <button type="submit" class="btn btn-primary">
207 Create repository
208 </button>
209 </form>
210 </div>
211 </Layout>
212 );
213});
214
215web.post("/new", requireAuth, async (c) => {
216 const user = c.get("user")!;
217 const body = await c.req.parseBody();
218 const name = String(body.name || "").trim();
219 const description = String(body.description || "").trim();
220 const isPrivate = body.visibility === "private";
221
222 if (!name) {
223 return c.redirect("/new?error=Repository+name+is+required");
224 }
225
c63b860Claude226 // P4 — plan-quota gate. Fail-open inside the helper so a billing
227 // outage never blocks repo creation.
228 const { checkRepoCreateAllowed } = await import("../lib/repo-create-gate");
229 const gate = await checkRepoCreateAllowed(user.id);
230 if (!gate.ok) {
231 return c.redirect(`/new?error=${encodeURIComponent(gate.reason)}`);
232 }
233
06d5ffeClaude234 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
235 return c.redirect("/new?error=Invalid+repository+name");
236 }
237
238 if (await repoExists(user.username, name)) {
239 return c.redirect("/new?error=Repository+already+exists");
240 }
241
242 const diskPath = await initBareRepo(user.username, name);
243
3ef4c9dClaude244 const [newRepo] = await db
245 .insert(repositories)
246 .values({
247 name,
248 ownerId: user.id,
249 description: description || null,
250 isPrivate,
251 diskPath,
252 })
253 .returning();
254
255 if (newRepo) {
256 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
257 await bootstrapRepository({
258 repositoryId: newRepo.id,
259 ownerUserId: user.id,
260 defaultBranch: "main",
261 });
262 }
06d5ffeClaude263
264 return c.redirect(`/${user.username}/${name}`);
265});
266
267// User profile
fc1817aClaude268web.get("/:owner", async (c) => {
06d5ffeClaude269 const { owner: ownerName } = c.req.param();
270 const user = c.get("user");
271
272 // Avoid clashing with fixed routes
273 if (
274 ["login", "register", "logout", "new", "settings", "api"].includes(
275 ownerName
276 )
277 ) {
278 return c.notFound();
279 }
280
281 let ownerUser;
282 try {
283 const [found] = await db
284 .select()
285 .from(users)
286 .where(eq(users.username, ownerName))
287 .limit(1);
288 ownerUser = found;
289 } catch {
290 // DB not available — check if repos exist on disk
291 ownerUser = null;
292 }
293
294 // Even without DB, show repos if they exist on disk
295 let repos: any[] = [];
296 if (ownerUser) {
297 const allRepos = await db
298 .select()
299 .from(repositories)
300 .where(eq(repositories.ownerId, ownerUser.id))
301 .orderBy(desc(repositories.updatedAt));
302
303 // Show public repos to everyone, private only to owner
304 repos =
305 user?.id === ownerUser.id
306 ? allRepos
307 : allRepos.filter((r) => !r.isPrivate);
308 }
309
7aa8b99Claude310 // Block J4 — follow counts + viewer's follow state
311 let followState = {
312 followers: 0,
313 following: 0,
314 viewerFollows: false,
315 };
316 if (ownerUser) {
317 try {
318 const { followCounts, isFollowing } = await import("../lib/follows");
319 const counts = await followCounts(ownerUser.id);
320 followState.followers = counts.followers;
321 followState.following = counts.following;
322 if (user && user.id !== ownerUser.id) {
323 followState.viewerFollows = await isFollowing(user.id, ownerUser.id);
324 }
325 } catch {
326 // DB hiccup — fall back to zeros.
327 }
328 }
329 const canFollow = !!user && !!ownerUser && user.id !== ownerUser.id;
330
d412586Claude331 // Block J5 — profile README. Render owner/owner repo's README on the
332 // profile page (GitHub convention). Tries "<user>/<user>" first, falling
333 // back to "<user>/.github" for org-style profile repos.
334 let profileReadmeHtml: string | null = null;
335 try {
336 const candidates = [ownerName, ".github"];
337 for (const rname of candidates) {
338 if (await repoExists(ownerName, rname)) {
339 const ref = (await getDefaultBranch(ownerName, rname)) || "main";
340 const md = await getReadme(ownerName, rname, ref);
341 if (md) {
342 profileReadmeHtml = renderMarkdown(md);
343 break;
344 }
345 }
346 }
347 } catch {
348 profileReadmeHtml = null;
349 }
350
fc1817aClaude351 return c.html(
06d5ffeClaude352 <Layout title={ownerName} user={user}>
353 <div class="user-profile">
354 <div class="user-avatar">
355 {(ownerUser?.displayName || ownerName)[0].toUpperCase()}
356 </div>
357 <div class="user-info">
358 <h2>{ownerUser?.displayName || ownerName}</h2>
359 <div class="username">@{ownerName}</div>
360 {ownerUser?.bio && <div class="bio">{ownerUser.bio}</div>}
7aa8b99Claude361 <div
dc26881CC LABS App362 style="margin-top:var(--space-2);display:flex;gap:var(--space-3);align-items:center;flex-wrap:wrap;font-size:13px"
7aa8b99Claude363 >
364 <a
365 href={`/${ownerName}/followers`}
366 style="color:var(--text-muted)"
367 >
368 <strong style="color:var(--text)">
369 {followState.followers}
370 </strong>{" "}
371 follower{followState.followers === 1 ? "" : "s"}
372 </a>
373 <a
374 href={`/${ownerName}/following`}
375 style="color:var(--text-muted)"
376 >
377 <strong style="color:var(--text)">
378 {followState.following}
379 </strong>{" "}
380 following
381 </a>
382 {canFollow && (
383 <form
001af43Claude384 method="post"
7aa8b99Claude385 action={`/${ownerName}/${
386 followState.viewerFollows ? "unfollow" : "follow"
387 }`}
388 >
389 <button
390 type="submit"
391 class={`btn ${
392 followState.viewerFollows ? "" : "btn-primary"
393 } btn-sm`}
394 >
395 {followState.viewerFollows ? "Unfollow" : "Follow"}
396 </button>
397 </form>
398 )}
399 </div>
06d5ffeClaude400 </div>
401 </div>
d412586Claude402 {profileReadmeHtml && (
403 <div
404 class="markdown-body"
dc26881CC LABS App405 style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:var(--space-5) var(--space-6);margin-bottom:var(--space-6)"
d412586Claude406 dangerouslySetInnerHTML={{ __html: profileReadmeHtml }}
407 />
408 )}
06d5ffeClaude409 <h3 style="margin-bottom: 16px">Repositories</h3>
410 {repos.length === 0 ? (
411 <p style="color: var(--text-muted)">No repositories yet.</p>
412 ) : (
413 <div class="card-grid">
414 {repos.map((repo) => (
415 <RepoCard repo={repo} ownerName={ownerName} />
416 ))}
417 </div>
418 )}
fc1817aClaude419 </Layout>
420 );
421});
422
06d5ffeClaude423// Star/unstar a repo
424web.post("/:owner/:repo/star", requireAuth, async (c) => {
425 const { owner: ownerName, repo: repoName } = c.req.param();
426 const user = c.get("user")!;
427
428 try {
429 const [ownerUser] = await db
430 .select()
431 .from(users)
432 .where(eq(users.username, ownerName))
433 .limit(1);
434 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
435
436 const [repo] = await db
437 .select()
438 .from(repositories)
439 .where(
440 and(
441 eq(repositories.ownerId, ownerUser.id),
442 eq(repositories.name, repoName)
443 )
444 )
445 .limit(1);
446 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
447
448 // Toggle star
449 const [existing] = await db
450 .select()
451 .from(stars)
452 .where(
453 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
454 )
455 .limit(1);
456
457 if (existing) {
458 await db.delete(stars).where(eq(stars.id, existing.id));
459 await db
460 .update(repositories)
461 .set({ starCount: Math.max(0, repo.starCount - 1) })
462 .where(eq(repositories.id, repo.id));
463 } else {
464 await db.insert(stars).values({
465 userId: user.id,
466 repositoryId: repo.id,
467 });
468 await db
469 .update(repositories)
470 .set({ starCount: repo.starCount + 1 })
471 .where(eq(repositories.id, repo.id));
472 }
473 } catch {
474 // DB error — ignore
475 }
476
477 return c.redirect(`/${ownerName}/${repoName}`);
478});
479
fc1817aClaude480// Repository overview — file tree at HEAD
481web.get("/:owner/:repo", async (c) => {
482 const { owner, repo } = c.req.param();
06d5ffeClaude483 const user = c.get("user");
fc1817aClaude484
8f50ed0Claude485 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
486 trackByName(owner, repo, "view", {
487 userId: user?.id || null,
488 path: `/${owner}/${repo}`,
489 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
490 userAgent: c.req.header("user-agent") || null,
491 referer: c.req.header("referer") || null,
492 }).catch(() => {});
493
fc1817aClaude494 if (!(await repoExists(owner, repo))) {
495 return c.html(
06d5ffeClaude496 <Layout title="Not Found" user={user}>
fc1817aClaude497 <div class="empty-state">
498 <h2>Repository not found</h2>
499 <p>
500 {owner}/{repo} does not exist.
501 </p>
502 </div>
503 </Layout>,
504 404
505 );
506 }
507
05b973eClaude508 // Parallelize all independent operations
509 const [defaultBranch, branches] = await Promise.all([
510 getDefaultBranch(owner, repo).then((b) => b || "main"),
511 listBranches(owner, repo),
512 ]);
513 const [tree, starInfo] = await Promise.all([
514 getTree(owner, repo, defaultBranch),
515 // Star info fetched in parallel with tree
516 (async () => {
517 try {
518 const [ownerUser] = await db
519 .select()
520 .from(users)
521 .where(eq(users.username, owner))
522 .limit(1);
71cd5ecClaude523 if (!ownerUser)
524 return {
525 starCount: 0,
526 starred: false,
527 archived: false,
528 isTemplate: false,
529 };
05b973eClaude530 const [repoRow] = await db
531 .select()
532 .from(repositories)
533 .where(
534 and(
535 eq(repositories.ownerId, ownerUser.id),
536 eq(repositories.name, repo)
537 )
06d5ffeClaude538 )
05b973eClaude539 .limit(1);
71cd5ecClaude540 if (!repoRow)
541 return {
542 starCount: 0,
543 starred: false,
544 archived: false,
545 isTemplate: false,
546 };
05b973eClaude547 let starred = false;
06d5ffeClaude548 if (user) {
549 const [star] = await db
550 .select()
551 .from(stars)
552 .where(
553 and(
554 eq(stars.userId, user.id),
555 eq(stars.repositoryId, repoRow.id)
556 )
557 )
558 .limit(1);
559 starred = !!star;
560 }
71cd5ecClaude561 return {
562 starCount: repoRow.starCount,
563 starred,
564 archived: repoRow.isArchived,
565 isTemplate: repoRow.isTemplate,
566 };
05b973eClaude567 } catch {
71cd5ecClaude568 return {
569 starCount: 0,
570 starred: false,
571 archived: false,
572 isTemplate: false,
573 };
06d5ffeClaude574 }
05b973eClaude575 })(),
576 ]);
71cd5ecClaude577 const { starCount, starred, archived, isTemplate } = starInfo;
06d5ffeClaude578
fc1817aClaude579 if (tree.length === 0) {
580 return c.html(
06d5ffeClaude581 <Layout title={`${owner}/${repo}`} user={user}>
582 <RepoHeader
583 owner={owner}
584 repo={repo}
585 starCount={starCount}
586 starred={starred}
587 currentUser={user?.username}
71cd5ecClaude588 archived={archived}
589 isTemplate={isTemplate}
06d5ffeClaude590 />
fc1817aClaude591 <RepoNav owner={owner} repo={repo} active="code" />
592 <div class="empty-state">
593 <h2>Empty repository</h2>
594 <p>Get started by pushing code:</p>
ea52715copilot-swe-agent[bot]595 <pre>{`git remote add gluecron ${config.appBaseUrl}/${owner}/${repo}.git
fc1817aClaude596git push -u gluecron main`}</pre>
597 </div>
598 </Layout>
599 );
600 }
601
602 const readme = await getReadme(owner, repo, defaultBranch);
603
604 return c.html(
06d5ffeClaude605 <Layout title={`${owner}/${repo}`} user={user}>
606 <RepoHeader
607 owner={owner}
608 repo={repo}
609 starCount={starCount}
610 starred={starred}
611 currentUser={user?.username}
71cd5ecClaude612 archived={archived}
613 isTemplate={isTemplate}
06d5ffeClaude614 />
71cd5ecClaude615 {isTemplate && user && user.username !== owner && (
616 <div
617 class="panel"
dc26881CC LABS App618 style="margin-bottom:var(--space-4);padding:var(--space-3);display:flex;align-items:center;justify-content:space-between;gap:var(--space-3)"
71cd5ecClaude619 >
620 <div style="font-size:13px">
621 <strong>Template repository.</strong> Create a new repository from
622 this template's files.
623 </div>
624 <form
001af43Claude625 method="post"
71cd5ecClaude626 action={`/${owner}/${repo}/use-template`}
dc26881CC LABS App627 style="display:flex;gap:var(--space-2);align-items:center"
71cd5ecClaude628 >
629 <input
630 type="text"
631 name="name"
632 placeholder="new-repo-name"
633 required
2c3ba6ecopilot-swe-agent[bot]634 aria-label="New repository name"
71cd5ecClaude635 style="width:200px"
636 />
637 <button type="submit" class="btn btn-primary">
638 Use this template
639 </button>
640 </form>
641 </div>
642 )}
fc1817aClaude643 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude644 <BranchSwitcher
645 owner={owner}
646 repo={repo}
647 currentRef={defaultBranch}
648 branches={branches}
649 pathType="tree"
650 />
fc1817aClaude651 <FileTable
652 entries={tree}
653 owner={owner}
654 repo={repo}
655 ref={defaultBranch}
656 path=""
657 />
79136bbClaude658 {readme && (() => {
659 const readmeHtml = renderMarkdown(readme);
660 return (
661 <div class="blob-view" style="margin-top: 20px">
662 <div class="blob-header">README.md</div>
663 <style>{markdownCss}</style>
664 <div class="markdown-body">
665 {html([readmeHtml] as unknown as TemplateStringsArray)}
666 </div>
fc1817aClaude667 </div>
79136bbClaude668 );
669 })()}
fc1817aClaude670 </Layout>
671 );
672});
673
674// Browse tree at ref/path
675web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
676 const { owner, repo } = c.req.param();
06d5ffeClaude677 const user = c.get("user");
fc1817aClaude678 const refAndPath = c.req.param("ref");
679
680 const branches = await listBranches(owner, repo);
681 let ref = "";
682 let treePath = "";
683
684 for (const branch of branches) {
685 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
686 ref = branch;
687 treePath = refAndPath.slice(branch.length + 1);
688 break;
689 }
690 }
691
692 if (!ref) {
693 const slashIdx = refAndPath.indexOf("/");
694 if (slashIdx === -1) {
695 ref = refAndPath;
696 } else {
697 ref = refAndPath.slice(0, slashIdx);
698 treePath = refAndPath.slice(slashIdx + 1);
699 }
700 }
701
702 const tree = await getTree(owner, repo, ref, treePath);
703
704 return c.html(
06d5ffeClaude705 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
fc1817aClaude706 <RepoHeader owner={owner} repo={repo} />
707 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude708 <BranchSwitcher
709 owner={owner}
710 repo={repo}
711 currentRef={ref}
712 branches={branches}
713 pathType="tree"
714 subPath={treePath}
715 />
fc1817aClaude716 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
717 <FileTable
718 entries={tree}
719 owner={owner}
720 repo={repo}
721 ref={ref}
722 path={treePath}
723 />
724 </Layout>
725 );
726});
727
06d5ffeClaude728// View file blob with syntax highlighting
fc1817aClaude729web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
730 const { owner, repo } = c.req.param();
06d5ffeClaude731 const user = c.get("user");
fc1817aClaude732 const refAndPath = c.req.param("ref");
733
734 const branches = await listBranches(owner, repo);
735 let ref = "";
736 let filePath = "";
737
738 for (const branch of branches) {
739 if (refAndPath.startsWith(branch + "/")) {
740 ref = branch;
741 filePath = refAndPath.slice(branch.length + 1);
742 break;
743 }
744 }
745
746 if (!ref) {
747 const slashIdx = refAndPath.indexOf("/");
748 if (slashIdx === -1) return c.text("Not found", 404);
749 ref = refAndPath.slice(0, slashIdx);
750 filePath = refAndPath.slice(slashIdx + 1);
751 }
752
753 const blob = await getBlob(owner, repo, ref, filePath);
754 if (!blob) {
755 return c.html(
06d5ffeClaude756 <Layout title="Not Found" user={user}>
fc1817aClaude757 <div class="empty-state">
758 <h2>File not found</h2>
759 </div>
760 </Layout>,
761 404
762 );
763 }
764
06d5ffeClaude765 const fileName = filePath.split("/").pop() || filePath;
fc1817aClaude766
767 return c.html(
06d5ffeClaude768 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
fc1817aClaude769 <RepoHeader owner={owner} repo={repo} />
770 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude771 <BranchSwitcher
772 owner={owner}
773 repo={repo}
774 currentRef={ref}
775 branches={branches}
776 pathType="blob"
777 subPath={filePath}
778 />
fc1817aClaude779 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
780 <div class="blob-view">
781 <div class="blob-header">
06d5ffeClaude782 <span>{fileName} — {blob.size} bytes</span>
dc26881CC LABS App783 <span style="display: flex; gap: var(--space-3)">
79136bbClaude784 <a href={`/${owner}/${repo}/raw/${ref}/${filePath}`} style="font-size: 12px">
785 Raw
786 </a>
787 <a href={`/${owner}/${repo}/blame/${ref}/${filePath}`} style="font-size: 12px">
788 Blame
789 </a>
16b325cClaude790 <a href={`/${owner}/${repo}/timeline/${ref}/${filePath}`} style="font-size: 12px">
791 History
792 </a>
0074234Claude793 {user && (
794 <a href={`/${owner}/${repo}/edit/${ref}/${filePath}`} style="font-size: 12px">
795 Edit
796 </a>
797 )}
79136bbClaude798 </span>
fc1817aClaude799 </div>
800 {blob.isBinary ? (
dc26881CC LABS App801 <div style="padding: var(--space-4); color: var(--text-muted)">
fc1817aClaude802 Binary file not shown.
803 </div>
06d5ffeClaude804 ) : (() => {
805 const { html: highlighted, language } = highlightCode(
806 blob.content,
807 fileName
808 );
809 const lineCount = blob.content.split("\n").length;
810 // Trim trailing newline from count
811 const adjustedCount =
812 blob.content.endsWith("\n") ? lineCount - 1 : lineCount;
813
814 if (language) {
815 return (
816 <HighlightedCode
817 highlightedHtml={highlighted}
818 lineCount={adjustedCount}
819 />
820 );
821 }
822 const lines = blob.content.split("\n");
823 if (lines[lines.length - 1] === "") lines.pop();
824 return <PlainCode lines={lines} />;
825 })()}
fc1817aClaude826 </div>
827 </Layout>
828 );
829});
830
831// Commit log
832web.get("/:owner/:repo/commits/:ref?", async (c) => {
833 const { owner, repo } = c.req.param();
06d5ffeClaude834 const user = c.get("user");
fc1817aClaude835 const ref =
836 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude837 const branches = await listBranches(owner, repo);
fc1817aClaude838
839 const commits = await listCommits(owner, repo, ref, 50);
840
3951454Claude841 // Block J3 — batch-fetch cached verification results for the page.
842 let verifications: Record<string, { verified: boolean; reason: string }> = {};
843 try {
844 const [ownerRow] = await db
845 .select()
846 .from(users)
847 .where(eq(users.username, owner))
848 .limit(1);
849 if (ownerRow) {
850 const [repoRow] = await db
851 .select()
852 .from(repositories)
853 .where(
854 and(
855 eq(repositories.ownerId, ownerRow.id),
856 eq(repositories.name, repo)
857 )
858 )
859 .limit(1);
860 if (repoRow && commits.length > 0) {
861 const rows = await db
862 .select()
863 .from(commitVerifications)
864 .where(
865 and(
866 eq(commitVerifications.repositoryId, repoRow.id),
867 inArray(
868 commitVerifications.commitSha,
869 commits.map((c) => c.sha)
870 )
871 )
872 );
873 for (const r of rows) {
874 verifications[r.commitSha] = {
875 verified: r.verified,
876 reason: r.reason,
877 };
878 }
879 }
880 }
881 } catch {
882 // DB unavailable — skip the badges gracefully.
883 }
884
fc1817aClaude885 return c.html(
06d5ffeClaude886 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
fc1817aClaude887 <RepoHeader owner={owner} repo={repo} />
888 <RepoNav owner={owner} repo={repo} active="commits" />
06d5ffeClaude889 <BranchSwitcher
890 owner={owner}
891 repo={repo}
892 currentRef={ref}
893 branches={branches}
894 pathType="commits"
895 />
fc1817aClaude896 {commits.length === 0 ? (
897 <div class="empty-state">
898 <p>No commits yet.</p>
899 </div>
900 ) : (
3951454Claude901 <CommitList
902 commits={commits}
903 owner={owner}
904 repo={repo}
905 verifications={verifications}
906 />
fc1817aClaude907 )}
908 </Layout>
909 );
910});
911
912// Single commit with diff
913web.get("/:owner/:repo/commit/:sha", async (c) => {
914 const { owner, repo, sha } = c.req.param();
06d5ffeClaude915 const user = c.get("user");
fc1817aClaude916
05b973eClaude917 // Fetch commit, full message, and diff in parallel
918 const [commit, fullMessage, diffResult] = await Promise.all([
919 getCommit(owner, repo, sha),
920 getCommitFullMessage(owner, repo, sha),
921 getDiff(owner, repo, sha),
922 ]);
fc1817aClaude923 if (!commit) {
924 return c.html(
06d5ffeClaude925 <Layout title="Not Found" user={user}>
fc1817aClaude926 <div class="empty-state">
927 <h2>Commit not found</h2>
928 </div>
929 </Layout>,
930 404
931 );
932 }
933
3951454Claude934 // Block J3 — try to verify this commit's signature.
935 let verification:
936 | { verified: boolean; reason: string; signatureType: string | null }
937 | null = null;
0cdfd89Claude938 // Block J8 — external CI commit statuses rollup.
939 let statusCombined:
940 | {
941 state: "pending" | "success" | "failure";
942 total: number;
943 contexts: Array<{
944 context: string;
945 state: string;
946 description: string | null;
947 targetUrl: string | null;
948 }>;
949 }
950 | null = null;
3951454Claude951 try {
952 const [ownerRow] = await db
953 .select()
954 .from(users)
955 .where(eq(users.username, owner))
956 .limit(1);
957 if (ownerRow) {
958 const [repoRow] = await db
959 .select()
960 .from(repositories)
961 .where(
962 and(
963 eq(repositories.ownerId, ownerRow.id),
964 eq(repositories.name, repo)
965 )
966 )
967 .limit(1);
968 if (repoRow) {
969 const { verifyCommit } = await import("../lib/signatures");
970 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
971 verification = {
972 verified: v.verified,
973 reason: v.reason,
974 signatureType: v.signatureType,
975 };
0cdfd89Claude976 try {
977 const { combinedStatus } = await import("../lib/commit-statuses");
978 const combined = await combinedStatus(repoRow.id, commit.sha);
979 if (combined.total > 0) {
980 statusCombined = {
981 state: combined.state as any,
982 total: combined.total,
983 contexts: combined.contexts.map((c) => ({
984 context: c.context,
985 state: c.state,
986 description: c.description,
987 targetUrl: c.targetUrl,
988 })),
989 };
990 }
991 } catch {
992 statusCombined = null;
993 }
3951454Claude994 }
995 }
996 } catch {
997 verification = null;
998 }
999
05b973eClaude1000 const { files, raw } = diffResult;
fc1817aClaude1001
1002 return c.html(
06d5ffeClaude1003 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
fc1817aClaude1004 <RepoHeader owner={owner} repo={repo} />
1005 <div
dc26881CC LABS App1006 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--space-4); margin-bottom: var(--space-5)"
fc1817aClaude1007 >
1008 <div style="font-size: 18px; font-weight: 600; margin-bottom: 8px">
1009 {commit.message}
1010 </div>
1011 {fullMessage !== commit.message && (
1012 <div style="white-space: pre-wrap; color: var(--text-muted); font-size: 14px; margin-bottom: 12px">
1013 {fullMessage}
1014 </div>
1015 )}
1016 <div style="font-size: 13px; color: var(--text-muted)">
1017 <strong style="color: var(--text)">{commit.author}</strong>{" "}
1018 committed on{" "}
1019 {new Date(commit.date).toLocaleDateString("en-US", {
1020 month: "long",
1021 day: "numeric",
1022 year: "numeric",
1023 })}
3951454Claude1024 {verification && verification.reason !== "unsigned" && (
1025 <span
1026 style={`margin-left:10px;font-size:10px;padding:1px 6px;border-radius:3px;text-transform:uppercase;letter-spacing:.4px;color:#fff;background:${
1027 verification.verified
1028 ? "var(--green,#2ea043)"
1029 : "var(--yellow,#d29922)"
1030 }`}
1031 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
1032 >
1033 {verification.verified ? "Verified" : verification.reason}
1034 </span>
1035 )}
fc1817aClaude1036 </div>
1037 <div style="margin-top: 8px">
1038 <span class="commit-sha">{commit.sha}</span>
1039 {commit.parentShas.length > 0 && (
1040 <span style="margin-left: 12px; font-size: 13px; color: var(--text-muted)">
1041 Parent:{" "}
1042 {commit.parentShas.map((p) => (
1043 <a
1044 href={`/${owner}/${repo}/commit/${p}`}
1045 class="commit-sha"
1046 style="margin-left: 4px"
1047 >
1048 {p.slice(0, 7)}
1049 </a>
1050 ))}
1051 </span>
1052 )}
1053 </div>
0cdfd89Claude1054 {statusCombined && (
1055 <div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); font-size: 13px">
1056 <strong style="color: var(--text)">Checks</strong>
1057 <span style="margin-left: 8px; color: var(--text-muted)">
1058 {statusCombined.total} total —{" "}
1059 <span
1060 style={`color:${
1061 statusCombined.state === "success"
1062 ? "var(--green,#2ea043)"
1063 : statusCombined.state === "failure"
1064 ? "var(--red,#da3633)"
1065 : "var(--yellow,#d29922)"
1066 }`}
1067 >
1068 {statusCombined.state}
1069 </span>
1070 </span>
1071 <div style="margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px">
1072 {statusCombined.contexts.map((cx) => (
1073 <span
1074 style={`font-size:11px;padding:2px 6px;border-radius:3px;color:#fff;background:${
1075 cx.state === "success"
1076 ? "var(--green,#2ea043)"
1077 : cx.state === "pending"
1078 ? "var(--yellow,#d29922)"
1079 : "var(--red,#da3633)"
1080 }`}
1081 title={cx.description || cx.context}
1082 >
1083 {cx.targetUrl ? (
1084 <a
1085 href={cx.targetUrl}
1086 style="color: inherit; text-decoration: none"
1087 rel="noopener"
1088 >
1089 {cx.context}: {cx.state}
1090 </a>
1091 ) : (
1092 <>
1093 {cx.context}: {cx.state}
1094 </>
1095 )}
1096 </span>
1097 ))}
1098 </div>
1099 </div>
1100 )}
fc1817aClaude1101 </div>
1102 <DiffView raw={raw} files={files} />
1103 </Layout>
1104 );
1105});
1106
79136bbClaude1107// Raw file download
1108web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
1109 const { owner, repo } = c.req.param();
1110 const refAndPath = c.req.param("ref");
1111
1112 const branches = await listBranches(owner, repo);
1113 let ref = "";
1114 let filePath = "";
1115
1116 for (const branch of branches) {
1117 if (refAndPath.startsWith(branch + "/")) {
1118 ref = branch;
1119 filePath = refAndPath.slice(branch.length + 1);
1120 break;
1121 }
1122 }
1123
1124 if (!ref) {
1125 const slashIdx = refAndPath.indexOf("/");
1126 if (slashIdx === -1) return c.text("Not found", 404);
1127 ref = refAndPath.slice(0, slashIdx);
1128 filePath = refAndPath.slice(slashIdx + 1);
1129 }
1130
1131 const data = await getRawBlob(owner, repo, ref, filePath);
1132 if (!data) return c.text("Not found", 404);
1133
1134 const fileName = filePath.split("/").pop() || "file";
772a24fClaude1135 return new Response(data as BodyInit, {
79136bbClaude1136 headers: {
1137 "Content-Type": "application/octet-stream",
1138 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude1139 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude1140 },
1141 });
1142});
1143
1144// Blame view
1145web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
1146 const { owner, repo } = c.req.param();
1147 const user = c.get("user");
1148 const refAndPath = c.req.param("ref");
1149
1150 const branches = await listBranches(owner, repo);
1151 let ref = "";
1152 let filePath = "";
1153
1154 for (const branch of branches) {
1155 if (refAndPath.startsWith(branch + "/")) {
1156 ref = branch;
1157 filePath = refAndPath.slice(branch.length + 1);
1158 break;
1159 }
1160 }
1161
1162 if (!ref) {
1163 const slashIdx = refAndPath.indexOf("/");
1164 if (slashIdx === -1) return c.text("Not found", 404);
1165 ref = refAndPath.slice(0, slashIdx);
1166 filePath = refAndPath.slice(slashIdx + 1);
1167 }
1168
1169 const blameLines = await getBlame(owner, repo, ref, filePath);
1170 if (blameLines.length === 0) {
1171 return c.html(
1172 <Layout title="Not Found" user={user}>
1173 <div class="empty-state">
1174 <h2>File not found</h2>
1175 </div>
1176 </Layout>,
1177 404
1178 );
1179 }
1180
1181 const fileName = filePath.split("/").pop() || filePath;
1182
1183 return c.html(
1184 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
1185 <RepoHeader owner={owner} repo={repo} />
1186 <RepoNav owner={owner} repo={repo} active="code" />
1187 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
1188 <div class="blob-view">
1189 <div class="blob-header">
1190 <span>{fileName} — blame</span>
1191 <a href={`/${owner}/${repo}/blob/${ref}/${filePath}`} style="font-size: 12px">
1192 Normal view
1193 </a>
1194 </div>
1195 <div class="blob-code" style="overflow-x: auto">
1196 <table style="width: 100%; border-collapse: collapse; font-size: 13px; font-family: var(--font-mono)">
1197 <tbody>
1198 {blameLines.map((line, i) => {
1199 const showInfo =
1200 i === 0 || blameLines[i - 1].sha !== line.sha;
1201 return (
1202 <tr style="border-bottom: 1px solid var(--border)">
1203 <td
1204 style={`width: 200px; padding: 0 8px; font-size: 11px; color: var(--text-muted); white-space: nowrap; vertical-align: top; ${showInfo ? "border-top: 1px solid var(--border)" : ""}`}
1205 >
1206 {showInfo && (
1207 <>
1208 <a
1209 href={`/${owner}/${repo}/commit/${line.sha}`}
1210 style="color: var(--text-link); font-family: var(--font-mono)"
1211 >
1212 {line.sha.slice(0, 7)}
1213 </a>{" "}
1214 <span>{line.author}</span>
1215 </>
1216 )}
1217 </td>
1218 <td class="line-num">{line.lineNum}</td>
1219 <td class="line-content">{line.content}</td>
1220 </tr>
1221 );
1222 })}
1223 </tbody>
1224 </table>
1225 </div>
1226 </div>
1227 </Layout>
1228 );
1229});
1230
1231// Search
1232web.get("/:owner/:repo/search", async (c) => {
1233 const { owner, repo } = c.req.param();
1234 const user = c.get("user");
1235 const q = c.req.query("q") || "";
1236
1237 if (!(await repoExists(owner, repo))) return c.notFound();
1238
1239 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
1240 let results: Array<{ file: string; lineNum: number; line: string }> = [];
1241
1242 if (q.trim()) {
1243 results = await searchCode(owner, repo, defaultBranch, q.trim());
1244 }
1245
1246 return c.html(
1247 <Layout title={`Search — ${owner}/${repo}`} user={user}>
1248 <RepoHeader owner={owner} repo={repo} />
1249 <RepoNav owner={owner} repo={repo} active="code" />
1250 <form
001af43Claude1251 method="get"
79136bbClaude1252 action={`/${owner}/${repo}/search`}
1253 style="margin-bottom: 20px"
1254 >
dc26881CC LABS App1255 <div style="display: flex; gap: var(--space-2)">
79136bbClaude1256 <input
1257 type="text"
1258 name="q"
1259 value={q}
1260 placeholder="Search code..."
2c3ba6ecopilot-swe-agent[bot]1261 aria-label="Search code"
dc26881CC LABS App1262 style="flex: 1; padding: var(--space-2) var(--space-3); background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
79136bbClaude1263 />
1264 <button type="submit" class="btn btn-primary">
1265 Search
1266 </button>
1267 </div>
1268 </form>
1269 {q && (
1270 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 16px">
1271 {results.length} result{results.length !== 1 ? "s" : ""} for{" "}
1272 <strong style="color: var(--text)">"{q}"</strong>
1273 </p>
1274 )}
1275 {results.length > 0 && (
1276 <div class="search-results">
1277 {(() => {
1278 // Group by file
1279 const grouped: Record<
1280 string,
1281 Array<{ lineNum: number; line: string }>
1282 > = {};
1283 for (const r of results) {
1284 if (!grouped[r.file]) grouped[r.file] = [];
1285 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
1286 }
1287 return Object.entries(grouped).map(([file, matches]) => (
1288 <div class="diff-file" style="margin-bottom: 12px">
1289 <div class="diff-file-header">
1290 <a
1291 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
1292 >
1293 {file}
1294 </a>
1295 </div>
1296 <div class="blob-code">
1297 <table>
1298 <tbody>
1299 {matches.map((m) => (
1300 <tr>
1301 <td class="line-num">{m.lineNum}</td>
1302 <td class="line-content">{m.line}</td>
1303 </tr>
1304 ))}
1305 </tbody>
1306 </table>
1307 </div>
1308 </div>
1309 ));
1310 })()}
1311 </div>
1312 )}
1313 </Layout>
1314 );
1315});
1316
fc1817aClaude1317export default web;