@@ -16,6 +16,7 @@ const {
1616const { writeInvestigation } = require ( "./evidence.cjs" ) ;
1717const { allowedFile, validateResult } = require ( "./repair-protocol.cjs" ) ;
1818const { collectFiles, run } = require ( "./worker.cjs" ) ;
19+ const { dryRun } = require ( "./dry-run.cjs" ) ;
1920
2021const head = "a" . repeat ( 40 ) ;
2122const repo = { owner : "github" , repo : "vscode-codeql" } ;
@@ -258,6 +259,143 @@ test("discovery randomly selects bounded batches without replacement", async (t)
258259 }
259260} ) ;
260261
262+ test ( "planning reports live decisions without mutations or evidence collection" , async ( t ) => {
263+ for ( const [ name , change , action ] of [
264+ [
265+ "ineligible" ,
266+ ( state ) => {
267+ state . pr . draft = true ;
268+ } ,
269+ "skip" ,
270+ ] ,
271+ [
272+ "held" ,
273+ ( state ) => {
274+ state . pr . labels . push ( { name : reviewLabel } ) ;
275+ } ,
276+ "skip" ,
277+ ] ,
278+ [
279+ "non-pristine" ,
280+ ( state ) => {
281+ state . commits [ 0 ] . author . login = "someone" ;
282+ } ,
283+ "needs-review" ,
284+ ] ,
285+ [
286+ "waiting" ,
287+ ( state ) => {
288+ state . runs . pop ( ) ;
289+ } ,
290+ "wait" ,
291+ ] ,
292+ [ "passing" , ( ) => { } , "merge" ] ,
293+ [
294+ "retryable" ,
295+ ( state ) => {
296+ state . runs [ 0 ] . conclusion = "failure" ;
297+ state . runs [ 2 ] . conclusion = "timed_out" ;
298+ } ,
299+ "retry" ,
300+ ] ,
301+ [
302+ "investigation" ,
303+ ( state ) => {
304+ state . runs [ 0 ] . conclusion = "failure" ;
305+ state . runs [ 0 ] . run_attempt = 2 ;
306+ } ,
307+ "investigate" ,
308+ ] ,
309+ ] ) {
310+ await t . test ( name , async ( t ) => {
311+ const workspace = temporaryWorkspace ( t ) ;
312+ const { controller, state } = harness ( { workspace } ) ;
313+ state . pr . auto_merge = { } ;
314+ change ( state ) ;
315+ const originalPr = structuredClone ( state . pr ) ;
316+ const decision = await controller . plan ( ) ;
317+ assert . equal ( decision . action , action ) ;
318+ assert . equal ( decision . pr . head . sha , head ) ;
319+ assert ( decision . reason ) ;
320+ assert . equal ( decision . disableAutoMerge , name !== "ineligible" ) ;
321+ if ( action === "retry" ) {
322+ assert . deepEqual (
323+ decision . runs . map ( ( run ) => run . id ) ,
324+ [ 1 , 3 ] ,
325+ ) ;
326+ }
327+ assert . deepEqual ( state . pr , originalPr ) ;
328+ assert . deepEqual ( state . calls , [ ] ) ;
329+ assert . deepEqual ( state . comments , [ ] ) ;
330+ assert . deepEqual ( state . outputs , { } ) ;
331+ assert . deepEqual ( fs . readdirSync ( workspace ) , [ ] ) ;
332+ } ) ;
333+ }
334+ } ) ;
335+
336+ test ( "planning propagates unavailable CI instead of reporting a decision" , async ( ) => {
337+ const { controller, github, state } = harness ( ) ;
338+ github . rest . actions . listWorkflowRunsForRepo = async ( ) => {
339+ throw new Error ( "Cannot read CI" ) ;
340+ } ;
341+ await assert . rejects ( controller . plan ( ) , / C a n n o t r e a d C I / ) ;
342+ assert . deepEqual ( state . calls , [ ] ) ;
343+ assert . deepEqual ( state . outputs , { } ) ;
344+ } ) ;
345+
346+ test ( "dry-run orchestration reports investigation using only read APIs" , async ( ) => {
347+ const { state, github } = harness ( ) ;
348+ state . pr . auto_merge = { } ;
349+ state . runs [ 0 ] . conclusion = "failure" ;
350+ state . runs [ 0 ] . run_attempt = 2 ;
351+ const readOnlyGithub = {
352+ rest : {
353+ pulls : {
354+ list : github . rest . pulls . list ,
355+ get : github . rest . pulls . get ,
356+ listCommits : github . rest . pulls . listCommits ,
357+ } ,
358+ actions : {
359+ listWorkflowRunsForRepo : github . rest . actions . listWorkflowRunsForRepo ,
360+ } ,
361+ } ,
362+ paginate : github . paginate ,
363+ } ;
364+ const content = [ ] ;
365+ let written = false ;
366+ const summary = {
367+ addHeading ( text ) {
368+ content . push ( text ) ;
369+ return this ;
370+ } ,
371+ addRaw ( text ) {
372+ content . push ( text ) ;
373+ return this ;
374+ } ,
375+ addTable ( rows ) {
376+ content . push ( ...rows . flat ( ) . filter ( ( cell ) => typeof cell === "string" ) ) ;
377+ return this ;
378+ } ,
379+ async write ( ) {
380+ written = true ;
381+ } ,
382+ } ;
383+ await dryRun ( {
384+ github : readOnlyGithub ,
385+ context : { repo } ,
386+ core : { info ( ) { } , setOutput ( ) { } , summary } ,
387+ } ) ;
388+ assert ( written ) ;
389+ const report = content . join ( "\n" ) ;
390+ assert . match ( report , / # 4 2 / ) ;
391+ assert ( report . includes ( head ) ) ;
392+ assert . match ( report , / A d d r e v i e w h o l d , c o l l e c t e v i d e n c e , a n d i n v o k e C o p i l o t / ) ;
393+ assert . match ( report , / R e a d - o n l y p r e v i e w / ) ;
394+ assert . match ( report , / n o t v a l i d a t e d / ) ;
395+ assert . deepEqual ( state . calls , [ ] ) ;
396+ assert . deepEqual ( state . pr . labels , [ ] ) ;
397+ } ) ;
398+
261399test ( "only open, non-draft, same-repository Dependabot PRs qualify" , ( ) => {
262400 const pr = candidate ( ) ;
263401 assert ( isCandidate ( pr , "github/vscode-codeql" ) ) ;
0 commit comments