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

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

pulls.tsxBlame1551 lines · 1 contributor
0074234Claude1/**
2 * Pull request routes — create, list, view, merge, close, comment.
3 */
4
5import { Hono } from "hono";
6import { eq, and, desc, asc, sql } from "drizzle-orm";
7import { db } from "../db";
8import {
9 pullRequests,
10 prComments,
11 repositories,
12 users,
d62fb36Claude13 issues,
14 issueComments,
0074234Claude15} from "../db/schema";
3247f79Claude16import {
17 autoAssignFromCodeowners,
18 listForPr as listReviewRequestsForPr,
19 requestReviewers,
20 dismissRequest,
21} from "../lib/review-requests";
0074234Claude22import { Layout } from "../views/layout";
23import { RepoHeader, DiffView } from "../views/components";
6fc53bdClaude24import { ReactionsBar } from "../views/reactions";
25import { summariseReactions } from "../lib/reactions";
24cf2caClaude26import { loadPrTemplate } from "../lib/templates";
0074234Claude27import { renderMarkdown } from "../lib/markdown";
28import { softAuth, requireAuth } from "../middleware/auth";
29import type { AuthEnv } from "../middleware/auth";
30import {
31 listBranches,
32 getRepoPath,
e883329Claude33 resolveRef,
0074234Claude34} from "../git/repository";
35import type { GitDiffFile } from "../git/repository";
36import { html } from "hono/html";
e883329Claude37import { reviewDiff, isAiReviewEnabled } from "../lib/ai-review";
3cbe3d6Claude38import { triagePullRequest } from "../lib/ai-generators";
e883329Claude39import { mergeWithAutoResolve } from "../lib/merge-resolver";
40import { runAllGateChecks, type GateCheckResult } from "../lib/gate";
3cbe3d6Claude41import { labels as labelsTable } from "../db/schema";
1e162a8Claude42import {
43 matchProtection,
44 evaluateProtection,
45 countHumanApprovals,
a79a9edClaude46 listRequiredChecks,
47 passingCheckNames,
1e162a8Claude48} from "../lib/branch-protection";
0074234Claude49
50const pulls = new Hono<AuthEnv>();
51
52async function resolveRepo(ownerName: string, repoName: string) {
53 const [owner] = await db
54 .select()
55 .from(users)
56 .where(eq(users.username, ownerName))
57 .limit(1);
58 if (!owner) return null;
59 const [repo] = await db
60 .select()
61 .from(repositories)
62 .where(
63 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
64 )
65 .limit(1);
66 if (!repo) return null;
67 return { owner, repo };
68}
69
70// PR Nav helper
71const PrNav = ({
72 owner,
73 repo,
74 active,
75}: {
76 owner: string;
77 repo: string;
78 active: "code" | "issues" | "pulls" | "commits";
79}) => (
80 <div class="repo-nav">
81 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
82 Code
83 </a>
84 <a
85 href={`/${owner}/${repo}/issues`}
86 class={active === "issues" ? "active" : ""}
87 >
88 Issues
89 </a>
90 <a
91 href={`/${owner}/${repo}/pulls`}
92 class={active === "pulls" ? "active" : ""}
93 >
94 Pull Requests
95 </a>
96 <a
97 href={`/${owner}/${repo}/commits`}
98 class={active === "commits" ? "active" : ""}
99 >
100 Commits
101 </a>
102 </div>
103);
104
105// List PRs
106pulls.get("/:owner/:repo/pulls", softAuth, async (c) => {
107 const { owner: ownerName, repo: repoName } = c.req.param();
108 const user = c.get("user");
109 const state = c.req.query("state") || "open";
110
111 const resolved = await resolveRepo(ownerName, repoName);
112 if (!resolved) return c.notFound();
113
6fc53bdClaude114 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
115 const stateFilter =
116 state === "draft"
117 ? and(
118 eq(pullRequests.state, "open"),
119 eq(pullRequests.isDraft, true)
120 )
121 : eq(pullRequests.state, state);
122
0074234Claude123 const prList = await db
124 .select({
125 pr: pullRequests,
126 author: { username: users.username },
127 })
128 .from(pullRequests)
129 .innerJoin(users, eq(pullRequests.authorId, users.id))
130 .where(
6fc53bdClaude131 and(eq(pullRequests.repositoryId, resolved.repo.id), stateFilter)
0074234Claude132 )
133 .orderBy(desc(pullRequests.createdAt));
134
135 const [counts] = await db
136 .select({
137 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude138 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude139 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
140 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
141 })
142 .from(pullRequests)
143 .where(eq(pullRequests.repositoryId, resolved.repo.id));
144
145 return c.html(
146 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
147 <RepoHeader owner={ownerName} repo={repoName} />
148 <PrNav owner={ownerName} repo={repoName} active="pulls" />
149 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
150 <div class="issue-tabs">
151 <a
152 href={`/${ownerName}/${repoName}/pulls?state=open`}
153 class={state === "open" ? "active" : ""}
154 >
155 {counts?.open ?? 0} Open
156 </a>
6fc53bdClaude157 <a
158 href={`/${ownerName}/${repoName}/pulls?state=draft`}
159 class={state === "draft" ? "active" : ""}
160 >
161 {counts?.draft ?? 0} Draft
162 </a>
0074234Claude163 <a
164 href={`/${ownerName}/${repoName}/pulls?state=merged`}
165 class={state === "merged" ? "active" : ""}
166 >
167 {counts?.merged ?? 0} Merged
168 </a>
169 <a
170 href={`/${ownerName}/${repoName}/pulls?state=closed`}
171 class={state === "closed" ? "active" : ""}
172 >
173 {counts?.closed ?? 0} Closed
174 </a>
175 </div>
176 {user && (
177 <a
178 href={`/${ownerName}/${repoName}/pulls/new`}
179 class="btn btn-primary"
180 >
181 New pull request
182 </a>
183 )}
184 </div>
185 {prList.length === 0 ? (
186 <div class="empty-state">
187 <p>No {state} pull requests.</p>
188 </div>
189 ) : (
190 <div class="issue-list">
6fc53bdClaude191 {prList.map(({ pr, author }) => {
192 const isDraft = pr.state === "open" && pr.isDraft;
193 const stateClass = isDraft
194 ? "state-draft"
195 : pr.state === "open"
196 ? "state-open"
197 : pr.state === "merged"
198 ? "state-merged"
199 : "state-closed";
200 const stateIcon = isDraft
201 ? "\u270E"
202 : pr.state === "open"
203 ? "\u25CB"
204 : pr.state === "merged"
205 ? "\u2B8C"
206 : "\u2713";
207 return (
208 <div class="issue-item">
209 <div class={`issue-state-icon ${stateClass}`}>{stateIcon}</div>
210 <div>
211 <div class="issue-title">
212 <a href={`/${ownerName}/${repoName}/pulls/${pr.number}`}>
213 {pr.title}
214 </a>
215 {isDraft && (
216 <span class="issue-badge draft-badge" style="margin-left: 8px; font-size: 11px; padding: 2px 8px">
217 Draft
218 </span>
219 )}
220 </div>
221 <div class="issue-meta">
222 #{pr.number}{" "}
223 {pr.headBranch} → {pr.baseBranch}{" "}
224 by {author.username}{" "}
225 {formatRelative(pr.createdAt)}
226 </div>
0074234Claude227 </div>
228 </div>
6fc53bdClaude229 );
230 })}
0074234Claude231 </div>
232 )}
233 </Layout>
234 );
235});
236
237// New PR form
238pulls.get(
239 "/:owner/:repo/pulls/new",
240 softAuth,
241 requireAuth,
242 async (c) => {
243 const { owner: ownerName, repo: repoName } = c.req.param();
244 const user = c.get("user")!;
245 const branches = await listBranches(ownerName, repoName);
246 const error = c.req.query("error");
247 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude248 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude249
250 return c.html(
251 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
252 <RepoHeader owner={ownerName} repo={repoName} />
253 <PrNav owner={ownerName} repo={repoName} active="pulls" />
254 <div style="max-width: 800px">
255 <h2 style="margin-bottom: 16px">Open a pull request</h2>
24cf2caClaude256 {template && (
257 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 8px">
258 Using <code>PULL_REQUEST_TEMPLATE.md</code> from the default branch.
259 </div>
260 )}
0074234Claude261 {error && (
262 <div class="auth-error">{decodeURIComponent(error)}</div>
263 )}
264 <form method="POST" action={`/${ownerName}/${repoName}/pulls/new`}>
265 <div style="display: flex; gap: 12px; align-items: center; margin-bottom: 16px">
266 <select name="base" style="padding: 6px 12px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 13px">
267 {branches.map((b) => (
268 <option value={b} selected={b === defaultBase}>
269 {b}
270 </option>
271 ))}
272 </select>
273 <span style="color: var(--text-muted)">&larr;</span>
274 <select name="head" style="padding: 6px 12px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 13px">
275 {branches
276 .filter((b) => b !== defaultBase)
277 .concat(defaultBase === branches[0] ? [] : [branches[0]])
278 .map((b) => (
279 <option value={b}>{b}</option>
280 ))}
281 </select>
282 </div>
283 <div class="form-group">
284 <input
285 type="text"
286 name="title"
287 required
288 placeholder="Title"
289 style="font-size: 16px; padding: 10px 14px"
290 />
291 </div>
292 <div class="form-group">
293 <textarea
294 name="body"
295 rows={8}
296 placeholder="Description (Markdown supported)"
297 style="font-family: var(--font-mono); font-size: 13px"
24cf2caClaude298 >
299 {template || ""}
300 </textarea>
0074234Claude301 </div>
6fc53bdClaude302 <div style="display: flex; gap: 8px">
303 <button type="submit" class="btn btn-primary">
304 Create pull request
305 </button>
306 <button
307 type="submit"
308 name="draft"
309 value="1"
310 class="btn"
311 title="Create a draft PR — skips AI review and cannot be merged until marked ready"
312 >
313 Create draft
314 </button>
315 </div>
0074234Claude316 </form>
317 </div>
318 </Layout>
319 );
320 }
321);
322
323// Create PR
324pulls.post(
325 "/:owner/:repo/pulls/new",
326 softAuth,
327 requireAuth,
328 async (c) => {
329 const { owner: ownerName, repo: repoName } = c.req.param();
330 const user = c.get("user")!;
331 const body = await c.req.parseBody();
332 const title = String(body.title || "").trim();
333 const prBody = String(body.body || "").trim();
334 const baseBranch = String(body.base || "main");
335 const headBranch = String(body.head || "");
336
337 if (!title || !headBranch) {
338 return c.redirect(
339 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
340 );
341 }
342
343 if (baseBranch === headBranch) {
344 return c.redirect(
345 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
346 );
347 }
348
349 const resolved = await resolveRepo(ownerName, repoName);
350 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
351
6fc53bdClaude352 const isDraft = String(body.draft || "") === "1";
353
0074234Claude354 const [pr] = await db
355 .insert(pullRequests)
356 .values({
357 repositoryId: resolved.repo.id,
358 authorId: user.id,
359 title,
360 body: prBody || null,
361 baseBranch,
362 headBranch,
6fc53bdClaude363 isDraft,
0074234Claude364 })
365 .returning();
366
6fc53bdClaude367 // Skip AI review on drafts — it runs again when the PR is marked ready.
368 if (!isDraft && isAiReviewEnabled()) {
e883329Claude369 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
370 (err) => console.error("[ai-review] Failed:", err)
371 );
372 }
373
3cbe3d6Claude374 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
375 triggerPrTriage({
376 ownerName,
377 repoName,
378 repositoryId: resolved.repo.id,
379 prId: pr.id,
380 prAuthorId: user.id,
381 title,
382 body: prBody,
383 baseBranch,
384 headBranch,
385 }).catch((err) => console.error("[pr-triage] Failed:", err));
386
3247f79Claude387 // J11 — fire-and-forget CODEOWNERS auto-assign. Reads diff paths
388 // between base..head and requests review from the matched owners.
389 (async () => {
390 try {
391 const repoDir = getRepoPath(ownerName, repoName);
392 const statProc = Bun.spawn(
393 ["git", "diff", "--numstat", `${baseBranch}...${headBranch}`],
394 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
395 );
396 const stat = await new Response(statProc.stdout).text();
397 await statProc.exited;
398 const changedPaths = stat
399 .trim()
400 .split("\n")
401 .filter(Boolean)
402 .map((line) => line.split("\t")[2])
403 .filter(Boolean);
404 await autoAssignFromCodeowners({
405 repositoryId: resolved.repo.id,
406 pullRequestId: pr.id,
407 authorId: user.id,
408 changedPaths,
409 prUrl: `/${ownerName}/${repoName}/pulls/${pr.number}`,
410 prTitle: title,
411 });
412 } catch (err) {
413 console.error("[codeowners-assign] Failed:", err);
414 }
415 })();
416
0074234Claude417 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
418 }
419);
420
421// View single PR
422pulls.get("/:owner/:repo/pulls/:number", softAuth, async (c) => {
423 const { owner: ownerName, repo: repoName } = c.req.param();
424 const prNum = parseInt(c.req.param("number"), 10);
425 const user = c.get("user");
426 const tab = c.req.query("tab") || "conversation";
427
428 const resolved = await resolveRepo(ownerName, repoName);
429 if (!resolved) return c.notFound();
430
431 const [pr] = await db
432 .select()
433 .from(pullRequests)
434 .where(
435 and(
436 eq(pullRequests.repositoryId, resolved.repo.id),
437 eq(pullRequests.number, prNum)
438 )
439 )
440 .limit(1);
441
442 if (!pr) return c.notFound();
443
444 const [author] = await db
445 .select()
446 .from(users)
447 .where(eq(users.id, pr.authorId))
448 .limit(1);
449
450 const comments = await db
451 .select({
452 comment: prComments,
453 author: { username: users.username },
454 })
455 .from(prComments)
456 .innerJoin(users, eq(prComments.authorId, users.id))
457 .where(eq(prComments.pullRequestId, pr.id))
458 .orderBy(asc(prComments.createdAt));
459
6fc53bdClaude460 // Reactions for the PR body + each comment, in parallel.
461 const [prReactions, ...prCommentReactions] = await Promise.all([
462 summariseReactions("pr", pr.id, user?.id),
463 ...comments.map((row) =>
464 summariseReactions("pr_comment", row.comment.id, user?.id)
465 ),
466 ]);
467
0074234Claude468 const canManage =
469 user &&
470 (user.id === resolved.owner.id || user.id === pr.authorId);
471
e883329Claude472 const error = c.req.query("error");
473
474 // Get gate check status for open PRs
475 let gateChecks: GateCheckResult[] = [];
476 if (pr.state === "open") {
477 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
478 if (headSha) {
479 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
480 const aiApproved = aiComments.length === 0 || aiComments.some(
481 ({ comment }) => comment.body.includes("**Approved**")
482 );
483 const gateResult = await runAllGateChecks(
484 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
485 );
486 gateChecks = gateResult.checks;
487 }
488 }
489
3247f79Claude490 // J11 — requested reviewers (CODEOWNERS auto-assign + manual add).
491 const reviewRequests = await listReviewRequestsForPr(pr.id);
492
0074234Claude493 // Get diff for "Files changed" tab
494 let diffRaw = "";
495 let diffFiles: GitDiffFile[] = [];
496 if (tab === "files") {
497 const repoDir = getRepoPath(ownerName, repoName);
498 const proc = Bun.spawn(
499 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
500 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
501 );
502 diffRaw = await new Response(proc.stdout).text();
503 await proc.exited;
504
505 const statProc = Bun.spawn(
506 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
507 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
508 );
509 const stat = await new Response(statProc.stdout).text();
510 await statProc.exited;
511
512 diffFiles = stat
513 .trim()
514 .split("\n")
515 .filter(Boolean)
516 .map((line) => {
517 const [add, del, filePath] = line.split("\t");
518 return {
519 path: filePath,
520 status: "modified",
521 additions: add === "-" ? 0 : parseInt(add, 10),
522 deletions: del === "-" ? 0 : parseInt(del, 10),
523 patch: "",
524 };
525 });
526 }
527
528 return c.html(
529 <Layout
530 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
531 user={user}
532 >
533 <RepoHeader owner={ownerName} repo={repoName} />
534 <PrNav owner={ownerName} repo={repoName} active="pulls" />
535 <div class="issue-detail">
536 <h2>
537 {pr.title}{" "}
538 <span style="color: var(--text-muted); font-weight: 400">
539 #{pr.number}
540 </span>
541 </h2>
542 <div style="margin: 8px 0 20px; display: flex; align-items: center; gap: 8px">
6fc53bdClaude543 {pr.state === "open" && pr.isDraft ? (
544 <span class="issue-badge draft-badge">
545 {"\u270E Draft"}
546 </span>
547 ) : (
548 <span
549 class={`issue-badge ${pr.state === "open" ? "badge-open" : pr.state === "merged" ? "badge-merged" : "badge-closed"}`}
550 >
551 {pr.state === "open"
552 ? "\u25CB Open"
553 : pr.state === "merged"
554 ? "\u2B8C Merged"
555 : "\u2713 Closed"}
556 </span>
557 )}
0074234Claude558 <span style="color: var(--text-muted); font-size: 14px">
559 <strong style="color: var(--text)">
560 {author?.username}
561 </strong>{" "}
562 wants to merge <code>{pr.headBranch}</code> into{" "}
563 <code>{pr.baseBranch}</code>
564 </span>
565 </div>
566
3247f79Claude567 <ReviewersPanel
568 reviewers={reviewRequests}
569 canManage={canManage}
570 ownerName={ownerName}
571 repoName={repoName}
572 prNumber={pr.number}
573 />
574
0074234Claude575 <div class="issue-tabs" style="margin-bottom: 20px">
576 <a
577 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
578 class={tab === "conversation" ? "active" : ""}
579 >
580 Conversation
581 </a>
582 <a
583 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
584 class={tab === "files" ? "active" : ""}
585 >
586 Files changed
587 </a>
588 </div>
589
590 {tab === "files" ? (
591 <DiffView raw={diffRaw} files={diffFiles} />
592 ) : (
593 <>
594 {pr.body && (
595 <div class="issue-comment-box">
596 <div class="comment-header">
597 <strong>{author?.username}</strong> commented{" "}
598 {formatRelative(pr.createdAt)}
599 </div>
600 <div class="markdown-body">
601 {html([renderMarkdown(pr.body)] as unknown as TemplateStringsArray)}
602 </div>
6fc53bdClaude603 <div style="padding: 0 16px 12px">
604 <ReactionsBar
605 targetType="pr"
606 targetId={pr.id}
607 summaries={prReactions}
608 canReact={!!user}
609 />
610 </div>
0074234Claude611 </div>
612 )}
613
6fc53bdClaude614 {comments.map(({ comment, author: commentAuthor }, i) => (
0074234Claude615 <div
616 class={`issue-comment-box ${comment.isAiReview ? "ai-review" : ""}`}
617 >
618 <div class="comment-header">
619 <strong>{commentAuthor.username}</strong>
620 {comment.isAiReview && (
621 <span class="badge" style="margin-left: 8px; background: rgba(31, 111, 235, 0.15); color: var(--text-link); border-color: var(--accent)">
622 AI Review
623 </span>
624 )}
625 {" "}
626 commented {formatRelative(comment.createdAt)}
627 {comment.filePath && (
628 <span style="margin-left: 8px; font-family: var(--font-mono); font-size: 11px">
629 {comment.filePath}
630 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
631 </span>
632 )}
633 </div>
634 <div class="markdown-body">
635 {html([renderMarkdown(comment.body)] as unknown as TemplateStringsArray)}
636 </div>
6fc53bdClaude637 <div style="padding: 0 16px 12px">
638 <ReactionsBar
639 targetType="pr_comment"
640 targetId={comment.id}
641 summaries={prCommentReactions[i] || []}
642 canReact={!!user}
643 />
644 </div>
0074234Claude645 </div>
646 ))}
647
e883329Claude648 {error && (
649 <div class="auth-error" style="margin-top: 16px; padding: 12px; background: rgba(248, 81, 73, 0.1); border: 1px solid var(--red); border-radius: var(--radius); color: var(--red)">
650 {decodeURIComponent(error)}
651 </div>
652 )}
653
654 {pr.state === "open" && gateChecks.length > 0 && (
655 <div style="margin-top: 20px; padding: 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)">
656 <h3 style="margin: 0 0 12px; font-size: 14px">Gate Checks</h3>
657 {gateChecks.map((check) => (
658 <div style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border)">
659 <span style={`font-size: 16px; color: ${check.passed ? "var(--green)" : "var(--red)"}`}>
660 {check.passed ? "\u2713" : "\u2717"}
661 </span>
662 <strong style="font-size: 13px">{check.name}</strong>
663 <span style="font-size: 12px; color: var(--text-muted); margin-left: auto">{check.details}</span>
664 </div>
665 ))}
666 <div style="margin-top: 8px; font-size: 12px; color: var(--text-muted)">
667 {gateChecks.every((c) => c.passed)
668 ? "All checks passed — ready to merge"
669 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
670 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge"
671 : "Some checks failed — resolve issues before merging"}
672 </div>
673 </div>
674 )}
675
0074234Claude676 {user && pr.state === "open" && (
677 <div style="margin-top: 20px">
678 <form
679 method="POST"
680 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
681 >
682 <div class="form-group">
683 <textarea
684 name="body"
685 rows={6}
686 required
687 placeholder="Leave a comment... (Markdown supported)"
688 style="font-family: var(--font-mono); font-size: 13px"
689 />
690 </div>
691 <div style="display: flex; gap: 8px">
692 <button type="submit" class="btn btn-primary">
693 Comment
694 </button>
695 {canManage && (
696 <>
6fc53bdClaude697 {pr.isDraft ? (
698 <button
699 type="submit"
700 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
701 class="btn"
702 style="background: rgba(63, 185, 80, 0.15); border-color: var(--green); color: var(--green)"
703 title="Mark this draft PR as ready for review — triggers AI review"
704 >
705 Ready for review
706 </button>
707 ) : (
708 <>
709 <button
710 type="submit"
711 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
712 class="btn"
713 style={`background: ${gateChecks.every((c) => c.passed) ? "rgba(63, 185, 80, 0.15)" : "rgba(248, 81, 73, 0.1)"}; border-color: ${gateChecks.every((c) => c.passed) ? "var(--green)" : "var(--red)"}; color: ${gateChecks.every((c) => c.passed) ? "var(--green)" : "var(--red)"}`}
714 >
715 {gateChecks.every((c) => c.passed)
716 ? "Merge pull request"
717 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
718 ? "Merge with auto-resolve"
719 : "Merge pull request"}
720 </button>
a79a9edClaude721 <button
722 type="submit"
723 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/enqueue`}
724 class="btn"
725 title="Queue this PR — gates will re-run against latest base before merge"
726 >
727 Add to merge queue
728 </button>
6fc53bdClaude729 <button
730 type="submit"
731 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
732 class="btn"
733 title="Convert back to draft"
734 >
735 Convert to draft
736 </button>
737 </>
738 )}
0074234Claude739 <button
740 type="submit"
741 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
742 class="btn btn-danger"
743 >
744 Close
745 </button>
746 </>
747 )}
748 </div>
749 </form>
750 </div>
751 )}
752 </>
753 )}
754 </div>
755 </Layout>
756 );
757});
758
759// Add comment to PR
760pulls.post(
761 "/:owner/:repo/pulls/:number/comment",
762 softAuth,
763 requireAuth,
764 async (c) => {
765 const { owner: ownerName, repo: repoName } = c.req.param();
766 const prNum = parseInt(c.req.param("number"), 10);
767 const user = c.get("user")!;
768 const body = await c.req.parseBody();
769 const commentBody = String(body.body || "").trim();
770
771 if (!commentBody) {
772 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
773 }
774
775 const resolved = await resolveRepo(ownerName, repoName);
776 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
777
778 const [pr] = await db
779 .select()
780 .from(pullRequests)
781 .where(
782 and(
783 eq(pullRequests.repositoryId, resolved.repo.id),
784 eq(pullRequests.number, prNum)
785 )
786 )
787 .limit(1);
788
789 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
790
791 await db.insert(prComments).values({
792 pullRequestId: pr.id,
793 authorId: user.id,
794 body: commentBody,
795 });
796
797 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
798 }
799);
800
e883329Claude801// Merge PR — with green gate enforcement and auto conflict resolution
0074234Claude802pulls.post(
803 "/:owner/:repo/pulls/:number/merge",
804 softAuth,
805 requireAuth,
806 async (c) => {
807 const { owner: ownerName, repo: repoName } = c.req.param();
808 const prNum = parseInt(c.req.param("number"), 10);
809 const user = c.get("user")!;
810
811 const resolved = await resolveRepo(ownerName, repoName);
812 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
813
814 const [pr] = await db
815 .select()
816 .from(pullRequests)
817 .where(
818 and(
819 eq(pullRequests.repositoryId, resolved.repo.id),
820 eq(pullRequests.number, prNum)
821 )
822 )
823 .limit(1);
824
825 if (!pr || pr.state !== "open") {
826 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
827 }
828
6fc53bdClaude829 // Draft PRs cannot be merged — must be marked ready first.
830 if (pr.isDraft) {
831 return c.redirect(
832 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
833 "This PR is a draft. Mark it as ready for review before merging."
834 )}`
835 );
836 }
837
e883329Claude838 // Resolve head SHA
839 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
840 if (!headSha) {
841 return c.redirect(
842 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
843 );
844 }
845
846 // Check if AI review approved this PR
847 const aiComments = await db
848 .select()
849 .from(prComments)
850 .where(
851 and(
852 eq(prComments.pullRequestId, pr.id),
853 eq(prComments.isAiReview, true)
854 )
855 );
856 const aiApproved = aiComments.length === 0 || aiComments.some(
857 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude858 );
e883329Claude859
860 // Run all green gate checks (GateTest + mergeability + AI review)
861 const gateResult = await runAllGateChecks(
862 ownerName,
863 repoName,
864 pr.baseBranch,
865 pr.headBranch,
866 headSha,
867 aiApproved
0074234Claude868 );
869
e883329Claude870 // If GateTest or AI review failed (hard blocks), reject the merge
871 const hardFailures = gateResult.checks.filter(
872 (check) => !check.passed && check.name !== "Merge check"
873 );
874 if (hardFailures.length > 0) {
875 const errorMsg = hardFailures
876 .map((f) => `${f.name}: ${f.details}`)
877 .join("; ");
0074234Claude878 return c.redirect(
e883329Claude879 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude880 );
881 }
882
1e162a8Claude883 // D5 — Branch-protection enforcement. Looks up the matching rule for the
884 // base branch and blocks the merge if requireAiApproval / requireGreenGates
885 // / requireHumanReview / requiredApprovals are not satisfied. Independent
886 // of repo-global settings, so owners can lock specific branches down
887 // further than the repo default.
888 const protectionRule = await matchProtection(
889 resolved.repo.id,
890 pr.baseBranch
891 );
892 if (protectionRule) {
893 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude894 const required = await listRequiredChecks(protectionRule.id);
895 const passingNames = required.length > 0
896 ? await passingCheckNames(resolved.repo.id, headSha)
897 : [];
898 const decision = evaluateProtection(
899 protectionRule,
900 {
901 aiApproved,
902 humanApprovalCount: humanApprovals,
903 gateResultGreen: hardFailures.length === 0,
904 hasFailedGates: hardFailures.length > 0,
905 passingCheckNames: passingNames,
906 },
907 required.map((r) => r.checkName)
908 );
1e162a8Claude909 if (!decision.allowed) {
910 return c.redirect(
911 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
912 decision.reasons.join(" ")
913 )}`
914 );
915 }
916 }
917
e883329Claude918 // Attempt the merge — with auto conflict resolution if needed
919 const repoDir = getRepoPath(ownerName, repoName);
920 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
921 const hasConflicts = mergeCheck && !mergeCheck.passed;
922
923 if (hasConflicts && isAiReviewEnabled()) {
924 // Use Claude to auto-resolve conflicts
925 const mergeResult = await mergeWithAutoResolve(
926 ownerName,
927 repoName,
928 pr.baseBranch,
929 pr.headBranch,
930 `Merge pull request #${pr.number}: ${pr.title}`
931 );
932
933 if (!mergeResult.success) {
934 return c.redirect(
935 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
936 );
937 }
938
939 // Post a comment about the auto-resolution
940 if (mergeResult.resolvedFiles.length > 0) {
941 await db.insert(prComments).values({
942 pullRequestId: pr.id,
943 authorId: user.id,
944 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
945 isAiReview: true,
946 });
947 }
948 } else {
949 // Standard merge — fast-forward or clean merge
950 const ffProc = Bun.spawn(
951 [
952 "git",
953 "update-ref",
954 `refs/heads/${pr.baseBranch}`,
955 `refs/heads/${pr.headBranch}`,
956 ],
957 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
958 );
959 const ffExit = await ffProc.exited;
960
961 if (ffExit !== 0) {
962 return c.redirect(
963 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
964 );
965 }
966 }
967
0074234Claude968 await db
969 .update(pullRequests)
970 .set({
971 state: "merged",
972 mergedAt: new Date(),
973 mergedBy: user.id,
974 updatedAt: new Date(),
975 })
976 .where(eq(pullRequests.id, pr.id));
977
d62fb36Claude978 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
979 // and auto-close each matching open issue with a back-link comment. Bounded
980 // to the same repo for v1 (cross-repo refs ignored). Failures never block
981 // the merge redirect.
982 try {
983 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
984 const refs = extractClosingRefsMulti([pr.title, pr.body]);
985 for (const n of refs) {
986 const [issue] = await db
987 .select()
988 .from(issues)
989 .where(
990 and(
991 eq(issues.repositoryId, resolved.repo.id),
992 eq(issues.number, n)
993 )
994 )
995 .limit(1);
996 if (!issue || issue.state !== "open") continue;
997 await db
998 .update(issues)
999 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1000 .where(eq(issues.id, issue.id));
1001 await db.insert(issueComments).values({
1002 issueId: issue.id,
1003 authorId: user.id,
1004 body: `Closed by pull request #${pr.number}.`,
1005 });
1006 }
1007 } catch {
1008 // Never block the merge on close-keyword failures.
1009 }
1010
0074234Claude1011 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1012 }
1013);
1014
6fc53bdClaude1015// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
1016// hasn't run yet on this PR.
1017pulls.post(
1018 "/:owner/:repo/pulls/:number/ready",
1019 softAuth,
1020 requireAuth,
1021 async (c) => {
1022 const { owner: ownerName, repo: repoName } = c.req.param();
1023 const prNum = parseInt(c.req.param("number"), 10);
1024 const user = c.get("user")!;
1025
1026 const resolved = await resolveRepo(ownerName, repoName);
1027 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1028
1029 const [pr] = await db
1030 .select()
1031 .from(pullRequests)
1032 .where(
1033 and(
1034 eq(pullRequests.repositoryId, resolved.repo.id),
1035 eq(pullRequests.number, prNum)
1036 )
1037 )
1038 .limit(1);
1039 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
1040
1041 // Only the author or repo owner can toggle draft state.
1042 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
1043 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1044 }
1045
1046 if (pr.state === "open" && pr.isDraft) {
1047 await db
1048 .update(pullRequests)
1049 .set({ isDraft: false, updatedAt: new Date() })
1050 .where(eq(pullRequests.id, pr.id));
1051
1052 if (isAiReviewEnabled()) {
1053 triggerAiReview(
1054 ownerName,
1055 repoName,
1056 pr.id,
1057 pr.title,
1058 pr.body,
1059 pr.baseBranch,
1060 pr.headBranch
1061 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
1062 }
1063 }
1064
1065 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1066 }
1067);
1068
1069// Convert a PR back to draft.
1070pulls.post(
1071 "/:owner/:repo/pulls/:number/draft",
1072 softAuth,
1073 requireAuth,
1074 async (c) => {
1075 const { owner: ownerName, repo: repoName } = c.req.param();
1076 const prNum = parseInt(c.req.param("number"), 10);
1077 const user = c.get("user")!;
1078
1079 const resolved = await resolveRepo(ownerName, repoName);
1080 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1081
1082 const [pr] = await db
1083 .select()
1084 .from(pullRequests)
1085 .where(
1086 and(
1087 eq(pullRequests.repositoryId, resolved.repo.id),
1088 eq(pullRequests.number, prNum)
1089 )
1090 )
1091 .limit(1);
1092 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
1093
1094 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
1095 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1096 }
1097
1098 if (pr.state === "open" && !pr.isDraft) {
1099 await db
1100 .update(pullRequests)
1101 .set({ isDraft: true, updatedAt: new Date() })
1102 .where(eq(pullRequests.id, pr.id));
1103 }
1104
1105 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1106 }
1107);
1108
0074234Claude1109// Close PR
1110pulls.post(
1111 "/:owner/:repo/pulls/:number/close",
1112 softAuth,
1113 requireAuth,
1114 async (c) => {
1115 const { owner: ownerName, repo: repoName } = c.req.param();
1116 const prNum = parseInt(c.req.param("number"), 10);
1117
1118 const resolved = await resolveRepo(ownerName, repoName);
1119 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1120
1121 await db
1122 .update(pullRequests)
1123 .set({
1124 state: "closed",
1125 closedAt: new Date(),
1126 updatedAt: new Date(),
1127 })
1128 .where(
1129 and(
1130 eq(pullRequests.repositoryId, resolved.repo.id),
1131 eq(pullRequests.number, prNum)
1132 )
1133 );
1134
1135 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1136 }
1137);
1138
e883329Claude1139/**
1140 * Trigger AI code review asynchronously after PR creation.
1141 * Runs the diff through Claude and posts review comments.
1142 */
1143async function triggerAiReview(
1144 ownerName: string,
1145 repoName: string,
1146 prId: string,
1147 title: string,
1148 body: string | null,
1149 baseBranch: string,
1150 headBranch: string
1151): Promise<void> {
1152 const repoDir = getRepoPath(ownerName, repoName);
1153
1154 // Get the diff between branches
1155 const proc = Bun.spawn(
1156 ["git", "diff", `${baseBranch}...${headBranch}`],
1157 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1158 );
1159 const diffText = await new Response(proc.stdout).text();
1160 await proc.exited;
1161
1162 if (!diffText.trim()) return;
1163
1164 const result = await reviewDiff(
1165 `${ownerName}/${repoName}`,
1166 title,
1167 body,
1168 baseBranch,
1169 headBranch,
1170 diffText
1171 );
1172
1173 // We need a system user for AI reviews — use the PR author for now
1174 // Get the PR to find the author
1175 const [pr] = await db
1176 .select()
1177 .from(pullRequests)
1178 .where(eq(pullRequests.id, prId))
1179 .limit(1);
1180
1181 if (!pr) return;
1182
1183 // Post summary comment
1184 const statusEmoji = result.approved ? "**Approved**" : "**Changes Requested**";
1185 let commentBody = `## AI Code Review ${statusEmoji}\n\n${result.summary}`;
1186
1187 if (result.comments.length > 0) {
1188 commentBody += "\n\n### Issues Found\n";
1189 for (const comment of result.comments) {
1190 const location = comment.filePath
1191 ? `\`${comment.filePath}${comment.lineNumber ? `:${comment.lineNumber}` : ""}\``
1192 : "";
1193 commentBody += `\n---\n${location}\n\n${comment.body}\n`;
1194 }
1195 }
1196
1197 await db.insert(prComments).values({
1198 pullRequestId: prId,
1199 authorId: pr.authorId,
1200 body: commentBody,
1201 isAiReview: true,
1202 });
1203
1204 // Post individual file-level comments
1205 for (const comment of result.comments) {
1206 if (comment.filePath) {
1207 await db.insert(prComments).values({
1208 pullRequestId: prId,
1209 authorId: pr.authorId,
1210 body: comment.body,
1211 isAiReview: true,
1212 filePath: comment.filePath,
1213 lineNumber: comment.lineNumber,
1214 });
1215 }
1216 }
1217
1218 console.log(
1219 `[ai-review] Review posted for PR ${prId}: ${result.approved ? "approved" : "changes requested"}, ${result.comments.length} comments`
1220 );
1221}
1222
3cbe3d6Claude1223/**
1224 * D3 — AI PR triage. Runs Claude Haiku on the PR title/body + diff summary and
1225 * posts an AI-authored comment suggesting labels, reviewers, and priority.
1226 * Nothing is auto-applied — the PR author remains in control.
1227 */
1228async function triggerPrTriage(args: {
1229 ownerName: string;
1230 repoName: string;
1231 repositoryId: string;
1232 prId: string;
1233 prAuthorId: string;
1234 title: string;
1235 body: string;
1236 baseBranch: string;
1237 headBranch: string;
1238}): Promise<void> {
1239 try {
1240 // Gather candidate reviewers (top contributors from recent commits).
1241 const repoDir = getRepoPath(args.ownerName, args.repoName);
1242 const shortlogProc = Bun.spawn(
1243 ["git", "shortlog", "-sn", "--no-merges", "-50", args.baseBranch],
1244 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1245 );
1246 const shortlogOut = await new Response(shortlogProc.stdout).text();
1247 await shortlogProc.exited;
1248 const authorNames = shortlogOut
1249 .trim()
1250 .split("\n")
1251 .map((l) => l.trim().split(/\s+/).slice(1).join(" "))
1252 .filter(Boolean)
1253 .slice(0, 10);
1254
1255 // Look up usernames matching these author names (best-effort match).
1256 const candidateUsernames: string[] = [];
1257 if (authorNames.length > 0) {
1258 try {
1259 const matches = await db
1260 .select({ username: users.username, displayName: users.displayName })
1261 .from(users)
1262 .limit(100);
1263 for (const u of matches) {
1264 if (
1265 authorNames.some(
1266 (n) =>
1267 u.username === n ||
1268 (u.displayName && u.displayName === n)
1269 )
1270 ) {
1271 candidateUsernames.push(u.username);
1272 }
1273 }
1274 } catch {
1275 /* ignore */
1276 }
1277 }
1278 // Always include the repo owner as a candidate reviewer.
1279 try {
1280 const [ownerRow] = await db
1281 .select({ username: users.username })
1282 .from(users)
1283 .where(eq(users.username, args.ownerName))
1284 .limit(1);
1285 if (ownerRow && !candidateUsernames.includes(ownerRow.username)) {
1286 candidateUsernames.push(ownerRow.username);
1287 }
1288 } catch {
1289 /* ignore */
1290 }
1291
1292 // Load repo labels.
1293 const availableLabels = await db
1294 .select({ name: labelsTable.name })
1295 .from(labelsTable)
1296 .where(eq(labelsTable.repositoryId, args.repositoryId))
1297 .then((rows) => rows.map((r) => r.name))
1298 .catch(() => [] as string[]);
1299
1300 // Short diff summary (numstat only to keep prompt small).
1301 const statProc = Bun.spawn(
1302 ["git", "diff", "--numstat", `${args.baseBranch}...${args.headBranch}`],
1303 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1304 );
1305 const diffSummary = await new Response(statProc.stdout).text();
1306 await statProc.exited;
1307
1308 const result = await triagePullRequest(
1309 args.title,
1310 args.body,
1311 diffSummary,
1312 availableLabels,
1313 candidateUsernames
1314 );
1315
1316 // Skip posting if we have absolutely nothing useful to say.
1317 if (
1318 !result.summary &&
1319 result.suggestedLabels.length === 0 &&
1320 result.suggestedReviewerUsernames.length === 0
1321 ) {
1322 return;
1323 }
1324
1325 const priorityEmoji =
1326 result.priority === "critical"
1327 ? "**Critical**"
1328 : result.priority === "high"
1329 ? "**High**"
1330 : result.priority === "low"
1331 ? "**Low**"
1332 : "**Medium**";
1333 const parts: string[] = [`## AI Triage\n`];
1334 if (result.summary) parts.push(`${result.summary}\n`);
1335 parts.push(`- **Priority:** ${priorityEmoji}`);
1336 parts.push(`- **Risk area:** ${result.riskArea}`);
1337 if (result.suggestedLabels.length > 0) {
1338 parts.push(
1339 `- **Suggested labels:** ${result.suggestedLabels.map((l) => `\`${l}\``).join(", ")}`
1340 );
1341 }
1342 if (result.suggestedReviewerUsernames.length > 0) {
1343 parts.push(
1344 `- **Suggested reviewers:** ${result.suggestedReviewerUsernames.map((u) => `@${u}`).join(", ")}`
1345 );
1346 }
1347 parts.push(
1348 `\n_Suggestions only — nothing was auto-applied. The PR author remains in control._`
1349 );
1350
1351 await db.insert(prComments).values({
1352 pullRequestId: args.prId,
1353 authorId: args.prAuthorId,
1354 body: parts.join("\n"),
1355 isAiReview: true,
1356 });
1357 } catch (err) {
1358 console.error("[pr-triage]", err);
1359 }
1360}
1361
0074234Claude1362function formatRelative(date: Date | string): string {
1363 const d = typeof date === "string" ? new Date(date) : date;
1364 const now = new Date();
1365 const diffMs = now.getTime() - d.getTime();
1366 const diffMins = Math.floor(diffMs / 60000);
1367 if (diffMins < 1) return "just now";
1368 if (diffMins < 60) return `${diffMins}m ago`;
1369 const diffHours = Math.floor(diffMins / 60);
1370 if (diffHours < 24) return `${diffHours}h ago`;
1371 const diffDays = Math.floor(diffHours / 24);
1372 if (diffDays < 30) return `${diffDays}d ago`;
1373 return d.toLocaleDateString("en-US", {
1374 month: "short",
1375 day: "numeric",
1376 year: "numeric",
1377 });
1378}
1379
3247f79Claude1380// ---------------------------------------------------------------------------
1381// J11 — Reviewer request management (manual add + dismiss)
1382// ---------------------------------------------------------------------------
1383pulls.post(
1384 "/:owner/:repo/pulls/:number/reviewers",
1385 softAuth,
1386 requireAuth,
1387 async (c) => {
1388 const { owner: ownerName, repo: repoName } = c.req.param();
1389 const prNum = parseInt(c.req.param("number"), 10);
1390 const user = c.get("user")!;
1391 const form = await c.req.parseBody();
1392 const usernameRaw = String(form.username || "").trim().replace(/^@/, "");
1393 if (!usernameRaw) {
1394 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1395 }
1396 const resolved = await resolveRepo(ownerName, repoName);
1397 if (!resolved) return c.notFound();
1398 const [pr] = await db
1399 .select()
1400 .from(pullRequests)
1401 .where(
1402 and(
1403 eq(pullRequests.repositoryId, resolved.repo.id),
1404 eq(pullRequests.number, prNum)
1405 )
1406 )
1407 .limit(1);
1408 if (!pr) return c.notFound();
1409 const canManage =
1410 user.id === resolved.owner.id || user.id === pr.authorId;
1411 if (!canManage) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1412 const [target] = await db
1413 .select()
1414 .from(users)
1415 .where(eq(users.username, usernameRaw))
1416 .limit(1);
1417 if (!target) {
1418 return c.redirect(
1419 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("User not found")}`
1420 );
1421 }
1422 if (target.id === pr.authorId) {
1423 return c.redirect(
1424 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("PR author cannot be a reviewer")}`
1425 );
1426 }
1427 await requestReviewers(pr.id, [target.id], user.id, "manual");
1428 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1429 }
1430);
1431
1432pulls.post(
1433 "/:owner/:repo/pulls/:number/reviewers/:reviewerId/dismiss",
1434 softAuth,
1435 requireAuth,
1436 async (c) => {
1437 const { owner: ownerName, repo: repoName, reviewerId } = c.req.param();
1438 const prNum = parseInt(c.req.param("number"), 10);
1439 const user = c.get("user")!;
1440 const resolved = await resolveRepo(ownerName, repoName);
1441 if (!resolved) return c.notFound();
1442 const [pr] = await db
1443 .select()
1444 .from(pullRequests)
1445 .where(
1446 and(
1447 eq(pullRequests.repositoryId, resolved.repo.id),
1448 eq(pullRequests.number, prNum)
1449 )
1450 )
1451 .limit(1);
1452 if (!pr) return c.notFound();
1453 const canManage =
1454 user.id === resolved.owner.id ||
1455 user.id === pr.authorId ||
1456 user.id === reviewerId;
1457 if (!canManage) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1458 await dismissRequest(pr.id, reviewerId);
1459 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1460 }
1461);
1462
1463interface ReviewersPanelProps {
1464 reviewers: Awaited<ReturnType<typeof listReviewRequestsForPr>>;
1465 canManage: boolean | null | undefined;
1466 ownerName: string;
1467 repoName: string;
1468 prNumber: number;
1469}
1470
1471function ReviewersPanel(props: ReviewersPanelProps) {
1472 const { reviewers, canManage, ownerName, repoName, prNumber } = props;
1473 const stateColor = (s: string) =>
1474 s === "approved"
1475 ? "var(--green)"
1476 : s === "changes_requested"
1477 ? "var(--red)"
1478 : s === "dismissed"
1479 ? "var(--text-muted)"
1480 : "var(--text)";
1481 return (
1482 <div
1483 style="margin: 0 0 20px; padding: 12px 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)"
1484 data-testid="reviewers-panel"
1485 >
1486 <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px">
1487 <h3 style="margin: 0; font-size: 14px">Reviewers</h3>
1488 <span style="color: var(--text-muted); font-size: 12px">
1489 {reviewers.length === 0
1490 ? "No reviewers requested"
1491 : `${reviewers.filter((r) => r.state === "pending").length} pending · ${reviewers.filter((r) => r.state === "approved").length} approved`}
1492 </span>
1493 </div>
1494 {reviewers.length > 0 && (
1495 <ul style="list-style: none; padding: 0; margin: 0 0 8px">
1496 {reviewers.map((r) => (
1497 <li style="display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px">
1498 <a href={`/${r.username}`}>
1499 <strong>@{r.username}</strong>
1500 </a>
1501 <span
1502 style={`font-size: 11px; padding: 1px 6px; border-radius: 10px; background: rgba(139, 148, 158, 0.15); color: ${stateColor(r.state)}; text-transform: capitalize`}
1503 >
1504 {r.state.replace("_", " ")}
1505 </span>
1506 <span style="font-size: 11px; color: var(--text-muted)">
1507 via {r.source}
1508 </span>
1509 {canManage && r.state !== "dismissed" && (
1510 <form
1511 method="POST"
1512 action={`/${ownerName}/${repoName}/pulls/${prNumber}/reviewers/${r.reviewerId}/dismiss`}
1513 style="margin-left: auto"
1514 >
1515 <button
1516 type="submit"
1517 class="btn"
1518 style="padding: 2px 8px; font-size: 11px"
1519 title="Dismiss this review request"
1520 >
1521 Dismiss
1522 </button>
1523 </form>
1524 )}
1525 </li>
1526 ))}
1527 </ul>
1528 )}
1529 {canManage && (
1530 <form
1531 method="POST"
1532 action={`/${ownerName}/${repoName}/pulls/${prNumber}/reviewers`}
1533 style="display: flex; gap: 6px"
1534 >
1535 <input
1536 type="text"
1537 name="username"
1538 placeholder="Request review from @username"
1539 required
1540 style="flex: 1; padding: 4px 8px; font-size: 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text)"
1541 />
1542 <button type="submit" class="btn" style="padding: 4px 10px; font-size: 12px">
1543 Request
1544 </button>
1545 </form>
1546 )}
1547 </div>
1548 );
1549}
1550
0074234Claude1551export default pulls;