22// SPDX-License-Identifier: Apache-2.0
33
44/**
5- * Generate a QA handoff summary for the upcoming release tag .
5+ * Generate exact-range QA context for a release brief .
66 *
7- * Lists commits since the last tag, identifies risky areas touched,
8- * and suggests test focus areas. Output is JSON.
9- *
10- * Usage: node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/handoff-summary.ts [--repo OWNER/REPO]
7+ * Usage:
8+ * node --experimental-strip-types --no-warnings handoff-summary.ts \
9+ * --plan PATH --output PATH
1110 */
1211
13- import { isRiskyFile , run } from "./shared.ts" ;
12+ import { execFileSync } from "node:child_process" ;
13+ import fs from "node:fs" ;
14+ import path from "node:path" ;
15+ import { pathToFileURL } from "node:url" ;
1416
15- interface CommitInfo {
16- sha : string ;
17- subject : string ;
18- }
17+ import { isRiskyFile } from "./shared.ts" ;
1918
20- interface HandoffOutput {
19+ export interface HandoffInput {
2120 previousTag : string ;
21+ previousTagCommit : string ;
2222 targetVersion : string ;
23+ candidateCommit : string ;
24+ }
25+
26+ export interface HandoffOutput extends HandoffInput {
2327 commitCount : number ;
24- commits : CommitInfo [ ] ;
25- riskyFilesTouched : string [ ] ;
28+ riskyFileCount : number ;
2629 riskyAreas : string [ ] ;
2730 suggestedTestFocus : string [ ] ;
2831}
2932
33+ type CommandRunner = ( command : string , args : string [ ] ) => string ;
34+
35+ const SEMVER = / ^ v \d + \. \d + \. \d + $ / ;
36+ const SHA = / ^ [ 0 - 9 a - f ] { 40 } $ / ;
37+ const INCOMPLETE = "TODO_RELEASE_BRIEF" ;
38+
3039const AREA_LABELS : Record < string , RegExp [ ] > = {
3140 "Installer / bootstrap" : [
3241 / ^ i n s t a l l \. s h $ / ,
3342 / ^ s e t u p \. s h $ / ,
3443 / ^ b r e v - s e t u p \. s h $ / ,
3544 / ^ s c r i p t s \/ .* \. s h $ / ,
3645 ] ,
37- "Onboarding / host glue" : [ / ^ b i n \/ l i b \/ o n b o a r d \. j s $ / , / ^ b i n \/ .* \. j s $ / ] ,
46+ "Onboarding / host glue" : [ / ^ b i n \/ l i b \/ o n b o a r d \. j s $ / , / ^ b i n \/ .* \. j s $ / , / ^ s r c \/ l i b \/ o n b o a r d \/ / ] ,
3847 "Sandbox / policy / SSRF" : [
3948 / ^ n e m o c l a w \/ s r c \/ b l u e p r i n t \/ / ,
4049 / ^ n e m o c l a w - b l u e p r i n t \/ / ,
@@ -45,89 +54,231 @@ const AREA_LABELS: Record<string, RegExp[]> = {
4554 "Credentials / inference" : [ / c r e d e n t i a l / i, / i n f e r e n c e / i] ,
4655} ;
4756
48- function getLatestTag ( ) : string {
49- const out = run ( "git" , [ "tag" , "--sort=-v:refname" ] ) ;
50- if ( ! out ) return "v0.0.0" ;
51- for ( const line of out . split ( "\n" ) ) {
52- if ( / ^ v \d + \. \d + \. \d + $ / . test ( line . trim ( ) ) ) return line . trim ( ) ;
57+ function run ( command : string , args : string [ ] ) : string {
58+ try {
59+ return execFileSync ( command , args , {
60+ encoding : "utf8" ,
61+ maxBuffer : 10 * 1024 * 1024 ,
62+ stdio : [ "ignore" , "pipe" , "pipe" ] ,
63+ timeout : 120_000 ,
64+ } ) . trim ( ) ;
65+ } catch ( error ) {
66+ const value = error as { stderr ?: Buffer | string } ;
67+ const detail = value . stderr ? String ( value . stderr ) . trim ( ) : "" ;
68+ throw new Error (
69+ [ `Command failed: ${ command } ${ args . join ( " " ) } ` , detail ] . filter ( Boolean ) . join ( "\n" ) ,
70+ ) ;
5371 }
54- return "v0.0.0" ;
5572}
5673
57- function bumpPatch ( tag : string ) : string {
58- const match = tag . match ( / ^ v ( \d + ) \. ( \d + ) \. ( \d + ) $ / ) ;
59- if ( ! match ) return "v0.0.1" ;
60- return `v${ match [ 1 ] } .${ match [ 2 ] } .${ parseInt ( match [ 3 ] , 10 ) + 1 } ` ;
74+ function validateInput ( input : HandoffInput ) : void {
75+ if ( ! SEMVER . test ( input . previousTag ) ) throw new Error ( "previous tag must be vX.Y.Z" ) ;
76+ if ( ! SEMVER . test ( input . targetVersion ) ) throw new Error ( "target version must be vX.Y.Z" ) ;
77+ if ( ! SHA . test ( input . previousTagCommit ) ) {
78+ throw new Error ( "previous tag commit must be a lowercase 40-character Git SHA" ) ;
79+ }
80+ if ( ! SHA . test ( input . candidateCommit ) ) {
81+ throw new Error ( "candidate commit must be a lowercase 40-character Git SHA" ) ;
82+ }
6183}
6284
63- function main ( ) : void {
64- run ( "git" , [ "fetch" , "origin" , "--tags" , "--prune" ] ) ;
65-
66- const previousTag = getLatestTag ( ) ;
67- const targetVersion = bumpPatch ( previousTag ) ;
68-
69- // Commits since last tag
70- const logOut = run ( "git" , [ "log" , "--oneline" , "--format=%h %s" , `${ previousTag } ..origin/main` ] ) ;
71- const commits : CommitInfo [ ] = [ ] ;
72- if ( logOut ) {
73- for ( const line of logOut . split ( "\n" ) ) {
74- const spaceIdx = line . indexOf ( " " ) ;
75- if ( spaceIdx > 0 ) {
76- commits . push ( {
77- sha : line . slice ( 0 , spaceIdx ) ,
78- subject : line . slice ( spaceIdx + 1 ) ,
79- } ) ;
80- }
81- }
85+ function suggestedFocus ( areasHit : Set < string > , commitCount : number ) : string [ ] {
86+ const focus : string [ ] = [ ] ;
87+ if ( areasHit . has ( "Installer / bootstrap" ) ) focus . push ( "Fresh install and upgrade paths" ) ;
88+ if ( areasHit . has ( "Onboarding / host glue" ) ) {
89+ focus . push ( "Onboarding wizard and sandbox creation" ) ;
90+ }
91+ if ( areasHit . has ( "Sandbox / policy / SSRF" ) ) {
92+ focus . push ( "Policy enforcement, network egress, and SSRF protections" ) ;
8293 }
94+ if ( areasHit . has ( "Workflow / enforcement" ) ) {
95+ focus . push ( "CI checks, pre-commit hooks, and DCO declarations" ) ;
96+ }
97+ if ( areasHit . has ( "Credentials / inference" ) ) {
98+ focus . push ( "Credential storage and inference provider routing" ) ;
99+ }
100+ if ( focus . length === 0 && commitCount > 0 ) {
101+ focus . push ( "General smoke test; no risky areas were detected" ) ;
102+ }
103+ return focus ;
104+ }
105+
106+ export function buildHandoffSummary (
107+ input : HandoffInput ,
108+ command : CommandRunner = run ,
109+ ) : HandoffOutput {
110+ validateInput ( input ) ;
83111
84- // Files changed since last tag
85- const diffOut = run ( "git" , [ "diff" , "--name-only" , `${ previousTag } ..origin/main` ] ) ;
86- const changedFiles = diffOut
87- ? diffOut
112+ const resolvedCandidate = command ( "git" , [ "rev-parse" , `${ input . candidateCommit } ^{commit}` ] ) ;
113+ if ( resolvedCandidate !== input . candidateCommit ) {
114+ throw new Error ( `candidate does not resolve to ${ input . candidateCommit } ` ) ;
115+ }
116+ const mergeBase = command ( "git" , [ "merge-base" , input . previousTagCommit , input . candidateCommit ] ) ;
117+ if ( mergeBase !== input . previousTagCommit ) {
118+ throw new Error ( "previous tag commit is not an ancestor of the candidate" ) ;
119+ }
120+
121+ const range = `${ input . previousTagCommit } ..${ input . candidateCommit } ` ;
122+ const commitCountText = command ( "git" , [ "rev-list" , "--count" , range ] ) ;
123+ if ( ! / ^ \d + $ / u. test ( commitCountText ) ) throw new Error ( "git returned an invalid commit count" ) ;
124+ const commitCount = Number ( commitCountText ) ;
125+ if ( ! Number . isSafeInteger ( commitCount ) ) throw new Error ( "release range is too large" ) ;
126+
127+ const changed = command ( "git" , [ "diff" , "--name-only" , range ] ) ;
128+ const changedFiles = changed
129+ ? changed
88130 . split ( "\n" )
89- . map ( ( f ) => f . trim ( ) )
131+ . map ( ( file ) => file . trim ( ) )
90132 . filter ( Boolean )
91133 : [ ] ;
92134 const riskyFilesTouched = changedFiles . filter ( isRiskyFile ) ;
93-
94- // Map risky files to area labels
95135 const areasHit = new Set < string > ( ) ;
96136 for ( const file of riskyFilesTouched ) {
97137 for ( const [ area , patterns ] of Object . entries ( AREA_LABELS ) ) {
98- if ( patterns . some ( ( re ) => re . test ( file ) ) ) {
99- areasHit . add ( area ) ;
100- }
138+ if ( patterns . some ( ( pattern ) => pattern . test ( file ) ) ) areasHit . add ( area ) ;
101139 }
102140 }
103- const riskyAreas = [ ...areasHit ] ;
104-
105- // Suggest test focus based on areas
106- const suggestedTestFocus : string [ ] = [ ] ;
107- if ( areasHit . has ( "Installer / bootstrap" ) )
108- suggestedTestFocus . push ( "Fresh install and upgrade paths" ) ;
109- if ( areasHit . has ( "Onboarding / host glue" ) )
110- suggestedTestFocus . push ( "Onboarding wizard, sandbox creation" ) ;
111- if ( areasHit . has ( "Sandbox / policy / SSRF" ) )
112- suggestedTestFocus . push ( "Policy enforcement, network egress, SSRF protections" ) ;
113- if ( areasHit . has ( "Workflow / enforcement" ) )
114- suggestedTestFocus . push ( "CI checks, pre-commit hooks, DCO signing" ) ;
115- if ( areasHit . has ( "Credentials / inference" ) )
116- suggestedTestFocus . push ( "Credential storage, inference provider routing" ) ;
117- if ( suggestedTestFocus . length === 0 && commits . length > 0 )
118- suggestedTestFocus . push ( "General smoke test — no risky areas touched" ) ;
119-
120- const output : HandoffOutput = {
121- previousTag,
122- targetVersion,
123- commitCount : commits . length ,
124- commits,
125- riskyFilesTouched,
126- riskyAreas,
127- suggestedTestFocus,
141+
142+ return {
143+ ...input ,
144+ commitCount,
145+ riskyFileCount : riskyFilesTouched . length ,
146+ riskyAreas : [ ...areasHit ] ,
147+ suggestedTestFocus : suggestedFocus ( areasHit , commitCount ) ,
128148 } ;
149+ }
150+
151+ function text ( value : string ) : string {
152+ return value . replace ( / ( [ \\ ` * _ [ \] < > # ] ) / g, "\\$1" ) ;
153+ }
129154
130- console . log ( JSON . stringify ( output , null , 2 ) ) ;
155+ function code ( value : string ) : string {
156+ return `\`${ value . replace ( / ` / g, "\\`" ) } \`` ;
157+ }
158+
159+ function list ( values : string [ ] , empty : string ) : string [ ] {
160+ return values . length ? values . map ( ( value ) => `- ${ text ( value ) } ` ) : [ `- ${ empty } ` ] ;
161+ }
162+
163+ export function renderHandoffMarkdown ( summary : HandoffOutput ) : string {
164+ const lines = [
165+ `# NemoClaw ${ summary . targetVersion } release brief` ,
166+ "" ,
167+ "## Release range" ,
168+ "" ,
169+ `- Previous release: ${ code ( summary . previousTag ) } at ${ code ( summary . previousTagCommit ) } ` ,
170+ `- Candidate: ${ code ( summary . candidateCommit ) } ` ,
171+ `- Commits: ${ summary . commitCount } ` ,
172+ `- Risky files detected: ${ summary . riskyFileCount } ` ,
173+ "" ,
174+ "## QA context" ,
175+ "" ,
176+ "### Risky areas" ,
177+ "" ,
178+ ...list ( summary . riskyAreas , "None detected." ) ,
179+ "" ,
180+ "### Suggested test focus" ,
181+ "" ,
182+ ...list ( summary . suggestedTestFocus , "No test focus was inferred." ) ,
183+ "" ,
184+ "## Canonical release entry" ,
185+ "" ,
186+ `- Path: ${ INCOMPLETE } ` ,
187+ "- Entry:" ,
188+ "" ,
189+ INCOMPLETE ,
190+ "" ,
191+ "## Pi documentation evidence" ,
192+ "" ,
193+ `- Pi candidate: ${ code ( summary . candidateCommit ) } ` ,
194+ `- Evidence: ${ INCOMPLETE } (workflow and job URLs, artifact name, normalized approved-empty review, and managed-branch checks)` ,
195+ "" ,
196+ "## Base and managed image evidence" ,
197+ "" ,
198+ `- Base-image candidate: ${ code ( summary . candidateCommit ) } ` ,
199+ `- Evidence: ${ INCOMPLETE } ` ,
200+ "" ,
201+ "## Exact staging Brev Launchable evidence" ,
202+ "" ,
203+ `- Launchable candidate: ${ code ( summary . candidateCommit ) } ` ,
204+ `- Evidence: ${ INCOMPLETE } ` ,
205+ "" ,
206+ "## General E2E decision" ,
207+ "" ,
208+ `- ${ INCOMPLETE } : displayed run, requested runs, and maintainer choice.` ,
209+ "" ,
210+ `Exceptions: ${ INCOMPLETE } ` ,
211+ "" ,
212+ ] ;
213+ return `${ lines . join ( "\n" ) } \n` ;
214+ }
215+
216+ function parseArguments ( argv : string [ ] ) : { output : string ; plan : string } {
217+ let output = "" ;
218+ let plan = "" ;
219+ for ( let index = 0 ; index < argv . length ; index += 1 ) {
220+ const argument = argv [ index ] ;
221+ if ( argument === "--plan" ) {
222+ plan = argv [ ++ index ] ?? "" ;
223+ if ( ! plan || plan . startsWith ( "--" ) ) throw new Error ( "--plan requires a path" ) ;
224+ } else if ( argument === "--output" ) {
225+ output = argv [ ++ index ] ?? "" ;
226+ if ( ! output || output . startsWith ( "--" ) ) throw new Error ( "--output requires a path" ) ;
227+ } else {
228+ throw new Error ( `unknown argument: ${ argument ?? "missing" } ` ) ;
229+ }
230+ }
231+ if ( ! plan || ! output ) throw new Error ( "usage: handoff-summary.ts --plan PATH --output PATH" ) ;
232+ return { output, plan } ;
131233}
132234
133- main ( ) ;
235+ function readPlan ( planPath : string ) : HandoffInput {
236+ const value = JSON . parse ( fs . readFileSync ( path . resolve ( planPath ) , "utf8" ) ) as Record <
237+ string ,
238+ unknown
239+ > ;
240+ const expectedKeys = [
241+ "nextTag" ,
242+ "originMainCommit" ,
243+ "originMainHeadline" ,
244+ "previousTag" ,
245+ "previousTagCommit" ,
246+ "previousTagObject" ,
247+ ] ;
248+ if ( JSON . stringify ( Object . keys ( value ) . sort ( ) ) !== JSON . stringify ( expectedKeys ) ) {
249+ throw new Error ( "release plan must contain exactly the six supported fields" ) ;
250+ }
251+ if ( typeof value . originMainHeadline !== "string" || ! value . originMainHeadline ) {
252+ throw new Error ( "release plan headline must be a nonempty string" ) ;
253+ }
254+ const input = {
255+ previousTag : String ( value . previousTag ) ,
256+ previousTagCommit : String ( value . previousTagCommit ) ,
257+ targetVersion : String ( value . nextTag ) ,
258+ candidateCommit : String ( value . originMainCommit ) ,
259+ } ;
260+ validateInput ( input ) ;
261+ return input ;
262+ }
263+
264+ function main ( ) : void {
265+ const options = parseArguments ( process . argv . slice ( 2 ) ) ;
266+ const summary = buildHandoffSummary ( readPlan ( options . plan ) ) ;
267+ const output = path . resolve ( options . output ) ;
268+ fs . mkdirSync ( path . dirname ( output ) , { recursive : true } ) ;
269+ fs . writeFileSync ( output , renderHandoffMarkdown ( summary ) , { encoding : "utf8" , flag : "wx" } ) ;
270+ console . log ( `Release brief written: ${ output } ` ) ;
271+ }
272+
273+ const invoked = process . argv [ 1 ]
274+ ? pathToFileURL ( path . resolve ( process . argv [ 1 ] ) ) . href === import . meta. url
275+ : false ;
276+ if ( invoked ) {
277+ try {
278+ main ( ) ;
279+ } catch ( error ) {
280+ const message = error instanceof Error ? error . message : String ( error ) ;
281+ process . stderr . write ( `handoff-summary: ${ message } \n` ) ;
282+ process . exitCode = 1 ;
283+ }
284+ }
0 commit comments