返回 DeepSeek-Reasonix
release-credits.test.mjs
根目录 / scripts / release-credits.test.mjs
1 import assert from "node:assert/strict";
2 import { test } from "node:test";
3 import {
4 contributorLogins,
5 CreditError,
6 CreditErrorCode,
7 creditSuffix,
8 githubRefLookup,
9 isBotAccount,
10 reportWarnings,
11 resolveCredits,
12 tokenFromEnvironment,
13 unresolvedError,
14 } from "./release-credits.mjs";
15
16 function jsonResponse(status, body, headers = {}) {
17 return {
18 status,
19 ok: status >= 200 && status < 300,
20 headers: { get: (name) => headers[name] ?? null },
21 json: async () => body,
22 };
23 }
24
25 // routes: number -> node | "discussion" | Error | [status, body, headers] |
26 // an array of those, answered in turn; a missing number is NOT_FOUND.
27 function fakeGitHub(routes) {
28 const calls = [];
29 const fetchImpl = async (url, init) => {
30 const { variables } = JSON.parse(init.body);
31 calls.push({ url, init, variables });
32 let route = routes[variables.number];
33 if (route?.sequence) route = route.sequence.shift();
34 if (route instanceof Error) throw route;
35 if (Array.isArray(route)) return jsonResponse(...route);
36 if (route && route !== "discussion") {
37 return jsonResponse(200, { data: { repository: { discussion: null, issueOrPullRequest: route } } });
38 }
39 const errors = [{ type: "NOT_FOUND", message: "Could not resolve" }];
40 if (!route) errors.push({ type: "NOT_FOUND", message: "Could not resolve" });
41 return jsonResponse(200, {
42 data: { repository: { discussion: route ? { number: variables.number } : null, issueOrPullRequest: null } },
43 errors,
44 });
45 };
46 return { calls, fetchImpl };
47 }
48
49 const noSleep = { sleep: async () => {} };
50
51 const user = (login, type = "User") => ({ login, __typename: type });
52 const pull = (login, type) => ({ __typename: "PullRequest", author: user(login, type) });
53 const linked = (number, login, merged = true, type) => ({ number, merged, author: user(login, type) });
54 const issue = (references = [], closer = null) => ({
55 __typename: "Issue",
56 closedByPullRequestsReferences: { nodes: references },
57 timelineItems: { nodes: closer ? [{ closer }] : [] },
58 });
59
60 test("a pull request credits its author", async () => {
61 const { calls, fetchImpl } = fakeGitHub({ 1: pull("alice") });
62 const lookup = githubRefLookup({ repository: "o/r", token: "t", fetchImpl });
63 assert.deepEqual(await lookup(1), { kind: "pull", login: "alice", bot: false });
64 assert.equal(calls[0].url, "https://api.github.com/graphql");
65 assert.equal(calls[0].init.headers.Authorization, "Bearer t");
66 assert.deepEqual(calls[0].variables, { owner: "o", name: "r", number: 1 });
67 });
68
69 test("an issue credits the merged pull requests that closed it, and nothing else", async () => {
70 const { fetchImpl } = fakeGitHub({
71 2: issue([linked(12, "bob"), linked(11, "carol", false)], { __typename: "PullRequest", ...linked(10, "alice") }),
72 3: issue([], { __typename: "Commit" }),
73 4: issue([linked(14, "bob"), linked(14, "bob")], { __typename: "PullRequest", ...linked(14, "bob") }),
74 });
75 const lookup = githubRefLookup({ repository: "o/r", fetchImpl });
76 assert.deepEqual(await lookup(2), {
77 kind: "issue",
78 fixes: [
79 { number: 10, login: "alice", bot: false },
80 { number: 12, login: "bob", bot: false },
81 ],
82 });
83 assert.deepEqual(await lookup(3), { kind: "issue", fixes: [] });
84 assert.deepEqual(await lookup(4), { kind: "issue", fixes: [{ number: 14, login: "bob", bot: false }] });
85 });
86
87 test("bot and deleted authors are never credited", async () => {
88 const { fetchImpl } = fakeGitHub({
89 1: pull("github-actions", "Bot"),
90 2: pull("dependabot"),
91 3: pull("renovate[bot]"),
92 4: { __typename: "PullRequest", author: null },
93 5: issue([linked(15, "dependabot[bot]")]),
94 });
95 const lookup = githubRefLookup({ repository: "o/r", fetchImpl });
96 for (const ref of [1, 2, 3, 4]) assert.equal((await lookup(ref)).bot, true);
97 const fixed = await lookup(5);
98 assert.equal(fixed.fixes[0].bot, true);
99 assert.equal(creditSuffix(fixed), " fixed in #15");
100 assert.equal(isBotAccount("github-actions"), true);
101 assert.equal(isBotAccount("esengine"), false);
102 });
103
104 test("a discussion or a number naming nothing resolves, and warns instead of failing", async () => {
105 const { fetchImpl } = fakeGitHub({ 8: "discussion", 1: pull("alice") });
106 const lookup = githubRefLookup({ repository: "o/r", fetchImpl });
107 assert.deepEqual(await lookup(8), { kind: "discussion" });
108 assert.deepEqual(await lookup(9), { kind: "missing" });
109 const { credits, failures, warnings } = await resolveCredits([8, 1, 9], lookup);
110 assert.deepEqual(failures, []);
111 assert.equal(creditSuffix(credits.get(8)), "");
112 assert.deepEqual(
113 warnings.map((warning) => [warning.ref, warning.code]),
114 [
115 [8, CreditErrorCode.discussion],
116 [9, CreditErrorCode.notFound],
117 ],
118 );
119 const lines = [];
120 reportWarnings(warnings, { env: { GITHUB_ACTIONS: "true" }, write: (line) => lines.push(line) });
121 assert.ok(lines.every((line) => line.startsWith("::warning::release_credits.")));
122 });
123
124 test("transient failures are retried with backoff; the last one is reported by code", async () => {
125 const slept = [];
126 const sleep = async (ms) => slept.push(ms);
127 const { calls, fetchImpl } = fakeGitHub({
128 1: { sequence: [[502, {}], new TypeError("socket hang up"), [429, {}, { "retry-after": "7" }], pull("alice")] },
129 2: [503, {}],
130 3: new TypeError("fetch failed"),
131 4: { sequence: [[200, { data: null, errors: [{ type: "RATE_LIMITED" }] }], pull("bob")] },
132 5: [403, {}, { "x-ratelimit-remaining": "0" }],
133 });
134 const lookup = githubRefLookup({ repository: "o/r", fetchImpl, sleep });
135 assert.equal((await lookup(1)).login, "alice");
136 assert.deepEqual(slept, [1000, 2000, 7000]);
137 await assert.rejects(lookup(2), { code: CreditErrorCode.status, ref: 2 });
138 await assert.rejects(lookup(3), { code: CreditErrorCode.unreachable, ref: 3 });
139 assert.equal((await lookup(4)).login, "bob");
140 await assert.rejects(lookup(5), { code: CreditErrorCode.status, ref: 5 });
141 const tries = (ref) => calls.filter((call) => call.variables.number === ref).length;
142 assert.deepEqual([1, 2, 3, 4, 5].map(tries), [4, 4, 4, 2, 4]);
143 });
144
145 test("an authorisation failure is final and never retried", async () => {
146 const { calls, fetchImpl } = fakeGitHub({ 1: [401, {}], 2: [403, {}], 3: [200, { data: null, errors: [{ type: "FORBIDDEN" }] }] });
147 const lookup = githubRefLookup({ repository: "o/r", fetchImpl, ...noSleep });
148 await assert.rejects(lookup(1), { code: CreditErrorCode.status });
149 await assert.rejects(lookup(2), { code: CreditErrorCode.status });
150 await assert.rejects(lookup(3), { code: CreditErrorCode.status });
151 assert.equal(calls.length, 3);
152 });
153
154 test("suffixes use one wording for pull requests and fixed issues", () => {
155 assert.equal(creditSuffix({ kind: "pull", login: "alice", bot: false }), " by @alice");
156 assert.equal(creditSuffix({ kind: "pull", login: "x[bot]", bot: true }), "");
157 assert.equal(creditSuffix({ kind: "issue", fixes: [] }), "");
158 assert.equal(
159 creditSuffix({
160 kind: "issue",
161 fixes: [
162 { number: 2, login: "alice", bot: false },
163 { number: 3, login: "bob", bot: false },
164 ],
165 }),
166 " fixed in #2 by @alice and #3 by @bob",
167 );
168 });
169
170 test("each distinct ref is looked up once and failures are collected in ref order", async () => {
171 const seen = [];
172 const lookup = async (ref) => {
173 seen.push(ref);
174 if (ref === 3 || ref === 1) throw new CreditError(CreditErrorCode.notFound, `#${ref}`, { ref });
175 return { kind: "pull", login: `u${ref}`, bot: false };
176 };
177 const { credits, failures } = await resolveCredits([3, 2, 3, 1, 2], lookup, { concurrency: 2 });
178 assert.deepEqual(seen.sort(), [1, 2, 3]);
179 assert.deepEqual([...credits.keys()], [2]);
180 assert.deepEqual(failures.map((failure) => failure.ref), [3, 1]);
181 const error = unresolvedError(failures);
182 assert.equal(error.code, CreditErrorCode.unresolved);
183 assert.deepEqual(error.failures.map((failure) => failure.code), [CreditErrorCode.notFound, CreditErrorCode.notFound]);
184 });
185
186 test("an unexpected lookup error is not swallowed", async () => {
187 await assert.rejects(
188 resolveCredits([1], async () => {
189 throw new RangeError("bug");
190 }),
191 RangeError,
192 );
193 });
194
195 test("contributors are unique humans in first-appearance order, fixers included", () => {
196 const credits = new Map([
197 [1, { kind: "pull", login: "bob", bot: false }],
198 [2, { kind: "pull", login: "alice", bot: false }],
199 [3, { kind: "issue", fixes: [{ number: 30, login: "carol", bot: false }] }],
200 [4, { kind: "pull", login: "github-actions[bot]", bot: true }],
201 [5, { kind: "pull", login: "Bob", bot: false }],
202 [6, { kind: "issue", fixes: [] }],
203 ]);
204 assert.deepEqual(contributorLogins([3, 1, 4, 2, 1, 5, 6, 9], credits), ["carol", "bob", "alice"]);
205 });
206
207 test("the token comes from the environment, then gh, else a coded error", () => {
208 assert.equal(tokenFromEnvironment({ GH_TOKEN: "a", GITHUB_TOKEN: "b" }, () => "c"), "a");
209 assert.equal(tokenFromEnvironment({ GITHUB_TOKEN: "b" }, () => "c"), "b");
210 assert.equal(tokenFromEnvironment({}, () => "c\n"), "c");
211 assert.throws(
212 () =>
213 tokenFromEnvironment({}, () => {
214 throw new Error("gh: not logged in");
215 }),
216 { code: CreditErrorCode.noToken },
217 );
218 });
219
219 lines Plain Text