|
| 1 | +package boatstack |
| 2 | + |
| 3 | +import "strings" |
| 4 | + |
| 5 | +// PRPhase is the observed position of a published pull request between |
| 6 | +// publication and merge. It is derived ONLY from a live GitHub observation |
| 7 | +// (checks, review decision, merge state) at the moment of a read-only |
| 8 | +// resolution; it is never persisted, recorded by an agent, or accepted from |
| 9 | +// text. Anything the derivation cannot classify with certainty degrades to |
| 10 | +// PRPhaseUnknown, which downstream classification treats as the operator's. |
| 11 | +// control-law: pr-phase-derives-only-from-live-observation |
| 12 | +type PRPhase string |
| 13 | + |
| 14 | +const ( |
| 15 | + // PRPhaseUnknown: the observation is missing, partial, or names a |
| 16 | + // combination this derivation does not understand. Fail-closed default. |
| 17 | + PRPhaseUnknown PRPhase = "PR_UNKNOWN" |
| 18 | + // PRPhaseChecksPending: the PR is open and at least one check has not finished. |
| 19 | + PRPhaseChecksPending PRPhase = "PR_CHECKS_PENDING" |
| 20 | + // PRPhaseChecksFailing: the PR is open and at least one check concluded badly. |
| 21 | + PRPhaseChecksFailing PRPhase = "PR_CHECKS_FAILING" |
| 22 | + // PRPhaseChangesRequested: a reviewer requested changes. This outranks check |
| 23 | + // status: a human review verdict is a stronger signal than CI and hands the |
| 24 | + // step to the operator regardless of what the checks are doing. |
| 25 | + PRPhaseChangesRequested PRPhase = "PR_CHANGES_REQUESTED" |
| 26 | + // PRPhaseReviewRequired: checks are green but a required review approval is |
| 27 | + // still owed. Granting approval is never Boatstack's or the agent's to do. |
| 28 | + PRPhaseReviewRequired PRPhase = "PR_REVIEW_REQUIRED" |
| 29 | + // PRPhaseMergeEligible: checks green, review satisfied, and GitHub reports |
| 30 | + // the branch cleanly mergeable. |
| 31 | + PRPhaseMergeEligible PRPhase = "PR_MERGE_ELIGIBLE" |
| 32 | + // PRPhaseMerged / PRPhaseClosed: terminal, mirrors the PR lifecycle. |
| 33 | + PRPhaseMerged PRPhase = "PR_MERGED" |
| 34 | + PRPhaseClosed PRPhase = "PR_CLOSED" |
| 35 | +) |
| 36 | + |
| 37 | +// prStatusCheck is one element of gh's statusCheckRollup array. GitHub emits |
| 38 | +// two shapes — CheckRun (Actions/checks API: status+conclusion+name) and |
| 39 | +// StatusContext (legacy commit status: state+context) — and this struct holds |
| 40 | +// the union so one decode covers both. |
| 41 | +type prStatusCheck struct { |
| 42 | + TypeName string `json:"__typename"` |
| 43 | + Name string `json:"name"` |
| 44 | + Status string `json:"status"` |
| 45 | + Conclusion string `json:"conclusion"` |
| 46 | + Context string `json:"context"` |
| 47 | + State string `json:"state"` |
| 48 | +} |
| 49 | + |
| 50 | +// prCheckSummary aggregates a statusCheckRollup. Unrecognized is sticky: one |
| 51 | +// entry the tables below cannot classify poisons the whole summary, because a |
| 52 | +// phase derived from a partially understood rollup would be a guess. |
| 53 | +type prCheckSummary struct { |
| 54 | + Total int |
| 55 | + Passed int |
| 56 | + Failed int |
| 57 | + Pending int |
| 58 | + Failing []string |
| 59 | + Unrecognized bool |
| 60 | +} |
| 61 | + |
| 62 | +// prFailingChecksCap bounds the failing-check name list carried into status |
| 63 | +// output so one enormous check matrix cannot flood a rendered response. |
| 64 | +const prFailingChecksCap = 8 |
| 65 | + |
| 66 | +func summarizeCheckRollup(entries []prStatusCheck) prCheckSummary { |
| 67 | + summary := prCheckSummary{Total: len(entries)} |
| 68 | + for _, entry := range entries { |
| 69 | + name := strings.TrimSpace(entry.Name) |
| 70 | + if name == "" { |
| 71 | + name = strings.TrimSpace(entry.Context) |
| 72 | + } |
| 73 | + switch classifyStatusCheck(entry) { |
| 74 | + case "passed": |
| 75 | + summary.Passed++ |
| 76 | + case "pending": |
| 77 | + summary.Pending++ |
| 78 | + case "failed": |
| 79 | + summary.Failed++ |
| 80 | + if name != "" && len(summary.Failing) < prFailingChecksCap { |
| 81 | + summary.Failing = append(summary.Failing, name) |
| 82 | + } |
| 83 | + default: |
| 84 | + summary.Unrecognized = true |
| 85 | + } |
| 86 | + } |
| 87 | + return summary |
| 88 | +} |
| 89 | + |
| 90 | +// classifyStatusCheck maps one rollup entry to passed/pending/failed, or "" |
| 91 | +// when the entry's vocabulary is not in the tables. The typename is trusted |
| 92 | +// first; when absent, the populated field set identifies the shape. |
| 93 | +func classifyStatusCheck(entry prStatusCheck) string { |
| 94 | + shape := strings.TrimSpace(entry.TypeName) |
| 95 | + if shape == "" { |
| 96 | + switch { |
| 97 | + case entry.State != "" || entry.Context != "": |
| 98 | + shape = "StatusContext" |
| 99 | + case entry.Status != "" || entry.Conclusion != "": |
| 100 | + shape = "CheckRun" |
| 101 | + } |
| 102 | + } |
| 103 | + switch shape { |
| 104 | + case "CheckRun": |
| 105 | + if !strings.EqualFold(strings.TrimSpace(entry.Status), "COMPLETED") { |
| 106 | + return "pending" |
| 107 | + } |
| 108 | + switch strings.ToUpper(strings.TrimSpace(entry.Conclusion)) { |
| 109 | + case "SUCCESS", "NEUTRAL", "SKIPPED": |
| 110 | + return "passed" |
| 111 | + case "FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE": |
| 112 | + return "failed" |
| 113 | + } |
| 114 | + case "StatusContext": |
| 115 | + switch strings.ToUpper(strings.TrimSpace(entry.State)) { |
| 116 | + case "SUCCESS": |
| 117 | + return "passed" |
| 118 | + case "PENDING", "EXPECTED": |
| 119 | + return "pending" |
| 120 | + case "FAILURE", "ERROR": |
| 121 | + return "failed" |
| 122 | + } |
| 123 | + } |
| 124 | + return "" |
| 125 | +} |
| 126 | + |
| 127 | +// derivePRPhase turns one live observation into a PRPhase. The derivation is |
| 128 | +// pure and total: every input lands somewhere, and everything outside the |
| 129 | +// explicitly understood combinations lands on PRPhaseUnknown. Notably absent |
| 130 | +// on purpose: DIRTY/BEHIND/BLOCKED/DRAFT merge states (conflicts, stale base, |
| 131 | +// branch protection this derivation cannot see, drafts) all stay Unknown so |
| 132 | +// they reach the operator instead of being guessed at. |
| 133 | +func derivePRPhase(prState string, checks prCheckSummary, reviewDecision, mergeState string) PRPhase { |
| 134 | + switch strings.ToUpper(strings.TrimSpace(prState)) { |
| 135 | + case "MERGED": |
| 136 | + return PRPhaseMerged |
| 137 | + case "CLOSED": |
| 138 | + return PRPhaseClosed |
| 139 | + case "OPEN": |
| 140 | + default: |
| 141 | + return PRPhaseUnknown |
| 142 | + } |
| 143 | + if checks.Unrecognized { |
| 144 | + return PRPhaseUnknown |
| 145 | + } |
| 146 | + decision := strings.ToUpper(strings.TrimSpace(reviewDecision)) |
| 147 | + if decision == "CHANGES_REQUESTED" { |
| 148 | + return PRPhaseChangesRequested |
| 149 | + } |
| 150 | + if checks.Failed > 0 { |
| 151 | + return PRPhaseChecksFailing |
| 152 | + } |
| 153 | + if checks.Pending > 0 { |
| 154 | + return PRPhaseChecksPending |
| 155 | + } |
| 156 | + switch decision { |
| 157 | + case "REVIEW_REQUIRED": |
| 158 | + return PRPhaseReviewRequired |
| 159 | + case "", "APPROVED": |
| 160 | + default: |
| 161 | + return PRPhaseUnknown |
| 162 | + } |
| 163 | + // An empty rollup means no checks are configured; green-by-absence is |
| 164 | + // acceptable only because merge eligibility still requires GitHub itself |
| 165 | + // to report the branch cleanly mergeable below. |
| 166 | + switch strings.ToUpper(strings.TrimSpace(mergeState)) { |
| 167 | + case "CLEAN", "HAS_HOOKS": |
| 168 | + return PRPhaseMergeEligible |
| 169 | + } |
| 170 | + return PRPhaseUnknown |
| 171 | +} |
0 commit comments