mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
ci(release-studio): credit pull request authors and issue fixers in published notes
The publish job now renders release-notes/studio/<semver>.md before it
signs anything. A #N that is a pull request gains "by @author"; a #N that
is an issue gains "fixed in #M by @author" for each merged pull request
GitHub links as closing it (the ClosedEvent closer and
closedByPullRequestsReferences, read over GraphQL; mentions are not
fixes). An issue fixed by a direct push stays plain. A 贡献者 list of the
credited humans, in first-appearance order, closes the notes. Bot authors
are never credited; the source file is not rewritten.
scripts/markdown-refs.mjs decides what is a reference: #N at a line start
or after whitespace, "(", "(" or a list separator, outside code spans,
backtick and tilde fences (each closed only by its own kind), indented
code, HTML comments and link targets. A number that names a discussion or
nothing stays plain with a warning instead of failing the release.
Lookups retry with backoff on 5xx, rate limits and dropped connections. A
failure that persists, or an authorisation failure, fails the job with a
release_credits.* code before signing. The publish job gains
issues: read and pull-requests: read.
CI gains a release-notes-scripts job, gated on scripts/ and
release-notes/studio/, that runs the three test files with contents: read.
scripts/release-credits.mjs is the same module the 1.x renderer uses.
This commit is contained in:
1 parent
011c2738f8
commit
11920eb387
9 files changed
+799
-7
No files matched your search
@@ -0,0 +1,89 @@
|
||||
// Finds the #N references in Markdown prose. A number counts only where a
|
||||
// reference is written: at the start of a line, or after whitespace, "(", "("
|
||||
// or a list separator ("、", ",", ","). Fenced and indented code, code spans,
|
||||
// HTML comments, link targets ("](#N") and escaped "\#N" are never references.
|
||||
|
||||
const refPattern = /(?<=^|[\s(、,,]|(?<!\])\()#(\d+)(?![\w-])/g;
|
||||
const codeSpanPattern = /(`+)[\s\S]*?\1/g;
|
||||
const listItemPattern = /^ {0,3}(?:[-*+]|\d+[.)])(?:\s|$)/;
|
||||
const masked = "\u0001";
|
||||
|
||||
function mask(text, start, end) {
|
||||
return text.slice(0, start) + masked.repeat(end - start) + text.slice(end);
|
||||
}
|
||||
|
||||
// Returns, per line, the text with every non-prose span replaced by a
|
||||
// same-length run of a non-space placeholder, so match offsets still index the
|
||||
// original line and a placeholder never reads as the whitespace a ref needs.
|
||||
function proseLines(markdown) {
|
||||
const lines = markdown.split("\n");
|
||||
let fence = null;
|
||||
let comment = false;
|
||||
let previousBlank = true;
|
||||
let previousCode = false;
|
||||
let inList = false;
|
||||
return lines.map((line) => {
|
||||
const blank = line.trim() === "";
|
||||
const opening = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
||||
if (fence) {
|
||||
const closing = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line);
|
||||
if (closing && closing[1][0] === fence[0] && closing[1].length >= fence.length) fence = null;
|
||||
previousBlank = false;
|
||||
return masked.repeat(line.length);
|
||||
}
|
||||
if (opening && !comment) {
|
||||
fence = opening[1];
|
||||
previousBlank = false;
|
||||
return masked.repeat(line.length);
|
||||
}
|
||||
const indented = /^( {4}|\t)/.test(line);
|
||||
if (!comment && indented && !inList && (previousBlank || previousCode)) {
|
||||
previousCode = true;
|
||||
previousBlank = false;
|
||||
return masked.repeat(line.length);
|
||||
}
|
||||
previousCode = false;
|
||||
if (listItemPattern.test(line)) inList = true;
|
||||
else if (!blank && !indented) inList = false;
|
||||
previousBlank = blank;
|
||||
|
||||
let text = line;
|
||||
for (const match of text.matchAll(codeSpanPattern)) {
|
||||
if (!comment) text = mask(text, match.index, match.index + match[0].length);
|
||||
}
|
||||
let from = 0;
|
||||
while (from <= text.length) {
|
||||
if (comment) {
|
||||
const end = text.indexOf("-->", from);
|
||||
if (end === -1) return mask(text, from, text.length);
|
||||
text = mask(text, from, end + 3);
|
||||
from = end + 3;
|
||||
comment = false;
|
||||
}
|
||||
const start = text.indexOf("<!--", from);
|
||||
if (start === -1) break;
|
||||
comment = true;
|
||||
from = start;
|
||||
}
|
||||
return text;
|
||||
});
|
||||
}
|
||||
|
||||
export function markdownRefs(markdown) {
|
||||
return proseLines(markdown).flatMap((prose) => [...prose.matchAll(refPattern)].map((match) => Number(match[1])));
|
||||
}
|
||||
|
||||
// Appends suffixFor(N) after each reference; everything else is left byte for byte.
|
||||
export function annotateMarkdown(markdown, suffixFor) {
|
||||
const source = markdown.split("\n");
|
||||
return proseLines(markdown)
|
||||
.map((prose, index) => {
|
||||
let line = source[index];
|
||||
for (const match of [...prose.matchAll(refPattern)].reverse()) {
|
||||
const end = match.index + match[0].length;
|
||||
line = line.slice(0, end) + suffixFor(Number(match[1])) + line.slice(end);
|
||||
}
|
||||
return line;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { test } from "node:test";
|
||||
import { annotateMarkdown, markdownRefs } from "./markdown-refs.mjs";
|
||||
|
||||
const tag = (ref) => `<${ref}>`;
|
||||
|
||||
test("a reference is written at line start or after whitespace, a parenthesis or a list separator", () => {
|
||||
const source = ["#1 修复", "- 见 #2", "- 修复(#3、#4)与 (#5, #6),#7", "- 列表(#8,#9)"].join("\n");
|
||||
assert.deepEqual(markdownRefs(source), [1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
||||
assert.equal(
|
||||
annotateMarkdown(source, tag),
|
||||
["#1<1> 修复", "- 见 #2<2>", "- 修复(#3<3>、#4<4>)与 (#5<5>, #6<6>),#7<7>", "- 列表(#8<8>,#9<9>)"].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
test("colours, entities, paths, anchors and escapes are not references", () => {
|
||||
const source = [
|
||||
"- 颜色#333 与 color:#123456 与 #333abc 与 #12-a",
|
||||
"- 与 a/#11 与 \\#12 与 [段落](#13) 与 [见](https://x/#14)",
|
||||
].join("\n");
|
||||
assert.deepEqual(markdownRefs(source), []);
|
||||
assert.equal(annotateMarkdown(source, tag), source);
|
||||
});
|
||||
|
||||
test("code spans, fences of either kind and indented code are skipped", () => {
|
||||
const source = [
|
||||
"- `#1` 与 ``a `#2` b`` 之外 #3",
|
||||
"```",
|
||||
"#4",
|
||||
"~~~",
|
||||
"#5",
|
||||
"```",
|
||||
"#6",
|
||||
"~~~~",
|
||||
"#7",
|
||||
"```",
|
||||
"#8",
|
||||
"~~~~~",
|
||||
"#9",
|
||||
"",
|
||||
" #10 indented code",
|
||||
" #11 still code",
|
||||
"#12",
|
||||
].join("\n");
|
||||
assert.deepEqual(markdownRefs(source), [3, 6, 9, 12]);
|
||||
});
|
||||
|
||||
test("indentation inside a list item is a continuation, not code", () => {
|
||||
const source = ["- 第一项", "", " 续行提到 #1", "", "段落", "", " #2 is code"].join("\n");
|
||||
assert.deepEqual(markdownRefs(source), [1]);
|
||||
});
|
||||
|
||||
test("HTML comments hide references on one line or across several", () => {
|
||||
const source = ["- 可见 #1 <!-- 隐藏 #2 --> 可见 #3", "<!--", "#4", "隐藏 #5 -->", "#6 <!-- #7"].join("\n");
|
||||
assert.deepEqual(markdownRefs(source), [1, 3, 6]);
|
||||
});
|
||||
|
||||
test("annotating leaves every other byte in place", async () => {
|
||||
const dir = new URL("../release-notes/studio/", import.meta.url);
|
||||
for (const name of await readdir(dir)) {
|
||||
const source = await readFile(new URL(name, dir), "utf8");
|
||||
assert.equal(annotateMarkdown(source, () => ""), source, name);
|
||||
assert.equal(annotateMarkdown(source, tag).replace(/<\d+>/g, ""), source, name);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
// Resolves the #N references in release notes to the people they credit: a
|
||||
// pull request's author, or for an issue the authors of the merged pull
|
||||
// requests that closed it. A number that names a discussion or nothing stays
|
||||
// plain with a warning; a lookup GitHub could not answer fails by code.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
export const defaultRepository = "esengine/DeepSeek-Reasonix";
|
||||
|
||||
export const CreditErrorCode = Object.freeze({
|
||||
noToken: "release_credits.no_token",
|
||||
unreachable: "release_credits.api_unreachable",
|
||||
status: "release_credits.api_status",
|
||||
notFound: "release_credits.ref_not_found",
|
||||
discussion: "release_credits.ref_is_discussion",
|
||||
unresolved: "release_credits.unresolved",
|
||||
});
|
||||
|
||||
export class CreditError extends Error {
|
||||
constructor(code, message, { ref, failures } = {}) {
|
||||
super(message);
|
||||
this.name = "CreditError";
|
||||
this.code = code;
|
||||
if (ref !== undefined) this.ref = ref;
|
||||
if (failures !== undefined) this.failures = failures;
|
||||
}
|
||||
}
|
||||
|
||||
const botLogins = new Set(["github-actions", "dependabot", "dependabot-preview"]);
|
||||
|
||||
export function isBotAccount(login, type) {
|
||||
if (type === "Bot") return true;
|
||||
const name = String(login).toLowerCase();
|
||||
return name.endsWith("[bot]") || botLogins.has(name);
|
||||
}
|
||||
|
||||
export function tokenFromEnvironment(env = process.env, runGh = defaultRunGh) {
|
||||
const token = env.GH_TOKEN || env.GITHUB_TOKEN;
|
||||
if (token) return token;
|
||||
try {
|
||||
const fromGh = runGh().trim();
|
||||
if (fromGh) return fromGh;
|
||||
} catch {
|
||||
// gh missing or signed out; reported below as no_token.
|
||||
}
|
||||
throw new CreditError(
|
||||
CreditErrorCode.noToken,
|
||||
"no GitHub token: set GH_TOKEN (in CI: ${{ github.token }}), or sign in with `gh auth login`",
|
||||
);
|
||||
}
|
||||
|
||||
function defaultRunGh() {
|
||||
return execFileSync("gh", ["auth", "token"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
||||
}
|
||||
|
||||
const refQuery = `query($owner: String!, $name: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
discussion(number: $number) { number }
|
||||
issueOrPullRequest(number: $number) {
|
||||
__typename
|
||||
... on PullRequest { author { login __typename } }
|
||||
... on Issue {
|
||||
closedByPullRequestsReferences(first: 20, includeClosedPrs: true) {
|
||||
nodes { number merged author { login __typename } }
|
||||
}
|
||||
timelineItems(itemTypes: [CLOSED_EVENT], last: 1) {
|
||||
nodes { ... on ClosedEvent { closer { __typename ... on PullRequest { number merged author { login __typename } } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
function person(author) {
|
||||
if (!author) return { login: null, bot: true };
|
||||
return { login: author.login, bot: isBotAccount(author.login, author.__typename) };
|
||||
}
|
||||
|
||||
// An issue's fixers are the merged pull requests GitHub links to it as closing
|
||||
// it: the one that closed it, and any linked by a closing keyword or by hand.
|
||||
// A mere mention is not a fix, so cross-references are not read.
|
||||
function issueFixes(issue) {
|
||||
const closer = issue.timelineItems?.nodes?.[0]?.closer;
|
||||
const candidates = [...(issue.closedByPullRequestsReferences?.nodes || [])];
|
||||
if (closer?.__typename === "PullRequest") candidates.push(closer);
|
||||
const fixes = new Map();
|
||||
for (const pull of candidates) {
|
||||
if (pull?.merged && !fixes.has(pull.number)) fixes.set(pull.number, { number: pull.number, ...person(pull.author) });
|
||||
}
|
||||
return [...fixes.values()].sort((a, b) => a.number - b.number);
|
||||
}
|
||||
|
||||
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// A 5xx, a 429, a 403 that carries rate-limit headers, a GraphQL RATE_LIMITED
|
||||
// error or a dropped connection is transient; anything else is final.
|
||||
function retryDelay(response, payload, attempt) {
|
||||
const backoff = 1000 * 2 ** attempt;
|
||||
if (!response) return backoff;
|
||||
const header = (name) => response.headers?.get?.(name) ?? null;
|
||||
const after = header("retry-after") === null ? null : Number(header("retry-after"));
|
||||
const limited =
|
||||
response.status === 429 ||
|
||||
(response.status === 403 && (after !== null || header("x-ratelimit-remaining") === "0")) ||
|
||||
payload?.errors?.some((error) => error.type === "RATE_LIMITED");
|
||||
if (!limited && response.status < 500) return null;
|
||||
return after > 0 ? Math.min(after * 1000, 60_000) : backoff;
|
||||
}
|
||||
|
||||
// Returns an async lookup: number -> { kind: "pull", login, bot } |
|
||||
// { kind: "issue", fixes: [{ number, login, bot }] } | { kind: "discussion" } |
|
||||
// { kind: "missing" }.
|
||||
export function githubRefLookup({
|
||||
repository = defaultRepository,
|
||||
token,
|
||||
fetchImpl = fetch,
|
||||
sleep = defaultSleep,
|
||||
attempts = 4,
|
||||
} = {}) {
|
||||
const [owner, name] = repository.split("/");
|
||||
const headers = { "Content-Type": "application/json", "User-Agent": "reasonix-release-credits" };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const body = (ref) => JSON.stringify({ query: refQuery, variables: { owner, name, number: ref } });
|
||||
|
||||
async function query(ref) {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
let response = null;
|
||||
let payload = null;
|
||||
let failure;
|
||||
try {
|
||||
response = await fetchImpl("https://api.github.com/graphql", { method: "POST", headers, body: body(ref) });
|
||||
if (response.ok) payload = await response.json();
|
||||
if (response.ok && !payload.errors?.some((error) => error.type === "RATE_LIMITED")) return payload;
|
||||
const why = response.ok ? "GraphQL RATE_LIMITED" : `status ${response.status}`;
|
||||
failure = new CreditError(CreditErrorCode.status, `#${ref}: GitHub API answered ${why}`, { ref });
|
||||
} catch (error) {
|
||||
if (error instanceof CreditError) throw error;
|
||||
response = null;
|
||||
failure = new CreditError(CreditErrorCode.unreachable, `#${ref}: GitHub API unreachable (${error.message})`, { ref });
|
||||
}
|
||||
const delay = retryDelay(response, payload, attempt);
|
||||
if (delay === null || attempt + 1 >= attempts) throw failure;
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
|
||||
return async (ref) => {
|
||||
const payload = await query(ref);
|
||||
const repositoryNode = payload.data?.repository;
|
||||
const node = repositoryNode?.issueOrPullRequest;
|
||||
if (node?.__typename === "PullRequest") return { kind: "pull", ...person(node.author) };
|
||||
if (node?.__typename === "Issue") return { kind: "issue", fixes: issueFixes(node) };
|
||||
const errors = payload.errors || [];
|
||||
if (repositoryNode && errors.every((error) => error.type === "NOT_FOUND")) {
|
||||
return { kind: repositoryNode.discussion ? "discussion" : "missing" };
|
||||
}
|
||||
const types = errors.map((error) => error.type || "unknown").join(", ") || "empty response";
|
||||
throw new CreditError(CreditErrorCode.status, `#${ref}: GitHub GraphQL returned no node (${types})`, { ref });
|
||||
};
|
||||
}
|
||||
|
||||
// Looks each distinct ref up once. Failures are collected, not thrown, so the
|
||||
// caller can still render every ref it has and then fail with all of them. A
|
||||
// discussion or a number that names nothing is a warning, not a failure.
|
||||
export async function resolveCredits(refs, lookup, { concurrency = 6 } = {}) {
|
||||
const distinct = [...new Set(refs)];
|
||||
const outcomes = new Array(distinct.length);
|
||||
let next = 0;
|
||||
async function worker() {
|
||||
while (next < distinct.length) {
|
||||
const index = next++;
|
||||
try {
|
||||
outcomes[index] = { credit: await lookup(distinct[index]) };
|
||||
} catch (error) {
|
||||
if (!(error instanceof CreditError)) throw error;
|
||||
outcomes[index] = { failure: { ref: distinct[index], code: error.code, message: error.message } };
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, distinct.length) }, worker));
|
||||
const credits = new Map();
|
||||
const failures = [];
|
||||
const warnings = [];
|
||||
distinct.forEach((ref, index) => {
|
||||
const { credit, failure } = outcomes[index];
|
||||
if (failure) return failures.push(failure);
|
||||
credits.set(ref, credit);
|
||||
if (credit.kind === "discussion") {
|
||||
warnings.push({ ref, code: CreditErrorCode.discussion, message: `#${ref} is a discussion; left uncredited` });
|
||||
} else if (credit.kind === "missing") {
|
||||
warnings.push({ ref, code: CreditErrorCode.notFound, message: `#${ref} names no issue, pull request or discussion; left uncredited` });
|
||||
}
|
||||
});
|
||||
return { credits, failures, warnings };
|
||||
}
|
||||
|
||||
export function reportWarnings(warnings, { env = process.env, write = (line) => console.error(line) } = {}) {
|
||||
const prefix = env.GITHUB_ACTIONS === "true" ? "::warning::" : "warning: ";
|
||||
for (const warning of warnings) write(`${prefix}${warning.code} ${warning.message}`);
|
||||
}
|
||||
|
||||
export function unresolvedError(failures) {
|
||||
const lines = failures.map((failure) => ` ${failure.code} ${failure.message}`);
|
||||
return new CreditError(
|
||||
CreditErrorCode.unresolved,
|
||||
`${failures.length} reference(s) could not be credited:\n${lines.join("\n")}`,
|
||||
{ failures },
|
||||
);
|
||||
}
|
||||
|
||||
function humanLogin(person) {
|
||||
return person.login && !person.bot ? person.login : null;
|
||||
}
|
||||
|
||||
export function creditedLogins(credit) {
|
||||
if (credit?.kind === "pull") return [humanLogin(credit)].filter(Boolean);
|
||||
if (credit?.kind === "issue") return credit.fixes.map(humanLogin).filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
||||
// " by @a" for a pull request, " fixed in #2 by @a and #3" for an issue.
|
||||
export function creditSuffix(credit) {
|
||||
const by = (person) => (humanLogin(person) ? ` by @${person.login}` : "");
|
||||
if (credit?.kind === "pull") return by(credit);
|
||||
if (credit?.kind !== "issue" || !credit.fixes.length) return "";
|
||||
return ` fixed in ${credit.fixes.map((fix) => `#${fix.number}${by(fix)}`).join(" and ")}`;
|
||||
}
|
||||
|
||||
// Unique human logins in the order their refs first appear.
|
||||
export function contributorLogins(refs, credits) {
|
||||
const logins = [];
|
||||
const seen = new Set();
|
||||
for (const ref of refs) {
|
||||
for (const login of creditedLogins(credits.get(ref))) {
|
||||
if (isBotAccount(login) || seen.has(login.toLowerCase())) continue;
|
||||
seen.add(login.toLowerCase());
|
||||
logins.push(login);
|
||||
}
|
||||
}
|
||||
return logins;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
contributorLogins,
|
||||
CreditError,
|
||||
CreditErrorCode,
|
||||
creditSuffix,
|
||||
githubRefLookup,
|
||||
isBotAccount,
|
||||
reportWarnings,
|
||||
resolveCredits,
|
||||
tokenFromEnvironment,
|
||||
unresolvedError,
|
||||
} from "./release-credits.mjs";
|
||||
|
||||
function jsonResponse(status, body, headers = {}) {
|
||||
return {
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
headers: { get: (name) => headers[name] ?? null },
|
||||
json: async () => body,
|
||||
};
|
||||
}
|
||||
|
||||
// routes: number -> node | "discussion" | Error | [status, body, headers] |
|
||||
// an array of those, answered in turn; a missing number is NOT_FOUND.
|
||||
function fakeGitHub(routes) {
|
||||
const calls = [];
|
||||
const fetchImpl = async (url, init) => {
|
||||
const { variables } = JSON.parse(init.body);
|
||||
calls.push({ url, init, variables });
|
||||
let route = routes[variables.number];
|
||||
if (route?.sequence) route = route.sequence.shift();
|
||||
if (route instanceof Error) throw route;
|
||||
if (Array.isArray(route)) return jsonResponse(...route);
|
||||
if (route && route !== "discussion") {
|
||||
return jsonResponse(200, { data: { repository: { discussion: null, issueOrPullRequest: route } } });
|
||||
}
|
||||
const errors = [{ type: "NOT_FOUND", message: "Could not resolve" }];
|
||||
if (!route) errors.push({ type: "NOT_FOUND", message: "Could not resolve" });
|
||||
return jsonResponse(200, {
|
||||
data: { repository: { discussion: route ? { number: variables.number } : null, issueOrPullRequest: null } },
|
||||
errors,
|
||||
});
|
||||
};
|
||||
return { calls, fetchImpl };
|
||||
}
|
||||
|
||||
const noSleep = { sleep: async () => {} };
|
||||
|
||||
const user = (login, type = "User") => ({ login, __typename: type });
|
||||
const pull = (login, type) => ({ __typename: "PullRequest", author: user(login, type) });
|
||||
const linked = (number, login, merged = true, type) => ({ number, merged, author: user(login, type) });
|
||||
const issue = (references = [], closer = null) => ({
|
||||
__typename: "Issue",
|
||||
closedByPullRequestsReferences: { nodes: references },
|
||||
timelineItems: { nodes: closer ? [{ closer }] : [] },
|
||||
});
|
||||
|
||||
test("a pull request credits its author", async () => {
|
||||
const { calls, fetchImpl } = fakeGitHub({ 1: pull("alice") });
|
||||
const lookup = githubRefLookup({ repository: "o/r", token: "t", fetchImpl });
|
||||
assert.deepEqual(await lookup(1), { kind: "pull", login: "alice", bot: false });
|
||||
assert.equal(calls[0].url, "https://api.github.com/graphql");
|
||||
assert.equal(calls[0].init.headers.Authorization, "Bearer t");
|
||||
assert.deepEqual(calls[0].variables, { owner: "o", name: "r", number: 1 });
|
||||
});
|
||||
|
||||
test("an issue credits the merged pull requests that closed it, and nothing else", async () => {
|
||||
const { fetchImpl } = fakeGitHub({
|
||||
2: issue([linked(12, "bob"), linked(11, "carol", false)], { __typename: "PullRequest", ...linked(10, "alice") }),
|
||||
3: issue([], { __typename: "Commit" }),
|
||||
4: issue([linked(14, "bob"), linked(14, "bob")], { __typename: "PullRequest", ...linked(14, "bob") }),
|
||||
});
|
||||
const lookup = githubRefLookup({ repository: "o/r", fetchImpl });
|
||||
assert.deepEqual(await lookup(2), {
|
||||
kind: "issue",
|
||||
fixes: [
|
||||
{ number: 10, login: "alice", bot: false },
|
||||
{ number: 12, login: "bob", bot: false },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(await lookup(3), { kind: "issue", fixes: [] });
|
||||
assert.deepEqual(await lookup(4), { kind: "issue", fixes: [{ number: 14, login: "bob", bot: false }] });
|
||||
});
|
||||
|
||||
test("bot and deleted authors are never credited", async () => {
|
||||
const { fetchImpl } = fakeGitHub({
|
||||
1: pull("github-actions", "Bot"),
|
||||
2: pull("dependabot"),
|
||||
3: pull("renovate[bot]"),
|
||||
4: { __typename: "PullRequest", author: null },
|
||||
5: issue([linked(15, "dependabot[bot]")]),
|
||||
});
|
||||
const lookup = githubRefLookup({ repository: "o/r", fetchImpl });
|
||||
for (const ref of [1, 2, 3, 4]) assert.equal((await lookup(ref)).bot, true);
|
||||
const fixed = await lookup(5);
|
||||
assert.equal(fixed.fixes[0].bot, true);
|
||||
assert.equal(creditSuffix(fixed), " fixed in #15");
|
||||
assert.equal(isBotAccount("github-actions"), true);
|
||||
assert.equal(isBotAccount("esengine"), false);
|
||||
});
|
||||
|
||||
test("a discussion or a number naming nothing resolves, and warns instead of failing", async () => {
|
||||
const { fetchImpl } = fakeGitHub({ 8: "discussion", 1: pull("alice") });
|
||||
const lookup = githubRefLookup({ repository: "o/r", fetchImpl });
|
||||
assert.deepEqual(await lookup(8), { kind: "discussion" });
|
||||
assert.deepEqual(await lookup(9), { kind: "missing" });
|
||||
const { credits, failures, warnings } = await resolveCredits([8, 1, 9], lookup);
|
||||
assert.deepEqual(failures, []);
|
||||
assert.equal(creditSuffix(credits.get(8)), "");
|
||||
assert.deepEqual(
|
||||
warnings.map((warning) => [warning.ref, warning.code]),
|
||||
[
|
||||
[8, CreditErrorCode.discussion],
|
||||
[9, CreditErrorCode.notFound],
|
||||
],
|
||||
);
|
||||
const lines = [];
|
||||
reportWarnings(warnings, { env: { GITHUB_ACTIONS: "true" }, write: (line) => lines.push(line) });
|
||||
assert.ok(lines.every((line) => line.startsWith("::warning::release_credits.")));
|
||||
});
|
||||
|
||||
test("transient failures are retried with backoff; the last one is reported by code", async () => {
|
||||
const slept = [];
|
||||
const sleep = async (ms) => slept.push(ms);
|
||||
const { calls, fetchImpl } = fakeGitHub({
|
||||
1: { sequence: [[502, {}], new TypeError("socket hang up"), [429, {}, { "retry-after": "7" }], pull("alice")] },
|
||||
2: [503, {}],
|
||||
3: new TypeError("fetch failed"),
|
||||
4: { sequence: [[200, { data: null, errors: [{ type: "RATE_LIMITED" }] }], pull("bob")] },
|
||||
5: [403, {}, { "x-ratelimit-remaining": "0" }],
|
||||
});
|
||||
const lookup = githubRefLookup({ repository: "o/r", fetchImpl, sleep });
|
||||
assert.equal((await lookup(1)).login, "alice");
|
||||
assert.deepEqual(slept, [1000, 2000, 7000]);
|
||||
await assert.rejects(lookup(2), { code: CreditErrorCode.status, ref: 2 });
|
||||
await assert.rejects(lookup(3), { code: CreditErrorCode.unreachable, ref: 3 });
|
||||
assert.equal((await lookup(4)).login, "bob");
|
||||
await assert.rejects(lookup(5), { code: CreditErrorCode.status, ref: 5 });
|
||||
const tries = (ref) => calls.filter((call) => call.variables.number === ref).length;
|
||||
assert.deepEqual([1, 2, 3, 4, 5].map(tries), [4, 4, 4, 2, 4]);
|
||||
});
|
||||
|
||||
test("an authorisation failure is final and never retried", async () => {
|
||||
const { calls, fetchImpl } = fakeGitHub({ 1: [401, {}], 2: [403, {}], 3: [200, { data: null, errors: [{ type: "FORBIDDEN" }] }] });
|
||||
const lookup = githubRefLookup({ repository: "o/r", fetchImpl, ...noSleep });
|
||||
await assert.rejects(lookup(1), { code: CreditErrorCode.status });
|
||||
await assert.rejects(lookup(2), { code: CreditErrorCode.status });
|
||||
await assert.rejects(lookup(3), { code: CreditErrorCode.status });
|
||||
assert.equal(calls.length, 3);
|
||||
});
|
||||
|
||||
test("suffixes use one wording for pull requests and fixed issues", () => {
|
||||
assert.equal(creditSuffix({ kind: "pull", login: "alice", bot: false }), " by @alice");
|
||||
assert.equal(creditSuffix({ kind: "pull", login: "x[bot]", bot: true }), "");
|
||||
assert.equal(creditSuffix({ kind: "issue", fixes: [] }), "");
|
||||
assert.equal(
|
||||
creditSuffix({
|
||||
kind: "issue",
|
||||
fixes: [
|
||||
{ number: 2, login: "alice", bot: false },
|
||||
{ number: 3, login: "bob", bot: false },
|
||||
],
|
||||
}),
|
||||
" fixed in #2 by @alice and #3 by @bob",
|
||||
);
|
||||
});
|
||||
|
||||
test("each distinct ref is looked up once and failures are collected in ref order", async () => {
|
||||
const seen = [];
|
||||
const lookup = async (ref) => {
|
||||
seen.push(ref);
|
||||
if (ref === 3 || ref === 1) throw new CreditError(CreditErrorCode.notFound, `#${ref}`, { ref });
|
||||
return { kind: "pull", login: `u${ref}`, bot: false };
|
||||
};
|
||||
const { credits, failures } = await resolveCredits([3, 2, 3, 1, 2], lookup, { concurrency: 2 });
|
||||
assert.deepEqual(seen.sort(), [1, 2, 3]);
|
||||
assert.deepEqual([...credits.keys()], [2]);
|
||||
assert.deepEqual(failures.map((failure) => failure.ref), [3, 1]);
|
||||
const error = unresolvedError(failures);
|
||||
assert.equal(error.code, CreditErrorCode.unresolved);
|
||||
assert.deepEqual(error.failures.map((failure) => failure.code), [CreditErrorCode.notFound, CreditErrorCode.notFound]);
|
||||
});
|
||||
|
||||
test("an unexpected lookup error is not swallowed", async () => {
|
||||
await assert.rejects(
|
||||
resolveCredits([1], async () => {
|
||||
throw new RangeError("bug");
|
||||
}),
|
||||
RangeError,
|
||||
);
|
||||
});
|
||||
|
||||
test("contributors are unique humans in first-appearance order, fixers included", () => {
|
||||
const credits = new Map([
|
||||
[1, { kind: "pull", login: "bob", bot: false }],
|
||||
[2, { kind: "pull", login: "alice", bot: false }],
|
||||
[3, { kind: "issue", fixes: [{ number: 30, login: "carol", bot: false }] }],
|
||||
[4, { kind: "pull", login: "github-actions[bot]", bot: true }],
|
||||
[5, { kind: "pull", login: "Bob", bot: false }],
|
||||
[6, { kind: "issue", fixes: [] }],
|
||||
]);
|
||||
assert.deepEqual(contributorLogins([3, 1, 4, 2, 1, 5, 6, 9], credits), ["carol", "bob", "alice"]);
|
||||
});
|
||||
|
||||
test("the token comes from the environment, then gh, else a coded error", () => {
|
||||
assert.equal(tokenFromEnvironment({ GH_TOKEN: "a", GITHUB_TOKEN: "b" }, () => "c"), "a");
|
||||
assert.equal(tokenFromEnvironment({ GITHUB_TOKEN: "b" }, () => "c"), "b");
|
||||
assert.equal(tokenFromEnvironment({}, () => "c\n"), "c");
|
||||
assert.throws(
|
||||
() =>
|
||||
tokenFromEnvironment({}, () => {
|
||||
throw new Error("gh: not logged in");
|
||||
}),
|
||||
{ code: CreditErrorCode.noToken },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env node
|
||||
// Renders release-notes/studio/<semver>.md for publication: each #N gains its
|
||||
// pull request author, or for an issue the pull requests that closed it, and a
|
||||
// 贡献者 list closes the notes. The source file is never rewritten.
|
||||
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { annotateMarkdown, markdownRefs } from "./markdown-refs.mjs";
|
||||
import {
|
||||
contributorLogins,
|
||||
creditSuffix,
|
||||
githubRefLookup,
|
||||
reportWarnings,
|
||||
resolveCredits,
|
||||
tokenFromEnvironment,
|
||||
unresolvedError,
|
||||
} from "./release-credits.mjs";
|
||||
|
||||
export function renderStudioNotes(markdown, credits) {
|
||||
const body = annotateMarkdown(markdown, (ref) => creditSuffix(credits.get(ref))).trimEnd();
|
||||
const contributors = contributorLogins(markdownRefs(markdown), credits);
|
||||
if (!contributors.length) return `${body}\n`;
|
||||
return `${body}\n\n## 贡献者\n\n感谢本版本的贡献者:${contributors.map((login) => `@${login}`).join("、")}\n`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [input, output] = process.argv.slice(2);
|
||||
if (!input || !output) throw new Error("usage: studio-release-notes.mjs <notes.md> <output.md>");
|
||||
const markdown = await readFile(input, "utf8");
|
||||
const refs = markdownRefs(markdown);
|
||||
let credits = new Map();
|
||||
let failures = [];
|
||||
if (refs.length) {
|
||||
const lookup = githubRefLookup({ token: tokenFromEnvironment() });
|
||||
let warnings;
|
||||
({ credits, failures, warnings } = await resolveCredits(refs, lookup));
|
||||
reportWarnings(warnings);
|
||||
}
|
||||
await writeFile(output, renderStudioNotes(markdown, credits));
|
||||
if (failures.length) throw unresolvedError(failures);
|
||||
console.log(`Rendered ${input} to ${output}: ${new Set(refs).size} reference(s), ${credits.size} resolved`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main().catch((error) => {
|
||||
console.error(error.code ? `${error.code}: ${error.message}` : error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { test } from "node:test";
|
||||
import { markdownRefs } from "./markdown-refs.mjs";
|
||||
import { CreditError, CreditErrorCode, resolveCredits } from "./release-credits.mjs";
|
||||
import { renderStudioNotes } from "./studio-release-notes.mjs";
|
||||
|
||||
const notes = `本版修复两件事。
|
||||
|
||||
## 修复
|
||||
|
||||
- 读图模型不再声称看不到图片(#20)(44150b0aa)
|
||||
- Windows 窗口可以打开 #21
|
||||
- 依赖升级 #22,并补上 #20 的回归测试
|
||||
- 会话不再丢失(#23)
|
||||
`;
|
||||
|
||||
const authors = {
|
||||
20: { kind: "pull", login: "alice", bot: false },
|
||||
21: { kind: "issue", fixes: [] },
|
||||
22: { kind: "pull", login: "dependabot[bot]", bot: true },
|
||||
23: { kind: "issue", fixes: [{ number: 30, login: "bob", bot: false }] },
|
||||
};
|
||||
|
||||
test("pull requests credit their authors, fixed issues their fixers, the rest stay plain", async () => {
|
||||
const calls = [];
|
||||
const { credits, failures } = await resolveCredits(markdownRefs(notes), async (ref) => {
|
||||
calls.push(ref);
|
||||
return authors[ref];
|
||||
});
|
||||
assert.deepEqual(failures, []);
|
||||
assert.deepEqual(calls.sort(), [20, 21, 22, 23]);
|
||||
assert.equal(
|
||||
renderStudioNotes(notes, credits),
|
||||
`本版修复两件事。
|
||||
|
||||
## 修复
|
||||
|
||||
- 读图模型不再声称看不到图片(#20 by @alice)(44150b0aa)
|
||||
- Windows 窗口可以打开 #21
|
||||
- 依赖升级 #22,并补上 #20 by @alice 的回归测试
|
||||
- 会话不再丢失(#23 fixed in #30 by @bob)
|
||||
|
||||
## 贡献者
|
||||
|
||||
感谢本版本的贡献者:@alice、@bob
|
||||
`,
|
||||
);
|
||||
});
|
||||
|
||||
test("an unresolved ref stays plain and is reported by code", async () => {
|
||||
const { credits, failures } = await resolveCredits(markdownRefs(notes), async (ref) => {
|
||||
if (ref === 20) throw new CreditError(CreditErrorCode.unreachable, "#20: down", { ref });
|
||||
return authors[ref];
|
||||
});
|
||||
assert.deepEqual(failures.map((failure) => [failure.ref, failure.code]), [[20, CreditErrorCode.unreachable]]);
|
||||
const rendered = renderStudioNotes(notes, credits);
|
||||
assert.doesNotMatch(rendered, /#20 by/);
|
||||
assert.match(rendered, /#23 fixed in #30 by @bob/);
|
||||
assert.match(rendered, /感谢本版本的贡献者:@bob\n$/);
|
||||
});
|
||||
|
||||
test("committed notes render unchanged when nothing is credited", async () => {
|
||||
const dir = new URL("../release-notes/studio/", import.meta.url);
|
||||
for (const name of await readdir(dir)) {
|
||||
const source = await readFile(new URL(name, dir), "utf8");
|
||||
assert.equal(renderStudioNotes(source, new Map()), `${source.trimEnd()}\n`, name);
|
||||
}
|
||||
});
|
||||
|
||||
test("a number naming a discussion or nothing stays plain and only warns", async () => {
|
||||
const source = "- 讨论见 #40,颜色改为 #123456\n";
|
||||
const { credits, failures, warnings } = await resolveCredits(markdownRefs(source), async (ref) =>
|
||||
ref === 40 ? { kind: "discussion" } : { kind: "missing" },
|
||||
);
|
||||
assert.deepEqual(failures, []);
|
||||
assert.deepEqual(
|
||||
warnings.map((warning) => warning.code),
|
||||
[CreditErrorCode.discussion, CreditErrorCode.notFound],
|
||||
);
|
||||
assert.equal(renderStudioNotes(source, credits), source);
|
||||
});
|
||||
Reference in new issue
Block a user