teqo-backup/scripts/lib/github-api.mjs

264 lines
9.3 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.

/**
* 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 (same policy as forgejo-api OPS67): 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])
/**
* @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) {
throw new Error(`GitHub API ${method} ${path} → ${response.status}: ${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) {
throw new Error(`GitHub GraphQL → ${response.status}: ${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,
nodeId: pr.node_id ?? '',
head: { ref: pr.head?.ref ?? '', sha: pr.head?.sha ?? '' },
base: { ref: pr.base?.ref ?? '' },
})
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
},
/** 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 },
)
},
/**
* 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
}