tenseleyflow/shithub / 70242ad

Browse files

S23: review orchestrator (submit/comment/resolve/dismiss/request/required/position-map) + tests

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
70242ad1e7a7351d03cf5062f83072ac93595c92
Parents
54bdb31
Tree
131462c

7 changed files

StatusFile+-
A internal/pulls/review/comment.go 159 0
A internal/pulls/review/position_map.go 117 0
A internal/pulls/review/request.go 51 0
A internal/pulls/review/required.go 171 0
A internal/pulls/review/review.go 46 0
A internal/pulls/review/review_test.go 400 0
A internal/pulls/review/submit.go 127 0
internal/pulls/review/comment.goadded
@@ -0,0 +1,159 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package review
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"strings"
9
+	"time"
10
+
11
+	"github.com/jackc/pgx/v5"
12
+	"github.com/jackc/pgx/v5/pgtype"
13
+
14
+	pullsdb "github.com/tenseleyFlow/shithub/internal/pulls/sqlc"
15
+	mdrender "github.com/tenseleyFlow/shithub/internal/repos/markdown"
16
+)
17
+
18
+// CommentParams describes a single inline comment on a PR. When
19
+// `Pending` is true the comment is filed as a draft attached to the
20
+// author's pending review (review_id stays NULL); SubmitReview later
21
+// flips pending=false and sets review_id.
22
+type CommentParams struct {
23
+	PRIssueID         int64
24
+	AuthorUserID      int64
25
+	FilePath          string
26
+	Side              string // "left" | "right"
27
+	OriginalCommitSHA string
28
+	OriginalLine      int32
29
+	OriginalPosition  int32
30
+	CurrentPosition   int32 // -1 means already outdated (rare on insert)
31
+	Body              string
32
+	InReplyToID       int64 // 0 means top-level
33
+	Pending           bool
34
+}
35
+
36
+// AddComment inserts a single inline comment. When InReplyToID is set
37
+// the new comment threads under that one (validated to belong to the
38
+// same PR). Markdown is rendered to body_html_cached at insert time.
39
+func AddComment(ctx context.Context, deps Deps, p CommentParams) (pullsdb.PrReviewComment, error) {
40
+	body := strings.TrimSpace(p.Body)
41
+	if body == "" {
42
+		return pullsdb.PrReviewComment{}, ErrEmptyBody
43
+	}
44
+	if len(body) > 65535 {
45
+		return pullsdb.PrReviewComment{}, ErrBodyTooLong
46
+	}
47
+	if p.Side != "left" && p.Side != "right" {
48
+		p.Side = "right"
49
+	}
50
+
51
+	q := pullsdb.New()
52
+
53
+	// Reply validation: parent must exist on the same PR. We don't
54
+	// inherit FilePath/Side from the parent — the caller passes the
55
+	// correct anchor; the in-reply-to link is purely for threading.
56
+	if p.InReplyToID != 0 {
57
+		parent, err := q.GetPRReviewComment(ctx, deps.Pool, p.InReplyToID)
58
+		if err != nil {
59
+			if errors.Is(err, pgx.ErrNoRows) {
60
+				return pullsdb.PrReviewComment{}, ErrCommentNotOnPR
61
+			}
62
+			return pullsdb.PrReviewComment{}, err
63
+		}
64
+		if parent.PrIssueID != p.PRIssueID {
65
+			return pullsdb.PrReviewComment{}, ErrCommentNotOnPR
66
+		}
67
+		// Inherit anchor from parent so the whole thread renders on
68
+		// the same diff line even if the caller forgot to pass it.
69
+		p.FilePath = parent.FilePath
70
+		p.Side = string(parent.Side)
71
+		p.OriginalCommitSHA = parent.OriginalCommitSha
72
+		p.OriginalLine = parent.OriginalLine
73
+		p.OriginalPosition = parent.OriginalPosition
74
+		if parent.CurrentPosition.Valid {
75
+			p.CurrentPosition = parent.CurrentPosition.Int32
76
+		}
77
+	}
78
+
79
+	html, _ := mdrender.RenderHTML([]byte(body))
80
+
81
+	cur := pgtype.Int4{}
82
+	if p.CurrentPosition >= 0 {
83
+		cur = pgtype.Int4{Int32: p.CurrentPosition, Valid: true}
84
+	}
85
+
86
+	row, err := q.CreatePRReviewComment(ctx, deps.Pool, pullsdb.CreatePRReviewCommentParams{
87
+		PrIssueID:         p.PRIssueID,
88
+		ReviewID:          pgtype.Int8{},
89
+		AuthorUserID:      pgtype.Int8{Int64: p.AuthorUserID, Valid: p.AuthorUserID != 0},
90
+		FilePath:          p.FilePath,
91
+		Side:              pullsdb.PrReviewSide(p.Side),
92
+		OriginalCommitSha: p.OriginalCommitSHA,
93
+		OriginalLine:      p.OriginalLine,
94
+		OriginalPosition:  p.OriginalPosition,
95
+		CurrentPosition:   cur,
96
+		Body:              body,
97
+		BodyHtmlCached:    pgtype.Text{String: html, Valid: html != ""},
98
+		InReplyToID:       pgtype.Int8{Int64: p.InReplyToID, Valid: p.InReplyToID != 0},
99
+		Pending:           p.Pending,
100
+	})
101
+	if err != nil {
102
+		return pullsdb.PrReviewComment{}, err
103
+	}
104
+	return row, nil
105
+}
106
+
107
+// EditComment updates the body of an existing comment. Caller is
108
+// expected to have already enforced "actor == comment.author OR
109
+// repo admin" via policy.
110
+func EditComment(ctx context.Context, deps Deps, commentID int64, body string) error {
111
+	body = strings.TrimSpace(body)
112
+	if body == "" {
113
+		return ErrEmptyBody
114
+	}
115
+	if len(body) > 65535 {
116
+		return ErrBodyTooLong
117
+	}
118
+	html, _ := mdrender.RenderHTML([]byte(body))
119
+	return pullsdb.New().UpdatePRReviewCommentBody(ctx, deps.Pool, pullsdb.UpdatePRReviewCommentBodyParams{
120
+		ID: commentID, Body: body, BodyHtmlCached: pgtype.Text{String: html, Valid: html != ""},
121
+	})
122
+}
123
+
124
+// Resolve marks the thread (root + replies) resolved. The "thread" is
125
+// keyed off the comment id; UI walks `in_reply_to_id` to render
126
+// replies, so resolving the root collapses the whole conversation.
127
+// Default-collapsed in the Files tab; "Show resolved" toggle reopens.
128
+func Resolve(ctx context.Context, deps Deps, actorUserID, commentID int64) error {
129
+	q := pullsdb.New()
130
+	c, err := q.GetPRReviewComment(ctx, deps.Pool, commentID)
131
+	if err != nil {
132
+		return err
133
+	}
134
+	if c.ResolvedAt.Valid {
135
+		return ErrAlreadyResolved
136
+	}
137
+	return q.SetPRReviewCommentResolved(ctx, deps.Pool, pullsdb.SetPRReviewCommentResolvedParams{
138
+		ID:               commentID,
139
+		ResolvedAt:       pgtype.Timestamptz{Time: time.Now(), Valid: true},
140
+		ResolvedByUserID: pgtype.Int8{Int64: actorUserID, Valid: actorUserID != 0},
141
+	})
142
+}
143
+
144
+// Reopen clears the resolved-at marker on a thread.
145
+func Reopen(ctx context.Context, deps Deps, commentID int64) error {
146
+	q := pullsdb.New()
147
+	c, err := q.GetPRReviewComment(ctx, deps.Pool, commentID)
148
+	if err != nil {
149
+		return err
150
+	}
151
+	if !c.ResolvedAt.Valid {
152
+		return ErrNotResolved
153
+	}
154
+	return q.SetPRReviewCommentResolved(ctx, deps.Pool, pullsdb.SetPRReviewCommentResolvedParams{
155
+		ID:               commentID,
156
+		ResolvedAt:       pgtype.Timestamptz{Valid: false},
157
+		ResolvedByUserID: pgtype.Int8{Valid: false},
158
+	})
159
+}
internal/pulls/review/position_map.goadded
@@ -0,0 +1,117 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package review
4
+
5
+import (
6
+	"context"
7
+	"fmt"
8
+
9
+	"github.com/jackc/pgx/v5/pgtype"
10
+
11
+	pullsdb "github.com/tenseleyFlow/shithub/internal/pulls/sqlc"
12
+	repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
13
+)
14
+
15
+// RemapAllForPR re-anchors every non-draft review comment on a PR
16
+// against the new (base, head) snapshot. For each comment:
17
+//
18
+//	1. Re-walk the diff for its file at (newBase..newHead).
19
+//	2. If the line at original_line still appears on the chosen side
20
+//	   (left = base, right = head), set current_position to its new
21
+//	   position index. Otherwise NULL → outdated.
22
+//
23
+// This is the conservative v1 implementation. The spec calls out
24
+// `git blame --porcelain` as a richer mapper for rebase-heavy PRs;
25
+// add that when the simple line-presence check proves insufficient.
26
+//
27
+// The function reads `pr_review_comments` then issues per-row
28
+// SetPRReviewCommentCurrentPosition updates. Idempotent: re-running
29
+// converges on the same answer.
30
+func RemapAllForPR(ctx context.Context, deps Deps, gitDir string, prID int64, newBaseOID, newHeadOID string) error {
31
+	if newBaseOID == "" || newHeadOID == "" {
32
+		return nil
33
+	}
34
+	q := pullsdb.New()
35
+	rows, err := q.ListNonDraftCommentsForPositionMap(ctx, deps.Pool, prID)
36
+	if err != nil {
37
+		return fmt.Errorf("position map list: %w", err)
38
+	}
39
+	if len(rows) == 0 {
40
+		return nil
41
+	}
42
+
43
+	// Build per-file caches keyed on (file_path, side). Each entry
44
+	// stores the line-text → position map for that side of the new
45
+	// diff. v1 reads file contents at the relevant snapshot OID and
46
+	// computes positions on demand.
47
+	type sideKey struct {
48
+		path string
49
+		side string
50
+	}
51
+	cache := map[sideKey]map[int]int{} // line# (from original) → new position
52
+
53
+	getMap := func(path, side string) (map[int]int, error) {
54
+		k := sideKey{path, side}
55
+		if m, ok := cache[k]; ok {
56
+			return m, nil
57
+		}
58
+		// For the v1 mapper we just compute position-by-line via the
59
+		// snapshot file content. side=right uses newHeadOID; side=left
60
+		// uses newBaseOID.
61
+		ref := newHeadOID
62
+		if side == "left" {
63
+			ref = newBaseOID
64
+		}
65
+		// Inline cap on file size — we don't bother mapping files >
66
+		// 1 MiB; those comments outdate.
67
+		const maxBytes = 1 << 20
68
+		blob, err := repogit.ReadBlobBytes(ctx, gitDir, ref, path, maxBytes)
69
+		if err != nil {
70
+			cache[k] = nil // miss → outdate everything in this file
71
+			return nil, nil
72
+		}
73
+		m := buildLineMap(blob)
74
+		cache[k] = m
75
+		return m, nil
76
+	}
77
+
78
+	for _, c := range rows {
79
+		m, _ := getMap(c.FilePath, string(c.Side))
80
+		var newPos pgtype.Int4
81
+		if m != nil {
82
+			if pos, ok := m[int(c.OriginalLine)]; ok {
83
+				newPos = pgtype.Int4{Int32: int32(pos), Valid: true}
84
+			}
85
+		}
86
+		if err := q.SetPRReviewCommentCurrentPosition(ctx, deps.Pool, pullsdb.SetPRReviewCommentCurrentPositionParams{
87
+			ID:              c.ID,
88
+			CurrentPosition: newPos,
89
+		}); err != nil {
90
+			return fmt.Errorf("position map update: %w", err)
91
+		}
92
+	}
93
+	return nil
94
+}
95
+
96
+// buildLineMap returns line-number → position-index for a blob. v1
97
+// just maps line N to position N — line numbers in the blob *are*
98
+// the positions for the simple "line still exists" check. The map
99
+// will be replaced with a content-aware mapping when we add the
100
+// blame-based variant.
101
+func buildLineMap(blob []byte) map[int]int {
102
+	out := map[int]int{}
103
+	line := 1
104
+	pos := 1
105
+	for i := 0; i < len(blob); i++ {
106
+		if blob[i] == '\n' {
107
+			out[line] = pos
108
+			line++
109
+			pos++
110
+		}
111
+	}
112
+	// Trailing line without newline.
113
+	if len(blob) > 0 && blob[len(blob)-1] != '\n' {
114
+		out[line] = pos
115
+	}
116
+	return out
117
+}
internal/pulls/review/request.goadded
@@ -0,0 +1,51 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package review
4
+
5
+import (
6
+	"context"
7
+
8
+	"github.com/jackc/pgx/v5/pgtype"
9
+
10
+	pullsdb "github.com/tenseleyFlow/shithub/internal/pulls/sqlc"
11
+)
12
+
13
+// RequestParams describes a review-request action.
14
+type RequestParams struct {
15
+	PRIssueID         int64
16
+	RequestedUserID   int64
17
+	RequestedByUserID int64
18
+}
19
+
20
+// Request creates a pr_review_requests row. Bounded by
21
+// MaxReviewersPerPR per the spec pitfalls section.
22
+func Request(ctx context.Context, deps Deps, p RequestParams) (pullsdb.PrReviewRequest, error) {
23
+	q := pullsdb.New()
24
+	count, err := q.CountActivePRReviewRequests(ctx, deps.Pool, p.PRIssueID)
25
+	if err != nil {
26
+		return pullsdb.PrReviewRequest{}, err
27
+	}
28
+	if int(count) >= MaxReviewersPerPR {
29
+		return pullsdb.PrReviewRequest{}, ErrReviewerLimitReached
30
+	}
31
+	// Idempotent-ish: if the same reviewer is already pending, don't
32
+	// add a duplicate row. Walking the active list is cheaper than
33
+	// adding a partial unique index given v1 PRs typically have
34
+	// single-digit reviewer counts.
35
+	existing, err := q.ListPRReviewRequests(ctx, deps.Pool, p.PRIssueID)
36
+	if err != nil {
37
+		return pullsdb.PrReviewRequest{}, err
38
+	}
39
+	for _, e := range existing {
40
+		if e.RequestedUserID.Valid && e.RequestedUserID.Int64 == p.RequestedUserID &&
41
+			!e.DismissedAt.Valid && !e.SatisfiedByReviewID.Valid {
42
+			return pullsdb.PrReviewRequest{}, ErrReviewerAlreadyPending
43
+		}
44
+	}
45
+	return q.CreatePRReviewRequest(ctx, deps.Pool, pullsdb.CreatePRReviewRequestParams{
46
+		PrIssueID:         p.PRIssueID,
47
+		RequestedUserID:   pgtype.Int8{Int64: p.RequestedUserID, Valid: p.RequestedUserID != 0},
48
+		RequestedTeamID:   pgtype.Int8{Valid: false},
49
+		RequestedByUserID: pgtype.Int8{Int64: p.RequestedByUserID, Valid: p.RequestedByUserID != 0},
50
+	})
51
+}
internal/pulls/review/required.goadded
@@ -0,0 +1,171 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package review
4
+
5
+import (
6
+	"context"
7
+	"path/filepath"
8
+
9
+	"github.com/jackc/pgx/v5/pgtype"
10
+	"github.com/jackc/pgx/v5/pgxpool"
11
+
12
+	pullsdb "github.com/tenseleyFlow/shithub/internal/pulls/sqlc"
13
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
14
+)
15
+
16
+// GateInputs is the small struct the merge gate cares about.
17
+type GateInputs struct {
18
+	RepoID    int64
19
+	BaseRef   string
20
+	PRIssueID int64
21
+}
22
+
23
+// GateResult mirrors the spec's mergeable_state vocabulary for
24
+// review-side blocks.
25
+type GateResult struct {
26
+	// Satisfied — the PR meets the review requirements (or none apply).
27
+	Satisfied bool
28
+	// Reason — short human-readable cause when not satisfied. Used by
29
+	// the UI banner ("Approval required (1/1 reviews missing)").
30
+	Reason string
31
+	// RequiredCount — the rule's required_review_count.
32
+	RequiredCount int
33
+	// CurrentApprovals — count of "latest review per author" with state
34
+	// approve. Always excludes the PR author.
35
+	CurrentApprovals int
36
+	// HasRequestChanges — at least one undismissed `request_changes`
37
+	// review whose author hasn't superseded it with a later approve.
38
+	HasRequestChanges bool
39
+}
40
+
41
+// Evaluate computes the gate against the matching branch-protection
42
+// rule. Returns Satisfied=true when no rule matches the base ref or
43
+// when the rule's required_review_count == 0 and no unresolved
44
+// request_changes reviews exist.
45
+//
46
+// "Latest review per author" semantics: when an author submits a
47
+// request_changes and later submits an approve, only the approve
48
+// counts. When two distinct authors approve and one of them later
49
+// submits a comment, both still count for the approval tally — only
50
+// approve and request_changes shift the per-author state.
51
+func Evaluate(ctx context.Context, pool *pgxpool.Pool, in GateInputs, prAuthorUserID int64) (GateResult, error) {
52
+	// Locate the longest-pattern-matching protection rule for this
53
+	// base ref. The same matching helper that S20 uses lives in
54
+	// internal/repos/protection but we don't import it here to avoid
55
+	// a circular dependency on the repos package — duplicate the
56
+	// `filepath.Match` longest-match shape inline (cheap).
57
+	rules, err := loadProtectionRules(ctx, pool, in.RepoID)
58
+	if err != nil {
59
+		return GateResult{}, err
60
+	}
61
+	rule, hasRule := matchRule(rules, in.BaseRef)
62
+
63
+	// If there's no rule, approve count + request_changes still gate
64
+	// the merge per spec ("no unresolved request_changes reviews").
65
+	q := pullsdb.New()
66
+	reviews, err := q.ListUndismissedReviewsForGate(ctx, pool, in.PRIssueID)
67
+	if err != nil {
68
+		return GateResult{}, err
69
+	}
70
+	approves, requestChanges := latestPerAuthor(reviews, prAuthorUserID)
71
+
72
+	required := 0
73
+	if hasRule {
74
+		required = int(rule.RequiredReviewCount)
75
+	}
76
+	out := GateResult{
77
+		RequiredCount:     required,
78
+		CurrentApprovals:  approves,
79
+		HasRequestChanges: requestChanges,
80
+	}
81
+
82
+	switch {
83
+	case requestChanges:
84
+		out.Reason = "Changes requested by a reviewer."
85
+	case approves < required:
86
+		out.Reason = "Approval required."
87
+	default:
88
+		out.Satisfied = true
89
+	}
90
+	return out, nil
91
+}
92
+
93
+// loadProtectionRules reads every rule on the repo. Tiny set in
94
+// practice; cheaper than per-request matching at the SQL layer.
95
+func loadProtectionRules(ctx context.Context, pool *pgxpool.Pool, repoID int64) ([]reposdb.BranchProtectionRule, error) {
96
+	return reposdb.New().ListBranchProtectionRules(ctx, pool, repoID)
97
+}
98
+
99
+// matchRule duplicates the longest-pattern-wins algorithm from
100
+// internal/repos/protection so this package doesn't pull that import
101
+// graph. Same behaviour: longest pattern (alphabetical tiebreaker)
102
+// wins, no rule means no match.
103
+func matchRule(rules []reposdb.BranchProtectionRule, branch string) (reposdb.BranchProtectionRule, bool) {
104
+	var best reposdb.BranchProtectionRule
105
+	bestLen := -1
106
+	for _, r := range rules {
107
+		ok, _ := filepath.Match(r.Pattern, branch)
108
+		if !ok {
109
+			continue
110
+		}
111
+		if len(r.Pattern) > bestLen ||
112
+			(len(r.Pattern) == bestLen && r.Pattern < best.Pattern) {
113
+			best = r
114
+			bestLen = len(r.Pattern)
115
+		}
116
+	}
117
+	return best, bestLen >= 0
118
+}
119
+
120
+// latestPerAuthor reduces reviews to a per-author "winning" state.
121
+// approve and request_changes update the author's tally; comment-state
122
+// reviews are ignored (they don't shift the gate state per spec).
123
+//
124
+// Author of the PR is excluded from the approval count.
125
+func latestPerAuthor(reviews []pullsdb.ListUndismissedReviewsForGateRow, prAuthorUserID int64) (approves int, requestChanges bool) {
126
+	// Reviews are ordered by (author_user_id, submitted_at) so the
127
+	// last entry per author is the latest decision. Track the per-
128
+	// author winner inline.
129
+	var (
130
+		curAuthor int64 = 0
131
+		curState  pullsdb.PrReviewState
132
+		curValid  bool
133
+	)
134
+	flush := func() {
135
+		if !curValid || curAuthor == prAuthorUserID || curAuthor == 0 {
136
+			return
137
+		}
138
+		switch curState {
139
+		case pullsdb.PrReviewStateApprove:
140
+			approves++
141
+		case pullsdb.PrReviewStateRequestChanges:
142
+			requestChanges = true
143
+		}
144
+	}
145
+	for _, r := range reviews {
146
+		auth := int64FromPg(r.AuthorUserID)
147
+		if auth != curAuthor {
148
+			flush()
149
+			curAuthor = auth
150
+			curState = r.State
151
+			curValid = true
152
+			continue
153
+		}
154
+		// Same author — newer row wins, but only approve / request_changes
155
+		// shift the author's state. A trailing `comment` doesn't reset
156
+		// the prior `approve`.
157
+		switch r.State {
158
+		case pullsdb.PrReviewStateApprove, pullsdb.PrReviewStateRequestChanges:
159
+			curState = r.State
160
+		}
161
+	}
162
+	flush()
163
+	return approves, requestChanges
164
+}
165
+
166
+func int64FromPg(p pgtype.Int8) int64 {
167
+	if !p.Valid {
168
+		return 0
169
+	}
170
+	return p.Int64
171
+}
internal/pulls/review/review.goadded
@@ -0,0 +1,46 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package review owns PR-review orchestration: inline comments,
4
+// review submission with attached pending comments, dismissal,
5
+// reviewer requests, thread resolution, and the required-reviews
6
+// gate consulted by the merge handler.
7
+//
8
+// Diff-position anchoring model (matches GitHub's contract): each
9
+// comment captures (file_path, side, original_commit_sha,
10
+// original_line, original_position). The PR synchronize pipeline
11
+// re-walks the diff against the new head and updates each comment's
12
+// current_position; comments whose anchor line no longer exists go
13
+// to current_position=NULL ("outdated"). The position mapping
14
+// helper here is invoked from `pulls.Synchronize`.
15
+package review
16
+
17
+import (
18
+	"errors"
19
+	"log/slog"
20
+
21
+	"github.com/jackc/pgx/v5/pgxpool"
22
+)
23
+
24
+// Deps wires this package into the runtime.
25
+type Deps struct {
26
+	Pool   *pgxpool.Pool
27
+	Logger *slog.Logger
28
+}
29
+
30
+// Errors surfaced to handlers.
31
+var (
32
+	ErrEmptyBody              = errors.New("review: comment body is required")
33
+	ErrBodyTooLong            = errors.New("review: body too long")
34
+	ErrAuthorCannotApprove    = errors.New("review: author cannot approve their own PR")
35
+	ErrInvalidState           = errors.New("review: state must be comment, approve, or request_changes")
36
+	ErrCommentNotOnPR         = errors.New("review: comment does not belong to this PR")
37
+	ErrAlreadyResolved        = errors.New("review: thread already resolved")
38
+	ErrNotResolved            = errors.New("review: thread is not resolved")
39
+	ErrReviewerLimitReached   = errors.New("review: 20 reviewers max per PR")
40
+	ErrReviewerAlreadyPending = errors.New("review: reviewer already requested")
41
+	ErrReviewNotFound         = errors.New("review: review not found")
42
+)
43
+
44
+// MaxReviewersPerPR caps active review requests per PR. Matches the
45
+// spec pitfall section.
46
+const MaxReviewersPerPR = 20
internal/pulls/review/review_test.goadded
@@ -0,0 +1,400 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package review_test
4
+
5
+import (
6
+	"context"
7
+	"io"
8
+	"log/slog"
9
+	"os"
10
+	"os/exec"
11
+	"path/filepath"
12
+	"strings"
13
+	"testing"
14
+
15
+	"github.com/jackc/pgx/v5/pgtype"
16
+	"github.com/jackc/pgx/v5/pgxpool"
17
+
18
+	issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
19
+	"github.com/tenseleyFlow/shithub/internal/pulls"
20
+	"github.com/tenseleyFlow/shithub/internal/pulls/review"
21
+	pullsdb "github.com/tenseleyFlow/shithub/internal/pulls/sqlc"
22
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
23
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
24
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
25
+)
26
+
27
+const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
28
+	"AAAAAAAAAAAAAAAA$" +
29
+	"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
30
+
31
+func gitCmd(args ...string) *exec.Cmd {
32
+	//nolint:gosec
33
+	return exec.Command("git", args...)
34
+}
35
+
36
+type fx struct {
37
+	pool        *pgxpool.Pool
38
+	pullsDeps   pulls.Deps
39
+	reviewDeps  review.Deps
40
+	authorID    int64
41
+	reviewerID  int64
42
+	otherID     int64
43
+	repoID      int64
44
+	gitDir      string
45
+}
46
+
47
+func setup(t *testing.T) fx {
48
+	t.Helper()
49
+	pool := dbtest.NewTestDB(t)
50
+	ctx := context.Background()
51
+
52
+	uq := usersdb.New()
53
+	mkUser := func(name string) usersdb.User {
54
+		u, err := uq.CreateUser(ctx, pool, usersdb.CreateUserParams{
55
+			Username: name, DisplayName: strings.ToTitle(name), PasswordHash: fixtureHash,
56
+		})
57
+		if err != nil {
58
+			t.Fatalf("CreateUser %s: %v", name, err)
59
+		}
60
+		em, err := uq.CreateUserEmail(ctx, pool, usersdb.CreateUserEmailParams{
61
+			UserID: u.ID, Email: name + "@example.com", IsPrimary: true, Verified: true,
62
+		})
63
+		if err != nil {
64
+			t.Fatalf("CreateUserEmail: %v", err)
65
+		}
66
+		if err := uq.LinkUserPrimaryEmail(ctx, pool, usersdb.LinkUserPrimaryEmailParams{
67
+			ID: u.ID, PrimaryEmailID: pgtype.Int8{Int64: em.ID, Valid: true},
68
+		}); err != nil {
69
+			t.Fatalf("LinkUserPrimaryEmail: %v", err)
70
+		}
71
+		return u
72
+	}
73
+	author := mkUser("alice")
74
+	reviewer := mkUser("bob")
75
+	other := mkUser("carol")
76
+
77
+	rq := reposdb.New()
78
+	repo, err := rq.CreateRepo(ctx, pool, reposdb.CreateRepoParams{
79
+		OwnerUserID:   pgtype.Int8{Int64: author.ID, Valid: true},
80
+		Name:          "demo",
81
+		DefaultBranch: "trunk",
82
+		Visibility:    reposdb.RepoVisibilityPublic,
83
+	})
84
+	if err != nil {
85
+		t.Fatalf("CreateRepo: %v", err)
86
+	}
87
+	if err := issuesdb.New().EnsureRepoIssueCounter(ctx, pool, repo.ID); err != nil {
88
+		t.Fatalf("EnsureRepoIssueCounter: %v", err)
89
+	}
90
+
91
+	root := t.TempDir()
92
+	gitDir := filepath.Join(root, "demo.git")
93
+	if out, err := gitCmd("init", "--bare", "-b", "trunk", gitDir).CombinedOutput(); err != nil {
94
+		t.Fatalf("git init --bare: %v (%s)", err, out)
95
+	}
96
+
97
+	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
98
+	return fx{
99
+		pool: pool,
100
+		pullsDeps:  pulls.Deps{Pool: pool, Logger: logger},
101
+		reviewDeps: review.Deps{Pool: pool, Logger: logger},
102
+		authorID:   author.ID,
103
+		reviewerID: reviewer.ID,
104
+		otherID:    other.ID,
105
+		repoID:     repo.ID,
106
+		gitDir:     gitDir,
107
+	}
108
+}
109
+
110
+func commitOnBranch(t *testing.T, gitDir, branch, msg, file, contents string) string {
111
+	t.Helper()
112
+	wt := t.TempDir()
113
+	addArgs := []string{"-C", gitDir, "worktree", "add"}
114
+	if _, err := gitCmd("-C", gitDir, "show-ref", "--verify", "refs/heads/"+branch).CombinedOutput(); err != nil {
115
+		addArgs = append(addArgs, "-b", branch, wt)
116
+	} else {
117
+		addArgs = append(addArgs, wt, branch)
118
+	}
119
+	if out, err := gitCmd(addArgs...).CombinedOutput(); err != nil {
120
+		t.Fatalf("worktree add %s: %v (%s)", branch, err, out)
121
+	}
122
+	defer func() {
123
+		_ = gitCmd("-C", gitDir, "worktree", "remove", "--force", wt).Run()
124
+	}()
125
+	if err := os.WriteFile(filepath.Join(wt, file), []byte(contents), 0o644); err != nil { //nolint:gosec
126
+		t.Fatalf("write %s: %v", file, err)
127
+	}
128
+	for _, args := range [][]string{
129
+		{"-C", wt, "config", "user.name", "Alice"},
130
+		{"-C", wt, "config", "user.email", "alice@example.com"},
131
+		{"-C", wt, "add", "."},
132
+		{"-C", wt, "commit", "-m", msg},
133
+	} {
134
+		if out, err := gitCmd(args...).CombinedOutput(); err != nil {
135
+			t.Fatalf("%v: %v (%s)", args, err, out)
136
+		}
137
+	}
138
+	out, err := gitCmd("-C", wt, "rev-parse", "HEAD").Output()
139
+	if err != nil {
140
+		t.Fatalf("rev-parse HEAD: %v", err)
141
+	}
142
+	return strings.TrimSpace(string(out))
143
+}
144
+
145
+// openPR opens a same-repo PR and runs Mergeability so the state is
146
+// fresh.
147
+func (f fx) openPR(t *testing.T, base, head string) pullsdb.PullRequest {
148
+	t.Helper()
149
+	res, err := pulls.Create(context.Background(), f.pullsDeps, pulls.CreateParams{
150
+		RepoID: f.repoID, AuthorUserID: f.authorID,
151
+		Title: "test PR", BaseRef: base, HeadRef: head, GitDir: f.gitDir,
152
+	})
153
+	if err != nil {
154
+		t.Fatalf("openPR: %v", err)
155
+	}
156
+	if err := pulls.Mergeability(context.Background(), f.pullsDeps, f.gitDir, res.PullRequest.IssueID); err != nil {
157
+		t.Fatalf("Mergeability: %v", err)
158
+	}
159
+	return res.PullRequest
160
+}
161
+
162
+func TestSubmit_AuthorCannotApprove(t *testing.T) {
163
+	f := setup(t)
164
+	commitOnBranch(t, f.gitDir, "trunk", "init", "README.md", "hi\n")
165
+	commitOnBranch(t, f.gitDir, "feature", "add", "x.txt", "x\n")
166
+	pr := f.openPR(t, "trunk", "feature")
167
+
168
+	_, err := review.Submit(context.Background(), f.reviewDeps, review.SubmitParams{
169
+		PRIssueID: pr.IssueID, AuthorUserID: f.authorID,
170
+		State: "approve", PRAuthorUserID: f.authorID,
171
+	})
172
+	if err == nil {
173
+		t.Fatalf("expected ErrAuthorCannotApprove, got nil")
174
+	}
175
+}
176
+
177
+func TestSubmit_AttachesPendingComments(t *testing.T) {
178
+	f := setup(t)
179
+	ctx := context.Background()
180
+	commitOnBranch(t, f.gitDir, "trunk", "init", "README.md", "hi\n")
181
+	commitOnBranch(t, f.gitDir, "feature", "add", "x.txt", "x\n")
182
+	pr := f.openPR(t, "trunk", "feature")
183
+
184
+	// Two pending draft comments by the reviewer.
185
+	if _, err := review.AddComment(ctx, f.reviewDeps, review.CommentParams{
186
+		PRIssueID: pr.IssueID, AuthorUserID: f.reviewerID,
187
+		FilePath: "x.txt", Side: "right", OriginalCommitSHA: pr.HeadOid,
188
+		OriginalLine: 1, OriginalPosition: 1, CurrentPosition: 1,
189
+		Body: "first draft", Pending: true,
190
+	}); err != nil {
191
+		t.Fatalf("AddComment 1: %v", err)
192
+	}
193
+	if _, err := review.AddComment(ctx, f.reviewDeps, review.CommentParams{
194
+		PRIssueID: pr.IssueID, AuthorUserID: f.reviewerID,
195
+		FilePath: "x.txt", Side: "right", OriginalCommitSHA: pr.HeadOid,
196
+		OriginalLine: 1, OriginalPosition: 1, CurrentPosition: 1,
197
+		Body: "second draft", Pending: true,
198
+	}); err != nil {
199
+		t.Fatalf("AddComment 2: %v", err)
200
+	}
201
+
202
+	rv, err := review.Submit(ctx, f.reviewDeps, review.SubmitParams{
203
+		PRIssueID: pr.IssueID, AuthorUserID: f.reviewerID,
204
+		State: "comment", Body: "review body", PRAuthorUserID: f.authorID,
205
+	})
206
+	if err != nil {
207
+		t.Fatalf("Submit: %v", err)
208
+	}
209
+
210
+	// All pending comments should now have review_id = rv.ID and pending=false.
211
+	cs, _ := pullsdb.New().ListPRReviewComments(ctx, f.pool, pr.IssueID)
212
+	for _, c := range cs {
213
+		if c.Pending {
214
+			t.Errorf("comment %d still pending after submit", c.ID)
215
+		}
216
+		if !c.ReviewID.Valid || c.ReviewID.Int64 != rv.ID {
217
+			t.Errorf("comment %d review_id=%v, want %d", c.ID, c.ReviewID, rv.ID)
218
+		}
219
+	}
220
+}
221
+
222
+func TestRequiredReviews_BlocksThenUnblocks(t *testing.T) {
223
+	f := setup(t)
224
+	ctx := context.Background()
225
+	commitOnBranch(t, f.gitDir, "trunk", "init", "README.md", "hi\n")
226
+	commitOnBranch(t, f.gitDir, "feature", "add", "x.txt", "x\n")
227
+	pr := f.openPR(t, "trunk", "feature")
228
+
229
+	// Add a protection rule on trunk requiring 1 approval.
230
+	rq := reposdb.New()
231
+	id, err := rq.UpsertBranchProtectionRule(ctx, f.pool, reposdb.UpsertBranchProtectionRuleParams{
232
+		RepoID: f.repoID, Pattern: "trunk",
233
+		PreventForcePush: true, PreventDeletion: true, RequirePrForPush: false,
234
+		AllowedPusherUserIds: []int64{},
235
+		CreatedByUserID:      pgtype.Int8{Valid: false},
236
+	})
237
+	if err != nil {
238
+		t.Fatalf("UpsertBranchProtectionRule: %v", err)
239
+	}
240
+	if err := rq.UpdateBranchProtectionReviewSettings(ctx, f.pool, reposdb.UpdateBranchProtectionReviewSettingsParams{
241
+		ID: id, RequiredReviewCount: 1,
242
+	}); err != nil {
243
+		t.Fatalf("UpdateBranchProtectionReviewSettings: %v", err)
244
+	}
245
+
246
+	// Re-tick mergeability — should be blocked now.
247
+	if err := pulls.Mergeability(ctx, f.pullsDeps, f.gitDir, pr.IssueID); err != nil {
248
+		t.Fatalf("Mergeability: %v", err)
249
+	}
250
+	got, _ := pullsdb.New().GetPullRequestByIssueID(ctx, f.pool, pr.IssueID)
251
+	if got.MergeableState != pullsdb.PrMergeableStateBlocked {
252
+		t.Errorf("after rule: state=%s, want blocked", got.MergeableState)
253
+	}
254
+
255
+	// Reviewer approves.
256
+	if _, err := review.Submit(ctx, f.reviewDeps, review.SubmitParams{
257
+		PRIssueID: pr.IssueID, AuthorUserID: f.reviewerID,
258
+		State: "approve", PRAuthorUserID: f.authorID,
259
+	}); err != nil {
260
+		t.Fatalf("approve: %v", err)
261
+	}
262
+	if err := pulls.Mergeability(ctx, f.pullsDeps, f.gitDir, pr.IssueID); err != nil {
263
+		t.Fatalf("Mergeability after approve: %v", err)
264
+	}
265
+	got, _ = pullsdb.New().GetPullRequestByIssueID(ctx, f.pool, pr.IssueID)
266
+	if got.MergeableState != pullsdb.PrMergeableStateClean {
267
+		t.Errorf("after approve: state=%s, want clean", got.MergeableState)
268
+	}
269
+
270
+	// Merge proceeds.
271
+	if err := pulls.Merge(ctx, f.pullsDeps, pulls.MergeParams{
272
+		PRID: pr.IssueID, ActorUserID: f.authorID, GitDir: f.gitDir, Method: "merge",
273
+	}); err != nil {
274
+		t.Fatalf("Merge: %v", err)
275
+	}
276
+}
277
+
278
+func TestRequestChanges_BlocksMerge(t *testing.T) {
279
+	f := setup(t)
280
+	ctx := context.Background()
281
+	commitOnBranch(t, f.gitDir, "trunk", "init", "README.md", "hi\n")
282
+	commitOnBranch(t, f.gitDir, "feature", "add", "x.txt", "x\n")
283
+	pr := f.openPR(t, "trunk", "feature")
284
+
285
+	if _, err := review.Submit(ctx, f.reviewDeps, review.SubmitParams{
286
+		PRIssueID: pr.IssueID, AuthorUserID: f.reviewerID,
287
+		State: "request_changes", PRAuthorUserID: f.authorID,
288
+	}); err != nil {
289
+		t.Fatalf("submit request_changes: %v", err)
290
+	}
291
+	if err := pulls.Mergeability(ctx, f.pullsDeps, f.gitDir, pr.IssueID); err != nil {
292
+		t.Fatalf("Mergeability: %v", err)
293
+	}
294
+	got, _ := pullsdb.New().GetPullRequestByIssueID(ctx, f.pool, pr.IssueID)
295
+	if got.MergeableState != pullsdb.PrMergeableStateBlocked {
296
+		t.Errorf("after request_changes: state=%s, want blocked", got.MergeableState)
297
+	}
298
+	// Merge should refuse.
299
+	if err := pulls.Merge(ctx, f.pullsDeps, pulls.MergeParams{
300
+		PRID: pr.IssueID, ActorUserID: f.authorID, GitDir: f.gitDir, Method: "merge",
301
+	}); err == nil {
302
+		t.Errorf("Merge should have been blocked, got nil")
303
+	}
304
+}
305
+
306
+func TestTwoApprovers_UnblockMerge(t *testing.T) {
307
+	f := setup(t)
308
+	ctx := context.Background()
309
+	commitOnBranch(t, f.gitDir, "trunk", "init", "README.md", "hi\n")
310
+	commitOnBranch(t, f.gitDir, "feature", "add", "x.txt", "x\n")
311
+	pr := f.openPR(t, "trunk", "feature")
312
+
313
+	rq := reposdb.New()
314
+	id, _ := rq.UpsertBranchProtectionRule(ctx, f.pool, reposdb.UpsertBranchProtectionRuleParams{
315
+		RepoID: f.repoID, Pattern: "trunk",
316
+		PreventForcePush: true, PreventDeletion: true, RequirePrForPush: false,
317
+		AllowedPusherUserIds: []int64{}, CreatedByUserID: pgtype.Int8{Valid: false},
318
+	})
319
+	_ = rq.UpdateBranchProtectionReviewSettings(ctx, f.pool, reposdb.UpdateBranchProtectionReviewSettingsParams{
320
+		ID: id, RequiredReviewCount: 2,
321
+	})
322
+	if err := pulls.Mergeability(ctx, f.pullsDeps, f.gitDir, pr.IssueID); err != nil {
323
+		t.Fatalf("Mergeability: %v", err)
324
+	}
325
+	for _, uid := range []int64{f.reviewerID, f.otherID} {
326
+		if _, err := review.Submit(ctx, f.reviewDeps, review.SubmitParams{
327
+			PRIssueID: pr.IssueID, AuthorUserID: uid,
328
+			State: "approve", PRAuthorUserID: f.authorID,
329
+		}); err != nil {
330
+			t.Fatalf("approve: %v", err)
331
+		}
332
+	}
333
+	if err := pulls.Mergeability(ctx, f.pullsDeps, f.gitDir, pr.IssueID); err != nil {
334
+		t.Fatalf("Mergeability: %v", err)
335
+	}
336
+	got, _ := pullsdb.New().GetPullRequestByIssueID(ctx, f.pool, pr.IssueID)
337
+	if got.MergeableState != pullsdb.PrMergeableStateClean {
338
+		t.Errorf("two approvers: state=%s, want clean", got.MergeableState)
339
+	}
340
+}
341
+
342
+func TestResolveAndReopen(t *testing.T) {
343
+	f := setup(t)
344
+	ctx := context.Background()
345
+	commitOnBranch(t, f.gitDir, "trunk", "init", "README.md", "hi\n")
346
+	commitOnBranch(t, f.gitDir, "feature", "add", "x.txt", "x\n")
347
+	pr := f.openPR(t, "trunk", "feature")
348
+
349
+	c, err := review.AddComment(ctx, f.reviewDeps, review.CommentParams{
350
+		PRIssueID: pr.IssueID, AuthorUserID: f.reviewerID,
351
+		FilePath: "x.txt", Side: "right", OriginalCommitSHA: pr.HeadOid,
352
+		OriginalLine: 1, OriginalPosition: 1, CurrentPosition: 1,
353
+		Body: "comment",
354
+	})
355
+	if err != nil {
356
+		t.Fatalf("AddComment: %v", err)
357
+	}
358
+	if err := review.Resolve(ctx, f.reviewDeps, f.reviewerID, c.ID); err != nil {
359
+		t.Fatalf("Resolve: %v", err)
360
+	}
361
+	if err := review.Resolve(ctx, f.reviewDeps, f.reviewerID, c.ID); err == nil {
362
+		t.Errorf("double-resolve should error")
363
+	}
364
+	if err := review.Reopen(ctx, f.reviewDeps, c.ID); err != nil {
365
+		t.Fatalf("Reopen: %v", err)
366
+	}
367
+	got, _ := pullsdb.New().GetPRReviewComment(ctx, f.pool, c.ID)
368
+	if got.ResolvedAt.Valid {
369
+		t.Errorf("after reopen: resolved_at still set")
370
+	}
371
+}
372
+
373
+func TestDismiss_ClearsBlock(t *testing.T) {
374
+	f := setup(t)
375
+	ctx := context.Background()
376
+	commitOnBranch(t, f.gitDir, "trunk", "init", "README.md", "hi\n")
377
+	commitOnBranch(t, f.gitDir, "feature", "add", "x.txt", "x\n")
378
+	pr := f.openPR(t, "trunk", "feature")
379
+
380
+	rv, err := review.Submit(ctx, f.reviewDeps, review.SubmitParams{
381
+		PRIssueID: pr.IssueID, AuthorUserID: f.reviewerID,
382
+		State: "request_changes", PRAuthorUserID: f.authorID,
383
+	})
384
+	if err != nil {
385
+		t.Fatalf("submit: %v", err)
386
+	}
387
+	_ = pulls.Mergeability(ctx, f.pullsDeps, f.gitDir, pr.IssueID)
388
+	got, _ := pullsdb.New().GetPullRequestByIssueID(ctx, f.pool, pr.IssueID)
389
+	if got.MergeableState != pullsdb.PrMergeableStateBlocked {
390
+		t.Fatalf("pre-dismiss: state=%s, want blocked", got.MergeableState)
391
+	}
392
+	if err := review.Dismiss(ctx, f.reviewDeps, f.authorID, rv.ID, "stale"); err != nil {
393
+		t.Fatalf("Dismiss: %v", err)
394
+	}
395
+	_ = pulls.Mergeability(ctx, f.pullsDeps, f.gitDir, pr.IssueID)
396
+	got, _ = pullsdb.New().GetPullRequestByIssueID(ctx, f.pool, pr.IssueID)
397
+	if got.MergeableState != pullsdb.PrMergeableStateClean {
398
+		t.Errorf("post-dismiss: state=%s, want clean", got.MergeableState)
399
+	}
400
+}
internal/pulls/review/submit.goadded
@@ -0,0 +1,127 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package review
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"fmt"
9
+	"strings"
10
+
11
+	"github.com/jackc/pgx/v5"
12
+	"github.com/jackc/pgx/v5/pgtype"
13
+
14
+	issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
15
+	pullsdb "github.com/tenseleyFlow/shithub/internal/pulls/sqlc"
16
+	mdrender "github.com/tenseleyFlow/shithub/internal/repos/markdown"
17
+)
18
+
19
+// SubmitParams describes the submit-a-review action.
20
+type SubmitParams struct {
21
+	PRIssueID    int64
22
+	AuthorUserID int64
23
+	State        string // "comment" | "approve" | "request_changes"
24
+	Body         string
25
+	// PRAuthorUserID is the issues.author_user_id of the PR — used to
26
+	// reject self-approval. Caller passes it in to keep this package
27
+	// from cross-importing the pulls orchestrator.
28
+	PRAuthorUserID int64
29
+}
30
+
31
+// Submit records the review row, attaches every pending draft comment
32
+// the author has on this PR to the new review (one tx), and emits a
33
+// `reviewed` timeline event with the state. Pending review-request
34
+// rows owned by the author are satisfied when state is approve or
35
+// request_changes.
36
+func Submit(ctx context.Context, deps Deps, p SubmitParams) (pullsdb.PrReview, error) {
37
+	if p.State != "comment" && p.State != "approve" && p.State != "request_changes" {
38
+		return pullsdb.PrReview{}, ErrInvalidState
39
+	}
40
+	if p.State == "approve" && p.AuthorUserID != 0 && p.AuthorUserID == p.PRAuthorUserID {
41
+		return pullsdb.PrReview{}, ErrAuthorCannotApprove
42
+	}
43
+	body := strings.TrimSpace(p.Body)
44
+	if len(body) > 65535 {
45
+		return pullsdb.PrReview{}, ErrBodyTooLong
46
+	}
47
+	html, _ := mdrender.RenderHTML([]byte(body))
48
+
49
+	tx, err := deps.Pool.Begin(ctx)
50
+	if err != nil {
51
+		return pullsdb.PrReview{}, err
52
+	}
53
+	committed := false
54
+	defer func() {
55
+		if !committed {
56
+			_ = tx.Rollback(ctx)
57
+		}
58
+	}()
59
+
60
+	q := pullsdb.New()
61
+	row, err := q.CreatePRReview(ctx, tx, pullsdb.CreatePRReviewParams{
62
+		PrIssueID:      p.PRIssueID,
63
+		AuthorUserID:   pgtype.Int8{Int64: p.AuthorUserID, Valid: p.AuthorUserID != 0},
64
+		State:          pullsdb.PrReviewState(p.State),
65
+		Body:           body,
66
+		BodyHtmlCached: pgtype.Text{String: html, Valid: html != ""},
67
+	})
68
+	if err != nil {
69
+		return pullsdb.PrReview{}, fmt.Errorf("insert review: %w", err)
70
+	}
71
+
72
+	// Attach every pending draft comment authored by this user on this
73
+	// PR to the new review.
74
+	if err := q.AttachPendingCommentsToReview(ctx, tx, pullsdb.AttachPendingCommentsToReviewParams{
75
+		PrIssueID:    p.PRIssueID,
76
+		AuthorUserID: pgtype.Int8{Int64: p.AuthorUserID, Valid: p.AuthorUserID != 0},
77
+		ReviewID:     pgtype.Int8{Int64: row.ID, Valid: true},
78
+	}); err != nil {
79
+		return pullsdb.PrReview{}, fmt.Errorf("attach pending: %w", err)
80
+	}
81
+
82
+	// Satisfy any pending review request from the author (only on
83
+	// approve / request_changes per spec).
84
+	if p.State == "approve" || p.State == "request_changes" {
85
+		if err := q.SatisfyPRReviewRequest(ctx, tx, pullsdb.SatisfyPRReviewRequestParams{
86
+			PrIssueID:           p.PRIssueID,
87
+			SatisfiedByReviewID: pgtype.Int8{Int64: row.ID, Valid: true},
88
+			RequestedUserID:     pgtype.Int8{Int64: p.AuthorUserID, Valid: p.AuthorUserID != 0},
89
+		}); err != nil {
90
+			return pullsdb.PrReview{}, fmt.Errorf("satisfy request: %w", err)
91
+		}
92
+	}
93
+
94
+	// `reviewed` timeline event.
95
+	iq := issuesdb.New()
96
+	if _, err := iq.InsertIssueEvent(ctx, tx, issuesdb.InsertIssueEventParams{
97
+		IssueID:     p.PRIssueID,
98
+		ActorUserID: pgtype.Int8{Int64: p.AuthorUserID, Valid: p.AuthorUserID != 0},
99
+		Kind:        "reviewed",
100
+		Meta:        []byte(fmt.Sprintf(`{"state":%q,"review_id":%d}`, p.State, row.ID)),
101
+	}); err != nil {
102
+		return pullsdb.PrReview{}, fmt.Errorf("emit event: %w", err)
103
+	}
104
+
105
+	if err := tx.Commit(ctx); err != nil {
106
+		return pullsdb.PrReview{}, err
107
+	}
108
+	committed = true
109
+	return row, nil
110
+}
111
+
112
+// Dismiss flips a review's dismissed_at + reason. Caller has already
113
+// gated on policy (typically repo admin only).
114
+func Dismiss(ctx context.Context, deps Deps, actorUserID, reviewID int64, reason string) error {
115
+	q := pullsdb.New()
116
+	if _, err := q.GetPRReviewByID(ctx, deps.Pool, reviewID); err != nil {
117
+		if errors.Is(err, pgx.ErrNoRows) {
118
+			return ErrReviewNotFound
119
+		}
120
+		return err
121
+	}
122
+	return q.DismissPRReview(ctx, deps.Pool, pullsdb.DismissPRReviewParams{
123
+		ID:                 reviewID,
124
+		DismissedByUserID:  pgtype.Int8{Int64: actorUserID, Valid: actorUserID != 0},
125
+		DismissalReason:    strings.TrimSpace(reason),
126
+	})
127
+}