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

review-requests.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

review-requests.tsBlame295 lines · 1 contributor
3247f79Claude1/**
2 * Block J11 — PR review requests (auto-assign + manual).
3 *
4 * Manages the set of users who have been asked to review a pull request.
5 * Rows are idempotent per (pr, reviewer) — calling `requestReviewers` twice
6 * with the same reviewer is a no-op. Sources:
7 * - 'codeowners' — auto-assigned from the repo's CODEOWNERS rules on PR open
8 * - 'manual' — a maintainer added the reviewer by hand
9 * - 'ai' — PR triage suggested them
10 *
11 * State transitions:
12 * pending → approved | changes_requested | dismissed
13 *
14 * All DB helpers swallow errors and return safe defaults — never throw — so
15 * review-request failures can never block PR creation or merge flows.
16 */
17
18import { and, eq, inArray, sql } from "drizzle-orm";
19import { db } from "../db";
20import { prReviewRequests, users } from "../db/schema";
21import { reviewersForChangedFiles } from "./codeowners";
22import { notify } from "./notify";
23
24export type ReviewSource = "codeowners" | "manual" | "ai";
25export type ReviewState =
26 | "pending"
27 | "approved"
28 | "changes_requested"
29 | "dismissed";
30
31export const REVIEW_SOURCES: ReviewSource[] = ["codeowners", "manual", "ai"];
32export const REVIEW_STATES: ReviewState[] = [
33 "pending",
34 "approved",
35 "changes_requested",
36 "dismissed",
37];
38
39export function isValidSource(s: string): s is ReviewSource {
40 return (REVIEW_SOURCES as string[]).includes(s);
41}
42
43export function isValidState(s: string): s is ReviewState {
44 return (REVIEW_STATES as string[]).includes(s);
45}
46
47/**
48 * Deterministic merger: given an existing request state and a fresh review
49 * outcome, return what the new state should be. Dismissed is terminal unless
50 * someone explicitly re-requests.
51 */
52export function nextState(
53 prev: ReviewState,
54 incoming: "approved" | "changes_requested" | "commented" | "dismissed"
55): ReviewState {
56 if (prev === "dismissed") return "dismissed";
57 if (incoming === "commented") return prev; // comment doesn't resolve the request
58 return incoming;
59}
60
61/**
62 * Filter helper — remove author + duplicates + empties from a candidate
63 * reviewer ID set. Pure; safe with null/undefined inputs.
64 */
65export function sanitiseCandidates(
66 candidateIds: Array<string | null | undefined>,
67 authorId: string | null | undefined
68): string[] {
69 const seen = new Set<string>();
70 const out: string[] = [];
71 for (const id of candidateIds) {
72 if (!id) continue;
73 if (id === authorId) continue;
74 if (seen.has(id)) continue;
75 seen.add(id);
76 out.push(id);
77 }
78 return out;
79}
80
81/**
82 * Request reviews from a set of users. Idempotent — existing (pr, reviewer)
83 * rows are left alone (we preserve their state, which may be non-pending
84 * from a prior review cycle). Returns the list of reviewer IDs that were
85 * newly inserted.
86 */
87export async function requestReviewers(
88 pullRequestId: string,
89 reviewerIds: string[],
90 requestedBy: string | null,
91 source: ReviewSource
92): Promise<string[]> {
93 const cleaned = sanitiseCandidates(reviewerIds, null);
94 if (cleaned.length === 0) return [];
95 try {
96 const existing = await db
97 .select({ reviewerId: prReviewRequests.reviewerId })
98 .from(prReviewRequests)
99 .where(
100 and(
101 eq(prReviewRequests.pullRequestId, pullRequestId),
102 inArray(prReviewRequests.reviewerId, cleaned)
103 )
104 );
105 const have = new Set(existing.map((r) => r.reviewerId));
106 const fresh = cleaned.filter((id) => !have.has(id));
107 if (fresh.length === 0) return [];
108 await db.insert(prReviewRequests).values(
109 fresh.map((reviewerId) => ({
110 pullRequestId,
111 reviewerId,
112 requestedBy,
113 source,
114 state: "pending" as const,
115 }))
116 );
117 return fresh;
118 } catch (err) {
119 console.error("[review-requests] requestReviewers failed:", err);
120 return [];
121 }
122}
123
124/** List all requested reviewers for a PR with username joined. */
125export async function listForPr(
126 pullRequestId: string
127): Promise<
128 Array<{
129 id: string;
130 reviewerId: string;
131 username: string;
132 source: ReviewSource;
133 state: ReviewState;
134 requestedAt: Date;
135 resolvedAt: Date | null;
136 }>
137> {
138 try {
139 const rows = await db
140 .select({
141 id: prReviewRequests.id,
142 reviewerId: prReviewRequests.reviewerId,
143 username: users.username,
144 source: prReviewRequests.source,
145 state: prReviewRequests.state,
146 requestedAt: prReviewRequests.requestedAt,
147 resolvedAt: prReviewRequests.resolvedAt,
148 })
149 .from(prReviewRequests)
150 .innerJoin(users, eq(users.id, prReviewRequests.reviewerId))
151 .where(eq(prReviewRequests.pullRequestId, pullRequestId))
152 .orderBy(prReviewRequests.requestedAt);
153 return rows.map((r) => ({
154 ...r,
155 source: r.source as ReviewSource,
156 state: r.state as ReviewState,
157 }));
158 } catch (err) {
159 console.error("[review-requests] listForPr failed:", err);
160 return [];
161 }
162}
163
164/** Dismiss a request (e.g. reviewer removed by maintainer). Idempotent. */
165export async function dismissRequest(
166 pullRequestId: string,
167 reviewerId: string
168): Promise<boolean> {
169 try {
170 const res = await db
171 .update(prReviewRequests)
172 .set({ state: "dismissed", resolvedAt: new Date() })
173 .where(
174 and(
175 eq(prReviewRequests.pullRequestId, pullRequestId),
176 eq(prReviewRequests.reviewerId, reviewerId)
177 )
178 )
179 .returning({ id: prReviewRequests.id });
180 return res.length > 0;
181 } catch (err) {
182 console.error("[review-requests] dismissRequest failed:", err);
183 return false;
184 }
185}
186
187/**
188 * Mark a reviewer's request as resolved when they submit a review. Called
189 * by the PR review handler. `commented` leaves the request in `pending`.
190 */
191export async function recordReviewOutcome(
192 pullRequestId: string,
193 reviewerId: string,
194 outcome: "approved" | "changes_requested" | "commented" | "dismissed"
195): Promise<void> {
196 try {
197 const [row] = await db
198 .select()
199 .from(prReviewRequests)
200 .where(
201 and(
202 eq(prReviewRequests.pullRequestId, pullRequestId),
203 eq(prReviewRequests.reviewerId, reviewerId)
204 )
205 )
206 .limit(1);
207 if (!row) return; // reviewer wasn't requested — nothing to update
208 const newState = nextState(row.state as ReviewState, outcome);
209 if (newState === row.state) return;
210 await db
211 .update(prReviewRequests)
212 .set({
213 state: newState,
214 resolvedAt: newState === "pending" ? null : new Date(),
215 })
216 .where(eq(prReviewRequests.id, row.id));
217 } catch (err) {
218 console.error("[review-requests] recordReviewOutcome failed:", err);
219 }
220}
221
222/**
223 * Core helper used by PR creation. Given a PR + changed file list, resolves
224 * CODEOWNERS to usernames, maps to user IDs, and requests reviews. Never
225 * throws; logs + skips on failure.
226 */
227export async function autoAssignFromCodeowners(opts: {
228 repositoryId: string;
229 pullRequestId: string;
230 authorId: string;
231 changedPaths: string[];
232 prUrl?: string;
233 prTitle?: string;
234}): Promise<string[]> {
235 try {
236 if (opts.changedPaths.length === 0) return [];
237 const usernames = await reviewersForChangedFiles(
238 opts.repositoryId,
239 opts.changedPaths
240 );
241 if (usernames.length === 0) return [];
242 // Resolve usernames → user IDs in one query.
243 const rows = await db
244 .select({ id: users.id, username: users.username })
245 .from(users)
246 .where(inArray(users.username, usernames));
247 const reviewerIds = rows
248 .map((r) => r.id)
249 .filter((id) => id !== opts.authorId);
250 if (reviewerIds.length === 0) return [];
251 const fresh = await requestReviewers(
252 opts.pullRequestId,
253 reviewerIds,
254 null,
255 "codeowners"
256 );
257 // Fire-and-forget notifications.
258 for (const rid of fresh) {
259 notify(rid, {
260 kind: "review_requested",
261 title: opts.prTitle
262 ? `Review requested: ${opts.prTitle}`
263 : "Review requested",
264 body: "CODEOWNERS auto-assigned you to review this pull request.",
265 url: opts.prUrl,
266 }).catch(() => {});
267 }
268 return fresh;
269 } catch (err) {
270 console.error("[review-requests] autoAssignFromCodeowners failed:", err);
271 return [];
272 }
273}
274
275/**
276 * Count pending review requests for a user. Useful for dashboard badges.
277 */
278export async function countPendingForUser(userId: string): Promise<number> {
279 try {
280 const [row] = await db
281 .select({ n: sql<number>`count(*)::int` })
282 .from(prReviewRequests)
283 .where(
284 and(
285 eq(prReviewRequests.reviewerId, userId),
286 eq(prReviewRequests.state, "pending")
287 )
288 );
289 return Number(row?.n || 0);
290 } catch {
291 return 0;
292 }
293}
294
295export const __internal = { sanitiseCandidates, nextState };