mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
c70aee247e
* refactor(scripts): migrate JavaScript tools to TypeScript * fix(ci): keep changed-scope preflight zero-install * fix(ci): preserve zero-install script owners * fix(ci): complete script migration follow-through * fix(release): keep stable closeout zero-install * fix(scripts): preserve standalone execution boundaries * fix(scripts): repair standalone loader boundaries * fix(scripts): normalize gateway observation ids * fix(scripts): keep Docker packager standalone * test(scripts): preserve rebase cleanup helpers * test(sessions): use tracked temp directory
2699 lines
87 KiB
JavaScript
2699 lines
87 KiB
JavaScript
#!/usr/bin/env -S node --import tsx
|
|
|
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
renameSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import {
|
|
extractChangelogReleaseSections,
|
|
formatContributionRecordProvenance,
|
|
formatShippedBaselineExclusions,
|
|
parseContributionRecordProvenance,
|
|
parseShippedBaselineExclusions,
|
|
releaseNotesVersionForTag,
|
|
verifyGithubReleaseNotes,
|
|
} from "../../../../scripts/render-github-release-notes.mts";
|
|
|
|
const repo = "openclaw/openclaw";
|
|
const githubSnapshotSchemaVersion = 1;
|
|
const githubSnapshotCheckpointInterval = 25;
|
|
const commitAssociationQueryBatchSize = 20;
|
|
const excludedHandles = new Set([
|
|
"openclaw",
|
|
"clawsweeper",
|
|
"claude",
|
|
"codex",
|
|
"hugin-bot",
|
|
"steipete",
|
|
"steipete-oai",
|
|
]);
|
|
const nonEditorialTypes = new Set([
|
|
"build",
|
|
"chore",
|
|
"ci",
|
|
"docs",
|
|
"qa",
|
|
"refactor",
|
|
"style",
|
|
"test",
|
|
]);
|
|
const nonEditorialTitlePattern =
|
|
/(?:^|[\s:([{-])(docs?|documentation|tests?|testing|qa|quality assurance|refactor(?:ing)?|ci|continuous integration|build|chore|style|lint|format)(?:$|[\s:)\]}-])/i;
|
|
const editorialTitlePattern =
|
|
/^\s*(?:\[[^\]]+\]\s*)?(?:#\d+:\s*)?(?:add|allow|block|enable|expose|fail|fix|harden|honor|improve|keep|migrate|move|persist|polish|preserve|prevent|propagate|rate[- ]?limit|restore|revert|ship|support|treat|validate)\b|^\s*#\d+:/i;
|
|
const genericDirectCommitTerms = new Set([
|
|
"add",
|
|
"allow",
|
|
"avoid",
|
|
"build",
|
|
"change",
|
|
"fix",
|
|
"improve",
|
|
"keep",
|
|
"make",
|
|
"missing",
|
|
"move",
|
|
"omit",
|
|
"omitted",
|
|
"prevent",
|
|
"repair",
|
|
"required",
|
|
"restore",
|
|
"update",
|
|
]);
|
|
const ansiEscapePattern = new RegExp(String.raw`\u001B\[[0-?]*[ -/]*[@-~]`, "g");
|
|
let githubSnapshotState;
|
|
|
|
function fail(message) {
|
|
throw new Error(message);
|
|
}
|
|
|
|
function printUsage() {
|
|
console.log(`Usage:
|
|
node --import tsx .agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs \\
|
|
--base <tag-or-sha> --target <tag-or-sha> --version <version> [options]
|
|
|
|
Required:
|
|
--base <ref> Release range start.
|
|
--target <ref> Release range end.
|
|
--version <version> CHANGELOG.md version heading to verify.
|
|
|
|
Options:
|
|
--manifest <path> Read or write the complete contribution record ledger.
|
|
--github-snapshot <path>
|
|
Override the exact-range GitHub GraphQL snapshot path.
|
|
--no-github-snapshot Disable GitHub GraphQL snapshot reuse.
|
|
--refresh-github-snapshot
|
|
Ignore an existing exact-range snapshot and rebuild it.
|
|
--main-ref <ref> Canonical mainline used to replace backport PRs.
|
|
--seed-ref <ref> Use an existing release section as editorial input.
|
|
--shipped-ref <tag> Exclude PRs already recorded by this shipped tag; repeatable.
|
|
--release-provenance <sha -> #PR[, #PR]>
|
|
Supply an exact provenance marker; repeatable.
|
|
--write-ledger Write the verified ledger back into CHANGELOG.md.
|
|
--release-tag <tag> GitHub release tag to compare; repeatable with --check-github.
|
|
--check-github Require each supplied GitHub release body to match.
|
|
--json Emit machine-readable verification output.
|
|
--help Show this help text.`);
|
|
}
|
|
|
|
export function parseArgs(argv) {
|
|
const options = {
|
|
releaseTags: [],
|
|
releaseProvenance: [],
|
|
checkGithub: false,
|
|
help: false,
|
|
json: false,
|
|
manifestPath: undefined,
|
|
githubSnapshotPath: undefined,
|
|
mainRef: undefined,
|
|
noGithubSnapshot: false,
|
|
refreshGithubSnapshot: false,
|
|
seedRef: /** @type {string | undefined} */ (undefined),
|
|
shippedRefs: [],
|
|
writeLedger: false,
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === "--help") {
|
|
options.help = true;
|
|
continue;
|
|
}
|
|
if (
|
|
arg === "--check-github" ||
|
|
arg === "--json" ||
|
|
arg === "--no-github-snapshot" ||
|
|
arg === "--refresh-github-snapshot" ||
|
|
arg === "--write-ledger"
|
|
) {
|
|
options[
|
|
arg === "--check-github"
|
|
? "checkGithub"
|
|
: arg === "--write-ledger"
|
|
? "writeLedger"
|
|
: arg === "--no-github-snapshot"
|
|
? "noGithubSnapshot"
|
|
: arg === "--refresh-github-snapshot"
|
|
? "refreshGithubSnapshot"
|
|
: "json"
|
|
] = true;
|
|
continue;
|
|
}
|
|
if (
|
|
arg === "--base" ||
|
|
arg === "--target" ||
|
|
arg === "--version" ||
|
|
arg === "--release-tag" ||
|
|
arg === "--release-provenance" ||
|
|
arg === "--shipped-ref" ||
|
|
arg === "--github-snapshot" ||
|
|
arg === "--main-ref" ||
|
|
arg === "--manifest" ||
|
|
arg === "--seed-ref"
|
|
) {
|
|
const value = argv[index + 1];
|
|
if (!value || value.startsWith("--")) {
|
|
fail(`missing value for ${arg}`);
|
|
}
|
|
if (arg === "--release-tag") {
|
|
options.releaseTags.push(value);
|
|
} else if (arg === "--release-provenance") {
|
|
options.releaseProvenance.push(value);
|
|
} else if (arg === "--shipped-ref") {
|
|
options.shippedRefs.push(value);
|
|
} else if (arg === "--manifest") {
|
|
options.manifestPath = value;
|
|
} else if (arg === "--github-snapshot") {
|
|
options.githubSnapshotPath = value;
|
|
} else if (arg === "--main-ref") {
|
|
options.mainRef = value;
|
|
} else if (arg === "--seed-ref") {
|
|
options.seedRef = value;
|
|
} else {
|
|
options[arg.slice(2)] = value;
|
|
}
|
|
index += 1;
|
|
continue;
|
|
}
|
|
fail(`unknown argument: ${arg}`);
|
|
}
|
|
|
|
if (!options.help) {
|
|
for (const name of ["base", "target", "version"]) {
|
|
if (!options[name]) {
|
|
fail(`--${name} is required`);
|
|
}
|
|
}
|
|
} else if (options.checkGithub || options.releaseTags.length > 0) {
|
|
fail("--help cannot be combined with verification options");
|
|
}
|
|
if (!options.help && options.checkGithub && options.releaseTags.length === 0) {
|
|
fail("--check-github requires at least one --release-tag");
|
|
}
|
|
if (options.noGithubSnapshot && options.githubSnapshotPath) {
|
|
fail("--no-github-snapshot cannot be combined with --github-snapshot");
|
|
}
|
|
if (options.noGithubSnapshot && options.refreshGithubSnapshot) {
|
|
fail("--no-github-snapshot cannot be combined with --refresh-github-snapshot");
|
|
}
|
|
const uniqueShippedRefs = new Set(options.shippedRefs);
|
|
if (uniqueShippedRefs.size !== options.shippedRefs.length) {
|
|
fail("--shipped-ref values must be unique");
|
|
}
|
|
options.shippedRefs = options.shippedRefs.toSorted((a, b) => (a === b ? 0 : a < b ? -1 : 1));
|
|
return options;
|
|
}
|
|
|
|
function run(command, args, options = {}) {
|
|
return execFileSync(command, args, {
|
|
encoding: "utf8",
|
|
env: { ...process.env, NO_COLOR: "1", ...options.env },
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
}
|
|
|
|
function git(args) {
|
|
return run("git", args).trimEnd();
|
|
}
|
|
|
|
function gitIsAncestor(base, target) {
|
|
const result = spawnSync(
|
|
"git",
|
|
["merge-base", "--is-ancestor", `${base}^{commit}`, `${target}^{commit}`],
|
|
{
|
|
encoding: "utf8",
|
|
env: { ...process.env, NO_COLOR: "1" },
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
},
|
|
);
|
|
if (result.status === 0) {
|
|
return true;
|
|
}
|
|
if (result.status === 1) {
|
|
return false;
|
|
}
|
|
return fail(
|
|
`could not validate release range ancestry for ${base}..${target}: ${
|
|
result.stderr?.trim() || result.signal || result.status
|
|
}`,
|
|
);
|
|
}
|
|
|
|
function gitCommit(ref, required = false) {
|
|
const result = spawnSync("git", ["rev-parse", "--verify", `${ref}^{commit}`], {
|
|
encoding: "utf8",
|
|
env: { ...process.env, NO_COLOR: "1" },
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
if (result.status === 0) {
|
|
return result.stdout.trim();
|
|
}
|
|
if (!required) {
|
|
return undefined;
|
|
}
|
|
return fail(
|
|
`could not resolve canonical main ref ${ref}: ${result.stderr?.trim() || result.status}`,
|
|
);
|
|
}
|
|
|
|
function fetchGithubApi(args) {
|
|
try {
|
|
return JSON.parse(
|
|
run("ghx", ["api", ...args], { env: { GHX_NO_CACHE: "1" } }).replace(ansiEscapePattern, ""),
|
|
);
|
|
} catch (error) {
|
|
if (typeof error.stdout === "string" && error.stdout.trim() !== "") {
|
|
return JSON.parse(error.stdout.replace(ansiEscapePattern, ""));
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export function createGithubSnapshotState({
|
|
base,
|
|
checkpointEvery = githubSnapshotCheckpointInterval,
|
|
filePath,
|
|
refresh = false,
|
|
repository = repo,
|
|
target,
|
|
}) {
|
|
if (!Number.isSafeInteger(checkpointEvery) || checkpointEvery < 1) {
|
|
fail("GitHub snapshot checkpoint interval must be a positive integer");
|
|
}
|
|
let responses = {};
|
|
if (!refresh && existsSync(filePath)) {
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(readFileSync(filePath, "utf8"));
|
|
} catch (error) {
|
|
fail(
|
|
`could not read GitHub snapshot ${filePath}: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
}
|
|
if (
|
|
parsed.schemaVersion !== githubSnapshotSchemaVersion ||
|
|
parsed.repository !== repository ||
|
|
parsed.base !== base ||
|
|
parsed.target !== target ||
|
|
!parsed.responses ||
|
|
typeof parsed.responses !== "object" ||
|
|
Array.isArray(parsed.responses)
|
|
) {
|
|
fail(
|
|
`GitHub snapshot ${filePath} does not match ${repository} ${base}..${target}; use --refresh-github-snapshot`,
|
|
);
|
|
}
|
|
responses = parsed.responses;
|
|
}
|
|
return {
|
|
base,
|
|
checkpointEvery,
|
|
dirty: refresh && existsSync(filePath),
|
|
filePath,
|
|
hits: 0,
|
|
misses: 0,
|
|
repository,
|
|
responses,
|
|
target,
|
|
writesSincePersist: 0,
|
|
};
|
|
}
|
|
|
|
export function githubApiWithSnapshot(args, fetchApi, snapshotState) {
|
|
if (!snapshotState || args[0] !== "graphql") {
|
|
return fetchApi(args);
|
|
}
|
|
const key = JSON.stringify(args);
|
|
const cached = snapshotState.responses[key];
|
|
if (cached !== undefined) {
|
|
snapshotState.hits += 1;
|
|
return structuredClone(cached);
|
|
}
|
|
snapshotState.misses += 1;
|
|
const response = fetchApi(args);
|
|
if (
|
|
!response ||
|
|
typeof response !== "object" ||
|
|
Array.isArray(response) ||
|
|
response.data === undefined ||
|
|
(Array.isArray(response.errors) && response.errors.length > 0)
|
|
) {
|
|
return response;
|
|
}
|
|
snapshotState.responses[key] = structuredClone(response);
|
|
snapshotState.dirty = true;
|
|
snapshotState.writesSincePersist += 1;
|
|
if (snapshotState.writesSincePersist >= snapshotState.checkpointEvery) {
|
|
persistGithubSnapshot(snapshotState);
|
|
}
|
|
return response;
|
|
}
|
|
|
|
export function persistGithubSnapshot(snapshotState) {
|
|
if (!snapshotState?.dirty) {
|
|
return;
|
|
}
|
|
const output = `${JSON.stringify(
|
|
{
|
|
schemaVersion: githubSnapshotSchemaVersion,
|
|
repository: snapshotState.repository,
|
|
base: snapshotState.base,
|
|
target: snapshotState.target,
|
|
responses: snapshotState.responses,
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`;
|
|
mkdirSync(path.dirname(snapshotState.filePath), { recursive: true });
|
|
const tempPath = `${snapshotState.filePath}.${process.pid}.tmp`;
|
|
try {
|
|
writeFileSync(tempPath, output);
|
|
renameSync(tempPath, snapshotState.filePath);
|
|
snapshotState.dirty = false;
|
|
snapshotState.writesSincePersist = 0;
|
|
} finally {
|
|
rmSync(tempPath, { force: true });
|
|
}
|
|
}
|
|
|
|
function githubApi(args) {
|
|
return githubApiWithSnapshot(args, fetchGithubApi, githubSnapshotState);
|
|
}
|
|
|
|
export function defaultGithubSnapshotPath(base, target, gitCommonDir) {
|
|
const defaultName = `verify-release-notes-${base}-${target}.json`;
|
|
return path.resolve(gitCommonDir, "openclaw-release-cache", defaultName);
|
|
}
|
|
|
|
function initializeGithubSnapshot(options) {
|
|
if (options.noGithubSnapshot) {
|
|
return undefined;
|
|
}
|
|
const base = git(["rev-parse", `${options.base}^{commit}`]);
|
|
const target = git(["rev-parse", `${options.target}^{commit}`]);
|
|
const filePath = path.resolve(
|
|
options.githubSnapshotPath ??
|
|
defaultGithubSnapshotPath(base, target, git(["rev-parse", "--git-common-dir"])),
|
|
);
|
|
const state = createGithubSnapshotState({
|
|
base,
|
|
filePath,
|
|
refresh: options.refreshGithubSnapshot,
|
|
target,
|
|
});
|
|
process.once("exit", () => persistGithubSnapshot(state));
|
|
return state;
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
export function isEligibleHandle(handle) {
|
|
return (
|
|
typeof handle === "string" &&
|
|
handle.toLowerCase() !== "undefined" &&
|
|
!handle.endsWith("[bot]") &&
|
|
!excludedHandles.has(handle.toLowerCase())
|
|
);
|
|
}
|
|
|
|
function githubHandleFromNoreply(email) {
|
|
return email.match(/^(?:\d+\+)?([^@]+)@users\.noreply\.github\.com$/i)?.[1];
|
|
}
|
|
|
|
function editorialClassification(subject) {
|
|
const type = subject.match(/^\s*([a-z]+)(?:\([^)]*\))?!?:/i)?.[1]?.toLowerCase();
|
|
return {
|
|
editorialEligible:
|
|
(Boolean(type) || editorialTitlePattern.test(subject)) &&
|
|
!nonEditorialTypes.has(type) &&
|
|
!nonEditorialTitlePattern.test(subject),
|
|
type: type ?? "other",
|
|
};
|
|
}
|
|
|
|
function mergedByTarget(mergedAt, targetTimestamp) {
|
|
const mergedTimestamp = Date.parse(mergedAt);
|
|
return Number.isFinite(mergedTimestamp) && mergedTimestamp <= targetTimestamp;
|
|
}
|
|
|
|
function optionalSectionFor(changelog, version) {
|
|
const heading = new RegExp(`^## ${escapeRegExp(version)}\\r?$`, "m").exec(changelog);
|
|
if (!heading || heading.index === undefined) {
|
|
return undefined;
|
|
}
|
|
const start = heading.index;
|
|
const bodyStart = changelog.indexOf("\n", start) + 1;
|
|
const next = /^## /gm;
|
|
next.lastIndex = bodyStart;
|
|
const nextHeading = next.exec(changelog);
|
|
const end = nextHeading?.index ?? changelog.length;
|
|
return {
|
|
start,
|
|
end,
|
|
source: changelog.slice(start, end).trimEnd(),
|
|
body: changelog.slice(bodyStart, end).trim(),
|
|
};
|
|
}
|
|
|
|
function sectionFor(changelog, version) {
|
|
const section = optionalSectionFor(changelog, version);
|
|
if (!section) {
|
|
fail(`CHANGELOG.md does not contain ## ${version}`);
|
|
}
|
|
return section;
|
|
}
|
|
|
|
function referencesIn(text) {
|
|
const references = [];
|
|
for (const match of text.matchAll(
|
|
/(?<![A-Za-z0-9_.&-])(?:(?<owner>[A-Za-z0-9_.-]+)\/(?<name>[A-Za-z0-9_.-]+))?#(?<number>\d+)/g,
|
|
)) {
|
|
const qualifiedRepository = match.groups?.owner
|
|
? `${match.groups.owner}/${match.groups.name}`.toLowerCase()
|
|
: undefined;
|
|
if (!qualifiedRepository || qualifiedRepository === repo) {
|
|
references.push(Number(match.groups?.number));
|
|
}
|
|
}
|
|
return references;
|
|
}
|
|
|
|
function referenceLabelsIn(text) {
|
|
const labels = [];
|
|
for (const match of text.matchAll(
|
|
/(?<![A-Za-z0-9_.&-])(?:(?<owner>[A-Za-z0-9_.-]+)\/(?<name>[A-Za-z0-9_.-]+))?#(?<number>\d+)/g,
|
|
)) {
|
|
const qualifiedRepository = match.groups?.owner
|
|
? `${match.groups.owner}/${match.groups.name}`
|
|
: undefined;
|
|
labels.push(
|
|
!qualifiedRepository || qualifiedRepository.toLowerCase() === repo
|
|
? `#${match.groups?.number}`
|
|
: `${qualifiedRepository}#${match.groups?.number}`,
|
|
);
|
|
}
|
|
return labels;
|
|
}
|
|
|
|
export function renderContributionRecordEntry(entry) {
|
|
const references = [];
|
|
const linkedIssueNumbers = new Set(entry.linkedIssues.map((issue) => issue.number));
|
|
appendUnique(
|
|
references,
|
|
referenceLabelsIn(entry.title).filter(
|
|
(reference) =>
|
|
!reference.startsWith("#") || linkedIssueNumbers.has(Number(reference.slice(1))),
|
|
),
|
|
);
|
|
appendUnique(
|
|
references,
|
|
(entry.priorReferences ?? [])
|
|
.filter((number) => linkedIssueNumbers.has(number))
|
|
.map((number) => `#${number}`),
|
|
);
|
|
appendUnique(references, entry.externalReferences ?? []);
|
|
for (const issue of entry.linkedIssues) {
|
|
appendUnique(references, [`#${issue.number}`]);
|
|
}
|
|
const related = references.length > 0 ? ` Related ${references.join(", ")}.` : "";
|
|
const attribution =
|
|
entry.thanks.length > 0
|
|
? ` Thanks ${entry.thanks.map((handle) => `@${handle}`).join(" and ")}.`
|
|
: "";
|
|
return `- **PR #${entry.number}**${related}${attribution}`;
|
|
}
|
|
|
|
export function releaseNoteReferences(sectionSource, shippedBaselines) {
|
|
const shippedBaselineLine = formatShippedBaselineExclusions(shippedBaselines);
|
|
// The baseline inventory proves subtraction; its PR ids are not release-note references.
|
|
const referenceSource = shippedBaselineLine
|
|
? sectionSource.replace(shippedBaselineLine, "")
|
|
: sectionSource;
|
|
return referencesIn(referenceSource);
|
|
}
|
|
|
|
function closingReferencesIn(text) {
|
|
const references = [];
|
|
for (const match of text.matchAll(
|
|
/\b(?:fix(?:es|ed)?|closes?|closed|resolves?|resolved)\s+(?:(?:openclaw\/openclaw)?#\d+)(?:\s*(?:,|and)\s*(?:(?:openclaw\/openclaw)?#\d+))*/gi,
|
|
)) {
|
|
appendReferences(references, referencesIn(match[0]));
|
|
}
|
|
return references;
|
|
}
|
|
|
|
export function standardRevertedHash(message) {
|
|
const paragraphs = message
|
|
.trim()
|
|
.split(/\n\s*\n/)
|
|
.map((paragraph) => paragraph.trim());
|
|
const messageIsRevert = /^(?:[a-z][a-z0-9-]*(?:\([^)]+\))?!?:\s*)?revert\b/i.test(
|
|
paragraphs[0] ?? "",
|
|
);
|
|
for (const [index, paragraph] of paragraphs.entries()) {
|
|
const revertedHash = paragraph.match(/^This reverts commit ([0-9a-f]{7,40})\.$/i)?.[1];
|
|
if (!revertedHash) {
|
|
continue;
|
|
}
|
|
// GitHub squash messages can embed a reverted intermediate commit. Its
|
|
// marker follows the corresponding bullet and does not revert the squash.
|
|
if (!messageIsRevert && /^\*\s+Revert\b/i.test(paragraphs[index - 1] ?? "")) {
|
|
continue;
|
|
}
|
|
return revertedHash;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function handlesIn(text) {
|
|
const thanksStart = text.lastIndexOf(" Thanks ");
|
|
if (thanksStart < 0) {
|
|
return [];
|
|
}
|
|
const content = text.slice(0, thanksStart);
|
|
return [...text.slice(thanksStart).matchAll(/@([A-Za-z0-9-]+)/g)]
|
|
.map((match) => match[1])
|
|
.filter(
|
|
(handle) =>
|
|
isEligibleHandle(handle) &&
|
|
!new RegExp(`(?<![A-Za-z0-9-])@${escapeRegExp(handle)}\\b`, "i").test(content),
|
|
);
|
|
}
|
|
|
|
function externalReferencesIn(text) {
|
|
return referenceLabelsIn(text).filter((reference) => !reference.startsWith("#"));
|
|
}
|
|
|
|
function appendUnique(values, additions) {
|
|
const seen = new Set(values.map((value) => value.toLowerCase()));
|
|
for (const value of additions) {
|
|
const key = value.toLowerCase();
|
|
if (!seen.has(key)) {
|
|
values.push(value);
|
|
seen.add(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
function addContributionRecordEntry(entries, key, entry) {
|
|
const existing = entries.get(key);
|
|
if (!existing) {
|
|
entries.set(key, {
|
|
...entry,
|
|
externalReferences: [...(entry.externalReferences ?? [])],
|
|
references: [...entry.references],
|
|
thanks: [...entry.thanks],
|
|
});
|
|
return;
|
|
}
|
|
appendUnique(existing.externalReferences, entry.externalReferences ?? []);
|
|
appendReferences(existing.references, entry.references);
|
|
addHandles(existing.thanks, entry.thanks);
|
|
}
|
|
|
|
export function contributionRecordFor(section) {
|
|
const result = { legacyIssues: new Map(), pullRequests: new Map() };
|
|
const recordStart = section.source.search(/\n### Complete contribution (?:ledger|record)\r?$/m);
|
|
if (recordStart < 0) {
|
|
return result;
|
|
}
|
|
const record = section.source.slice(recordStart);
|
|
let subsection = "";
|
|
for (const line of record.split("\n")) {
|
|
if (line === "#### Pull requests") {
|
|
subsection = "pull-requests";
|
|
continue;
|
|
}
|
|
if (line === "#### Linked issues") {
|
|
subsection = "linked-issues";
|
|
continue;
|
|
}
|
|
if (line.startsWith("#### ")) {
|
|
subsection = "";
|
|
continue;
|
|
}
|
|
if (subsection === "pull-requests") {
|
|
const explicitRecord = line.match(/^- \*\*PR #(\d+)\*\*/);
|
|
const legacyRecord = line.match(/\(#(\d+)\)\.(?: Thanks.*)?$/);
|
|
const number = explicitRecord?.[1] ?? legacyRecord?.[1];
|
|
if (number) {
|
|
const value = Number(number);
|
|
const metadata = explicitRecord ? line.slice(explicitRecord[0].length) : line;
|
|
addContributionRecordEntry(result.pullRequests, value, {
|
|
externalReferences: externalReferencesIn(metadata),
|
|
references: referencesIn(metadata).filter((reference) => reference !== value),
|
|
thanks: handlesIn(line),
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
if (subsection === "linked-issues") {
|
|
const number = referencesIn(line)[0];
|
|
if (number) {
|
|
addContributionRecordEntry(result.legacyIssues, number, {
|
|
references: [],
|
|
thanks: handlesIn(line),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function contributionRecordTarget(section) {
|
|
const recordStart = section.source.search(/\n### Complete contribution (?:ledger|record)\r?$/m);
|
|
if (recordStart < 0) {
|
|
return undefined;
|
|
}
|
|
const target = parseContributionRecordProvenance(section.source.slice(recordStart))?.target;
|
|
return target && /^[0-9a-f]{40}$/u.test(target) ? target : undefined;
|
|
}
|
|
|
|
export function pullRequestTitleFromCommitSubject(subject, number) {
|
|
const match = subject.match(/^(?<title>\S(?:.*?\S)?)(?<pr> \(#(?<number>[1-9]\d*)\))\k<pr>*$/u);
|
|
return match?.groups?.number === String(number) ? match.groups.title : undefined;
|
|
}
|
|
|
|
function completeContributionRecord(section, label) {
|
|
const recordStart = section.source.search(/\n### Complete contribution record\r?$/m);
|
|
if (recordStart < 0) {
|
|
fail(`${label} is missing ### Complete contribution record`);
|
|
}
|
|
const recordSource = section.source.slice(recordStart);
|
|
const provenance = parseContributionRecordProvenance(recordSource);
|
|
if (!provenance || !/^[0-9a-f]{40}$/u.test(provenance.target)) {
|
|
fail(`${label} is missing exact complete contribution record provenance`);
|
|
}
|
|
const record = contributionRecordFor(section);
|
|
const declaredCount = provenance.uniquePullRequests;
|
|
return { record, declaredCount };
|
|
}
|
|
|
|
export function cumulativeShippedPullRequests(changelog, label) {
|
|
const sections = extractChangelogReleaseSections(changelog).filter(
|
|
(section) =>
|
|
section.version !== "Unreleased" &&
|
|
section.source.includes("\n### Complete contribution record"),
|
|
);
|
|
if (sections.length === 0) {
|
|
fail(`${label} is missing ### Complete contribution record`);
|
|
}
|
|
const pullRequests = new Set();
|
|
for (const section of sections) {
|
|
const record = contributionRecordFor(section);
|
|
for (const number of record.pullRequests.keys()) {
|
|
pullRequests.add(number);
|
|
}
|
|
}
|
|
return pullRequests;
|
|
}
|
|
|
|
function shippedBaselineFor(ref) {
|
|
const version = releaseNotesVersionForTag(ref);
|
|
const tagRef = `refs/tags/${ref}`;
|
|
git(["rev-parse", `${tagRef}^{commit}`]);
|
|
const changelog = git(["show", `${tagRef}:CHANGELOG.md`]);
|
|
completeContributionRecord(sectionFor(changelog, version), `shipped baseline ${ref}`);
|
|
return {
|
|
ref,
|
|
pullRequests: cumulativeShippedPullRequests(changelog, `shipped baseline ${ref}`),
|
|
};
|
|
}
|
|
|
|
export function subtractShippedPullRequests(source, baselines) {
|
|
const excluded = new Set();
|
|
const metadata = [];
|
|
for (const baseline of baselines.toSorted((a, b) =>
|
|
a.ref === b.ref ? 0 : a.ref < b.ref ? -1 : 1,
|
|
)) {
|
|
const pullRequests = [];
|
|
for (const number of baseline.pullRequests) {
|
|
if (
|
|
!excluded.has(number) &&
|
|
(source.pullRequests.has(number) || source.references.includes(number))
|
|
) {
|
|
excluded.add(number);
|
|
pullRequests.push(number);
|
|
}
|
|
source.pullRequests.delete(number);
|
|
}
|
|
source.references = source.references.filter((number) => !baseline.pullRequests.has(number));
|
|
const sortedPullRequests = pullRequests.toSorted((a, b) => a - b);
|
|
metadata.push({
|
|
ref: baseline.ref,
|
|
count: sortedPullRequests.length,
|
|
pullRequests: sortedPullRequests,
|
|
});
|
|
}
|
|
return { baselines: metadata, pullRequests: excluded };
|
|
}
|
|
|
|
export function withoutExcludedContributionRecords(record, excludedReferences) {
|
|
if (excludedReferences.size === 0) {
|
|
return record;
|
|
}
|
|
const filtered = { legacyIssues: new Map(), pullRequests: new Map() };
|
|
for (const [number, entry] of record.pullRequests) {
|
|
if (excludedReferences.has(number)) {
|
|
continue;
|
|
}
|
|
addContributionRecordEntry(filtered.pullRequests, number, {
|
|
...entry,
|
|
externalReferences: entry.externalReferences,
|
|
references: entry.references.filter((reference) => !excludedReferences.has(reference)),
|
|
});
|
|
}
|
|
for (const [number, entry] of record.legacyIssues) {
|
|
if (!excludedReferences.has(number)) {
|
|
addContributionRecordEntry(filtered.legacyIssues, number, entry);
|
|
}
|
|
}
|
|
return filtered;
|
|
}
|
|
|
|
function contributionRecordReferences(record) {
|
|
return [...record.pullRequests.keys()];
|
|
}
|
|
|
|
function contributionRecordMetadataReferences(record) {
|
|
const references = contributionRecordReferences(record);
|
|
for (const entry of record.pullRequests.values()) {
|
|
appendReferences(references, entry.references);
|
|
}
|
|
appendReferences(references, record.legacyIssues.keys());
|
|
return references;
|
|
}
|
|
|
|
export function renderedContributionRecordReferences(record, writeLedger) {
|
|
// Write mode replaces the existing generated record. Validating stale record
|
|
// references here would make the verifier unable to repair its own output.
|
|
return writeLedger ? [] : contributionRecordMetadataReferences(record);
|
|
}
|
|
|
|
export function contaminatingPullRequestReferences({
|
|
noteReferences,
|
|
recordedReferences,
|
|
excludedRecordedReferences = new Set(),
|
|
sourcePullRequests,
|
|
sourceReferences,
|
|
seededPullRequests,
|
|
nodes,
|
|
}) {
|
|
const allowed = new Set([...sourcePullRequests, ...seededPullRequests]);
|
|
for (const number of sourceReferences) {
|
|
if (nodes.get(number)?.__typename === "PullRequest") {
|
|
allowed.add(number);
|
|
}
|
|
}
|
|
const effectiveRecordedReferences = recordedReferences.filter(
|
|
(number) => !excludedRecordedReferences.has(number),
|
|
);
|
|
return [...new Set([...noteReferences, ...effectiveRecordedReferences])].filter(
|
|
(number) => nodes.get(number)?.__typename === "PullRequest" && !allowed.has(number),
|
|
);
|
|
}
|
|
|
|
function appendReferences(references, additions) {
|
|
const seen = new Set(references);
|
|
for (const number of additions) {
|
|
if (!seen.has(number)) {
|
|
references.push(number);
|
|
seen.add(number);
|
|
}
|
|
}
|
|
}
|
|
|
|
function normalizedCommitSubject(subject) {
|
|
return subject
|
|
.replace(/\s+\(#\d+\)\s*$/, "")
|
|
.replace(/\s+/g, " ")
|
|
.trim()
|
|
.toLowerCase();
|
|
}
|
|
|
|
function cherryPickOrigins(message) {
|
|
return [...message.matchAll(/^\(cherry picked from commit ([0-9a-f]{40})\)$/gim)].map((match) =>
|
|
match[1].toLowerCase(),
|
|
);
|
|
}
|
|
|
|
function backportPullRequestOrigins(message) {
|
|
return [
|
|
...message.matchAll(/^Backport of #(\d+) to release\/[A-Za-z0-9._/-]+(?:\.)?\s*$/gim),
|
|
].map((match) => Number(match[1]));
|
|
}
|
|
|
|
function releaseProvenanceMarker(line) {
|
|
const match = line.match(/^Release provenance: ([0-9a-f]{40}) -> (#\d+(?:,\s*#\d+)*)\.?\s*$/i);
|
|
if (!match) {
|
|
fail(`invalid release provenance marker: ${line}`);
|
|
}
|
|
return {
|
|
commit: match[1].toLowerCase(),
|
|
pullRequests: [...match[2].matchAll(/#(\d+)/g)].map((reference) => Number(reference[1])),
|
|
};
|
|
}
|
|
|
|
export function releaseProvenanceMarkers(message) {
|
|
return message
|
|
.split("\n")
|
|
.filter((line) => /^Release provenance:/i.test(line))
|
|
.map(releaseProvenanceMarker);
|
|
}
|
|
|
|
export function collectReleaseProvenanceOverrides(activeCommits, releaseProvenance = []) {
|
|
const activeCommitHashes = new Set(activeCommits.map((commit) => commit.hash));
|
|
const overrides = new Map();
|
|
const addMarker = (marker) => {
|
|
if (!activeCommitHashes.has(marker.commit)) {
|
|
fail(`release provenance marker targets commit outside the active range: ${marker.commit}`);
|
|
}
|
|
const existing = overrides.get(marker.commit);
|
|
if (existing && existing.join(",") !== marker.pullRequests.join(",")) {
|
|
fail(`conflicting release provenance markers for ${marker.commit}`);
|
|
}
|
|
overrides.set(marker.commit, marker.pullRequests);
|
|
};
|
|
for (const commit of activeCommits) {
|
|
for (const marker of releaseProvenanceMarkers(commit.body)) {
|
|
addMarker(marker);
|
|
}
|
|
}
|
|
for (const value of releaseProvenance) {
|
|
if (/[\r\n]/u.test(value)) {
|
|
fail(`invalid release provenance marker: Release provenance: ${value}`);
|
|
}
|
|
addMarker(releaseProvenanceMarker(`Release provenance: ${value}`));
|
|
}
|
|
return overrides;
|
|
}
|
|
|
|
export function resolvedReleasePullRequests(
|
|
currentPullRequests,
|
|
mainPullRequests,
|
|
hasCanonicalMainCommit,
|
|
provenanceOverride,
|
|
) {
|
|
return (
|
|
provenanceOverride ??
|
|
canonicalPullRequests(currentPullRequests, mainPullRequests, hasCanonicalMainCommit)
|
|
);
|
|
}
|
|
|
|
export function releasePullRequestReferencesToSuppress(
|
|
currentPullRequests,
|
|
subject,
|
|
associatedPullRequests,
|
|
hasProvenanceOverride,
|
|
) {
|
|
const candidates = new Set(currentPullRequests);
|
|
if (hasProvenanceOverride) {
|
|
for (const match of subject.matchAll(/\(#(\d+)\)\s*$/g)) {
|
|
candidates.add(Number(match[1]));
|
|
}
|
|
}
|
|
return [...candidates].filter((number) => !associatedPullRequests.includes(number));
|
|
}
|
|
|
|
function changedPathsForCommit(hash) {
|
|
return new Set(
|
|
git(["diff-tree", "--root", "--no-commit-id", "--name-only", "-r", hash, "--"])
|
|
.split("\n")
|
|
.filter(Boolean),
|
|
);
|
|
}
|
|
|
|
function authorsMatch(left, right) {
|
|
const leftEmail = left.authorEmail?.trim().toLowerCase();
|
|
const rightEmail = right.authorEmail?.trim().toLowerCase();
|
|
if (leftEmail && rightEmail && leftEmail === rightEmail) {
|
|
return true;
|
|
}
|
|
return (
|
|
left.authorName?.trim().toLowerCase() === right.authorName?.trim().toLowerCase() &&
|
|
Boolean(left.authorName?.trim())
|
|
);
|
|
}
|
|
|
|
function pathsOverlap(left, right) {
|
|
for (const filePath of left) {
|
|
if (right.has(filePath)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function canonicalMainCommitMatches(commit, candidates) {
|
|
const exact = candidates.find((candidate) => candidate.hash === commit.hash);
|
|
if (exact) {
|
|
return [exact.hash];
|
|
}
|
|
|
|
const origins = new Set(cherryPickOrigins(commit.body));
|
|
const explicit = candidates
|
|
.filter((candidate) => origins.has(candidate.hash))
|
|
.map((candidate) => candidate.hash);
|
|
if (explicit.length > 0) {
|
|
return [...new Set(explicit)];
|
|
}
|
|
|
|
const pullRequestOrigins = new Set(backportPullRequestOrigins(commit.body));
|
|
if (pullRequestOrigins.size > 0) {
|
|
const pullRequestMatches = candidates.filter(
|
|
(candidate) =>
|
|
(candidate.pullRequests ?? []).some((number) => pullRequestOrigins.has(number)) &&
|
|
authorsMatch(commit, candidate) &&
|
|
pathsOverlap(commit.changedPaths, candidate.changedPaths),
|
|
);
|
|
return pullRequestMatches.length === 1 ? [pullRequestMatches[0].hash] : [];
|
|
}
|
|
|
|
const subject = normalizedCommitSubject(commit.subject);
|
|
const matches = candidates.filter(
|
|
(candidate) =>
|
|
normalizedCommitSubject(candidate.subject) === subject &&
|
|
authorsMatch(commit, candidate) &&
|
|
pathsOverlap(commit.changedPaths, candidate.changedPaths),
|
|
);
|
|
return matches.length === 1 ? [matches[0].hash] : [];
|
|
}
|
|
|
|
export function canonicalPullRequests(
|
|
currentPullRequests,
|
|
mainPullRequests,
|
|
hasCanonicalMainCommit = mainPullRequests.length > 0,
|
|
) {
|
|
const pullRequests = hasCanonicalMainCommit ? mainPullRequests : currentPullRequests;
|
|
return [...new Set(pullRequests)].toSorted((left, right) => left - right);
|
|
}
|
|
|
|
function canonicalMainCommits(base, mainRef) {
|
|
if (!mainRef) {
|
|
return [];
|
|
}
|
|
const mainCommit = /** @type {string} */ (gitCommit(mainRef, true));
|
|
const mainBase = git(["merge-base", base, mainCommit]);
|
|
const output = git([
|
|
"log",
|
|
"--reverse",
|
|
"--format=%H%x1f%s%x1f%an%x1f%ae%x1f%B%x1e",
|
|
`${mainBase}..${mainCommit}`,
|
|
]);
|
|
const commits = [];
|
|
for (const record of output.split("\x1e")) {
|
|
if (!record) {
|
|
continue;
|
|
}
|
|
const [rawHash, subject, authorName, authorEmail, ...bodyParts] = record.split("\x1f");
|
|
const hash = rawHash.trim();
|
|
commits.push({
|
|
authorEmail,
|
|
authorName,
|
|
body: bodyParts.join("\x1f"),
|
|
hash,
|
|
subject,
|
|
});
|
|
}
|
|
return commits;
|
|
}
|
|
|
|
function sourceCommits(base, target, mainRef, releaseProvenance = []) {
|
|
const targetCommit = git(["rev-parse", `${target}^{commit}`]);
|
|
if (!gitIsAncestor(base, targetCommit)) {
|
|
fail(`release range base ${base} must be an ancestor of target ${target}`);
|
|
}
|
|
const mergeBase = git(["merge-base", base, targetCommit]);
|
|
const targetTimestamp = Date.parse(git(["show", "-s", "--format=%cI", targetCommit]));
|
|
if (!Number.isFinite(targetTimestamp)) {
|
|
fail(`could not resolve timestamp for release target ${target}`);
|
|
}
|
|
const output = git([
|
|
"log",
|
|
"--first-parent",
|
|
"--reverse",
|
|
"--format=%H%x1f%s%x1f%an%x1f%ae%x1f%cI%x1f%B%x1e",
|
|
`${mergeBase}..${targetCommit}`,
|
|
]);
|
|
const commits = new Map();
|
|
const revertsByTarget = new Map();
|
|
for (const record of output.split("\x1e")) {
|
|
if (!record) {
|
|
continue;
|
|
}
|
|
const [rawHash, subject, authorName, authorEmail, committedAt, ...bodyParts] =
|
|
record.split("\x1f");
|
|
const hash = rawHash.trim();
|
|
const body = bodyParts.join("\x1f");
|
|
const revertedHash = standardRevertedHash(body);
|
|
const isRevert = Boolean(revertedHash) || subject.startsWith('Revert "');
|
|
commits.set(hash, {
|
|
authorEmail,
|
|
authorName,
|
|
body,
|
|
committedAt,
|
|
hash,
|
|
isRevert,
|
|
revertedHash,
|
|
subject,
|
|
});
|
|
}
|
|
for (const commit of commits.values()) {
|
|
if (!commit.revertedHash) {
|
|
continue;
|
|
}
|
|
const targetHash = [...commits.keys()].find((candidate) =>
|
|
candidate.startsWith(commit.revertedHash),
|
|
);
|
|
if (targetHash) {
|
|
const reverts = revertsByTarget.get(targetHash) ?? [];
|
|
reverts.push(commit.hash);
|
|
revertsByTarget.set(targetHash, reverts);
|
|
}
|
|
}
|
|
const active = new Map();
|
|
function isActive(hash) {
|
|
if (active.has(hash)) {
|
|
return active.get(hash);
|
|
}
|
|
const cancellingReverts = revertsByTarget.get(hash) ?? [];
|
|
const value = !cancellingReverts.some((revertHash) => isActive(revertHash));
|
|
active.set(hash, value);
|
|
return value;
|
|
}
|
|
const revertedCommitStates = new Map();
|
|
function revertedCommitState(ref, seen = new Set()) {
|
|
let hash;
|
|
try {
|
|
hash = git(["rev-parse", `${ref}^{commit}`]);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
const cached = revertedCommitStates.get(hash);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
if (seen.has(hash)) {
|
|
fail(`cyclic revert history at ${hash}`);
|
|
}
|
|
seen.add(hash);
|
|
const commitOutput = git(["show", "-s", "--format=%s%x1f%B", hash]);
|
|
const [subject, ...bodyParts] = commitOutput.split("\x1f");
|
|
const body = bodyParts.join("\x1f");
|
|
const message = `${subject}\n${body}`;
|
|
const revertedHash = standardRevertedHash(body);
|
|
const targetState = revertedHash ? revertedCommitState(revertedHash, seen) : undefined;
|
|
const state = targetState
|
|
? { ...targetState, depth: targetState.depth + 1 }
|
|
: { depth: 0, hash, references: referencesIn(message) };
|
|
revertedCommitStates.set(hash, state);
|
|
return state;
|
|
}
|
|
|
|
const references = [];
|
|
const revertedReferences = new Set();
|
|
const revertedCommitHashes = new Set();
|
|
const coauthorsByReference = new Map();
|
|
const activeCommits = [];
|
|
for (const commit of commits.values()) {
|
|
if (commit.isRevert && isActive(commit.hash)) {
|
|
const coauthorEmails = [...commit.body.matchAll(/^Co-authored-by:\s*.+?<([^>\s]+)>$/gim)].map(
|
|
(match) => match[1],
|
|
);
|
|
activeCommits.push({
|
|
authorEmail: commit.authorEmail,
|
|
authorHandle: githubHandleFromNoreply(commit.authorEmail),
|
|
authorName: commit.authorName,
|
|
body: commit.body,
|
|
closingReferences: [],
|
|
committedAt: commit.committedAt,
|
|
coauthors: coauthorEmails.map(githubHandleFromNoreply).filter(isEligibleHandle),
|
|
coauthorEmails,
|
|
hash: commit.hash,
|
|
isRevert: true,
|
|
pullRequests: [],
|
|
references: [],
|
|
subject: commit.subject,
|
|
});
|
|
continue;
|
|
}
|
|
if (commit.isRevert) {
|
|
continue;
|
|
}
|
|
const uniqueReferences = [...new Set(referencesIn(`${commit.subject}\n${commit.body}`))];
|
|
if (!isActive(commit.hash)) {
|
|
revertedCommitHashes.add(commit.hash);
|
|
for (const number of uniqueReferences) {
|
|
revertedReferences.add(number);
|
|
}
|
|
continue;
|
|
}
|
|
const coauthorEmails = [...commit.body.matchAll(/^Co-authored-by:\s*.+?<([^>\s]+)>$/gim)].map(
|
|
(match) => match[1],
|
|
);
|
|
const coauthors = coauthorEmails.map(githubHandleFromNoreply).filter(isEligibleHandle);
|
|
activeCommits.push({
|
|
authorEmail: commit.authorEmail,
|
|
authorHandle: githubHandleFromNoreply(commit.authorEmail),
|
|
authorName: commit.authorName,
|
|
body: commit.body,
|
|
closingReferences: closingReferencesIn(`${commit.subject}\n${commit.body}`),
|
|
committedAt: commit.committedAt,
|
|
coauthors,
|
|
coauthorEmails,
|
|
hash: commit.hash,
|
|
isRevert: false,
|
|
pullRequests: [],
|
|
references: uniqueReferences,
|
|
subject: commit.subject,
|
|
});
|
|
}
|
|
for (const commit of commits.values()) {
|
|
if (!commit.isRevert || !commit.revertedHash || !isActive(commit.hash)) {
|
|
continue;
|
|
}
|
|
const targetInRange = [...commits.keys()].some((candidate) =>
|
|
candidate.startsWith(commit.revertedHash),
|
|
);
|
|
if (targetInRange) {
|
|
continue;
|
|
}
|
|
const revertedState = revertedCommitState(commit.revertedHash);
|
|
if (!revertedState) {
|
|
continue;
|
|
}
|
|
if (revertedState.depth % 2 !== 0) {
|
|
continue;
|
|
}
|
|
revertedCommitHashes.add(revertedState.hash);
|
|
for (const number of revertedState.references) {
|
|
revertedReferences.add(number);
|
|
}
|
|
}
|
|
const activePullRequests = resolveAssociatedPullRequests(
|
|
activeCommits.map((commit) => commit.hash),
|
|
targetTimestamp,
|
|
);
|
|
const provenanceOverrides = collectReleaseProvenanceOverrides(activeCommits, releaseProvenance);
|
|
const mainCommits = canonicalMainCommits(base, mainRef);
|
|
const mainCommit = provenanceOverrides.size > 0 ? gitCommit(mainRef, true) : undefined;
|
|
const mainCommitsByHash = new Map(mainCommits.map((commit) => [commit.hash, commit]));
|
|
const mainCommitsBySubject = new Map();
|
|
for (const commit of mainCommits) {
|
|
const subject = normalizedCommitSubject(commit.subject);
|
|
const matches = mainCommitsBySubject.get(subject) ?? [];
|
|
matches.push(commit);
|
|
mainCommitsBySubject.set(subject, matches);
|
|
}
|
|
const canonicalMainCommitsByReleaseCommit = new Map();
|
|
const namedMainPullRequestsByReleaseCommit = new Map();
|
|
const canonicalMainHashes = new Set();
|
|
const mainAssociationCandidateHashes = new Set();
|
|
const pendingCanonicalMatches = [];
|
|
const changedPathsByCommit = new Map();
|
|
const withChangedPaths = (commit) => {
|
|
if (!changedPathsByCommit.has(commit.hash)) {
|
|
changedPathsByCommit.set(commit.hash, changedPathsForCommit(commit.hash));
|
|
}
|
|
return { ...commit, changedPaths: changedPathsByCommit.get(commit.hash) };
|
|
};
|
|
for (const commit of activeCommits) {
|
|
const exact = mainCommitsByHash.get(commit.hash);
|
|
if (exact) {
|
|
canonicalMainCommitsByReleaseCommit.set(commit.hash, []);
|
|
continue;
|
|
}
|
|
const explicit = cherryPickOrigins(commit.body)
|
|
.map((origin) => mainCommitsByHash.get(origin))
|
|
.filter(Boolean);
|
|
if (explicit.length > 0) {
|
|
const matches = [...new Set(explicit.map((candidate) => candidate.hash))];
|
|
canonicalMainCommitsByReleaseCommit.set(commit.hash, matches);
|
|
for (const hash of matches) {
|
|
canonicalMainHashes.add(hash);
|
|
}
|
|
continue;
|
|
}
|
|
const releaseCommit = withChangedPaths(commit);
|
|
const pullRequestOrigins = backportPullRequestOrigins(commit.body);
|
|
const candidates = new Map(
|
|
(mainCommitsBySubject.get(normalizedCommitSubject(commit.subject)) ?? []).map((candidate) => [
|
|
candidate.hash,
|
|
candidate,
|
|
]),
|
|
);
|
|
if (pullRequestOrigins.length > 0) {
|
|
for (const candidate of mainCommits) {
|
|
if (!authorsMatch(releaseCommit, candidate)) {
|
|
continue;
|
|
}
|
|
const mainCandidate = withChangedPaths(candidate);
|
|
if (!pathsOverlap(releaseCommit.changedPaths, mainCandidate.changedPaths)) {
|
|
continue;
|
|
}
|
|
candidates.set(candidate.hash, candidate);
|
|
mainAssociationCandidateHashes.add(candidate.hash);
|
|
}
|
|
}
|
|
pendingCanonicalMatches.push({ candidates, commit: releaseCommit, pullRequestOrigins });
|
|
}
|
|
const candidateMainPullRequests = resolveAssociatedPullRequests(
|
|
[...mainAssociationCandidateHashes],
|
|
Number.POSITIVE_INFINITY,
|
|
);
|
|
for (const { candidates, commit, pullRequestOrigins } of pendingCanonicalMatches) {
|
|
const matches = canonicalMainCommitMatches(
|
|
commit,
|
|
[...candidates.values()].map((candidate) =>
|
|
Object.assign({}, withChangedPaths(candidate), {
|
|
pullRequests: candidateMainPullRequests.get(candidate.hash) ?? [],
|
|
}),
|
|
),
|
|
);
|
|
canonicalMainCommitsByReleaseCommit.set(commit.hash, matches);
|
|
if (pullRequestOrigins.length > 0 && matches.length === 1) {
|
|
const associatedPullRequests = candidateMainPullRequests.get(matches[0]) ?? [];
|
|
const namedPullRequests = pullRequestOrigins.filter((number) =>
|
|
associatedPullRequests.includes(number),
|
|
);
|
|
if (namedPullRequests.length > 0) {
|
|
namedMainPullRequestsByReleaseCommit.set(commit.hash, namedPullRequests);
|
|
}
|
|
}
|
|
for (const hash of matches) {
|
|
canonicalMainHashes.add(hash);
|
|
}
|
|
}
|
|
const canonicalMainPullRequests = resolveAssociatedPullRequests(
|
|
[...canonicalMainHashes],
|
|
Number.POSITIVE_INFINITY,
|
|
);
|
|
const resolvedCoauthors = resolveCommitCoauthors(activeCommits);
|
|
const pullRequests = new Set();
|
|
const nonRevertPullRequests = new Set();
|
|
for (const commit of activeCommits) {
|
|
const currentPullRequests = activePullRequests.get(commit.hash) ?? [];
|
|
const namedMainPullRequests = namedMainPullRequestsByReleaseCommit.get(commit.hash);
|
|
const mainPullRequests =
|
|
namedMainPullRequests ??
|
|
(canonicalMainCommitsByReleaseCommit.get(commit.hash) ?? []).flatMap(
|
|
(hash) => canonicalMainPullRequests.get(hash) ?? [],
|
|
);
|
|
const matchedMainCommits = canonicalMainCommitsByReleaseCommit.get(commit.hash) ?? [];
|
|
const provenanceOverride = provenanceOverrides.get(commit.hash);
|
|
const associatedPullRequests = resolvedReleasePullRequests(
|
|
currentPullRequests,
|
|
mainPullRequests,
|
|
matchedMainCommits.length > 0,
|
|
provenanceOverride,
|
|
);
|
|
commit.pullRequests = associatedPullRequests;
|
|
const suppressedBackportPullRequests = new Set(
|
|
releasePullRequestReferencesToSuppress(
|
|
currentPullRequests,
|
|
commit.subject,
|
|
associatedPullRequests,
|
|
provenanceOverride !== undefined,
|
|
),
|
|
);
|
|
commit.references = commit.references.filter(
|
|
(number) => !suppressedBackportPullRequests.has(number),
|
|
);
|
|
addHandles(commit.coauthors, resolvedCoauthors.get(commit.hash) ?? []);
|
|
appendReferences(commit.references, associatedPullRequests);
|
|
for (const number of associatedPullRequests) {
|
|
pullRequests.add(number);
|
|
if (!commit.isRevert) {
|
|
nonRevertPullRequests.add(number);
|
|
}
|
|
}
|
|
appendReferences(references, commit.references);
|
|
if (commit.coauthors.length === 0) {
|
|
continue;
|
|
}
|
|
for (const number of commit.references) {
|
|
const handles = coauthorsByReference.get(number) ?? new Set();
|
|
for (const handle of commit.coauthors) {
|
|
handles.add(handle);
|
|
}
|
|
coauthorsByReference.set(number, handles);
|
|
}
|
|
}
|
|
const revertedPullRequests = new Set();
|
|
for (const revertedPullRequestNumbers of resolveAssociatedPullRequests(
|
|
[...revertedCommitHashes],
|
|
targetTimestamp,
|
|
).values()) {
|
|
for (const number of revertedPullRequestNumbers) {
|
|
revertedPullRequests.add(number);
|
|
}
|
|
}
|
|
// A later active implementation supersedes an earlier reverted fix, including
|
|
// direct commits that cite the same issue without having a recoverable PR.
|
|
for (const commit of activeCommits) {
|
|
if (commit.isRevert) {
|
|
continue;
|
|
}
|
|
for (const number of commit.references) {
|
|
revertedReferences.delete(number);
|
|
}
|
|
}
|
|
// A PR can span several commits. A reverted commit does not erase the PR while
|
|
// another non-revert commit from it remains active in this release range.
|
|
for (const number of revertedPullRequests) {
|
|
if (!nonRevertPullRequests.has(number)) {
|
|
pullRequests.delete(number);
|
|
revertedReferences.add(number);
|
|
}
|
|
}
|
|
for (const number of pullRequests) {
|
|
revertedReferences.delete(number);
|
|
}
|
|
|
|
return {
|
|
activeCommits,
|
|
coauthorsByReference,
|
|
mainCommit,
|
|
mergeBase,
|
|
pullRequests,
|
|
provenanceOverrides,
|
|
references,
|
|
revertedReferences,
|
|
target: targetCommit,
|
|
targetTimestamp,
|
|
};
|
|
}
|
|
|
|
function graphql(query) {
|
|
let lastError;
|
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
try {
|
|
const response = githubApi(["graphql", "-f", `query=${query}`]);
|
|
if (response?.data && typeof response.data === "object") {
|
|
return response.data;
|
|
}
|
|
const errors = Array.isArray(response?.errors)
|
|
? response.errors.map((error) => error?.message).filter(Boolean)
|
|
: [];
|
|
const detail = [...errors, response?.message].filter(Boolean).join("\n");
|
|
throw new Error(
|
|
detail
|
|
? `GitHub GraphQL response did not include data:\n${detail}`
|
|
: "GitHub GraphQL response did not include data.",
|
|
);
|
|
} catch (error) {
|
|
lastError = error;
|
|
const message = [error?.message, error?.stdout, error?.stderr].filter(Boolean).join("\n");
|
|
// Historical ranges batch hundreds of objects; only retry transient transport failures.
|
|
if (
|
|
!/(?:operation timed out|ECONNRESET|ETIMEDOUT|EAI_AGAIN|TLS handshake timeout|stream error: .*CANCEL|unexpected end of JSON input|upstream connect error|connection termination|connection reset by peer|error connecting to api\.github\.com|Unexpected token '<'|something went wrong|temporarily unavailable|internal server error|rate limit)/i.test(
|
|
message,
|
|
)
|
|
) {
|
|
throw error;
|
|
}
|
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500 * 2 ** attempt);
|
|
}
|
|
}
|
|
throw lastError;
|
|
}
|
|
|
|
function resolveAssociatedPullRequests(commitHashes, targetTimestamp) {
|
|
const pullRequestsByCommit = new Map();
|
|
const pending = [];
|
|
function appendPullRequests(commitHash, connection) {
|
|
const pullRequests = pullRequestsByCommit.get(commitHash) ?? [];
|
|
const seen = new Set(pullRequests);
|
|
for (const pullRequest of connection?.nodes ?? []) {
|
|
// GitHub's mergedAt can trail the merge commit timestamp by a second.
|
|
// Keep an exact merge-commit association so a release ending there does not drop its PR.
|
|
const isExactMergeCommit = pullRequest.mergeCommit?.oid === commitHash;
|
|
if (
|
|
pullRequest.mergedAt &&
|
|
(isExactMergeCommit || mergedByTarget(pullRequest.mergedAt, targetTimestamp)) &&
|
|
!seen.has(pullRequest.number)
|
|
) {
|
|
pullRequests.push(pullRequest.number);
|
|
seen.add(pullRequest.number);
|
|
}
|
|
}
|
|
pullRequestsByCommit.set(commitHash, pullRequests);
|
|
if (connection?.pageInfo?.hasNextPage) {
|
|
pending.push({ commitHash, cursor: connection.pageInfo.endCursor });
|
|
}
|
|
}
|
|
for (let index = 0; index < commitHashes.length; index += commitAssociationQueryBatchSize) {
|
|
const chunk = commitHashes.slice(index, index + commitAssociationQueryBatchSize);
|
|
const fields = chunk
|
|
.map(
|
|
(hash, offset) =>
|
|
`c${index + offset}: repository(owner: "openclaw", name: "openclaw") {
|
|
object(expression: ${JSON.stringify(hash)}) {
|
|
... on Commit {
|
|
associatedPullRequests(first: 100) {
|
|
nodes {
|
|
number
|
|
mergedAt
|
|
mergeCommit { oid }
|
|
}
|
|
pageInfo { hasNextPage endCursor }
|
|
}
|
|
}
|
|
}
|
|
}`,
|
|
)
|
|
.join("\n");
|
|
const data = graphql(`query { ${fields} }`);
|
|
for (let offset = 0; offset < chunk.length; offset += 1) {
|
|
appendPullRequests(chunk[offset], data[`c${index + offset}`]?.object?.associatedPullRequests);
|
|
}
|
|
}
|
|
while (pending.length > 0) {
|
|
const chunk = pending.splice(0, 20);
|
|
const fields = chunk
|
|
.map(
|
|
(item, offset) =>
|
|
`c${offset}: repository(owner: "openclaw", name: "openclaw") {
|
|
object(expression: ${JSON.stringify(item.commitHash)}) {
|
|
... on Commit {
|
|
associatedPullRequests(first: 100, after: ${JSON.stringify(item.cursor)}) {
|
|
nodes {
|
|
number
|
|
mergedAt
|
|
mergeCommit { oid }
|
|
}
|
|
pageInfo { hasNextPage endCursor }
|
|
}
|
|
}
|
|
}
|
|
}`,
|
|
)
|
|
.join("\n");
|
|
const data = graphql(`query { ${fields} }`);
|
|
for (let offset = 0; offset < chunk.length; offset += 1) {
|
|
appendPullRequests(
|
|
chunk[offset].commitHash,
|
|
data[`c${offset}`]?.object?.associatedPullRequests,
|
|
);
|
|
}
|
|
}
|
|
return pullRequestsByCommit;
|
|
}
|
|
|
|
function issueConnectionName(node) {
|
|
if (node.__typename === "Issue") {
|
|
return "closedByPullRequestsReferences";
|
|
}
|
|
if (node.__typename === "PullRequest") {
|
|
return "closingIssuesReferences";
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function resolveIssueRelationshipPages(nodes) {
|
|
const pending = [];
|
|
for (const [number, node] of nodes) {
|
|
const connectionName = issueConnectionName(node);
|
|
const pageInfo = connectionName ? node[connectionName]?.pageInfo : undefined;
|
|
if (pageInfo?.hasNextPage) {
|
|
pending.push({ connectionName, cursor: pageInfo.endCursor, number, type: node.__typename });
|
|
}
|
|
}
|
|
while (pending.length > 0) {
|
|
const chunk = pending.splice(0, 20);
|
|
const fields = chunk
|
|
.map((item, offset) => {
|
|
const connection = `${item.connectionName}(first: 100, after: ${JSON.stringify(item.cursor)}) {
|
|
nodes { number }
|
|
pageInfo { hasNextPage endCursor }
|
|
}`;
|
|
return `n${offset}: repository(owner: "openclaw", name: "openclaw") {
|
|
issueOrPullRequest(number: ${item.number}) {
|
|
... on ${item.type} {
|
|
${connection}
|
|
}
|
|
}
|
|
}`;
|
|
})
|
|
.join("\n");
|
|
const data = graphql(`query { ${fields} }`);
|
|
for (let offset = 0; offset < chunk.length; offset += 1) {
|
|
const item = chunk[offset];
|
|
const node = nodes.get(item.number);
|
|
const connection = data[`n${offset}`]?.issueOrPullRequest?.[item.connectionName];
|
|
if (!node || !connection) {
|
|
continue;
|
|
}
|
|
node[item.connectionName] = {
|
|
nodes: [...(node[item.connectionName]?.nodes ?? []), ...connection.nodes],
|
|
pageInfo: connection.pageInfo,
|
|
};
|
|
if (connection.pageInfo.hasNextPage) {
|
|
pending.push({
|
|
connectionName: item.connectionName,
|
|
cursor: connection.pageInfo.endCursor,
|
|
number: item.number,
|
|
type: item.type,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return nodes;
|
|
}
|
|
|
|
function resolveReferences(numbers) {
|
|
const nodes = new Map();
|
|
for (let index = 0; index < numbers.length; index += 40) {
|
|
const chunk = numbers.slice(index, index + 40);
|
|
const fields = chunk
|
|
.map(
|
|
(number) => `n${number}: repository(owner: "openclaw", name: "openclaw") {
|
|
issueOrPullRequest(number: ${number}) {
|
|
__typename
|
|
... on Issue {
|
|
number
|
|
title
|
|
author { __typename login }
|
|
closedByPullRequestsReferences(first: 100) {
|
|
nodes { number }
|
|
pageInfo { hasNextPage endCursor }
|
|
}
|
|
}
|
|
... on PullRequest {
|
|
number
|
|
title
|
|
baseRefName
|
|
mergedAt
|
|
mergeCommit { oid }
|
|
author { __typename login }
|
|
closingIssuesReferences(first: 100) {
|
|
nodes { number }
|
|
pageInfo { hasNextPage endCursor }
|
|
}
|
|
}
|
|
}
|
|
}`,
|
|
)
|
|
.join("\n");
|
|
const data = graphql(`query { ${fields} }`);
|
|
for (const number of chunk) {
|
|
const node = data[`n${number}`]?.issueOrPullRequest;
|
|
if (node) {
|
|
nodes.set(number, node);
|
|
}
|
|
}
|
|
}
|
|
return resolveIssueRelationshipPages(nodes);
|
|
}
|
|
|
|
// A vanished GitHub PR is recoverable only when a prior exact-SHA ledger
|
|
// covered its exact merge-title commit; every other unresolved ref stays fatal.
|
|
export function recoverUnavailablePullRequests({
|
|
numbers,
|
|
nodes,
|
|
record,
|
|
recordTarget,
|
|
source,
|
|
isAncestor = gitIsAncestor,
|
|
}) {
|
|
const recovered = new Map();
|
|
if (!recordTarget || !isAncestor(recordTarget, source.target)) {
|
|
return recovered;
|
|
}
|
|
for (const number of numbers) {
|
|
if (nodes.has(number)) {
|
|
continue;
|
|
}
|
|
const recorded = record.pullRequests.get(number);
|
|
if (!recorded) {
|
|
continue;
|
|
}
|
|
const commits = source.activeCommits
|
|
.map((commit) => ({
|
|
commit,
|
|
title: pullRequestTitleFromCommitSubject(commit.subject, number),
|
|
}))
|
|
.filter(
|
|
({ commit, title }) =>
|
|
title &&
|
|
commit.references.includes(number) &&
|
|
Number.isFinite(Date.parse(commit.committedAt)),
|
|
);
|
|
if (commits.length !== 1) {
|
|
continue;
|
|
}
|
|
const { commit, title } = commits[0];
|
|
if (!isAncestor(commit.hash, recordTarget)) {
|
|
continue;
|
|
}
|
|
const authorHandle = commit.authorHandle ?? recorded.thanks[0];
|
|
const node = {
|
|
__typename: "PullRequest",
|
|
number,
|
|
title,
|
|
baseRefName: "main",
|
|
mergedAt: commit.committedAt,
|
|
mergeCommit: { oid: commit.hash },
|
|
...(authorHandle ? { author: { __typename: "User", login: authorHandle } } : {}),
|
|
closingIssuesReferences: {
|
|
nodes: commit.closingReferences.map((reference) => ({ number: reference })),
|
|
pageInfo: { hasNextPage: false, endCursor: null },
|
|
},
|
|
};
|
|
nodes.set(number, node);
|
|
recovered.set(number, node);
|
|
commit.pullRequests = [...new Set([...commit.pullRequests, number])];
|
|
source.pullRequests.add(number);
|
|
const credits = source.coauthorsByReference.get(number) ?? new Set();
|
|
for (const handle of recorded.thanks) {
|
|
credits.add(handle);
|
|
}
|
|
source.coauthorsByReference.set(number, credits);
|
|
}
|
|
return recovered;
|
|
}
|
|
|
|
export function validateReleaseProvenanceOverrides(
|
|
provenanceOverrides,
|
|
nodes,
|
|
mainCommit,
|
|
isMainAncestor = gitIsAncestor,
|
|
) {
|
|
for (const [commit, pullRequests] of provenanceOverrides) {
|
|
for (const number of pullRequests) {
|
|
const node = nodes.get(number);
|
|
// Markers may name current-main forward-ports, but never release-only or unmerged PRs.
|
|
if (
|
|
node?.__typename !== "PullRequest" ||
|
|
node.baseRefName !== "main" ||
|
|
!node.mergedAt ||
|
|
!node.mergeCommit?.oid ||
|
|
!isMainAncestor(node.mergeCommit.oid, mainCommit)
|
|
) {
|
|
fail(`release provenance marker for ${commit} references non-main PR #${number}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function resolveGitHubHandles(handles) {
|
|
const resolved = new Map();
|
|
const uniqueHandles = [...new Set(handles)];
|
|
for (let index = 0; index < uniqueHandles.length; index += 80) {
|
|
const chunk = uniqueHandles.slice(index, index + 80);
|
|
const fields = chunk
|
|
.map(
|
|
(handle, offset) =>
|
|
`u${index + offset}: user(login: ${JSON.stringify(handle)}) { __typename login }`,
|
|
)
|
|
.join("\n");
|
|
const data = graphql(`query { ${fields} }`);
|
|
for (let offset = 0; offset < chunk.length; offset += 1) {
|
|
const user = data[`u${index + offset}`];
|
|
if (user?.__typename === "User" && isEligibleHandle(user.login)) {
|
|
resolved.set(chunk[offset].toLowerCase(), user.login);
|
|
}
|
|
}
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
function resolveDirectCommitAuthors(commits) {
|
|
const resolved = new Map();
|
|
const commitsWithoutGitHubHandle = commits.filter((commit) => !commit.author?.handle);
|
|
for (let index = 0; index < commitsWithoutGitHubHandle.length; index += 40) {
|
|
const chunk = commitsWithoutGitHubHandle.slice(index, index + 40);
|
|
const fields = chunk
|
|
.map(
|
|
(commit, offset) =>
|
|
`c${index + offset}: repository(owner: "openclaw", name: "openclaw") {
|
|
object(expression: ${JSON.stringify(commit.hash)}) {
|
|
... on Commit {
|
|
author {
|
|
user {
|
|
login
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`,
|
|
)
|
|
.join("\n");
|
|
const data = graphql(`query { ${fields} }`);
|
|
for (let offset = 0; offset < chunk.length; offset += 1) {
|
|
const author = data[`c${index + offset}`]?.object?.author?.user;
|
|
if (author?.login && isEligibleHandle(author.login)) {
|
|
resolved.set(chunk[offset].hash, author.login);
|
|
}
|
|
}
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
function resolveCommitCoauthors(commits) {
|
|
const resolved = new Map();
|
|
const commitsWithCoauthors = commits.filter((commit) => commit.coauthorEmails.length > 0);
|
|
for (let index = 0; index < commitsWithCoauthors.length; index += 40) {
|
|
const chunk = commitsWithCoauthors.slice(index, index + 40);
|
|
const fields = chunk
|
|
.map(
|
|
(commit, offset) =>
|
|
`c${index + offset}: repository(owner: "openclaw", name: "openclaw") {
|
|
object(expression: ${JSON.stringify(commit.hash)}) {
|
|
... on Commit {
|
|
authors(first: 20) {
|
|
nodes {
|
|
email
|
|
user { login }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`,
|
|
)
|
|
.join("\n");
|
|
const data = graphql(`query { ${fields} }`);
|
|
for (let offset = 0; offset < chunk.length; offset += 1) {
|
|
const coauthorEmails = new Set(
|
|
chunk[offset].coauthorEmails.map((email) => email.toLowerCase()),
|
|
);
|
|
const handles =
|
|
data[`c${index + offset}`]?.object?.authors?.nodes
|
|
.filter((author) => coauthorEmails.has(author.email?.toLowerCase()))
|
|
.map((author) => author.user?.login)
|
|
.filter(isEligibleHandle) ?? [];
|
|
resolved.set(chunk[offset].hash, handles);
|
|
}
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
function withDirectCommitAuthors(commits, resolvedAuthors) {
|
|
return commits.map((commit) => {
|
|
const authorHandle = resolvedAuthors.get(commit.hash) ?? commit.author?.handle;
|
|
const contributors = [];
|
|
if (authorHandle) {
|
|
contributors.push(authorHandle);
|
|
}
|
|
addHandles(contributors, commit.contributors);
|
|
return {
|
|
...commit,
|
|
author: {
|
|
handle: authorHandle,
|
|
name: commit.author?.name ?? commit.authorName,
|
|
},
|
|
contributors,
|
|
};
|
|
});
|
|
}
|
|
|
|
function thanksFor(node, coauthorHandles) {
|
|
const handles = [];
|
|
if (node.author?.__typename === "User" && isEligibleHandle(node.author.login)) {
|
|
handles.push(node.author.login);
|
|
}
|
|
for (const handle of coauthorHandles) {
|
|
if (!handles.some((candidate) => candidate.toLowerCase() === handle.toLowerCase())) {
|
|
handles.push(handle);
|
|
}
|
|
}
|
|
return handles;
|
|
}
|
|
|
|
function addHandles(handles, additions) {
|
|
for (const handle of additions) {
|
|
if (!isEligibleHandle(handle)) {
|
|
continue;
|
|
}
|
|
if (!handles.some((candidate) => candidate.toLowerCase() === handle.toLowerCase())) {
|
|
handles.push(handle);
|
|
}
|
|
}
|
|
return handles;
|
|
}
|
|
|
|
function titleReferences(entries) {
|
|
return [...new Set(entries.flatMap((entry) => referencesIn(entry.title)))];
|
|
}
|
|
|
|
function releaseTitle(title) {
|
|
return title;
|
|
}
|
|
|
|
function withSentenceEnding(value) {
|
|
return /[.!?]$/.test(value) ? value : `${value}.`;
|
|
}
|
|
|
|
function formatThanks(handles) {
|
|
const mentions = handles.map((handle) => `@${handle}`);
|
|
if (mentions.length <= 1) {
|
|
return mentions[0] ?? "";
|
|
}
|
|
if (mentions.length === 2) {
|
|
return mentions.join(" and ");
|
|
}
|
|
return `${mentions.slice(0, -1).join(", ")}, and ${mentions.at(-1)}`;
|
|
}
|
|
|
|
function directCommitTitleTokens(subject) {
|
|
const title = subject.replace(/^\s*[a-z]+(?:\([^)]*\))?!?:\s*/i, "");
|
|
return [...new Set(title.toLowerCase().match(/[a-z0-9][a-z0-9-]{3,}/g) ?? [])].filter(
|
|
(token) => !genericDirectCommitTerms.has(token),
|
|
);
|
|
}
|
|
|
|
function lineHasTerm(line, term) {
|
|
return new RegExp(`\\b${escapeRegExp(term)}\\b`, "i").test(line);
|
|
}
|
|
|
|
function directCommitMatchesLine(commit, line) {
|
|
if (!line.startsWith("- ")) {
|
|
return false;
|
|
}
|
|
if (commit.closingReferences.some((number) => referencesIn(line).includes(number))) {
|
|
return true;
|
|
}
|
|
const matchingTerms = directCommitTitleTokens(commit.subject).filter((token) =>
|
|
lineHasTerm(line, token),
|
|
);
|
|
return matchingTerms.length >= 2;
|
|
}
|
|
|
|
function directCommitCreditsForLine(line, directCommits) {
|
|
const contributors = [];
|
|
for (const commit of directCommits) {
|
|
if (
|
|
!editorialClassification(commit.subject).editorialEligible ||
|
|
!directCommitMatchesLine(commit, line)
|
|
) {
|
|
continue;
|
|
}
|
|
addHandles(contributors, commit.contributors);
|
|
}
|
|
return contributors;
|
|
}
|
|
|
|
function completeEditorialCredits(prose, pullRequests, directCommits) {
|
|
const pullRequestsByNumber = new Map(
|
|
pullRequests.map((pullRequest) => [pullRequest.number, pullRequest]),
|
|
);
|
|
return prose
|
|
.split("\n")
|
|
.map((line) => {
|
|
if (!line.startsWith("- ")) {
|
|
return line;
|
|
}
|
|
const contributors = [];
|
|
for (const number of referencesIn(line)) {
|
|
addHandles(contributors, pullRequestsByNumber.get(number)?.thanks ?? []);
|
|
}
|
|
addHandles(contributors, directCommitCreditsForLine(line, directCommits));
|
|
if (contributors.length === 0) {
|
|
return line;
|
|
}
|
|
const existingContributors = handlesIn(line);
|
|
addHandles(existingContributors, contributors);
|
|
const thanksStart = line.lastIndexOf(" Thanks ");
|
|
const rawContent = thanksStart >= 0 ? line.slice(0, thanksStart) : line;
|
|
const content =
|
|
referencesIn(rawContent).length === 0
|
|
? withSentenceEnding(rawContent)
|
|
: rawContent.replace(/[.!?]$/, "");
|
|
return `${content} Thanks ${formatThanks(existingContributors)}.`;
|
|
})
|
|
.join("\n");
|
|
}
|
|
|
|
function issueEntries(numbers, nodes, priorIssues = new Map()) {
|
|
return [...new Set(numbers)]
|
|
.map((number) => {
|
|
const node = nodes.get(number);
|
|
if (node?.__typename !== "Issue") {
|
|
return undefined;
|
|
}
|
|
const thanks = thanksFor(node, []);
|
|
addHandles(thanks, priorIssues.get(number)?.thanks ?? []);
|
|
return {
|
|
number,
|
|
thanks,
|
|
title: node.title.replace(/\s+/g, " ").trim(),
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function legacyIssuesByPullRequest(priorRecord, nodes) {
|
|
const result = new Map();
|
|
for (const number of priorRecord.legacyIssues.keys()) {
|
|
const issue = nodes.get(number);
|
|
if (issue?.__typename !== "Issue") {
|
|
continue;
|
|
}
|
|
const pullRequests =
|
|
issue.closedByPullRequestsReferences?.nodes.map((pullRequest) => pullRequest.number) ?? [];
|
|
for (const pullRequest of new Set(pullRequests)) {
|
|
const issues = result.get(pullRequest) ?? [];
|
|
issues.push(number);
|
|
result.set(pullRequest, issues);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function contributionRelationships(source, nodes, resolvedContributors) {
|
|
const issuesByPullRequest = new Map();
|
|
const directCommits = [];
|
|
for (const commit of source.activeCommits) {
|
|
const pullRequests = commit.pullRequests;
|
|
const issues = issueEntries(commit.closingReferences, nodes);
|
|
if (pullRequests.length === 0) {
|
|
const authorHandle = commit.authorHandle
|
|
? resolvedContributors.get(commit.authorHandle.toLowerCase())
|
|
: undefined;
|
|
const contributors = [];
|
|
if (authorHandle) {
|
|
contributors.push(authorHandle);
|
|
}
|
|
addHandles(
|
|
contributors,
|
|
commit.coauthors
|
|
.map((handle) => resolvedContributors.get(handle.toLowerCase()))
|
|
.filter(Boolean),
|
|
);
|
|
directCommits.push({
|
|
...commit,
|
|
author: { handle: authorHandle, name: commit.authorName },
|
|
contributors,
|
|
issues,
|
|
});
|
|
continue;
|
|
}
|
|
if (issues.length === 0) {
|
|
continue;
|
|
}
|
|
for (const number of pullRequests) {
|
|
const existing = issuesByPullRequest.get(number) ?? [];
|
|
issuesByPullRequest.set(number, [...existing, ...issues]);
|
|
}
|
|
}
|
|
return { directCommits, issuesByPullRequest };
|
|
}
|
|
|
|
function mergeIssues(...groups) {
|
|
const entries = new Map();
|
|
for (const group of groups) {
|
|
for (const issue of group) {
|
|
const existing = entries.get(issue.number);
|
|
if (existing) {
|
|
addHandles(existing.thanks, issue.thanks);
|
|
} else {
|
|
entries.set(issue.number, { ...issue, thanks: [...issue.thanks] });
|
|
}
|
|
}
|
|
}
|
|
return [...entries.values()];
|
|
}
|
|
|
|
export function ledgerFor(
|
|
base,
|
|
target,
|
|
references,
|
|
nodes,
|
|
coauthorsByReference,
|
|
resolvedHandles,
|
|
relationships,
|
|
priorRecord,
|
|
sourcePullRequests,
|
|
sourceReferences,
|
|
noteReferences,
|
|
legacyIssuePullRequests,
|
|
revertedReferences,
|
|
shippedBaselines,
|
|
targetTimestamp,
|
|
) {
|
|
const entries = references.map((number) => {
|
|
const node = nodes.get(number);
|
|
const rawCoauthors = coauthorsByReference.get(number) ?? new Set();
|
|
const coauthors = [...rawCoauthors]
|
|
.map((handle) => resolvedHandles.get(handle.toLowerCase()))
|
|
.filter(Boolean);
|
|
return {
|
|
number,
|
|
title: releaseTitle(node.title.replace(/\s+/g, " ").trim()),
|
|
type: node.__typename,
|
|
mergedAt: node.mergedAt,
|
|
closingIssuesReferences: node.closingIssuesReferences,
|
|
thanks: thanksFor(node, coauthors),
|
|
};
|
|
});
|
|
|
|
const recordedPullRequests = new Set([
|
|
...sourcePullRequests,
|
|
...sourceReferences,
|
|
...noteReferences,
|
|
...legacyIssuePullRequests,
|
|
...priorRecord.pullRequests.keys(),
|
|
]);
|
|
const pullRequests = entries.filter(
|
|
(entry) =>
|
|
entry.type === "PullRequest" &&
|
|
entry.mergedAt &&
|
|
(sourcePullRequests.has(entry.number) || mergedByTarget(entry.mergedAt, targetTimestamp)) &&
|
|
recordedPullRequests.has(entry.number) &&
|
|
!revertedReferences.has(entry.number),
|
|
);
|
|
const issues = entries.filter((entry) => entry.type === "Issue");
|
|
const inRangePullRequestNumbers = new Set([
|
|
...sourcePullRequests,
|
|
...[...sourceReferences].filter((number) => nodes.get(number)?.__typename === "PullRequest"),
|
|
]);
|
|
const legacyIssues = legacyIssuesByPullRequest(priorRecord, nodes);
|
|
const records = pullRequests.map((entry) => {
|
|
const priorEntry = priorRecord.pullRequests.get(entry.number);
|
|
const priorReferences = priorEntry?.references ?? [];
|
|
const titleIssues = issueEntries(referencesIn(entry.title), nodes);
|
|
const closingIssues = issueEntries(
|
|
entry.closingIssuesReferences?.nodes.map((issue) => issue.number) ?? [],
|
|
nodes,
|
|
);
|
|
const linkedIssues = mergeIssues(
|
|
titleIssues,
|
|
closingIssues,
|
|
relationships.issuesByPullRequest.get(entry.number) ?? [],
|
|
issueEntries(priorReferences, nodes),
|
|
issueEntries(legacyIssues.get(entry.number) ?? [], nodes, priorRecord.legacyIssues),
|
|
);
|
|
const thanks = [...entry.thanks];
|
|
addHandles(thanks, priorEntry?.thanks ?? []);
|
|
for (const issue of linkedIssues) {
|
|
addHandles(thanks, issue.thanks);
|
|
}
|
|
return Object.assign({}, entry, editorialClassification(entry.title), {
|
|
externalReferences: priorEntry?.externalReferences ?? [],
|
|
linkedIssues,
|
|
priorReferences,
|
|
thanks,
|
|
});
|
|
});
|
|
const inRangePullRequests = records.filter((entry) =>
|
|
inRangePullRequestNumbers.has(entry.number),
|
|
).length;
|
|
const retainedSeedOnlyPullRequests = records.length - inRangePullRequests;
|
|
const provenance = {
|
|
inRangePullRequests,
|
|
retainedSeedOnlyPullRequests,
|
|
uniquePullRequests: records.length,
|
|
};
|
|
const shippedBaselineLine = formatShippedBaselineExclusions(shippedBaselines);
|
|
const ledger = [
|
|
"### Complete contribution record",
|
|
"",
|
|
`${formatContributionRecordProvenance({ base, target, ...provenance })} The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.`,
|
|
...(shippedBaselineLine ? ["", shippedBaselineLine] : []),
|
|
"",
|
|
"#### Pull requests",
|
|
"",
|
|
...records.map((entry) => renderContributionRecordEntry(entry)),
|
|
].join("\n");
|
|
return {
|
|
entries,
|
|
issues,
|
|
ledger,
|
|
pullRequests: records,
|
|
provenance,
|
|
titleReferences: titleReferences(records),
|
|
};
|
|
}
|
|
|
|
function replaceLedger(changelog, section, ledger, pullRequests, directCommits) {
|
|
const beforeLedger = completeEditorialCredits(
|
|
section.source.replace(/\n+### Complete contribution (?:ledger|record)[\s\S]*$/m, "").trimEnd(),
|
|
pullRequests,
|
|
directCommits,
|
|
);
|
|
const replacement = `${beforeLedger}\n\n${ledger}\n`;
|
|
return `${changelog.slice(0, section.start)}${replacement}${changelog.slice(section.end)}`;
|
|
}
|
|
|
|
export function countTopLevelSectionBullets(sectionSource, heading) {
|
|
const headingMatch = new RegExp(`^### ${escapeRegExp(heading)}\\r?$`, "mu").exec(sectionSource);
|
|
if (!headingMatch || headingMatch.index === undefined) {
|
|
return 0;
|
|
}
|
|
const headingEnd = sectionSource.indexOf("\n", headingMatch.index);
|
|
const bodyStart = headingEnd < 0 ? sectionSource.length : headingEnd + 1;
|
|
const nextHeading = /^### /gmu;
|
|
nextHeading.lastIndex = bodyStart;
|
|
const end = nextHeading.exec(sectionSource)?.index ?? sectionSource.length;
|
|
return sectionSource
|
|
.slice(bodyStart, end)
|
|
.split("\n")
|
|
.filter((line) => line.startsWith("- ")).length;
|
|
}
|
|
|
|
export function highlightCountError(sectionSource) {
|
|
const count = countTopLevelSectionBullets(sectionSource, "Highlights");
|
|
return count >= 5 && count <= 8
|
|
? undefined
|
|
: `### Highlights must contain 5-8 top-level bullets; found ${count}`;
|
|
}
|
|
|
|
export function ledgerChecks(section, pullRequests, nodes, directCommits, shippedBaselines = []) {
|
|
const errors = [];
|
|
let sectionReferences = referencesIn(section.source);
|
|
if (/@undefined\b/i.test(section.source)) {
|
|
errors.push("release section contains invalid @undefined contributor credit");
|
|
}
|
|
if (!section.source.includes("### Highlights")) {
|
|
errors.push("missing ### Highlights");
|
|
} else {
|
|
const error = highlightCountError(section.source);
|
|
if (error) {
|
|
errors.push(error);
|
|
}
|
|
}
|
|
if (!section.source.includes("### Changes")) {
|
|
errors.push("missing ### Changes");
|
|
}
|
|
if (!section.source.includes("### Fixes")) {
|
|
errors.push("missing ### Fixes");
|
|
}
|
|
const ledgerStart = section.source.indexOf("### Complete contribution record");
|
|
if (ledgerStart < 0) {
|
|
errors.push("missing ### Complete contribution record");
|
|
return errors;
|
|
}
|
|
const ledger = section.source.slice(ledgerStart);
|
|
try {
|
|
const provenance = parseContributionRecordProvenance(ledger);
|
|
if (!provenance) {
|
|
errors.push("missing exact complete contribution record provenance");
|
|
} else if (
|
|
section.expectedProvenance &&
|
|
provenance.inRangePullRequests !== undefined &&
|
|
(provenance.inRangePullRequests !== section.expectedProvenance.inRangePullRequests ||
|
|
provenance.retainedSeedOnlyPullRequests !==
|
|
section.expectedProvenance.retainedSeedOnlyPullRequests)
|
|
) {
|
|
errors.push("contribution record provenance partition does not match generated inventory");
|
|
}
|
|
} catch (error) {
|
|
errors.push(error instanceof Error ? error.message : String(error));
|
|
}
|
|
const expectedShippedBaselineLine = formatShippedBaselineExclusions(shippedBaselines);
|
|
try {
|
|
const sectionShippedBaselineLine = formatShippedBaselineExclusions(
|
|
parseShippedBaselineExclusions(section.source),
|
|
);
|
|
const actualShippedBaselineLine = formatShippedBaselineExclusions(
|
|
parseShippedBaselineExclusions(ledger),
|
|
);
|
|
if (sectionShippedBaselineLine !== actualShippedBaselineLine) {
|
|
errors.push(
|
|
"shipped baseline exclusions must appear inside the complete contribution record",
|
|
);
|
|
} else if (actualShippedBaselineLine !== expectedShippedBaselineLine) {
|
|
errors.push(
|
|
`shipped baseline exclusions mismatch: expected ${
|
|
expectedShippedBaselineLine || "none"
|
|
}, found ${actualShippedBaselineLine || "none"}`,
|
|
);
|
|
} else {
|
|
sectionReferences = releaseNoteReferences(section.source, shippedBaselines);
|
|
}
|
|
} catch (error) {
|
|
errors.push(error instanceof Error ? error.message : String(error));
|
|
}
|
|
if (ledger.includes("#### Linked issues")) {
|
|
errors.push("complete contribution record must not have a linked-issues inventory");
|
|
}
|
|
if (ledger.includes("#### Direct commits")) {
|
|
errors.push("complete contribution record must not list direct commits");
|
|
}
|
|
for (const number of new Set(sectionReferences)) {
|
|
if (!nodes.has(number)) {
|
|
errors.push(`unresolved release-note reference #${number}`);
|
|
}
|
|
}
|
|
for (const entry of pullRequests) {
|
|
const line = ledger
|
|
.split("\n")
|
|
.find((candidate) => candidate.startsWith(`- **PR #${entry.number}**`));
|
|
if (!line) {
|
|
errors.push(`missing contribution record for PR #${entry.number}`);
|
|
continue;
|
|
}
|
|
for (const handle of entry.thanks) {
|
|
if (!line.toLowerCase().includes(`@${handle.toLowerCase()}`)) {
|
|
errors.push(`missing Thanks @${handle} for #${entry.number}`);
|
|
}
|
|
}
|
|
const expectedReferences = [];
|
|
appendUnique(expectedReferences, externalReferencesIn(entry.title));
|
|
appendUnique(expectedReferences, entry.externalReferences);
|
|
appendUnique(
|
|
expectedReferences,
|
|
entry.linkedIssues.map((issue) => `#${issue.number}`),
|
|
);
|
|
const actualReferences = new Set(
|
|
referenceLabelsIn(line).map((reference) => reference.toLowerCase()),
|
|
);
|
|
for (const reference of expectedReferences) {
|
|
if (!actualReferences.has(reference.toLowerCase())) {
|
|
errors.push(`missing ${reference} on contribution record for PR #${entry.number}`);
|
|
}
|
|
}
|
|
}
|
|
const editorialProse = section.source.slice(0, ledgerStart);
|
|
for (const entry of pullRequests) {
|
|
if (
|
|
!entry.editorialEligible &&
|
|
new RegExp(`(?<![A-Za-z0-9_./-])#${entry.number}\\b`).test(editorialProse)
|
|
) {
|
|
errors.push(
|
|
`editorial release prose references non-editorial ${entry.type} PR #${entry.number} (${entry.type})`,
|
|
);
|
|
}
|
|
}
|
|
const editorialLines = editorialProse.split("\n");
|
|
for (const entry of pullRequests) {
|
|
for (const line of editorialLines) {
|
|
if (
|
|
!new RegExp(`(?<![A-Za-z0-9_./-])#${entry.number}\\b`).test(line) ||
|
|
!line.startsWith("- ")
|
|
) {
|
|
continue;
|
|
}
|
|
for (const handle of entry.thanks) {
|
|
if (!line.toLowerCase().includes(`@${handle.toLowerCase()}`)) {
|
|
errors.push(`missing editorial Thanks @${handle} for PR #${entry.number}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for (const line of editorialLines) {
|
|
if (!line.startsWith("- ")) {
|
|
continue;
|
|
}
|
|
for (const handle of directCommitCreditsForLine(line, directCommits)) {
|
|
if (!line.toLowerCase().includes(`@${handle.toLowerCase()}`)) {
|
|
errors.push(`missing editorial Thanks @${handle} for directly landed work`);
|
|
}
|
|
}
|
|
}
|
|
const lines = section.source.split("\n");
|
|
for (const number of new Set(referencesIn(section.source))) {
|
|
const node = nodes.get(number);
|
|
if (node?.__typename !== "Issue") {
|
|
continue;
|
|
}
|
|
for (const handle of thanksFor(node, [])) {
|
|
const credited = lines.some(
|
|
(line) =>
|
|
line.includes(`#${number}`) && line.toLowerCase().includes(`@${handle.toLowerCase()}`),
|
|
);
|
|
if (!credited) {
|
|
errors.push(`missing Thanks @${handle} for issue #${number}`);
|
|
}
|
|
}
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
function manifestFor(options, source, ledger, directCommitRecords) {
|
|
const directCommits = directCommitRecords.map((commit) => ({
|
|
...editorialClassification(commit.subject),
|
|
commit: commit.hash.slice(0, 12),
|
|
subject: commit.subject,
|
|
references: commit.references,
|
|
author: commit.author,
|
|
contributors: commit.contributors,
|
|
issues: commit.issues.map((issue) => ({
|
|
number: issue.number,
|
|
title: issue.title,
|
|
reporter: issue.thanks,
|
|
})),
|
|
}));
|
|
const unlinkedCommits = directCommits.filter((commit) => commit.references.length === 0);
|
|
return {
|
|
schemaVersion: 3,
|
|
base: options.base,
|
|
target: options.target,
|
|
mergeBase: source.mergeBase,
|
|
version: options.version,
|
|
shippedBaselines: source.shippedBaselines,
|
|
source: {
|
|
references: ledger.entries.length,
|
|
...ledger.provenance,
|
|
issues: ledger.issues.length,
|
|
directCommits: directCommits.length,
|
|
unlinkedCommits: unlinkedCommits.length,
|
|
},
|
|
pullRequests: ledger.pullRequests.map((entry) => ({
|
|
number: entry.number,
|
|
title: entry.title,
|
|
type: entry.type,
|
|
editorialEligible: entry.editorialEligible,
|
|
thanks: entry.thanks,
|
|
externalReferences: entry.externalReferences,
|
|
relatedReferences: [...new Set(entry.linkedIssues.map((issue) => issue.number))],
|
|
linkedIssues: entry.linkedIssues.map((issue) => ({
|
|
number: issue.number,
|
|
title: issue.title,
|
|
reporter: issue.thanks,
|
|
})),
|
|
})),
|
|
directCommits,
|
|
unlinkedCommits,
|
|
};
|
|
}
|
|
|
|
function releaseChecks(changelog, version, releaseTags) {
|
|
const checks = [];
|
|
for (const tag of releaseTags) {
|
|
const release = githubApi([`repos/${repo}/releases/tags/${encodeURIComponent(tag)}`]);
|
|
const verification = verifyGithubReleaseNotes({
|
|
body: release.body ?? "",
|
|
changelog,
|
|
version,
|
|
tag,
|
|
repository: repo,
|
|
});
|
|
checks.push({
|
|
tag,
|
|
releaseId: release.id,
|
|
matches: verification.matches,
|
|
mode: verification.mode,
|
|
bodyLength: verification.actualSize.characters,
|
|
bodyBytes: verification.actualSize.bytes,
|
|
});
|
|
}
|
|
return checks;
|
|
}
|
|
|
|
function writeFileAtomic(filePath, contents) {
|
|
const directory = path.dirname(filePath);
|
|
mkdirSync(directory, { recursive: true });
|
|
const tempDirectory = mkdtempSync(path.join(directory, `.${path.basename(filePath)}.tmp-`));
|
|
const tempPath = path.join(tempDirectory, path.basename(filePath));
|
|
try {
|
|
writeFileSync(tempPath, contents);
|
|
renameSync(tempPath, filePath);
|
|
} finally {
|
|
rmSync(tempDirectory, { force: true, recursive: true });
|
|
}
|
|
}
|
|
|
|
function main() {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
if (options.help) {
|
|
printUsage();
|
|
return;
|
|
}
|
|
githubSnapshotState = initializeGithubSnapshot(options);
|
|
const changelog = readFileSync("CHANGELOG.md", "utf8");
|
|
const section = sectionFor(changelog, options.version);
|
|
const source = sourceCommits(
|
|
options.base,
|
|
options.target,
|
|
options.mainRef ?? "origin/main",
|
|
options.releaseProvenance,
|
|
);
|
|
const committedSection = optionalSectionFor(
|
|
git(["show", `${source.target}:CHANGELOG.md`]),
|
|
options.version,
|
|
);
|
|
const committedRecord = committedSection
|
|
? contributionRecordFor(committedSection)
|
|
: { legacyIssues: new Map(), pullRequests: new Map() };
|
|
const committedRecordTarget = committedSection
|
|
? contributionRecordTarget(committedSection)
|
|
: undefined;
|
|
const shippedBaselineRecords = options.shippedRefs.map(shippedBaselineFor);
|
|
const shippedExclusions = subtractShippedPullRequests(source, shippedBaselineRecords);
|
|
source.shippedBaselines = shippedExclusions.baselines;
|
|
const preexistingNotes = section.source.replace(
|
|
/\n+### Complete contribution (?:ledger|record)[\s\S]*$/m,
|
|
"",
|
|
);
|
|
const noteReferences = referencesIn(preexistingNotes);
|
|
const revertedNoteReferences = noteReferences.filter((number) =>
|
|
source.revertedReferences.has(number),
|
|
);
|
|
if (revertedNoteReferences.length > 0) {
|
|
fail(
|
|
`release notes reference reverted work: ${[...new Set(revertedNoteReferences)]
|
|
.map((number) => `#${number}`)
|
|
.join(", ")}`,
|
|
);
|
|
}
|
|
const renderedRecord = contributionRecordFor(section);
|
|
const renderedRecordReferences = contributionRecordMetadataReferences(renderedRecord);
|
|
const revertedRenderedReferences = renderedRecordReferences.filter((number) =>
|
|
source.revertedReferences.has(number),
|
|
);
|
|
if (!options.writeLedger && revertedRenderedReferences.length > 0) {
|
|
fail(
|
|
`contribution record references reverted work: ${[...new Set(revertedRenderedReferences)]
|
|
.map((number) => `#${number}`)
|
|
.join(", ")}`,
|
|
);
|
|
}
|
|
const excludedRecordedReferences = new Set([
|
|
...source.revertedReferences,
|
|
...shippedExclusions.pullRequests,
|
|
]);
|
|
const effectiveRenderedRecordReferences = renderedContributionRecordReferences(
|
|
renderedRecord,
|
|
options.writeLedger,
|
|
);
|
|
let priorRecord = { legacyIssues: new Map(), pullRequests: new Map() };
|
|
if (options.seedRef) {
|
|
const seedChangelog = git(["show", `${options.seedRef}:CHANGELOG.md`]);
|
|
const seedSection = sectionFor(seedChangelog, options.version);
|
|
priorRecord = contributionRecordFor(seedSection);
|
|
}
|
|
priorRecord = withoutExcludedContributionRecords(priorRecord, excludedRecordedReferences);
|
|
const recordedReferences = contributionRecordMetadataReferences(priorRecord);
|
|
const revertedRecordedReferences = recordedReferences.filter((number) =>
|
|
source.revertedReferences.has(number),
|
|
);
|
|
if (revertedRecordedReferences.length > 0) {
|
|
fail(
|
|
`contribution record references reverted work: ${[...new Set(revertedRecordedReferences)]
|
|
.map((number) => `#${number}`)
|
|
.join(", ")}`,
|
|
);
|
|
}
|
|
const references = [...source.references];
|
|
appendReferences(references, noteReferences);
|
|
appendReferences(references, effectiveRenderedRecordReferences);
|
|
appendReferences(references, recordedReferences);
|
|
let nodes = resolveReferences(references);
|
|
const contamination = contaminatingPullRequestReferences({
|
|
noteReferences,
|
|
recordedReferences: effectiveRenderedRecordReferences,
|
|
excludedRecordedReferences,
|
|
sourcePullRequests: source.pullRequests,
|
|
sourceReferences: source.references,
|
|
seededPullRequests: new Set(priorRecord.pullRequests.keys()),
|
|
nodes,
|
|
});
|
|
if (contamination.length > 0) {
|
|
fail(
|
|
`release section contains PRs outside ${options.base}..${options.target}: ${contamination
|
|
.map((number) => `#${number}`)
|
|
.join(", ")}; use --seed-ref only for an intentional historical backfill`,
|
|
);
|
|
}
|
|
const legacyIssuePullRequests = [...legacyIssuesByPullRequest(priorRecord, nodes).keys()].filter(
|
|
(number) => !shippedExclusions.pullRequests.has(number),
|
|
);
|
|
appendReferences(references, legacyIssuePullRequests);
|
|
nodes = resolveReferences(references);
|
|
const recoveredPullRequests = recoverUnavailablePullRequests({
|
|
numbers: references,
|
|
nodes,
|
|
record: committedRecord,
|
|
recordTarget: committedRecordTarget,
|
|
source,
|
|
});
|
|
const unresolvedSourceReferences = references.filter((number) => !nodes.has(number));
|
|
if (unresolvedSourceReferences.length > 0) {
|
|
fail(
|
|
`GitHub could not resolve source references: ${unresolvedSourceReferences
|
|
.map((number) => `#${number}`)
|
|
.join(", ")}`,
|
|
);
|
|
}
|
|
validateReleaseProvenanceOverrides(source.provenanceOverrides, nodes, source.mainCommit);
|
|
const provisionalEntries = references
|
|
.map((number) => nodes.get(number))
|
|
.filter((node) => node?.__typename === "PullRequest");
|
|
const titleReferenceNumbers = titleReferences(provisionalEntries);
|
|
const closingIssueNumbers = provisionalEntries.flatMap(
|
|
(entry) => entry.closingIssuesReferences?.nodes.map((issue) => issue.number) ?? [],
|
|
);
|
|
const resolvedReferences = [...references];
|
|
appendReferences(resolvedReferences, titleReferenceNumbers);
|
|
appendReferences(resolvedReferences, closingIssueNumbers);
|
|
nodes = resolveReferences(resolvedReferences);
|
|
for (const [number, node] of recoveredPullRequests) {
|
|
if (!nodes.has(number)) {
|
|
nodes.set(number, node);
|
|
}
|
|
}
|
|
const invalidRecordedPullRequests = [...priorRecord.pullRequests.keys()].filter((number) => {
|
|
const node = nodes.get(number);
|
|
return (
|
|
node?.__typename !== "PullRequest" ||
|
|
!node.mergedAt ||
|
|
(!source.pullRequests.has(number) && !mergedByTarget(node.mergedAt, source.targetTimestamp))
|
|
);
|
|
});
|
|
if (!options.writeLedger && invalidRecordedPullRequests.length > 0) {
|
|
fail(
|
|
`contribution record contains unresolved or unmerged PRs: ${invalidRecordedPullRequests
|
|
.map((number) => `#${number}`)
|
|
.join(", ")}`,
|
|
);
|
|
}
|
|
const unresolvedTitleReferences = titleReferenceNumbers.filter((number) => !nodes.has(number));
|
|
if (unresolvedTitleReferences.length > 0) {
|
|
fail(
|
|
`GitHub could not resolve PR-title references: ${unresolvedTitleReferences
|
|
.map((number) => `#${number}`)
|
|
.join(", ")}`,
|
|
);
|
|
}
|
|
const contributorHandles = [
|
|
...source.activeCommits.flatMap((commit) => commit.coauthors),
|
|
...source.activeCommits.map((commit) => commit.authorHandle).filter(Boolean),
|
|
];
|
|
const resolvedHandles = resolveGitHubHandles(contributorHandles);
|
|
const relationships = contributionRelationships(source, nodes, resolvedHandles);
|
|
const resolvedCommitAuthors = resolveDirectCommitAuthors(relationships.directCommits);
|
|
relationships.directCommits = withDirectCommitAuthors(
|
|
relationships.directCommits,
|
|
resolvedCommitAuthors,
|
|
);
|
|
const ledger = ledgerFor(
|
|
options.base,
|
|
source.target,
|
|
references,
|
|
nodes,
|
|
source.coauthorsByReference,
|
|
resolvedHandles,
|
|
relationships,
|
|
priorRecord,
|
|
source.pullRequests,
|
|
source.references,
|
|
noteReferences,
|
|
legacyIssuePullRequests,
|
|
source.revertedReferences,
|
|
source.shippedBaselines,
|
|
source.targetTimestamp,
|
|
);
|
|
const manifest = manifestFor(
|
|
{ ...options, target: source.target },
|
|
source,
|
|
ledger,
|
|
relationships.directCommits,
|
|
);
|
|
if (options.manifestPath) {
|
|
writeFileAtomic(options.manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
}
|
|
|
|
let candidateChangelog = changelog;
|
|
let candidateSection = section;
|
|
if (options.writeLedger) {
|
|
candidateChangelog = replaceLedger(
|
|
changelog,
|
|
section,
|
|
ledger.ledger,
|
|
ledger.pullRequests,
|
|
relationships.directCommits,
|
|
);
|
|
candidateSection = sectionFor(candidateChangelog, options.version);
|
|
}
|
|
|
|
const errors = ledgerChecks(
|
|
{ ...candidateSection, expectedProvenance: ledger.provenance },
|
|
ledger.pullRequests,
|
|
nodes,
|
|
relationships.directCommits,
|
|
source.shippedBaselines,
|
|
);
|
|
const github = options.checkGithub
|
|
? releaseChecks(candidateChangelog, options.version, options.releaseTags)
|
|
: [];
|
|
for (const check of github) {
|
|
if (!check.matches) {
|
|
errors.push(
|
|
`GitHub release ${check.tag} does not match the ${options.version} CHANGELOG section`,
|
|
);
|
|
}
|
|
}
|
|
if (errors.length === 0) {
|
|
if (options.writeLedger) {
|
|
writeFileAtomic("CHANGELOG.md", candidateChangelog);
|
|
}
|
|
}
|
|
|
|
const result = {
|
|
base: options.base,
|
|
target: source.target,
|
|
mergeBase: source.mergeBase,
|
|
version: options.version,
|
|
shippedBaselines: source.shippedBaselines,
|
|
source: {
|
|
references: references.length,
|
|
...ledger.provenance,
|
|
issues: ledger.issues.length,
|
|
directCommits: manifest.directCommits.length,
|
|
unlinkedCommits: manifest.unlinkedCommits.length,
|
|
},
|
|
github,
|
|
githubSnapshot: githubSnapshotState
|
|
? {
|
|
path: githubSnapshotState.filePath,
|
|
hits: githubSnapshotState.hits,
|
|
misses: githubSnapshotState.misses,
|
|
}
|
|
: null,
|
|
errors,
|
|
};
|
|
persistGithubSnapshot(githubSnapshotState);
|
|
if (options.json) {
|
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
} else {
|
|
const snapshotSummary = githubSnapshotState
|
|
? `, GitHub snapshot ${githubSnapshotState.hits} hits/${githubSnapshotState.misses} misses`
|
|
: "";
|
|
process.stdout.write(
|
|
`${options.version}: ${ledger.provenance.uniquePullRequests} unique PRs (${ledger.provenance.inRangePullRequests} in-range + ${ledger.provenance.retainedSeedOnlyPullRequests} retained seed-only), ${ledger.issues.length} issues, ${errors.length === 0 ? "verified" : `${errors.length} errors`}${snapshotSummary}\n`,
|
|
);
|
|
}
|
|
if (errors.length > 0) {
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
main();
|
|
}
|