CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.
| 3247f79 | 1 | /** |
| 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 | ||
| 18 | import { and, eq, inArray, sql } from "drizzle-orm"; | |
| 19 | import { db } from "../db"; | |
| 20 | import { prReviewRequests, users } from "../db/schema"; | |
| 21 | import { reviewersForChangedFiles } from "./codeowners"; | |
| 22 | import { notify } from "./notify"; | |
| 23 | ||
| 24 | export type ReviewSource = "codeowners" | "manual" | "ai"; | |
| 25 | export type ReviewState = | |
| 26 | | "pending" | |
| 27 | | "approved" | |
| 28 | | "changes_requested" | |
| 29 | | "dismissed"; | |
| 30 | ||
| 31 | export const REVIEW_SOURCES: ReviewSource[] = ["codeowners", "manual", "ai"]; | |
| 32 | export const REVIEW_STATES: ReviewState[] = [ | |
| 33 | "pending", | |
| 34 | "approved", | |
| 35 | "changes_requested", | |
| 36 | "dismissed", | |
| 37 | ]; | |
| 38 | ||
| 39 | export function isValidSource(s: string): s is ReviewSource { | |
| 40 | return (REVIEW_SOURCES as string[]).includes(s); | |
| 41 | } | |
| 42 | ||
| 43 | export 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 | */ | |
| 52 | export 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 | */ | |
| 65 | export 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 | */ | |
| 87 | export 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. */ | |
| 125 | export 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. */ | |
| 165 | export 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 | */ | |
| 191 | export 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 | */ | |
| 227 | export 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 | */ | |
| 278 | export 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 | ||
| 295 | export const __internal = { sanitiseCandidates, nextState }; |