Skip to content

Commit 0956abe

Browse files
Copilotdsymegh-aw-bot
authored
fix: apply_samples derivePrHeadRef uses target-repo config for siderepo PR lookups (#41295)
* Initial plan * fix: derivePrHeadRef uses target-repo from safe-outputs config for siderepo PR lookups (#41292) Co-authored-by: dsyme <7204669+dsyme@users.noreply.github.com> * Apply remaining changes Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: dsyme <7204669+dsyme@users.noreply.github.com> Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
1 parent eed4304 commit 0956abe

3 files changed

Lines changed: 184 additions & 4 deletions

File tree

.changeset/fix-apply-samples-siderepo-target-repo.md

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

actions/setup/js/apply_samples.cjs

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -223,10 +223,19 @@ async function derivePrHeadRef(entry) {
223223
return directRef.trim();
224224
}
225225

226-
// Determine the target repo for any API lookups. Prefer the entry's repo if
227-
// the sample sets one (cross-repo workflows), otherwise fall back to
228-
// GITHUB_REPOSITORY.
229-
const repoSlug = (typeof entry.arguments.repo === "string" && entry.arguments.repo.trim()) || process.env.GITHUB_REPOSITORY || "";
226+
// Determine the target repo for any API lookups.
227+
// Resolution order:
228+
// a. entry.arguments.repo — explicit per-sample override (cross-repo workflows).
229+
// b. target-repo from the safe-outputs config file (GH_AW_SAFE_OUTPUTS_CONFIG_PATH)
230+
// for the tool — covers siderepo workflow_dispatch where the sample arguments
231+
// carry `pull_request_number` but not a `repo` override (issue #41292).
232+
// c. GITHUB_REPOSITORY — host repo fallback.
233+
let repoSlug = "";
234+
if (typeof entry.arguments.repo === "string" && entry.arguments.repo.trim()) {
235+
repoSlug = entry.arguments.repo.trim();
236+
} else {
237+
repoSlug = readConfiguredTargetRepo(entry.tool) || process.env.GITHUB_REPOSITORY || "";
238+
}
230239
const [owner, repo] = repoSlug.split("/");
231240
if (!owner || !repo) return null;
232241

actions/setup/js/apply_samples.test.cjs

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)