PR review
S23 ships file-level inline review comments, three review states
(comment / approve / request_changes), reviewer-request
flow, dismiss, server-side draft → submit, branch-protection-driven
required-review gating on merge, and outdated-comment handling on
synchronize.
Schema (migration 0024)
pr_reviews — one row per submitted review (state, body, dismissed_*)
pr_review_comments — file-level inline comments anchored on
(file_path, side, original_commit_sha,
original_line, original_position).
review_id NULL + pending=true means draft.
review_id N means part of review N.
review_id NULL + pending=false means single inline comment.
pr_review_requests — pending review requests (user; teams in S31).
Plus three columns on branch_protection_rules:
| Column | Default | Notes |
|---|---|---|
required_review_count |
0 |
How many distinct approves required before merge |
dismiss_stale_reviews_on_push |
false |
Placeholder; dismiss-on-push wires post-MVP |
require_code_owner_review |
false |
Placeholder; CODEOWNERS parsing post-MVP |
The CHECK on required_review_count >= 0 keeps the gate sane when an
admin types nonsense.
Anchoring model
Each comment captures (file_path, side, original_commit_sha, original_line, original_position). On synchronize, the new helper
internal/pulls/review/position_map.go::RemapAllForPR re-walks the
file's content at the new snapshot and tries to match each comment
back to a position. Lines that survive get their current_position
updated; lines that don't go to NULL ("outdated").
The v1 mapper is intentionally simple: it reads the file at the new
snapshot and asks "does the original line number still exist?" That
covers ~90% of the additive-commit and pure-rename cases. The
spec calls out git blame --porcelain for rebase-heavy PRs; that
swap-in is a follow-up.
Outdated comments still display in the Files tab under a default- hidden "Show outdated" toggle and stay readable in the Conversation timeline.
Server-side drafts
Browser-side drafts are fragile across tabs and don't survive a
crash. The S23 model uses pending=true rows on
pr_review_comments: every "Add inline comment" action with the
"Pending" checkbox checked stores a draft row with review_id=NULL
and pending=true. Submitting a review runs
UPDATE pr_review_comments
SET review_id = $review_id, pending = false
WHERE pr_issue_id = $pr AND author_user_id = $author AND pending = true;
inside the same tx as the review insert, so the operation is atomic.
A single inline comment outside a review (the spec's "post directly
without starting a review" path) is pending=false from the start,
review_id=NULL, and shows up in the diff inline immediately.
Routes
| Route | Auth |
|---|---|
POST /{owner}/{repo}/pulls/{n}/review-comments |
RequireUser |
POST /{owner}/{repo}/pulls/{n}/review-comments/{cid}/reply |
RequireUser |
POST /{owner}/{repo}/pulls/{n}/review-comments/{cid}/edit |
RequireUser |
POST /{owner}/{repo}/pulls/{n}/review-comments/{cid}/resolve |
RequireUser |
POST /{owner}/{repo}/pulls/{n}/review-comments/{cid}/reopen |
RequireUser |
POST /{owner}/{repo}/pulls/{n}/reviews |
RequireUser |
POST /{owner}/{repo}/pulls/{n}/reviews/{rid}/dismiss |
RequireUser |
POST /{owner}/{repo}/pulls/{n}/reviewers |
RequireUser |
GET /{owner}/{repo}/pulls/{n}.diff |
Public read |
GET /{owner}/{repo}/pulls/{n}.patch |
Public read |
The settings/branches handler now also accepts
required_review_count and dismiss_stale_reviews_on_push.
The PR template hides review-request, review-submit, inline-comment,
and thread-resolve controls unless policy.Can(pull:review) allows
the viewer. Merge and close controls are similarly driven by
pull:merge and pull:close decisions. Public viewers who can read a
PR should not see forms that only lead to 403s.
The Conversation tab uses the same markdown renderer as repo web-edit previews for comment preview. The composer suggestion payload is server-rendered from known PR participants, assignees, reviewers, and recent same-repo issues/PRs. Copilot users or actions are intentionally not included.
Required-review gate
internal/pulls/review/required.go::Evaluate is the authoritative
gate. It:
- Looks up the longest-pattern-matching
branch_protection_rulefor the PR'sbase_ref. - Counts approves per author (latest review per author wins).
- Counts undismissed
request_changesreviews. - Returns
Satisfied=trueiffapproves >= required_review_countAND no undismissed request_changes.
PR-author approvals are excluded from the count.
The gate is consulted at two points (belt-and-braces, per spec):
pulls.Mergeabilitysetsmergeable_state = 'blocked'when not satisfied. Re-runs after everypr:synchronize, every review submit, and every dismiss.pulls.Mergere-evaluates the gate inside theFOR UPDATErow lock right beforerepogit.PerformMergeruns. Arequest_changessubmitted between the last mergeability tick and the merge attempt still blocks.
mergeable_state priority order in Mergeability:
dirty > behind > blocked > clean
Author cannot approve own PR
Enforced at review.Submit: when state is approve and the author
matches the PR author, the orchestrator returns
ErrAuthorCannotApprove (HTTP 403). The UI also hides the option
in the review form template — server check is authoritative.
Reviewer requests
Capped at 20 active reviewers per PR (MaxReviewersPerPR). Already-
pending reviewer hits ErrReviewerAlreadyPending instead of
producing a duplicate row. Submitting an approve or
request_changes review by a requested reviewer satisfies their
request (satisfied_by_review_id is set inside the submit tx); a
plain comment review does not satisfy, matching GitHub.
Resolved threads
Resolve sets resolved_at + resolved_by_user_id on the root
comment; Reopen clears them. The Files tab default-collapses
resolved threads under their file section; the comment row picks up
the shithub-pull-thread-resolved CSS class so they're visually
distinct.
Notifications hook
S23 emits these timeline events via issuesdb.InsertIssueEvent:
reviewed(withmeta = {state: "approve"|...})
Comment-added and review-requested events go through S29's pipeline when notifications ship; the trigger points are already in place.
Tests
| File | Covers |
|---|---|
internal/pulls/review/review_test.go::AuthorCannotApprove |
Self-approval rejected at orchestrator |
…::AttachesPendingComments |
Two pending=true drafts attach to a single submitted review |
…::RequiredReviews_BlocksThenUnblocks |
Rule with required_review_count=1: blocked → approve → clean |
…::RequestChanges_BlocksMerge |
Even without a rule, an undismissed request_changes blocks |
…::TwoApprovers_UnblockMerge |
required_review_count=2 satisfied by two distinct authors |
…::ResolveAndReopen |
Resolve flips resolved_at; double-resolve errors; reopen clears |
…::Dismiss_ClearsBlock |
Dismissing a request_changes re-clears mergeable_state |
Pitfalls handled
- TOCTOU between mergeability and merge — pre-flight inside the
row lock re-evaluates the gate. A reviewer hitting
request_changesmid-merge-click can't lose the race. - Latest-review-per-author — a reviewer who first
request_changesand thenapprovecounts as approve. A trailingcommentdoesn't reset earlier approve. Encoded inlatestPerAuthor. - Author email leak — same caveat as everywhere; reviewer's primary verified email is propagated. Documented; noreply is post-MVP.
pending=trueorphan rows — drafts with no submit are user state; left in place. A janitor that cleans drafts older than 30d is post-MVP.- Approver count includes dismissed reviews? No. The query in
ListUndismissedReviewsForGatefiltersdismissed_at IS NULLbefore the per-author reduce.
Deferred
- CODEOWNERS-driven required reviewers → post-MVP. Column exists; parser + UI follow.
- Auto-dismiss-stale-reviews on synchronize → post-MVP. Column exists; default off matches GitHub.
- Multi-line comment ranges → post-MVP.
- Suggestion-block markdown fence → post-MVP. Renders as plain code today.
- Cross-fork (S27) reviews — comments anchor on the head repo's OID; cross-repo lookup gap closes when forks land.
@teamreview requests → S31. Column already in place.- Position mapping via blame for rebase-heavy PRs → follow-up. Current line-presence mapper is the floor.
- Notification fan-out → S29 consumes the events S23 emits.
- Comment edit on someone else's comment — not a feature; only the author can edit, server-enforced. Repo admins dismiss reviews but don't edit comment bodies.
View source
| 1 | # PR review |
| 2 | |
| 3 | S23 ships file-level inline review comments, three review states |
| 4 | (`comment` / `approve` / `request_changes`), reviewer-request |
| 5 | flow, dismiss, server-side draft → submit, branch-protection-driven |
| 6 | required-review gating on merge, and outdated-comment handling on |
| 7 | synchronize. |
| 8 | |
| 9 | ## Schema (migration 0024) |
| 10 | |
| 11 | ``` |
| 12 | pr_reviews — one row per submitted review (state, body, dismissed_*) |
| 13 | pr_review_comments — file-level inline comments anchored on |
| 14 | (file_path, side, original_commit_sha, |
| 15 | original_line, original_position). |
| 16 | review_id NULL + pending=true means draft. |
| 17 | review_id N means part of review N. |
| 18 | review_id NULL + pending=false means single inline comment. |
| 19 | pr_review_requests — pending review requests (user; teams in S31). |
| 20 | ``` |
| 21 | |
| 22 | Plus three columns on `branch_protection_rules`: |
| 23 | |
| 24 | | Column | Default | Notes | |
| 25 | | ------------------------------- | ------- | -------------------------------------------------- | |
| 26 | | `required_review_count` | `0` | How many distinct approves required before merge | |
| 27 | | `dismiss_stale_reviews_on_push` | `false` | Placeholder; dismiss-on-push wires post-MVP | |
| 28 | | `require_code_owner_review` | `false` | Placeholder; CODEOWNERS parsing post-MVP | |
| 29 | |
| 30 | The CHECK on `required_review_count >= 0` keeps the gate sane when an |
| 31 | admin types nonsense. |
| 32 | |
| 33 | ## Anchoring model |
| 34 | |
| 35 | Each comment captures `(file_path, side, original_commit_sha, |
| 36 | original_line, original_position)`. On synchronize, the new helper |
| 37 | `internal/pulls/review/position_map.go::RemapAllForPR` re-walks the |
| 38 | file's content at the new snapshot and tries to match each comment |
| 39 | back to a position. Lines that survive get their `current_position` |
| 40 | updated; lines that don't go to NULL ("outdated"). |
| 41 | |
| 42 | The v1 mapper is intentionally simple: it reads the file at the new |
| 43 | snapshot and asks "does the original line number still exist?" That |
| 44 | covers ~90% of the additive-commit and pure-rename cases. The |
| 45 | spec calls out `git blame --porcelain` for rebase-heavy PRs; that |
| 46 | swap-in is a follow-up. |
| 47 | |
| 48 | Outdated comments still display in the Files tab under a default- |
| 49 | hidden "Show outdated" toggle and stay readable in the Conversation |
| 50 | timeline. |
| 51 | |
| 52 | ## Server-side drafts |
| 53 | |
| 54 | Browser-side drafts are fragile across tabs and don't survive a |
| 55 | crash. The S23 model uses `pending=true` rows on |
| 56 | `pr_review_comments`: every "Add inline comment" action with the |
| 57 | "Pending" checkbox checked stores a draft row with `review_id=NULL` |
| 58 | and `pending=true`. Submitting a review runs |
| 59 | |
| 60 | ```sql |
| 61 | UPDATE pr_review_comments |
| 62 | SET review_id = $review_id, pending = false |
| 63 | WHERE pr_issue_id = $pr AND author_user_id = $author AND pending = true; |
| 64 | ``` |
| 65 | |
| 66 | inside the same tx as the review insert, so the operation is atomic. |
| 67 | |
| 68 | A single inline comment outside a review (the spec's "post directly |
| 69 | without starting a review" path) is `pending=false` from the start, |
| 70 | `review_id=NULL`, and shows up in the diff inline immediately. |
| 71 | |
| 72 | ## Routes |
| 73 | |
| 74 | | Route | Auth | |
| 75 | | ----------------------------------------------------------------- | ------------ | |
| 76 | | `POST /{owner}/{repo}/pulls/{n}/review-comments` | RequireUser | |
| 77 | | `POST /{owner}/{repo}/pulls/{n}/review-comments/{cid}/reply` | RequireUser | |
| 78 | | `POST /{owner}/{repo}/pulls/{n}/review-comments/{cid}/edit` | RequireUser | |
| 79 | | `POST /{owner}/{repo}/pulls/{n}/review-comments/{cid}/resolve` | RequireUser | |
| 80 | | `POST /{owner}/{repo}/pulls/{n}/review-comments/{cid}/reopen` | RequireUser | |
| 81 | | `POST /{owner}/{repo}/pulls/{n}/reviews` | RequireUser | |
| 82 | | `POST /{owner}/{repo}/pulls/{n}/reviews/{rid}/dismiss` | RequireUser | |
| 83 | | `POST /{owner}/{repo}/pulls/{n}/reviewers` | RequireUser | |
| 84 | | `GET /{owner}/{repo}/pulls/{n}.diff` | Public read | |
| 85 | | `GET /{owner}/{repo}/pulls/{n}.patch` | Public read | |
| 86 | |
| 87 | The settings/branches handler now also accepts |
| 88 | `required_review_count` and `dismiss_stale_reviews_on_push`. |
| 89 | |
| 90 | The PR template hides review-request, review-submit, inline-comment, |
| 91 | and thread-resolve controls unless `policy.Can(pull:review)` allows |
| 92 | the viewer. Merge and close controls are similarly driven by |
| 93 | `pull:merge` and `pull:close` decisions. Public viewers who can read a |
| 94 | PR should not see forms that only lead to 403s. |
| 95 | |
| 96 | The Conversation tab uses the same markdown renderer as repo web-edit |
| 97 | previews for comment preview. The composer suggestion payload is |
| 98 | server-rendered from known PR participants, assignees, reviewers, and |
| 99 | recent same-repo issues/PRs. Copilot users or actions are intentionally |
| 100 | not included. |
| 101 | |
| 102 | ## Required-review gate |
| 103 | |
| 104 | `internal/pulls/review/required.go::Evaluate` is the authoritative |
| 105 | gate. It: |
| 106 | |
| 107 | 1. Looks up the longest-pattern-matching `branch_protection_rule` |
| 108 | for the PR's `base_ref`. |
| 109 | 2. Counts approves per author (latest review per author wins). |
| 110 | 3. Counts undismissed `request_changes` reviews. |
| 111 | 4. Returns `Satisfied=true` iff `approves >= required_review_count` |
| 112 | AND no undismissed request_changes. |
| 113 | |
| 114 | PR-author approvals are **excluded** from the count. |
| 115 | |
| 116 | The gate is consulted at **two points** (belt-and-braces, per spec): |
| 117 | |
| 118 | - `pulls.Mergeability` sets `mergeable_state = 'blocked'` when not |
| 119 | satisfied. Re-runs after every `pr:synchronize`, every review |
| 120 | submit, and every dismiss. |
| 121 | - `pulls.Merge` re-evaluates the gate inside the `FOR UPDATE` row |
| 122 | lock right before `repogit.PerformMerge` runs. A `request_changes` |
| 123 | submitted between the last mergeability tick and the merge attempt |
| 124 | still blocks. |
| 125 | |
| 126 | `mergeable_state` priority order in `Mergeability`: |
| 127 | |
| 128 | ``` |
| 129 | dirty > behind > blocked > clean |
| 130 | ``` |
| 131 | |
| 132 | ## Author cannot approve own PR |
| 133 | |
| 134 | Enforced at `review.Submit`: when state is `approve` and the author |
| 135 | matches the PR author, the orchestrator returns |
| 136 | `ErrAuthorCannotApprove` (HTTP 403). The UI also hides the option |
| 137 | in the review form template — server check is authoritative. |
| 138 | |
| 139 | ## Reviewer requests |
| 140 | |
| 141 | Capped at 20 active reviewers per PR (`MaxReviewersPerPR`). Already- |
| 142 | pending reviewer hits `ErrReviewerAlreadyPending` instead of |
| 143 | producing a duplicate row. Submitting an `approve` or |
| 144 | `request_changes` review by a requested reviewer satisfies their |
| 145 | request (`satisfied_by_review_id` is set inside the submit tx); a |
| 146 | plain `comment` review does **not** satisfy, matching GitHub. |
| 147 | |
| 148 | ## Resolved threads |
| 149 | |
| 150 | `Resolve` sets `resolved_at + resolved_by_user_id` on the root |
| 151 | comment; `Reopen` clears them. The Files tab default-collapses |
| 152 | resolved threads under their file section; the comment row picks up |
| 153 | the `shithub-pull-thread-resolved` CSS class so they're visually |
| 154 | distinct. |
| 155 | |
| 156 | ## Notifications hook |
| 157 | |
| 158 | S23 emits these timeline events via `issuesdb.InsertIssueEvent`: |
| 159 | |
| 160 | - `reviewed` (with `meta = {state: "approve"|...}`) |
| 161 | |
| 162 | Comment-added and review-requested events go through S29's pipeline |
| 163 | when notifications ship; the trigger points are already in place. |
| 164 | |
| 165 | ## Tests |
| 166 | |
| 167 | | File | Covers | |
| 168 | | ------------------------------------------------- | -------------------------------------------------------------- | |
| 169 | | `internal/pulls/review/review_test.go::AuthorCannotApprove` | Self-approval rejected at orchestrator | |
| 170 | | `…::AttachesPendingComments` | Two `pending=true` drafts attach to a single submitted review | |
| 171 | | `…::RequiredReviews_BlocksThenUnblocks` | Rule with `required_review_count=1`: blocked → approve → clean | |
| 172 | | `…::RequestChanges_BlocksMerge` | Even without a rule, an undismissed `request_changes` blocks | |
| 173 | | `…::TwoApprovers_UnblockMerge` | `required_review_count=2` satisfied by two distinct authors | |
| 174 | | `…::ResolveAndReopen` | Resolve flips `resolved_at`; double-resolve errors; reopen clears | |
| 175 | | `…::Dismiss_ClearsBlock` | Dismissing a `request_changes` re-clears mergeable_state | |
| 176 | |
| 177 | ## Pitfalls handled |
| 178 | |
| 179 | - **TOCTOU between mergeability and merge** — pre-flight inside the |
| 180 | row lock re-evaluates the gate. A reviewer hitting |
| 181 | `request_changes` mid-merge-click can't lose the race. |
| 182 | - **Latest-review-per-author** — a reviewer who first |
| 183 | `request_changes` and then `approve` counts as approve. A trailing |
| 184 | `comment` doesn't reset earlier approve. Encoded in |
| 185 | `latestPerAuthor`. |
| 186 | - **Author email leak** — same caveat as everywhere; reviewer's |
| 187 | primary verified email is propagated. Documented; noreply is |
| 188 | post-MVP. |
| 189 | - **`pending=true` orphan rows** — drafts with no submit are user |
| 190 | state; left in place. A janitor that cleans drafts older than 30d |
| 191 | is post-MVP. |
| 192 | - **Approver count includes dismissed reviews?** No. The query in |
| 193 | `ListUndismissedReviewsForGate` filters `dismissed_at IS NULL` |
| 194 | before the per-author reduce. |
| 195 | |
| 196 | ## Deferred |
| 197 | |
| 198 | - **CODEOWNERS-driven required reviewers** → post-MVP. Column |
| 199 | exists; parser + UI follow. |
| 200 | - **Auto-dismiss-stale-reviews on synchronize** → post-MVP. Column |
| 201 | exists; default off matches GitHub. |
| 202 | - **Multi-line comment ranges** → post-MVP. |
| 203 | - **Suggestion-block markdown fence** → post-MVP. Renders as plain |
| 204 | code today. |
| 205 | - **Cross-fork (S27) reviews** — comments anchor on the head repo's |
| 206 | OID; cross-repo lookup gap closes when forks land. |
| 207 | - **`@team` review requests** → S31. Column already in place. |
| 208 | - **Position mapping via blame for rebase-heavy PRs** → follow-up. |
| 209 | Current line-presence mapper is the floor. |
| 210 | - **Notification fan-out** → S29 consumes the events S23 emits. |
| 211 | - **Comment edit on someone else's comment** — not a feature; only |
| 212 | the author can edit, server-enforced. Repo admins dismiss reviews |
| 213 | but don't edit comment bodies. |