| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | |
| 3 | package checks |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | |
| 10 | "github.com/jackc/pgx/v5" |
| 11 | "github.com/jackc/pgx/v5/pgxpool" |
| 12 | |
| 13 | checksdb "github.com/tenseleyFlow/shithub/internal/checks/sqlc" |
| 14 | ) |
| 15 | |
| 16 | // GateInputs is the small struct the merge gate cares about. |
| 17 | type GateInputs struct { |
| 18 | RepoID int64 |
| 19 | HeadSHA string |
| 20 | RequiredNames []string // from branch_protection_rules.status_checks_required |
| 21 | } |
| 22 | |
| 23 | // GateResult mirrors the spec's blocked-with-reason vocabulary. |
| 24 | type GateResult struct { |
| 25 | Satisfied bool |
| 26 | Reason string // "" when Satisfied; otherwise human-readable cause |
| 27 | // Missing is the subset of RequiredNames that haven't yet succeeded |
| 28 | // or returned neutral on HeadSHA. Populated for UI tooltips. |
| 29 | Missing []string |
| 30 | } |
| 31 | |
| 32 | // EvaluateRequiredChecks returns Satisfied=true when every required |
| 33 | // check name has a `success` or `neutral` conclusion on HeadSHA. A |
| 34 | // missing run, an in-progress run, or any other conclusion (failure / |
| 35 | // stale / cancelled / etc.) is unsatisfied. |
| 36 | // |
| 37 | // The evaluator is the same one consulted by `pulls.Mergeability` and |
| 38 | // `pulls.Merge` — composes with `review.Evaluate` at the call site. |
| 39 | func EvaluateRequiredChecks(ctx context.Context, pool *pgxpool.Pool, in GateInputs) (GateResult, error) { |
| 40 | if len(in.RequiredNames) == 0 { |
| 41 | return GateResult{Satisfied: true}, nil |
| 42 | } |
| 43 | if in.HeadSHA == "" { |
| 44 | // No head snapshot yet — block, since we can't verify any check |
| 45 | // passed against an unknown commit. |
| 46 | return GateResult{ |
| 47 | Reason: "head SHA not yet recorded for this PR", |
| 48 | Missing: append([]string(nil), in.RequiredNames...), |
| 49 | }, nil |
| 50 | } |
| 51 | q := checksdb.New() |
| 52 | missing := []string{} |
| 53 | for _, name := range in.RequiredNames { |
| 54 | run, err := q.GetLatestCheckRunByName(ctx, pool, checksdb.GetLatestCheckRunByNameParams{ |
| 55 | RepoID: in.RepoID, |
| 56 | HeadSha: in.HeadSHA, |
| 57 | Name: name, |
| 58 | }) |
| 59 | if err != nil { |
| 60 | if errors.Is(err, pgx.ErrNoRows) { |
| 61 | missing = append(missing, name) |
| 62 | continue |
| 63 | } |
| 64 | return GateResult{}, fmt.Errorf("required-check lookup %q: %w", name, err) |
| 65 | } |
| 66 | if !runSatisfies(run) { |
| 67 | missing = append(missing, name) |
| 68 | } |
| 69 | } |
| 70 | if len(missing) == 0 { |
| 71 | return GateResult{Satisfied: true}, nil |
| 72 | } |
| 73 | reason := "Required check missing: " + missing[0] |
| 74 | if len(missing) > 1 { |
| 75 | reason = fmt.Sprintf("Required checks missing: %v", missing) |
| 76 | } |
| 77 | return GateResult{Reason: reason, Missing: missing}, nil |
| 78 | } |
| 79 | |
| 80 | // runSatisfies reports whether a single run "passes" for the |
| 81 | // required-check gate. Matches the spec: `success` and `neutral` on |
| 82 | // the head SHA satisfy; anything else (including in_progress) does |
| 83 | // not. |
| 84 | func runSatisfies(r checksdb.CheckRun) bool { |
| 85 | if r.Status != checksdb.CheckStatusCompleted { |
| 86 | return false |
| 87 | } |
| 88 | if !r.Conclusion.Valid { |
| 89 | return false |
| 90 | } |
| 91 | switch r.Conclusion.CheckConclusion { |
| 92 | case checksdb.CheckConclusionSuccess, checksdb.CheckConclusionNeutral: |
| 93 | return true |
| 94 | } |
| 95 | return false |
| 96 | } |
| 97 |