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

feat(BLOCK-J): J11 CODEOWNERS auto-assign + PR review requests

feat(BLOCK-J): J11 CODEOWNERS auto-assign + PR review requests

Persists who has been asked to review a PR and auto-assigns reviewers
from CODEOWNERS on PR open.

- drizzle/0034_pr_review_requests.sql: pr_review_requests table,
  unique on (pr, reviewer), source (codeowners|manual|ai) and state
  (pending|approved|changes_requested|dismissed) enums.
- src/lib/review-requests.ts: pure helpers (isValidSource,
  isValidState, nextState state machine, sanitiseCandidates) and DB
  helpers (requestReviewers idempotent, listForPr, dismissRequest,
  recordReviewOutcome, autoAssignFromCodeowners, countPendingForUser).
  Every DB helper swallows errors — can never block the PR.
- src/routes/pulls.tsx: fire-and-forget autoAssign runs after PR
  creation, diffing base...head via git to extract changed paths,
  resolving CODEOWNERS rules to user IDs (excluding author), inserting
  requests, firing review_requested notifications. New POST routes to
  manually add + dismiss reviewers. PR detail renders a ReviewersPanel
  with per-reviewer state pills + source labels.
- 17 new tests. Total: 823 passing.
Claude committed on April 15, 2026Parent: 3648956
6 files changed+71813247f798b82f472ff4bd1ba052a9b63e7dc50c6a
6 changed files+718−1
ModifiedBUILD_BIBLE.md+6−1View fileUnifiedSplit
133133| AI chat (global + repo) | ✅ | `src/routes/ask.tsx` |
134134| AI explain-this-codebase | ✅ | D6 — per-commit cached markdown, `GET /:owner/:repo/explain`, `src/lib/ai-explain.ts` + `src/routes/ai-explain.tsx` |
135135| AI PR triage | ✅ | D3 — Claude Haiku suggests labels/reviewers/priority as an AI comment on PR create; `triagePullRequest` in `src/lib/ai-generators.ts`, wired in `src/routes/pulls.tsx` |
136| CODEOWNERS auto-assign reviewers | ✅ | J11 — on PR open, `git diff --numstat base...head` → CODEOWNERS rule match → user IDs → `pr_review_requests` rows + `review_requested` notifications. PR detail page renders a Reviewers panel with state pills (pending/approved/changes_requested/dismissed), manual `@username` add, and dismiss. `src/lib/review-requests.ts` + `drizzle/0034_pr_review_requests.sql`. |
136137| GitHub Actions equivalent (workflow runner) | ✅ | `src/lib/workflow-parser.ts`, `src/lib/workflow-runner.ts`, `src/routes/workflows.tsx`; `.gluecron/workflows/*.yml` auto-discovered on push; Bun subprocess executor, per-step timeouts, size-capped logs |
137138| Dependabot equivalent (AI dep bumper) | ✅ | D2 — `dep_update_runs` table, npm registry fetch, plan + apply bumps, creates `gluecron/dep-update-*` branch + PR row via git plumbing. `src/lib/dep-updater.ts`, `src/routes/dep-updater.tsx`, settings UI at `/:owner/:repo/settings/dep-updater`. |
138139| Code scanning UI | ✅ | I5 — `src/routes/code-scanning.tsx`, `GET /:owner/:repo/security`. Aggregates last-100 `gate_runs` matching `%scan%`/`%security%`, rolls up latest status per gate, shows failed/repaired/total cards + scanner status list + recent runs. |
293294- **J6** — Repository rulesets (push policy engine) → ✅ shipped. `drizzle/0032_repo_rulesets.sql` adds `repo_rulesets` (unique on `(repository_id, name)`, enforcement enum active/evaluate/disabled) + `ruleset_rules` (JSON params). `src/lib/rulesets.ts` exposes six rule types (`commit_message_pattern`, `branch_name_pattern`, `tag_name_pattern`, `blocked_file_paths`, `max_file_size`, `forbid_force_push`) + the pure evaluator `evaluatePush(rulesets, ctx) → {allowed, violations}`. Helpers: glob-lite matcher (`globToRegex`), defensive `parseParams`. CRUD: `listRulesetsForRepo`, `getRuleset`, `createRuleset`, `updateRulesetEnforcement`, `deleteRuleset`, `addRule`, `deleteRule`. `src/routes/rulesets.tsx` serves owner-only UI at `/:owner/:repo/settings/rulesets` (list + create), `/:id` (detail, enforcement toggle, add rule), `/:id/delete`, `/:id/rules/:ruleId/delete`. 23 new tests covering each rule type, enforcement modes, glob edge cases, and route-auth redirects.
294295- **J7** — Closing keywords auto-close issues on PR merge → ✅ shipped. `src/lib/close-keywords.ts` exports pure `extractClosingRefs(text)` and `extractClosingRefsMulti(sources[])` — scans for `(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)[:|-]? #N` with case-insensitive, punctuation-tolerant, word-boundary-respecting matching. Rejects cross-repo refs (`owner/repo#N`), embedded-in-word verbs (`disclose`, `unresolved`), and non-positive numbers. Wired into the PR merge handler in `src/routes/pulls.tsx` — after a successful merge, scans `pr.title + pr.body`, looks up each referenced open issue in the same repo, closes it, and posts a "Closed by pull request #N" comment. Wrapped in try/catch so close-keyword failures never block the merge redirect. 14 new tests covering verb forms, punctuation variants, de-dup+sort, cross-repo rejection, embedded-word rejection, case-insensitivity, and multi-source merging. Total suite 749/749.
295296- **J8** — Commit status API (external CI signals) → ✅ shipped. `drizzle/0033_commit_statuses.sql` adds `commit_statuses` (unique on `(repository_id, commit_sha, context)`, state vocabulary pending/success/failure/error). `src/lib/commit-statuses.ts` exposes pure helpers (`isValidSha`, `isValidState`, `sanitiseContext`, `reduceCombined`) and DB helpers (`setStatus` with delete-then-insert upsert, `listStatuses`, `combinedStatus`). `src/routes/commit-statuses.ts` serves `POST /api/v1/repos/:owner/:repo/statuses/:sha` (requireAuth + owner check), `GET /api/v1/repos/:owner/:repo/commits/:sha/statuses` (list, private-repo visibility), `GET /api/v1/repos/:owner/:repo/commits/:sha/status` (combined rollup). Commit detail view now renders a "Checks" pill row when statuses exist, colour-coded per state with clickable target URLs. 18 new tests covering the pure helpers + route auth + invalid-sha rejection. Total suite 767/767.
297- **J11** — PR auto-assign reviewers from CODEOWNERS + requested-reviewers tracking → ✅ shipped. `drizzle/0034_pr_review_requests.sql` adds `pr_review_requests` (unique on `(pull_request_id, reviewer_id)`, source enum `codeowners|manual|ai`, state enum `pending|approved|changes_requested|dismissed`). `src/lib/review-requests.ts` exposes pure helpers (`isValidSource`, `isValidState`, `nextState`, `sanitiseCandidates`) and DB helpers (`requestReviewers` idempotent, `listForPr` with username join, `dismissRequest`, `recordReviewOutcome`, `autoAssignFromCodeowners`, `countPendingForUser`). On PR creation, `src/routes/pulls.tsx` runs `git diff --numstat base...head` to extract changed paths, calls `reviewersForChangedFiles` (Block B3 CODEOWNERS parser), resolves usernames → user IDs, excludes the PR author, and fires `review_requested` notifications. PR detail page renders a `ReviewersPanel` with per-reviewer state pills, source labels, and dismiss + manual-add forms for owner/author. Auto-assign runs fire-and-forget — CODEOWNERS failures never block PR creation. 17 new tests. Total suite 823/823.
298- **J10** — Repository status badges (shields.io-style SVG) → ✅ shipped. `src/lib/badge.ts` renders shields.io-style flat two-segment badges with zero IO — exports `renderBadge`, `escapeXml`, `estimateTextWidth` (Verdana-11 heuristic), `colorForState`. Named colour table (green/red/yellow/blue/grey/orange) + hex-literal passthrough. Label + value clamped to 64 chars. `src/routes/badges.ts` serves `/:o/:r/badge/gates.svg` (latest 20 gate_runs rollup → passing/running/failing), `/issues.svg` + `/prs.svg` (open counts), `/status.svg` (combined commit status on default-branch HEAD), `/status/:context.svg` (single named context). Every handler wrapped in try/catch and returns a grey "unknown" badge on DB or git failure — never 500. `image/svg+xml; charset=utf-8`, `Cache-Control: public, max-age=60, stale-while-revalidate=300`. `softAuth` so public-repo badges don't require cookies. 21 new tests. Total suite 806/806.
296299- **J9** — GitHub-style contribution heatmap on user profile → ✅ shipped. `src/lib/contribution-heatmap.ts` exposes pure `buildHeatmap(activities, windowDays=365, today?)` that returns a 53-week Sunday-aligned grid of `{date, count, level 0-4, dow}` cells plus `totalContributions`, `maxDayCount`, `longestStreak`, `currentStreak`, and window start/end dates. `levelFor(count, max)` buckets into 5 GitHub-style quartiles. Wired into the profile handler in `src/routes/web.tsx` — queries `activity_feed` rows authored by the user over the last 365 days and renders a scrollable 11×11 px cell grid with hover titles, a legend, and streak counters. No schema changes — reuses existing `activity_feed` rows. 18 new tests. Total suite 785/785.
297300
298301### BLOCK H — Marketplace
309312- `src/app.tsx` — route composition, middleware order, error handlers
310313- `src/index.ts` — Bun server entry
311314- `src/lib/config.ts` — env getters (late-binding)
312- `src/db/schema.ts` — 95 tables. New tables only via new migration.
315- `src/db/schema.ts` — 96 tables. New tables only via new migration.
313316- `src/db/index.ts` — lazy proxy DB connection
314317- `src/db/migrate.ts` — migration runner
315318- `drizzle/0000_initial.sql`, `drizzle/0001_green_ecosystem.sql` — migrations
343346- `drizzle/0031_user_follows.sql` (Block J4) — migration, never edited in place. Adds `user_follows` (composite PK on `(follower_id, following_id)`, CHECK no-self-follow, reverse index on `following_id`).
344347- `drizzle/0032_repo_rulesets.sql` (Block J6) — migration, never edited in place. Adds `repo_rulesets` (unique on `(repository_id, name)`, enforcement enum) + `ruleset_rules` (JSON params).
345348- `drizzle/0033_commit_statuses.sql` (Block J8) — migration, never edited in place. Adds `commit_statuses` (unique on `(repository_id, commit_sha, context)`, state vocabulary pending/success/failure/error).
349- `drizzle/0034_pr_review_requests.sql` (Block J11) — migration, never edited in place. Adds `pr_review_requests` (unique on `(pull_request_id, reviewer_id)`, source enum codeowners/manual/ai, state enum pending/approved/changes_requested/dismissed, reviewer+state index for inbox queries).
346350
347351### 4.2 Git layer (locked)
348352- `src/git/repository.ts` — tree / blob / commits / diff / branches / blame / search / raw / tags / commitsBetween
478482- `src/lib/contribution-heatmap.ts` (Block J9) — pure heatmap builder. Exports `buildHeatmap(activities, windowDays=365, today?)` returning 53-week Sunday-aligned grid + rollup stats. Helpers: `levelFor(count, max)` (5-level quartile bucket), `formatDateKey` (UTC YYYY-MM-DD), `startOfUtcDay`, `daysBetween`. `__internal` re-exports for tests. Silently ignores invalid dates + activity outside the window.
479483- `src/lib/badge.ts` (Block J10) — zero-IO SVG badge renderer. Exports `renderBadge({label, value, color, labelColor})`, `escapeXml`, `estimateTextWidth` (Verdana-11 per-char heuristic), `colorForState` (success/passed→green, pending→yellow, failure/failed/error→red, else grey). Named colour table (green/red/yellow/blue/grey/orange) + hex literals accepted. Label + value clamped to 64 chars each before rendering. Shields.io flat style with `<title>` + `aria-label` + shadow/main text pairs.
480484- `src/routes/badges.ts` (Block J10) — serves `/:owner/:repo/badge/gates.svg` (latest 20 gate_runs rollup), `/issues.svg` (open count), `/prs.svg` (open count), `/status.svg` (combined commit status on default-branch HEAD), `/status/:context.svg` (single context on HEAD). Every handler wrapped in try/catch and returns a grey "unknown" SVG on DB or git failure — never 500. `image/svg+xml; charset=utf-8`, `Cache-Control: public, max-age=60, stale-while-revalidate=300`. `softAuth` so public-repo badges don't require cookies.
485- `src/lib/review-requests.ts` (Block J11) — PR review-request lifecycle helpers. Pure: `isValidSource`, `isValidState`, `nextState` (state machine; `dismissed` terminal, `commented` is no-op), `sanitiseCandidates` (de-dup + drop author). DB: `requestReviewers` (idempotent, skips existing (pr, reviewer) rows), `listForPr` (joins `users` for username), `dismissRequest`, `recordReviewOutcome`, `autoAssignFromCodeowners` (diff paths → CODEOWNERS → user IDs → review requests + `review_requested` notifications), `countPendingForUser` (for inbox badges). Every DB helper swallows errors and returns safe defaults — never throws.
481486
482487### 4.7 Views (locked contracts)
483488- `src/views/layout.tsx` — `Layout` accepts `title`, `user`, `notificationCount`
Addeddrizzle/0034_pr_review_requests.sql+23−0View fileUnifiedSplit
1-- Block J11 — PR review requests (auto-assign from CODEOWNERS + manual).
2--
3-- Each row represents one reviewer who has been asked to review a PR. Rows
4-- are idempotent per (pr, reviewer) and track who requested it, the source
5-- (codeowners auto-assign, manual, or AI suggestion), and the resolution
6-- state once the reviewer submits a review or is dismissed.
7
8CREATE TABLE IF NOT EXISTS "pr_review_requests" (
9 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
10 "pull_request_id" uuid NOT NULL REFERENCES "pull_requests"("id") ON DELETE CASCADE,
11 "reviewer_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
12 "requested_by" uuid REFERENCES "users"("id") ON DELETE SET NULL,
13 "source" text NOT NULL DEFAULT 'manual', -- 'codeowners' | 'manual' | 'ai'
14 "state" text NOT NULL DEFAULT 'pending', -- 'pending' | 'approved' | 'changes_requested' | 'dismissed'
15 "requested_at" timestamp NOT NULL DEFAULT now(),
16 "resolved_at" timestamp
17);
18
19CREATE UNIQUE INDEX IF NOT EXISTS "pr_review_requests_pr_reviewer_unique"
20 ON "pr_review_requests" ("pull_request_id", "reviewer_id");
21
22CREATE INDEX IF NOT EXISTS "pr_review_requests_reviewer_state_idx"
23 ON "pr_review_requests" ("reviewer_id", "state");
Addedsrc/__tests__/review-requests.test.ts+135−0View fileUnifiedSplit
1/**
2 * Block J11 — PR review requests. Pure helpers + route-auth smokes.
3 */
4
5import { describe, it, expect } from "bun:test";
6import app from "../app";
7import {
8 REVIEW_SOURCES,
9 REVIEW_STATES,
10 isValidSource,
11 isValidState,
12 nextState,
13 __internal,
14} from "../lib/review-requests";
15
16describe("review-requests — isValidSource", () => {
17 it("accepts the three canonical sources", () => {
18 for (const s of REVIEW_SOURCES) expect(isValidSource(s)).toBe(true);
19 });
20
21 it("rejects unknown sources", () => {
22 expect(isValidSource("auto")).toBe(false);
23 expect(isValidSource("")).toBe(false);
24 expect(isValidSource("CODEOWNERS")).toBe(false); // case-sensitive
25 });
26});
27
28describe("review-requests — isValidState", () => {
29 it("accepts the four canonical states", () => {
30 for (const s of REVIEW_STATES) expect(isValidState(s)).toBe(true);
31 });
32
33 it("rejects unknown states", () => {
34 expect(isValidState("open")).toBe(false);
35 expect(isValidState("approved!")).toBe(false);
36 expect(isValidState("")).toBe(false);
37 });
38});
39
40describe("review-requests — nextState", () => {
41 it("dismissed is terminal — nothing moves it", () => {
42 expect(nextState("dismissed", "approved")).toBe("dismissed");
43 expect(nextState("dismissed", "changes_requested")).toBe("dismissed");
44 expect(nextState("dismissed", "commented")).toBe("dismissed");
45 expect(nextState("dismissed", "dismissed")).toBe("dismissed");
46 });
47
48 it("commented leaves state unchanged", () => {
49 expect(nextState("pending", "commented")).toBe("pending");
50 expect(nextState("approved", "commented")).toBe("approved");
51 expect(nextState("changes_requested", "commented")).toBe("changes_requested");
52 });
53
54 it("approved / changes_requested overwrite pending + each other", () => {
55 expect(nextState("pending", "approved")).toBe("approved");
56 expect(nextState("pending", "changes_requested")).toBe("changes_requested");
57 expect(nextState("approved", "changes_requested")).toBe("changes_requested");
58 expect(nextState("changes_requested", "approved")).toBe("approved");
59 });
60
61 it("dismissed outcome transitions non-dismissed to dismissed", () => {
62 expect(nextState("pending", "dismissed")).toBe("dismissed");
63 expect(nextState("approved", "dismissed")).toBe("dismissed");
64 expect(nextState("changes_requested", "dismissed")).toBe("dismissed");
65 });
66});
67
68describe("review-requests — sanitiseCandidates", () => {
69 const { sanitiseCandidates } = __internal;
70
71 it("drops nulls, undefineds, and empty strings", () => {
72 expect(sanitiseCandidates([null, undefined, "", "u1"], null)).toEqual([
73 "u1",
74 ]);
75 });
76
77 it("de-dupes preserving first-seen order", () => {
78 expect(sanitiseCandidates(["u1", "u2", "u1", "u3", "u2"], null)).toEqual([
79 "u1",
80 "u2",
81 "u3",
82 ]);
83 });
84
85 it("excludes the PR author from the result", () => {
86 expect(
87 sanitiseCandidates(["author", "u1", "author", "u2"], "author")
88 ).toEqual(["u1", "u2"]);
89 });
90
91 it("handles no-author case", () => {
92 expect(sanitiseCandidates(["u1"], null)).toEqual(["u1"]);
93 expect(sanitiseCandidates(["u1"], undefined)).toEqual(["u1"]);
94 });
95
96 it("returns [] for all-invalid input", () => {
97 expect(sanitiseCandidates([null, undefined, ""], null)).toEqual([]);
98 });
99
100 it("preserves the author if explicitly included with a different-string ID", () => {
101 // sanitiseCandidates only filters by exact ID equality
102 expect(sanitiseCandidates(["author-x"], "author")).toEqual(["author-x"]);
103 });
104});
105
106describe("review-requests — routes", () => {
107 it("POST /:o/:r/pulls/:n/reviewers requires auth (redirects unauthed)", async () => {
108 const res = await app.request(
109 "/alice/nope/pulls/1/reviewers",
110 { method: "POST", body: "username=bob" }
111 );
112 // requireAuth middleware redirects browsers to /login
113 expect([302, 401].includes(res.status)).toBe(true);
114 });
115
116 it("POST dismiss route requires auth", async () => {
117 const res = await app.request(
118 "/alice/nope/pulls/1/reviewers/x/dismiss",
119 { method: "POST" }
120 );
121 expect([302, 401].includes(res.status)).toBe(true);
122 });
123
124 it("POST with invalid bearer token → 401 JSON", async () => {
125 const res = await app.request(
126 "/alice/nope/pulls/1/reviewers",
127 {
128 method: "POST",
129 headers: { authorization: "Bearer glc_garbage" },
130 body: "username=bob",
131 }
132 );
133 expect(res.status).toBe(401);
134 });
135});
Modifiedsrc/db/schema.ts+41−0View fileUnifiedSplit
23592359);
23602360
23612361export type CommitStatus = typeof commitStatuses.$inferSelect;
2362
2363// ============================================================================
2364// PR REVIEW REQUESTS (Block J11)
2365// ============================================================================
2366/**
2367 * Auto-assigned (from CODEOWNERS) or manually requested reviewers on a PR.
2368 * One row per (pull_request, reviewer); state tracks lifecycle from pending
2369 * → approved / changes_requested / dismissed. Source records the origin
2370 * ('codeowners' auto-assign vs 'manual' add vs 'ai' suggestion).
2371 */
2372export const prReviewRequests = pgTable(
2373 "pr_review_requests",
2374 {
2375 id: uuid("id").primaryKey().defaultRandom(),
2376 pullRequestId: uuid("pull_request_id")
2377 .notNull()
2378 .references(() => pullRequests.id, { onDelete: "cascade" }),
2379 reviewerId: uuid("reviewer_id")
2380 .notNull()
2381 .references(() => users.id, { onDelete: "cascade" }),
2382 requestedBy: uuid("requested_by").references(() => users.id, {
2383 onDelete: "set null",
2384 }),
2385 source: text("source").default("manual").notNull(),
2386 state: text("state").default("pending").notNull(),
2387 requestedAt: timestamp("requested_at").defaultNow().notNull(),
2388 resolvedAt: timestamp("resolved_at"),
2389 },
2390 (table) => [
2391 uniqueIndex("pr_review_requests_pr_reviewer_unique").on(
2392 table.pullRequestId,
2393 table.reviewerId
2394 ),
2395 index("pr_review_requests_reviewer_state_idx").on(
2396 table.reviewerId,
2397 table.state
2398 ),
2399 ]
2400);
2401
2402export type PrReviewRequest = typeof prReviewRequests.$inferSelect;
Addedsrc/lib/review-requests.ts+295−0View fileUnifiedSplit
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
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 };
Modifiedsrc/routes/pulls.tsx+218−0View fileUnifiedSplit
1313 issues,
1414 issueComments,
1515} from "../db/schema";
16import {
17 autoAssignFromCodeowners,
18 listForPr as listReviewRequestsForPr,
19 requestReviewers,
20 dismissRequest,
21} from "../lib/review-requests";
1622import { Layout } from "../views/layout";
1723import { RepoHeader, DiffView } from "../views/components";
1824import { ReactionsBar } from "../views/reactions";
378384 headBranch,
379385 }).catch((err) => console.error("[pr-triage] Failed:", err));
380386
387 // 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
381417 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
382418 }
383419);
451487 }
452488 }
453489
490 // J11 — requested reviewers (CODEOWNERS auto-assign + manual add).
491 const reviewRequests = await listReviewRequestsForPr(pr.id);
492
454493 // Get diff for "Files changed" tab
455494 let diffRaw = "";
456495 let diffFiles: GitDiffFile[] = [];
525564 </span>
526565 </div>
527566
567 <ReviewersPanel
568 reviewers={reviewRequests}
569 canManage={canManage}
570 ownerName={ownerName}
571 repoName={repoName}
572 prNumber={pr.number}
573 />
574
528575 <div class="issue-tabs" style="margin-bottom: 20px">
529576 <a
530577 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
13301377 });
13311378}
13321379
1380// ---------------------------------------------------------------------------
1381// J11 — Reviewer request management (manual add + dismiss)
1382// ---------------------------------------------------------------------------
1383pulls.post(
1384 "/:owner/:repo/pulls/:number/reviewers",
1385 softAuth,
1386 requireAuth,
1387 async (c) => {
1388 const { owner: ownerName, repo: repoName } = c.req.param();
1389 const prNum = parseInt(c.req.param("number"), 10);
1390 const user = c.get("user")!;
1391 const form = await c.req.parseBody();
1392 const usernameRaw = String(form.username || "").trim().replace(/^@/, "");
1393 if (!usernameRaw) {
1394 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1395 }
1396 const resolved = await resolveRepo(ownerName, repoName);
1397 if (!resolved) return c.notFound();
1398 const [pr] = await db
1399 .select()
1400 .from(pullRequests)
1401 .where(
1402 and(
1403 eq(pullRequests.repositoryId, resolved.repo.id),
1404 eq(pullRequests.number, prNum)
1405 )
1406 )
1407 .limit(1);
1408 if (!pr) return c.notFound();
1409 const canManage =
1410 user.id === resolved.owner.id || user.id === pr.authorId;
1411 if (!canManage) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1412 const [target] = await db
1413 .select()
1414 .from(users)
1415 .where(eq(users.username, usernameRaw))
1416 .limit(1);
1417 if (!target) {
1418 return c.redirect(
1419 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("User not found")}`
1420 );
1421 }
1422 if (target.id === pr.authorId) {
1423 return c.redirect(
1424 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("PR author cannot be a reviewer")}`
1425 );
1426 }
1427 await requestReviewers(pr.id, [target.id], user.id, "manual");
1428 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1429 }
1430);
1431
1432pulls.post(
1433 "/:owner/:repo/pulls/:number/reviewers/:reviewerId/dismiss",
1434 softAuth,
1435 requireAuth,
1436 async (c) => {
1437 const { owner: ownerName, repo: repoName, reviewerId } = c.req.param();
1438 const prNum = parseInt(c.req.param("number"), 10);
1439 const user = c.get("user")!;
1440 const resolved = await resolveRepo(ownerName, repoName);
1441 if (!resolved) return c.notFound();
1442 const [pr] = await db
1443 .select()
1444 .from(pullRequests)
1445 .where(
1446 and(
1447 eq(pullRequests.repositoryId, resolved.repo.id),
1448 eq(pullRequests.number, prNum)
1449 )
1450 )
1451 .limit(1);
1452 if (!pr) return c.notFound();
1453 const canManage =
1454 user.id === resolved.owner.id ||
1455 user.id === pr.authorId ||
1456 user.id === reviewerId;
1457 if (!canManage) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1458 await dismissRequest(pr.id, reviewerId);
1459 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1460 }
1461);
1462
1463interface ReviewersPanelProps {
1464 reviewers: Awaited<ReturnType<typeof listReviewRequestsForPr>>;
1465 canManage: boolean | null | undefined;
1466 ownerName: string;
1467 repoName: string;
1468 prNumber: number;
1469}
1470
1471function ReviewersPanel(props: ReviewersPanelProps) {
1472 const { reviewers, canManage, ownerName, repoName, prNumber } = props;
1473 const stateColor = (s: string) =>
1474 s === "approved"
1475 ? "var(--green)"
1476 : s === "changes_requested"
1477 ? "var(--red)"
1478 : s === "dismissed"
1479 ? "var(--text-muted)"
1480 : "var(--text)";
1481 return (
1482 <div
1483 style="margin: 0 0 20px; padding: 12px 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)"
1484 data-testid="reviewers-panel"
1485 >
1486 <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px">
1487 <h3 style="margin: 0; font-size: 14px">Reviewers</h3>
1488 <span style="color: var(--text-muted); font-size: 12px">
1489 {reviewers.length === 0
1490 ? "No reviewers requested"
1491 : `${reviewers.filter((r) => r.state === "pending").length} pending · ${reviewers.filter((r) => r.state === "approved").length} approved`}
1492 </span>
1493 </div>
1494 {reviewers.length > 0 && (
1495 <ul style="list-style: none; padding: 0; margin: 0 0 8px">
1496 {reviewers.map((r) => (
1497 <li style="display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px">
1498 <a href={`/${r.username}`}>
1499 <strong>@{r.username}</strong>
1500 </a>
1501 <span
1502 style={`font-size: 11px; padding: 1px 6px; border-radius: 10px; background: rgba(139, 148, 158, 0.15); color: ${stateColor(r.state)}; text-transform: capitalize`}
1503 >
1504 {r.state.replace("_", " ")}
1505 </span>
1506 <span style="font-size: 11px; color: var(--text-muted)">
1507 via {r.source}
1508 </span>
1509 {canManage && r.state !== "dismissed" && (
1510 <form
1511 method="POST"
1512 action={`/${ownerName}/${repoName}/pulls/${prNumber}/reviewers/${r.reviewerId}/dismiss`}
1513 style="margin-left: auto"
1514 >
1515 <button
1516 type="submit"
1517 class="btn"
1518 style="padding: 2px 8px; font-size: 11px"
1519 title="Dismiss this review request"
1520 >
1521 Dismiss
1522 </button>
1523 </form>
1524 )}
1525 </li>
1526 ))}
1527 </ul>
1528 )}
1529 {canManage && (
1530 <form
1531 method="POST"
1532 action={`/${ownerName}/${repoName}/pulls/${prNumber}/reviewers`}
1533 style="display: flex; gap: 6px"
1534 >
1535 <input
1536 type="text"
1537 name="username"
1538 placeholder="Request review from @username"
1539 required
1540 style="flex: 1; padding: 4px 8px; font-size: 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text)"
1541 />
1542 <button type="submit" class="btn" style="padding: 4px 10px; font-size: 12px">
1543 Request
1544 </button>
1545 </form>
1546 )}
1547 </div>
1548 );
1549}
1550
13331551export default pulls;
13341552