From 58b094cfceb7480995f65312bdba1bd9d554b5ef Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:46:59 -0700 Subject: [PATCH] perf(release): cache changelog GitHub discovery --- .../skills/openclaw-changelog-update/SKILL.md | 7 + .../scripts/verify-release-notes.mjs | 170 +++++++++++++++++- test/scripts/verify-release-notes.test.ts | 71 ++++++++ 3 files changed, 243 insertions(+), 5 deletions(-) diff --git a/.agents/skills/openclaw-changelog-update/SKILL.md b/.agents/skills/openclaw-changelog-update/SKILL.md index 4c0160cf9baf..cb53f3b28659 100644 --- a/.agents/skills/openclaw-changelog-update/SKILL.md +++ b/.agents/skills/openclaw-changelog-update/SKILL.md @@ -49,6 +49,13 @@ every human `Thanks @...` attribution. --write-ledger ``` + The verifier automatically reuses public GitHub GraphQL responses from an + exact base/target SHA snapshot under the worktree's git metadata. Iterative + rewrites at the same target avoid repeated network discovery. Use + `--refresh-github-snapshot` after suspect API data, `--github-snapshot + ` for an explicit artifact, or `--no-github-snapshot` for a live-only + audit. GitHub release bodies are always read live. + - the manifest is the required input to the rewrite, not an after-the-fact audit; it contains every referenced PR, eligible contributor credit, inline issue context, every direct commit, and an editorial-eligibility diff --git a/.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs b/.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs index b5a1478e36db..d9fc389bfb0a 100644 --- a/.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs +++ b/.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node import { execFileSync, spawnSync } from "node:child_process"; -import { readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; import { pathToFileURL } from "node:url"; import { extractChangelogReleaseSections, @@ -12,6 +13,7 @@ import { } from "../../../../scripts/render-github-release-notes.mjs"; const repo = "openclaw/openclaw"; +const githubSnapshotSchemaVersion = 1; const commitAssociationQueryBatchSize = 20; const excludedHandles = new Set(["openclaw", "clawsweeper", "claude", "codex", "steipete"]); const nonEditorialTypes = new Set([ @@ -48,6 +50,7 @@ const genericDirectCommitTerms = new Set([ "restore", "update", ]); +let githubSnapshotState; function fail(message) { throw new Error(message); @@ -65,6 +68,11 @@ Required: Options: --manifest Read or write the complete contribution record ledger. + --github-snapshot + 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. --seed-ref Use an existing release section as editorial input. --shipped-ref Exclude PRs already recorded by this shipped tag; repeatable. --write-ledger Write the verified ledger back into CHANGELOG.md. @@ -81,6 +89,9 @@ function parseArgs(argv) { help: false, json: false, manifestPath: undefined, + githubSnapshotPath: undefined, + noGithubSnapshot: false, + refreshGithubSnapshot: false, seedRef: undefined, shippedRefs: [], writeLedger: false, @@ -92,9 +103,23 @@ function parseArgs(argv) { options.help = true; continue; } - if (arg === "--check-github" || arg === "--json" || arg === "--write-ledger") { + 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" : "json" + arg === "--check-github" + ? "checkGithub" + : arg === "--write-ledger" + ? "writeLedger" + : arg === "--no-github-snapshot" + ? "noGithubSnapshot" + : arg === "--refresh-github-snapshot" + ? "refreshGithubSnapshot" + : "json" ] = true; continue; } @@ -104,6 +129,7 @@ function parseArgs(argv) { arg === "--version" || arg === "--release-tag" || arg === "--shipped-ref" || + arg === "--github-snapshot" || arg === "--manifest" || arg === "--seed-ref" ) { @@ -117,6 +143,8 @@ function parseArgs(argv) { options.shippedRefs.push(value); } else if (arg === "--manifest") { options.manifestPath = value; + } else if (arg === "--github-snapshot") { + options.githubSnapshotPath = value; } else if (arg === "--seed-ref") { options.seedRef = value; } else { @@ -140,6 +168,12 @@ function parseArgs(argv) { 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"); @@ -184,7 +218,7 @@ function gitIsAncestor(base, target) { ); } -function githubApi(args) { +function fetchGithubApi(args) { try { return JSON.parse(run("ghx", ["api", ...args]).replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, "")); } catch (error) { @@ -195,6 +229,120 @@ function githubApi(args) { } } +export function createGithubSnapshotState({ + base, + filePath, + refresh = false, + repository = repo, + target, +}) { + 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, + dirty: refresh && existsSync(filePath), + filePath, + hits: 0, + misses: 0, + repository, + responses, + target, + }; +} + +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); + } + const response = fetchApi(args); + snapshotState.responses[key] = structuredClone(response); + snapshotState.misses += 1; + snapshotState.dirty = true; + 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; + } finally { + rmSync(tempPath, { force: true }); + } +} + +function githubApi(args) { + return githubApiWithSnapshot(args, fetchGithubApi, githubSnapshotState); +} + +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 defaultName = `verify-release-notes-${base}-${target}.json`; + const filePath = path.resolve( + options.githubSnapshotPath ?? + git(["rev-parse", "--git-path", `openclaw-release-cache/${defaultName}`]), + ); + const state = createGithubSnapshotState({ + base, + filePath, + refresh: options.refreshGithubSnapshot, + target, + }); + process.once("exit", () => persistGithubSnapshot(state)); + return state; +} + function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -1653,6 +1801,7 @@ function main() { printUsage(); return; } + githubSnapshotState = initializeGithubSnapshot(options); let changelog = readFileSync("CHANGELOG.md", "utf8"); let section = sectionFor(changelog, options.version); const source = sourceCommits(options.base, options.target); @@ -1863,13 +2012,24 @@ function main() { 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.pullRequests.length} PRs, ${ledger.issues.length} issues, ${errors.length === 0 ? "verified" : `${errors.length} errors`}\n`, + `${options.version}: ${ledger.pullRequests.length} PRs, ${ledger.issues.length} issues, ${errors.length === 0 ? "verified" : `${errors.length} errors`}${snapshotSummary}\n`, ); } if (errors.length > 0) { diff --git a/test/scripts/verify-release-notes.test.ts b/test/scripts/verify-release-notes.test.ts index c6fe532472da..1f6ae5d133b3 100644 --- a/test/scripts/verify-release-notes.test.ts +++ b/test/scripts/verify-release-notes.test.ts @@ -6,8 +6,11 @@ import { describe, expect, it } from "vitest"; import { contaminatingPullRequestReferences, countTopLevelSectionBullets, + createGithubSnapshotState, cumulativeShippedPullRequests, + githubApiWithSnapshot, highlightCountError, + persistGithubSnapshot, releaseNoteReferences, standardRevertedHash, subtractShippedPullRequests, @@ -33,6 +36,74 @@ function git(cwd: string, args: string[]): string { } describe("release-note verification", () => { + it("reuses exact-range GitHub GraphQL snapshots without caching REST reads", () => { + const cwd = mkdtempSync(join(tmpdir(), "openclaw-release-notes-snapshot-")); + try { + const filePath = join(cwd, "snapshot.json"); + let fetches = 0; + const fetchApi = (args: string[]) => { + fetches += 1; + return { request: args, fetches }; + }; + const first = createGithubSnapshotState({ + base: "a".repeat(40), + filePath, + target: "b".repeat(40), + }); + + expect(githubApiWithSnapshot(["graphql", "-f", "query=one"], fetchApi, first)).toEqual({ + request: ["graphql", "-f", "query=one"], + fetches: 1, + }); + expect( + githubApiWithSnapshot(["repos/openclaw/openclaw/releases/tags/v1"], fetchApi, first), + ).toEqual({ + request: ["repos/openclaw/openclaw/releases/tags/v1"], + fetches: 2, + }); + persistGithubSnapshot(first); + + const second = createGithubSnapshotState({ + base: "a".repeat(40), + filePath, + target: "b".repeat(40), + }); + expect(githubApiWithSnapshot(["graphql", "-f", "query=one"], fetchApi, second)).toEqual({ + request: ["graphql", "-f", "query=one"], + fetches: 1, + }); + expect(second.hits).toBe(1); + expect(second.misses).toBe(0); + expect(fetches).toBe(2); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + it("rejects a snapshot bound to a different release target", () => { + const cwd = mkdtempSync(join(tmpdir(), "openclaw-release-notes-snapshot-")); + try { + const filePath = join(cwd, "snapshot.json"); + const state = createGithubSnapshotState({ + base: "a".repeat(40), + filePath, + target: "b".repeat(40), + }); + githubApiWithSnapshot(["graphql", "-f", "query=one"], () => ({ data: true }), state); + persistGithubSnapshot(state); + + expect(() => + createGithubSnapshotState({ + base: "a".repeat(40), + filePath, + target: "c".repeat(40), + }), + ).toThrow("use --refresh-github-snapshot"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + it("ignores nested revert markers in squash-merge bodies", () => { const nestedRevert = [ "feat(android): render display math (#101435)",