teqo/scripts/lib/forgejo-api.mjs
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

491 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Forgejo REST client for the agent ops scripts (`pnpm agent:*`, workflow
* helpers). Zero dependencies — plain Node `fetch` — so it also runs inside
* Forgejo Actions jobs without `pnpm install`.
*
* Auth: `FORGEJO_API_TOKEN` (PAT) or the native `GITHUB_TOKEN` that Forgejo
* Actions injects. Base URL: `FORGEJO_API_URL`, else `<GITHUB_SERVER_URL>/api/v1`
* inside Actions, else `https://git.solla.dev/api/v1`. A browser-ish User-Agent
* is sent because the instance sits behind Cloudflare.
*
* Retry (OPS67): transient failures retry with exponential backoff — a fetch
* that rejects (network-level `fetch failed`, DNS/TCP/reset) retries on ANY
* method; a 5xx (502/503/504 — Cloudflare blips) retries on GET only, so the
* safety net's long `waitForChecks` poll survives, while write endpoints keep
* failing closed instead of risking duplicated side effects. 410 Gone
* (OPS71-FLIP): served by the reverse proxy (nginx/Cloudflare) BEFORE the
* request reaches the app — the origin never saw it, so retrying is safe on
* ANY method (unlike a 5xx, where the origin may have applied the write).
* 4xx never retries (410 is the one documented proxy-level exception).
* Defaults: 3 retries, base 300 ms ×2, ±20% jitter.
*
* Shapes are normalized to the GitHub-flavored contract the agent scripts and
* their unit specs already use: `issue.state` ∈ OPEN|CLOSED,
* `issue.labels = [{ name, color }]`, `createdAt` ISO string.
*/
const DEFAULT_BASE_URL = 'https://git.solla.dev/api/v1'
const DEFAULT_REPOSITORY = 'fsolla/teqo'
const USER_AGENT = 'teqo-agent-scripts/1.0 (forgejo)'
const RETRYABLE_STATUSES = new Set([502, 503, 504])
// 410 Gone from the reverse proxy (OPS71-FLIP live finding: nginx answered
// `GET /repos/fsolla/teqo/issues/97/labels` with 410, transient). The origin
// never received the request — retrying is side-effect-free on ANY method.
const RETRYABLE_BEFORE_ORIGIN = new Set([410])
/**
* @typedef {object} ForgejoApiOptions
* @property {string} [base]
* @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 {ForgejoApiOptions} [options]
*/
export const createApi = ({
base,
token,
repository,
fetchImpl,
retries = 3,
backoffMs = 300,
jitter = true,
sleepImpl = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
} = {}) => {
const baseUrl = (
base ??
process.env.FORGEJO_API_URL ??
(process.env.GITHUB_SERVER_URL ? `${process.env.GITHUB_SERVER_URL}/api/v1` : null) ??
DEFAULT_BASE_URL
).replace(/\/+$/, '')
const repo =
repository ??
process.env.FORGEJO_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(
`[forgejo-api] ${method} ${path} falhou (tentativa ${attempt}/${attemptCount}): ${reason} — retry em ${delay}ms`,
)
const request = async (path, { method = 'GET', body, query } = {}) => {
const authToken = token ?? process.env.FORGEJO_API_TOKEN ?? process.env.GITHUB_TOKEN
if (!authToken) {
throw new Error(
'Sem token para a API do Forgejo — defina FORGEJO_API_TOKEN (ou rode dentro do Forgejo Actions).',
)
}
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 init = {
method,
headers: {
Authorization: `token ${authToken}`,
'Content-Type': 'application/json',
'User-Agent': USER_AGENT,
},
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
}
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, init)
} 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
}
if (!isLastAttempt && RETRYABLE_BEFORE_ORIGIN.has(response.status)) {
await response.body?.cancel()
await retryAfter(attempt, `HTTP ${response.status} (proxy — origem intocada)`)
continue
}
let text
try {
text = await response.text()
} catch (error) {
if (isLastAttempt || method !== 'GET') throw error
await retryAfter(attempt, error.message)
continue
}
if (!response.ok) {
const isProxyHtml = /^\s*</.test(text)
const snippet = isProxyHtml
? `corpo não-JSON (proxy/nginx — HTTP ${response.status}): ${text
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120)}`
: text.slice(0, 400)
throw new Error(`Forgejo API ${method} ${path} → ${response.status}: ${snippet}`)
}
if (!text) return null
try {
return JSON.parse(text)
} catch {
throw new Error(`Forgejo API ${method} ${path}: resposta não-JSON: ${text.slice(0, 80)}`)
}
}
}
const normalizeState = (state) => (String(state).toLowerCase() === 'closed' ? 'CLOSED' : 'OPEN')
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 /api/v1/repos/{owner}/{repo}/issues — shape normalized like `gh issue list --json`. */
listIssues: async ({ state = 'open', labels, limit = 200, page = 1 } = {}) => {
const issues = await request(`/repos/${owner}/${name}/issues`, {
query: { state, labels, limit, page, type: 'issues' },
})
return (Array.isArray(issues) ? issues : []).map(normalizeIssue)
},
getIssue: async (number) => {
const issue = await request(`/repos/${owner}/${name}/issues/${number}`)
return normalizeIssue(issue)
},
createIssue: async ({ title, body }) => {
const issue = await request(`/repos/${owner}/${name}/issues`, {
method: 'POST',
body: { title, body },
})
return normalizeIssue(issue)
},
/** Adds labels (never removes). */
addLabels: (number, labels) =>
request(`/repos/${owner}/${name}/issues/${number}/labels`, {
method: 'POST',
body: { labels },
}),
/** Removes labels by name (resolves ids, ignores unknown names). */
removeLabels: async (number, labels) => {
const current = await request(`/repos/${owner}/${name}/issues/${number}/labels`)
for (const label of current ?? []) {
if (labels.includes(label.name)) {
await request(`/repos/${owner}/${name}/issues/${number}/labels/${label.id}`, {
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)
},
addComment: (number, body) =>
request(`/repos/${owner}/${name}/issues/${number}/comments`, {
method: 'POST',
body: { body },
}),
/**
* PATCH state=closed — closes the issue on the tracker. Forgejo-era: the
* merge of the PR closed the issue natively. GitHub-era (OPS71): the PR
* lives on GitHub and never touches the Forgejo tracker — the flip must
* close explicitly, else every merged issue stays OPEN with done+in-prod.
*/
closeIssue: (number) =>
request(`/repos/${owner}/${name}/issues/${number}`, {
method: 'PATCH',
body: { state: 'closed' },
}),
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 ?? '',
}))
},
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 /api/v1/repos/{owner}/{repo}/pulls — raw, normalized lightly. */
listPullRequests: async ({ state = 'open', limit = 100 } = {}) => {
const pulls = await request(`/repos/${owner}/${name}/pulls`, {
query: { state, limit },
})
return (Array.isArray(pulls) ? pulls : []).map((pr) => ({
number: pr.number,
title: pr.title,
state: normalizeState(pr.state),
body: pr.body ?? '',
head: { ref: pr.head?.ref, sha: pr.head?.sha },
base: { ref: pr.base?.ref },
}))
},
getPullRequest: async (number) => {
const pr = await request(`/repos/${owner}/${name}/pulls/${number}`)
return {
number: pr.number,
title: pr.title,
state: normalizeState(pr.state),
merged: Boolean(pr.merged),
mergeable: pr.mergeable,
isDraft: Boolean(pr.is_draft),
body: pr.body ?? '',
head: { ref: pr.head?.ref, sha: pr.head?.sha },
base: { ref: pr.base?.ref },
}
},
/** Marks a draft PR as ready for review (PATCH is_draft=false). */
markPullRequestReady: (number) =>
request(`/repos/${owner}/${name}/pulls/${number}`, {
method: 'PATCH',
body: { is_draft: false },
}),
createPullRequest: async ({ head, base, 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 }
},
/**
* Commit statuses of a sha (Forgejo Actions check runs surface here).
* Returns [{ context, state }] with state ∈ success|failure|pending|error.
*/
listCommitStatuses: async (sha) => {
const statuses = await request(`/repos/${owner}/${name}/commits/${sha}/status`)
const combined = statuses?.statuses ?? []
return combined.map((entry) => ({ context: entry.context, state: entry.status }))
},
/** Merges a PR with rebase (the repo's canonical merge style). */
mergePullRequest: (number) =>
request(`/repos/${owner}/${name}/pulls/${number}/merge`, {
method: 'POST',
body: { Do: 'rebase' },
}),
/** Triggers a workflow via workflow_dispatch. */
workflowDispatch: (workflowFile, { ref = 'main', inputs = {} } = {}) =>
request(`/repos/${owner}/${name}/actions/workflows/${workflowFile}/dispatches`, {
method: 'POST',
body: { ref, inputs },
}),
/** GET /api/v1/repos/{owner}/{repo}/contents/{path}?ref=… — { content, sha } base64-decoded. */
getFileContents: async (path, ref = 'main') => {
const file = await request(`/repos/${owner}/${name}/contents/${path}`, {
query: { ref },
})
return {
content: Buffer.from(file.content ?? '', 'base64').toString('utf8'),
sha: file.sha,
}
},
/** POST /contents — creates a file on a branch (fails if it exists). */
createFile: (path, { message, content, branch }) =>
request(`/repos/${owner}/${name}/contents/${path}`, {
method: 'POST',
body: {
message,
content: Buffer.from(content, 'utf8').toString('base64'),
branch,
},
}),
/** PUT /contents — updates an existing file (sha required). */
updateFile: (path, { message, content, branch, sha }) =>
request(`/repos/${owner}/${name}/contents/${path}`, {
method: 'PUT',
body: {
message,
content: Buffer.from(content, 'utf8').toString('base64'),
branch,
sha,
},
}),
/** POST /api/v1/repos/{owner}/{repo}/branch_protections (Forgejo rule-based protection). */
updateBranchProtection: (branch, payload) =>
request(`/repos/${owner}/${name}/branch_protections`, {
method: 'POST',
body: { ...payload, branch_name: branch },
}),
/** GET /api/v1/repos/{owner}/{repo}/branch_protections — normalized rules. */
listBranchProtections: async () => {
const rules = await request(`/repos/${owner}/${name}/branch_protections`)
return (Array.isArray(rules) ? rules : []).map((rule) => ({
rule_name: rule.rule_name ?? '',
branch_name: rule.branch_name ?? '',
enable_status_check: Boolean(rule.enable_status_check),
status_check_contexts: rule.status_check_contexts ?? [],
enable_push: Boolean(rule.enable_push),
required_approvals: rule.required_approvals ?? 0,
dismiss_stale_approvals: Boolean(rule.dismiss_stale_approvals),
}))
},
/** PATCH /api/v1/repos/{owner}/{repo}/branch_protections/{name} — updates an existing rule. */
editBranchProtection: (name, payload) =>
request(`/repos/${owner}/${name}/branch_protections/${name}`, {
method: 'PATCH',
body: payload,
}),
/**
* The CI (PR) cascade rollup is green and Forgejo finished computing
* mergeability. Returns the last PR read. A draft PR stops the wait
* (converted mid-run): the caller decides what "no merge" means — the CLI
* skips non-`cursor/*` drafts (OPS57 veto).
*
* OPS61 rollup gate: the CI (PR) cascade's aggregate job (`checks`, the
* `if: always()` rollup over every ci-pr job) must have POSTED and be
* success. Job statuses are posted as jobs finish, so a snapshot with zero
* pending can still miss jobs that were not scheduled yet. The rollup only
* exists once the whole cascade settled (it `needs` every job), so its
* absence keeps the wait going.
*
* The safety-net's OWN context (`PR Ready + auto-merge / …`) is posted as
* `pending` for the whole run and flips only at the end — it must never
* gate the verdict. Live finding (PR #67): the pre-OPS61
* `pending.length === 0` gate was unsatisfiable for that reason, so the
* CLI never auto-merged anything — every Forgejo merge so far was manual
* (`merged_by` the owner). The other pre-OPS61 blocker was the token: the
* merge POST pushes to main, and Forgejo's pre-receive hook rejects the
* push when the caller's user has no repo write — the native GITHUB_TOKEN
* acts as the Actions system user, so the CLI's own merge attempts died
* on 409 "User permission denied for writing" (the job must run with
* FORGEJO_API_TOKEN). The branch-protection rule on the server gates
* manual merges too: the merge POST is rejected (405) while the required
* context is not green.
*
* `mergeable === false` is only trusted after the cascade settled — while
* jobs still run (or the branch-protection rule blocks), Forgejo may
* report false without meaning "conflict".
*/
waitForChecks: async (number, { timeoutMs = 30 * 60 * 1000, pollMs = 15000, log } = {}) => {
const started = Date.now()
for (;;) {
const pr = await api.getPullRequest(number)
if (!pr || pr.state !== 'OPEN' || pr.merged || pr.isDraft) return pr
const statuses = await api.listCommitStatuses(pr.head.sha)
const verdicts = statuses.filter(
(entry) => !entry.context.startsWith('PR Ready + auto-merge'),
)
const rollup = verdicts.find((entry) => entry.context.startsWith('CI (PR) / checks'))
const cascadeSettled = Boolean(rollup && rollup.state === 'success')
const failed = verdicts.filter(
(entry) => entry.state === 'failure' || entry.state === 'error',
)
if (failed.length > 0) {
const names = failed.map((entry) => entry.context).join(', ')
throw new Error(`Checks falharam no PR #${number}: ${names}`)
}
if (pr.mergeable === false && cascadeSettled) {
throw new Error(`PR #${number} não mergeável (conflito?) — resolver manualmente.`)
}
if (cascadeSettled && pr.mergeable === true) return pr
if (Date.now() - started > timeoutMs) {
throw new Error(`Timeout esperando checks do PR #${number}.`)
}
if (log)
log(
`[pr#${number}] ${verdicts.length} status, rollup=${
rollup ? rollup.state : 'ausente'
}, mergeable=${pr.mergeable ?? 'computando'} — aguardando…`,
)
await new Promise((resolve) => setTimeout(resolve, pollMs))
}
},
/**
* Wait for checks then merge by rebase (the `gh pr merge --auto --rebase`
* equivalent). The verdict is a RE-READ of the PR, never the merge POST
* response: Forgejo 9 answers a successful merge with 200 + empty body, so
* response shape alone cannot distinguish "merged" from "still open".
*
* @returns {Promise<{ attempted: boolean, merged: boolean, pr: object | null }>}
*/
autoMerge: async (number, options) => {
const pr = await api.waitForChecks(number, options)
if (!pr || pr.merged || pr.state !== 'OPEN' || pr.isDraft) {
return { attempted: false, merged: Boolean(pr?.merged), pr }
}
try {
await api.mergePullRequest(number)
} catch (error) {
const after = await api.getPullRequest(number)
if (!after?.merged && after?.state === 'OPEN') throw error
return { attempted: true, merged: Boolean(after?.merged), pr: after }
}
const after = await api.getPullRequest(number)
return { attempted: true, merged: Boolean(after?.merged), pr: after }
},
}
return api
}
/** Default instance bound to the environment. */
export const forgejoApi = createApi({})