|
| 1 | +// Core decision logic for the PR-readiness helper. Pure functions here are |
| 2 | +// unit-tested (test/classify.test.ts); the API-calling orchestration lives in |
| 3 | +// main.ts. |
| 4 | + |
| 5 | +import type { CheckRun, Config, Decision, GitHubUser, JobStep, Signal, SignalMatch, SignalState } from './types.ts'; |
| 6 | + |
| 7 | +const FAILURE_CONCLUSIONS = new Set(['failure', 'timed_out', 'action_required']); |
| 8 | +const NOT_APPLICABLE_CONCLUSIONS = new Set(['skipped', 'cancelled']); |
| 9 | + |
| 10 | +function matchesIgnore(name: string, patterns: string[]): boolean { |
| 11 | + return patterns.some((p) => (p.endsWith('*') ? name.startsWith(p.slice(0, -1)) : name === p)); |
| 12 | +} |
| 13 | + |
| 14 | +function findRun(checkRuns: CheckRun[], match: SignalMatch): CheckRun | undefined { |
| 15 | + return checkRuns.find( |
| 16 | + (r) => r.name === match.check && (!match.app || (r.app != null && r.app.slug === match.app)) |
| 17 | + ); |
| 18 | +} |
| 19 | + |
| 20 | +function runState(run: CheckRun | undefined): SignalState { |
| 21 | + if (!run) { |
| 22 | + return 'not-applicable'; |
| 23 | + } |
| 24 | + if (run.status !== 'completed') { |
| 25 | + return 'pending'; |
| 26 | + } |
| 27 | + if (run.conclusion !== null && FAILURE_CONCLUSIONS.has(run.conclusion)) { |
| 28 | + return 'failure'; |
| 29 | + } |
| 30 | + if (run.conclusion !== null && NOT_APPLICABLE_CONCLUSIONS.has(run.conclusion)) { |
| 31 | + return 'not-applicable'; |
| 32 | + } |
| 33 | + return 'success'; // success, neutral |
| 34 | +} |
| 35 | + |
| 36 | +// Maps the covered signals from checks.config.json onto the live check runs |
| 37 | +// for a head SHA. Uncovered checks (unit/E2E tests etc.) are invisible. |
| 38 | +export function classifySignals(checkRuns: CheckRun[], config: Config): Signal[] { |
| 39 | + return config.signals.map((signal) => { |
| 40 | + const run = findRun(checkRuns, signal.match); |
| 41 | + return { |
| 42 | + id: signal.id, |
| 43 | + title: signal.title, |
| 44 | + guidance: signal.guidance, |
| 45 | + stepGuidance: signal.stepGuidance ?? null, |
| 46 | + state: runState(run), |
| 47 | + url: run ? run.html_url : null, |
| 48 | + }; |
| 49 | + }); |
| 50 | +} |
| 51 | + |
| 52 | +// Drift detection: failing check runs from apps we cover that match neither a |
| 53 | +// signal nor the ignore list — typically a renamed job. Logged, never posted. |
| 54 | +export function diagnostics(checkRuns: CheckRun[], config: Config): { unmapped: string[] } { |
| 55 | + const unmapped = checkRuns |
| 56 | + .filter( |
| 57 | + (r) => |
| 58 | + r.status === 'completed' && |
| 59 | + r.conclusion !== null && |
| 60 | + FAILURE_CONCLUSIONS.has(r.conclusion) && |
| 61 | + r.app != null && |
| 62 | + config.coveredApps.includes(r.app.slug) && |
| 63 | + !config.signals.some((s) => findRun([r], s.match)) && |
| 64 | + !matchesIgnore(r.name, config.ignoreChecks) |
| 65 | + ) |
| 66 | + .map((r) => r.name); |
| 67 | + return { unmapped }; |
| 68 | +} |
| 69 | + |
| 70 | +interface DecideArgs { |
| 71 | + signals: ReadonlyArray<{ id: string; state: string }>; |
| 72 | + templateVerdict: { compliant: boolean } | null; |
| 73 | + existingState: { draftedSha?: string | null } | null; |
| 74 | + hasExistingComment: boolean; |
| 75 | + pr: { draft: boolean; headSha: string }; |
| 76 | +} |
| 77 | + |
| 78 | +// The convergence rules. See README.md for the decision table. |
| 79 | +export function decide({ signals, templateVerdict, existingState, hasExistingComment, pr }: DecideArgs): Decision { |
| 80 | + const failing = signals.filter((s) => s.state === 'failure').map((s) => s.id); |
| 81 | + const templateBlocking = Boolean(templateVerdict && templateVerdict.compliant === false); |
| 82 | + const blocking = failing.length > 0 || templateBlocking; |
| 83 | + const anyPending = signals.some((s) => s.state === 'pending'); |
| 84 | + |
| 85 | + let variant: Decision['variant'] = null; |
| 86 | + let shouldComment = false; |
| 87 | + if (blocking) { |
| 88 | + variant = 'issues'; |
| 89 | + shouldComment = true; |
| 90 | + } else if (hasExistingComment) { |
| 91 | + variant = anyPending ? 'waiting' : 'allclear'; |
| 92 | + shouldComment = true; |
| 93 | + } |
| 94 | + |
| 95 | + const alreadyDraftedThisSha = Boolean(existingState && existingState.draftedSha === pr.headSha); |
| 96 | + const shouldDraft = blocking && !pr.draft && !alreadyDraftedThisSha; |
| 97 | + |
| 98 | + return { variant, shouldComment, shouldDraft, failing, templateBlocking }; |
| 99 | +} |
| 100 | + |
| 101 | +// OWNERS is a small YAML subset: three keys, each a list of logins. |
| 102 | +export function parseOwners(yamlText: string): string[] { |
| 103 | + const sections = new Set(['owners', 'approvers', 'reviewers']); |
| 104 | + const logins: string[] = []; |
| 105 | + let current: string | null = null; |
| 106 | + for (const line of yamlText.split('\n')) { |
| 107 | + const key = line.match(/^(\w+):/); |
| 108 | + if (key) { |
| 109 | + current = key[1]; |
| 110 | + continue; |
| 111 | + } |
| 112 | + const item = line.match(/^-\s*(\S+)/); |
| 113 | + if (item && current !== null && sections.has(current)) { |
| 114 | + logins.push(item[1]); |
| 115 | + } |
| 116 | + } |
| 117 | + return logins; |
| 118 | +} |
| 119 | + |
| 120 | +export function isExemptAuthor(user: GitHubUser, ownersYaml: string): boolean { |
| 121 | + if (user.type === 'Bot' || /\[bot\]$/i.test(user.login)) { |
| 122 | + return true; |
| 123 | + } |
| 124 | + const login = user.login.toLowerCase(); |
| 125 | + return parseOwners(ownersYaml).some((l) => l.toLowerCase() === login); |
| 126 | +} |
| 127 | + |
| 128 | +export function findPullRequest<T extends { head: { sha: string } }>(openPrs: T[], headSha: string): T | null { |
| 129 | + return openPrs.find((pr) => pr.head.sha === headSha) ?? null; |
| 130 | +} |
| 131 | + |
| 132 | +// For checks with per-step guidance (the feature-pr-handling job), pick the |
| 133 | +// guidance of the failing step; fall back to the signal's generic guidance. |
| 134 | +export function pickStepGuidance( |
| 135 | + signal: { guidance: string; stepGuidance?: Record<string, string> | null }, |
| 136 | + steps: JobStep[] | null |
| 137 | +): string { |
| 138 | + if (signal.stepGuidance && Array.isArray(steps)) { |
| 139 | + const failed = steps.find((s) => s.conclusion === 'failure' && signal.stepGuidance![s.name]); |
| 140 | + if (failed) { |
| 141 | + return signal.stepGuidance[failed.name]; |
| 142 | + } |
| 143 | + } |
| 144 | + return signal.guidance; |
| 145 | +} |
0 commit comments