Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

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.tsxBlame1322 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,
a28cedeClaude492 }).catch((err) => {
493 console.warn(
494 `[web] view tracking failed for ${owner}/${repo}:`,
495 err instanceof Error ? err.message : err
496 );
497 });
8f50ed0Claude498
fc1817aClaude499 if (!(await repoExists(owner, repo))) {
500 return c.html(
06d5ffeClaude501 <Layout title="Not Found" user={user}>
fc1817aClaude502 <div class="empty-state">
503 <h2>Repository not found</h2>
504 <p>
505 {owner}/{repo} does not exist.
506 </p>
507 </div>
508 </Layout>,
509 404
510 );
511 }
512
05b973eClaude513 // Parallelize all independent operations
514 const [defaultBranch, branches] = await Promise.all([
515 getDefaultBranch(owner, repo).then((b) => b || "main"),
516 listBranches(owner, repo),
517 ]);
518 const [tree, starInfo] = await Promise.all([
519 getTree(owner, repo, defaultBranch),
520 // Star info fetched in parallel with tree
521 (async () => {
522 try {
523 const [ownerUser] = await db
524 .select()
525 .from(users)
526 .where(eq(users.username, owner))
527 .limit(1);
71cd5ecClaude528 if (!ownerUser)
529 return {
530 starCount: 0,
531 starred: false,
532 archived: false,
533 isTemplate: false,
534 };
05b973eClaude535 const [repoRow] = await db
536 .select()
537 .from(repositories)
538 .where(
539 and(
540 eq(repositories.ownerId, ownerUser.id),
541 eq(repositories.name, repo)
542 )
06d5ffeClaude543 )
05b973eClaude544 .limit(1);
71cd5ecClaude545 if (!repoRow)
546 return {
547 starCount: 0,
548 starred: false,
549 archived: false,
550 isTemplate: false,
551 };
05b973eClaude552 let starred = false;
06d5ffeClaude553 if (user) {
554 const [star] = await db
555 .select()
556 .from(stars)
557 .where(
558 and(
559 eq(stars.userId, user.id),
560 eq(stars.repositoryId, repoRow.id)
561 )
562 )
563 .limit(1);
564 starred = !!star;
565 }
71cd5ecClaude566 return {
567 starCount: repoRow.starCount,
568 starred,
569 archived: repoRow.isArchived,
570 isTemplate: repoRow.isTemplate,
571 };
05b973eClaude572 } catch {
71cd5ecClaude573 return {
574 starCount: 0,
575 starred: false,
576 archived: false,
577 isTemplate: false,
578 };
06d5ffeClaude579 }
05b973eClaude580 })(),
581 ]);
71cd5ecClaude582 const { starCount, starred, archived, isTemplate } = starInfo;
06d5ffeClaude583
fc1817aClaude584 if (tree.length === 0) {
585 return c.html(
06d5ffeClaude586 <Layout title={`${owner}/${repo}`} user={user}>
587 <RepoHeader
588 owner={owner}
589 repo={repo}
590 starCount={starCount}
591 starred={starred}
592 currentUser={user?.username}
71cd5ecClaude593 archived={archived}
594 isTemplate={isTemplate}
06d5ffeClaude595 />
fc1817aClaude596 <RepoNav owner={owner} repo={repo} active="code" />
597 <div class="empty-state">
598 <h2>Empty repository</h2>
599 <p>Get started by pushing code:</p>
ea52715copilot-swe-agent[bot]600 <pre>{`git remote add gluecron ${config.appBaseUrl}/${owner}/${repo}.git
fc1817aClaude601git push -u gluecron main`}</pre>
602 </div>
603 </Layout>
604 );
605 }
606
607 const readme = await getReadme(owner, repo, defaultBranch);
608
609 return c.html(
06d5ffeClaude610 <Layout title={`${owner}/${repo}`} user={user}>
611 <RepoHeader
612 owner={owner}
613 repo={repo}
614 starCount={starCount}
615 starred={starred}
616 currentUser={user?.username}
71cd5ecClaude617 archived={archived}
618 isTemplate={isTemplate}
06d5ffeClaude619 />
71cd5ecClaude620 {isTemplate && user && user.username !== owner && (
621 <div
622 class="panel"
dc26881CC LABS App623 style="margin-bottom:var(--space-4);padding:var(--space-3);display:flex;align-items:center;justify-content:space-between;gap:var(--space-3)"
71cd5ecClaude624 >
625 <div style="font-size:13px">
626 <strong>Template repository.</strong> Create a new repository from
627 this template's files.
628 </div>
629 <form
001af43Claude630 method="post"
71cd5ecClaude631 action={`/${owner}/${repo}/use-template`}
dc26881CC LABS App632 style="display:flex;gap:var(--space-2);align-items:center"
71cd5ecClaude633 >
634 <input
635 type="text"
636 name="name"
637 placeholder="new-repo-name"
638 required
2c3ba6ecopilot-swe-agent[bot]639 aria-label="New repository name"
71cd5ecClaude640 style="width:200px"
641 />
642 <button type="submit" class="btn btn-primary">
643 Use this template
644 </button>
645 </form>
646 </div>
647 )}
fc1817aClaude648 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude649 <BranchSwitcher
650 owner={owner}
651 repo={repo}
652 currentRef={defaultBranch}
653 branches={branches}
654 pathType="tree"
655 />
fc1817aClaude656 <FileTable
657 entries={tree}
658 owner={owner}
659 repo={repo}
660 ref={defaultBranch}
661 path=""
662 />
79136bbClaude663 {readme && (() => {
664 const readmeHtml = renderMarkdown(readme);
665 return (
666 <div class="blob-view" style="margin-top: 20px">
667 <div class="blob-header">README.md</div>
668 <style>{markdownCss}</style>
669 <div class="markdown-body">
670 {html([readmeHtml] as unknown as TemplateStringsArray)}
671 </div>
fc1817aClaude672 </div>
79136bbClaude673 );
674 })()}
fc1817aClaude675 </Layout>
676 );
677});
678
679// Browse tree at ref/path
680web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
681 const { owner, repo } = c.req.param();
06d5ffeClaude682 const user = c.get("user");
fc1817aClaude683 const refAndPath = c.req.param("ref");
684
685 const branches = await listBranches(owner, repo);
686 let ref = "";
687 let treePath = "";
688
689 for (const branch of branches) {
690 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
691 ref = branch;
692 treePath = refAndPath.slice(branch.length + 1);
693 break;
694 }
695 }
696
697 if (!ref) {
698 const slashIdx = refAndPath.indexOf("/");
699 if (slashIdx === -1) {
700 ref = refAndPath;
701 } else {
702 ref = refAndPath.slice(0, slashIdx);
703 treePath = refAndPath.slice(slashIdx + 1);
704 }
705 }
706
707 const tree = await getTree(owner, repo, ref, treePath);
708
709 return c.html(
06d5ffeClaude710 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
fc1817aClaude711 <RepoHeader owner={owner} repo={repo} />
712 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude713 <BranchSwitcher
714 owner={owner}
715 repo={repo}
716 currentRef={ref}
717 branches={branches}
718 pathType="tree"
719 subPath={treePath}
720 />
fc1817aClaude721 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
722 <FileTable
723 entries={tree}
724 owner={owner}
725 repo={repo}
726 ref={ref}
727 path={treePath}
728 />
729 </Layout>
730 );
731});
732
06d5ffeClaude733// View file blob with syntax highlighting
fc1817aClaude734web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
735 const { owner, repo } = c.req.param();
06d5ffeClaude736 const user = c.get("user");
fc1817aClaude737 const refAndPath = c.req.param("ref");
738
739 const branches = await listBranches(owner, repo);
740 let ref = "";
741 let filePath = "";
742
743 for (const branch of branches) {
744 if (refAndPath.startsWith(branch + "/")) {
745 ref = branch;
746 filePath = refAndPath.slice(branch.length + 1);
747 break;
748 }
749 }
750
751 if (!ref) {
752 const slashIdx = refAndPath.indexOf("/");
753 if (slashIdx === -1) return c.text("Not found", 404);
754 ref = refAndPath.slice(0, slashIdx);
755 filePath = refAndPath.slice(slashIdx + 1);
756 }
757
758 const blob = await getBlob(owner, repo, ref, filePath);
759 if (!blob) {
760 return c.html(
06d5ffeClaude761 <Layout title="Not Found" user={user}>
fc1817aClaude762 <div class="empty-state">
763 <h2>File not found</h2>
764 </div>
765 </Layout>,
766 404
767 );
768 }
769
06d5ffeClaude770 const fileName = filePath.split("/").pop() || filePath;
fc1817aClaude771
772 return c.html(
06d5ffeClaude773 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
fc1817aClaude774 <RepoHeader owner={owner} repo={repo} />
775 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude776 <BranchSwitcher
777 owner={owner}
778 repo={repo}
779 currentRef={ref}
780 branches={branches}
781 pathType="blob"
782 subPath={filePath}
783 />
fc1817aClaude784 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
785 <div class="blob-view">
786 <div class="blob-header">
06d5ffeClaude787 <span>{fileName} — {blob.size} bytes</span>
dc26881CC LABS App788 <span style="display: flex; gap: var(--space-3)">
79136bbClaude789 <a href={`/${owner}/${repo}/raw/${ref}/${filePath}`} style="font-size: 12px">
790 Raw
791 </a>
792 <a href={`/${owner}/${repo}/blame/${ref}/${filePath}`} style="font-size: 12px">
793 Blame
794 </a>
16b325cClaude795 <a href={`/${owner}/${repo}/timeline/${ref}/${filePath}`} style="font-size: 12px">
796 History
797 </a>
0074234Claude798 {user && (
799 <a href={`/${owner}/${repo}/edit/${ref}/${filePath}`} style="font-size: 12px">
800 Edit
801 </a>
802 )}
79136bbClaude803 </span>
fc1817aClaude804 </div>
805 {blob.isBinary ? (
dc26881CC LABS App806 <div style="padding: var(--space-4); color: var(--text-muted)">
fc1817aClaude807 Binary file not shown.
808 </div>
06d5ffeClaude809 ) : (() => {
810 const { html: highlighted, language } = highlightCode(
811 blob.content,
812 fileName
813 );
814 const lineCount = blob.content.split("\n").length;
815 // Trim trailing newline from count
816 const adjustedCount =
817 blob.content.endsWith("\n") ? lineCount - 1 : lineCount;
818
819 if (language) {
820 return (
821 <HighlightedCode
822 highlightedHtml={highlighted}
823 lineCount={adjustedCount}
824 />
825 );
826 }
827 const lines = blob.content.split("\n");
828 if (lines[lines.length - 1] === "") lines.pop();
829 return <PlainCode lines={lines} />;
830 })()}
fc1817aClaude831 </div>
832 </Layout>
833 );
834});
835
836// Commit log
837web.get("/:owner/:repo/commits/:ref?", async (c) => {
838 const { owner, repo } = c.req.param();
06d5ffeClaude839 const user = c.get("user");
fc1817aClaude840 const ref =
841 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude842 const branches = await listBranches(owner, repo);
fc1817aClaude843
844 const commits = await listCommits(owner, repo, ref, 50);
845
3951454Claude846 // Block J3 — batch-fetch cached verification results for the page.
847 let verifications: Record<string, { verified: boolean; reason: string }> = {};
848 try {
849 const [ownerRow] = await db
850 .select()
851 .from(users)
852 .where(eq(users.username, owner))
853 .limit(1);
854 if (ownerRow) {
855 const [repoRow] = await db
856 .select()
857 .from(repositories)
858 .where(
859 and(
860 eq(repositories.ownerId, ownerRow.id),
861 eq(repositories.name, repo)
862 )
863 )
864 .limit(1);
865 if (repoRow && commits.length > 0) {
866 const rows = await db
867 .select()
868 .from(commitVerifications)
869 .where(
870 and(
871 eq(commitVerifications.repositoryId, repoRow.id),
872 inArray(
873 commitVerifications.commitSha,
874 commits.map((c) => c.sha)
875 )
876 )
877 );
878 for (const r of rows) {
879 verifications[r.commitSha] = {
880 verified: r.verified,
881 reason: r.reason,
882 };
883 }
884 }
885 }
886 } catch {
887 // DB unavailable — skip the badges gracefully.
888 }
889
fc1817aClaude890 return c.html(
06d5ffeClaude891 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
fc1817aClaude892 <RepoHeader owner={owner} repo={repo} />
893 <RepoNav owner={owner} repo={repo} active="commits" />
06d5ffeClaude894 <BranchSwitcher
895 owner={owner}
896 repo={repo}
897 currentRef={ref}
898 branches={branches}
899 pathType="commits"
900 />
fc1817aClaude901 {commits.length === 0 ? (
902 <div class="empty-state">
903 <p>No commits yet.</p>
904 </div>
905 ) : (
3951454Claude906 <CommitList
907 commits={commits}
908 owner={owner}
909 repo={repo}
910 verifications={verifications}
911 />
fc1817aClaude912 )}
913 </Layout>
914 );
915});
916
917// Single commit with diff
918web.get("/:owner/:repo/commit/:sha", async (c) => {
919 const { owner, repo, sha } = c.req.param();
06d5ffeClaude920 const user = c.get("user");
fc1817aClaude921
05b973eClaude922 // Fetch commit, full message, and diff in parallel
923 const [commit, fullMessage, diffResult] = await Promise.all([
924 getCommit(owner, repo, sha),
925 getCommitFullMessage(owner, repo, sha),
926 getDiff(owner, repo, sha),
927 ]);
fc1817aClaude928 if (!commit) {
929 return c.html(
06d5ffeClaude930 <Layout title="Not Found" user={user}>
fc1817aClaude931 <div class="empty-state">
932 <h2>Commit not found</h2>
933 </div>
934 </Layout>,
935 404
936 );
937 }
938
3951454Claude939 // Block J3 — try to verify this commit's signature.
940 let verification:
941 | { verified: boolean; reason: string; signatureType: string | null }
942 | null = null;
0cdfd89Claude943 // Block J8 — external CI commit statuses rollup.
944 let statusCombined:
945 | {
946 state: "pending" | "success" | "failure";
947 total: number;
948 contexts: Array<{
949 context: string;
950 state: string;
951 description: string | null;
952 targetUrl: string | null;
953 }>;
954 }
955 | null = null;
3951454Claude956 try {
957 const [ownerRow] = await db
958 .select()
959 .from(users)
960 .where(eq(users.username, owner))
961 .limit(1);
962 if (ownerRow) {
963 const [repoRow] = await db
964 .select()
965 .from(repositories)
966 .where(
967 and(
968 eq(repositories.ownerId, ownerRow.id),
969 eq(repositories.name, repo)
970 )
971 )
972 .limit(1);
973 if (repoRow) {
974 const { verifyCommit } = await import("../lib/signatures");
975 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
976 verification = {
977 verified: v.verified,
978 reason: v.reason,
979 signatureType: v.signatureType,
980 };
0cdfd89Claude981 try {
982 const { combinedStatus } = await import("../lib/commit-statuses");
983 const combined = await combinedStatus(repoRow.id, commit.sha);
984 if (combined.total > 0) {
985 statusCombined = {
986 state: combined.state as any,
987 total: combined.total,
988 contexts: combined.contexts.map((c) => ({
989 context: c.context,
990 state: c.state,
991 description: c.description,
992 targetUrl: c.targetUrl,
993 })),
994 };
995 }
996 } catch {
997 statusCombined = null;
998 }
3951454Claude999 }
1000 }
1001 } catch {
1002 verification = null;
1003 }
1004
05b973eClaude1005 const { files, raw } = diffResult;
fc1817aClaude1006
1007 return c.html(
06d5ffeClaude1008 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
fc1817aClaude1009 <RepoHeader owner={owner} repo={repo} />
1010 <div
dc26881CC LABS App1011 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--space-4); margin-bottom: var(--space-5)"
fc1817aClaude1012 >
1013 <div style="font-size: 18px; font-weight: 600; margin-bottom: 8px">
1014 {commit.message}
1015 </div>
1016 {fullMessage !== commit.message && (
1017 <div style="white-space: pre-wrap; color: var(--text-muted); font-size: 14px; margin-bottom: 12px">
1018 {fullMessage}
1019 </div>
1020 )}
1021 <div style="font-size: 13px; color: var(--text-muted)">
1022 <strong style="color: var(--text)">{commit.author}</strong>{" "}
1023 committed on{" "}
1024 {new Date(commit.date).toLocaleDateString("en-US", {
1025 month: "long",
1026 day: "numeric",
1027 year: "numeric",
1028 })}
3951454Claude1029 {verification && verification.reason !== "unsigned" && (
1030 <span
1031 style={`margin-left:10px;font-size:10px;padding:1px 6px;border-radius:3px;text-transform:uppercase;letter-spacing:.4px;color:#fff;background:${
1032 verification.verified
1033 ? "var(--green,#2ea043)"
1034 : "var(--yellow,#d29922)"
1035 }`}
1036 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
1037 >
1038 {verification.verified ? "Verified" : verification.reason}
1039 </span>
1040 )}
fc1817aClaude1041 </div>
1042 <div style="margin-top: 8px">
1043 <span class="commit-sha">{commit.sha}</span>
1044 {commit.parentShas.length > 0 && (
1045 <span style="margin-left: 12px; font-size: 13px; color: var(--text-muted)">
1046 Parent:{" "}
1047 {commit.parentShas.map((p) => (
1048 <a
1049 href={`/${owner}/${repo}/commit/${p}`}
1050 class="commit-sha"
1051 style="margin-left: 4px"
1052 >
1053 {p.slice(0, 7)}
1054 </a>
1055 ))}
1056 </span>
1057 )}
1058 </div>
0cdfd89Claude1059 {statusCombined && (
1060 <div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); font-size: 13px">
1061 <strong style="color: var(--text)">Checks</strong>
1062 <span style="margin-left: 8px; color: var(--text-muted)">
1063 {statusCombined.total} total —{" "}
1064 <span
1065 style={`color:${
1066 statusCombined.state === "success"
1067 ? "var(--green,#2ea043)"
1068 : statusCombined.state === "failure"
1069 ? "var(--red,#da3633)"
1070 : "var(--yellow,#d29922)"
1071 }`}
1072 >
1073 {statusCombined.state}
1074 </span>
1075 </span>
1076 <div style="margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px">
1077 {statusCombined.contexts.map((cx) => (
1078 <span
1079 style={`font-size:11px;padding:2px 6px;border-radius:3px;color:#fff;background:${
1080 cx.state === "success"
1081 ? "var(--green,#2ea043)"
1082 : cx.state === "pending"
1083 ? "var(--yellow,#d29922)"
1084 : "var(--red,#da3633)"
1085 }`}
1086 title={cx.description || cx.context}
1087 >
1088 {cx.targetUrl ? (
1089 <a
1090 href={cx.targetUrl}
1091 style="color: inherit; text-decoration: none"
1092 rel="noopener"
1093 >
1094 {cx.context}: {cx.state}
1095 </a>
1096 ) : (
1097 <>
1098 {cx.context}: {cx.state}
1099 </>
1100 )}
1101 </span>
1102 ))}
1103 </div>
1104 </div>
1105 )}
fc1817aClaude1106 </div>
1107 <DiffView raw={raw} files={files} />
1108 </Layout>
1109 );
1110});
1111
79136bbClaude1112// Raw file download
1113web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
1114 const { owner, repo } = c.req.param();
1115 const refAndPath = c.req.param("ref");
1116
1117 const branches = await listBranches(owner, repo);
1118 let ref = "";
1119 let filePath = "";
1120
1121 for (const branch of branches) {
1122 if (refAndPath.startsWith(branch + "/")) {
1123 ref = branch;
1124 filePath = refAndPath.slice(branch.length + 1);
1125 break;
1126 }
1127 }
1128
1129 if (!ref) {
1130 const slashIdx = refAndPath.indexOf("/");
1131 if (slashIdx === -1) return c.text("Not found", 404);
1132 ref = refAndPath.slice(0, slashIdx);
1133 filePath = refAndPath.slice(slashIdx + 1);
1134 }
1135
1136 const data = await getRawBlob(owner, repo, ref, filePath);
1137 if (!data) return c.text("Not found", 404);
1138
1139 const fileName = filePath.split("/").pop() || "file";
772a24fClaude1140 return new Response(data as BodyInit, {
79136bbClaude1141 headers: {
1142 "Content-Type": "application/octet-stream",
1143 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude1144 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude1145 },
1146 });
1147});
1148
1149// Blame view
1150web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
1151 const { owner, repo } = c.req.param();
1152 const user = c.get("user");
1153 const refAndPath = c.req.param("ref");
1154
1155 const branches = await listBranches(owner, repo);
1156 let ref = "";
1157 let filePath = "";
1158
1159 for (const branch of branches) {
1160 if (refAndPath.startsWith(branch + "/")) {
1161 ref = branch;
1162 filePath = refAndPath.slice(branch.length + 1);
1163 break;
1164 }
1165 }
1166
1167 if (!ref) {
1168 const slashIdx = refAndPath.indexOf("/");
1169 if (slashIdx === -1) return c.text("Not found", 404);
1170 ref = refAndPath.slice(0, slashIdx);
1171 filePath = refAndPath.slice(slashIdx + 1);
1172 }
1173
1174 const blameLines = await getBlame(owner, repo, ref, filePath);
1175 if (blameLines.length === 0) {
1176 return c.html(
1177 <Layout title="Not Found" user={user}>
1178 <div class="empty-state">
1179 <h2>File not found</h2>
1180 </div>
1181 </Layout>,
1182 404
1183 );
1184 }
1185
1186 const fileName = filePath.split("/").pop() || filePath;
1187
1188 return c.html(
1189 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
1190 <RepoHeader owner={owner} repo={repo} />
1191 <RepoNav owner={owner} repo={repo} active="code" />
1192 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
1193 <div class="blob-view">
1194 <div class="blob-header">
1195 <span>{fileName} — blame</span>
1196 <a href={`/${owner}/${repo}/blob/${ref}/${filePath}`} style="font-size: 12px">
1197 Normal view
1198 </a>
1199 </div>
1200 <div class="blob-code" style="overflow-x: auto">
1201 <table style="width: 100%; border-collapse: collapse; font-size: 13px; font-family: var(--font-mono)">
1202 <tbody>
1203 {blameLines.map((line, i) => {
1204 const showInfo =
1205 i === 0 || blameLines[i - 1].sha !== line.sha;
1206 return (
1207 <tr style="border-bottom: 1px solid var(--border)">
1208 <td
1209 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)" : ""}`}
1210 >
1211 {showInfo && (
1212 <>
1213 <a
1214 href={`/${owner}/${repo}/commit/${line.sha}`}
1215 style="color: var(--text-link); font-family: var(--font-mono)"
1216 >
1217 {line.sha.slice(0, 7)}
1218 </a>{" "}
1219 <span>{line.author}</span>
1220 </>
1221 )}
1222 </td>
1223 <td class="line-num">{line.lineNum}</td>
1224 <td class="line-content">{line.content}</td>
1225 </tr>
1226 );
1227 })}
1228 </tbody>
1229 </table>
1230 </div>
1231 </div>
1232 </Layout>
1233 );
1234});
1235
1236// Search
1237web.get("/:owner/:repo/search", async (c) => {
1238 const { owner, repo } = c.req.param();
1239 const user = c.get("user");
1240 const q = c.req.query("q") || "";
1241
1242 if (!(await repoExists(owner, repo))) return c.notFound();
1243
1244 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
1245 let results: Array<{ file: string; lineNum: number; line: string }> = [];
1246
1247 if (q.trim()) {
1248 results = await searchCode(owner, repo, defaultBranch, q.trim());
1249 }
1250
1251 return c.html(
1252 <Layout title={`Search — ${owner}/${repo}`} user={user}>
1253 <RepoHeader owner={owner} repo={repo} />
1254 <RepoNav owner={owner} repo={repo} active="code" />
1255 <form
001af43Claude1256 method="get"
79136bbClaude1257 action={`/${owner}/${repo}/search`}
1258 style="margin-bottom: 20px"
1259 >
dc26881CC LABS App1260 <div style="display: flex; gap: var(--space-2)">
79136bbClaude1261 <input
1262 type="text"
1263 name="q"
1264 value={q}
1265 placeholder="Search code..."
2c3ba6ecopilot-swe-agent[bot]1266 aria-label="Search code"
dc26881CC LABS App1267 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"
79136bbClaude1268 />
1269 <button type="submit" class="btn btn-primary">
1270 Search
1271 </button>
1272 </div>
1273 </form>
1274 {q && (
1275 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 16px">
1276 {results.length} result{results.length !== 1 ? "s" : ""} for{" "}
1277 <strong style="color: var(--text)">"{q}"</strong>
1278 </p>
1279 )}
1280 {results.length > 0 && (
1281 <div class="search-results">
1282 {(() => {
1283 // Group by file
1284 const grouped: Record<
1285 string,
1286 Array<{ lineNum: number; line: string }>
1287 > = {};
1288 for (const r of results) {
1289 if (!grouped[r.file]) grouped[r.file] = [];
1290 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
1291 }
1292 return Object.entries(grouped).map(([file, matches]) => (
1293 <div class="diff-file" style="margin-bottom: 12px">
1294 <div class="diff-file-header">
1295 <a
1296 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
1297 >
1298 {file}
1299 </a>
1300 </div>
1301 <div class="blob-code">
1302 <table>
1303 <tbody>
1304 {matches.map((m) => (
1305 <tr>
1306 <td class="line-num">{m.lineNum}</td>
1307 <td class="line-content">{m.line}</td>
1308 </tr>
1309 ))}
1310 </tbody>
1311 </table>
1312 </div>
1313 </div>
1314 ));
1315 })()}
1316 </div>
1317 )}
1318 </Layout>
1319 );
1320});
1321
fc1817aClaude1322export default web;