teqo-backup/scripts/lib/branch-protection.mjs
Francisco Solla ac11afecf3 ops(OPS61): flip de issues pós-merge com PAT + gate do rollup checks + branch protection real no Forgejo
- issue-done/plan-issue-ready: env FORGEJO_API_TOKEN (GITHUB_TOKEN 403 em
  Issues) + fork guard (checkout de fork roda script do fork com o PAT — RCE)
- forgejo-issue-transition: falha de flip vira exit 1 (job vermelho, nunca
  skip silencioso)
- waitForChecks: exige o rollup CI (PR) / checks presente+verde (raça PR #52:
  snapshot com jobs não-agendados parecia verde); mergeable=false só conta
  como conflito após a cascata assentar; timeouts 30→45min (CLI) e 40→55min
  (workflow)
- configure-branch-protection: contexto real CI (PR) / checks* (Forgejo casa
  por glob contra 'CI (PR) / checks (pull_request)'; literal 'checks' nunca
  casaria), idempotente GET→POST/PATCH, verificação pós-aplicação; decisão
  pura em scripts/lib/branch-protection.mjs
- docs honestas (AGENT-OPS, agent-pr-workflow.mdc, CUTOVER-MAIN-ONLY, README)
- testes: raça do rollup/rollup failure/mergeable pré-settle +
  branchProtection create/update/noop + glob real
2026-08-18 17:45:54 -03:00

60 lines
2 KiB
JavaScript

/**
* Pure decision for the main branch protection rule (OPS61).
*
* The desired rule is the repo contract: main requires the CI (PR) cascade
* rollup as the only status check (`strict=false`, 0 reviews). The context is
* a glob on purpose — Forgejo matches `status_check_contexts` with gobwas/glob
* against the real commit-status contexts (`CI (PR) / checks (pull_request)` —
* the ` (event)` suffix is part of the context), so the literal `checks` would
* match nothing and block every merge.
*/
export const DESIRED_RULE = Object.freeze({
rule_name: 'main',
enable_status_check: true,
status_check_contexts: ['CI (PR) / checks*'],
enable_push: false,
required_approvals: 0,
dismiss_stale_approvals: false,
})
const COMPARED_FIELDS = [
'rule_name',
'enable_status_check',
'status_check_contexts',
'enable_push',
'required_approvals',
'dismiss_stale_approvals',
]
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 the API, already normalized) matches the
* desired rule on every compared field — array fields as sets.
*/
export const ruleMatches = (existing, desired = DESIRED_RULE) => {
if (!existing) return false
return COMPARED_FIELDS.every((field) => {
const want = desired[field]
const have = existing[field]
if (Array.isArray(want)) return Array.isArray(have) && sameStrings(have, want)
return have === want
})
}
/**
* 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
* @param {object} [desired]
* @returns {{ action: 'create' | 'update' | 'noop' }}
*/
export const planBranchProtectionRule = (existing, desired = DESIRED_RULE) => {
if (ruleMatches(existing, desired)) return { action: 'noop' }
return { action: existing ? 'update' : 'create' }
}