rea/tests/boundary/filesystem/releaseNotes.test.ts

539 lines
17 KiB
TypeScript

import {
access,
chmod,
cp,
mkdir,
readFile,
writeFile,
} from "node:fs/promises";
import { delimiter, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterAll, beforeAll, expect, it } from "vitest";
import { z } from "zod";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import {
createTestWorkspace,
removeTestWorkspace,
} from "../../support/workspace/workspaceFixture.js";
import {
executeWorkflowFixture as exec,
workflowGit as git,
} from "../../support/workflowGit.js";
const cli = fileURLToPath(
new URL("../../../scripts/release-notes.mjs", import.meta.url),
);
const actor = (login: string, __typename = "User") => ({ login, __typename });
const author = (login: string | null, type = "User") => ({
name: login ?? "Unknown",
email: "public@example.invalid",
user: login === null ? null : actor(login, type),
});
const page = (nodes: unknown[], cursor: string | null = null) => ({
nodes,
pageInfo: { hasNextPage: cursor !== null, endCursor: cursor },
});
interface ReleaseNotesSeed {
readonly directory: string;
readonly base: string;
readonly target: string;
}
let seed: ReleaseNotesSeed | undefined;
let seedWorkspaceRoot: string | undefined;
beforeAll(async () => {
const workspace = await createTestWorkspace("rea-release-notes-seed-");
seedWorkspaceRoot = workspace.root;
const directory = workspace.path("repository");
await mkdir(directory, { recursive: true });
await git(directory, ["init", "--initial-branch=main"]);
await writeFile(join(directory, "product.txt"), "base");
await writeFile(
join(directory, "package.json"),
JSON.stringify({ version: "6.3.0" }),
);
await writeFile(
join(directory, ".release-please-manifest.json"),
JSON.stringify({ ".": "6.3.0" }),
);
await git(directory, ["add", "."]);
await git(directory, ["commit", "-m", "chore: baseline"]);
const base = await git(directory, ["rev-parse", "HEAD"]);
await git(directory, ["tag", "rea-agents-6.3.0"]);
await git(directory, ["switch", "-c", "feature"]);
await writeFile(join(directory, "feature.txt"), "capability");
await git(directory, ["add", "."]);
await git(directory, ["commit", "-m", "feat: useful capability (#101)"]);
const feature = await git(directory, ["rev-parse", "HEAD"]);
await git(directory, ["switch", "main"]);
await writeFile(join(directory, "direct.txt"), "direct fix");
await git(directory, ["add", "."]);
await git(directory, ["commit", "-m", "fix: direct improvement"]);
const direct = await git(directory, ["rev-parse", "HEAD"]);
await git(directory, [
"merge",
"--no-ff",
"feature",
"-m",
"Merge pull request #101",
]);
const target = await git(directory, ["rev-parse", "HEAD"]);
const bin = join(directory, "bin");
await mkdir(bin);
const issue = (number: number, login: string) => ({
number,
url: `https://github.com/fixture/rea/issues/${number}`,
author: actor(login),
repository: { nameWithOwner: "fixture/rea" },
});
const metadata = {
commits: {
[feature]: {
oid: feature,
authors: page([author("alice")], "authors-2"),
associatedPullRequests: page(
[{ number: 999, mergeCommit: null }],
"prs-2",
),
},
[direct]: {
oid: direct,
authors: page([
author("dana"),
author("automation[bot]"),
author("platformbot", "Bot"),
author("claude"),
author("cursoragent"),
author("morluto"),
]),
associatedPullRequests: page([]),
},
[target]: {
oid: target,
authors: page([author("merger")]),
associatedPullRequests: page([
{ number: 101, mergeCommit: { oid: target } },
]),
},
},
prs: {
101: {
number: 101,
title: "feat: useful capability",
url: "https://github.com/fixture/rea/pull/101",
author: actor("alice"),
mergeCommit: { oid: target },
closingIssuesReferences: page([issue(51, "reporter")], "issues-2"),
},
},
continuations: {
"authors-2": { authors: page([author("bob"), author(null)]) },
"prs-2": {
associatedPullRequests: page([
{ number: 101, mergeCommit: { oid: target } },
]),
},
"issues-2": {
closingIssuesReferences: page([issue(52, "second-reporter")]),
},
},
};
await writeFile(join(directory, "github.json"), JSON.stringify(metadata));
await writeFile(
join(bin, "gh"),
`#!${process.execPath}
import { readFileSync } from 'node:fs';
if (!process.argv.includes('graphql')) {
const pr = JSON.parse(readFileSync(new URL('../.git/release-pr.json', import.meta.url), 'utf8'));
process.stdout.write(JSON.stringify(process.argv.includes('--slurp') ? [[pr]] : pr));
process.exit(0);
}
let input = ''; for await (const chunk of process.stdin) input += chunk;
const request = JSON.parse(input);
const fixture = JSON.parse(readFileSync(new URL('../github.json', import.meta.url), 'utf8'));
if (fixture.errors) { process.stdout.write(JSON.stringify({ errors: fixture.errors })); process.exit(0); }
const result = {};
if (request.variables.cursor) result.item = fixture.continuations[request.variables.cursor];
else {
for (const match of request.query.matchAll(/(c\\d+): object\\(oid:"([a-f0-9]+)"\\)/g)) result[match[1]] = fixture.commits[match[2]];
for (const match of request.query.matchAll(/(p\\d+): pullRequest\\(number:(\\d+)\\)/g)) result[match[1]] = fixture.prs[match[2]];
}
if (!Object.keys(result).length || Object.values(result).some(value => !value)) throw new Error('Unmodeled GitHub query');
process.stdout.write(JSON.stringify({data:{repository:result}}));
`,
);
await chmod(join(bin, "gh"), 0o755);
seed = { directory, base, target };
});
afterAll(async () => {
if (seedWorkspaceRoot !== undefined)
await removeTestWorkspace(seedWorkspaceRoot);
});
async function fixture() {
const initial = seed;
if (initial === undefined)
throw new Error("Release notes seed was not initialized");
const directory = await createTestTempDirectory("rea-release-notes-");
await cp(initial.directory, directory, {
recursive: true,
dereference: true,
});
const { base, target } = initial;
const bin = join(directory, "bin");
const manifest = join(directory, "inventory.json");
const invoke = (args: string[]) =>
exec(process.execPath, [cli, ...args], {
cwd: directory,
env: {
...process.env,
PATH: `${bin}${delimiter}${process.env.PATH ?? ""}`,
},
});
await invoke([
"inventory",
"--repository",
"fixture/rea",
"--base",
base,
"--target",
target,
"--version",
"6.4.0",
"--manifest",
manifest,
]);
const { stdout: record } = await invoke(["record", "--manifest", manifest]);
const bullet =
"- Added the capability (#101; fixes #51, #52). Thanks @alice, @bob, @reporter, @second-reporter.";
const notes = `## [6.4.0]\n\n### Highlights\n\n${bullet}\n\n### Changes\n\n### Fixes\n\n${record}`;
await writeFile(join(directory, "CHANGELOG.md"), notes);
return { directory, base, target, manifest, invoke, notes, record, bullet };
}
it("inventories side branches and paginated credits, then checks and renders synchronized notes", async () => {
const f = await fixture();
const inventory = z
.object({
commits: z.array(z.object({ sha: z.string() })),
pullRequests: z.array(z.object({ number: z.number() })),
})
.parse(JSON.parse(await readFile(f.manifest, "utf8")));
expect(inventory.commits).toHaveLength(3);
expect(inventory.pullRequests.map((pr) => pr.number)).toEqual([101]);
expect(f.record).toContain(
"Thanks @alice, @bob, @reporter, @second-reporter.",
);
expect(f.record).toContain("Thanks @dana.");
expect(f.record).not.toMatch(
/@(?:merger|automation|platformbot|claude|cursoragent|morluto)/u,
);
const body = join(f.directory, "body.md");
await writeFile(
body,
`## 🤖 I have created a release beep boop\n\n${f.notes}\n---\nThis PR was generated with [Release Please](https://github.com/googleapis/release-please).\n`,
);
const report = await f.invoke([
"check",
"--manifest",
f.manifest,
"--pr-body",
body,
]);
expect(JSON.parse(report.stdout)).toMatchObject({
commits: 3,
pullRequests: 1,
unresolvedAuthors: [{ name: "Unknown", user: null }],
});
const output = join(f.directory, "rendered.md");
await f.invoke(["render", "--manifest", f.manifest, "--output", output]);
expect(await readFile(output, "utf8")).toBe(f.notes.trim() + "\n");
await expect(
f.invoke(["render", "--manifest", f.manifest, "--output", output]),
).rejects.toMatchObject({ stderr: expect.stringContaining("EEXIST") });
const { stdout: summary } = await f.invoke([
"record",
"--manifest",
f.manifest,
"--summary",
]);
expect(summary).toContain("3 commits and 1 merged PR]");
expect(summary).toContain(
"/blob/rea-agents-6.4.0/docs/releases/6.4.0.contributions.json",
);
expect(summary).not.toContain("Thanks");
const conciseNotes = f.notes
.replace(f.record, summary)
.replace(
"### Changes\n",
"### Changes\n\n- Capability details (#101).\n- Related migration (#51).\n",
);
await writeFile(join(f.directory, "CHANGELOG.md"), conciseNotes);
await f.invoke(["check", "--manifest", f.manifest]);
await writeFile(
join(f.directory, "CHANGELOG.md"),
conciseNotes.replace(
"6.4.0.contributions.json",
"6.3.0.contributions.json",
),
);
await expect(
f.invoke(["check", "--manifest", f.manifest]),
).rejects.toMatchObject({
stderr: expect.stringContaining("Contribution record differs"),
});
await expect(
f.invoke([
"inventory",
"--repository",
"fixture/rea",
"--base",
f.base,
"--target",
f.target,
"--version",
"6.4.0",
"--manifest",
f.manifest,
]),
).rejects.toMatchObject({ stderr: expect.stringContaining("EEXIST") });
});
it("rejects lost grouped credits, fabricated references, bots, and stale PR bodies", async () => {
const f = await fixture();
const directLink =
/\[[a-f0-9]{7}\]\(https:\/\/github\.com\/fixture\/rea\/commit\/[a-f0-9]+\)/u.exec(
f.record,
)?.[0];
if (!directLink)
throw new Error("Fixture is missing its direct contribution");
for (const [replacement, error] of [
[f.bullet.replace(", @bob", ""), "Missing Thanks @bob"],
[
f.bullet.replace(", @bob", "") + "\n- Thanks @bob for other work.",
"Missing Thanks @bob",
],
[f.bullet + " (#999)", "Unknown release reference #999"],
[f.bullet + "a".repeat(65_536), "inline PR body limit"],
[f.bullet + "汉".repeat(43_000), "GitHub's body budget"],
[
f.bullet + `\n- Fixed direct behavior (${directLink}).`,
"Missing Thanks @dana",
],
[
f.bullet + " Thanks @automation[bot].",
"Unverified human credit @automation[bot]",
],
]) {
await writeFile(
join(f.directory, "CHANGELOG.md"),
f.notes.replace(f.bullet, replacement!),
);
await expect(
f.invoke(["check", "--manifest", f.manifest]),
).rejects.toMatchObject({ stderr: expect.stringContaining(error!) });
}
await writeFile(
join(f.directory, "CHANGELOG.md"),
f.notes.replace(
f.bullet,
f.bullet + "\n- See [#101](https://github.com/other/repo/pull/101).",
),
);
await f.invoke(["check", "--manifest", f.manifest]);
await writeFile(join(f.directory, "CHANGELOG.md"), f.notes);
const body = join(f.directory, "body.md");
await writeFile(
body,
f.notes.replace("Added the capability", "Stale capability"),
);
await expect(
f.invoke(["check", "--manifest", f.manifest, "--pr-body", body]),
).rejects.toMatchObject({
stderr: expect.stringContaining("PR body differs"),
});
await writeFile(
join(f.directory, "CHANGELOG.md"),
f.notes.replace("### Complete contribution record", "### Missing record"),
);
await expect(
f.invoke(["check", "--manifest", f.manifest]),
).rejects.toMatchObject({
stderr: expect.stringContaining("Contribution record differs"),
});
});
it("binds publication to complete recorded history and the reviewed base", async () => {
const f = await fixture();
await git(f.directory, ["add", "CHANGELOG.md"]);
await git(f.directory, ["commit", "-m", "chore: release notes"]);
const source = await git(f.directory, ["rev-parse", "HEAD"]);
await f.invoke(["check", "--manifest", f.manifest, "--source", source]);
await writeFile(join(f.directory, "product.txt"), "later product change");
await git(f.directory, ["add", "product.txt"]);
await git(f.directory, ["commit", "-m", "fix: later product change"]);
await expect(
f.invoke(["check", "--manifest", f.manifest, "--source", "HEAD"]),
).rejects.toMatchObject({ stderr: expect.stringContaining("base moved") });
const value = z
.object({ commits: z.array(z.unknown()) })
.passthrough()
.parse(JSON.parse(await readFile(f.manifest, "utf8")));
value.commits.pop();
await writeFile(f.manifest, JSON.stringify(value));
await expect(
f.invoke(["check", "--manifest", f.manifest]),
).rejects.toMatchObject({
stderr: expect.stringContaining("complete local Git history"),
});
});
it("checks the live merged PR and exact Git snapshot before either publication route", async () => {
const f = await fixture();
await mkdir(join(f.directory, "docs/releases"), { recursive: true });
await writeFile(
join(f.directory, "docs/releases/6.4.0.contributions.json"),
await readFile(f.manifest),
);
await writeFile(
join(f.directory, "package.json"),
JSON.stringify({ version: "6.4.0" }),
);
await git(f.directory, [
"add",
"CHANGELOG.md",
"package.json",
"docs/releases/6.4.0.contributions.json",
]);
await git(f.directory, ["commit", "-m", "chore: release 6.4.0"]);
const source = await git(f.directory, ["rev-parse", "HEAD"]);
const pr = {
number: 1399,
body: f.notes,
merged_at: "2026-10-11T00:00:00Z",
merge_commit_sha: source,
head: {
ref: "release-please--branches--main--components--rea-agents",
repo: { full_name: "fixture/rea" },
},
labels: [{ name: "rea:release-notes-finalized" }],
};
const prFile = join(f.directory, ".git/release-pr.json");
await writeFile(prFile, JSON.stringify(pr));
for (const selector of [[], ["--pr", "1399"]]) {
const result = await f.invoke([
"publication",
"--repository",
"fixture/rea",
"--source",
source,
...selector,
]);
expect(JSON.parse(result.stdout)).toMatchObject({
status: "verified",
version: "6.4.0",
commits: 3,
});
}
// Worktree edits cannot substitute for the reviewed source.
await writeFile(join(f.directory, "CHANGELOG.md"), "unreviewed working text");
await f.invoke([
"publication",
"--repository",
"fixture/rea",
"--source",
source,
]);
await writeFile(
prFile,
JSON.stringify({
...pr,
body: f.notes.replace("Added the capability", "Different release"),
}),
);
await expect(
f.invoke([
"publication",
"--repository",
"fixture/rea",
"--source",
source,
]),
).rejects.toMatchObject({
stderr: expect.stringContaining("PR body differs"),
});
await writeFile(prFile, JSON.stringify(pr));
await git(f.directory, ["rm", "docs/releases/6.4.0.contributions.json"]);
await git(f.directory, ["commit", "-m", "chore: remove inventory"]);
const missing = await git(f.directory, ["rev-parse", "HEAD"]);
await writeFile(prFile, JSON.stringify({ ...pr, merge_commit_sha: missing }));
await expect(
f.invoke([
"publication",
"--repository",
"fixture/rea",
"--source",
missing,
]),
).rejects.toMatchObject({
stderr: expect.stringContaining("missing its contribution inventory"),
});
await writeFile(
prFile,
JSON.stringify({ ...pr, merge_commit_sha: missing, labels: [] }),
);
const legacy = await f.invoke([
"publication",
"--repository",
"fixture/rea",
"--source",
missing,
]);
expect(JSON.parse(legacy.stdout)).toMatchObject({ status: "legacy" });
});
it("fails closed on partial GitHub errors and non-advancing pagination without saving an inventory", async () => {
const f = await fixture();
const metadataFile = join(f.directory, "github.json");
const metadata = JSON.parse(await readFile(metadataFile, "utf8")) as Record<
string,
unknown
>;
await writeFile(
metadataFile,
JSON.stringify({ ...metadata, errors: [{ message: "quota exceeded" }] }),
);
const output = join(f.directory, "failed.json");
const args = [
"inventory",
"--repository",
"fixture/rea",
"--base",
f.base,
"--target",
f.target,
"--version",
"6.4.0",
"--manifest",
output,
];
await expect(f.invoke(args)).rejects.toMatchObject({
stderr: expect.stringContaining("quota exceeded"),
});
await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" });
const cyclic = {
...metadata,
continuations: {
"authors-2": { authors: page([author("bob")], "authors-2") },
},
};
await writeFile(metadataFile, JSON.stringify(cyclic));
await expect(f.invoke(args)).rejects.toMatchObject({
stderr: expect.stringContaining("non-advancing authors cursor"),
});
await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" });
});