Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit0a67773unknown_key

feat(pr-review): formal Approve / Request Changes review workflow

feat(pr-review): formal Approve / Request Changes review workflow

Wires the existing prReviews table (previously defined but unused) into
a complete GitHub-style review UI:

- "Approve" and "Request Changes" buttons appear for non-authors on open PRs
- POST /:owner/:repo/pulls/:number/review stores a pr_reviews row
- Review summary banner shows who approved and who requested changes
- countHumanApprovals upgraded: checks pr_reviews.state='approved' first,
  falls back to the legacy LGTM-in-comment heuristic for older PRs

The Approve/Request Changes buttons reuse the existing comment textarea so
reviewers can optionally include a summary comment with their verdict.

https://claude.ai/code/session_01ACsT2Pc8GRoZwZRF8SK68Y
Claude committed on May 28, 2026Parent: 47a7a0a
2 files changed+19400a6777387aa14839af2babd1e6770ea22c5a5153
2 changed files+194−0
Modifiedsrc/lib/branch-protection.ts+23−0View fileUnifiedSplit
2222 branchRequiredChecks,
2323 gateRuns,
2424 prComments,
25 prReviews,
2526 workflowRuns,
2627 workflows,
2728} from "../db/schema";
144145 */
145146export async function countHumanApprovals(pullRequestId: string): Promise<number> {
146147 try {
148 // Primary: count distinct reviewers with state='approved' in pr_reviews,
149 // using the most recent non-commented review per reviewer.
150 const rows = await db
151 .select({ reviewerId: prReviews.reviewerId, state: prReviews.state })
152 .from(prReviews)
153 .where(
154 and(
155 eq(prReviews.pullRequestId, pullRequestId),
156 eq(prReviews.isAi, false)
157 )
158 )
159 .orderBy(desc(prReviews.createdAt));
160 const latestByReviewer = new Map<string, string>();
161 for (const r of rows) {
162 if (r.state !== "commented" && !latestByReviewer.has(r.reviewerId)) {
163 latestByReviewer.set(r.reviewerId, r.state);
164 }
165 }
166 const formalApprovals = [...latestByReviewer.values()].filter(s => s === "approved").length;
167 if (formalApprovals > 0) return formalApprovals;
168
169 // Fallback: legacy LGTM-in-comment heuristic (for repos not yet using reviews)
147170 const comments = await db
148171 .select({ body: prComments.body, isAi: prComments.isAiReview })
149172 .from(prComments)
Modifiedsrc/routes/pulls.tsx+171−0View fileUnifiedSplit
1919import {
2020 pullRequests,
2121 prComments,
22 prReviews,
2223 repositories,
2324 users,
2425 issues,
741742 }
742743 .prs-merge-back-draft:hover { color: var(--text); background: var(--bg-hover); }
743744
745 /* Review summary banner */
746 .prs-review-summary {
747 display: flex; flex-direction: column; gap: 6px;
748 padding: 12px 16px;
749 background: var(--bg-elevated);
750 border: 1px solid var(--border);
751 border-radius: var(--r-md, 8px);
752 margin-bottom: 12px;
753 }
754 .prs-review-row {
755 display: flex; align-items: center; gap: 10px;
756 font-size: 13px;
757 }
758 .prs-review-icon { font-size: 15px; font-weight: 700; flex-shrink: 0; }
759 .prs-review-approved .prs-review-icon { color: #34d399; }
760 .prs-review-changes .prs-review-icon { color: #f87171; }
761
762 /* Review action buttons */
763 .prs-review-approve-btn {
764 display: inline-flex; align-items: center; gap: 5px;
765 padding: 8px 14px; border-radius: 8px; font-size: 13px;
766 font-weight: 600; cursor: pointer;
767 background: rgba(52,211,153,0.12);
768 color: #34d399;
769 border: 1px solid rgba(52,211,153,0.35);
770 transition: background 120ms;
771 }
772 .prs-review-approve-btn:hover { background: rgba(52,211,153,0.22); }
773 .prs-review-changes-btn {
774 display: inline-flex; align-items: center; gap: 5px;
775 padding: 8px 14px; border-radius: 8px; font-size: 13px;
776 font-weight: 600; cursor: pointer;
777 background: rgba(248,113,113,0.10);
778 color: #f87171;
779 border: 1px solid rgba(248,113,113,0.30);
780 transition: background 120ms;
781 }
782 .prs-review-changes-btn:hover { background: rgba(248,113,113,0.20); }
783
744784 /* Inline form helpers */
745785 .prs-inline-form { display: inline-flex; }
746786
22162256 ),
22172257 ]);
22182258
2259 // Formal reviews (Approve / Request Changes)
2260 const reviewRows = await db
2261 .select({
2262 id: prReviews.id,
2263 state: prReviews.state,
2264 body: prReviews.body,
2265 isAi: prReviews.isAi,
2266 createdAt: prReviews.createdAt,
2267 reviewerUsername: users.username,
2268 reviewerId: prReviews.reviewerId,
2269 })
2270 .from(prReviews)
2271 .innerJoin(users, eq(prReviews.reviewerId, users.id))
2272 .where(eq(prReviews.pullRequestId, pr.id))
2273 .orderBy(asc(prReviews.createdAt));
2274 // Most recent review per reviewer determines the current state
2275 const latestReviewByReviewer = new Map<string, typeof reviewRows[0]>();
2276 for (const r of reviewRows) {
2277 if (r.state !== "commented") latestReviewByReviewer.set(r.reviewerId, r);
2278 }
2279 const approvals = [...latestReviewByReviewer.values()].filter(r => r.state === "approved");
2280 const changesRequested = [...latestReviewByReviewer.values()].filter(r => r.state === "changes_requested");
2281 const viewerHasReviewed = user ? latestReviewByReviewer.has(user.id) : false;
2282
22192283 const canManage =
22202284 user &&
22212285 (user.id === resolved.owner.id || user.id === pr.authorId);
26272691 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
26282692 )}
26292693
2694 {/* ─── Review summary ─────────────────────────────────── */}
2695 {(approvals.length > 0 || changesRequested.length > 0) && (
2696 <div class="prs-review-summary">
2697 {approvals.length > 0 && (
2698 <div class="prs-review-row prs-review-approved">
2699 <span class="prs-review-icon"></span>
2700 <span>
2701 <strong>{approvals.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
2702 approved this pull request
2703 </span>
2704 </div>
2705 )}
2706 {changesRequested.length > 0 && (
2707 <div class="prs-review-row prs-review-changes">
2708 <span class="prs-review-icon"></span>
2709 <span>
2710 <strong>{changesRequested.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
2711 requested changes
2712 </span>
2713 </div>
2714 )}
2715 </div>
2716 )}
2717
26302718 {pr.state === "open" && gateChecks.length > 0 && (
26312719 <div class="prs-gate-card">
26322720 <div class="prs-gate-head">
27522840 <Button type="submit" variant="primary">
27532841 Comment
27542842 </Button>
2843 {user && user.id !== pr.authorId && pr.state === "open" && (
2844 <>
2845 <button
2846 type="submit"
2847 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
2848 name="review_state"
2849 value="approved"
2850 class="prs-review-approve-btn"
2851 title="Approve this pull request"
2852 >
2853 ✓ Approve
2854 </button>
2855 <button
2856 type="submit"
2857 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
2858 name="review_state"
2859 value="changes_requested"
2860 class="prs-review-changes-btn"
2861 title="Request changes before merging"
2862 >
2863 ✗ Request changes
2864 </button>
2865 </>
2866 )}
27552867 {canManage && (
27562868 <>
27572869 {pr.isDraft ? (
29923104 }
29933105);
29943106
3107// Formal review — Approve / Request Changes / Comment
3108pulls.post(
3109 "/:owner/:repo/pulls/:number/review",
3110 softAuth,
3111 requireAuth,
3112 requireRepoAccess("read"),
3113 async (c) => {
3114 const { owner: ownerName, repo: repoName } = c.req.param();
3115 const prNum = parseInt(c.req.param("number"), 10);
3116 const user = c.get("user")!;
3117 const body = await c.req.parseBody();
3118 const reviewBody = String(body.body || "").trim();
3119 const reviewState = String(body.review_state || "commented");
3120
3121 const validStates = ["approved", "changes_requested", "commented"];
3122 if (!validStates.includes(reviewState)) {
3123 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3124 }
3125
3126 const resolved = await resolveRepo(ownerName, repoName);
3127 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3128
3129 const [pr] = await db
3130 .select()
3131 .from(pullRequests)
3132 .where(
3133 and(
3134 eq(pullRequests.repositoryId, resolved.repo.id),
3135 eq(pullRequests.number, prNum)
3136 )
3137 )
3138 .limit(1);
3139 if (!pr || pr.state !== "open") {
3140 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3141 }
3142 // Authors can't review their own PR
3143 if (pr.authorId === user.id) {
3144 return c.redirect(
3145 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("You cannot review your own pull request")}`
3146 );
3147 }
3148
3149 await db.insert(prReviews).values({
3150 pullRequestId: pr.id,
3151 reviewerId: user.id,
3152 state: reviewState,
3153 body: reviewBody || null,
3154 });
3155
3156 const stateLabel =
3157 reviewState === "approved" ? "Approved"
3158 : reviewState === "changes_requested" ? "Changes requested"
3159 : "Commented";
3160 return c.redirect(
3161 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(stateLabel)}`
3162 );
3163 }
3164);
3165
29953166// Merge PR — with green gate enforcement and auto conflict resolution
29963167// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
29973168// but we keep it at "write" for v1 so trusted collaborators can ship.
29983169