| 1 | // Resolves the #N references in release notes to the people they credit: a |
| 2 | // pull request's author, or for an issue the authors of the merged pull |
| 3 | // requests that closed it. A number that names a discussion or nothing stays |
| 4 | // plain with a warning; a lookup GitHub could not answer fails by code. |
| 5 | |
| 6 | import { execFileSync } from "node:child_process"; |
| 7 | |
| 8 | export const defaultRepository = "esengine/DeepSeek-Reasonix"; |
| 9 | |
| 10 | export const CreditErrorCode = Object.freeze({ |
| 11 | noToken: "release_credits.no_token", |
| 12 | unreachable: "release_credits.api_unreachable", |
| 13 | status: "release_credits.api_status", |
| 14 | notFound: "release_credits.ref_not_found", |
| 15 | discussion: "release_credits.ref_is_discussion", |
| 16 | unresolved: "release_credits.unresolved", |
| 17 | }); |
| 18 | |
| 19 | export class CreditError extends Error { |
| 20 | constructor(code, message, { ref, failures } = {}) { |
| 21 | super(message); |
| 22 | this.name = "CreditError"; |
| 23 | this.code = code; |
| 24 | if (ref !== undefined) this.ref = ref; |
| 25 | if (failures !== undefined) this.failures = failures; |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | const botLogins = new Set(["github-actions", "dependabot", "dependabot-preview"]); |
| 30 | |
| 31 | export function isBotAccount(login, type) { |
| 32 | if (type === "Bot") return true; |
| 33 | const name = String(login).toLowerCase(); |
| 34 | return name.endsWith("[bot]") || botLogins.has(name); |
| 35 | } |
| 36 | |
| 37 | export function tokenFromEnvironment(env = process.env, runGh = defaultRunGh) { |
| 38 | const token = env.GH_TOKEN || env.GITHUB_TOKEN; |
| 39 | if (token) return token; |
| 40 | try { |
| 41 | const fromGh = runGh().trim(); |
| 42 | if (fromGh) return fromGh; |
| 43 | } catch { |
| 44 | // gh missing or signed out; reported below as no_token. |
| 45 | } |
| 46 | throw new CreditError( |
| 47 | CreditErrorCode.noToken, |
| 48 | "no GitHub token: set GH_TOKEN (in CI: ${{ github.token }}), or sign in with `gh auth login`", |
| 49 | ); |
| 50 | } |
| 51 | |
| 52 | function defaultRunGh() { |
| 53 | return execFileSync("gh", ["auth", "token"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); |
| 54 | } |
| 55 | |
| 56 | const refQuery = `query($owner: String!, $name: String!, $number: Int!) { |
| 57 | repository(owner: $owner, name: $name) { |
| 58 | discussion(number: $number) { number } |
| 59 | issueOrPullRequest(number: $number) { |
| 60 | __typename |
| 61 | ... on PullRequest { author { login __typename } } |
| 62 | ... on Issue { |
| 63 | closedByPullRequestsReferences(first: 20, includeClosedPrs: true) { |
| 64 | nodes { number merged author { login __typename } } |
| 65 | } |
| 66 | timelineItems(itemTypes: [CLOSED_EVENT], last: 1) { |
| 67 | nodes { ... on ClosedEvent { closer { __typename ... on PullRequest { number merged author { login __typename } } } } } |
| 68 | } |
| 69 | } |
| 70 | } |
| 71 | } |
| 72 | }`; |
| 73 | |
| 74 | function person(author) { |
| 75 | if (!author) return { login: null, bot: true }; |
| 76 | return { login: author.login, bot: isBotAccount(author.login, author.__typename) }; |
| 77 | } |
| 78 | |
| 79 | // An issue's fixers are the merged pull requests GitHub links to it as closing |
| 80 | // it: the one that closed it, and any linked by a closing keyword or by hand. |
| 81 | // A mere mention is not a fix, so cross-references are not read. |
| 82 | function issueFixes(issue) { |
| 83 | const closer = issue.timelineItems?.nodes?.[0]?.closer; |
| 84 | const candidates = [...(issue.closedByPullRequestsReferences?.nodes || [])]; |
| 85 | if (closer?.__typename === "PullRequest") candidates.push(closer); |
| 86 | const fixes = new Map(); |
| 87 | for (const pull of candidates) { |
| 88 | if (pull?.merged && !fixes.has(pull.number)) fixes.set(pull.number, { number: pull.number, ...person(pull.author) }); |
| 89 | } |
| 90 | return [...fixes.values()].sort((a, b) => a.number - b.number); |
| 91 | } |
| 92 | |
| 93 | const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); |
| 94 | |
| 95 | // A 5xx, a 429, a 403 that carries rate-limit headers, a GraphQL RATE_LIMITED |
| 96 | // error or a dropped connection is transient; anything else is final. |
| 97 | function retryDelay(response, payload, attempt) { |
| 98 | const backoff = 1000 * 2 ** attempt; |
| 99 | if (!response) return backoff; |
| 100 | const header = (name) => response.headers?.get?.(name) ?? null; |
| 101 | const after = header("retry-after") === null ? null : Number(header("retry-after")); |
| 102 | const limited = |
| 103 | response.status === 429 || |
| 104 | (response.status === 403 && (after !== null || header("x-ratelimit-remaining") === "0")) || |
| 105 | payload?.errors?.some((error) => error.type === "RATE_LIMITED"); |
| 106 | if (!limited && response.status < 500) return null; |
| 107 | return after > 0 ? Math.min(after * 1000, 60_000) : backoff; |
| 108 | } |
| 109 | |
| 110 | // Returns an async lookup: number -> { kind: "pull", login, bot } | |
| 111 | // { kind: "issue", fixes: [{ number, login, bot }] } | { kind: "discussion" } | |
| 112 | // { kind: "missing" }. |
| 113 | export function githubRefLookup({ |
| 114 | repository = defaultRepository, |
| 115 | token, |
| 116 | fetchImpl = fetch, |
| 117 | sleep = defaultSleep, |
| 118 | attempts = 4, |
| 119 | } = {}) { |
| 120 | const [owner, name] = repository.split("/"); |
| 121 | const headers = { "Content-Type": "application/json", "User-Agent": "reasonix-release-credits" }; |
| 122 | if (token) headers.Authorization = `Bearer ${token}`; |
| 123 | const body = (ref) => JSON.stringify({ query: refQuery, variables: { owner, name, number: ref } }); |
| 124 | |
| 125 | async function query(ref) { |
| 126 | for (let attempt = 0; ; attempt += 1) { |
| 127 | let response = null; |
| 128 | let payload = null; |
| 129 | let failure; |
| 130 | try { |
| 131 | response = await fetchImpl("https://api.github.com/graphql", { method: "POST", headers, body: body(ref) }); |
| 132 | if (response.ok) payload = await response.json(); |
| 133 | if (response.ok && !payload.errors?.some((error) => error.type === "RATE_LIMITED")) return payload; |
| 134 | const why = response.ok ? "GraphQL RATE_LIMITED" : `status ${response.status}`; |
| 135 | failure = new CreditError(CreditErrorCode.status, `#${ref}: GitHub API answered ${why}`, { ref }); |
| 136 | } catch (error) { |
| 137 | if (error instanceof CreditError) throw error; |
| 138 | response = null; |
| 139 | failure = new CreditError(CreditErrorCode.unreachable, `#${ref}: GitHub API unreachable (${error.message})`, { ref }); |
| 140 | } |
| 141 | const delay = retryDelay(response, payload, attempt); |
| 142 | if (delay === null || attempt + 1 >= attempts) throw failure; |
| 143 | await sleep(delay); |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | return async (ref) => { |
| 148 | const payload = await query(ref); |
| 149 | const repositoryNode = payload.data?.repository; |
| 150 | const node = repositoryNode?.issueOrPullRequest; |
| 151 | if (node?.__typename === "PullRequest") return { kind: "pull", ...person(node.author) }; |
| 152 | if (node?.__typename === "Issue") return { kind: "issue", fixes: issueFixes(node) }; |
| 153 | const errors = payload.errors || []; |
| 154 | if (repositoryNode && errors.every((error) => error.type === "NOT_FOUND")) { |
| 155 | return { kind: repositoryNode.discussion ? "discussion" : "missing" }; |
| 156 | } |
| 157 | const types = errors.map((error) => error.type || "unknown").join(", ") || "empty response"; |
| 158 | throw new CreditError(CreditErrorCode.status, `#${ref}: GitHub GraphQL returned no node (${types})`, { ref }); |
| 159 | }; |
| 160 | } |
| 161 | |
| 162 | // Looks each distinct ref up once. Failures are collected, not thrown, so the |
| 163 | // caller can still render every ref it has and then fail with all of them. A |
| 164 | // discussion or a number that names nothing is a warning, not a failure. |
| 165 | export async function resolveCredits(refs, lookup, { concurrency = 6 } = {}) { |
| 166 | const distinct = [...new Set(refs)]; |
| 167 | const outcomes = new Array(distinct.length); |
| 168 | let next = 0; |
| 169 | async function worker() { |
| 170 | while (next < distinct.length) { |
| 171 | const index = next++; |
| 172 | try { |
| 173 | outcomes[index] = { credit: await lookup(distinct[index]) }; |
| 174 | } catch (error) { |
| 175 | if (!(error instanceof CreditError)) throw error; |
| 176 | outcomes[index] = { failure: { ref: distinct[index], code: error.code, message: error.message } }; |
| 177 | } |
| 178 | } |
| 179 | } |
| 180 | await Promise.all(Array.from({ length: Math.min(concurrency, distinct.length) }, worker)); |
| 181 | const credits = new Map(); |
| 182 | const failures = []; |
| 183 | const warnings = []; |
| 184 | distinct.forEach((ref, index) => { |
| 185 | const { credit, failure } = outcomes[index]; |
| 186 | if (failure) return failures.push(failure); |
| 187 | credits.set(ref, credit); |
| 188 | if (credit.kind === "discussion") { |
| 189 | warnings.push({ ref, code: CreditErrorCode.discussion, message: `#${ref} is a discussion; left uncredited` }); |
| 190 | } else if (credit.kind === "missing") { |
| 191 | warnings.push({ ref, code: CreditErrorCode.notFound, message: `#${ref} names no issue, pull request or discussion; left uncredited` }); |
| 192 | } |
| 193 | }); |
| 194 | return { credits, failures, warnings }; |
| 195 | } |
| 196 | |
| 197 | export function reportWarnings(warnings, { env = process.env, write = (line) => console.error(line) } = {}) { |
| 198 | const prefix = env.GITHUB_ACTIONS === "true" ? "::warning::" : "warning: "; |
| 199 | for (const warning of warnings) write(`${prefix}${warning.code} ${warning.message}`); |
| 200 | } |
| 201 | |
| 202 | export function unresolvedError(failures) { |
| 203 | const lines = failures.map((failure) => ` ${failure.code} ${failure.message}`); |
| 204 | return new CreditError( |
| 205 | CreditErrorCode.unresolved, |
| 206 | `${failures.length} reference(s) could not be credited:\n${lines.join("\n")}`, |
| 207 | { failures }, |
| 208 | ); |
| 209 | } |
| 210 | |
| 211 | function humanLogin(person) { |
| 212 | return person.login && !person.bot ? person.login : null; |
| 213 | } |
| 214 | |
| 215 | export function creditedLogins(credit) { |
| 216 | if (credit?.kind === "pull") return [humanLogin(credit)].filter(Boolean); |
| 217 | if (credit?.kind === "issue") return credit.fixes.map(humanLogin).filter(Boolean); |
| 218 | return []; |
| 219 | } |
| 220 | |
| 221 | // " by @a" for a pull request, " fixed in #2 by @a and #3" for an issue. |
| 222 | export function creditSuffix(credit) { |
| 223 | const by = (person) => (humanLogin(person) ? ` by @${person.login}` : ""); |
| 224 | if (credit?.kind === "pull") return by(credit); |
| 225 | if (credit?.kind !== "issue" || !credit.fixes.length) return ""; |
| 226 | return ` fixed in ${credit.fixes.map((fix) => `#${fix.number}${by(fix)}`).join(" and ")}`; |
| 227 | } |
| 228 | |
| 229 | // Unique human logins in the order their refs first appear. |
| 230 | export function contributorLogins(refs, credits) { |
| 231 | const logins = []; |
| 232 | const seen = new Set(); |
| 233 | for (const ref of refs) { |
| 234 | for (const login of creditedLogins(credits.get(ref))) { |
| 235 | if (isBotAccount(login) || seen.has(login.toLowerCase())) continue; |
| 236 | seen.add(login.toLowerCase()); |
| 237 | logins.push(login); |
| 238 | } |
| 239 | } |
| 240 | return logins; |
| 241 | } |
| 242 |