fix(release): never credit the repository owner in release notes

This commit is contained in:
esengine committed 2026-10-03 17:12:08 -07:00
1 parent 028179b37f
commit de789afafb
4 files changed
+50 -6

No files matched your search

+2
View File
@@ -145,6 +145,8 @@ The published body is rendered, not copied:
| `#N`, an issue fixed by a direct push; a commit; a bot author | as written |
| `#N`, a discussion or no such number | as written, with a warning |
The repository owner and any login in `RELEASE_CREDIT_EXCLUDE` (comma separated) are treated like bots: never named in an item or in the list.
A `## 贡献者` list of the credited authors closes the notes. A `#N` counts only at a line start or after whitespace, `(`, `(`, `、`, `,` or `,`, and never in code, an HTML comment or a link target. Write colours such as `#333` in a code span.
A lookup GitHub refuses, or cannot answer after retries, fails `publish` with a `release_credits.*` code. Preview with `node scripts/studio-release-notes.mjs release-notes/studio/X.Y.Z.md /tmp/notes.md`; it reads `GH_TOKEN`, else `gh auth token`.
+17 -6
View File
@@ -34,6 +34,14 @@ export function isBotAccount(login, type) {
return name.endsWith("[bot]") || botLogins.has(name);
}
// Accounts left uncredited like bots: the repository owner, plus the logins in
// RELEASE_CREDIT_EXCLUDE (comma separated). Compared case-insensitively.
export function creditExclusions(repository = defaultRepository, env = process.env) {
const extra = String(env.RELEASE_CREDIT_EXCLUDE || "").split(",");
const names = [repository.split("/")[0], ...extra].map((name) => name.trim().toLowerCase());
return new Set(names.filter(Boolean));
}
export function tokenFromEnvironment(env = process.env, runGh = defaultRunGh) {
const token = env.GH_TOKEN || env.GITHUB_TOKEN;
if (token) return token;
@@ -71,21 +79,23 @@ const refQuery = `query($owner: String!, $name: String!, $number: Int!) {
}
}`;
function person(author) {
// `bot` is true for every account that is never credited, excluded humans included.
function person(author, excluded) {
if (!author) return { login: null, bot: true };
return { login: author.login, bot: isBotAccount(author.login, author.__typename) };
const bot = isBotAccount(author.login, author.__typename) || excluded.has(author.login.toLowerCase());
return { login: author.login, bot };
}
// 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) {
function issueFixes(issue, excluded) {
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) });
if (pull?.merged && !fixes.has(pull.number)) fixes.set(pull.number, { number: pull.number, ...person(pull.author, excluded) });
}
return [...fixes.values()].sort((a, b) => a.number - b.number);
}
@@ -116,6 +126,7 @@ export function githubRefLookup({
fetchImpl = fetch,
sleep = defaultSleep,
attempts = 4,
excluded = creditExclusions(repository),
} = {}) {
const [owner, name] = repository.split("/");
const headers = { "Content-Type": "application/json", "User-Agent": "reasonix-release-credits" };
@@ -148,8 +159,8 @@ export function githubRefLookup({
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) };
if (node?.__typename === "PullRequest") return { kind: "pull", ...person(node.author, excluded) };
if (node?.__typename === "Issue") return { kind: "issue", fixes: issueFixes(node, excluded) };
const errors = payload.errors || [];
if (repositoryNode && errors.every((error) => error.type === "NOT_FOUND")) {
return { kind: repositoryNode.discussion ? "discussion" : "missing" };
+23
View File
@@ -4,6 +4,7 @@ import {
contributorLogins,
CreditError,
CreditErrorCode,
creditExclusions,
creditSuffix,
githubRefLookup,
isBotAccount,
@@ -216,3 +217,25 @@ test("the token comes from the environment, then gh, else a coded error", () =>
{ code: CreditErrorCode.noToken },
);
});
test("the repository owner and excluded logins are left as written, a community author is credited", async () => {
const { fetchImpl } = fakeGitHub({
1: pull("Owner"),
2: pull("alice"),
3: issue([linked(30, "owner"), linked(31, "alice")]),
4: pull("helper"),
});
const lookup = githubRefLookup({ repository: "owner/r", fetchImpl });
const credits = new Map([
[1, await lookup(1)],
[2, await lookup(2)],
[3, await lookup(3)],
]);
assert.equal(creditSuffix(credits.get(1)), "");
assert.equal(creditSuffix(credits.get(2)), " by @alice");
assert.equal(creditSuffix(credits.get(3)), " fixed in #30 and #31 by @alice");
assert.deepEqual(contributorLogins([1, 2, 3], credits), ["alice"]);
const excluding = githubRefLookup({ repository: "owner/r", fetchImpl, excluded: creditExclusions("owner/r", { RELEASE_CREDIT_EXCLUDE: " Helper, ," }) });
assert.equal(creditSuffix(await excluding(4)), "");
assert.deepEqual([...creditExclusions("o/r", {})], ["o"]);
});
+8
View File
@@ -80,3 +80,11 @@ test("a number naming a discussion or nothing stays plain and only warns", async
);
assert.equal(renderStudioNotes(source, credits), source);
});
test("an item whose only author is excluded renders plain and lists no contributor", () => {
const credits = new Map([
[1, { kind: "pull", login: "owner", bot: true }],
[2, { kind: "issue", fixes: [{ number: 9, login: "owner", bot: true }] }],
]);
assert.equal(renderStudioNotes("摘要。\n\n## 修复\n\n- 一 #1\n- 二 #2\n", credits), "摘要。\n\n## 修复\n\n- 一 #1\n- 二 #2 fixed in #9\n");
});