| 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 |