Go · 5067 bytes Raw Blame History
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 "github.com/jackc/pgx/v5/pgxpool"
10
11 pullsdb "github.com/tenseleyFlow/shithub/internal/pulls/sqlc"
12 "github.com/tenseleyFlow/shithub/internal/repos/protection"
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 := protection.MatchLongestRule(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 // (matchRule extracted to protection.MatchLongestRule — see audit fix.)
101
102 // latestPerAuthor reduces reviews to a per-author "winning" state.
103 // approve and request_changes update the author's tally; comment-state
104 // reviews are ignored (they don't shift the gate state per spec).
105 //
106 // Author of the PR is excluded from the approval count.
107 func latestPerAuthor(reviews []pullsdb.ListUndismissedReviewsForGateRow, prAuthorUserID int64) (approves int, requestChanges bool) {
108 // Reviews are ordered by (author_user_id, submitted_at) so the
109 // last entry per author is the latest decision. Track the per-
110 // author winner inline.
111 var (
112 curAuthor int64 = 0
113 curState pullsdb.PrReviewState
114 curValid bool
115 )
116 flush := func() {
117 if !curValid || curAuthor == prAuthorUserID || curAuthor == 0 {
118 return
119 }
120 switch curState {
121 case pullsdb.PrReviewStateApprove:
122 approves++
123 case pullsdb.PrReviewStateRequestChanges:
124 requestChanges = true
125 }
126 }
127 for _, r := range reviews {
128 auth := int64FromPg(r.AuthorUserID)
129 if auth != curAuthor {
130 flush()
131 curAuthor = auth
132 curState = r.State
133 curValid = true
134 continue
135 }
136 // Same author — newer row wins, but only approve / request_changes
137 // shift the author's state. A trailing `comment` doesn't reset
138 // the prior `approve`.
139 switch r.State {
140 case pullsdb.PrReviewStateApprove, pullsdb.PrReviewStateRequestChanges:
141 curState = r.State
142 }
143 }
144 flush()
145 return approves, requestChanges
146 }
147
148 func int64FromPg(p pgtype.Int8) int64 {
149 if !p.Valid {
150 return 0
151 }
152 return p.Int64
153 }
154