11#!/usr/bin/env node
22
33import { execFileSync , spawnSync } from "node:child_process" ;
4- import { readFileSync , writeFileSync } from "node:fs" ;
4+ import { existsSync , mkdirSync , readFileSync , renameSync , rmSync , writeFileSync } from "node:fs" ;
5+ import path from "node:path" ;
56import { pathToFileURL } from "node:url" ;
67import {
78 extractChangelogReleaseSections ,
@@ -12,6 +13,7 @@ import {
1213} from "../../../../scripts/render-github-release-notes.mjs" ;
1314
1415const repo = "openclaw/openclaw" ;
16+ const githubSnapshotSchemaVersion = 1 ;
1517const commitAssociationQueryBatchSize = 20 ;
1618const excludedHandles = new Set ( [ "openclaw" , "clawsweeper" , "claude" , "codex" , "steipete" ] ) ;
1719const nonEditorialTypes = new Set ( [
@@ -48,6 +50,7 @@ const genericDirectCommitTerms = new Set([
4850 "restore" ,
4951 "update" ,
5052] ) ;
53+ let githubSnapshotState ;
5154
5255function fail ( message ) {
5356 throw new Error ( message ) ;
@@ -65,6 +68,11 @@ Required:
6568
6669Options:
6770 --manifest <path> Read or write the complete contribution record ledger.
71+ --github-snapshot <path>
72+ Override the exact-range GitHub GraphQL snapshot path.
73+ --no-github-snapshot Disable GitHub GraphQL snapshot reuse.
74+ --refresh-github-snapshot
75+ Ignore an existing exact-range snapshot and rebuild it.
6876 --seed-ref <ref> Use an existing release section as editorial input.
6977 --shipped-ref <tag> Exclude PRs already recorded by this shipped tag; repeatable.
7078 --write-ledger Write the verified ledger back into CHANGELOG.md.
@@ -81,6 +89,9 @@ function parseArgs(argv) {
8189 help : false ,
8290 json : false ,
8391 manifestPath : undefined ,
92+ githubSnapshotPath : undefined ,
93+ noGithubSnapshot : false ,
94+ refreshGithubSnapshot : false ,
8495 seedRef : undefined ,
8596 shippedRefs : [ ] ,
8697 writeLedger : false ,
@@ -92,9 +103,23 @@ function parseArgs(argv) {
92103 options . help = true ;
93104 continue ;
94105 }
95- if ( arg === "--check-github" || arg === "--json" || arg === "--write-ledger" ) {
106+ if (
107+ arg === "--check-github" ||
108+ arg === "--json" ||
109+ arg === "--no-github-snapshot" ||
110+ arg === "--refresh-github-snapshot" ||
111+ arg === "--write-ledger"
112+ ) {
96113 options [
97- arg === "--check-github" ? "checkGithub" : arg === "--write-ledger" ? "writeLedger" : "json"
114+ arg === "--check-github"
115+ ? "checkGithub"
116+ : arg === "--write-ledger"
117+ ? "writeLedger"
118+ : arg === "--no-github-snapshot"
119+ ? "noGithubSnapshot"
120+ : arg === "--refresh-github-snapshot"
121+ ? "refreshGithubSnapshot"
122+ : "json"
98123 ] = true ;
99124 continue ;
100125 }
@@ -104,6 +129,7 @@ function parseArgs(argv) {
104129 arg === "--version" ||
105130 arg === "--release-tag" ||
106131 arg === "--shipped-ref" ||
132+ arg === "--github-snapshot" ||
107133 arg === "--manifest" ||
108134 arg === "--seed-ref"
109135 ) {
@@ -117,6 +143,8 @@ function parseArgs(argv) {
117143 options . shippedRefs . push ( value ) ;
118144 } else if ( arg === "--manifest" ) {
119145 options . manifestPath = value ;
146+ } else if ( arg === "--github-snapshot" ) {
147+ options . githubSnapshotPath = value ;
120148 } else if ( arg === "--seed-ref" ) {
121149 options . seedRef = value ;
122150 } else {
@@ -140,6 +168,12 @@ function parseArgs(argv) {
140168 if ( ! options . help && options . checkGithub && options . releaseTags . length === 0 ) {
141169 fail ( "--check-github requires at least one --release-tag" ) ;
142170 }
171+ if ( options . noGithubSnapshot && options . githubSnapshotPath ) {
172+ fail ( "--no-github-snapshot cannot be combined with --github-snapshot" ) ;
173+ }
174+ if ( options . noGithubSnapshot && options . refreshGithubSnapshot ) {
175+ fail ( "--no-github-snapshot cannot be combined with --refresh-github-snapshot" ) ;
176+ }
143177 const uniqueShippedRefs = new Set ( options . shippedRefs ) ;
144178 if ( uniqueShippedRefs . size !== options . shippedRefs . length ) {
145179 fail ( "--shipped-ref values must be unique" ) ;
@@ -184,7 +218,7 @@ function gitIsAncestor(base, target) {
184218 ) ;
185219}
186220
187- function githubApi ( args ) {
221+ function fetchGithubApi ( args ) {
188222 try {
189223 return JSON . parse ( run ( "ghx" , [ "api" , ...args ] ) . replace ( / \u001B \[ [ 0 - ? ] * [ - / ] * [ @ - ~ ] / g, "" ) ) ;
190224 } catch ( error ) {
@@ -195,6 +229,129 @@ function githubApi(args) {
195229 }
196230}
197231
232+ export function createGithubSnapshotState ( {
233+ base,
234+ filePath,
235+ refresh = false ,
236+ repository = repo ,
237+ target,
238+ } ) {
239+ let responses = { } ;
240+ if ( ! refresh && existsSync ( filePath ) ) {
241+ let parsed ;
242+ try {
243+ parsed = JSON . parse ( readFileSync ( filePath , "utf8" ) ) ;
244+ } catch ( error ) {
245+ fail (
246+ `could not read GitHub snapshot ${ filePath } : ${
247+ error instanceof Error ? error . message : String ( error )
248+ } `,
249+ ) ;
250+ }
251+ if (
252+ parsed . schemaVersion !== githubSnapshotSchemaVersion ||
253+ parsed . repository !== repository ||
254+ parsed . base !== base ||
255+ parsed . target !== target ||
256+ ! parsed . responses ||
257+ typeof parsed . responses !== "object" ||
258+ Array . isArray ( parsed . responses )
259+ ) {
260+ fail (
261+ `GitHub snapshot ${ filePath } does not match ${ repository } ${ base } ..${ target } ; use --refresh-github-snapshot` ,
262+ ) ;
263+ }
264+ responses = parsed . responses ;
265+ }
266+ return {
267+ base,
268+ dirty : refresh && existsSync ( filePath ) ,
269+ filePath,
270+ hits : 0 ,
271+ misses : 0 ,
272+ repository,
273+ responses,
274+ target,
275+ } ;
276+ }
277+
278+ export function githubApiWithSnapshot ( args , fetchApi , snapshotState ) {
279+ if ( ! snapshotState || args [ 0 ] !== "graphql" ) {
280+ return fetchApi ( args ) ;
281+ }
282+ const key = JSON . stringify ( args ) ;
283+ const cached = snapshotState . responses [ key ] ;
284+ if ( cached !== undefined ) {
285+ snapshotState . hits += 1 ;
286+ return structuredClone ( cached ) ;
287+ }
288+ snapshotState . misses += 1 ;
289+ const response = fetchApi ( args ) ;
290+ if (
291+ ! response ||
292+ typeof response !== "object" ||
293+ Array . isArray ( response ) ||
294+ response . data === undefined ||
295+ ( Array . isArray ( response . errors ) && response . errors . length > 0 )
296+ ) {
297+ return response ;
298+ }
299+ snapshotState . responses [ key ] = structuredClone ( response ) ;
300+ snapshotState . dirty = true ;
301+ return response ;
302+ }
303+
304+ export function persistGithubSnapshot ( snapshotState ) {
305+ if ( ! snapshotState ?. dirty ) {
306+ return ;
307+ }
308+ const output = `${ JSON . stringify (
309+ {
310+ schemaVersion : githubSnapshotSchemaVersion ,
311+ repository : snapshotState . repository ,
312+ base : snapshotState . base ,
313+ target : snapshotState . target ,
314+ responses : snapshotState . responses ,
315+ } ,
316+ null ,
317+ 2 ,
318+ ) } \n`;
319+ mkdirSync ( path . dirname ( snapshotState . filePath ) , { recursive : true } ) ;
320+ const tempPath = `${ snapshotState . filePath } .${ process . pid } .tmp` ;
321+ try {
322+ writeFileSync ( tempPath , output ) ;
323+ renameSync ( tempPath , snapshotState . filePath ) ;
324+ snapshotState . dirty = false ;
325+ } finally {
326+ rmSync ( tempPath , { force : true } ) ;
327+ }
328+ }
329+
330+ function githubApi ( args ) {
331+ return githubApiWithSnapshot ( args , fetchGithubApi , githubSnapshotState ) ;
332+ }
333+
334+ function initializeGithubSnapshot ( options ) {
335+ if ( options . noGithubSnapshot ) {
336+ return undefined ;
337+ }
338+ const base = git ( [ "rev-parse" , `${ options . base } ^{commit}` ] ) ;
339+ const target = git ( [ "rev-parse" , `${ options . target } ^{commit}` ] ) ;
340+ const defaultName = `verify-release-notes-${ base } -${ target } .json` ;
341+ const filePath = path . resolve (
342+ options . githubSnapshotPath ??
343+ git ( [ "rev-parse" , "--git-path" , `openclaw-release-cache/${ defaultName } ` ] ) ,
344+ ) ;
345+ const state = createGithubSnapshotState ( {
346+ base,
347+ filePath,
348+ refresh : options . refreshGithubSnapshot ,
349+ target,
350+ } ) ;
351+ process . once ( "exit" , ( ) => persistGithubSnapshot ( state ) ) ;
352+ return state ;
353+ }
354+
198355function escapeRegExp ( value ) {
199356 return value . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, "\\$&" ) ;
200357}
@@ -1719,6 +1876,7 @@ function main() {
17191876 printUsage ( ) ;
17201877 return ;
17211878 }
1879+ githubSnapshotState = initializeGithubSnapshot ( options ) ;
17221880 let changelog = readFileSync ( "CHANGELOG.md" , "utf8" ) ;
17231881 let section = sectionFor ( changelog , options . version ) ;
17241882 const source = sourceCommits ( options . base , options . target ) ;
@@ -1930,13 +2088,24 @@ function main() {
19302088 unlinkedCommits : manifest . unlinkedCommits . length ,
19312089 } ,
19322090 github,
2091+ githubSnapshot : githubSnapshotState
2092+ ? {
2093+ path : githubSnapshotState . filePath ,
2094+ hits : githubSnapshotState . hits ,
2095+ misses : githubSnapshotState . misses ,
2096+ }
2097+ : null ,
19332098 errors,
19342099 } ;
2100+ persistGithubSnapshot ( githubSnapshotState ) ;
19352101 if ( options . json ) {
19362102 process . stdout . write ( `${ JSON . stringify ( result , null , 2 ) } \n` ) ;
19372103 } else {
2104+ const snapshotSummary = githubSnapshotState
2105+ ? `, GitHub snapshot ${ githubSnapshotState . hits } hits/${ githubSnapshotState . misses } misses`
2106+ : "" ;
19382107 process . stdout . write (
1939- `${ options . version } : ${ ledger . pullRequests . length } PRs, ${ ledger . issues . length } issues, ${ errors . length === 0 ? "verified" : `${ errors . length } errors` } \n` ,
2108+ `${ options . version } : ${ ledger . pullRequests . length } PRs, ${ ledger . issues . length } issues, ${ errors . length === 0 ? "verified" : `${ errors . length } errors` } ${ snapshotSummary } \n` ,
19402109 ) ;
19412110 }
19422111 if ( errors . length > 0 ) {
0 commit comments