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:
esengine committed 2026-09-26 23:28:25 -07:00
1 parent 011c2738f8
commit 11920eb387
9 files changed
+799 -7

No files matched your search

+89
View File
@@ -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");
}
+66
View File
@@ -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",
"- &#10; 与 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);
}
});
+241
View File
@@ -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;
}
+218
View File
@@ -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 },
);
});
+50
View File
@@ -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;
});
}
+82
View File
@@ -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);
});