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.tsxBlame1309 lines · 2 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
226 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
227 return c.redirect("/new?error=Invalid+repository+name");
228 }
229
230 if (await repoExists(user.username, name)) {
231 return c.redirect("/new?error=Repository+already+exists");
232 }
233
234 const diskPath = await initBareRepo(user.username, name);
235
3ef4c9dClaude236 const [newRepo] = await db
237 .insert(repositories)
238 .values({
239 name,
240 ownerId: user.id,
241 description: description || null,
242 isPrivate,
243 diskPath,
244 })
245 .returning();
246
247 if (newRepo) {
248 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
249 await bootstrapRepository({
250 repositoryId: newRepo.id,
251 ownerUserId: user.id,
252 defaultBranch: "main",
253 });
254 }
06d5ffeClaude255
256 return c.redirect(`/${user.username}/${name}`);
257});
258
259// User profile
fc1817aClaude260web.get("/:owner", async (c) => {
06d5ffeClaude261 const { owner: ownerName } = c.req.param();
262 const user = c.get("user");
263
264 // Avoid clashing with fixed routes
265 if (
266 ["login", "register", "logout", "new", "settings", "api"].includes(
267 ownerName
268 )
269 ) {
270 return c.notFound();
271 }
272
273 let ownerUser;
274 try {
275 const [found] = await db
276 .select()
277 .from(users)
278 .where(eq(users.username, ownerName))
279 .limit(1);
280 ownerUser = found;
281 } catch {
282 // DB not available — check if repos exist on disk
283 ownerUser = null;
284 }
285
286 // Even without DB, show repos if they exist on disk
287 let repos: any[] = [];
288 if (ownerUser) {
289 const allRepos = await db
290 .select()
291 .from(repositories)
292 .where(eq(repositories.ownerId, ownerUser.id))
293 .orderBy(desc(repositories.updatedAt));
294
295 // Show public repos to everyone, private only to owner
296 repos =
297 user?.id === ownerUser.id
298 ? allRepos
299 : allRepos.filter((r) => !r.isPrivate);
300 }
301
7aa8b99Claude302 // Block J4 — follow counts + viewer's follow state
303 let followState = {
304 followers: 0,
305 following: 0,
306 viewerFollows: false,
307 };
308 if (ownerUser) {
309 try {
310 const { followCounts, isFollowing } = await import("../lib/follows");
311 const counts = await followCounts(ownerUser.id);
312 followState.followers = counts.followers;
313 followState.following = counts.following;
314 if (user && user.id !== ownerUser.id) {
315 followState.viewerFollows = await isFollowing(user.id, ownerUser.id);
316 }
317 } catch {
318 // DB hiccup — fall back to zeros.
319 }
320 }
321 const canFollow = !!user && !!ownerUser && user.id !== ownerUser.id;
322
d412586Claude323 // Block J5 — profile README. Render owner/owner repo's README on the
324 // profile page (GitHub convention). Tries "<user>/<user>" first, falling
325 // back to "<user>/.github" for org-style profile repos.
326 let profileReadmeHtml: string | null = null;
327 try {
328 const candidates = [ownerName, ".github"];
329 for (const rname of candidates) {
330 if (await repoExists(ownerName, rname)) {
331 const ref = (await getDefaultBranch(ownerName, rname)) || "main";
332 const md = await getReadme(ownerName, rname, ref);
333 if (md) {
334 profileReadmeHtml = renderMarkdown(md);
335 break;
336 }
337 }
338 }
339 } catch {
340 profileReadmeHtml = null;
341 }
342
fc1817aClaude343 return c.html(
06d5ffeClaude344 <Layout title={ownerName} user={user}>
345 <div class="user-profile">
346 <div class="user-avatar">
347 {(ownerUser?.displayName || ownerName)[0].toUpperCase()}
348 </div>
349 <div class="user-info">
350 <h2>{ownerUser?.displayName || ownerName}</h2>
351 <div class="username">@{ownerName}</div>
352 {ownerUser?.bio && <div class="bio">{ownerUser.bio}</div>}
7aa8b99Claude353 <div
354 style="margin-top:8px;display:flex;gap:12px;align-items:center;flex-wrap:wrap;font-size:13px"
355 >
356 <a
357 href={`/${ownerName}/followers`}
358 style="color:var(--text-muted)"
359 >
360 <strong style="color:var(--text)">
361 {followState.followers}
362 </strong>{" "}
363 follower{followState.followers === 1 ? "" : "s"}
364 </a>
365 <a
366 href={`/${ownerName}/following`}
367 style="color:var(--text-muted)"
368 >
369 <strong style="color:var(--text)">
370 {followState.following}
371 </strong>{" "}
372 following
373 </a>
374 {canFollow && (
375 <form
001af43Claude376 method="post"
7aa8b99Claude377 action={`/${ownerName}/${
378 followState.viewerFollows ? "unfollow" : "follow"
379 }`}
380 >
381 <button
382 type="submit"
383 class={`btn ${
384 followState.viewerFollows ? "" : "btn-primary"
385 } btn-sm`}
386 >
387 {followState.viewerFollows ? "Unfollow" : "Follow"}
388 </button>
389 </form>
390 )}
391 </div>
06d5ffeClaude392 </div>
393 </div>
d412586Claude394 {profileReadmeHtml && (
395 <div
396 class="markdown-body"
397 style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:20px 24px;margin-bottom:24px"
398 dangerouslySetInnerHTML={{ __html: profileReadmeHtml }}
399 />
400 )}
06d5ffeClaude401 <h3 style="margin-bottom: 16px">Repositories</h3>
402 {repos.length === 0 ? (
403 <p style="color: var(--text-muted)">No repositories yet.</p>
404 ) : (
405 <div class="card-grid">
406 {repos.map((repo) => (
407 <RepoCard repo={repo} ownerName={ownerName} />
408 ))}
409 </div>
410 )}
fc1817aClaude411 </Layout>
412 );
413});
414
06d5ffeClaude415// Star/unstar a repo
416web.post("/:owner/:repo/star", requireAuth, async (c) => {
417 const { owner: ownerName, repo: repoName } = c.req.param();
418 const user = c.get("user")!;
419
420 try {
421 const [ownerUser] = await db
422 .select()
423 .from(users)
424 .where(eq(users.username, ownerName))
425 .limit(1);
426 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
427
428 const [repo] = await db
429 .select()
430 .from(repositories)
431 .where(
432 and(
433 eq(repositories.ownerId, ownerUser.id),
434 eq(repositories.name, repoName)
435 )
436 )
437 .limit(1);
438 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
439
440 // Toggle star
441 const [existing] = await db
442 .select()
443 .from(stars)
444 .where(
445 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
446 )
447 .limit(1);
448
449 if (existing) {
450 await db.delete(stars).where(eq(stars.id, existing.id));
451 await db
452 .update(repositories)
453 .set({ starCount: Math.max(0, repo.starCount - 1) })
454 .where(eq(repositories.id, repo.id));
455 } else {
456 await db.insert(stars).values({
457 userId: user.id,
458 repositoryId: repo.id,
459 });
460 await db
461 .update(repositories)
462 .set({ starCount: repo.starCount + 1 })
463 .where(eq(repositories.id, repo.id));
464 }
465 } catch {
466 // DB error — ignore
467 }
468
469 return c.redirect(`/${ownerName}/${repoName}`);
470});
471
fc1817aClaude472// Repository overview — file tree at HEAD
473web.get("/:owner/:repo", async (c) => {
474 const { owner, repo } = c.req.param();
06d5ffeClaude475 const user = c.get("user");
fc1817aClaude476
8f50ed0Claude477 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
478 trackByName(owner, repo, "view", {
479 userId: user?.id || null,
480 path: `/${owner}/${repo}`,
481 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
482 userAgent: c.req.header("user-agent") || null,
483 referer: c.req.header("referer") || null,
484 }).catch(() => {});
485
fc1817aClaude486 if (!(await repoExists(owner, repo))) {
487 return c.html(
06d5ffeClaude488 <Layout title="Not Found" user={user}>
fc1817aClaude489 <div class="empty-state">
490 <h2>Repository not found</h2>
491 <p>
492 {owner}/{repo} does not exist.
493 </p>
494 </div>
495 </Layout>,
496 404
497 );
498 }
499
05b973eClaude500 // Parallelize all independent operations
501 const [defaultBranch, branches] = await Promise.all([
502 getDefaultBranch(owner, repo).then((b) => b || "main"),
503 listBranches(owner, repo),
504 ]);
505 const [tree, starInfo] = await Promise.all([
506 getTree(owner, repo, defaultBranch),
507 // Star info fetched in parallel with tree
508 (async () => {
509 try {
510 const [ownerUser] = await db
511 .select()
512 .from(users)
513 .where(eq(users.username, owner))
514 .limit(1);
71cd5ecClaude515 if (!ownerUser)
516 return {
517 starCount: 0,
518 starred: false,
519 archived: false,
520 isTemplate: false,
521 };
05b973eClaude522 const [repoRow] = await db
523 .select()
524 .from(repositories)
525 .where(
526 and(
527 eq(repositories.ownerId, ownerUser.id),
528 eq(repositories.name, repo)
529 )
06d5ffeClaude530 )
05b973eClaude531 .limit(1);
71cd5ecClaude532 if (!repoRow)
533 return {
534 starCount: 0,
535 starred: false,
536 archived: false,
537 isTemplate: false,
538 };
05b973eClaude539 let starred = false;
06d5ffeClaude540 if (user) {
541 const [star] = await db
542 .select()
543 .from(stars)
544 .where(
545 and(
546 eq(stars.userId, user.id),
547 eq(stars.repositoryId, repoRow.id)
548 )
549 )
550 .limit(1);
551 starred = !!star;
552 }
71cd5ecClaude553 return {
554 starCount: repoRow.starCount,
555 starred,
556 archived: repoRow.isArchived,
557 isTemplate: repoRow.isTemplate,
558 };
05b973eClaude559 } catch {
71cd5ecClaude560 return {
561 starCount: 0,
562 starred: false,
563 archived: false,
564 isTemplate: false,
565 };
06d5ffeClaude566 }
05b973eClaude567 })(),
568 ]);
71cd5ecClaude569 const { starCount, starred, archived, isTemplate } = starInfo;
06d5ffeClaude570
fc1817aClaude571 if (tree.length === 0) {
572 return c.html(
06d5ffeClaude573 <Layout title={`${owner}/${repo}`} user={user}>
574 <RepoHeader
575 owner={owner}
576 repo={repo}
577 starCount={starCount}
578 starred={starred}
579 currentUser={user?.username}
71cd5ecClaude580 archived={archived}
581 isTemplate={isTemplate}
06d5ffeClaude582 />
fc1817aClaude583 <RepoNav owner={owner} repo={repo} active="code" />
584 <div class="empty-state">
585 <h2>Empty repository</h2>
586 <p>Get started by pushing code:</p>
ea52715copilot-swe-agent[bot]587 <pre>{`git remote add gluecron ${config.appBaseUrl}/${owner}/${repo}.git
fc1817aClaude588git push -u gluecron main`}</pre>
589 </div>
590 </Layout>
591 );
592 }
593
594 const readme = await getReadme(owner, repo, defaultBranch);
595
596 return c.html(
06d5ffeClaude597 <Layout title={`${owner}/${repo}`} user={user}>
598 <RepoHeader
599 owner={owner}
600 repo={repo}
601 starCount={starCount}
602 starred={starred}
603 currentUser={user?.username}
71cd5ecClaude604 archived={archived}
605 isTemplate={isTemplate}
06d5ffeClaude606 />
71cd5ecClaude607 {isTemplate && user && user.username !== owner && (
608 <div
609 class="panel"
610 style="margin-bottom:16px;padding:12px;display:flex;align-items:center;justify-content:space-between;gap:12px"
611 >
612 <div style="font-size:13px">
613 <strong>Template repository.</strong> Create a new repository from
614 this template's files.
615 </div>
616 <form
001af43Claude617 method="post"
71cd5ecClaude618 action={`/${owner}/${repo}/use-template`}
619 style="display:flex;gap:8px;align-items:center"
620 >
621 <input
622 type="text"
623 name="name"
624 placeholder="new-repo-name"
625 required
2c3ba6ecopilot-swe-agent[bot]626 aria-label="New repository name"
71cd5ecClaude627 style="width:200px"
628 />
629 <button type="submit" class="btn btn-primary">
630 Use this template
631 </button>
632 </form>
633 </div>
634 )}
fc1817aClaude635 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude636 <BranchSwitcher
637 owner={owner}
638 repo={repo}
639 currentRef={defaultBranch}
640 branches={branches}
641 pathType="tree"
642 />
fc1817aClaude643 <FileTable
644 entries={tree}
645 owner={owner}
646 repo={repo}
647 ref={defaultBranch}
648 path=""
649 />
79136bbClaude650 {readme && (() => {
651 const readmeHtml = renderMarkdown(readme);
652 return (
653 <div class="blob-view" style="margin-top: 20px">
654 <div class="blob-header">README.md</div>
655 <style>{markdownCss}</style>
656 <div class="markdown-body">
657 {html([readmeHtml] as unknown as TemplateStringsArray)}
658 </div>
fc1817aClaude659 </div>
79136bbClaude660 );
661 })()}
fc1817aClaude662 </Layout>
663 );
664});
665
666// Browse tree at ref/path
667web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
668 const { owner, repo } = c.req.param();
06d5ffeClaude669 const user = c.get("user");
fc1817aClaude670 const refAndPath = c.req.param("ref");
671
672 const branches = await listBranches(owner, repo);
673 let ref = "";
674 let treePath = "";
675
676 for (const branch of branches) {
677 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
678 ref = branch;
679 treePath = refAndPath.slice(branch.length + 1);
680 break;
681 }
682 }
683
684 if (!ref) {
685 const slashIdx = refAndPath.indexOf("/");
686 if (slashIdx === -1) {
687 ref = refAndPath;
688 } else {
689 ref = refAndPath.slice(0, slashIdx);
690 treePath = refAndPath.slice(slashIdx + 1);
691 }
692 }
693
694 const tree = await getTree(owner, repo, ref, treePath);
695
696 return c.html(
06d5ffeClaude697 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
fc1817aClaude698 <RepoHeader owner={owner} repo={repo} />
699 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude700 <BranchSwitcher
701 owner={owner}
702 repo={repo}
703 currentRef={ref}
704 branches={branches}
705 pathType="tree"
706 subPath={treePath}
707 />
fc1817aClaude708 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
709 <FileTable
710 entries={tree}
711 owner={owner}
712 repo={repo}
713 ref={ref}
714 path={treePath}
715 />
716 </Layout>
717 );
718});
719
06d5ffeClaude720// View file blob with syntax highlighting
fc1817aClaude721web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
722 const { owner, repo } = c.req.param();
06d5ffeClaude723 const user = c.get("user");
fc1817aClaude724 const refAndPath = c.req.param("ref");
725
726 const branches = await listBranches(owner, repo);
727 let ref = "";
728 let filePath = "";
729
730 for (const branch of branches) {
731 if (refAndPath.startsWith(branch + "/")) {
732 ref = branch;
733 filePath = refAndPath.slice(branch.length + 1);
734 break;
735 }
736 }
737
738 if (!ref) {
739 const slashIdx = refAndPath.indexOf("/");
740 if (slashIdx === -1) return c.text("Not found", 404);
741 ref = refAndPath.slice(0, slashIdx);
742 filePath = refAndPath.slice(slashIdx + 1);
743 }
744
745 const blob = await getBlob(owner, repo, ref, filePath);
746 if (!blob) {
747 return c.html(
06d5ffeClaude748 <Layout title="Not Found" user={user}>
fc1817aClaude749 <div class="empty-state">
750 <h2>File not found</h2>
751 </div>
752 </Layout>,
753 404
754 );
755 }
756
06d5ffeClaude757 const fileName = filePath.split("/").pop() || filePath;
fc1817aClaude758
759 return c.html(
06d5ffeClaude760 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
fc1817aClaude761 <RepoHeader owner={owner} repo={repo} />
762 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude763 <BranchSwitcher
764 owner={owner}
765 repo={repo}
766 currentRef={ref}
767 branches={branches}
768 pathType="blob"
769 subPath={filePath}
770 />
fc1817aClaude771 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
772 <div class="blob-view">
773 <div class="blob-header">
06d5ffeClaude774 <span>{fileName} — {blob.size} bytes</span>
79136bbClaude775 <span style="display: flex; gap: 12px">
776 <a href={`/${owner}/${repo}/raw/${ref}/${filePath}`} style="font-size: 12px">
777 Raw
778 </a>
779 <a href={`/${owner}/${repo}/blame/${ref}/${filePath}`} style="font-size: 12px">
780 Blame
781 </a>
16b325cClaude782 <a href={`/${owner}/${repo}/timeline/${ref}/${filePath}`} style="font-size: 12px">
783 History
784 </a>
0074234Claude785 {user && (
786 <a href={`/${owner}/${repo}/edit/${ref}/${filePath}`} style="font-size: 12px">
787 Edit
788 </a>
789 )}
79136bbClaude790 </span>
fc1817aClaude791 </div>
792 {blob.isBinary ? (
793 <div style="padding: 16px; color: var(--text-muted)">
794 Binary file not shown.
795 </div>
06d5ffeClaude796 ) : (() => {
797 const { html: highlighted, language } = highlightCode(
798 blob.content,
799 fileName
800 );
801 const lineCount = blob.content.split("\n").length;
802 // Trim trailing newline from count
803 const adjustedCount =
804 blob.content.endsWith("\n") ? lineCount - 1 : lineCount;
805
806 if (language) {
807 return (
808 <HighlightedCode
809 highlightedHtml={highlighted}
810 lineCount={adjustedCount}
811 />
812 );
813 }
814 const lines = blob.content.split("\n");
815 if (lines[lines.length - 1] === "") lines.pop();
816 return <PlainCode lines={lines} />;
817 })()}
fc1817aClaude818 </div>
819 </Layout>
820 );
821});
822
823// Commit log
824web.get("/:owner/:repo/commits/:ref?", async (c) => {
825 const { owner, repo } = c.req.param();
06d5ffeClaude826 const user = c.get("user");
fc1817aClaude827 const ref =
828 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude829 const branches = await listBranches(owner, repo);
fc1817aClaude830
831 const commits = await listCommits(owner, repo, ref, 50);
832
3951454Claude833 // Block J3 — batch-fetch cached verification results for the page.
834 let verifications: Record<string, { verified: boolean; reason: string }> = {};
835 try {
836 const [ownerRow] = await db
837 .select()
838 .from(users)
839 .where(eq(users.username, owner))
840 .limit(1);
841 if (ownerRow) {
842 const [repoRow] = await db
843 .select()
844 .from(repositories)
845 .where(
846 and(
847 eq(repositories.ownerId, ownerRow.id),
848 eq(repositories.name, repo)
849 )
850 )
851 .limit(1);
852 if (repoRow && commits.length > 0) {
853 const rows = await db
854 .select()
855 .from(commitVerifications)
856 .where(
857 and(
858 eq(commitVerifications.repositoryId, repoRow.id),
859 inArray(
860 commitVerifications.commitSha,
861 commits.map((c) => c.sha)
862 )
863 )
864 );
865 for (const r of rows) {
866 verifications[r.commitSha] = {
867 verified: r.verified,
868 reason: r.reason,
869 };
870 }
871 }
872 }
873 } catch {
874 // DB unavailable — skip the badges gracefully.
875 }
876
fc1817aClaude877 return c.html(
06d5ffeClaude878 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
fc1817aClaude879 <RepoHeader owner={owner} repo={repo} />
880 <RepoNav owner={owner} repo={repo} active="commits" />
06d5ffeClaude881 <BranchSwitcher
882 owner={owner}
883 repo={repo}
884 currentRef={ref}
885 branches={branches}
886 pathType="commits"
887 />
fc1817aClaude888 {commits.length === 0 ? (
889 <div class="empty-state">
890 <p>No commits yet.</p>
891 </div>
892 ) : (
3951454Claude893 <CommitList
894 commits={commits}
895 owner={owner}
896 repo={repo}
897 verifications={verifications}
898 />
fc1817aClaude899 )}
900 </Layout>
901 );
902});
903
904// Single commit with diff
905web.get("/:owner/:repo/commit/:sha", async (c) => {
906 const { owner, repo, sha } = c.req.param();
06d5ffeClaude907 const user = c.get("user");
fc1817aClaude908
05b973eClaude909 // Fetch commit, full message, and diff in parallel
910 const [commit, fullMessage, diffResult] = await Promise.all([
911 getCommit(owner, repo, sha),
912 getCommitFullMessage(owner, repo, sha),
913 getDiff(owner, repo, sha),
914 ]);
fc1817aClaude915 if (!commit) {
916 return c.html(
06d5ffeClaude917 <Layout title="Not Found" user={user}>
fc1817aClaude918 <div class="empty-state">
919 <h2>Commit not found</h2>
920 </div>
921 </Layout>,
922 404
923 );
924 }
925
3951454Claude926 // Block J3 — try to verify this commit's signature.
927 let verification:
928 | { verified: boolean; reason: string; signatureType: string | null }
929 | null = null;
0cdfd89Claude930 // Block J8 — external CI commit statuses rollup.
931 let statusCombined:
932 | {
933 state: "pending" | "success" | "failure";
934 total: number;
935 contexts: Array<{
936 context: string;
937 state: string;
938 description: string | null;
939 targetUrl: string | null;
940 }>;
941 }
942 | null = null;
3951454Claude943 try {
944 const [ownerRow] = await db
945 .select()
946 .from(users)
947 .where(eq(users.username, owner))
948 .limit(1);
949 if (ownerRow) {
950 const [repoRow] = await db
951 .select()
952 .from(repositories)
953 .where(
954 and(
955 eq(repositories.ownerId, ownerRow.id),
956 eq(repositories.name, repo)
957 )
958 )
959 .limit(1);
960 if (repoRow) {
961 const { verifyCommit } = await import("../lib/signatures");
962 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
963 verification = {
964 verified: v.verified,
965 reason: v.reason,
966 signatureType: v.signatureType,
967 };
0cdfd89Claude968 try {
969 const { combinedStatus } = await import("../lib/commit-statuses");
970 const combined = await combinedStatus(repoRow.id, commit.sha);
971 if (combined.total > 0) {
972 statusCombined = {
973 state: combined.state as any,
974 total: combined.total,
975 contexts: combined.contexts.map((c) => ({
976 context: c.context,
977 state: c.state,
978 description: c.description,
979 targetUrl: c.targetUrl,
980 })),
981 };
982 }
983 } catch {
984 statusCombined = null;
985 }
3951454Claude986 }
987 }
988 } catch {
989 verification = null;
990 }
991
05b973eClaude992 const { files, raw } = diffResult;
fc1817aClaude993
994 return c.html(
06d5ffeClaude995 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
fc1817aClaude996 <RepoHeader owner={owner} repo={repo} />
997 <div
998 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 20px"
999 >
1000 <div style="font-size: 18px; font-weight: 600; margin-bottom: 8px">
1001 {commit.message}
1002 </div>
1003 {fullMessage !== commit.message && (
1004 <div style="white-space: pre-wrap; color: var(--text-muted); font-size: 14px; margin-bottom: 12px">
1005 {fullMessage}
1006 </div>
1007 )}
1008 <div style="font-size: 13px; color: var(--text-muted)">
1009 <strong style="color: var(--text)">{commit.author}</strong>{" "}
1010 committed on{" "}
1011 {new Date(commit.date).toLocaleDateString("en-US", {
1012 month: "long",
1013 day: "numeric",
1014 year: "numeric",
1015 })}
3951454Claude1016 {verification && verification.reason !== "unsigned" && (
1017 <span
1018 style={`margin-left:10px;font-size:10px;padding:1px 6px;border-radius:3px;text-transform:uppercase;letter-spacing:.4px;color:#fff;background:${
1019 verification.verified
1020 ? "var(--green,#2ea043)"
1021 : "var(--yellow,#d29922)"
1022 }`}
1023 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
1024 >
1025 {verification.verified ? "Verified" : verification.reason}
1026 </span>
1027 )}
fc1817aClaude1028 </div>
1029 <div style="margin-top: 8px">
1030 <span class="commit-sha">{commit.sha}</span>
1031 {commit.parentShas.length > 0 && (
1032 <span style="margin-left: 12px; font-size: 13px; color: var(--text-muted)">
1033 Parent:{" "}
1034 {commit.parentShas.map((p) => (
1035 <a
1036 href={`/${owner}/${repo}/commit/${p}`}
1037 class="commit-sha"
1038 style="margin-left: 4px"
1039 >
1040 {p.slice(0, 7)}
1041 </a>
1042 ))}
1043 </span>
1044 )}
1045 </div>
0cdfd89Claude1046 {statusCombined && (
1047 <div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); font-size: 13px">
1048 <strong style="color: var(--text)">Checks</strong>
1049 <span style="margin-left: 8px; color: var(--text-muted)">
1050 {statusCombined.total} total —{" "}
1051 <span
1052 style={`color:${
1053 statusCombined.state === "success"
1054 ? "var(--green,#2ea043)"
1055 : statusCombined.state === "failure"
1056 ? "var(--red,#da3633)"
1057 : "var(--yellow,#d29922)"
1058 }`}
1059 >
1060 {statusCombined.state}
1061 </span>
1062 </span>
1063 <div style="margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px">
1064 {statusCombined.contexts.map((cx) => (
1065 <span
1066 style={`font-size:11px;padding:2px 6px;border-radius:3px;color:#fff;background:${
1067 cx.state === "success"
1068 ? "var(--green,#2ea043)"
1069 : cx.state === "pending"
1070 ? "var(--yellow,#d29922)"
1071 : "var(--red,#da3633)"
1072 }`}
1073 title={cx.description || cx.context}
1074 >
1075 {cx.targetUrl ? (
1076 <a
1077 href={cx.targetUrl}
1078 style="color: inherit; text-decoration: none"
1079 rel="noopener"
1080 >
1081 {cx.context}: {cx.state}
1082 </a>
1083 ) : (
1084 <>
1085 {cx.context}: {cx.state}
1086 </>
1087 )}
1088 </span>
1089 ))}
1090 </div>
1091 </div>
1092 )}
fc1817aClaude1093 </div>
1094 <DiffView raw={raw} files={files} />
1095 </Layout>
1096 );
1097});
1098
79136bbClaude1099// Raw file download
1100web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
1101 const { owner, repo } = c.req.param();
1102 const refAndPath = c.req.param("ref");
1103
1104 const branches = await listBranches(owner, repo);
1105 let ref = "";
1106 let filePath = "";
1107
1108 for (const branch of branches) {
1109 if (refAndPath.startsWith(branch + "/")) {
1110 ref = branch;
1111 filePath = refAndPath.slice(branch.length + 1);
1112 break;
1113 }
1114 }
1115
1116 if (!ref) {
1117 const slashIdx = refAndPath.indexOf("/");
1118 if (slashIdx === -1) return c.text("Not found", 404);
1119 ref = refAndPath.slice(0, slashIdx);
1120 filePath = refAndPath.slice(slashIdx + 1);
1121 }
1122
1123 const data = await getRawBlob(owner, repo, ref, filePath);
1124 if (!data) return c.text("Not found", 404);
1125
1126 const fileName = filePath.split("/").pop() || "file";
772a24fClaude1127 return new Response(data as BodyInit, {
79136bbClaude1128 headers: {
1129 "Content-Type": "application/octet-stream",
1130 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude1131 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude1132 },
1133 });
1134});
1135
1136// Blame view
1137web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
1138 const { owner, repo } = c.req.param();
1139 const user = c.get("user");
1140 const refAndPath = c.req.param("ref");
1141
1142 const branches = await listBranches(owner, repo);
1143 let ref = "";
1144 let filePath = "";
1145
1146 for (const branch of branches) {
1147 if (refAndPath.startsWith(branch + "/")) {
1148 ref = branch;
1149 filePath = refAndPath.slice(branch.length + 1);
1150 break;
1151 }
1152 }
1153
1154 if (!ref) {
1155 const slashIdx = refAndPath.indexOf("/");
1156 if (slashIdx === -1) return c.text("Not found", 404);
1157 ref = refAndPath.slice(0, slashIdx);
1158 filePath = refAndPath.slice(slashIdx + 1);
1159 }
1160
1161 const blameLines = await getBlame(owner, repo, ref, filePath);
1162 if (blameLines.length === 0) {
1163 return c.html(
1164 <Layout title="Not Found" user={user}>
1165 <div class="empty-state">
1166 <h2>File not found</h2>
1167 </div>
1168 </Layout>,
1169 404
1170 );
1171 }
1172
1173 const fileName = filePath.split("/").pop() || filePath;
1174
1175 return c.html(
1176 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
1177 <RepoHeader owner={owner} repo={repo} />
1178 <RepoNav owner={owner} repo={repo} active="code" />
1179 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
1180 <div class="blob-view">
1181 <div class="blob-header">
1182 <span>{fileName} — blame</span>
1183 <a href={`/${owner}/${repo}/blob/${ref}/${filePath}`} style="font-size: 12px">
1184 Normal view
1185 </a>
1186 </div>
1187 <div class="blob-code" style="overflow-x: auto">
1188 <table style="width: 100%; border-collapse: collapse; font-size: 13px; font-family: var(--font-mono)">
1189 <tbody>
1190 {blameLines.map((line, i) => {
1191 const showInfo =
1192 i === 0 || blameLines[i - 1].sha !== line.sha;
1193 return (
1194 <tr style="border-bottom: 1px solid var(--border)">
1195 <td
1196 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)" : ""}`}
1197 >
1198 {showInfo && (
1199 <>
1200 <a
1201 href={`/${owner}/${repo}/commit/${line.sha}`}
1202 style="color: var(--text-link); font-family: var(--font-mono)"
1203 >
1204 {line.sha.slice(0, 7)}
1205 </a>{" "}
1206 <span>{line.author}</span>
1207 </>
1208 )}
1209 </td>
1210 <td class="line-num">{line.lineNum}</td>
1211 <td class="line-content">{line.content}</td>
1212 </tr>
1213 );
1214 })}
1215 </tbody>
1216 </table>
1217 </div>
1218 </div>
1219 </Layout>
1220 );
1221});
1222
1223// Search
1224web.get("/:owner/:repo/search", async (c) => {
1225 const { owner, repo } = c.req.param();
1226 const user = c.get("user");
1227 const q = c.req.query("q") || "";
1228
1229 if (!(await repoExists(owner, repo))) return c.notFound();
1230
1231 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
1232 let results: Array<{ file: string; lineNum: number; line: string }> = [];
1233
1234 if (q.trim()) {
1235 results = await searchCode(owner, repo, defaultBranch, q.trim());
1236 }
1237
1238 return c.html(
1239 <Layout title={`Search — ${owner}/${repo}`} user={user}>
1240 <RepoHeader owner={owner} repo={repo} />
1241 <RepoNav owner={owner} repo={repo} active="code" />
1242 <form
001af43Claude1243 method="get"
79136bbClaude1244 action={`/${owner}/${repo}/search`}
1245 style="margin-bottom: 20px"
1246 >
1247 <div style="display: flex; gap: 8px">
1248 <input
1249 type="text"
1250 name="q"
1251 value={q}
1252 placeholder="Search code..."
2c3ba6ecopilot-swe-agent[bot]1253 aria-label="Search code"
79136bbClaude1254 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
1255 />
1256 <button type="submit" class="btn btn-primary">
1257 Search
1258 </button>
1259 </div>
1260 </form>
1261 {q && (
1262 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 16px">
1263 {results.length} result{results.length !== 1 ? "s" : ""} for{" "}
1264 <strong style="color: var(--text)">"{q}"</strong>
1265 </p>
1266 )}
1267 {results.length > 0 && (
1268 <div class="search-results">
1269 {(() => {
1270 // Group by file
1271 const grouped: Record<
1272 string,
1273 Array<{ lineNum: number; line: string }>
1274 > = {};
1275 for (const r of results) {
1276 if (!grouped[r.file]) grouped[r.file] = [];
1277 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
1278 }
1279 return Object.entries(grouped).map(([file, matches]) => (
1280 <div class="diff-file" style="margin-bottom: 12px">
1281 <div class="diff-file-header">
1282 <a
1283 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
1284 >
1285 {file}
1286 </a>
1287 </div>
1288 <div class="blob-code">
1289 <table>
1290 <tbody>
1291 {matches.map((m) => (
1292 <tr>
1293 <td class="line-num">{m.lineNum}</td>
1294 <td class="line-content">{m.line}</td>
1295 </tr>
1296 ))}
1297 </tbody>
1298 </table>
1299 </div>
1300 </div>
1301 ));
1302 })()}
1303 </div>
1304 )}
1305 </Layout>
1306 );
1307});
1308
fc1817aClaude1309export default web;