mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:25:20 +00:00
78 lines
3 KiB
JavaScript
78 lines
3 KiB
JavaScript
/**
|
|
* Pure decision for the GitHub `main` branch protection rule (OPS71).
|
|
*
|
|
* The desired rule is the repo contract: main requires the ci-pr cascade
|
|
* check-run (OPS62) as the only status check (strict=false, 0 reviews) and
|
|
* `enforce_admins: true` so even repo admins cannot merge with a red required
|
|
* check (the rule-based protection of the Forgejo era applied to everyone —
|
|
* preserved).
|
|
*
|
|
* The auto-merge safety net never merges on red CI by construction (GitHub's
|
|
* server-side guarantee), but the branch protection rule is the final defense
|
|
* against every other merge path (manual API merges included).
|
|
*
|
|
* Context literal (verified live on PR #742, 2026-08-19): GitHub's
|
|
* required-check matching uses the CHECK-RUN NAME — the bare JOB name
|
|
* (`checks`), NOT the UI display `<workflow name> / <job name>`
|
|
* (`CI (PR) / checks`). The latter never matches and leaves the PR
|
|
* mergeable_state "blocked" forever. The UI still shows
|
|
* `CI (PR) / checks`; the rule literal is `checks`.
|
|
*/
|
|
|
|
export const REQUIRED_CHECK_CONTEXT = 'checks'
|
|
|
|
/**
|
|
* GitHub PUT /branches/{branch}/protection replace-semantics payload. Only
|
|
* `checks` (modern) is sent — `contexts` is legacy-derived and GitHub's API
|
|
* rejects the two together (422 anyOf). The GET normalization reads the
|
|
* check-run contexts from `checks`, so drift comparison is unaffected.
|
|
*/
|
|
export const DESIRED_RULE = Object.freeze({
|
|
required_status_checks: {
|
|
strict: false,
|
|
checks: [{ context: REQUIRED_CHECK_CONTEXT }],
|
|
},
|
|
enforce_admins: true,
|
|
required_pull_request_reviews: null,
|
|
restrictions: null,
|
|
required_linear_history: false,
|
|
allow_force_pushes: false,
|
|
allow_deletions: false,
|
|
block_creations: false,
|
|
})
|
|
|
|
const sameStrings = (have, want) =>
|
|
have.length === want.length &&
|
|
want.every((value) => have.includes(value)) &&
|
|
have.every((value) => want.includes(value))
|
|
|
|
/**
|
|
* True when the existing rule (from `getBranchProtection`, already
|
|
* normalized) matches the desired rule on every compared field — array
|
|
* fields as sets.
|
|
*/
|
|
export const ruleMatches = (existing) => {
|
|
if (!existing) return false
|
|
const wantStatus = DESIRED_RULE.required_status_checks
|
|
if (existing.enforce_admins !== DESIRED_RULE.enforce_admins) return false
|
|
if (existing.required_pull_request_reviews !== null) return false
|
|
const haveStatus = existing.required_status_checks
|
|
if (!haveStatus) return false
|
|
if (haveStatus.strict !== wantStatus.strict) return false
|
|
return sameStrings(
|
|
haveStatus.contexts,
|
|
wantStatus.checks.map((entry) => entry.context),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* What to do with the rule: `create` (no rule), `update` (rule exists with
|
|
* drift), `noop` (already compliant). Idempotent re-runs land on `noop`.
|
|
*
|
|
* @param {object|null} existing - normalized rule from the API, or null
|
|
* @returns {{ action: 'create' | 'update' | 'noop' }}
|
|
*/
|
|
export const planBranchProtectionRule = (existing) => {
|
|
if (ruleMatches(existing)) return { action: 'noop' }
|
|
return { action: existing ? 'update' : 'create' }
|
|
}
|