返回 DeepSeek-Reasonix
release-credits.mjs
根目录 / scripts / release-credits.mjs
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
242 lines Plain Text