markdown · 9948 bytes Raw Blame History

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:

  1. Looks up the longest-pattern-matching branch_protection_rule for the PR's base_ref.
  2. Counts approves per author (latest review per author wins).
  3. Counts undismissed request_changes reviews.
  4. Returns Satisfied=true iff approves >= required_review_count AND 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.Mergeability sets mergeable_state = 'blocked' when not satisfied. Re-runs after every pr:synchronize, every review submit, and every dismiss.
  • pulls.Merge re-evaluates the gate inside the FOR UPDATE row lock right before repogit.PerformMerge runs. A request_changes submitted 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 (with meta = {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_changes mid-merge-click can't lose the race.
  • Latest-review-per-author — a reviewer who first request_changes and then approve counts as approve. A trailing comment doesn't reset earlier approve. Encoded in latestPerAuthor.
  • Author email leak — same caveat as everywhere; reviewer's primary verified email is propagated. Documented; noreply is post-MVP.
  • pending=true orphan 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 ListUndismissedReviewsForGate filters dismissed_at IS NULL before 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.
  • @team review 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.