teqo-backup/tests/unit/githubPrFlow.unit.spec.ts
Francisco Solla e504491dd8 fix(OPS71-FLIP): flip pós-merge silencioso no GitHub — PAT no arm do auto-merge, dispatch de recuperação, close da Issue, retry 410
Root cause (Issue #119): auto-merge armado com GITHUB_TOKEN mergea como
github-actions[bot] e o anti-recursion do GitHub nunca cria runs para o
evento closed — os flips pós-merge (issue-done / plan-issue-ready) não
rodavam em silêncio (PR #746). Arm agora usa AUTOMERGE_PAT (fail-closed);
workflow_dispatch de recuperação nos dois flips; forgejo-issue-transition
fecha a Issue (PATCH state=closed); forgejo-api retenta 410 (proxy respondeu
antes da origem) e loga corpo nginx de forma compacta.
2026-08-19 22:45:29 -03:00

96 lines
3.2 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
CURSOR_HEAD_PREFIX,
automergeArmingToken,
decideAutomergeAction,
isCursorHead,
} from '../../scripts/lib/github-pr-flow.mjs'
const pr = (overrides: Record<string, unknown> = {}) => ({
number: 4,
title: 't',
body: '',
state: 'OPEN',
merged: false,
draft: false,
mergeable: true,
nodeId: 'PR_x',
head: { ref: 'OPS71-x', sha: 'abc' },
base: { ref: 'main' },
...overrides,
})
describe('github-pr-flow automerge decision (OPS71)', () => {
it('skips a null PR', () => {
expect(decideAutomergeAction(null).action).toBe('skip')
})
it('skips an already merged PR (idempotent)', () => {
expect(decideAutomergeAction(pr({ merged: true, state: 'CLOSED' })).action).toBe('skip')
})
it('skips a closed PR without merge', () => {
expect(decideAutomergeAction(pr({ state: 'CLOSED' })).action).toBe('skip')
})
it('skips a PR whose base is not main', () => {
expect(decideAutomergeAction(pr({ base: { ref: 'stage' } })).action).toBe('skip')
})
it('enables auto-merge on a ready PR to main', () => {
const verdict = decideAutomergeAction(pr())
expect(verdict.action).toBe('enable-auto-merge')
expect(verdict.reason).toBe('ready')
})
it('marks a cursor/* draft ready (OPS57 pool flow)', () => {
const verdict = decideAutomergeAction(
pr({ draft: true, head: { ref: `${CURSOR_HEAD_PREFIX}abc-123`, sha: 'abc' } }),
)
expect(verdict.action).toBe('mark-ready')
})
it('skips any non-cursor/* draft — the actor veto (OPS57)', () => {
const verdict = decideAutomergeAction(
pr({ draft: true, head: { ref: 'plans/ops71-x', sha: 'abc' } }),
)
expect(verdict.action).toBe('skip')
expect(verdict.reason).toBe('draft-veto')
})
it('isCursorHead only matches the cursor/ prefix', () => {
expect(isCursorHead('cursor/abc-123')).toBe(true)
expect(isCursorHead('OPS71-x')).toBe(false)
expect(isCursorHead('')).toBe(false)
expect(isCursorHead(undefined)).toBe(false)
})
it('the merge contract has no poll path: ready always arms native auto-merge', () => {
// OPS64 pin, GitHub shape: the Forgejo rollup-lie bug is structural here
// (single job → one honest check-run), and the server only merges with
// required checks green — so the decision surface is exactly this.
const actions = [
decideAutomergeAction(pr()).action,
decideAutomergeAction(pr({ draft: true, head: { ref: 'cursor/x', sha: 'a' } })).action,
]
expect(actions).toEqual(['enable-auto-merge', 'mark-ready'])
})
it('automergeArmingToken accepts a non-empty AUTOMERGE_PAT (OPS71-FLIP)', () => {
expect(automergeArmingToken({ AUTOMERGE_PAT: 'ghp_abc' })).toEqual({
ok: true,
token: 'ghp_abc',
})
})
it('automergeArmingToken fails closed without the PAT — never falls back to the run token', () => {
const verdict = automergeArmingToken({})
expect(verdict.ok).toBe(false)
expect(verdict.ok === false && verdict.reason).toContain('AUTOMERGE_PAT')
})
it('automergeArmingToken treats an empty-string PAT as missing', () => {
expect(automergeArmingToken({ AUTOMERGE_PAT: '' }).ok).toBe(false)
})
})