Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

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.tsxBlame1781 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";
7c0203eClaude49import { extractSuggestions } from "../lib/code-suggestions";
0074234Claude50
51const pulls = new Hono<AuthEnv>();
52
53async function resolveRepo(ownerName: string, repoName: string) {
54 const [owner] = await db
55 .select()
56 .from(users)
57 .where(eq(users.username, ownerName))
58 .limit(1);
59 if (!owner) return null;
60 const [repo] = await db
61 .select()
62 .from(repositories)
63 .where(
64 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
65 )
66 .limit(1);
67 if (!repo) return null;
68 return { owner, repo };
69}
70
71// PR Nav helper
72const PrNav = ({
73 owner,
74 repo,
75 active,
76}: {
77 owner: string;
78 repo: string;
79 active: "code" | "issues" | "pulls" | "commits";
80}) => (
81 <div class="repo-nav">
82 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
83 Code
84 </a>
85 <a
86 href={`/${owner}/${repo}/issues`}
87 class={active === "issues" ? "active" : ""}
88 >
89 Issues
90 </a>
91 <a
92 href={`/${owner}/${repo}/pulls`}
93 class={active === "pulls" ? "active" : ""}
94 >
95 Pull Requests
96 </a>
97 <a
98 href={`/${owner}/${repo}/commits`}
99 class={active === "commits" ? "active" : ""}
100 >
101 Commits
102 </a>
103 </div>
104);
105
106// List PRs
107pulls.get("/:owner/:repo/pulls", softAuth, async (c) => {
108 const { owner: ownerName, repo: repoName } = c.req.param();
109 const user = c.get("user");
110 const state = c.req.query("state") || "open";
111
112 const resolved = await resolveRepo(ownerName, repoName);
113 if (!resolved) return c.notFound();
114
6fc53bdClaude115 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
116 const stateFilter =
117 state === "draft"
118 ? and(
119 eq(pullRequests.state, "open"),
120 eq(pullRequests.isDraft, true)
121 )
122 : eq(pullRequests.state, state);
123
0074234Claude124 const prList = await db
125 .select({
126 pr: pullRequests,
127 author: { username: users.username },
128 })
129 .from(pullRequests)
130 .innerJoin(users, eq(pullRequests.authorId, users.id))
131 .where(
6fc53bdClaude132 and(eq(pullRequests.repositoryId, resolved.repo.id), stateFilter)
0074234Claude133 )
134 .orderBy(desc(pullRequests.createdAt));
135
136 const [counts] = await db
137 .select({
138 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude139 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude140 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
141 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
142 })
143 .from(pullRequests)
144 .where(eq(pullRequests.repositoryId, resolved.repo.id));
145
146 return c.html(
147 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
148 <RepoHeader owner={ownerName} repo={repoName} />
149 <PrNav owner={ownerName} repo={repoName} active="pulls" />
150 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
151 <div class="issue-tabs">
152 <a
153 href={`/${ownerName}/${repoName}/pulls?state=open`}
154 class={state === "open" ? "active" : ""}
155 >
156 {counts?.open ?? 0} Open
157 </a>
6fc53bdClaude158 <a
159 href={`/${ownerName}/${repoName}/pulls?state=draft`}
160 class={state === "draft" ? "active" : ""}
161 >
162 {counts?.draft ?? 0} Draft
163 </a>
0074234Claude164 <a
165 href={`/${ownerName}/${repoName}/pulls?state=merged`}
166 class={state === "merged" ? "active" : ""}
167 >
168 {counts?.merged ?? 0} Merged
169 </a>
170 <a
171 href={`/${ownerName}/${repoName}/pulls?state=closed`}
172 class={state === "closed" ? "active" : ""}
173 >
174 {counts?.closed ?? 0} Closed
175 </a>
176 </div>
177 {user && (
178 <a
179 href={`/${ownerName}/${repoName}/pulls/new`}
180 class="btn btn-primary"
181 >
182 New pull request
183 </a>
184 )}
185 </div>
186 {prList.length === 0 ? (
187 <div class="empty-state">
188 <p>No {state} pull requests.</p>
189 </div>
190 ) : (
191 <div class="issue-list">
6fc53bdClaude192 {prList.map(({ pr, author }) => {
193 const isDraft = pr.state === "open" && pr.isDraft;
194 const stateClass = isDraft
195 ? "state-draft"
196 : pr.state === "open"
197 ? "state-open"
198 : pr.state === "merged"
199 ? "state-merged"
200 : "state-closed";
201 const stateIcon = isDraft
202 ? "\u270E"
203 : pr.state === "open"
204 ? "\u25CB"
205 : pr.state === "merged"
206 ? "\u2B8C"
207 : "\u2713";
208 return (
209 <div class="issue-item">
210 <div class={`issue-state-icon ${stateClass}`}>{stateIcon}</div>
211 <div>
212 <div class="issue-title">
213 <a href={`/${ownerName}/${repoName}/pulls/${pr.number}`}>
214 {pr.title}
215 </a>
216 {isDraft && (
217 <span class="issue-badge draft-badge" style="margin-left: 8px; font-size: 11px; padding: 2px 8px">
218 Draft
219 </span>
220 )}
221 </div>
222 <div class="issue-meta">
223 #{pr.number}{" "}
224 {pr.headBranch} → {pr.baseBranch}{" "}
225 by {author.username}{" "}
226 {formatRelative(pr.createdAt)}
227 </div>
0074234Claude228 </div>
229 </div>
6fc53bdClaude230 );
231 })}
0074234Claude232 </div>
233 )}
234 </Layout>
235 );
236});
237
238// New PR form
239pulls.get(
240 "/:owner/:repo/pulls/new",
241 softAuth,
242 requireAuth,
243 async (c) => {
244 const { owner: ownerName, repo: repoName } = c.req.param();
245 const user = c.get("user")!;
246 const branches = await listBranches(ownerName, repoName);
247 const error = c.req.query("error");
248 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude249 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude250
251 return c.html(
252 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
253 <RepoHeader owner={ownerName} repo={repoName} />
254 <PrNav owner={ownerName} repo={repoName} active="pulls" />
255 <div style="max-width: 800px">
256 <h2 style="margin-bottom: 16px">Open a pull request</h2>
24cf2caClaude257 {template && (
258 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 8px">
259 Using <code>PULL_REQUEST_TEMPLATE.md</code> from the default branch.
260 </div>
261 )}
0074234Claude262 {error && (
263 <div class="auth-error">{decodeURIComponent(error)}</div>
264 )}
265 <form method="POST" action={`/${ownerName}/${repoName}/pulls/new`}>
266 <div style="display: flex; gap: 12px; align-items: center; margin-bottom: 16px">
267 <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">
268 {branches.map((b) => (
269 <option value={b} selected={b === defaultBase}>
270 {b}
271 </option>
272 ))}
273 </select>
274 <span style="color: var(--text-muted)">&larr;</span>
275 <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">
276 {branches
277 .filter((b) => b !== defaultBase)
278 .concat(defaultBase === branches[0] ? [] : [branches[0]])
279 .map((b) => (
280 <option value={b}>{b}</option>
281 ))}
282 </select>
283 </div>
284 <div class="form-group">
285 <input
286 type="text"
287 name="title"
288 required
289 placeholder="Title"
290 style="font-size: 16px; padding: 10px 14px"
291 />
292 </div>
293 <div class="form-group">
294 <textarea
295 name="body"
296 rows={8}
297 placeholder="Description (Markdown supported)"
298 style="font-family: var(--font-mono); font-size: 13px"
24cf2caClaude299 >
300 {template || ""}
301 </textarea>
0074234Claude302 </div>
6fc53bdClaude303 <div style="display: flex; gap: 8px">
304 <button type="submit" class="btn btn-primary">
305 Create pull request
306 </button>
307 <button
308 type="submit"
309 name="draft"
310 value="1"
311 class="btn"
312 title="Create a draft PR — skips AI review and cannot be merged until marked ready"
313 >
314 Create draft
315 </button>
316 </div>
0074234Claude317 </form>
318 </div>
319 </Layout>
320 );
321 }
322);
323
324// Create PR
325pulls.post(
326 "/:owner/:repo/pulls/new",
327 softAuth,
328 requireAuth,
329 async (c) => {
330 const { owner: ownerName, repo: repoName } = c.req.param();
331 const user = c.get("user")!;
332 const body = await c.req.parseBody();
333 const title = String(body.title || "").trim();
334 const prBody = String(body.body || "").trim();
335 const baseBranch = String(body.base || "main");
336 const headBranch = String(body.head || "");
337
338 if (!title || !headBranch) {
339 return c.redirect(
340 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
341 );
342 }
343
344 if (baseBranch === headBranch) {
345 return c.redirect(
346 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
347 );
348 }
349
350 const resolved = await resolveRepo(ownerName, repoName);
351 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
352
6fc53bdClaude353 const isDraft = String(body.draft || "") === "1";
354
0074234Claude355 const [pr] = await db
356 .insert(pullRequests)
357 .values({
358 repositoryId: resolved.repo.id,
359 authorId: user.id,
360 title,
361 body: prBody || null,
362 baseBranch,
363 headBranch,
6fc53bdClaude364 isDraft,
0074234Claude365 })
366 .returning();
367
6fc53bdClaude368 // Skip AI review on drafts — it runs again when the PR is marked ready.
369 if (!isDraft && isAiReviewEnabled()) {
e883329Claude370 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
371 (err) => console.error("[ai-review] Failed:", err)
372 );
373 }
374
3cbe3d6Claude375 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
376 triggerPrTriage({
377 ownerName,
378 repoName,
379 repositoryId: resolved.repo.id,
380 prId: pr.id,
381 prAuthorId: user.id,
382 title,
383 body: prBody,
384 baseBranch,
385 headBranch,
386 }).catch((err) => console.error("[pr-triage] Failed:", err));
387
3247f79Claude388 // J11 — fire-and-forget CODEOWNERS auto-assign. Reads diff paths
389 // between base..head and requests review from the matched owners.
390 (async () => {
391 try {
392 const repoDir = getRepoPath(ownerName, repoName);
393 const statProc = Bun.spawn(
394 ["git", "diff", "--numstat", `${baseBranch}...${headBranch}`],
395 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
396 );
397 const stat = await new Response(statProc.stdout).text();
398 await statProc.exited;
399 const changedPaths = stat
400 .trim()
401 .split("\n")
402 .filter(Boolean)
403 .map((line) => line.split("\t")[2])
404 .filter(Boolean);
405 await autoAssignFromCodeowners({
406 repositoryId: resolved.repo.id,
407 pullRequestId: pr.id,
408 authorId: user.id,
409 changedPaths,
410 prUrl: `/${ownerName}/${repoName}/pulls/${pr.number}`,
411 prTitle: title,
412 });
413 } catch (err) {
414 console.error("[codeowners-assign] Failed:", err);
415 }
416 })();
417
0074234Claude418 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
419 }
420);
421
422// View single PR
423pulls.get("/:owner/:repo/pulls/:number", softAuth, async (c) => {
424 const { owner: ownerName, repo: repoName } = c.req.param();
425 const prNum = parseInt(c.req.param("number"), 10);
426 const user = c.get("user");
427 const tab = c.req.query("tab") || "conversation";
428
429 const resolved = await resolveRepo(ownerName, repoName);
430 if (!resolved) return c.notFound();
431
432 const [pr] = await db
433 .select()
434 .from(pullRequests)
435 .where(
436 and(
437 eq(pullRequests.repositoryId, resolved.repo.id),
438 eq(pullRequests.number, prNum)
439 )
440 )
441 .limit(1);
442
443 if (!pr) return c.notFound();
444
445 const [author] = await db
446 .select()
447 .from(users)
448 .where(eq(users.id, pr.authorId))
449 .limit(1);
450
451 const comments = await db
452 .select({
453 comment: prComments,
454 author: { username: users.username },
455 })
456 .from(prComments)
457 .innerJoin(users, eq(prComments.authorId, users.id))
458 .where(eq(prComments.pullRequestId, pr.id))
459 .orderBy(asc(prComments.createdAt));
460
6fc53bdClaude461 // Reactions for the PR body + each comment, in parallel.
462 const [prReactions, ...prCommentReactions] = await Promise.all([
463 summariseReactions("pr", pr.id, user?.id),
464 ...comments.map((row) =>
465 summariseReactions("pr_comment", row.comment.id, user?.id)
466 ),
467 ]);
468
0074234Claude469 const canManage =
470 user &&
471 (user.id === resolved.owner.id || user.id === pr.authorId);
472
e883329Claude473 const error = c.req.query("error");
474
475 // Get gate check status for open PRs
476 let gateChecks: GateCheckResult[] = [];
477 if (pr.state === "open") {
478 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
479 if (headSha) {
480 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
481 const aiApproved = aiComments.length === 0 || aiComments.some(
482 ({ comment }) => comment.body.includes("**Approved**")
483 );
484 const gateResult = await runAllGateChecks(
485 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
486 );
487 gateChecks = gateResult.checks;
488 }
489 }
490
3247f79Claude491 // J11 — requested reviewers (CODEOWNERS auto-assign + manual add).
492 const reviewRequests = await listReviewRequestsForPr(pr.id);
493
21ff9beClaude494 // J16 — auto-merge opt-in state.
495 let autoMergeState: {
496 enabled: boolean;
497 mergeMethod: string;
498 lastStatus: string | null;
499 notifiedReady: boolean;
500 } = { enabled: false, mergeMethod: "merge", lastStatus: null, notifiedReady: false };
501 try {
502 const { getAutoMergeForPr } = await import("../lib/pr-auto-merge");
503 const am = await getAutoMergeForPr(pr.id);
504 if (am) autoMergeState = am as typeof autoMergeState;
505 } catch {
506 /* ignore */
507 }
508
0074234Claude509 // Get diff for "Files changed" tab
510 let diffRaw = "";
511 let diffFiles: GitDiffFile[] = [];
512 if (tab === "files") {
513 const repoDir = getRepoPath(ownerName, repoName);
514 const proc = Bun.spawn(
515 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
516 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
517 );
518 diffRaw = await new Response(proc.stdout).text();
519 await proc.exited;
520
521 const statProc = Bun.spawn(
522 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
523 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
524 );
525 const stat = await new Response(statProc.stdout).text();
526 await statProc.exited;
527
528 diffFiles = stat
529 .trim()
530 .split("\n")
531 .filter(Boolean)
532 .map((line) => {
533 const [add, del, filePath] = line.split("\t");
534 return {
535 path: filePath,
536 status: "modified",
537 additions: add === "-" ? 0 : parseInt(add, 10),
538 deletions: del === "-" ? 0 : parseInt(del, 10),
539 patch: "",
540 };
541 });
542 }
543
544 return c.html(
545 <Layout
546 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
547 user={user}
548 >
549 <RepoHeader owner={ownerName} repo={repoName} />
550 <PrNav owner={ownerName} repo={repoName} active="pulls" />
551 <div class="issue-detail">
552 <h2>
553 {pr.title}{" "}
554 <span style="color: var(--text-muted); font-weight: 400">
555 #{pr.number}
556 </span>
557 </h2>
558 <div style="margin: 8px 0 20px; display: flex; align-items: center; gap: 8px">
6fc53bdClaude559 {pr.state === "open" && pr.isDraft ? (
560 <span class="issue-badge draft-badge">
561 {"\u270E Draft"}
562 </span>
563 ) : (
564 <span
565 class={`issue-badge ${pr.state === "open" ? "badge-open" : pr.state === "merged" ? "badge-merged" : "badge-closed"}`}
566 >
567 {pr.state === "open"
568 ? "\u25CB Open"
569 : pr.state === "merged"
570 ? "\u2B8C Merged"
571 : "\u2713 Closed"}
572 </span>
573 )}
0074234Claude574 <span style="color: var(--text-muted); font-size: 14px">
575 <strong style="color: var(--text)">
576 {author?.username}
577 </strong>{" "}
578 wants to merge <code>{pr.headBranch}</code> into{" "}
579 <code>{pr.baseBranch}</code>
580 </span>
581 </div>
582
3247f79Claude583 <ReviewersPanel
584 reviewers={reviewRequests}
585 canManage={canManage}
586 ownerName={ownerName}
587 repoName={repoName}
588 prNumber={pr.number}
589 />
590
0074234Claude591 <div class="issue-tabs" style="margin-bottom: 20px">
592 <a
593 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
594 class={tab === "conversation" ? "active" : ""}
595 >
596 Conversation
597 </a>
598 <a
599 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
600 class={tab === "files" ? "active" : ""}
601 >
602 Files changed
603 </a>
604 </div>
605
606 {tab === "files" ? (
607 <DiffView raw={diffRaw} files={diffFiles} />
608 ) : (
609 <>
610 {pr.body && (
611 <div class="issue-comment-box">
612 <div class="comment-header">
613 <strong>{author?.username}</strong> commented{" "}
614 {formatRelative(pr.createdAt)}
615 </div>
616 <div class="markdown-body">
617 {html([renderMarkdown(pr.body)] as unknown as TemplateStringsArray)}
618 </div>
6fc53bdClaude619 <div style="padding: 0 16px 12px">
620 <ReactionsBar
621 targetType="pr"
622 targetId={pr.id}
623 summaries={prReactions}
624 canReact={!!user}
625 />
626 </div>
0074234Claude627 </div>
628 )}
629
6fc53bdClaude630 {comments.map(({ comment, author: commentAuthor }, i) => (
0074234Claude631 <div
632 class={`issue-comment-box ${comment.isAiReview ? "ai-review" : ""}`}
633 >
634 <div class="comment-header">
635 <strong>{commentAuthor.username}</strong>
636 {comment.isAiReview && (
637 <span class="badge" style="margin-left: 8px; background: rgba(31, 111, 235, 0.15); color: var(--text-link); border-color: var(--accent)">
638 AI Review
639 </span>
640 )}
641 {" "}
642 commented {formatRelative(comment.createdAt)}
643 {comment.filePath && (
644 <span style="margin-left: 8px; font-family: var(--font-mono); font-size: 11px">
645 {comment.filePath}
646 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
647 </span>
648 )}
649 </div>
650 <div class="markdown-body">
651 {html([renderMarkdown(comment.body)] as unknown as TemplateStringsArray)}
652 </div>
7c0203eClaude653 {(() => {
654 const suggestions = extractSuggestions(comment.body);
655 if (
656 suggestions.length === 0 ||
657 !comment.filePath ||
658 !comment.lineNumber ||
659 pr.state !== "open"
660 ) {
661 return null as unknown as JSX.Element;
662 }
663 const canApply =
664 !!user &&
665 (user.id === resolved.owner.id ||
666 user.id === pr.authorId ||
667 user.id === comment.authorId);
668 if (!canApply) return null as unknown as JSX.Element;
669 return (
670 <div style="padding: 0 16px 12px; display: flex; gap: 6px; flex-wrap: wrap; border-top: 1px solid var(--border); margin-top: 4px; padding-top: 10px">
671 {suggestions.map((_, si) => (
672 <form
673 method="POST"
674 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comments/${comment.id}/apply-suggestion`}
675 style="display: inline"
676 >
677 <input type="hidden" name="index" value={String(si)} />
678 <button
679 type="submit"
680 class="btn btn-primary"
681 style="padding: 3px 10px; font-size: 12px"
682 >
683 {suggestions.length === 1
684 ? "Commit suggestion"
685 : `Commit suggestion ${si + 1}`}
686 </button>
687 </form>
688 ))}
689 </div>
690 );
691 })()}
6fc53bdClaude692 <div style="padding: 0 16px 12px">
693 <ReactionsBar
694 targetType="pr_comment"
695 targetId={comment.id}
696 summaries={prCommentReactions[i] || []}
697 canReact={!!user}
698 />
699 </div>
0074234Claude700 </div>
701 ))}
702
e883329Claude703 {error && (
704 <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)">
705 {decodeURIComponent(error)}
706 </div>
707 )}
708
21ff9beClaude709 {pr.state === "open" && canManage && (
710 <AutoMergePanel
711 owner={ownerName}
712 repo={repoName}
713 prNumber={pr.number}
714 state={autoMergeState}
715 />
716 )}
717
e883329Claude718 {pr.state === "open" && gateChecks.length > 0 && (
719 <div style="margin-top: 20px; padding: 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)">
720 <h3 style="margin: 0 0 12px; font-size: 14px">Gate Checks</h3>
721 {gateChecks.map((check) => (
722 <div style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border)">
723 <span style={`font-size: 16px; color: ${check.passed ? "var(--green)" : "var(--red)"}`}>
724 {check.passed ? "\u2713" : "\u2717"}
725 </span>
726 <strong style="font-size: 13px">{check.name}</strong>
727 <span style="font-size: 12px; color: var(--text-muted); margin-left: auto">{check.details}</span>
728 </div>
729 ))}
730 <div style="margin-top: 8px; font-size: 12px; color: var(--text-muted)">
731 {gateChecks.every((c) => c.passed)
732 ? "All checks passed — ready to merge"
733 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
734 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge"
735 : "Some checks failed — resolve issues before merging"}
736 </div>
737 </div>
738 )}
739
0074234Claude740 {user && pr.state === "open" && (
741 <div style="margin-top: 20px">
742 <form
743 method="POST"
744 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
745 >
746 <div class="form-group">
747 <textarea
748 name="body"
749 rows={6}
750 required
751 placeholder="Leave a comment... (Markdown supported)"
752 style="font-family: var(--font-mono); font-size: 13px"
753 />
754 </div>
755 <div style="display: flex; gap: 8px">
756 <button type="submit" class="btn btn-primary">
757 Comment
758 </button>
759 {canManage && (
760 <>
6fc53bdClaude761 {pr.isDraft ? (
762 <button
763 type="submit"
764 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
765 class="btn"
766 style="background: rgba(63, 185, 80, 0.15); border-color: var(--green); color: var(--green)"
767 title="Mark this draft PR as ready for review — triggers AI review"
768 >
769 Ready for review
770 </button>
771 ) : (
772 <>
773 <button
774 type="submit"
775 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
776 class="btn"
777 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)"}`}
778 >
779 {gateChecks.every((c) => c.passed)
780 ? "Merge pull request"
781 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
782 ? "Merge with auto-resolve"
783 : "Merge pull request"}
784 </button>
a79a9edClaude785 <button
786 type="submit"
787 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/enqueue`}
788 class="btn"
789 title="Queue this PR — gates will re-run against latest base before merge"
790 >
791 Add to merge queue
792 </button>
6fc53bdClaude793 <button
794 type="submit"
795 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
796 class="btn"
797 title="Convert back to draft"
798 >
799 Convert to draft
800 </button>
801 </>
802 )}
0074234Claude803 <button
804 type="submit"
805 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
806 class="btn btn-danger"
807 >
808 Close
809 </button>
810 </>
811 )}
812 </div>
813 </form>
814 </div>
815 )}
816 </>
817 )}
818 </div>
819 </Layout>
820 );
821});
822
823// Add comment to PR
824pulls.post(
825 "/:owner/:repo/pulls/:number/comment",
826 softAuth,
827 requireAuth,
828 async (c) => {
829 const { owner: ownerName, repo: repoName } = c.req.param();
830 const prNum = parseInt(c.req.param("number"), 10);
831 const user = c.get("user")!;
832 const body = await c.req.parseBody();
833 const commentBody = String(body.body || "").trim();
834
835 if (!commentBody) {
836 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
837 }
838
839 const resolved = await resolveRepo(ownerName, repoName);
840 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
841
842 const [pr] = await db
843 .select()
844 .from(pullRequests)
845 .where(
846 and(
847 eq(pullRequests.repositoryId, resolved.repo.id),
848 eq(pullRequests.number, prNum)
849 )
850 )
851 .limit(1);
852
853 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
854
855 await db.insert(prComments).values({
856 pullRequestId: pr.id,
857 authorId: user.id,
858 body: commentBody,
859 });
860
861 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
862 }
863);
864
e883329Claude865// Merge PR — with green gate enforcement and auto conflict resolution
0074234Claude866pulls.post(
867 "/:owner/:repo/pulls/:number/merge",
868 softAuth,
869 requireAuth,
870 async (c) => {
871 const { owner: ownerName, repo: repoName } = c.req.param();
872 const prNum = parseInt(c.req.param("number"), 10);
873 const user = c.get("user")!;
874
875 const resolved = await resolveRepo(ownerName, repoName);
876 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
877
878 const [pr] = await db
879 .select()
880 .from(pullRequests)
881 .where(
882 and(
883 eq(pullRequests.repositoryId, resolved.repo.id),
884 eq(pullRequests.number, prNum)
885 )
886 )
887 .limit(1);
888
889 if (!pr || pr.state !== "open") {
890 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
891 }
892
6fc53bdClaude893 // Draft PRs cannot be merged — must be marked ready first.
894 if (pr.isDraft) {
895 return c.redirect(
896 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
897 "This PR is a draft. Mark it as ready for review before merging."
898 )}`
899 );
900 }
901
e883329Claude902 // Resolve head SHA
903 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
904 if (!headSha) {
905 return c.redirect(
906 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
907 );
908 }
909
910 // Check if AI review approved this PR
911 const aiComments = await db
912 .select()
913 .from(prComments)
914 .where(
915 and(
916 eq(prComments.pullRequestId, pr.id),
917 eq(prComments.isAiReview, true)
918 )
919 );
920 const aiApproved = aiComments.length === 0 || aiComments.some(
921 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude922 );
e883329Claude923
924 // Run all green gate checks (GateTest + mergeability + AI review)
925 const gateResult = await runAllGateChecks(
926 ownerName,
927 repoName,
928 pr.baseBranch,
929 pr.headBranch,
930 headSha,
931 aiApproved
0074234Claude932 );
933
e883329Claude934 // If GateTest or AI review failed (hard blocks), reject the merge
935 const hardFailures = gateResult.checks.filter(
936 (check) => !check.passed && check.name !== "Merge check"
937 );
938 if (hardFailures.length > 0) {
939 const errorMsg = hardFailures
940 .map((f) => `${f.name}: ${f.details}`)
941 .join("; ");
0074234Claude942 return c.redirect(
e883329Claude943 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude944 );
945 }
946
1e162a8Claude947 // D5 — Branch-protection enforcement. Looks up the matching rule for the
948 // base branch and blocks the merge if requireAiApproval / requireGreenGates
949 // / requireHumanReview / requiredApprovals are not satisfied. Independent
950 // of repo-global settings, so owners can lock specific branches down
951 // further than the repo default.
952 const protectionRule = await matchProtection(
953 resolved.repo.id,
954 pr.baseBranch
955 );
956 if (protectionRule) {
957 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude958 const required = await listRequiredChecks(protectionRule.id);
959 const passingNames = required.length > 0
960 ? await passingCheckNames(resolved.repo.id, headSha)
961 : [];
962 const decision = evaluateProtection(
963 protectionRule,
964 {
965 aiApproved,
966 humanApprovalCount: humanApprovals,
967 gateResultGreen: hardFailures.length === 0,
968 hasFailedGates: hardFailures.length > 0,
969 passingCheckNames: passingNames,
970 },
971 required.map((r) => r.checkName)
972 );
1e162a8Claude973 if (!decision.allowed) {
974 return c.redirect(
975 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
976 decision.reasons.join(" ")
977 )}`
978 );
979 }
980 }
981
e883329Claude982 // Attempt the merge — with auto conflict resolution if needed
983 const repoDir = getRepoPath(ownerName, repoName);
984 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
985 const hasConflicts = mergeCheck && !mergeCheck.passed;
986
987 if (hasConflicts && isAiReviewEnabled()) {
988 // Use Claude to auto-resolve conflicts
989 const mergeResult = await mergeWithAutoResolve(
990 ownerName,
991 repoName,
992 pr.baseBranch,
993 pr.headBranch,
994 `Merge pull request #${pr.number}: ${pr.title}`
995 );
996
997 if (!mergeResult.success) {
998 return c.redirect(
999 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
1000 );
1001 }
1002
1003 // Post a comment about the auto-resolution
1004 if (mergeResult.resolvedFiles.length > 0) {
1005 await db.insert(prComments).values({
1006 pullRequestId: pr.id,
1007 authorId: user.id,
1008 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
1009 isAiReview: true,
1010 });
1011 }
1012 } else {
1013 // Standard merge — fast-forward or clean merge
1014 const ffProc = Bun.spawn(
1015 [
1016 "git",
1017 "update-ref",
1018 `refs/heads/${pr.baseBranch}`,
1019 `refs/heads/${pr.headBranch}`,
1020 ],
1021 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1022 );
1023 const ffExit = await ffProc.exited;
1024
1025 if (ffExit !== 0) {
1026 return c.redirect(
1027 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
1028 );
1029 }
1030 }
1031
0074234Claude1032 await db
1033 .update(pullRequests)
1034 .set({
1035 state: "merged",
1036 mergedAt: new Date(),
1037 mergedBy: user.id,
1038 updatedAt: new Date(),
1039 })
1040 .where(eq(pullRequests.id, pr.id));
1041
d62fb36Claude1042 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
1043 // and auto-close each matching open issue with a back-link comment. Bounded
1044 // to the same repo for v1 (cross-repo refs ignored). Failures never block
1045 // the merge redirect.
1046 try {
1047 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
1048 const refs = extractClosingRefsMulti([pr.title, pr.body]);
1049 for (const n of refs) {
1050 const [issue] = await db
1051 .select()
1052 .from(issues)
1053 .where(
1054 and(
1055 eq(issues.repositoryId, resolved.repo.id),
1056 eq(issues.number, n)
1057 )
1058 )
1059 .limit(1);
1060 if (!issue || issue.state !== "open") continue;
1061 await db
1062 .update(issues)
1063 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1064 .where(eq(issues.id, issue.id));
1065 await db.insert(issueComments).values({
1066 issueId: issue.id,
1067 authorId: user.id,
1068 body: `Closed by pull request #${pr.number}.`,
1069 });
1070 }
1071 } catch {
1072 // Never block the merge on close-keyword failures.
1073 }
1074
0074234Claude1075 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1076 }
1077);
1078
6fc53bdClaude1079// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
1080// hasn't run yet on this PR.
1081pulls.post(
1082 "/:owner/:repo/pulls/:number/ready",
1083 softAuth,
1084 requireAuth,
1085 async (c) => {
1086 const { owner: ownerName, repo: repoName } = c.req.param();
1087 const prNum = parseInt(c.req.param("number"), 10);
1088 const user = c.get("user")!;
1089
1090 const resolved = await resolveRepo(ownerName, repoName);
1091 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1092
1093 const [pr] = await db
1094 .select()
1095 .from(pullRequests)
1096 .where(
1097 and(
1098 eq(pullRequests.repositoryId, resolved.repo.id),
1099 eq(pullRequests.number, prNum)
1100 )
1101 )
1102 .limit(1);
1103 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
1104
1105 // Only the author or repo owner can toggle draft state.
1106 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
1107 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1108 }
1109
1110 if (pr.state === "open" && pr.isDraft) {
1111 await db
1112 .update(pullRequests)
1113 .set({ isDraft: false, updatedAt: new Date() })
1114 .where(eq(pullRequests.id, pr.id));
1115
1116 if (isAiReviewEnabled()) {
1117 triggerAiReview(
1118 ownerName,
1119 repoName,
1120 pr.id,
1121 pr.title,
1122 pr.body,
1123 pr.baseBranch,
1124 pr.headBranch
1125 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
1126 }
1127 }
1128
1129 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1130 }
1131);
1132
1133// Convert a PR back to draft.
1134pulls.post(
1135 "/:owner/:repo/pulls/:number/draft",
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 const user = c.get("user")!;
1142
1143 const resolved = await resolveRepo(ownerName, repoName);
1144 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1145
1146 const [pr] = await db
1147 .select()
1148 .from(pullRequests)
1149 .where(
1150 and(
1151 eq(pullRequests.repositoryId, resolved.repo.id),
1152 eq(pullRequests.number, prNum)
1153 )
1154 )
1155 .limit(1);
1156 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
1157
1158 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
1159 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1160 }
1161
1162 if (pr.state === "open" && !pr.isDraft) {
1163 await db
1164 .update(pullRequests)
1165 .set({ isDraft: true, updatedAt: new Date() })
1166 .where(eq(pullRequests.id, pr.id));
1167 }
1168
1169 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1170 }
1171);
1172
0074234Claude1173// Close PR
1174pulls.post(
1175 "/:owner/:repo/pulls/:number/close",
1176 softAuth,
1177 requireAuth,
1178 async (c) => {
1179 const { owner: ownerName, repo: repoName } = c.req.param();
1180 const prNum = parseInt(c.req.param("number"), 10);
1181
1182 const resolved = await resolveRepo(ownerName, repoName);
1183 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1184
1185 await db
1186 .update(pullRequests)
1187 .set({
1188 state: "closed",
1189 closedAt: new Date(),
1190 updatedAt: new Date(),
1191 })
1192 .where(
1193 and(
1194 eq(pullRequests.repositoryId, resolved.repo.id),
1195 eq(pullRequests.number, prNum)
1196 )
1197 );
1198
1199 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1200 }
1201);
1202
e883329Claude1203/**
1204 * Trigger AI code review asynchronously after PR creation.
1205 * Runs the diff through Claude and posts review comments.
1206 */
1207async function triggerAiReview(
1208 ownerName: string,
1209 repoName: string,
1210 prId: string,
1211 title: string,
1212 body: string | null,
1213 baseBranch: string,
1214 headBranch: string
1215): Promise<void> {
1216 const repoDir = getRepoPath(ownerName, repoName);
1217
1218 // Get the diff between branches
1219 const proc = Bun.spawn(
1220 ["git", "diff", `${baseBranch}...${headBranch}`],
1221 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1222 );
1223 const diffText = await new Response(proc.stdout).text();
1224 await proc.exited;
1225
1226 if (!diffText.trim()) return;
1227
1228 const result = await reviewDiff(
1229 `${ownerName}/${repoName}`,
1230 title,
1231 body,
1232 baseBranch,
1233 headBranch,
1234 diffText
1235 );
1236
1237 // We need a system user for AI reviews — use the PR author for now
1238 // Get the PR to find the author
1239 const [pr] = await db
1240 .select()
1241 .from(pullRequests)
1242 .where(eq(pullRequests.id, prId))
1243 .limit(1);
1244
1245 if (!pr) return;
1246
1247 // Post summary comment
1248 const statusEmoji = result.approved ? "**Approved**" : "**Changes Requested**";
1249 let commentBody = `## AI Code Review ${statusEmoji}\n\n${result.summary}`;
1250
1251 if (result.comments.length > 0) {
1252 commentBody += "\n\n### Issues Found\n";
1253 for (const comment of result.comments) {
1254 const location = comment.filePath
1255 ? `\`${comment.filePath}${comment.lineNumber ? `:${comment.lineNumber}` : ""}\``
1256 : "";
1257 commentBody += `\n---\n${location}\n\n${comment.body}\n`;
1258 }
1259 }
1260
1261 await db.insert(prComments).values({
1262 pullRequestId: prId,
1263 authorId: pr.authorId,
1264 body: commentBody,
1265 isAiReview: true,
1266 });
1267
1268 // Post individual file-level comments
1269 for (const comment of result.comments) {
1270 if (comment.filePath) {
1271 await db.insert(prComments).values({
1272 pullRequestId: prId,
1273 authorId: pr.authorId,
1274 body: comment.body,
1275 isAiReview: true,
1276 filePath: comment.filePath,
1277 lineNumber: comment.lineNumber,
1278 });
1279 }
1280 }
1281
1282 console.log(
1283 `[ai-review] Review posted for PR ${prId}: ${result.approved ? "approved" : "changes requested"}, ${result.comments.length} comments`
1284 );
1285}
1286
3cbe3d6Claude1287/**
1288 * D3 — AI PR triage. Runs Claude Haiku on the PR title/body + diff summary and
1289 * posts an AI-authored comment suggesting labels, reviewers, and priority.
1290 * Nothing is auto-applied — the PR author remains in control.
1291 */
1292async function triggerPrTriage(args: {
1293 ownerName: string;
1294 repoName: string;
1295 repositoryId: string;
1296 prId: string;
1297 prAuthorId: string;
1298 title: string;
1299 body: string;
1300 baseBranch: string;
1301 headBranch: string;
1302}): Promise<void> {
1303 try {
1304 // Gather candidate reviewers (top contributors from recent commits).
1305 const repoDir = getRepoPath(args.ownerName, args.repoName);
1306 const shortlogProc = Bun.spawn(
1307 ["git", "shortlog", "-sn", "--no-merges", "-50", args.baseBranch],
1308 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1309 );
1310 const shortlogOut = await new Response(shortlogProc.stdout).text();
1311 await shortlogProc.exited;
1312 const authorNames = shortlogOut
1313 .trim()
1314 .split("\n")
1315 .map((l) => l.trim().split(/\s+/).slice(1).join(" "))
1316 .filter(Boolean)
1317 .slice(0, 10);
1318
1319 // Look up usernames matching these author names (best-effort match).
1320 const candidateUsernames: string[] = [];
1321 if (authorNames.length > 0) {
1322 try {
1323 const matches = await db
1324 .select({ username: users.username, displayName: users.displayName })
1325 .from(users)
1326 .limit(100);
1327 for (const u of matches) {
1328 if (
1329 authorNames.some(
1330 (n) =>
1331 u.username === n ||
1332 (u.displayName && u.displayName === n)
1333 )
1334 ) {
1335 candidateUsernames.push(u.username);
1336 }
1337 }
1338 } catch {
1339 /* ignore */
1340 }
1341 }
1342 // Always include the repo owner as a candidate reviewer.
1343 try {
1344 const [ownerRow] = await db
1345 .select({ username: users.username })
1346 .from(users)
1347 .where(eq(users.username, args.ownerName))
1348 .limit(1);
1349 if (ownerRow && !candidateUsernames.includes(ownerRow.username)) {
1350 candidateUsernames.push(ownerRow.username);
1351 }
1352 } catch {
1353 /* ignore */
1354 }
1355
1356 // Load repo labels.
1357 const availableLabels = await db
1358 .select({ name: labelsTable.name })
1359 .from(labelsTable)
1360 .where(eq(labelsTable.repositoryId, args.repositoryId))
1361 .then((rows) => rows.map((r) => r.name))
1362 .catch(() => [] as string[]);
1363
1364 // Short diff summary (numstat only to keep prompt small).
1365 const statProc = Bun.spawn(
1366 ["git", "diff", "--numstat", `${args.baseBranch}...${args.headBranch}`],
1367 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1368 );
1369 const diffSummary = await new Response(statProc.stdout).text();
1370 await statProc.exited;
1371
1372 const result = await triagePullRequest(
1373 args.title,
1374 args.body,
1375 diffSummary,
1376 availableLabels,
1377 candidateUsernames
1378 );
1379
1380 // Skip posting if we have absolutely nothing useful to say.
1381 if (
1382 !result.summary &&
1383 result.suggestedLabels.length === 0 &&
1384 result.suggestedReviewerUsernames.length === 0
1385 ) {
1386 return;
1387 }
1388
1389 const priorityEmoji =
1390 result.priority === "critical"
1391 ? "**Critical**"
1392 : result.priority === "high"
1393 ? "**High**"
1394 : result.priority === "low"
1395 ? "**Low**"
1396 : "**Medium**";
1397 const parts: string[] = [`## AI Triage\n`];
1398 if (result.summary) parts.push(`${result.summary}\n`);
1399 parts.push(`- **Priority:** ${priorityEmoji}`);
1400 parts.push(`- **Risk area:** ${result.riskArea}`);
1401 if (result.suggestedLabels.length > 0) {
1402 parts.push(
1403 `- **Suggested labels:** ${result.suggestedLabels.map((l) => `\`${l}\``).join(", ")}`
1404 );
1405 }
1406 if (result.suggestedReviewerUsernames.length > 0) {
1407 parts.push(
1408 `- **Suggested reviewers:** ${result.suggestedReviewerUsernames.map((u) => `@${u}`).join(", ")}`
1409 );
1410 }
1411 parts.push(
1412 `\n_Suggestions only — nothing was auto-applied. The PR author remains in control._`
1413 );
1414
1415 await db.insert(prComments).values({
1416 pullRequestId: args.prId,
1417 authorId: args.prAuthorId,
1418 body: parts.join("\n"),
1419 isAiReview: true,
1420 });
1421 } catch (err) {
1422 console.error("[pr-triage]", err);
1423 }
1424}
1425
0074234Claude1426function formatRelative(date: Date | string): string {
1427 const d = typeof date === "string" ? new Date(date) : date;
1428 const now = new Date();
1429 const diffMs = now.getTime() - d.getTime();
1430 const diffMins = Math.floor(diffMs / 60000);
1431 if (diffMins < 1) return "just now";
1432 if (diffMins < 60) return `${diffMins}m ago`;
1433 const diffHours = Math.floor(diffMins / 60);
1434 if (diffHours < 24) return `${diffHours}h ago`;
1435 const diffDays = Math.floor(diffHours / 24);
1436 if (diffDays < 30) return `${diffDays}d ago`;
1437 return d.toLocaleDateString("en-US", {
1438 month: "short",
1439 day: "numeric",
1440 year: "numeric",
1441 });
1442}
1443
3247f79Claude1444// ---------------------------------------------------------------------------
1445// J11 — Reviewer request management (manual add + dismiss)
1446// ---------------------------------------------------------------------------
1447pulls.post(
1448 "/:owner/:repo/pulls/:number/reviewers",
1449 softAuth,
1450 requireAuth,
1451 async (c) => {
1452 const { owner: ownerName, repo: repoName } = c.req.param();
1453 const prNum = parseInt(c.req.param("number"), 10);
1454 const user = c.get("user")!;
1455 const form = await c.req.parseBody();
1456 const usernameRaw = String(form.username || "").trim().replace(/^@/, "");
1457 if (!usernameRaw) {
1458 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1459 }
1460 const resolved = await resolveRepo(ownerName, repoName);
1461 if (!resolved) return c.notFound();
1462 const [pr] = await db
1463 .select()
1464 .from(pullRequests)
1465 .where(
1466 and(
1467 eq(pullRequests.repositoryId, resolved.repo.id),
1468 eq(pullRequests.number, prNum)
1469 )
1470 )
1471 .limit(1);
1472 if (!pr) return c.notFound();
1473 const canManage =
1474 user.id === resolved.owner.id || user.id === pr.authorId;
1475 if (!canManage) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1476 const [target] = await db
1477 .select()
1478 .from(users)
1479 .where(eq(users.username, usernameRaw))
1480 .limit(1);
1481 if (!target) {
1482 return c.redirect(
1483 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("User not found")}`
1484 );
1485 }
1486 if (target.id === pr.authorId) {
1487 return c.redirect(
1488 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("PR author cannot be a reviewer")}`
1489 );
1490 }
1491 await requestReviewers(pr.id, [target.id], user.id, "manual");
1492 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1493 }
1494);
1495
1496pulls.post(
1497 "/:owner/:repo/pulls/:number/reviewers/:reviewerId/dismiss",
1498 softAuth,
1499 requireAuth,
1500 async (c) => {
1501 const { owner: ownerName, repo: repoName, reviewerId } = c.req.param();
1502 const prNum = parseInt(c.req.param("number"), 10);
1503 const user = c.get("user")!;
1504 const resolved = await resolveRepo(ownerName, repoName);
1505 if (!resolved) return c.notFound();
1506 const [pr] = await db
1507 .select()
1508 .from(pullRequests)
1509 .where(
1510 and(
1511 eq(pullRequests.repositoryId, resolved.repo.id),
1512 eq(pullRequests.number, prNum)
1513 )
1514 )
1515 .limit(1);
1516 if (!pr) return c.notFound();
1517 const canManage =
1518 user.id === resolved.owner.id ||
1519 user.id === pr.authorId ||
1520 user.id === reviewerId;
1521 if (!canManage) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1522 await dismissRequest(pr.id, reviewerId);
1523 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1524 }
1525);
1526
1527interface ReviewersPanelProps {
1528 reviewers: Awaited<ReturnType<typeof listReviewRequestsForPr>>;
1529 canManage: boolean | null | undefined;
1530 ownerName: string;
1531 repoName: string;
1532 prNumber: number;
1533}
1534
1535function ReviewersPanel(props: ReviewersPanelProps) {
1536 const { reviewers, canManage, ownerName, repoName, prNumber } = props;
1537 const stateColor = (s: string) =>
1538 s === "approved"
1539 ? "var(--green)"
1540 : s === "changes_requested"
1541 ? "var(--red)"
1542 : s === "dismissed"
1543 ? "var(--text-muted)"
1544 : "var(--text)";
1545 return (
1546 <div
1547 style="margin: 0 0 20px; padding: 12px 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)"
1548 data-testid="reviewers-panel"
1549 >
1550 <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px">
1551 <h3 style="margin: 0; font-size: 14px">Reviewers</h3>
1552 <span style="color: var(--text-muted); font-size: 12px">
1553 {reviewers.length === 0
1554 ? "No reviewers requested"
1555 : `${reviewers.filter((r) => r.state === "pending").length} pending · ${reviewers.filter((r) => r.state === "approved").length} approved`}
1556 </span>
1557 </div>
1558 {reviewers.length > 0 && (
1559 <ul style="list-style: none; padding: 0; margin: 0 0 8px">
1560 {reviewers.map((r) => (
1561 <li style="display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px">
1562 <a href={`/${r.username}`}>
1563 <strong>@{r.username}</strong>
1564 </a>
1565 <span
1566 style={`font-size: 11px; padding: 1px 6px; border-radius: 10px; background: rgba(139, 148, 158, 0.15); color: ${stateColor(r.state)}; text-transform: capitalize`}
1567 >
1568 {r.state.replace("_", " ")}
1569 </span>
1570 <span style="font-size: 11px; color: var(--text-muted)">
1571 via {r.source}
1572 </span>
1573 {canManage && r.state !== "dismissed" && (
1574 <form
1575 method="POST"
1576 action={`/${ownerName}/${repoName}/pulls/${prNumber}/reviewers/${r.reviewerId}/dismiss`}
1577 style="margin-left: auto"
1578 >
1579 <button
1580 type="submit"
1581 class="btn"
1582 style="padding: 2px 8px; font-size: 11px"
1583 title="Dismiss this review request"
1584 >
1585 Dismiss
1586 </button>
1587 </form>
1588 )}
1589 </li>
1590 ))}
1591 </ul>
1592 )}
1593 {canManage && (
1594 <form
1595 method="POST"
1596 action={`/${ownerName}/${repoName}/pulls/${prNumber}/reviewers`}
1597 style="display: flex; gap: 6px"
1598 >
1599 <input
1600 type="text"
1601 name="username"
1602 placeholder="Request review from @username"
1603 required
1604 style="flex: 1; padding: 4px 8px; font-size: 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text)"
1605 />
1606 <button type="submit" class="btn" style="padding: 4px 10px; font-size: 12px">
1607 Request
1608 </button>
1609 </form>
1610 )}
1611 </div>
1612 );
1613}
1614
21ff9beClaude1615// J16 — PR auto-merge toggle
1616pulls.post(
1617 "/:owner/:repo/pulls/:number/auto-merge",
1618 softAuth,
1619 requireAuth,
1620 async (c) => {
1621 const { owner: ownerName, repo: repoName } = c.req.param();
1622 const prNum = parseInt(c.req.param("number"), 10);
1623 const user = c.get("user")!;
1624 const body = await c.req.parseBody();
1625 const method = String(body.mergeMethod || "merge").trim();
1626
1627 const resolved = await resolveRepo(ownerName, repoName);
1628 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1629 const [pr] = await db
1630 .select()
1631 .from(pullRequests)
1632 .where(
1633 and(
1634 eq(pullRequests.repositoryId, resolved.repo.id),
1635 eq(pullRequests.number, prNum)
1636 )
1637 )
1638 .limit(1);
1639 if (!pr) return c.notFound();
1640 const canManage =
1641 user.id === resolved.owner.id || user.id === pr.authorId;
1642 if (!canManage) {
1643 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1644 }
1645 if (pr.state !== "open" || pr.isDraft) {
1646 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1647 }
1648 try {
1649 const { enableAutoMerge, isValidMergeMethod } = await import(
1650 "../lib/pr-auto-merge"
1651 );
1652 await enableAutoMerge({
1653 pullRequestId: pr.id,
1654 enabledBy: user.id,
1655 mergeMethod: isValidMergeMethod(method) ? (method as any) : "merge",
1656 });
1657 } catch (err) {
1658 console.error("[pr-auto-merge] enable route failed:", err);
1659 }
1660 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1661 }
1662);
1663
1664pulls.post(
1665 "/:owner/:repo/pulls/:number/auto-merge/disable",
1666 softAuth,
1667 requireAuth,
1668 async (c) => {
1669 const { owner: ownerName, repo: repoName } = c.req.param();
1670 const prNum = parseInt(c.req.param("number"), 10);
1671 const user = c.get("user")!;
1672 const resolved = await resolveRepo(ownerName, repoName);
1673 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1674 const [pr] = await db
1675 .select()
1676 .from(pullRequests)
1677 .where(
1678 and(
1679 eq(pullRequests.repositoryId, resolved.repo.id),
1680 eq(pullRequests.number, prNum)
1681 )
1682 )
1683 .limit(1);
1684 if (!pr) return c.notFound();
1685 const canManage =
1686 user.id === resolved.owner.id || user.id === pr.authorId;
1687 if (!canManage) {
1688 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1689 }
1690 try {
1691 const { disableAutoMerge } = await import("../lib/pr-auto-merge");
1692 await disableAutoMerge(pr.id);
1693 } catch (err) {
1694 console.error("[pr-auto-merge] disable route failed:", err);
1695 }
1696 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1697 }
1698);
1699
1700// J16 — UI panel
1701interface AutoMergePanelProps {
1702 owner: string;
1703 repo: string;
1704 prNumber: number;
1705 state: {
1706 enabled: boolean;
1707 mergeMethod: string;
1708 lastStatus: string | null;
1709 notifiedReady: boolean;
1710 };
1711}
1712
1713function AutoMergePanel(props: AutoMergePanelProps) {
1714 const { owner, repo, prNumber, state } = props;
1715 let statusLabel: string;
1716 let statusColor: string;
1717 if (!state.enabled) {
1718 statusLabel = "Auto-merge is off";
1719 statusColor = "var(--text-muted)";
1720 } else if (state.lastStatus === "success" || state.notifiedReady) {
1721 statusLabel = "All checks passed \u2014 ready to merge";
1722 statusColor = "var(--green)";
1723 } else if (state.lastStatus === "failure" || state.lastStatus === "error") {
1724 statusLabel = "Checks failing \u2014 auto-merge paused";
1725 statusColor = "var(--red)";
1726 } else if (state.lastStatus === "pending") {
1727 statusLabel = "Waiting for checks to finish";
1728 statusColor = "var(--yellow)";
1729 } else {
1730 statusLabel = "Enabled \u2014 waiting for first check report";
1731 statusColor = "var(--yellow)";
1732 }
1733
1734 return (
1735 <div style="margin-top: 20px; padding: 12px 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)">
1736 <div style="display: flex; align-items: center; gap: 12px">
1737 <div style={`font-size: 13px; font-weight: 600; color: ${statusColor}; flex: 1`}>
1738 {"\u26A1"} Auto-merge: {statusLabel}
1739 </div>
1740 {state.enabled ? (
1741 <form
1742 method="POST"
1743 action={`/${owner}/${repo}/pulls/${prNumber}/auto-merge/disable`}
1744 style="margin: 0"
1745 >
1746 <button type="submit" class="btn" style="padding: 4px 10px; font-size: 12px">
1747 Disable auto-merge
1748 </button>
1749 </form>
1750 ) : (
1751 <form
1752 method="POST"
1753 action={`/${owner}/${repo}/pulls/${prNumber}/auto-merge`}
1754 style="margin: 0; display: flex; gap: 6px; align-items: center"
1755 >
1756 <select
1757 name="mergeMethod"
1758 style="padding: 4px 8px; font-size: 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text)"
1759 >
1760 <option value="merge">Merge</option>
1761 <option value="squash">Squash</option>
1762 <option value="rebase">Rebase</option>
1763 </select>
1764 <button type="submit" class="btn" style="padding: 4px 10px; font-size: 12px">
1765 Enable auto-merge
1766 </button>
1767 </form>
1768 )}
1769 </div>
1770 {state.enabled && (
1771 <div style="margin-top: 6px; font-size: 11px; color: var(--text-muted)">
1772 Chosen method: <code>{state.mergeMethod}</code>. The PR stays open
1773 until all commit statuses report success. You'll be notified on the
1774 PR when it becomes ready to merge.
1775 </div>
1776 )}
1777 </div>
1778 );
1779}
1780
0074234Claude1781export default pulls;