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.tsxBlame1741 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
21ff9beClaude493 // J16 — auto-merge opt-in state.
494 let autoMergeState: {
495 enabled: boolean;
496 mergeMethod: string;
497 lastStatus: string | null;
498 notifiedReady: boolean;
499 } = { enabled: false, mergeMethod: "merge", lastStatus: null, notifiedReady: false };
500 try {
501 const { getAutoMergeForPr } = await import("../lib/pr-auto-merge");
502 const am = await getAutoMergeForPr(pr.id);
503 if (am) autoMergeState = am as typeof autoMergeState;
504 } catch {
505 /* ignore */
506 }
507
0074234Claude508 // Get diff for "Files changed" tab
509 let diffRaw = "";
510 let diffFiles: GitDiffFile[] = [];
511 if (tab === "files") {
512 const repoDir = getRepoPath(ownerName, repoName);
513 const proc = Bun.spawn(
514 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
515 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
516 );
517 diffRaw = await new Response(proc.stdout).text();
518 await proc.exited;
519
520 const statProc = Bun.spawn(
521 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
522 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
523 );
524 const stat = await new Response(statProc.stdout).text();
525 await statProc.exited;
526
527 diffFiles = stat
528 .trim()
529 .split("\n")
530 .filter(Boolean)
531 .map((line) => {
532 const [add, del, filePath] = line.split("\t");
533 return {
534 path: filePath,
535 status: "modified",
536 additions: add === "-" ? 0 : parseInt(add, 10),
537 deletions: del === "-" ? 0 : parseInt(del, 10),
538 patch: "",
539 };
540 });
541 }
542
543 return c.html(
544 <Layout
545 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
546 user={user}
547 >
548 <RepoHeader owner={ownerName} repo={repoName} />
549 <PrNav owner={ownerName} repo={repoName} active="pulls" />
550 <div class="issue-detail">
551 <h2>
552 {pr.title}{" "}
553 <span style="color: var(--text-muted); font-weight: 400">
554 #{pr.number}
555 </span>
556 </h2>
557 <div style="margin: 8px 0 20px; display: flex; align-items: center; gap: 8px">
6fc53bdClaude558 {pr.state === "open" && pr.isDraft ? (
559 <span class="issue-badge draft-badge">
560 {"\u270E Draft"}
561 </span>
562 ) : (
563 <span
564 class={`issue-badge ${pr.state === "open" ? "badge-open" : pr.state === "merged" ? "badge-merged" : "badge-closed"}`}
565 >
566 {pr.state === "open"
567 ? "\u25CB Open"
568 : pr.state === "merged"
569 ? "\u2B8C Merged"
570 : "\u2713 Closed"}
571 </span>
572 )}
0074234Claude573 <span style="color: var(--text-muted); font-size: 14px">
574 <strong style="color: var(--text)">
575 {author?.username}
576 </strong>{" "}
577 wants to merge <code>{pr.headBranch}</code> into{" "}
578 <code>{pr.baseBranch}</code>
579 </span>
580 </div>
581
3247f79Claude582 <ReviewersPanel
583 reviewers={reviewRequests}
584 canManage={canManage}
585 ownerName={ownerName}
586 repoName={repoName}
587 prNumber={pr.number}
588 />
589
0074234Claude590 <div class="issue-tabs" style="margin-bottom: 20px">
591 <a
592 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
593 class={tab === "conversation" ? "active" : ""}
594 >
595 Conversation
596 </a>
597 <a
598 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
599 class={tab === "files" ? "active" : ""}
600 >
601 Files changed
602 </a>
603 </div>
604
605 {tab === "files" ? (
606 <DiffView raw={diffRaw} files={diffFiles} />
607 ) : (
608 <>
609 {pr.body && (
610 <div class="issue-comment-box">
611 <div class="comment-header">
612 <strong>{author?.username}</strong> commented{" "}
613 {formatRelative(pr.createdAt)}
614 </div>
615 <div class="markdown-body">
616 {html([renderMarkdown(pr.body)] as unknown as TemplateStringsArray)}
617 </div>
6fc53bdClaude618 <div style="padding: 0 16px 12px">
619 <ReactionsBar
620 targetType="pr"
621 targetId={pr.id}
622 summaries={prReactions}
623 canReact={!!user}
624 />
625 </div>
0074234Claude626 </div>
627 )}
628
6fc53bdClaude629 {comments.map(({ comment, author: commentAuthor }, i) => (
0074234Claude630 <div
631 class={`issue-comment-box ${comment.isAiReview ? "ai-review" : ""}`}
632 >
633 <div class="comment-header">
634 <strong>{commentAuthor.username}</strong>
635 {comment.isAiReview && (
636 <span class="badge" style="margin-left: 8px; background: rgba(31, 111, 235, 0.15); color: var(--text-link); border-color: var(--accent)">
637 AI Review
638 </span>
639 )}
640 {" "}
641 commented {formatRelative(comment.createdAt)}
642 {comment.filePath && (
643 <span style="margin-left: 8px; font-family: var(--font-mono); font-size: 11px">
644 {comment.filePath}
645 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
646 </span>
647 )}
648 </div>
649 <div class="markdown-body">
650 {html([renderMarkdown(comment.body)] as unknown as TemplateStringsArray)}
651 </div>
6fc53bdClaude652 <div style="padding: 0 16px 12px">
653 <ReactionsBar
654 targetType="pr_comment"
655 targetId={comment.id}
656 summaries={prCommentReactions[i] || []}
657 canReact={!!user}
658 />
659 </div>
0074234Claude660 </div>
661 ))}
662
e883329Claude663 {error && (
664 <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)">
665 {decodeURIComponent(error)}
666 </div>
667 )}
668
21ff9beClaude669 {pr.state === "open" && canManage && (
670 <AutoMergePanel
671 owner={ownerName}
672 repo={repoName}
673 prNumber={pr.number}
674 state={autoMergeState}
675 />
676 )}
677
e883329Claude678 {pr.state === "open" && gateChecks.length > 0 && (
679 <div style="margin-top: 20px; padding: 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)">
680 <h3 style="margin: 0 0 12px; font-size: 14px">Gate Checks</h3>
681 {gateChecks.map((check) => (
682 <div style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border)">
683 <span style={`font-size: 16px; color: ${check.passed ? "var(--green)" : "var(--red)"}`}>
684 {check.passed ? "\u2713" : "\u2717"}
685 </span>
686 <strong style="font-size: 13px">{check.name}</strong>
687 <span style="font-size: 12px; color: var(--text-muted); margin-left: auto">{check.details}</span>
688 </div>
689 ))}
690 <div style="margin-top: 8px; font-size: 12px; color: var(--text-muted)">
691 {gateChecks.every((c) => c.passed)
692 ? "All checks passed — ready to merge"
693 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
694 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge"
695 : "Some checks failed — resolve issues before merging"}
696 </div>
697 </div>
698 )}
699
0074234Claude700 {user && pr.state === "open" && (
701 <div style="margin-top: 20px">
702 <form
703 method="POST"
704 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
705 >
706 <div class="form-group">
707 <textarea
708 name="body"
709 rows={6}
710 required
711 placeholder="Leave a comment... (Markdown supported)"
712 style="font-family: var(--font-mono); font-size: 13px"
713 />
714 </div>
715 <div style="display: flex; gap: 8px">
716 <button type="submit" class="btn btn-primary">
717 Comment
718 </button>
719 {canManage && (
720 <>
6fc53bdClaude721 {pr.isDraft ? (
722 <button
723 type="submit"
724 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
725 class="btn"
726 style="background: rgba(63, 185, 80, 0.15); border-color: var(--green); color: var(--green)"
727 title="Mark this draft PR as ready for review — triggers AI review"
728 >
729 Ready for review
730 </button>
731 ) : (
732 <>
733 <button
734 type="submit"
735 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
736 class="btn"
737 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)"}`}
738 >
739 {gateChecks.every((c) => c.passed)
740 ? "Merge pull request"
741 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
742 ? "Merge with auto-resolve"
743 : "Merge pull request"}
744 </button>
a79a9edClaude745 <button
746 type="submit"
747 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/enqueue`}
748 class="btn"
749 title="Queue this PR — gates will re-run against latest base before merge"
750 >
751 Add to merge queue
752 </button>
6fc53bdClaude753 <button
754 type="submit"
755 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
756 class="btn"
757 title="Convert back to draft"
758 >
759 Convert to draft
760 </button>
761 </>
762 )}
0074234Claude763 <button
764 type="submit"
765 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
766 class="btn btn-danger"
767 >
768 Close
769 </button>
770 </>
771 )}
772 </div>
773 </form>
774 </div>
775 )}
776 </>
777 )}
778 </div>
779 </Layout>
780 );
781});
782
783// Add comment to PR
784pulls.post(
785 "/:owner/:repo/pulls/:number/comment",
786 softAuth,
787 requireAuth,
788 async (c) => {
789 const { owner: ownerName, repo: repoName } = c.req.param();
790 const prNum = parseInt(c.req.param("number"), 10);
791 const user = c.get("user")!;
792 const body = await c.req.parseBody();
793 const commentBody = String(body.body || "").trim();
794
795 if (!commentBody) {
796 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
797 }
798
799 const resolved = await resolveRepo(ownerName, repoName);
800 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
801
802 const [pr] = await db
803 .select()
804 .from(pullRequests)
805 .where(
806 and(
807 eq(pullRequests.repositoryId, resolved.repo.id),
808 eq(pullRequests.number, prNum)
809 )
810 )
811 .limit(1);
812
813 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
814
815 await db.insert(prComments).values({
816 pullRequestId: pr.id,
817 authorId: user.id,
818 body: commentBody,
819 });
820
821 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
822 }
823);
824
e883329Claude825// Merge PR — with green gate enforcement and auto conflict resolution
0074234Claude826pulls.post(
827 "/:owner/:repo/pulls/:number/merge",
828 softAuth,
829 requireAuth,
830 async (c) => {
831 const { owner: ownerName, repo: repoName } = c.req.param();
832 const prNum = parseInt(c.req.param("number"), 10);
833 const user = c.get("user")!;
834
835 const resolved = await resolveRepo(ownerName, repoName);
836 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
837
838 const [pr] = await db
839 .select()
840 .from(pullRequests)
841 .where(
842 and(
843 eq(pullRequests.repositoryId, resolved.repo.id),
844 eq(pullRequests.number, prNum)
845 )
846 )
847 .limit(1);
848
849 if (!pr || pr.state !== "open") {
850 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
851 }
852
6fc53bdClaude853 // Draft PRs cannot be merged — must be marked ready first.
854 if (pr.isDraft) {
855 return c.redirect(
856 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
857 "This PR is a draft. Mark it as ready for review before merging."
858 )}`
859 );
860 }
861
e883329Claude862 // Resolve head SHA
863 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
864 if (!headSha) {
865 return c.redirect(
866 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
867 );
868 }
869
870 // Check if AI review approved this PR
871 const aiComments = await db
872 .select()
873 .from(prComments)
874 .where(
875 and(
876 eq(prComments.pullRequestId, pr.id),
877 eq(prComments.isAiReview, true)
878 )
879 );
880 const aiApproved = aiComments.length === 0 || aiComments.some(
881 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude882 );
e883329Claude883
884 // Run all green gate checks (GateTest + mergeability + AI review)
885 const gateResult = await runAllGateChecks(
886 ownerName,
887 repoName,
888 pr.baseBranch,
889 pr.headBranch,
890 headSha,
891 aiApproved
0074234Claude892 );
893
e883329Claude894 // If GateTest or AI review failed (hard blocks), reject the merge
895 const hardFailures = gateResult.checks.filter(
896 (check) => !check.passed && check.name !== "Merge check"
897 );
898 if (hardFailures.length > 0) {
899 const errorMsg = hardFailures
900 .map((f) => `${f.name}: ${f.details}`)
901 .join("; ");
0074234Claude902 return c.redirect(
e883329Claude903 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude904 );
905 }
906
1e162a8Claude907 // D5 — Branch-protection enforcement. Looks up the matching rule for the
908 // base branch and blocks the merge if requireAiApproval / requireGreenGates
909 // / requireHumanReview / requiredApprovals are not satisfied. Independent
910 // of repo-global settings, so owners can lock specific branches down
911 // further than the repo default.
912 const protectionRule = await matchProtection(
913 resolved.repo.id,
914 pr.baseBranch
915 );
916 if (protectionRule) {
917 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude918 const required = await listRequiredChecks(protectionRule.id);
919 const passingNames = required.length > 0
920 ? await passingCheckNames(resolved.repo.id, headSha)
921 : [];
922 const decision = evaluateProtection(
923 protectionRule,
924 {
925 aiApproved,
926 humanApprovalCount: humanApprovals,
927 gateResultGreen: hardFailures.length === 0,
928 hasFailedGates: hardFailures.length > 0,
929 passingCheckNames: passingNames,
930 },
931 required.map((r) => r.checkName)
932 );
1e162a8Claude933 if (!decision.allowed) {
934 return c.redirect(
935 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
936 decision.reasons.join(" ")
937 )}`
938 );
939 }
940 }
941
e883329Claude942 // Attempt the merge — with auto conflict resolution if needed
943 const repoDir = getRepoPath(ownerName, repoName);
944 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
945 const hasConflicts = mergeCheck && !mergeCheck.passed;
946
947 if (hasConflicts && isAiReviewEnabled()) {
948 // Use Claude to auto-resolve conflicts
949 const mergeResult = await mergeWithAutoResolve(
950 ownerName,
951 repoName,
952 pr.baseBranch,
953 pr.headBranch,
954 `Merge pull request #${pr.number}: ${pr.title}`
955 );
956
957 if (!mergeResult.success) {
958 return c.redirect(
959 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
960 );
961 }
962
963 // Post a comment about the auto-resolution
964 if (mergeResult.resolvedFiles.length > 0) {
965 await db.insert(prComments).values({
966 pullRequestId: pr.id,
967 authorId: user.id,
968 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
969 isAiReview: true,
970 });
971 }
972 } else {
973 // Standard merge — fast-forward or clean merge
974 const ffProc = Bun.spawn(
975 [
976 "git",
977 "update-ref",
978 `refs/heads/${pr.baseBranch}`,
979 `refs/heads/${pr.headBranch}`,
980 ],
981 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
982 );
983 const ffExit = await ffProc.exited;
984
985 if (ffExit !== 0) {
986 return c.redirect(
987 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
988 );
989 }
990 }
991
0074234Claude992 await db
993 .update(pullRequests)
994 .set({
995 state: "merged",
996 mergedAt: new Date(),
997 mergedBy: user.id,
998 updatedAt: new Date(),
999 })
1000 .where(eq(pullRequests.id, pr.id));
1001
d62fb36Claude1002 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
1003 // and auto-close each matching open issue with a back-link comment. Bounded
1004 // to the same repo for v1 (cross-repo refs ignored). Failures never block
1005 // the merge redirect.
1006 try {
1007 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
1008 const refs = extractClosingRefsMulti([pr.title, pr.body]);
1009 for (const n of refs) {
1010 const [issue] = await db
1011 .select()
1012 .from(issues)
1013 .where(
1014 and(
1015 eq(issues.repositoryId, resolved.repo.id),
1016 eq(issues.number, n)
1017 )
1018 )
1019 .limit(1);
1020 if (!issue || issue.state !== "open") continue;
1021 await db
1022 .update(issues)
1023 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1024 .where(eq(issues.id, issue.id));
1025 await db.insert(issueComments).values({
1026 issueId: issue.id,
1027 authorId: user.id,
1028 body: `Closed by pull request #${pr.number}.`,
1029 });
1030 }
1031 } catch {
1032 // Never block the merge on close-keyword failures.
1033 }
1034
0074234Claude1035 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1036 }
1037);
1038
6fc53bdClaude1039// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
1040// hasn't run yet on this PR.
1041pulls.post(
1042 "/:owner/:repo/pulls/:number/ready",
1043 softAuth,
1044 requireAuth,
1045 async (c) => {
1046 const { owner: ownerName, repo: repoName } = c.req.param();
1047 const prNum = parseInt(c.req.param("number"), 10);
1048 const user = c.get("user")!;
1049
1050 const resolved = await resolveRepo(ownerName, repoName);
1051 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1052
1053 const [pr] = await db
1054 .select()
1055 .from(pullRequests)
1056 .where(
1057 and(
1058 eq(pullRequests.repositoryId, resolved.repo.id),
1059 eq(pullRequests.number, prNum)
1060 )
1061 )
1062 .limit(1);
1063 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
1064
1065 // Only the author or repo owner can toggle draft state.
1066 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
1067 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1068 }
1069
1070 if (pr.state === "open" && pr.isDraft) {
1071 await db
1072 .update(pullRequests)
1073 .set({ isDraft: false, updatedAt: new Date() })
1074 .where(eq(pullRequests.id, pr.id));
1075
1076 if (isAiReviewEnabled()) {
1077 triggerAiReview(
1078 ownerName,
1079 repoName,
1080 pr.id,
1081 pr.title,
1082 pr.body,
1083 pr.baseBranch,
1084 pr.headBranch
1085 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
1086 }
1087 }
1088
1089 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1090 }
1091);
1092
1093// Convert a PR back to draft.
1094pulls.post(
1095 "/:owner/:repo/pulls/:number/draft",
1096 softAuth,
1097 requireAuth,
1098 async (c) => {
1099 const { owner: ownerName, repo: repoName } = c.req.param();
1100 const prNum = parseInt(c.req.param("number"), 10);
1101 const user = c.get("user")!;
1102
1103 const resolved = await resolveRepo(ownerName, repoName);
1104 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1105
1106 const [pr] = await db
1107 .select()
1108 .from(pullRequests)
1109 .where(
1110 and(
1111 eq(pullRequests.repositoryId, resolved.repo.id),
1112 eq(pullRequests.number, prNum)
1113 )
1114 )
1115 .limit(1);
1116 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
1117
1118 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
1119 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1120 }
1121
1122 if (pr.state === "open" && !pr.isDraft) {
1123 await db
1124 .update(pullRequests)
1125 .set({ isDraft: true, updatedAt: new Date() })
1126 .where(eq(pullRequests.id, pr.id));
1127 }
1128
1129 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1130 }
1131);
1132
0074234Claude1133// Close PR
1134pulls.post(
1135 "/:owner/:repo/pulls/:number/close",
1136 softAuth,
1137 requireAuth,
1138 async (c) => {
1139 const { owner: ownerName, repo: repoName } = c.req.param();
1140 const prNum = parseInt(c.req.param("number"), 10);
1141
1142 const resolved = await resolveRepo(ownerName, repoName);
1143 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1144
1145 await db
1146 .update(pullRequests)
1147 .set({
1148 state: "closed",
1149 closedAt: new Date(),
1150 updatedAt: new Date(),
1151 })
1152 .where(
1153 and(
1154 eq(pullRequests.repositoryId, resolved.repo.id),
1155 eq(pullRequests.number, prNum)
1156 )
1157 );
1158
1159 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1160 }
1161);
1162
e883329Claude1163/**
1164 * Trigger AI code review asynchronously after PR creation.
1165 * Runs the diff through Claude and posts review comments.
1166 */
1167async function triggerAiReview(
1168 ownerName: string,
1169 repoName: string,
1170 prId: string,
1171 title: string,
1172 body: string | null,
1173 baseBranch: string,
1174 headBranch: string
1175): Promise<void> {
1176 const repoDir = getRepoPath(ownerName, repoName);
1177
1178 // Get the diff between branches
1179 const proc = Bun.spawn(
1180 ["git", "diff", `${baseBranch}...${headBranch}`],
1181 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1182 );
1183 const diffText = await new Response(proc.stdout).text();
1184 await proc.exited;
1185
1186 if (!diffText.trim()) return;
1187
1188 const result = await reviewDiff(
1189 `${ownerName}/${repoName}`,
1190 title,
1191 body,
1192 baseBranch,
1193 headBranch,
1194 diffText
1195 );
1196
1197 // We need a system user for AI reviews — use the PR author for now
1198 // Get the PR to find the author
1199 const [pr] = await db
1200 .select()
1201 .from(pullRequests)
1202 .where(eq(pullRequests.id, prId))
1203 .limit(1);
1204
1205 if (!pr) return;
1206
1207 // Post summary comment
1208 const statusEmoji = result.approved ? "**Approved**" : "**Changes Requested**";
1209 let commentBody = `## AI Code Review ${statusEmoji}\n\n${result.summary}`;
1210
1211 if (result.comments.length > 0) {
1212 commentBody += "\n\n### Issues Found\n";
1213 for (const comment of result.comments) {
1214 const location = comment.filePath
1215 ? `\`${comment.filePath}${comment.lineNumber ? `:${comment.lineNumber}` : ""}\``
1216 : "";
1217 commentBody += `\n---\n${location}\n\n${comment.body}\n`;
1218 }
1219 }
1220
1221 await db.insert(prComments).values({
1222 pullRequestId: prId,
1223 authorId: pr.authorId,
1224 body: commentBody,
1225 isAiReview: true,
1226 });
1227
1228 // Post individual file-level comments
1229 for (const comment of result.comments) {
1230 if (comment.filePath) {
1231 await db.insert(prComments).values({
1232 pullRequestId: prId,
1233 authorId: pr.authorId,
1234 body: comment.body,
1235 isAiReview: true,
1236 filePath: comment.filePath,
1237 lineNumber: comment.lineNumber,
1238 });
1239 }
1240 }
1241
1242 console.log(
1243 `[ai-review] Review posted for PR ${prId}: ${result.approved ? "approved" : "changes requested"}, ${result.comments.length} comments`
1244 );
1245}
1246
3cbe3d6Claude1247/**
1248 * D3 — AI PR triage. Runs Claude Haiku on the PR title/body + diff summary and
1249 * posts an AI-authored comment suggesting labels, reviewers, and priority.
1250 * Nothing is auto-applied — the PR author remains in control.
1251 */
1252async function triggerPrTriage(args: {
1253 ownerName: string;
1254 repoName: string;
1255 repositoryId: string;
1256 prId: string;
1257 prAuthorId: string;
1258 title: string;
1259 body: string;
1260 baseBranch: string;
1261 headBranch: string;
1262}): Promise<void> {
1263 try {
1264 // Gather candidate reviewers (top contributors from recent commits).
1265 const repoDir = getRepoPath(args.ownerName, args.repoName);
1266 const shortlogProc = Bun.spawn(
1267 ["git", "shortlog", "-sn", "--no-merges", "-50", args.baseBranch],
1268 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1269 );
1270 const shortlogOut = await new Response(shortlogProc.stdout).text();
1271 await shortlogProc.exited;
1272 const authorNames = shortlogOut
1273 .trim()
1274 .split("\n")
1275 .map((l) => l.trim().split(/\s+/).slice(1).join(" "))
1276 .filter(Boolean)
1277 .slice(0, 10);
1278
1279 // Look up usernames matching these author names (best-effort match).
1280 const candidateUsernames: string[] = [];
1281 if (authorNames.length > 0) {
1282 try {
1283 const matches = await db
1284 .select({ username: users.username, displayName: users.displayName })
1285 .from(users)
1286 .limit(100);
1287 for (const u of matches) {
1288 if (
1289 authorNames.some(
1290 (n) =>
1291 u.username === n ||
1292 (u.displayName && u.displayName === n)
1293 )
1294 ) {
1295 candidateUsernames.push(u.username);
1296 }
1297 }
1298 } catch {
1299 /* ignore */
1300 }
1301 }
1302 // Always include the repo owner as a candidate reviewer.
1303 try {
1304 const [ownerRow] = await db
1305 .select({ username: users.username })
1306 .from(users)
1307 .where(eq(users.username, args.ownerName))
1308 .limit(1);
1309 if (ownerRow && !candidateUsernames.includes(ownerRow.username)) {
1310 candidateUsernames.push(ownerRow.username);
1311 }
1312 } catch {
1313 /* ignore */
1314 }
1315
1316 // Load repo labels.
1317 const availableLabels = await db
1318 .select({ name: labelsTable.name })
1319 .from(labelsTable)
1320 .where(eq(labelsTable.repositoryId, args.repositoryId))
1321 .then((rows) => rows.map((r) => r.name))
1322 .catch(() => [] as string[]);
1323
1324 // Short diff summary (numstat only to keep prompt small).
1325 const statProc = Bun.spawn(
1326 ["git", "diff", "--numstat", `${args.baseBranch}...${args.headBranch}`],
1327 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1328 );
1329 const diffSummary = await new Response(statProc.stdout).text();
1330 await statProc.exited;
1331
1332 const result = await triagePullRequest(
1333 args.title,
1334 args.body,
1335 diffSummary,
1336 availableLabels,
1337 candidateUsernames
1338 );
1339
1340 // Skip posting if we have absolutely nothing useful to say.
1341 if (
1342 !result.summary &&
1343 result.suggestedLabels.length === 0 &&
1344 result.suggestedReviewerUsernames.length === 0
1345 ) {
1346 return;
1347 }
1348
1349 const priorityEmoji =
1350 result.priority === "critical"
1351 ? "**Critical**"
1352 : result.priority === "high"
1353 ? "**High**"
1354 : result.priority === "low"
1355 ? "**Low**"
1356 : "**Medium**";
1357 const parts: string[] = [`## AI Triage\n`];
1358 if (result.summary) parts.push(`${result.summary}\n`);
1359 parts.push(`- **Priority:** ${priorityEmoji}`);
1360 parts.push(`- **Risk area:** ${result.riskArea}`);
1361 if (result.suggestedLabels.length > 0) {
1362 parts.push(
1363 `- **Suggested labels:** ${result.suggestedLabels.map((l) => `\`${l}\``).join(", ")}`
1364 );
1365 }
1366 if (result.suggestedReviewerUsernames.length > 0) {
1367 parts.push(
1368 `- **Suggested reviewers:** ${result.suggestedReviewerUsernames.map((u) => `@${u}`).join(", ")}`
1369 );
1370 }
1371 parts.push(
1372 `\n_Suggestions only — nothing was auto-applied. The PR author remains in control._`
1373 );
1374
1375 await db.insert(prComments).values({
1376 pullRequestId: args.prId,
1377 authorId: args.prAuthorId,
1378 body: parts.join("\n"),
1379 isAiReview: true,
1380 });
1381 } catch (err) {
1382 console.error("[pr-triage]", err);
1383 }
1384}
1385
0074234Claude1386function formatRelative(date: Date | string): string {
1387 const d = typeof date === "string" ? new Date(date) : date;
1388 const now = new Date();
1389 const diffMs = now.getTime() - d.getTime();
1390 const diffMins = Math.floor(diffMs / 60000);
1391 if (diffMins < 1) return "just now";
1392 if (diffMins < 60) return `${diffMins}m ago`;
1393 const diffHours = Math.floor(diffMins / 60);
1394 if (diffHours < 24) return `${diffHours}h ago`;
1395 const diffDays = Math.floor(diffHours / 24);
1396 if (diffDays < 30) return `${diffDays}d ago`;
1397 return d.toLocaleDateString("en-US", {
1398 month: "short",
1399 day: "numeric",
1400 year: "numeric",
1401 });
1402}
1403
3247f79Claude1404// ---------------------------------------------------------------------------
1405// J11 — Reviewer request management (manual add + dismiss)
1406// ---------------------------------------------------------------------------
1407pulls.post(
1408 "/:owner/:repo/pulls/:number/reviewers",
1409 softAuth,
1410 requireAuth,
1411 async (c) => {
1412 const { owner: ownerName, repo: repoName } = c.req.param();
1413 const prNum = parseInt(c.req.param("number"), 10);
1414 const user = c.get("user")!;
1415 const form = await c.req.parseBody();
1416 const usernameRaw = String(form.username || "").trim().replace(/^@/, "");
1417 if (!usernameRaw) {
1418 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1419 }
1420 const resolved = await resolveRepo(ownerName, repoName);
1421 if (!resolved) return c.notFound();
1422 const [pr] = await db
1423 .select()
1424 .from(pullRequests)
1425 .where(
1426 and(
1427 eq(pullRequests.repositoryId, resolved.repo.id),
1428 eq(pullRequests.number, prNum)
1429 )
1430 )
1431 .limit(1);
1432 if (!pr) return c.notFound();
1433 const canManage =
1434 user.id === resolved.owner.id || user.id === pr.authorId;
1435 if (!canManage) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1436 const [target] = await db
1437 .select()
1438 .from(users)
1439 .where(eq(users.username, usernameRaw))
1440 .limit(1);
1441 if (!target) {
1442 return c.redirect(
1443 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("User not found")}`
1444 );
1445 }
1446 if (target.id === pr.authorId) {
1447 return c.redirect(
1448 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("PR author cannot be a reviewer")}`
1449 );
1450 }
1451 await requestReviewers(pr.id, [target.id], user.id, "manual");
1452 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1453 }
1454);
1455
1456pulls.post(
1457 "/:owner/:repo/pulls/:number/reviewers/:reviewerId/dismiss",
1458 softAuth,
1459 requireAuth,
1460 async (c) => {
1461 const { owner: ownerName, repo: repoName, reviewerId } = c.req.param();
1462 const prNum = parseInt(c.req.param("number"), 10);
1463 const user = c.get("user")!;
1464 const resolved = await resolveRepo(ownerName, repoName);
1465 if (!resolved) return c.notFound();
1466 const [pr] = await db
1467 .select()
1468 .from(pullRequests)
1469 .where(
1470 and(
1471 eq(pullRequests.repositoryId, resolved.repo.id),
1472 eq(pullRequests.number, prNum)
1473 )
1474 )
1475 .limit(1);
1476 if (!pr) return c.notFound();
1477 const canManage =
1478 user.id === resolved.owner.id ||
1479 user.id === pr.authorId ||
1480 user.id === reviewerId;
1481 if (!canManage) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1482 await dismissRequest(pr.id, reviewerId);
1483 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1484 }
1485);
1486
1487interface ReviewersPanelProps {
1488 reviewers: Awaited<ReturnType<typeof listReviewRequestsForPr>>;
1489 canManage: boolean | null | undefined;
1490 ownerName: string;
1491 repoName: string;
1492 prNumber: number;
1493}
1494
1495function ReviewersPanel(props: ReviewersPanelProps) {
1496 const { reviewers, canManage, ownerName, repoName, prNumber } = props;
1497 const stateColor = (s: string) =>
1498 s === "approved"
1499 ? "var(--green)"
1500 : s === "changes_requested"
1501 ? "var(--red)"
1502 : s === "dismissed"
1503 ? "var(--text-muted)"
1504 : "var(--text)";
1505 return (
1506 <div
1507 style="margin: 0 0 20px; padding: 12px 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)"
1508 data-testid="reviewers-panel"
1509 >
1510 <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px">
1511 <h3 style="margin: 0; font-size: 14px">Reviewers</h3>
1512 <span style="color: var(--text-muted); font-size: 12px">
1513 {reviewers.length === 0
1514 ? "No reviewers requested"
1515 : `${reviewers.filter((r) => r.state === "pending").length} pending · ${reviewers.filter((r) => r.state === "approved").length} approved`}
1516 </span>
1517 </div>
1518 {reviewers.length > 0 && (
1519 <ul style="list-style: none; padding: 0; margin: 0 0 8px">
1520 {reviewers.map((r) => (
1521 <li style="display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px">
1522 <a href={`/${r.username}`}>
1523 <strong>@{r.username}</strong>
1524 </a>
1525 <span
1526 style={`font-size: 11px; padding: 1px 6px; border-radius: 10px; background: rgba(139, 148, 158, 0.15); color: ${stateColor(r.state)}; text-transform: capitalize`}
1527 >
1528 {r.state.replace("_", " ")}
1529 </span>
1530 <span style="font-size: 11px; color: var(--text-muted)">
1531 via {r.source}
1532 </span>
1533 {canManage && r.state !== "dismissed" && (
1534 <form
1535 method="POST"
1536 action={`/${ownerName}/${repoName}/pulls/${prNumber}/reviewers/${r.reviewerId}/dismiss`}
1537 style="margin-left: auto"
1538 >
1539 <button
1540 type="submit"
1541 class="btn"
1542 style="padding: 2px 8px; font-size: 11px"
1543 title="Dismiss this review request"
1544 >
1545 Dismiss
1546 </button>
1547 </form>
1548 )}
1549 </li>
1550 ))}
1551 </ul>
1552 )}
1553 {canManage && (
1554 <form
1555 method="POST"
1556 action={`/${ownerName}/${repoName}/pulls/${prNumber}/reviewers`}
1557 style="display: flex; gap: 6px"
1558 >
1559 <input
1560 type="text"
1561 name="username"
1562 placeholder="Request review from @username"
1563 required
1564 style="flex: 1; padding: 4px 8px; font-size: 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text)"
1565 />
1566 <button type="submit" class="btn" style="padding: 4px 10px; font-size: 12px">
1567 Request
1568 </button>
1569 </form>
1570 )}
1571 </div>
1572 );
1573}
1574
21ff9beClaude1575// J16 — PR auto-merge toggle
1576pulls.post(
1577 "/:owner/:repo/pulls/:number/auto-merge",
1578 softAuth,
1579 requireAuth,
1580 async (c) => {
1581 const { owner: ownerName, repo: repoName } = c.req.param();
1582 const prNum = parseInt(c.req.param("number"), 10);
1583 const user = c.get("user")!;
1584 const body = await c.req.parseBody();
1585 const method = String(body.mergeMethod || "merge").trim();
1586
1587 const resolved = await resolveRepo(ownerName, repoName);
1588 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1589 const [pr] = await db
1590 .select()
1591 .from(pullRequests)
1592 .where(
1593 and(
1594 eq(pullRequests.repositoryId, resolved.repo.id),
1595 eq(pullRequests.number, prNum)
1596 )
1597 )
1598 .limit(1);
1599 if (!pr) return c.notFound();
1600 const canManage =
1601 user.id === resolved.owner.id || user.id === pr.authorId;
1602 if (!canManage) {
1603 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1604 }
1605 if (pr.state !== "open" || pr.isDraft) {
1606 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1607 }
1608 try {
1609 const { enableAutoMerge, isValidMergeMethod } = await import(
1610 "../lib/pr-auto-merge"
1611 );
1612 await enableAutoMerge({
1613 pullRequestId: pr.id,
1614 enabledBy: user.id,
1615 mergeMethod: isValidMergeMethod(method) ? (method as any) : "merge",
1616 });
1617 } catch (err) {
1618 console.error("[pr-auto-merge] enable route failed:", err);
1619 }
1620 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1621 }
1622);
1623
1624pulls.post(
1625 "/:owner/:repo/pulls/:number/auto-merge/disable",
1626 softAuth,
1627 requireAuth,
1628 async (c) => {
1629 const { owner: ownerName, repo: repoName } = c.req.param();
1630 const prNum = parseInt(c.req.param("number"), 10);
1631 const user = c.get("user")!;
1632 const resolved = await resolveRepo(ownerName, repoName);
1633 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1634 const [pr] = await db
1635 .select()
1636 .from(pullRequests)
1637 .where(
1638 and(
1639 eq(pullRequests.repositoryId, resolved.repo.id),
1640 eq(pullRequests.number, prNum)
1641 )
1642 )
1643 .limit(1);
1644 if (!pr) return c.notFound();
1645 const canManage =
1646 user.id === resolved.owner.id || user.id === pr.authorId;
1647 if (!canManage) {
1648 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1649 }
1650 try {
1651 const { disableAutoMerge } = await import("../lib/pr-auto-merge");
1652 await disableAutoMerge(pr.id);
1653 } catch (err) {
1654 console.error("[pr-auto-merge] disable route failed:", err);
1655 }
1656 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1657 }
1658);
1659
1660// J16 — UI panel
1661interface AutoMergePanelProps {
1662 owner: string;
1663 repo: string;
1664 prNumber: number;
1665 state: {
1666 enabled: boolean;
1667 mergeMethod: string;
1668 lastStatus: string | null;
1669 notifiedReady: boolean;
1670 };
1671}
1672
1673function AutoMergePanel(props: AutoMergePanelProps) {
1674 const { owner, repo, prNumber, state } = props;
1675 let statusLabel: string;
1676 let statusColor: string;
1677 if (!state.enabled) {
1678 statusLabel = "Auto-merge is off";
1679 statusColor = "var(--text-muted)";
1680 } else if (state.lastStatus === "success" || state.notifiedReady) {
1681 statusLabel = "All checks passed \u2014 ready to merge";
1682 statusColor = "var(--green)";
1683 } else if (state.lastStatus === "failure" || state.lastStatus === "error") {
1684 statusLabel = "Checks failing \u2014 auto-merge paused";
1685 statusColor = "var(--red)";
1686 } else if (state.lastStatus === "pending") {
1687 statusLabel = "Waiting for checks to finish";
1688 statusColor = "var(--yellow)";
1689 } else {
1690 statusLabel = "Enabled \u2014 waiting for first check report";
1691 statusColor = "var(--yellow)";
1692 }
1693
1694 return (
1695 <div style="margin-top: 20px; padding: 12px 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)">
1696 <div style="display: flex; align-items: center; gap: 12px">
1697 <div style={`font-size: 13px; font-weight: 600; color: ${statusColor}; flex: 1`}>
1698 {"\u26A1"} Auto-merge: {statusLabel}
1699 </div>
1700 {state.enabled ? (
1701 <form
1702 method="POST"
1703 action={`/${owner}/${repo}/pulls/${prNumber}/auto-merge/disable`}
1704 style="margin: 0"
1705 >
1706 <button type="submit" class="btn" style="padding: 4px 10px; font-size: 12px">
1707 Disable auto-merge
1708 </button>
1709 </form>
1710 ) : (
1711 <form
1712 method="POST"
1713 action={`/${owner}/${repo}/pulls/${prNumber}/auto-merge`}
1714 style="margin: 0; display: flex; gap: 6px; align-items: center"
1715 >
1716 <select
1717 name="mergeMethod"
1718 style="padding: 4px 8px; font-size: 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text)"
1719 >
1720 <option value="merge">Merge</option>
1721 <option value="squash">Squash</option>
1722 <option value="rebase">Rebase</option>
1723 </select>
1724 <button type="submit" class="btn" style="padding: 4px 10px; font-size: 12px">
1725 Enable auto-merge
1726 </button>
1727 </form>
1728 )}
1729 </div>
1730 {state.enabled && (
1731 <div style="margin-top: 6px; font-size: 11px; color: var(--text-muted)">
1732 Chosen method: <code>{state.mergeMethod}</code>. The PR stays open
1733 until all commit statuses report success. You'll be notified on the
1734 PR when it becomes ready to merge.
1735 </div>
1736 )}
1737 </div>
1738 );
1739}
1740
0074234Claude1741export default pulls;