@@ -543,6 +543,172 @@ describe("apply_samples.cjs preStagePatch (create_pull_request / push_to_pull_re
543543 }
544544 } ) ;
545545
546+ it ( "prefers explicit arguments.repo over safe-outputs target-repo when deriving PR branch" , async ( ) => {
547+ const workspace = makeTempDir ( "gh-aw-prestage-push-explicit-repo-" ) ;
548+ initRepo ( workspace , "main" ) ;
549+
550+ const headRef = "feat/explicit-repo-pr" ;
551+ const fetchSpy = vi . spyOn ( globalThis , "fetch" ) . mockResolvedValueOnce ( {
552+ ok : true ,
553+ status : 200 ,
554+ json : async ( ) => ( { head : { ref : headRef } } ) ,
555+ } ) ;
556+
557+ const configPath = path . join ( workspace , "config.json" ) ;
558+ fs . writeFileSync (
559+ configPath ,
560+ JSON . stringify ( {
561+ push_to_pull_request_branch : { "target-repo" : "githubnext/gh-aw-side-repo" } ,
562+ } )
563+ ) ;
564+
565+ const prevBase = process . env . GH_AW_CUSTOM_BASE_BRANCH ;
566+ const prevEvent = process . env . GITHUB_EVENT_PATH ;
567+ const prevRepo = process . env . GITHUB_REPOSITORY ;
568+ const prevConfig = process . env . GH_AW_SAFE_OUTPUTS_CONFIG_PATH ;
569+ delete process . env . GITHUB_EVENT_PATH ;
570+ process . env . GH_AW_CUSTOM_BASE_BRANCH = "main" ;
571+ process . env . GITHUB_REPOSITORY = "githubnext/gh-aw-test" ;
572+ process . env . GH_AW_SAFE_OUTPUTS_CONFIG_PATH = configPath ;
573+ try {
574+ const entry = {
575+ tool : "push_to_pull_request_branch" ,
576+ arguments : {
577+ message : "Push update" ,
578+ pull_request_number : 88 ,
579+ repo : "owner/explicit-repo" ,
580+ } ,
581+ sidecars : { patch : newFileDiff ( "explicit-repo.txt" , "explicit repo wins\n" ) } ,
582+ } ;
583+ await preStagePatch ( entry , 0 , workspace ) ;
584+ expect ( git ( [ "rev-parse" , "--abbrev-ref" , "HEAD" ] , workspace ) . trim ( ) ) . toBe ( headRef ) ;
585+ expect ( fetchSpy ) . toHaveBeenCalledWith ( expect . stringContaining ( "/repos/owner/explicit-repo/pulls/88" ) , expect . anything ( ) ) ;
586+ expect ( fetchSpy ) . not . toHaveBeenCalledWith ( expect . stringContaining ( "/repos/githubnext/gh-aw-side-repo/pulls/88" ) , expect . anything ( ) ) ;
587+ } finally {
588+ fetchSpy . mockRestore ( ) ;
589+ if ( prevBase === undefined ) delete process . env . GH_AW_CUSTOM_BASE_BRANCH ;
590+ else process . env . GH_AW_CUSTOM_BASE_BRANCH = prevBase ;
591+ if ( prevEvent !== undefined ) process . env . GITHUB_EVENT_PATH = prevEvent ;
592+ if ( prevRepo === undefined ) delete process . env . GITHUB_REPOSITORY ;
593+ else process . env . GITHUB_REPOSITORY = prevRepo ;
594+ if ( prevConfig === undefined ) delete process . env . GH_AW_SAFE_OUTPUTS_CONFIG_PATH ;
595+ else process . env . GH_AW_SAFE_OUTPUTS_CONFIG_PATH = prevConfig ;
596+ }
597+ } ) ;
598+
599+ it ( "derives push_to_pull_request_branch branch using target-repo from safe-outputs config (issue #41292 siderepo workflow_dispatch)" , async ( ) => {
600+ // Reproduces the siderepo failure: a workflow_dispatch provides
601+ // `pull_request_number` but no `repo` override in the sample arguments.
602+ // The safe-outputs config carries `target-repo: "githubnext/gh-aw-side-repo"`,
603+ // which derivePrHeadRef must use when building the PR fetch URL.
604+ const workspace = makeTempDir ( "gh-aw-prestage-push-siderepo-" ) ;
605+ initRepo ( workspace , "main" ) ;
606+
607+ const headRef = "feat/siderepo-pr-branch" ;
608+ const fetchSpy = vi . spyOn ( globalThis , "fetch" ) . mockResolvedValueOnce ( {
609+ ok : true ,
610+ status : 200 ,
611+ json : async ( ) => ( { head : { ref : headRef } } ) ,
612+ } ) ;
613+
614+ const configPath = path . join ( workspace , "config.json" ) ;
615+ fs . writeFileSync (
616+ configPath ,
617+ JSON . stringify ( {
618+ push_to_pull_request_branch : { "target-repo" : "githubnext/gh-aw-side-repo" } ,
619+ } )
620+ ) ;
621+
622+ const prevBase = process . env . GH_AW_CUSTOM_BASE_BRANCH ;
623+ const prevEvent = process . env . GITHUB_EVENT_PATH ;
624+ const prevRepo = process . env . GITHUB_REPOSITORY ;
625+ const prevConfig = process . env . GH_AW_SAFE_OUTPUTS_CONFIG_PATH ;
626+ delete process . env . GITHUB_EVENT_PATH ; // no event; rely on config
627+ process . env . GH_AW_CUSTOM_BASE_BRANCH = "main" ;
628+ process . env . GITHUB_REPOSITORY = "githubnext/gh-aw-test" ; // host repo (wrong repo)
629+ process . env . GH_AW_SAFE_OUTPUTS_CONFIG_PATH = configPath ;
630+ try {
631+ const entry = {
632+ tool : "push_to_pull_request_branch" ,
633+ // No `repo` override — agent emits only pull_request_number.
634+ arguments : { message : "Multi-commit test push from Copilot in side repo" , pull_request_number : 447 } ,
635+ sidecars : { patch : newFileDiff ( "src/siderepo-feature.py" , "# side repo\n" ) } ,
636+ } ;
637+ await preStagePatch ( entry , 0 , workspace ) ;
638+ expect ( git ( [ "rev-parse" , "--abbrev-ref" , "HEAD" ] , workspace ) . trim ( ) ) . toBe ( headRef ) ;
639+ // Must fetch from the side repo, NOT the host repo.
640+ expect ( fetchSpy ) . toHaveBeenCalledWith ( expect . stringContaining ( "/repos/githubnext/gh-aw-side-repo/pulls/447" ) , expect . anything ( ) ) ;
641+ expect ( fetchSpy ) . not . toHaveBeenCalledWith ( expect . stringContaining ( "/repos/githubnext/gh-aw-test/pulls/447" ) , expect . anything ( ) ) ;
642+ } finally {
643+ fetchSpy . mockRestore ( ) ;
644+ if ( prevBase === undefined ) delete process . env . GH_AW_CUSTOM_BASE_BRANCH ;
645+ else process . env . GH_AW_CUSTOM_BASE_BRANCH = prevBase ;
646+ if ( prevEvent !== undefined ) process . env . GITHUB_EVENT_PATH = prevEvent ;
647+ if ( prevRepo === undefined ) delete process . env . GITHUB_REPOSITORY ;
648+ else process . env . GITHUB_REPOSITORY = prevRepo ;
649+ if ( prevConfig === undefined ) delete process . env . GH_AW_SAFE_OUTPUTS_CONFIG_PATH ;
650+ else process . env . GH_AW_SAFE_OUTPUTS_CONFIG_PATH = prevConfig ;
651+ }
652+ } ) ;
653+
654+ it ( "derives PR-linked issue payload branch using target-repo from safe-outputs config" , async ( ) => {
655+ const workspace = makeTempDir ( "gh-aw-prestage-push-issue-siderepo-" ) ;
656+ initRepo ( workspace , "main" ) ;
657+
658+ const headRef = "feat/issue-side-repo" ;
659+ const eventPath = path . join ( workspace , "event.json" ) ;
660+ fs . writeFileSync (
661+ eventPath ,
662+ JSON . stringify ( {
663+ issue : { number : 42 , pull_request : { url : "https://api.github.com/repos/githubnext/gh-aw-side-repo/pulls/42" } } ,
664+ } )
665+ ) ;
666+
667+ const fetchSpy = vi . spyOn ( globalThis , "fetch" ) . mockResolvedValueOnce ( {
668+ ok : true ,
669+ status : 200 ,
670+ json : async ( ) => ( { head : { ref : headRef } } ) ,
671+ } ) ;
672+
673+ const configPath = path . join ( workspace , "config.json" ) ;
674+ fs . writeFileSync (
675+ configPath ,
676+ JSON . stringify ( {
677+ push_to_pull_request_branch : { "target-repo" : "githubnext/gh-aw-side-repo" } ,
678+ } )
679+ ) ;
680+
681+ const prevBase = process . env . GH_AW_CUSTOM_BASE_BRANCH ;
682+ const prevEvent = process . env . GITHUB_EVENT_PATH ;
683+ const prevRepo = process . env . GITHUB_REPOSITORY ;
684+ const prevConfig = process . env . GH_AW_SAFE_OUTPUTS_CONFIG_PATH ;
685+ process . env . GH_AW_CUSTOM_BASE_BRANCH = "main" ;
686+ process . env . GITHUB_EVENT_PATH = eventPath ;
687+ process . env . GITHUB_REPOSITORY = "githubnext/gh-aw-test" ;
688+ process . env . GH_AW_SAFE_OUTPUTS_CONFIG_PATH = configPath ;
689+ try {
690+ const entry = {
691+ tool : "push_to_pull_request_branch" ,
692+ arguments : { message : "Push update" } ,
693+ sidecars : { patch : newFileDiff ( "issue-side-repo.txt" , "issue side repo\n" ) } ,
694+ } ;
695+ await preStagePatch ( entry , 0 , workspace ) ;
696+ expect ( git ( [ "rev-parse" , "--abbrev-ref" , "HEAD" ] , workspace ) . trim ( ) ) . toBe ( headRef ) ;
697+ expect ( fetchSpy ) . toHaveBeenCalledWith ( expect . stringContaining ( "/repos/githubnext/gh-aw-side-repo/pulls/42" ) , expect . anything ( ) ) ;
698+ expect ( fetchSpy ) . not . toHaveBeenCalledWith ( expect . stringContaining ( "/repos/githubnext/gh-aw-test/pulls/42" ) , expect . anything ( ) ) ;
699+ } finally {
700+ fetchSpy . mockRestore ( ) ;
701+ if ( prevBase === undefined ) delete process . env . GH_AW_CUSTOM_BASE_BRANCH ;
702+ else process . env . GH_AW_CUSTOM_BASE_BRANCH = prevBase ;
703+ if ( prevEvent === undefined ) delete process . env . GITHUB_EVENT_PATH ;
704+ else process . env . GITHUB_EVENT_PATH = prevEvent ;
705+ if ( prevRepo === undefined ) delete process . env . GITHUB_REPOSITORY ;
706+ else process . env . GITHUB_REPOSITORY = prevRepo ;
707+ if ( prevConfig === undefined ) delete process . env . GH_AW_SAFE_OUTPUTS_CONFIG_PATH ;
708+ else process . env . GH_AW_SAFE_OUTPUTS_CONFIG_PATH = prevConfig ;
709+ }
710+ } ) ;
711+
546712 it ( "is a no-op when the sample tool isn't in the patch-sidecar set" , async ( ) => {
547713 // We assert this at the driver level (PATCH_SIDECAR_TOOLS gate in main()),
548714 // but preStagePatch itself should also be a no-op when called with an
0 commit comments