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.tsxBlame1241 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";
2b821b7Claude51import { LandingPage } from "../views/landing";
fc1817aClaude52
06d5ffeClaude53const web = new Hono<AuthEnv>();
54
55// Soft auth on all web routes — c.get("user") available but may be null
56web.use("*", softAuth);
fc1817aClaude57
58// Home page
06d5ffeClaude59web.get("/", async (c) => {
60 const user = c.get("user");
61
62 if (user) {
0316dbbClaude63 return c.redirect("/dashboard");
06d5ffeClaude64 }
65
8e9f1d9Claude66 let stats: { publicRepos?: number; users?: number } | undefined;
67 try {
68 const [repoRow] = await db
69 .select({ n: sql<number>`count(*)::int` })
70 .from(repositories)
71 .where(eq(repositories.isPrivate, false));
72 const [userRow] = await db
73 .select({ n: sql<number>`count(*)::int` })
74 .from(users);
75 stats = {
76 publicRepos: Number(repoRow?.n ?? 0),
77 users: Number(userRow?.n ?? 0),
78 };
79 } catch {
80 stats = undefined;
81 }
82
fc1817aClaude83 return c.html(
06d5ffeClaude84 <Layout user={null}>
8e9f1d9Claude85 <LandingPage stats={stats} />
fc1817aClaude86 </Layout>
87 );
88});
89
06d5ffeClaude90// New repository form
91web.get("/new", requireAuth, (c) => {
92 const user = c.get("user")!;
93 const error = c.req.query("error");
94
95 return c.html(
96 <Layout title="New repository" user={user}>
97 <div class="new-repo-form">
98 <h2>Create a new repository</h2>
99 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
001af43Claude100 <form method="post" action="/new">
06d5ffeClaude101 <div class="form-group">
102 <label>Owner</label>
2c3ba6ecopilot-swe-agent[bot]103 <input type="text" value={user.username} disabled aria-label="Owner" class="input-disabled" />
06d5ffeClaude104 </div>
105 <div class="form-group">
106 <label for="name">Repository name</label>
107 <input
108 type="text"
109 id="name"
110 name="name"
111 required
112 pattern="^[a-zA-Z0-9._-]+$"
113 placeholder="my-project"
114 autocomplete="off"
115 />
116 </div>
117 <div class="form-group">
118 <label for="description">Description (optional)</label>
119 <input
120 type="text"
121 id="description"
122 name="description"
123 placeholder="A short description of your repository"
124 />
125 </div>
126 <div class="visibility-options">
127 <label class="visibility-option">
128 <input type="radio" name="visibility" value="public" checked />
129 <div class="vis-label">Public</div>
130 <div class="vis-desc">Anyone can see this repository</div>
131 </label>
132 <label class="visibility-option">
133 <input type="radio" name="visibility" value="private" />
134 <div class="vis-label">Private</div>
135 <div class="vis-desc">Only you can see this repository</div>
136 </label>
137 </div>
138 <button type="submit" class="btn btn-primary">
139 Create repository
140 </button>
141 </form>
142 </div>
143 </Layout>
144 );
145});
146
147web.post("/new", requireAuth, async (c) => {
148 const user = c.get("user")!;
149 const body = await c.req.parseBody();
150 const name = String(body.name || "").trim();
151 const description = String(body.description || "").trim();
152 const isPrivate = body.visibility === "private";
153
154 if (!name) {
155 return c.redirect("/new?error=Repository+name+is+required");
156 }
157
158 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
159 return c.redirect("/new?error=Invalid+repository+name");
160 }
161
162 if (await repoExists(user.username, name)) {
163 return c.redirect("/new?error=Repository+already+exists");
164 }
165
166 const diskPath = await initBareRepo(user.username, name);
167
3ef4c9dClaude168 const [newRepo] = await db
169 .insert(repositories)
170 .values({
171 name,
172 ownerId: user.id,
173 description: description || null,
174 isPrivate,
175 diskPath,
176 })
177 .returning();
178
179 if (newRepo) {
180 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
181 await bootstrapRepository({
182 repositoryId: newRepo.id,
183 ownerUserId: user.id,
184 defaultBranch: "main",
185 });
186 }
06d5ffeClaude187
188 return c.redirect(`/${user.username}/${name}`);
189});
190
191// User profile
fc1817aClaude192web.get("/:owner", async (c) => {
06d5ffeClaude193 const { owner: ownerName } = c.req.param();
194 const user = c.get("user");
195
196 // Avoid clashing with fixed routes
197 if (
198 ["login", "register", "logout", "new", "settings", "api"].includes(
199 ownerName
200 )
201 ) {
202 return c.notFound();
203 }
204
205 let ownerUser;
206 try {
207 const [found] = await db
208 .select()
209 .from(users)
210 .where(eq(users.username, ownerName))
211 .limit(1);
212 ownerUser = found;
213 } catch {
214 // DB not available — check if repos exist on disk
215 ownerUser = null;
216 }
217
218 // Even without DB, show repos if they exist on disk
219 let repos: any[] = [];
220 if (ownerUser) {
221 const allRepos = await db
222 .select()
223 .from(repositories)
224 .where(eq(repositories.ownerId, ownerUser.id))
225 .orderBy(desc(repositories.updatedAt));
226
227 // Show public repos to everyone, private only to owner
228 repos =
229 user?.id === ownerUser.id
230 ? allRepos
231 : allRepos.filter((r) => !r.isPrivate);
232 }
233
7aa8b99Claude234 // Block J4 — follow counts + viewer's follow state
235 let followState = {
236 followers: 0,
237 following: 0,
238 viewerFollows: false,
239 };
240 if (ownerUser) {
241 try {
242 const { followCounts, isFollowing } = await import("../lib/follows");
243 const counts = await followCounts(ownerUser.id);
244 followState.followers = counts.followers;
245 followState.following = counts.following;
246 if (user && user.id !== ownerUser.id) {
247 followState.viewerFollows = await isFollowing(user.id, ownerUser.id);
248 }
249 } catch {
250 // DB hiccup — fall back to zeros.
251 }
252 }
253 const canFollow = !!user && !!ownerUser && user.id !== ownerUser.id;
254
d412586Claude255 // Block J5 — profile README. Render owner/owner repo's README on the
256 // profile page (GitHub convention). Tries "<user>/<user>" first, falling
257 // back to "<user>/.github" for org-style profile repos.
258 let profileReadmeHtml: string | null = null;
259 try {
260 const candidates = [ownerName, ".github"];
261 for (const rname of candidates) {
262 if (await repoExists(ownerName, rname)) {
263 const ref = (await getDefaultBranch(ownerName, rname)) || "main";
264 const md = await getReadme(ownerName, rname, ref);
265 if (md) {
266 profileReadmeHtml = renderMarkdown(md);
267 break;
268 }
269 }
270 }
271 } catch {
272 profileReadmeHtml = null;
273 }
274
fc1817aClaude275 return c.html(
06d5ffeClaude276 <Layout title={ownerName} user={user}>
277 <div class="user-profile">
278 <div class="user-avatar">
279 {(ownerUser?.displayName || ownerName)[0].toUpperCase()}
280 </div>
281 <div class="user-info">
282 <h2>{ownerUser?.displayName || ownerName}</h2>
283 <div class="username">@{ownerName}</div>
284 {ownerUser?.bio && <div class="bio">{ownerUser.bio}</div>}
7aa8b99Claude285 <div
286 style="margin-top:8px;display:flex;gap:12px;align-items:center;flex-wrap:wrap;font-size:13px"
287 >
288 <a
289 href={`/${ownerName}/followers`}
290 style="color:var(--text-muted)"
291 >
292 <strong style="color:var(--text)">
293 {followState.followers}
294 </strong>{" "}
295 follower{followState.followers === 1 ? "" : "s"}
296 </a>
297 <a
298 href={`/${ownerName}/following`}
299 style="color:var(--text-muted)"
300 >
301 <strong style="color:var(--text)">
302 {followState.following}
303 </strong>{" "}
304 following
305 </a>
306 {canFollow && (
307 <form
001af43Claude308 method="post"
7aa8b99Claude309 action={`/${ownerName}/${
310 followState.viewerFollows ? "unfollow" : "follow"
311 }`}
312 >
313 <button
314 type="submit"
315 class={`btn ${
316 followState.viewerFollows ? "" : "btn-primary"
317 } btn-sm`}
318 >
319 {followState.viewerFollows ? "Unfollow" : "Follow"}
320 </button>
321 </form>
322 )}
323 </div>
06d5ffeClaude324 </div>
325 </div>
d412586Claude326 {profileReadmeHtml && (
327 <div
328 class="markdown-body"
329 style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:20px 24px;margin-bottom:24px"
330 dangerouslySetInnerHTML={{ __html: profileReadmeHtml }}
331 />
332 )}
06d5ffeClaude333 <h3 style="margin-bottom: 16px">Repositories</h3>
334 {repos.length === 0 ? (
335 <p style="color: var(--text-muted)">No repositories yet.</p>
336 ) : (
337 <div class="card-grid">
338 {repos.map((repo) => (
339 <RepoCard repo={repo} ownerName={ownerName} />
340 ))}
341 </div>
342 )}
fc1817aClaude343 </Layout>
344 );
345});
346
06d5ffeClaude347// Star/unstar a repo
348web.post("/:owner/:repo/star", requireAuth, async (c) => {
349 const { owner: ownerName, repo: repoName } = c.req.param();
350 const user = c.get("user")!;
351
352 try {
353 const [ownerUser] = await db
354 .select()
355 .from(users)
356 .where(eq(users.username, ownerName))
357 .limit(1);
358 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
359
360 const [repo] = await db
361 .select()
362 .from(repositories)
363 .where(
364 and(
365 eq(repositories.ownerId, ownerUser.id),
366 eq(repositories.name, repoName)
367 )
368 )
369 .limit(1);
370 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
371
372 // Toggle star
373 const [existing] = await db
374 .select()
375 .from(stars)
376 .where(
377 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
378 )
379 .limit(1);
380
381 if (existing) {
382 await db.delete(stars).where(eq(stars.id, existing.id));
383 await db
384 .update(repositories)
385 .set({ starCount: Math.max(0, repo.starCount - 1) })
386 .where(eq(repositories.id, repo.id));
387 } else {
388 await db.insert(stars).values({
389 userId: user.id,
390 repositoryId: repo.id,
391 });
392 await db
393 .update(repositories)
394 .set({ starCount: repo.starCount + 1 })
395 .where(eq(repositories.id, repo.id));
396 }
397 } catch {
398 // DB error — ignore
399 }
400
401 return c.redirect(`/${ownerName}/${repoName}`);
402});
403
fc1817aClaude404// Repository overview — file tree at HEAD
405web.get("/:owner/:repo", async (c) => {
406 const { owner, repo } = c.req.param();
06d5ffeClaude407 const user = c.get("user");
fc1817aClaude408
8f50ed0Claude409 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
410 trackByName(owner, repo, "view", {
411 userId: user?.id || null,
412 path: `/${owner}/${repo}`,
413 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
414 userAgent: c.req.header("user-agent") || null,
415 referer: c.req.header("referer") || null,
416 }).catch(() => {});
417
fc1817aClaude418 if (!(await repoExists(owner, repo))) {
419 return c.html(
06d5ffeClaude420 <Layout title="Not Found" user={user}>
fc1817aClaude421 <div class="empty-state">
422 <h2>Repository not found</h2>
423 <p>
424 {owner}/{repo} does not exist.
425 </p>
426 </div>
427 </Layout>,
428 404
429 );
430 }
431
05b973eClaude432 // Parallelize all independent operations
433 const [defaultBranch, branches] = await Promise.all([
434 getDefaultBranch(owner, repo).then((b) => b || "main"),
435 listBranches(owner, repo),
436 ]);
437 const [tree, starInfo] = await Promise.all([
438 getTree(owner, repo, defaultBranch),
439 // Star info fetched in parallel with tree
440 (async () => {
441 try {
442 const [ownerUser] = await db
443 .select()
444 .from(users)
445 .where(eq(users.username, owner))
446 .limit(1);
71cd5ecClaude447 if (!ownerUser)
448 return {
449 starCount: 0,
450 starred: false,
451 archived: false,
452 isTemplate: false,
453 };
05b973eClaude454 const [repoRow] = await db
455 .select()
456 .from(repositories)
457 .where(
458 and(
459 eq(repositories.ownerId, ownerUser.id),
460 eq(repositories.name, repo)
461 )
06d5ffeClaude462 )
05b973eClaude463 .limit(1);
71cd5ecClaude464 if (!repoRow)
465 return {
466 starCount: 0,
467 starred: false,
468 archived: false,
469 isTemplate: false,
470 };
05b973eClaude471 let starred = false;
06d5ffeClaude472 if (user) {
473 const [star] = await db
474 .select()
475 .from(stars)
476 .where(
477 and(
478 eq(stars.userId, user.id),
479 eq(stars.repositoryId, repoRow.id)
480 )
481 )
482 .limit(1);
483 starred = !!star;
484 }
71cd5ecClaude485 return {
486 starCount: repoRow.starCount,
487 starred,
488 archived: repoRow.isArchived,
489 isTemplate: repoRow.isTemplate,
490 };
05b973eClaude491 } catch {
71cd5ecClaude492 return {
493 starCount: 0,
494 starred: false,
495 archived: false,
496 isTemplate: false,
497 };
06d5ffeClaude498 }
05b973eClaude499 })(),
500 ]);
71cd5ecClaude501 const { starCount, starred, archived, isTemplate } = starInfo;
06d5ffeClaude502
fc1817aClaude503 if (tree.length === 0) {
504 return c.html(
06d5ffeClaude505 <Layout title={`${owner}/${repo}`} user={user}>
506 <RepoHeader
507 owner={owner}
508 repo={repo}
509 starCount={starCount}
510 starred={starred}
511 currentUser={user?.username}
71cd5ecClaude512 archived={archived}
513 isTemplate={isTemplate}
06d5ffeClaude514 />
fc1817aClaude515 <RepoNav owner={owner} repo={repo} active="code" />
516 <div class="empty-state">
517 <h2>Empty repository</h2>
518 <p>Get started by pushing code:</p>
ea52715copilot-swe-agent[bot]519 <pre>{`git remote add gluecron ${config.appBaseUrl}/${owner}/${repo}.git
fc1817aClaude520git push -u gluecron main`}</pre>
521 </div>
522 </Layout>
523 );
524 }
525
526 const readme = await getReadme(owner, repo, defaultBranch);
527
528 return c.html(
06d5ffeClaude529 <Layout title={`${owner}/${repo}`} user={user}>
530 <RepoHeader
531 owner={owner}
532 repo={repo}
533 starCount={starCount}
534 starred={starred}
535 currentUser={user?.username}
71cd5ecClaude536 archived={archived}
537 isTemplate={isTemplate}
06d5ffeClaude538 />
71cd5ecClaude539 {isTemplate && user && user.username !== owner && (
540 <div
541 class="panel"
542 style="margin-bottom:16px;padding:12px;display:flex;align-items:center;justify-content:space-between;gap:12px"
543 >
544 <div style="font-size:13px">
545 <strong>Template repository.</strong> Create a new repository from
546 this template's files.
547 </div>
548 <form
001af43Claude549 method="post"
71cd5ecClaude550 action={`/${owner}/${repo}/use-template`}
551 style="display:flex;gap:8px;align-items:center"
552 >
553 <input
554 type="text"
555 name="name"
556 placeholder="new-repo-name"
557 required
2c3ba6ecopilot-swe-agent[bot]558 aria-label="New repository name"
71cd5ecClaude559 style="width:200px"
560 />
561 <button type="submit" class="btn btn-primary">
562 Use this template
563 </button>
564 </form>
565 </div>
566 )}
fc1817aClaude567 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude568 <BranchSwitcher
569 owner={owner}
570 repo={repo}
571 currentRef={defaultBranch}
572 branches={branches}
573 pathType="tree"
574 />
fc1817aClaude575 <FileTable
576 entries={tree}
577 owner={owner}
578 repo={repo}
579 ref={defaultBranch}
580 path=""
581 />
79136bbClaude582 {readme && (() => {
583 const readmeHtml = renderMarkdown(readme);
584 return (
585 <div class="blob-view" style="margin-top: 20px">
586 <div class="blob-header">README.md</div>
587 <style>{markdownCss}</style>
588 <div class="markdown-body">
589 {html([readmeHtml] as unknown as TemplateStringsArray)}
590 </div>
fc1817aClaude591 </div>
79136bbClaude592 );
593 })()}
fc1817aClaude594 </Layout>
595 );
596});
597
598// Browse tree at ref/path
599web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
600 const { owner, repo } = c.req.param();
06d5ffeClaude601 const user = c.get("user");
fc1817aClaude602 const refAndPath = c.req.param("ref");
603
604 const branches = await listBranches(owner, repo);
605 let ref = "";
606 let treePath = "";
607
608 for (const branch of branches) {
609 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
610 ref = branch;
611 treePath = refAndPath.slice(branch.length + 1);
612 break;
613 }
614 }
615
616 if (!ref) {
617 const slashIdx = refAndPath.indexOf("/");
618 if (slashIdx === -1) {
619 ref = refAndPath;
620 } else {
621 ref = refAndPath.slice(0, slashIdx);
622 treePath = refAndPath.slice(slashIdx + 1);
623 }
624 }
625
626 const tree = await getTree(owner, repo, ref, treePath);
627
628 return c.html(
06d5ffeClaude629 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
fc1817aClaude630 <RepoHeader owner={owner} repo={repo} />
631 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude632 <BranchSwitcher
633 owner={owner}
634 repo={repo}
635 currentRef={ref}
636 branches={branches}
637 pathType="tree"
638 subPath={treePath}
639 />
fc1817aClaude640 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
641 <FileTable
642 entries={tree}
643 owner={owner}
644 repo={repo}
645 ref={ref}
646 path={treePath}
647 />
648 </Layout>
649 );
650});
651
06d5ffeClaude652// View file blob with syntax highlighting
fc1817aClaude653web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
654 const { owner, repo } = c.req.param();
06d5ffeClaude655 const user = c.get("user");
fc1817aClaude656 const refAndPath = c.req.param("ref");
657
658 const branches = await listBranches(owner, repo);
659 let ref = "";
660 let filePath = "";
661
662 for (const branch of branches) {
663 if (refAndPath.startsWith(branch + "/")) {
664 ref = branch;
665 filePath = refAndPath.slice(branch.length + 1);
666 break;
667 }
668 }
669
670 if (!ref) {
671 const slashIdx = refAndPath.indexOf("/");
672 if (slashIdx === -1) return c.text("Not found", 404);
673 ref = refAndPath.slice(0, slashIdx);
674 filePath = refAndPath.slice(slashIdx + 1);
675 }
676
677 const blob = await getBlob(owner, repo, ref, filePath);
678 if (!blob) {
679 return c.html(
06d5ffeClaude680 <Layout title="Not Found" user={user}>
fc1817aClaude681 <div class="empty-state">
682 <h2>File not found</h2>
683 </div>
684 </Layout>,
685 404
686 );
687 }
688
06d5ffeClaude689 const fileName = filePath.split("/").pop() || filePath;
fc1817aClaude690
691 return c.html(
06d5ffeClaude692 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
fc1817aClaude693 <RepoHeader owner={owner} repo={repo} />
694 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude695 <BranchSwitcher
696 owner={owner}
697 repo={repo}
698 currentRef={ref}
699 branches={branches}
700 pathType="blob"
701 subPath={filePath}
702 />
fc1817aClaude703 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
704 <div class="blob-view">
705 <div class="blob-header">
06d5ffeClaude706 <span>{fileName} — {blob.size} bytes</span>
79136bbClaude707 <span style="display: flex; gap: 12px">
708 <a href={`/${owner}/${repo}/raw/${ref}/${filePath}`} style="font-size: 12px">
709 Raw
710 </a>
711 <a href={`/${owner}/${repo}/blame/${ref}/${filePath}`} style="font-size: 12px">
712 Blame
713 </a>
16b325cClaude714 <a href={`/${owner}/${repo}/timeline/${ref}/${filePath}`} style="font-size: 12px">
715 History
716 </a>
0074234Claude717 {user && (
718 <a href={`/${owner}/${repo}/edit/${ref}/${filePath}`} style="font-size: 12px">
719 Edit
720 </a>
721 )}
79136bbClaude722 </span>
fc1817aClaude723 </div>
724 {blob.isBinary ? (
725 <div style="padding: 16px; color: var(--text-muted)">
726 Binary file not shown.
727 </div>
06d5ffeClaude728 ) : (() => {
729 const { html: highlighted, language } = highlightCode(
730 blob.content,
731 fileName
732 );
733 const lineCount = blob.content.split("\n").length;
734 // Trim trailing newline from count
735 const adjustedCount =
736 blob.content.endsWith("\n") ? lineCount - 1 : lineCount;
737
738 if (language) {
739 return (
740 <HighlightedCode
741 highlightedHtml={highlighted}
742 lineCount={adjustedCount}
743 />
744 );
745 }
746 const lines = blob.content.split("\n");
747 if (lines[lines.length - 1] === "") lines.pop();
748 return <PlainCode lines={lines} />;
749 })()}
fc1817aClaude750 </div>
751 </Layout>
752 );
753});
754
755// Commit log
756web.get("/:owner/:repo/commits/:ref?", async (c) => {
757 const { owner, repo } = c.req.param();
06d5ffeClaude758 const user = c.get("user");
fc1817aClaude759 const ref =
760 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude761 const branches = await listBranches(owner, repo);
fc1817aClaude762
763 const commits = await listCommits(owner, repo, ref, 50);
764
3951454Claude765 // Block J3 — batch-fetch cached verification results for the page.
766 let verifications: Record<string, { verified: boolean; reason: string }> = {};
767 try {
768 const [ownerRow] = await db
769 .select()
770 .from(users)
771 .where(eq(users.username, owner))
772 .limit(1);
773 if (ownerRow) {
774 const [repoRow] = await db
775 .select()
776 .from(repositories)
777 .where(
778 and(
779 eq(repositories.ownerId, ownerRow.id),
780 eq(repositories.name, repo)
781 )
782 )
783 .limit(1);
784 if (repoRow && commits.length > 0) {
785 const rows = await db
786 .select()
787 .from(commitVerifications)
788 .where(
789 and(
790 eq(commitVerifications.repositoryId, repoRow.id),
791 inArray(
792 commitVerifications.commitSha,
793 commits.map((c) => c.sha)
794 )
795 )
796 );
797 for (const r of rows) {
798 verifications[r.commitSha] = {
799 verified: r.verified,
800 reason: r.reason,
801 };
802 }
803 }
804 }
805 } catch {
806 // DB unavailable — skip the badges gracefully.
807 }
808
fc1817aClaude809 return c.html(
06d5ffeClaude810 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
fc1817aClaude811 <RepoHeader owner={owner} repo={repo} />
812 <RepoNav owner={owner} repo={repo} active="commits" />
06d5ffeClaude813 <BranchSwitcher
814 owner={owner}
815 repo={repo}
816 currentRef={ref}
817 branches={branches}
818 pathType="commits"
819 />
fc1817aClaude820 {commits.length === 0 ? (
821 <div class="empty-state">
822 <p>No commits yet.</p>
823 </div>
824 ) : (
3951454Claude825 <CommitList
826 commits={commits}
827 owner={owner}
828 repo={repo}
829 verifications={verifications}
830 />
fc1817aClaude831 )}
832 </Layout>
833 );
834});
835
836// Single commit with diff
837web.get("/:owner/:repo/commit/:sha", async (c) => {
838 const { owner, repo, sha } = c.req.param();
06d5ffeClaude839 const user = c.get("user");
fc1817aClaude840
05b973eClaude841 // Fetch commit, full message, and diff in parallel
842 const [commit, fullMessage, diffResult] = await Promise.all([
843 getCommit(owner, repo, sha),
844 getCommitFullMessage(owner, repo, sha),
845 getDiff(owner, repo, sha),
846 ]);
fc1817aClaude847 if (!commit) {
848 return c.html(
06d5ffeClaude849 <Layout title="Not Found" user={user}>
fc1817aClaude850 <div class="empty-state">
851 <h2>Commit not found</h2>
852 </div>
853 </Layout>,
854 404
855 );
856 }
857
3951454Claude858 // Block J3 — try to verify this commit's signature.
859 let verification:
860 | { verified: boolean; reason: string; signatureType: string | null }
861 | null = null;
0cdfd89Claude862 // Block J8 — external CI commit statuses rollup.
863 let statusCombined:
864 | {
865 state: "pending" | "success" | "failure";
866 total: number;
867 contexts: Array<{
868 context: string;
869 state: string;
870 description: string | null;
871 targetUrl: string | null;
872 }>;
873 }
874 | null = null;
3951454Claude875 try {
876 const [ownerRow] = await db
877 .select()
878 .from(users)
879 .where(eq(users.username, owner))
880 .limit(1);
881 if (ownerRow) {
882 const [repoRow] = await db
883 .select()
884 .from(repositories)
885 .where(
886 and(
887 eq(repositories.ownerId, ownerRow.id),
888 eq(repositories.name, repo)
889 )
890 )
891 .limit(1);
892 if (repoRow) {
893 const { verifyCommit } = await import("../lib/signatures");
894 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
895 verification = {
896 verified: v.verified,
897 reason: v.reason,
898 signatureType: v.signatureType,
899 };
0cdfd89Claude900 try {
901 const { combinedStatus } = await import("../lib/commit-statuses");
902 const combined = await combinedStatus(repoRow.id, commit.sha);
903 if (combined.total > 0) {
904 statusCombined = {
905 state: combined.state as any,
906 total: combined.total,
907 contexts: combined.contexts.map((c) => ({
908 context: c.context,
909 state: c.state,
910 description: c.description,
911 targetUrl: c.targetUrl,
912 })),
913 };
914 }
915 } catch {
916 statusCombined = null;
917 }
3951454Claude918 }
919 }
920 } catch {
921 verification = null;
922 }
923
05b973eClaude924 const { files, raw } = diffResult;
fc1817aClaude925
926 return c.html(
06d5ffeClaude927 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
fc1817aClaude928 <RepoHeader owner={owner} repo={repo} />
929 <div
930 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 20px"
931 >
932 <div style="font-size: 18px; font-weight: 600; margin-bottom: 8px">
933 {commit.message}
934 </div>
935 {fullMessage !== commit.message && (
936 <div style="white-space: pre-wrap; color: var(--text-muted); font-size: 14px; margin-bottom: 12px">
937 {fullMessage}
938 </div>
939 )}
940 <div style="font-size: 13px; color: var(--text-muted)">
941 <strong style="color: var(--text)">{commit.author}</strong>{" "}
942 committed on{" "}
943 {new Date(commit.date).toLocaleDateString("en-US", {
944 month: "long",
945 day: "numeric",
946 year: "numeric",
947 })}
3951454Claude948 {verification && verification.reason !== "unsigned" && (
949 <span
950 style={`margin-left:10px;font-size:10px;padding:1px 6px;border-radius:3px;text-transform:uppercase;letter-spacing:.4px;color:#fff;background:${
951 verification.verified
952 ? "var(--green,#2ea043)"
953 : "var(--yellow,#d29922)"
954 }`}
955 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
956 >
957 {verification.verified ? "Verified" : verification.reason}
958 </span>
959 )}
fc1817aClaude960 </div>
961 <div style="margin-top: 8px">
962 <span class="commit-sha">{commit.sha}</span>
963 {commit.parentShas.length > 0 && (
964 <span style="margin-left: 12px; font-size: 13px; color: var(--text-muted)">
965 Parent:{" "}
966 {commit.parentShas.map((p) => (
967 <a
968 href={`/${owner}/${repo}/commit/${p}`}
969 class="commit-sha"
970 style="margin-left: 4px"
971 >
972 {p.slice(0, 7)}
973 </a>
974 ))}
975 </span>
976 )}
977 </div>
0cdfd89Claude978 {statusCombined && (
979 <div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); font-size: 13px">
980 <strong style="color: var(--text)">Checks</strong>
981 <span style="margin-left: 8px; color: var(--text-muted)">
982 {statusCombined.total} total —{" "}
983 <span
984 style={`color:${
985 statusCombined.state === "success"
986 ? "var(--green,#2ea043)"
987 : statusCombined.state === "failure"
988 ? "var(--red,#da3633)"
989 : "var(--yellow,#d29922)"
990 }`}
991 >
992 {statusCombined.state}
993 </span>
994 </span>
995 <div style="margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px">
996 {statusCombined.contexts.map((cx) => (
997 <span
998 style={`font-size:11px;padding:2px 6px;border-radius:3px;color:#fff;background:${
999 cx.state === "success"
1000 ? "var(--green,#2ea043)"
1001 : cx.state === "pending"
1002 ? "var(--yellow,#d29922)"
1003 : "var(--red,#da3633)"
1004 }`}
1005 title={cx.description || cx.context}
1006 >
1007 {cx.targetUrl ? (
1008 <a
1009 href={cx.targetUrl}
1010 style="color: inherit; text-decoration: none"
1011 rel="noopener"
1012 >
1013 {cx.context}: {cx.state}
1014 </a>
1015 ) : (
1016 <>
1017 {cx.context}: {cx.state}
1018 </>
1019 )}
1020 </span>
1021 ))}
1022 </div>
1023 </div>
1024 )}
fc1817aClaude1025 </div>
1026 <DiffView raw={raw} files={files} />
1027 </Layout>
1028 );
1029});
1030
79136bbClaude1031// Raw file download
1032web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
1033 const { owner, repo } = c.req.param();
1034 const refAndPath = c.req.param("ref");
1035
1036 const branches = await listBranches(owner, repo);
1037 let ref = "";
1038 let filePath = "";
1039
1040 for (const branch of branches) {
1041 if (refAndPath.startsWith(branch + "/")) {
1042 ref = branch;
1043 filePath = refAndPath.slice(branch.length + 1);
1044 break;
1045 }
1046 }
1047
1048 if (!ref) {
1049 const slashIdx = refAndPath.indexOf("/");
1050 if (slashIdx === -1) return c.text("Not found", 404);
1051 ref = refAndPath.slice(0, slashIdx);
1052 filePath = refAndPath.slice(slashIdx + 1);
1053 }
1054
1055 const data = await getRawBlob(owner, repo, ref, filePath);
1056 if (!data) return c.text("Not found", 404);
1057
1058 const fileName = filePath.split("/").pop() || "file";
772a24fClaude1059 return new Response(data as BodyInit, {
79136bbClaude1060 headers: {
1061 "Content-Type": "application/octet-stream",
1062 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude1063 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude1064 },
1065 });
1066});
1067
1068// Blame view
1069web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
1070 const { owner, repo } = c.req.param();
1071 const user = c.get("user");
1072 const refAndPath = c.req.param("ref");
1073
1074 const branches = await listBranches(owner, repo);
1075 let ref = "";
1076 let filePath = "";
1077
1078 for (const branch of branches) {
1079 if (refAndPath.startsWith(branch + "/")) {
1080 ref = branch;
1081 filePath = refAndPath.slice(branch.length + 1);
1082 break;
1083 }
1084 }
1085
1086 if (!ref) {
1087 const slashIdx = refAndPath.indexOf("/");
1088 if (slashIdx === -1) return c.text("Not found", 404);
1089 ref = refAndPath.slice(0, slashIdx);
1090 filePath = refAndPath.slice(slashIdx + 1);
1091 }
1092
1093 const blameLines = await getBlame(owner, repo, ref, filePath);
1094 if (blameLines.length === 0) {
1095 return c.html(
1096 <Layout title="Not Found" user={user}>
1097 <div class="empty-state">
1098 <h2>File not found</h2>
1099 </div>
1100 </Layout>,
1101 404
1102 );
1103 }
1104
1105 const fileName = filePath.split("/").pop() || filePath;
1106
1107 return c.html(
1108 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
1109 <RepoHeader owner={owner} repo={repo} />
1110 <RepoNav owner={owner} repo={repo} active="code" />
1111 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
1112 <div class="blob-view">
1113 <div class="blob-header">
1114 <span>{fileName} — blame</span>
1115 <a href={`/${owner}/${repo}/blob/${ref}/${filePath}`} style="font-size: 12px">
1116 Normal view
1117 </a>
1118 </div>
1119 <div class="blob-code" style="overflow-x: auto">
1120 <table style="width: 100%; border-collapse: collapse; font-size: 13px; font-family: var(--font-mono)">
1121 <tbody>
1122 {blameLines.map((line, i) => {
1123 const showInfo =
1124 i === 0 || blameLines[i - 1].sha !== line.sha;
1125 return (
1126 <tr style="border-bottom: 1px solid var(--border)">
1127 <td
1128 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)" : ""}`}
1129 >
1130 {showInfo && (
1131 <>
1132 <a
1133 href={`/${owner}/${repo}/commit/${line.sha}`}
1134 style="color: var(--text-link); font-family: var(--font-mono)"
1135 >
1136 {line.sha.slice(0, 7)}
1137 </a>{" "}
1138 <span>{line.author}</span>
1139 </>
1140 )}
1141 </td>
1142 <td class="line-num">{line.lineNum}</td>
1143 <td class="line-content">{line.content}</td>
1144 </tr>
1145 );
1146 })}
1147 </tbody>
1148 </table>
1149 </div>
1150 </div>
1151 </Layout>
1152 );
1153});
1154
1155// Search
1156web.get("/:owner/:repo/search", async (c) => {
1157 const { owner, repo } = c.req.param();
1158 const user = c.get("user");
1159 const q = c.req.query("q") || "";
1160
1161 if (!(await repoExists(owner, repo))) return c.notFound();
1162
1163 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
1164 let results: Array<{ file: string; lineNum: number; line: string }> = [];
1165
1166 if (q.trim()) {
1167 results = await searchCode(owner, repo, defaultBranch, q.trim());
1168 }
1169
1170 return c.html(
1171 <Layout title={`Search — ${owner}/${repo}`} user={user}>
1172 <RepoHeader owner={owner} repo={repo} />
1173 <RepoNav owner={owner} repo={repo} active="code" />
1174 <form
001af43Claude1175 method="get"
79136bbClaude1176 action={`/${owner}/${repo}/search`}
1177 style="margin-bottom: 20px"
1178 >
1179 <div style="display: flex; gap: 8px">
1180 <input
1181 type="text"
1182 name="q"
1183 value={q}
1184 placeholder="Search code..."
2c3ba6ecopilot-swe-agent[bot]1185 aria-label="Search code"
79136bbClaude1186 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
1187 />
1188 <button type="submit" class="btn btn-primary">
1189 Search
1190 </button>
1191 </div>
1192 </form>
1193 {q && (
1194 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 16px">
1195 {results.length} result{results.length !== 1 ? "s" : ""} for{" "}
1196 <strong style="color: var(--text)">"{q}"</strong>
1197 </p>
1198 )}
1199 {results.length > 0 && (
1200 <div class="search-results">
1201 {(() => {
1202 // Group by file
1203 const grouped: Record<
1204 string,
1205 Array<{ lineNum: number; line: string }>
1206 > = {};
1207 for (const r of results) {
1208 if (!grouped[r.file]) grouped[r.file] = [];
1209 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
1210 }
1211 return Object.entries(grouped).map(([file, matches]) => (
1212 <div class="diff-file" style="margin-bottom: 12px">
1213 <div class="diff-file-header">
1214 <a
1215 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
1216 >
1217 {file}
1218 </a>
1219 </div>
1220 <div class="blob-code">
1221 <table>
1222 <tbody>
1223 {matches.map((m) => (
1224 <tr>
1225 <td class="line-num">{m.lineNum}</td>
1226 <td class="line-content">{m.line}</td>
1227 </tr>
1228 ))}
1229 </tbody>
1230 </table>
1231 </div>
1232 </div>
1233 ));
1234 })()}
1235 </div>
1236 )}
1237 </Layout>
1238 );
1239});
1240
fc1817aClaude1241export default web;