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.
961 lines
28 KiB
TypeScript
961 lines
28 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { createApi } from '../../scripts/lib/forgejo-api.mjs'
|
|
|
|
type FetchCall = {
|
|
url: string
|
|
method?: string
|
|
init?: { headers?: Record<string, string>; method?: string; body?: string }
|
|
body?: unknown
|
|
}
|
|
|
|
const ok = (body: unknown, status = 200) =>
|
|
new Response(typeof body === 'string' ? body : JSON.stringify(body), {
|
|
status,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
|
|
describe('forgejo-api', () => {
|
|
it('builds the request with token, JSON body and explicit User-Agent', async () => {
|
|
const calls: FetchCall[] = []
|
|
const api = createApi({
|
|
base: 'https://git.solla.dev/api/v1',
|
|
token: 'tok',
|
|
repository: 'fsolla/teqo',
|
|
fetchImpl: async (url, init) => {
|
|
calls.push({ url: String(url), init })
|
|
return ok({ number: 1, title: 'x', body: '', state: 'open', created_at: '', labels: [] })
|
|
},
|
|
})
|
|
|
|
await api.createIssue({ title: 't', body: 'b' })
|
|
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0].url).toBe('https://git.solla.dev/api/v1/repos/fsolla/teqo/issues')
|
|
expect(calls[0].init!.method).toBe('POST')
|
|
expect(calls[0].init!.headers!.Authorization).toBe('token tok')
|
|
expect(calls[0].init!.headers!['User-Agent']).toContain('teqo-agent-scripts')
|
|
expect(JSON.parse(calls[0].init!.body!)).toEqual({ title: 't', body: 'b' })
|
|
})
|
|
|
|
it('defaults the base URL to git.solla.dev with the default repo path', async () => {
|
|
// OPS71: the CI now runs on GitHub Actions, where GITHUB_SERVER_URL is
|
|
// set (https://github.com) — forgejo-api's env chain would resolve a
|
|
// different base. Pin the fallback default by isolating the env vars.
|
|
const savedApi = process.env.FORGEJO_API_URL
|
|
const savedServer = process.env.GITHUB_SERVER_URL
|
|
delete process.env.FORGEJO_API_URL
|
|
delete process.env.GITHUB_SERVER_URL
|
|
try {
|
|
const calls: FetchCall[] = []
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async (url) => {
|
|
calls.push({ url: String(url) })
|
|
return ok([{ number: 1, title: '', body: '', state: 'open', created_at: '', labels: [] }])
|
|
},
|
|
})
|
|
|
|
await api.listIssues()
|
|
|
|
expect(calls[0].url).toContain('https://git.solla.dev/api/v1/repos/fsolla/teqo/issues?')
|
|
} finally {
|
|
if (savedApi) process.env.FORGEJO_API_URL = savedApi
|
|
else delete process.env.FORGEJO_API_URL
|
|
if (savedServer) process.env.GITHUB_SERVER_URL = savedServer
|
|
else delete process.env.GITHUB_SERVER_URL
|
|
}
|
|
})
|
|
|
|
it('normalizes state and labels to the gh-flavored contract', async () => {
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async () =>
|
|
ok({
|
|
number: 7,
|
|
title: 't',
|
|
body: 'b',
|
|
state: 'closed',
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
labels: [{ name: 'ready', color: '0E8A16' }],
|
|
}),
|
|
})
|
|
|
|
const issue = await api.getIssue(7)
|
|
|
|
expect(issue.state).toBe('CLOSED')
|
|
expect(issue.labels).toEqual([{ name: 'ready', color: '0E8A16' }])
|
|
expect(issue.createdAt).toBe('2026-01-01T00:00:00Z')
|
|
})
|
|
|
|
it('throws with the API error body on non-2xx', async () => {
|
|
let calls = 0
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async () => {
|
|
calls += 1
|
|
return ok('{"message":"nope"}', 403)
|
|
},
|
|
})
|
|
|
|
await expect(api.listIssues()).rejects.toThrow(/403/)
|
|
await expect(api.listIssues()).rejects.toThrow(/nope/)
|
|
expect(calls).toBe(2)
|
|
})
|
|
|
|
it('fails closed when no token is available', async () => {
|
|
delete process.env.FORGEJO_API_TOKEN
|
|
delete process.env.GITHUB_TOKEN
|
|
const api = createApi({ fetchImpl: async () => ok('[]') })
|
|
|
|
await expect(api.listIssues()).rejects.toThrow(/Sem token/)
|
|
})
|
|
|
|
it('setLabels removes by id and adds by name', async () => {
|
|
const calls: FetchCall[] = []
|
|
const api = createApi({
|
|
// OPS71: explicit base — the GitHub Actions CI sets GITHUB_SERVER_URL,
|
|
// which would change the env-resolved base and break the URL pins.
|
|
base: 'https://git.solla.dev/api/v1',
|
|
token: 'tok',
|
|
fetchImpl: async (url, init) => {
|
|
calls.push({ url: String(url), method: init?.method, body: init?.body })
|
|
if (String(url).endsWith('/labels') && init?.method === 'GET') {
|
|
return ok([{ name: 'blocked', id: 42 }])
|
|
}
|
|
return ok(null)
|
|
},
|
|
})
|
|
|
|
await api.setLabels(5, { add: ['ready'], remove: ['blocked'] })
|
|
|
|
expect(calls.map((call) => [call.method, call.url])).toEqual([
|
|
['GET', 'https://git.solla.dev/api/v1/repos/fsolla/teqo/issues/5/labels'],
|
|
['DELETE', 'https://git.solla.dev/api/v1/repos/fsolla/teqo/issues/5/labels/42'],
|
|
['POST', 'https://git.solla.dev/api/v1/repos/fsolla/teqo/issues/5/labels'],
|
|
])
|
|
expect(JSON.parse(calls[2].body as string)).toEqual({ labels: ['ready'] })
|
|
})
|
|
|
|
it('closeIssue PATCHes state=closed (OPS71-FLIP: the GitHub PR merge no longer closes the Forgejo issue)', async () => {
|
|
const calls: FetchCall[] = []
|
|
const api = createApi({
|
|
base: 'https://git.solla.dev/api/v1',
|
|
token: 'tok',
|
|
fetchImpl: async (url, init) => {
|
|
calls.push({ url: String(url), method: init?.method, body: init?.body })
|
|
return ok(null)
|
|
},
|
|
})
|
|
|
|
await api.closeIssue(9)
|
|
|
|
expect(calls).toEqual([
|
|
{
|
|
url: 'https://git.solla.dev/api/v1/repos/fsolla/teqo/issues/9',
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ state: 'closed' }),
|
|
},
|
|
])
|
|
})
|
|
|
|
it('workflowDispatch posts inputs and ref', async () => {
|
|
const calls: FetchCall[] = []
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async (url, init) => {
|
|
calls.push({ url: String(url), body: JSON.parse(String(init?.body ?? '{}')) })
|
|
return ok(null)
|
|
},
|
|
})
|
|
|
|
await api.workflowDispatch('agent-pool.yml', { ref: 'main', inputs: { action: 'tick' } })
|
|
|
|
expect(calls[0].url).toContain('/actions/workflows/agent-pool.yml/dispatches')
|
|
expect(calls[0].body).toEqual({ ref: 'main', inputs: { action: 'tick' } })
|
|
})
|
|
|
|
it('merges with rebase (repo canonical merge style)', async () => {
|
|
const calls: FetchCall[] = []
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async (url, init) => {
|
|
calls.push({ url: String(url), body: JSON.parse(String(init?.body ?? '{}')) })
|
|
return ok(null)
|
|
},
|
|
})
|
|
|
|
await api.mergePullRequest(4)
|
|
|
|
expect(calls[0].url).toContain('/pulls/4/merge')
|
|
expect(calls[0].body).toEqual({ Do: 'rebase' })
|
|
})
|
|
|
|
it('waitForChecks returns once every status is settled and no failure exists', async () => {
|
|
let poll = 0
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async (url) => {
|
|
if (String(url).includes('/pulls/')) {
|
|
return ok({
|
|
number: 4,
|
|
state: 'open',
|
|
merged: false,
|
|
mergeable: true,
|
|
head: { ref: 'x', sha: 'abc' },
|
|
})
|
|
}
|
|
poll += 1
|
|
const statuses =
|
|
poll < 2
|
|
? [{ context: 'CI / static', status: 'pending' }]
|
|
: [
|
|
{ context: 'CI / static', status: 'success' },
|
|
{ context: 'CI (PR) / checks (pull_request)', status: 'success' },
|
|
]
|
|
return ok({ statuses })
|
|
},
|
|
})
|
|
|
|
const pr = await api.waitForChecks(4, { pollMs: 1 })
|
|
|
|
expect(pr).toBeTruthy()
|
|
expect(poll).toBe(2)
|
|
})
|
|
|
|
it('waitForChecks keeps polling while mergeable is still being computed', async () => {
|
|
let poll = 0
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async (url) => {
|
|
if (String(url).includes('/pulls/')) {
|
|
poll += 1
|
|
return ok({
|
|
number: 4,
|
|
state: 'open',
|
|
merged: false,
|
|
mergeable: poll < 2 ? null : true,
|
|
head: { ref: 'x', sha: 'abc' },
|
|
})
|
|
}
|
|
return ok({
|
|
statuses: [
|
|
{ context: 'CI / static', status: 'success' },
|
|
{ context: 'CI (PR) / checks (pull_request)', status: 'success' },
|
|
],
|
|
})
|
|
},
|
|
})
|
|
|
|
const pr = await api.waitForChecks(4, { pollMs: 1 })
|
|
|
|
expect(poll).toBe(2)
|
|
expect(pr.mergeable).toBe(true)
|
|
})
|
|
|
|
it('waitForChecks fails fast when the PR is not mergeable', async () => {
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async (url) => {
|
|
if (String(url).includes('/pulls/')) {
|
|
return ok({
|
|
number: 4,
|
|
state: 'open',
|
|
merged: false,
|
|
mergeable: false,
|
|
head: { ref: 'x', sha: 'abc' },
|
|
})
|
|
}
|
|
return ok({
|
|
statuses: [
|
|
{ context: 'CI / static', status: 'success' },
|
|
{ context: 'CI (PR) / checks (pull_request)', status: 'success' },
|
|
],
|
|
})
|
|
},
|
|
})
|
|
|
|
await expect(api.waitForChecks(4, { pollMs: 1 })).rejects.toThrow(/não mergeável/)
|
|
})
|
|
|
|
it('waitForChecks stops when the PR is converted to draft mid-wait (OPS57 veto)', async () => {
|
|
let poll = 0
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async (url) => {
|
|
poll += 1
|
|
if (String(url).includes('/pulls/')) {
|
|
return ok({
|
|
number: 4,
|
|
state: 'open',
|
|
merged: false,
|
|
mergeable: true,
|
|
is_draft: poll >= 3,
|
|
head: { ref: 'OPS57-x', sha: 'abc' },
|
|
})
|
|
}
|
|
return ok({
|
|
statuses:
|
|
poll <= 2
|
|
? [{ context: 'CI / static', status: 'pending' }]
|
|
: [{ context: 'CI / static', status: 'success' }],
|
|
})
|
|
},
|
|
})
|
|
|
|
const pr = await api.waitForChecks(4, { pollMs: 1 })
|
|
|
|
expect(poll).toBe(3)
|
|
expect(pr.isDraft).toBe(true)
|
|
})
|
|
|
|
it('waitForChecks keeps polling while the CI (PR) checks rollup has not posted (OPS61 gate)', async () => {
|
|
let statusPoll = 0
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async (url) => {
|
|
if (String(url).includes('/pulls/')) {
|
|
return ok({
|
|
number: 4,
|
|
state: 'open',
|
|
merged: false,
|
|
mergeable: true,
|
|
head: { ref: 'x', sha: 'abc' },
|
|
})
|
|
}
|
|
statusPoll += 1
|
|
// First snapshots: every POSTED status is green — but jobs that were
|
|
// not scheduled yet (docs-guards/unit/checks) posted nothing. The
|
|
// rollup is the only context that exists once the WHOLE cascade
|
|
// settled; without the gate a snapshot can look green before the
|
|
// cascade finished.
|
|
const statuses =
|
|
statusPoll < 3
|
|
? [{ context: 'CI / static', status: 'success' }]
|
|
: [
|
|
{ context: 'CI / static', status: 'success' },
|
|
{ context: 'CI (PR) / checks (pull_request)', status: 'success' },
|
|
]
|
|
return ok({ statuses })
|
|
},
|
|
})
|
|
|
|
const pr = await api.waitForChecks(4, { pollMs: 1 })
|
|
|
|
expect(pr).toBeTruthy()
|
|
expect(statusPoll).toBe(3)
|
|
})
|
|
|
|
it('waitForChecks throws when the CI (PR) checks rollup reports failure', async () => {
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async (url) => {
|
|
if (String(url).includes('/pulls/')) {
|
|
return ok({
|
|
number: 4,
|
|
state: 'open',
|
|
merged: false,
|
|
mergeable: true,
|
|
head: { ref: 'x', sha: 'abc' },
|
|
})
|
|
}
|
|
return ok({
|
|
statuses: [
|
|
{ context: 'CI / static', status: 'success' },
|
|
{ context: 'CI (PR) / checks (pull_request)', status: 'failure' },
|
|
],
|
|
})
|
|
},
|
|
})
|
|
|
|
await expect(api.waitForChecks(4, { pollMs: 1 })).rejects.toThrow(/Checks falharam/)
|
|
})
|
|
|
|
it('waitForChecks throws when a matrix shard is red even if the rollup is green (run 607, OPS64)', async () => {
|
|
// Forgejo reports the LAST shard's result as the matrix parent result, so
|
|
// the ci-pr `checks` rollup job can post SUCCESS with e2e (1) red (runs
|
|
// 604/607/610). The CLI must NEVER trust the rollup alone: it scans the
|
|
// individual shard statuses (`CI (PR) / e2e (1)` = failure) and refuses to
|
|
// merge — this is what kept PR #55 out of main red in run 607.
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async (url) => {
|
|
if (String(url).includes('/pulls/')) {
|
|
return ok({
|
|
number: 55,
|
|
state: 'open',
|
|
merged: false,
|
|
mergeable: true,
|
|
head: { ref: 'OPS59-x', sha: 'abc' },
|
|
})
|
|
}
|
|
return ok({
|
|
statuses: [
|
|
{ context: 'CI / static', status: 'success' },
|
|
{ context: 'CI (PR) / e2e (1) (pull_request)', status: 'failure' },
|
|
{ context: 'CI (PR) / e2e (2) (pull_request)', status: 'success' },
|
|
{ context: 'CI (PR) / checks (pull_request)', status: 'success' },
|
|
],
|
|
})
|
|
},
|
|
})
|
|
|
|
await expect(api.waitForChecks(55, { pollMs: 1 })).rejects.toThrow(/Checks falharam/)
|
|
})
|
|
|
|
it('waitForChecks ignores the safety-net own status, pending for the whole run (OPS61 finding)', async () => {
|
|
let statusPoll = 0
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async (url) => {
|
|
if (String(url).includes('/pulls/')) {
|
|
return ok({
|
|
number: 4,
|
|
state: 'open',
|
|
merged: false,
|
|
mergeable: true,
|
|
head: { ref: 'x', sha: 'abc' },
|
|
})
|
|
}
|
|
statusPoll += 1
|
|
// Forgejo posts the job's own commit status as pending at job start
|
|
// and flips it only at the end — the pre-OPS61 `pending.length === 0`
|
|
// gate was unsatisfiable and no PR was ever merged by the CLI (every
|
|
// Forgejo merge was manual). The own context must never gate.
|
|
return ok({
|
|
statuses: [
|
|
{
|
|
context: 'PR Ready + auto-merge / ready-automerge (pull_request)',
|
|
status: 'pending',
|
|
},
|
|
{ context: 'CI / static', status: 'success' },
|
|
{ context: 'CI (PR) / checks (pull_request)', status: 'success' },
|
|
],
|
|
})
|
|
},
|
|
})
|
|
|
|
const pr = await api.waitForChecks(4, { pollMs: 1 })
|
|
|
|
expect(pr).toBeTruthy()
|
|
expect(statusPoll).toBe(1)
|
|
})
|
|
|
|
it('waitForChecks does not treat mergeable=false as conflict before the rollup settles (OPS61)', async () => {
|
|
let poll = 0
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async (url) => {
|
|
if (String(url).includes('/pulls/')) {
|
|
poll += 1
|
|
const settled = poll >= 3
|
|
return ok({
|
|
number: 4,
|
|
state: 'open',
|
|
merged: false,
|
|
mergeable: settled ? true : false,
|
|
head: { ref: 'x', sha: 'abc' },
|
|
})
|
|
}
|
|
return ok({
|
|
statuses:
|
|
poll < 3
|
|
? [{ context: 'CI / static', status: 'success' }]
|
|
: [
|
|
{ context: 'CI / static', status: 'success' },
|
|
{ context: 'CI (PR) / checks (pull_request)', status: 'success' },
|
|
],
|
|
})
|
|
},
|
|
})
|
|
|
|
const pr = await api.waitForChecks(4, { pollMs: 1 })
|
|
|
|
expect(poll).toBe(3)
|
|
expect(pr.mergeable).toBe(true)
|
|
})
|
|
|
|
it('autoMerge verifies the merge by re-reading the PR (POST answers 200 + empty body)', async () => {
|
|
const calls: string[] = []
|
|
let poll = 0
|
|
const api = createApi({
|
|
// OPS71: explicit base — the GitHub Actions CI sets GITHUB_SERVER_URL,
|
|
// which would change the env-resolved base and break the URL pins.
|
|
base: 'https://git.solla.dev/api/v1',
|
|
token: 'tok',
|
|
fetchImpl: async (url, init) => {
|
|
calls.push(`${init?.method ?? 'GET'} ${url}`)
|
|
if (String(url).includes('/merge')) return new Response('', { status: 200 })
|
|
if (String(url).includes('/pulls/')) {
|
|
poll += 1
|
|
const merged = poll > 1
|
|
return ok({
|
|
number: 27,
|
|
state: merged ? 'closed' : 'open',
|
|
merged,
|
|
mergeable: true,
|
|
head: { ref: 'x', sha: 'abc' },
|
|
})
|
|
}
|
|
return ok({
|
|
statuses: [
|
|
{ context: 'CI / static', status: 'success' },
|
|
{ context: 'CI (PR) / checks (pull_request)', status: 'success' },
|
|
],
|
|
})
|
|
},
|
|
})
|
|
|
|
const result = await api.autoMerge(27, { pollMs: 1 })
|
|
|
|
expect(result).toEqual({
|
|
attempted: true,
|
|
merged: true,
|
|
pr: expect.objectContaining({ merged: true }),
|
|
})
|
|
expect(calls.filter((call) => call.includes('/merge'))).toHaveLength(1)
|
|
expect(
|
|
calls.filter(
|
|
(call) => call === 'GET https://git.solla.dev/api/v1/repos/fsolla/teqo/pulls/27',
|
|
),
|
|
).toHaveLength(2)
|
|
})
|
|
|
|
it('autoMerge rethrows when the merge POST fails and the PR stays open', async () => {
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async (url, _init) => {
|
|
if (String(url).includes('/merge'))
|
|
return ok('{"message":"PR not in mergeable state"}', 405)
|
|
if (String(url).includes('/pulls/')) {
|
|
return ok({
|
|
number: 27,
|
|
state: 'open',
|
|
merged: false,
|
|
mergeable: true,
|
|
head: { ref: 'x', sha: 'abc' },
|
|
})
|
|
}
|
|
return ok({
|
|
statuses: [
|
|
{ context: 'CI / static', status: 'success' },
|
|
{ context: 'CI (PR) / checks (pull_request)', status: 'success' },
|
|
],
|
|
})
|
|
},
|
|
})
|
|
|
|
await expect(api.autoMerge(27, { pollMs: 1 })).rejects.toThrow(/405/)
|
|
})
|
|
|
|
it('autoMerge reports merged when a concurrent actor merged after our POST failed', async () => {
|
|
let poll = 0
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async (url, _init) => {
|
|
if (String(url).includes('/merge'))
|
|
return ok('{"message":"Pull request was already merged"}', 405)
|
|
if (String(url).includes('/pulls/')) {
|
|
poll += 1
|
|
const merged = poll > 1
|
|
return ok({
|
|
number: 27,
|
|
state: merged ? 'closed' : 'open',
|
|
merged,
|
|
mergeable: true,
|
|
head: { ref: 'x', sha: 'abc' },
|
|
})
|
|
}
|
|
return ok({
|
|
statuses: [
|
|
{ context: 'CI / static', status: 'success' },
|
|
{ context: 'CI (PR) / checks (pull_request)', status: 'success' },
|
|
],
|
|
})
|
|
},
|
|
})
|
|
|
|
const result = await api.autoMerge(27, { pollMs: 1 })
|
|
|
|
expect(result).toEqual({
|
|
attempted: true,
|
|
merged: true,
|
|
pr: expect.objectContaining({ merged: true }),
|
|
})
|
|
})
|
|
|
|
it('autoMerge is a no-op for an already merged PR (no POST)', async () => {
|
|
const calls: string[] = []
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async (url, init) => {
|
|
calls.push(`${init?.method ?? 'GET'} ${url}`)
|
|
return ok({
|
|
number: 4,
|
|
state: 'closed',
|
|
merged: true,
|
|
mergeable: false,
|
|
head: { ref: 'x', sha: 'abc' },
|
|
})
|
|
},
|
|
})
|
|
|
|
const result = await api.autoMerge(4, { pollMs: 1 })
|
|
|
|
expect(result).toEqual({
|
|
attempted: false,
|
|
merged: true,
|
|
pr: expect.objectContaining({ merged: true }),
|
|
})
|
|
expect(calls.some((call) => call.includes('/merge'))).toBe(false)
|
|
})
|
|
|
|
it('getFileContents decodes base64 and updateFile sends sha', async () => {
|
|
const calls: FetchCall[] = []
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async (url, init) => {
|
|
calls.push({
|
|
url: String(url),
|
|
method: init?.method,
|
|
body: init?.body ? JSON.parse(String(init.body)) : null,
|
|
})
|
|
if (init?.method === 'GET') {
|
|
return ok({ content: Buffer.from('{"a":1}').toString('base64'), sha: 'shasha' })
|
|
}
|
|
return ok(null)
|
|
},
|
|
})
|
|
|
|
const file = await api.getFileContents('pool-state.json', 'pool-state')
|
|
expect(file.content).toBe('{"a":1}')
|
|
expect(file.sha).toBe('shasha')
|
|
|
|
await api.updateFile('pool-state.json', {
|
|
message: 'm',
|
|
content: '{"a":2}',
|
|
branch: 'pool-state',
|
|
sha: 'shasha',
|
|
})
|
|
expect(calls.at(-1)!.method).toBe('PUT')
|
|
expect(
|
|
Buffer.from((calls.at(-1)!.body as { content: string }).content, 'base64').toString(),
|
|
).toBe('{"a":2}')
|
|
expect((calls.at(-1)!.body as { sha: string }).sha).toBe('shasha')
|
|
})
|
|
|
|
describe('retry with backoff (OPS67)', () => {
|
|
const issueOk = [{ number: 1, title: '', body: '', state: 'open', created_at: '', labels: [] }]
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
const silentWarn = () => vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
|
|
const brokenBody = () =>
|
|
new Response(
|
|
new ReadableStream({
|
|
start: (controller) => controller.error(new TypeError('terminated')),
|
|
}),
|
|
{ status: 200 },
|
|
)
|
|
|
|
it('retries a transient network failure and succeeds', async () => {
|
|
silentWarn()
|
|
let calls = 0
|
|
const sleeps: number[] = []
|
|
const api = createApi({
|
|
token: 'tok',
|
|
retries: 2,
|
|
backoffMs: 100,
|
|
jitter: false,
|
|
sleepImpl: async (ms) => {
|
|
sleeps.push(ms)
|
|
},
|
|
fetchImpl: async () => {
|
|
calls += 1
|
|
if (calls === 1) throw new TypeError('fetch failed')
|
|
return ok(issueOk)
|
|
},
|
|
})
|
|
|
|
const issues = await api.listIssues()
|
|
|
|
expect(calls).toBe(2)
|
|
expect(sleeps).toEqual([100])
|
|
expect(issues).toHaveLength(1)
|
|
})
|
|
|
|
it('retries a transient network failure on write methods too', async () => {
|
|
silentWarn()
|
|
let calls = 0
|
|
const api = createApi({
|
|
token: 'tok',
|
|
retries: 2,
|
|
backoffMs: 0,
|
|
jitter: false,
|
|
sleepImpl: async () => {},
|
|
fetchImpl: async () => {
|
|
calls += 1
|
|
if (calls === 1) throw new TypeError('fetch failed')
|
|
return ok(null)
|
|
},
|
|
})
|
|
|
|
await api.addComment(1, 'x')
|
|
|
|
expect(calls).toBe(2)
|
|
})
|
|
|
|
it('retries a GET whose body stream drops mid-read', async () => {
|
|
silentWarn()
|
|
let calls = 0
|
|
const api = createApi({
|
|
token: 'tok',
|
|
retries: 2,
|
|
backoffMs: 0,
|
|
jitter: false,
|
|
sleepImpl: async () => {},
|
|
fetchImpl: async () => {
|
|
calls += 1
|
|
return calls === 1 ? brokenBody() : ok(issueOk)
|
|
},
|
|
})
|
|
|
|
const issues = await api.listIssues()
|
|
|
|
expect(calls).toBe(2)
|
|
expect(issues).toHaveLength(1)
|
|
})
|
|
|
|
it('does not retry a write whose body drops after a status was received', async () => {
|
|
let calls = 0
|
|
const api = createApi({
|
|
token: 'tok',
|
|
retries: 2,
|
|
backoffMs: 0,
|
|
fetchImpl: async () => {
|
|
calls += 1
|
|
return calls === 1 ? brokenBody() : ok(null)
|
|
},
|
|
})
|
|
|
|
await expect(api.addComment(1, 'x')).rejects.toThrow(/terminated/)
|
|
expect(calls).toBe(1)
|
|
})
|
|
|
|
it('throws after exhausting retries on a persistent network failure', async () => {
|
|
silentWarn()
|
|
let calls = 0
|
|
const api = createApi({
|
|
token: 'tok',
|
|
retries: 2,
|
|
backoffMs: 0,
|
|
jitter: false,
|
|
sleepImpl: async () => {},
|
|
fetchImpl: async () => {
|
|
calls += 1
|
|
throw new TypeError('fetch failed')
|
|
},
|
|
})
|
|
|
|
await expect(api.listIssues()).rejects.toThrow(/fetch failed/)
|
|
expect(calls).toBe(3)
|
|
})
|
|
|
|
it('honors retries: 0 as a single attempt', async () => {
|
|
let calls = 0
|
|
const api = createApi({
|
|
token: 'tok',
|
|
retries: 0,
|
|
fetchImpl: async () => {
|
|
calls += 1
|
|
throw new TypeError('fetch failed')
|
|
},
|
|
})
|
|
|
|
await expect(api.listIssues()).rejects.toThrow(/fetch failed/)
|
|
expect(calls).toBe(1)
|
|
})
|
|
|
|
it('grows the backoff exponentially between retries', async () => {
|
|
const warn = silentWarn()
|
|
let calls = 0
|
|
const sleeps: number[] = []
|
|
const api = createApi({
|
|
token: 'tok',
|
|
retries: 3,
|
|
backoffMs: 100,
|
|
jitter: false,
|
|
sleepImpl: async (ms) => {
|
|
sleeps.push(ms)
|
|
},
|
|
fetchImpl: async () => {
|
|
calls += 1
|
|
throw new TypeError('fetch failed')
|
|
},
|
|
})
|
|
|
|
await expect(api.listIssues()).rejects.toThrow(/fetch failed/)
|
|
expect(calls).toBe(4)
|
|
expect(sleeps).toEqual([100, 200, 400])
|
|
expect(warn.mock.calls.flat().join(' ')).toContain('tentativa 1/4')
|
|
})
|
|
|
|
it('applies the ±20% jitter around the exponential delay', async () => {
|
|
silentWarn()
|
|
vi.spyOn(Math, 'random').mockReturnValue(0.5)
|
|
const sleeps: number[] = []
|
|
const api = createApi({
|
|
token: 'tok',
|
|
retries: 2,
|
|
backoffMs: 100,
|
|
jitter: true,
|
|
sleepImpl: async (ms) => {
|
|
sleeps.push(ms)
|
|
},
|
|
fetchImpl: async () => {
|
|
throw new TypeError('fetch failed')
|
|
},
|
|
})
|
|
|
|
await expect(api.listIssues()).rejects.toThrow(/fetch failed/)
|
|
expect(sleeps).toEqual([100, 200])
|
|
})
|
|
|
|
it('does not retry 4xx responses', async () => {
|
|
let calls = 0
|
|
const api = createApi({
|
|
token: 'tok',
|
|
retries: 3,
|
|
fetchImpl: async () => {
|
|
calls += 1
|
|
return ok('{"message":"nope"}', 403)
|
|
},
|
|
})
|
|
|
|
await expect(api.listIssues()).rejects.toThrow(/403/)
|
|
expect(calls).toBe(1)
|
|
})
|
|
|
|
it('retries 5xx responses on GET', async () => {
|
|
silentWarn()
|
|
let calls = 0
|
|
const api = createApi({
|
|
token: 'tok',
|
|
retries: 2,
|
|
backoffMs: 0,
|
|
jitter: false,
|
|
sleepImpl: async () => {},
|
|
fetchImpl: async () => {
|
|
calls += 1
|
|
return calls === 1 ? ok('{"message":"boom"}', 502) : ok(issueOk)
|
|
},
|
|
})
|
|
|
|
const issues = await api.listIssues()
|
|
|
|
expect(calls).toBe(2)
|
|
expect(issues).toHaveLength(1)
|
|
})
|
|
|
|
it('throws the API error after exhausting 5xx retries on GET', async () => {
|
|
silentWarn()
|
|
let calls = 0
|
|
const api = createApi({
|
|
token: 'tok',
|
|
retries: 2,
|
|
backoffMs: 0,
|
|
jitter: false,
|
|
sleepImpl: async () => {},
|
|
fetchImpl: async () => {
|
|
calls += 1
|
|
return ok('{"message":"boom"}', 503)
|
|
},
|
|
})
|
|
|
|
await expect(api.listIssues()).rejects.toThrow(/503/)
|
|
expect(calls).toBe(3)
|
|
})
|
|
|
|
it('does not retry 5xx responses on write methods', async () => {
|
|
let calls = 0
|
|
const api = createApi({
|
|
token: 'tok',
|
|
retries: 2,
|
|
fetchImpl: async () => {
|
|
calls += 1
|
|
return ok('{"message":"boom"}', 503)
|
|
},
|
|
})
|
|
|
|
await expect(api.addComment(1, 'x')).rejects.toThrow(/503/)
|
|
expect(calls).toBe(1)
|
|
})
|
|
|
|
it('retries a 410 on GET (proxy answered — origin untouched)', async () => {
|
|
silentWarn()
|
|
let calls = 0
|
|
const api = createApi({
|
|
token: 'tok',
|
|
retries: 2,
|
|
backoffMs: 0,
|
|
jitter: false,
|
|
sleepImpl: async () => {},
|
|
fetchImpl: async () => {
|
|
calls += 1
|
|
return calls === 1 ? ok('<html>410 Gone</html>', 410) : ok(issueOk)
|
|
},
|
|
})
|
|
|
|
const issues = await api.listIssues()
|
|
|
|
expect(calls).toBe(2)
|
|
expect(issues).toHaveLength(1)
|
|
})
|
|
|
|
it('retries a 410 on write methods too (nginx answered before the origin — no duplicated side effect)', async () => {
|
|
silentWarn()
|
|
let calls = 0
|
|
const api = createApi({
|
|
token: 'tok',
|
|
retries: 2,
|
|
backoffMs: 0,
|
|
jitter: false,
|
|
sleepImpl: async () => {},
|
|
fetchImpl: async () => {
|
|
calls += 1
|
|
return calls === 1 ? ok('<html>410 Gone</html>', 410) : ok(null)
|
|
},
|
|
})
|
|
|
|
await api.addComment(1, 'x')
|
|
|
|
expect(calls).toBe(2)
|
|
})
|
|
|
|
it('throws a compact proxy marker after exhausting 410 retries (nginx html not swallowed raw)', async () => {
|
|
silentWarn()
|
|
let calls = 0
|
|
const api = createApi({
|
|
token: 'tok',
|
|
retries: 1,
|
|
backoffMs: 0,
|
|
jitter: false,
|
|
sleepImpl: async () => {},
|
|
fetchImpl: async () => {
|
|
calls += 1
|
|
return ok('<html>\n<head><title>410 Gone</title></head>\n<body>gone</body></html>', 410)
|
|
},
|
|
})
|
|
|
|
const error = await api.addComment(1, 'x').catch((e: unknown) => e)
|
|
|
|
expect((error as Error).message).toMatch(/410/)
|
|
expect((error as Error).message).toMatch(/corpo não-JSON \(proxy\/nginx/)
|
|
expect((error as Error).message).toMatch(/410 Gone/)
|
|
expect(calls).toBe(2)
|
|
})
|
|
})
|
|
})
|