mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:45:18 +00:00
O GITHUB_TOKEN exportado no shell desde o OPS71 (PAT fine-grained) expirou e,
inválido, sombreava a credencial válida do gh (keyring) — quebrando
pnpm issue/agent:*/github-pr.mjs e o próprio gh (o bash dos agentes herda o env
do opencode serve, não do .bashrc). github-api.mjs passa a anexar dica acionável
a todo 401 (REST + GraphQL), com 2 testes de regressão; AGENT-OPS documenta a
expiração e a recuperação (derivar de gh auth token).
Mesmo lote do OPS71: o secret AUTOMERGE_PAT também expirou (safety net falha
fail-closed desde 21:39Z) — rotação é ação humana pendente.
C201/#1169: o typecheck da main (RED desde bdaf3c7c) foi corrigido pelo PR #1171;
este PR fecha o tracker item.
Post-mortem: docs/postmortems/2026-09-18-github-token-401.md
652 lines
24 KiB
JavaScript
652 lines
24 KiB
JavaScript
/**
|
||
* GitHub REST + GraphQL client for the CI/PR scripts (`github-pr*`, branch
|
||
* protection). Zero dependencies — plain Node `fetch` — so it also runs
|
||
* inside GitHub Actions jobs without `pnpm install`.
|
||
*
|
||
* Auth: `GITHUB_TOKEN` (PAT locally; the built-in Actions token in
|
||
* workflows). Base URL: `GITHUB_API_URL`, else `https://api.github.com`.
|
||
*
|
||
* Retry (OPS67 policy): transient failures retry with
|
||
* exponential backoff — a fetch that rejects (network-level, DNS/TCP/reset)
|
||
* retries on ANY method; a 5xx retries on GET only, so read polls survive
|
||
* while write endpoints keep failing closed instead of risking duplicated
|
||
* side effects. 4xx never retries. Defaults: 3 retries, base 300 ms ×2,
|
||
* ±20% jitter.
|
||
*
|
||
* Shapes are normalized to the contract the CLI scripts use: `pr.state` ∈
|
||
* OPEN|CLOSED, `pr.draft`, `pr.nodeId` (GraphQL auto-merge), branch
|
||
* protection normalized for drift comparison.
|
||
*/
|
||
|
||
const DEFAULT_BASE_URL = 'https://api.github.com'
|
||
const DEFAULT_GRAPHQL_URL = 'https://api.github.com/graphql'
|
||
const DEFAULT_REPOSITORY = 'fsolla/teqo'
|
||
const USER_AGENT = 'teqo-agent-scripts/1.0 (github)'
|
||
const RETRYABLE_STATUSES = new Set([502, 503, 504])
|
||
const AUTH_401_HINT =
|
||
'token rejeitado — GITHUB_TOKEN inválido/expirado ou revogado? Rode `gh auth status` e rotacione ' +
|
||
'o PAT (um token inválido no ambiente também sombreia o login do `gh`).'
|
||
|
||
/**
|
||
* @typedef {object} GithubApiOptions
|
||
* @property {string} [base]
|
||
* @property {string} [graphqlBase]
|
||
* @property {string} [token]
|
||
* @property {string} [repository]
|
||
* @property {(input: string | URL, init?: { method?: string; headers?: Record<string, string>; body?: string }) => Promise<Response>} [fetchImpl]
|
||
* @property {number} [retries] Additional attempts after the first (total = retries + 1). Default 3.
|
||
* @property {number} [backoffMs] Base exponential delay. Default 300.
|
||
* @property {boolean} [jitter] ±20% randomization on each delay. Default true.
|
||
* @property {(ms: number) => Promise<void>} [sleepImpl] Test seam for the backoff delay.
|
||
*/
|
||
|
||
/**
|
||
* @param {GithubApiOptions} [options]
|
||
*/
|
||
export const createApi = ({
|
||
base,
|
||
graphqlBase,
|
||
token,
|
||
repository,
|
||
fetchImpl,
|
||
retries = 3,
|
||
backoffMs = 300,
|
||
jitter = true,
|
||
sleepImpl = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||
} = {}) => {
|
||
const baseUrl = (base ?? process.env.GITHUB_API_URL ?? DEFAULT_BASE_URL).replace(/\/+$/, '')
|
||
const graphqlUrl = (graphqlBase ?? process.env.GITHUB_GRAPHQL_URL ?? DEFAULT_GRAPHQL_URL).replace(
|
||
/\/+$/,
|
||
'',
|
||
)
|
||
const repo = repository ?? process.env.GITHUB_REPOSITORY ?? DEFAULT_REPOSITORY
|
||
const fetcher = fetchImpl ?? fetch
|
||
const [owner, name] = repo.split('/')
|
||
if (retries < 0 || backoffMs < 0) {
|
||
throw new Error(
|
||
`Retry inválido: retries=${retries}, backoffMs=${backoffMs} — ambos devem ser >= 0`,
|
||
)
|
||
}
|
||
const attemptCount = retries + 1
|
||
|
||
const backoffDelay = (attempt) => {
|
||
const base = backoffMs * 2 ** (attempt - 1)
|
||
return jitter ? Math.round(base * (0.8 + Math.random() * 0.4)) : base
|
||
}
|
||
|
||
const warnRetry = (path, method, reason, attempt, delay) =>
|
||
console.warn(
|
||
`[github-api] ${method} ${path} falhou (tentativa ${attempt}/${attemptCount}): ${reason} — retry em ${delay}ms`,
|
||
)
|
||
|
||
const headers = (withJson = true) => {
|
||
const authToken = token ?? process.env.GITHUB_TOKEN
|
||
if (!authToken) {
|
||
throw new Error('Sem token para a API do GitHub — defina GITHUB_TOKEN.')
|
||
}
|
||
return {
|
||
Authorization: `Bearer ${authToken}`,
|
||
'User-Agent': USER_AGENT,
|
||
...(withJson ? { 'Content-Type': 'application/json' } : {}),
|
||
}
|
||
}
|
||
|
||
const request = async (path, { method = 'GET', body, query } = {}) => {
|
||
const qs = query
|
||
? '?' +
|
||
Object.entries(query)
|
||
.filter(([, value]) => value !== undefined && value !== null)
|
||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
|
||
.join('&')
|
||
: ''
|
||
const url = `${baseUrl}${path}${qs}`
|
||
const retryAfter = async (attempt, reason) => {
|
||
const delay = backoffDelay(attempt)
|
||
warnRetry(path, method, reason, attempt, delay)
|
||
await sleepImpl(delay)
|
||
}
|
||
for (let attempt = 1; attempt <= attemptCount; attempt += 1) {
|
||
const isLastAttempt = attempt === attemptCount
|
||
let response
|
||
try {
|
||
response = await fetcher(url, {
|
||
method,
|
||
headers: headers(),
|
||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||
})
|
||
} catch (error) {
|
||
if (isLastAttempt) throw error
|
||
await retryAfter(attempt, error.message)
|
||
continue
|
||
}
|
||
if (method === 'GET' && !isLastAttempt && RETRYABLE_STATUSES.has(response.status)) {
|
||
await response.body?.cancel()
|
||
await retryAfter(attempt, `HTTP ${response.status}`)
|
||
continue
|
||
}
|
||
let text
|
||
try {
|
||
text = await response.text()
|
||
} catch (error) {
|
||
if (isLastAttempt || method !== 'GET') throw error
|
||
await retryAfter(attempt, error.message)
|
||
continue
|
||
}
|
||
if (response.status === 404 && method === 'GET') return null
|
||
if (!response.ok) {
|
||
const hint = response.status === 401 ? `${AUTH_401_HINT} ` : ''
|
||
throw new Error(
|
||
`GitHub API ${method} ${path} → ${response.status}: ${hint}${text.slice(0, 400)}`,
|
||
)
|
||
}
|
||
if (!text) return null
|
||
try {
|
||
return JSON.parse(text)
|
||
} catch {
|
||
throw new Error(`GitHub API ${method} ${path}: resposta não-JSON: ${text.slice(0, 80)}`)
|
||
}
|
||
}
|
||
}
|
||
|
||
const graphql = async (query, variables) => {
|
||
let response
|
||
try {
|
||
response = await fetcher(graphqlUrl, {
|
||
method: 'POST',
|
||
headers: headers(),
|
||
body: JSON.stringify({ query, variables }),
|
||
})
|
||
} catch (error) {
|
||
throw new Error(`GitHub GraphQL falhou (rede): ${error.message}`)
|
||
}
|
||
const text = await response.text()
|
||
if (!response.ok) {
|
||
const hint = response.status === 401 ? `${AUTH_401_HINT} ` : ''
|
||
throw new Error(`GitHub GraphQL → ${response.status}: ${hint}${text.slice(0, 400)}`)
|
||
}
|
||
const parsed = JSON.parse(text)
|
||
const firstError = parsed?.errors?.[0]
|
||
if (firstError) {
|
||
throw new Error(`GitHub GraphQL: ${firstError.message}`)
|
||
}
|
||
return parsed.data ?? null
|
||
}
|
||
|
||
const normalizeState = (state) => (String(state).toLowerCase() === 'closed' ? 'CLOSED' : 'OPEN')
|
||
|
||
const normalizePullRequest = (pr) => ({
|
||
number: pr.number,
|
||
title: pr.title,
|
||
body: pr.body ?? '',
|
||
state: normalizeState(pr.state),
|
||
merged: Boolean(pr.merged_at || pr.merged),
|
||
draft: Boolean(pr.draft),
|
||
mergeable: pr.mergeable ?? null,
|
||
autoMerge: pr.auto_merge
|
||
? {
|
||
mergeMethod: pr.auto_merge.merge_method ?? '',
|
||
enabledBy: pr.auto_merge.enabled_by?.login ?? '',
|
||
}
|
||
: null,
|
||
nodeId: pr.node_id ?? '',
|
||
head: { ref: pr.head?.ref ?? '', sha: pr.head?.sha ?? '' },
|
||
base: { ref: pr.base?.ref ?? '' },
|
||
})
|
||
|
||
/** Issue shape normalized to the agent-script contract. */
|
||
const normalizeIssue = (issue) => ({
|
||
number: issue.number,
|
||
title: issue.title,
|
||
body: issue.body ?? '',
|
||
state: normalizeState(issue.state),
|
||
createdAt: issue.created_at,
|
||
labels: (issue.labels ?? []).map((label) => ({
|
||
name: label.name,
|
||
color: label.color ?? '',
|
||
})),
|
||
})
|
||
|
||
const api = {
|
||
/**
|
||
* GET /repos/{owner}/{repo}/pulls/{number} — normalized.
|
||
* @param {number} number
|
||
*/
|
||
getPullRequest: async (number) => {
|
||
const pr = await request(`/repos/${owner}/${name}/pulls/${number}`)
|
||
return pr ? normalizePullRequest(pr) : null
|
||
},
|
||
|
||
/** GET /repos/{owner}/{repo}/issues — normalized; `limit` → `per_page`. */
|
||
listIssues: async ({ state = 'open', labels, limit = 100, page = 1 } = {}) => {
|
||
const issues = await request(`/repos/${owner}/${name}/issues`, {
|
||
query: { state, labels, per_page: limit, page },
|
||
})
|
||
// GitHub returns PRs in the issues list; filter them out (the GitHub
|
||
// issues endpoint mixes PRs; there is no `type` filter).
|
||
return (Array.isArray(issues) ? issues : [])
|
||
.filter((issue) => !issue.pull_request)
|
||
.map(normalizeIssue)
|
||
},
|
||
|
||
/** GET /repos/{owner}/{repo}/issues/{number} — normalized. */
|
||
getIssue: async (number) => {
|
||
const issue = await request(`/repos/${owner}/${name}/issues/${number}`)
|
||
return issue ? normalizeIssue(issue) : null
|
||
},
|
||
|
||
/** POST /repos/{owner}/{repo}/issues — creates with title+body. */
|
||
createIssue: async ({ title, body }) => {
|
||
const issue = await request(`/repos/${owner}/${name}/issues`, {
|
||
method: 'POST',
|
||
body: { title, body },
|
||
})
|
||
return normalizeIssue(issue)
|
||
},
|
||
|
||
/** Adds labels by NAME (GitHub labels are repo-level, addressed by name). */
|
||
addLabels: (number, labels) =>
|
||
request(`/repos/${owner}/${name}/issues/${number}/labels`, {
|
||
method: 'POST',
|
||
body: { labels },
|
||
}),
|
||
|
||
/** Removes labels by NAME (GitHub DELETE is by label name). */
|
||
removeLabels: async (number, labels) => {
|
||
for (const label of labels) {
|
||
await request(
|
||
`/repos/${owner}/${name}/issues/${number}/labels/${encodeURIComponent(label)}`,
|
||
{
|
||
method: 'DELETE',
|
||
},
|
||
)
|
||
}
|
||
},
|
||
|
||
/**
|
||
* @param {number} number
|
||
* @param {{ add?: string[], remove?: string[] }} [options]
|
||
*/
|
||
setLabels: async (number, { add = [], remove = [] } = {}) => {
|
||
if (remove.length > 0) await api.removeLabels(number, remove)
|
||
if (add.length > 0) await api.addLabels(number, add)
|
||
},
|
||
|
||
/** POST /issues/{number}/comments — { body }. */
|
||
addComment: (number, body) =>
|
||
request(`/repos/${owner}/${name}/issues/${number}/comments`, {
|
||
method: 'POST',
|
||
body: { body },
|
||
}),
|
||
|
||
/** PATCH state=closed — closes the issue on the GitHub tracker. */
|
||
closeIssue: (number) =>
|
||
request(`/repos/${owner}/${name}/issues/${number}`, {
|
||
method: 'PATCH',
|
||
body: { state: 'closed' },
|
||
}),
|
||
|
||
/** GET /issues/{number}/comments — [{ body, user }]. */
|
||
listIssueComments: async (number) => {
|
||
const comments = await request(`/repos/${owner}/${name}/issues/${number}/comments`)
|
||
return (Array.isArray(comments) ? comments : []).map((comment) => ({
|
||
body: comment.body ?? '',
|
||
user: comment.user?.login ?? '',
|
||
}))
|
||
},
|
||
|
||
/** GET /pulls/{number}/files — [{ filename, status }]. */
|
||
listPullRequestFiles: async (number) => {
|
||
const files = await request(`/repos/${owner}/${name}/pulls/${number}/files`)
|
||
return (Array.isArray(files) ? files : []).map((file) => ({
|
||
filename: file.filename ?? '',
|
||
status: file.status ?? '',
|
||
}))
|
||
},
|
||
|
||
/**
|
||
* GET /pulls — normalized, GitHub PATCH uses `draft` not `is_draft`.
|
||
* `head` is the GitHub filter `owner:branch` (OPS106: the auto-unblock
|
||
* wrapper finds the agent's PR by the `fix/*` branch it created).
|
||
* @param {{ state?: string, limit?: number, head?: string }} [options]
|
||
*/
|
||
listPullRequests: async ({ state = 'open', limit = 100, head } = {}) => {
|
||
const pulls = await request(`/repos/${owner}/${name}/pulls`, {
|
||
query: { state, head, per_page: limit },
|
||
})
|
||
return (Array.isArray(pulls) ? pulls : []).map(normalizePullRequest)
|
||
},
|
||
|
||
/** POST /actions/workflows/{file}/dispatches — triggers a workflow. */
|
||
workflowDispatch: (workflowFile, { ref = 'main', inputs = {} } = {}) =>
|
||
request(`/repos/${owner}/${name}/actions/workflows/${workflowFile}/dispatches`, {
|
||
method: 'POST',
|
||
body: { ref, inputs },
|
||
}),
|
||
|
||
/**
|
||
* GET /actions/workflows/{file}/runs — normalized, newest first. GitHub's
|
||
* `status` filter accepts a single value, so callers wanting several poll
|
||
* once per status (e.g. OPS104's queued + in_progress).
|
||
* @param {string} workflowFile
|
||
* @param {{ status?: string, branch?: string, limit?: number }} [options]
|
||
*/
|
||
listWorkflowRuns: async (workflowFile, { status, branch, limit = 100 } = {}) => {
|
||
const payload = await request(
|
||
`/repos/${owner}/${name}/actions/workflows/${workflowFile}/runs`,
|
||
{ query: { status, branch, per_page: limit } },
|
||
)
|
||
const runs = Array.isArray(payload?.workflow_runs) ? payload.workflow_runs : []
|
||
return runs.map((run) => ({
|
||
id: run.id,
|
||
status: run.status ?? '',
|
||
conclusion: run.conclusion ?? null,
|
||
event: run.event ?? '',
|
||
headSha: run.head_sha ?? '',
|
||
headBranch: run.head_branch ?? '',
|
||
createdAt: run.created_at,
|
||
htmlUrl: run.html_url ?? '',
|
||
}))
|
||
},
|
||
|
||
/**
|
||
* GET /repos/{owner}/{repo}/actions/runs/{id}/jobs — normalized job list
|
||
* of a run (OPS106 gate: only a failed `verify` job earns an agent).
|
||
* Steps keep name+conclusion so the bug report names the failed step.
|
||
*/
|
||
getWorkflowRunJobs: async (runId) => {
|
||
const payload = await request(`/repos/${owner}/${name}/actions/runs/${runId}/jobs`, {
|
||
query: { per_page: 100 },
|
||
})
|
||
const jobs = Array.isArray(payload?.jobs) ? payload.jobs : []
|
||
return jobs.map((job) => ({
|
||
id: job.id,
|
||
name: job.name ?? '',
|
||
status: job.status ?? '',
|
||
conclusion: job.conclusion ?? null,
|
||
htmlUrl: job.html_url ?? '',
|
||
steps: (Array.isArray(job.steps) ? job.steps : []).map((step) => ({
|
||
name: step.name ?? '',
|
||
conclusion: step.conclusion ?? null,
|
||
})),
|
||
}))
|
||
},
|
||
|
||
/** GET /labels/{name} — `null` when the label does not exist (404). */
|
||
getLabel: async (label) => {
|
||
const found = await request(`/repos/${owner}/${name}/labels/${encodeURIComponent(label)}`)
|
||
if (!found) return null
|
||
return { name: found.name ?? '', color: found.color ?? '' }
|
||
},
|
||
|
||
/** POST /labels — creates a repo label (idempotent callers probe first). */
|
||
createLabel: async (label, { color = '1D76DB', description = '' } = {}) => {
|
||
const created = await request(`/repos/${owner}/${name}/labels`, {
|
||
method: 'POST',
|
||
body: { name: label, color, description },
|
||
})
|
||
return { name: created.name ?? label, color: created.color ?? color }
|
||
},
|
||
|
||
/** GET /repos/{owner}/{repo} — normalized (permissions used by `--check`). */
|
||
getRepository: async () => {
|
||
const repoPayload = await request(`/repos/${owner}/${name}`)
|
||
return {
|
||
fullName: repoPayload.full_name ?? repo,
|
||
defaultBranch: repoPayload.default_branch ?? 'main',
|
||
permissions: {
|
||
admin: Boolean(repoPayload.permissions?.admin),
|
||
push: Boolean(repoPayload.permissions?.push),
|
||
pull: Boolean(repoPayload.permissions?.pull),
|
||
},
|
||
}
|
||
},
|
||
|
||
/** GET /git/ref/heads/{branch} — `{ ref, sha }`; `null` when the branch is gone (404). */
|
||
getBranchHead: async (branch = 'main') => {
|
||
const ref = await request(`/repos/${owner}/${name}/git/ref/heads/${branch}`)
|
||
if (!ref) return null
|
||
return { ref: ref.ref ?? '', sha: ref.object?.sha ?? '' }
|
||
},
|
||
|
||
/** GET /contents/{path}?ref= — { content, sha } base64-decoded. */
|
||
getFileContents: async (path, ref = 'main') => {
|
||
const file = await request(`/repos/${owner}/${name}/contents/${path}`, { query: { ref } })
|
||
if (!file) return null
|
||
return {
|
||
content: Buffer.from(file.content ?? '', 'base64').toString('utf8'),
|
||
sha: file.sha,
|
||
}
|
||
},
|
||
|
||
/** PUT /contents/{path} — creates/updates a file on a branch (sha for update). */
|
||
writeFile: (path, { message, content, branch = 'main', sha } = {}) =>
|
||
request(`/repos/${owner}/${name}/contents/${path}`, {
|
||
method: 'PUT',
|
||
body: {
|
||
message,
|
||
content: Buffer.from(content, 'utf8').toString('base64'),
|
||
branch,
|
||
...(sha ? { sha } : {}),
|
||
},
|
||
}),
|
||
|
||
/** POST /contents/{path} — creates a file on a branch (fails if it exists). */
|
||
createFile: (path, { message, content, branch = 'main' } = {}) =>
|
||
request(`/repos/${owner}/${name}/contents/${path}`, {
|
||
method: 'PUT',
|
||
body: {
|
||
message,
|
||
content: Buffer.from(content, 'utf8').toString('base64'),
|
||
branch,
|
||
},
|
||
}),
|
||
|
||
/** PUT /contents/{path} — updates an existing file (sha required). */
|
||
updateFile: (path, { message, content, branch = 'main', sha } = {}) =>
|
||
request(`/repos/${owner}/${name}/contents/${path}`, {
|
||
method: 'PUT',
|
||
body: {
|
||
message,
|
||
content: Buffer.from(content, 'utf8').toString('base64'),
|
||
branch,
|
||
sha,
|
||
},
|
||
}),
|
||
|
||
/** PATCH draft:false — marks a draft PR ready for review. */
|
||
markPullRequestReady: (number) =>
|
||
request(`/repos/${owner}/${name}/pulls/${number}`, {
|
||
method: 'PATCH',
|
||
body: { draft: false },
|
||
}),
|
||
|
||
/** POST /pulls — creates a PR (never draft). */
|
||
createPullRequest: async ({ head, base = 'main', title, body }) => {
|
||
const pr = await request(`/repos/${owner}/${name}/pulls`, {
|
||
method: 'POST',
|
||
body: { head, base, title, body },
|
||
})
|
||
return { number: pr.number, title: pr.title, htmlUrl: pr.html_url }
|
||
},
|
||
|
||
/**
|
||
* Enables GitHub's native auto-merge (GraphQL) with the repo's canonical
|
||
* merge style (rebase). The server waits for the required checks — a PR
|
||
* with a red `CI (PR) / checks` can never auto-merge.
|
||
*/
|
||
enableAutoMerge: async (nodeId, mergeMethod = 'REBASE') => {
|
||
await graphql(
|
||
`
|
||
mutation EnablePullRequestAutoMerge($id: ID!, $mergeMethod: PullRequestMergeMethod!) {
|
||
enablePullRequestAutoMerge(input: { pullRequestId: $id, mergeMethod: $mergeMethod }) {
|
||
pullRequest {
|
||
number
|
||
}
|
||
}
|
||
}
|
||
`,
|
||
{ id: nodeId, mergeMethod },
|
||
)
|
||
},
|
||
|
||
/**
|
||
* Disarms GitHub's native auto-merge via GraphQL — the same channel the
|
||
* `agent-pr-ready-automerge` safety net uses to arm it. The REST
|
||
* counterpart (`DELETE /pulls/{number}/auto-merge`) answers 404 even
|
||
* when auto-merge IS armed by GraphQL, so it is not usable here
|
||
* (found live in the OPS97 dogfood, PR #905). Takes the PR's `nodeId`
|
||
* (`getPullRequest(n).nodeId`), mirroring `enableAutoMerge`.
|
||
*/
|
||
disableAutoMerge: async (nodeId) => {
|
||
await graphql(
|
||
`
|
||
mutation DisablePullRequestAutoMerge($id: ID!) {
|
||
disablePullRequestAutoMerge(input: { pullRequestId: $id }) {
|
||
pullRequest {
|
||
number
|
||
}
|
||
}
|
||
}
|
||
`,
|
||
{ id: nodeId },
|
||
)
|
||
},
|
||
|
||
/**
|
||
* Disarm-and-verify loop for react-audit's Done condition. The safety
|
||
* net arms auto-merge asynchronously AFTER the PR opens (the race is
|
||
* real: a single disarm right after opening lost it in the OPS97
|
||
* dogfood), so a bare DELETE/mutation is not enough — poll until
|
||
* `auto_merge` reads null or attempts are exhausted. Fail-closed: an
|
||
* unknown PR throws (a wrong number must not read as success); the
|
||
* final GET runs without a preceding sleep, so eventual consistency
|
||
* may raise a false "still armed" — that direction is safe.
|
||
*/
|
||
ensureAutoMergeDisabled: async (number, { attempts = 5, delayMs = 2000 } = {}) => {
|
||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||
const pr = await api.getPullRequest(number)
|
||
if (!pr) throw new Error(`PR #${number} não encontrado — desarme não verificado`)
|
||
if (pr.autoMerge === null) return true
|
||
await api.disableAutoMerge(pr.nodeId)
|
||
if (attempt < attempts) await sleepImpl(delayMs)
|
||
}
|
||
const final = await api.getPullRequest(number)
|
||
if (!final) throw new Error(`PR #${number} não encontrado — desarme não verificado`)
|
||
if (final.autoMerge === null) return true
|
||
throw new Error(
|
||
`auto-merge do PR #${number} segue armado após ${attempts} tentativas de desarme`,
|
||
)
|
||
},
|
||
|
||
/**
|
||
* GraphQL status read of a PR's auto-merge — the verification arm of the
|
||
* testing-audit disarm guard (P6-M909, miss #909). One query carries the
|
||
* `autoMergeRequest` (GraphQL-only), `isDraft`, `mergeable` and
|
||
* `mergeStateStatus` plus the node `id` (needed by the draft conversion
|
||
* fallback). Fail-closed: an unknown PR throws — a wrong number must not
|
||
* read as "disarmed".
|
||
* @param {number} number
|
||
*/
|
||
getPullRequestAutoMergeStatus: async (number) => {
|
||
const data = await graphql(
|
||
`
|
||
query PullRequestAutoMergeStatus($owner: String!, $name: String!, $number: Int!) {
|
||
repository(owner: $owner, name: $name) {
|
||
pullRequest(number: $number) {
|
||
id
|
||
isDraft
|
||
mergeable
|
||
mergeStateStatus
|
||
autoMergeRequest {
|
||
mergeMethod
|
||
enabledBy {
|
||
login
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
`,
|
||
{ owner, name, number },
|
||
)
|
||
const pr = data?.repository?.pullRequest
|
||
if (!pr) {
|
||
throw new Error(`PR #${number} não encontrado — status de auto-merge não verificado`)
|
||
}
|
||
return {
|
||
number,
|
||
nodeId: pr.id,
|
||
isDraft: Boolean(pr.isDraft),
|
||
mergeable: pr.mergeable,
|
||
mergeStateStatus: pr.mergeStateStatus,
|
||
autoMergeRequest: pr.autoMergeRequest
|
||
? {
|
||
mergeMethod: pr.autoMergeRequest.mergeMethod,
|
||
enabledBy: pr.autoMergeRequest.enabledBy?.login ?? '',
|
||
}
|
||
: null,
|
||
}
|
||
},
|
||
|
||
/**
|
||
* GraphQL mutation `convertPullRequestToDraft` — the fail-closed fallback
|
||
* of the testing-audit disarm guard: when the disarm verification fails,
|
||
* a draft is the structural veto the safety net respects (a draft never
|
||
* auto-merges). Takes the PR's node `id`
|
||
* (`getPullRequestAutoMergeStatus(n).nodeId`).
|
||
*/
|
||
convertPullRequestToDraft: async (nodeId) => {
|
||
await graphql(
|
||
`
|
||
mutation ConvertPullRequestToDraft($id: ID!) {
|
||
convertPullRequestToDraft(input: { pullRequestId: $id }) {
|
||
pullRequest {
|
||
number
|
||
isDraft
|
||
}
|
||
}
|
||
}
|
||
`,
|
||
{ id: nodeId },
|
||
)
|
||
},
|
||
|
||
/**
|
||
* GET /repos/{owner}/{repo}/branches/{branch}/protection — normalized for
|
||
* drift comparison; `null` when the branch has no protection rule (404).
|
||
*/
|
||
getBranchProtection: async (branch = 'main') => {
|
||
const rule = await request(`/repos/${owner}/${name}/branches/${branch}/protection`)
|
||
if (!rule) return null
|
||
return {
|
||
required_status_checks: rule.required_status_checks
|
||
? {
|
||
strict: Boolean(rule.required_status_checks.strict),
|
||
contexts: (rule.required_status_checks.checks ?? []).map((entry) => entry.context),
|
||
}
|
||
: null,
|
||
enforce_admins: Boolean(rule.enforce_admins?.enabled ?? rule.enforce_admins),
|
||
required_pull_request_reviews: rule.required_pull_request_reviews ?? null,
|
||
}
|
||
},
|
||
|
||
/**
|
||
* PUT /repos/{owner}/{repo}/branches/{branch}/protection — replaces the
|
||
* whole rule (GitHub replace semantics: omitted protections are removed).
|
||
*/
|
||
updateBranchProtection: (payload, branch = 'main') =>
|
||
request(`/repos/${owner}/${name}/branches/${branch}/protection`, {
|
||
method: 'PUT',
|
||
body: payload,
|
||
}),
|
||
|
||
/** PATCH /repos/{owner}/{repo} — repo settings (e.g. allow_auto_merge). */
|
||
updateRepository: (payload) =>
|
||
request(`/repos/${owner}/${name}`, { method: 'PATCH', body: payload }),
|
||
}
|
||
|
||
return api
|
||
}
|
||
|
||
/** Default instance bound to the environment. */
|
||
export const githubApi = createApi({})
|