Skip to content

Commit 3182e85

Browse files
jamesadevineCopilot
andcommitted
feat(safe-outputs): per-repo create-pull-request target branches
A meta repo that checks out N repos can now open a PR into each repo's own target branch, instead of a single target-branch applied to all. The base-ref deepening (prepare-pr-base) uses the per-repo target so the diff base resolves for each repo, and Stage 3 opens each PR into its resolved target. New create-pull-request config (collision-free — target-branch stays a literal): - target-branch: literal fallback (default main), unchanged. - target-branches: per-alias literal overrides (highest precedence). - infer-target-from-checkout-ref: opt-in bool; a checkout repo with no explicit override targets its own repos: ref. self never infers. Resolution (shared by the compiler's deepening and Stage 3's PR creation, so the deepened branch always matches the PR base): target-branches[R] > inferred ref > target-branch > main. - CreatePrConfig gains target_branches + infer_target_from_checkout_ref + resolve_target_branch(); ExecutionContext gains repo_refs (populated at Stage 3 and used to resolve per-repo targets). - prepare-pr-base emits/consumes repeated `--repo-dir <dir> --target-branch <branch>` pairs (per-dir target); double-quoted ADO-macro dir, single-quoted literal branch (shellcheck-clean). - Resolver unit matrix + compile-integration (meta repo, diverse refs) + bundle multi-target tests; docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 468569f commit 3182e85

19 files changed

Lines changed: 507 additions & 199 deletions

File tree

docs/ado-script.md

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -71,17 +71,20 @@ pipeline** as runtime helpers. Today it produces thirteen bundles:
7171
- `prepare-pr-base.js` — create-pull-request base-ref preparer that runs in the
7272
**Agent job** before the Copilot invocation when `create-pull-request` is
7373
configured (issue #1413). For each allowed create-PR repo dir (`self` +
74-
every `checkout:` alias, passed as repeated `--repo-dir` flags in the same form
75-
`mcp.rs::resolve_git_dir_for_patch` resolves them), it fetches and progressively
76-
deepens the configured `target-branch` into `refs/remotes/origin/<target>` and
77-
points `refs/remotes/origin/HEAD` at it, so the host-side SafeOutputs MCP server
78-
can compute a diff base on shallow-default agent pools without a full-history
79-
`checkout: self`. Reuses `shared/merge-base.ts::ensureTargetRefFetched`
80-
(the same fetch/deepen logic as the PR execution-context precompute). The
81-
single `--target-branch` (the PR's destination/base) and each `--repo-dir` are
82-
non-secret argv flags; the ADO bearer (`SYSTEM_ACCESSTOKEN`) rides in masked env
83-
for the authenticated git fetch. Per-dir failures are isolated (logged +
84-
skipped). Runs outside AWF. See
74+
every `checkout:` alias, passed as repeated `--repo-dir <dir> --target-branch
75+
<branch>` pairs in the same dir form `mcp.rs::resolve_git_dir_for_patch`
76+
resolves), it fetches and progressively deepens THAT repo's resolved target
77+
branch into `refs/remotes/origin/<target>` and points
78+
`refs/remotes/origin/HEAD` at it, so the host-side SafeOutputs MCP server can
79+
compute a diff base on shallow-default agent pools without a full-history
80+
`checkout: self`. In a multi-checkout ("meta repo") setup each dir may carry a
81+
different target (see `create-pull-request`'s `target-branches` /
82+
`infer-target-from-checkout-ref`). Reuses
83+
`shared/merge-base.ts::ensureTargetRefFetched` (the same fetch/deepen logic as
84+
the PR execution-context precompute). Each `--repo-dir` is a double-quoted
85+
ADO-macro path; each `--target-branch` is a single-quoted literal; the ADO
86+
bearer (`SYSTEM_ACCESSTOKEN`) rides in masked env for the authenticated git
87+
fetch. Per-dir failures are isolated (logged + skipped). Runs outside AWF. See
8588
[`safe-outputs.md`](safe-outputs.md#create-pull-request).
8689

8790
> **Internal-only.** `ado-script` is not a user-facing front-matter

docs/safe-outputs.md

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -277,11 +277,12 @@ a full-history checkout and **without** hand-editing the compiled lock (so the
277277
runtime integrity check keeps passing). No configuration is required. See
278278
[`docs/ado-script.md`](ado-script.md) (`prepare-pr-base.js`).
279279

280-
> **Branch semantics.** The step deepens the single `target-branch` (the PR's
281-
> **destination/base**, default `main`), applied uniformly to every repo dir — not
282-
> the per-repo `repos:` checkout `ref` (which is the source side). Multi-repo PRs
283-
> therefore all target the same branch name; per-repo target branches are not yet
284-
> expressible (tracked as a follow-up).
280+
> **Branch semantics.** The step deepens each repo's resolved `target-branch`
281+
> (the PR's **destination/base**) — not the per-repo `repos:` checkout `ref` (the
282+
> source side). By default every repo targets the single `target-branch`; enable
283+
> `infer-target-from-checkout-ref` (and/or `target-branches`) to give each repo
284+
> its own base branch in a multi-checkout setup. The deepened branch always
285+
> matches the branch the PR targets (shared resolution).
285286

286287
**Stage 3 Execution Architecture (Hybrid Git + ADO API):**
287288

@@ -326,7 +327,36 @@ This hybrid approach combines:
326327
Note: The source branch name is auto-generated from a sanitized version of the PR title plus a unique suffix (e.g., `agent/fix-bug-in-parser-a1b2c3`). This format is human-readable while preventing injection attacks.
327328
328329
**Configuration options (front matter):**
329-
- `target-branch` - Target branch to merge into (default: "main")
330+
- `target-branch` - Target (base) branch the PR merges into (default: "main"). A
331+
plain literal branch name, applied to every repo unless overridden below.
332+
- `target-branches` - Optional map of per-repository target-branch overrides,
333+
keyed by the repository alias the agent passes to `create-pull-request` (`self`
334+
or a `checkout:` alias). Highest precedence. Lets a multi-checkout ("meta repo")
335+
agent open a PR into a different base branch per repo.
336+
- `infer-target-from-checkout-ref` - Optional bool (default: false). When `true`,
337+
a checkout repo with no explicit `target-branches` entry targets its own
338+
`repos: ref` (the branch it was checked out at). `self` and repos without a
339+
known ref fall back to `target-branch`. It is a separate boolean (not a magic
340+
`target-branch` value) so a real branch name can never be mistaken for a
341+
directive.
342+
343+
**Per-repo target resolution precedence** (for a repo `R`): `target-branches[R]`
344+
→ (if `infer-target-from-checkout-ref`) `R`'s checkout ref → `target-branch` →
345+
`main`. The same resolution drives both the credentialed base-ref deepening (so
346+
the branch that is fetched/deepened matches the branch the PR targets) and the
347+
Stage 3 PR creation. Example (meta repo):
348+
```yaml
349+
repos:
350+
- name: my-org/service # checked out at refs/heads/main
351+
- name: my-org/docs
352+
ref: refs/heads/gh-pages
353+
safe-outputs:
354+
create-pull-request:
355+
target-branch: main # self + fallback
356+
infer-target-from-checkout-ref: true # service → main, docs → gh-pages (from their refs)
357+
target-branches:
358+
docs: gh-pages # (redundant here; shown as an explicit override)
359+
```
330360
- `draft` - Whether to create the PR as a draft (default: **true**). Set to `false` to publish the PR immediately. **Note:** `auto-complete` is silently skipped on draft PRs — set `draft: false` when using `auto-complete: true`.
331361
- `auto-complete` - Set auto-complete on the PR (default: false). Requires `draft: false` to take effect.
332362
- `delete-source-branch` - Delete source branch after merge (default: true)

scripts/ado-script/src/prepare-pr-base/__tests__/index.test.ts

Lines changed: 84 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -42,100 +42,106 @@ function recordingChdir(): { chdir: (dir: string) => void; dirs: string[] } {
4242
}
4343

4444
describe("parseArgs", () => {
45-
it("defaults to main when --target-branch is absent", () => {
46-
expect(parseArgs([]).targetBranch).toBe("main");
47-
});
48-
49-
it("reads the --target-branch value", () => {
50-
expect(parseArgs(["--target-branch", "develop"]).targetBranch).toBe("develop");
51-
});
52-
53-
it("strips a leading refs/heads/ prefix", () => {
54-
expect(parseArgs(["--target-branch", "refs/heads/release/2.x"]).targetBranch).toBe(
55-
"release/2.x",
56-
);
45+
it("pairs each --repo-dir with the following --target-branch", () => {
46+
const args = parseArgs([
47+
"--repo-dir",
48+
"/src",
49+
"--target-branch",
50+
"main",
51+
"--repo-dir",
52+
"/src/tools",
53+
"--target-branch",
54+
"release",
55+
]);
56+
expect(args.repos).toEqual([
57+
{ dir: "/src", target: "main" },
58+
{ dir: "/src/tools", target: "release" },
59+
]);
5760
});
5861

59-
it("falls back to main for an empty value", () => {
60-
expect(parseArgs(["--target-branch", ""]).targetBranch).toBe("main");
62+
it("strips a leading refs/heads/ from targets", () => {
63+
const args = parseArgs([
64+
"--repo-dir",
65+
"/src",
66+
"--target-branch",
67+
"refs/heads/release/2.x",
68+
]);
69+
expect(args.repos).toEqual([{ dir: "/src", target: "release/2.x" }]);
6170
});
6271

63-
it("collects repeated --repo-dir flags in order, defaulting to empty", () => {
64-
expect(parseArgs([]).repoDirs).toEqual([]);
65-
expect(
66-
parseArgs([
67-
"--target-branch",
68-
"main",
69-
"--repo-dir",
70-
"/src",
71-
"--repo-dir",
72-
"/src/tools",
73-
]).repoDirs,
74-
).toEqual(["/src", "/src/tools"]);
72+
it("uses the fallback target for a --repo-dir with no following --target-branch", () => {
73+
// A leading global --target-branch sets the fallback; a bare trailing
74+
// --repo-dir inherits it.
75+
const args = parseArgs(["--target-branch", "develop", "--repo-dir", "/src"]);
76+
expect(args.fallbackTarget).toBe("develop");
77+
expect(args.repos).toEqual([{ dir: "/src", target: "develop" }]);
7578
});
7679

77-
it("ignores empty --repo-dir values", () => {
78-
expect(parseArgs(["--repo-dir", "", "--repo-dir", "/src"]).repoDirs).toEqual(["/src"]);
80+
it("defaults the fallback target to main", () => {
81+
expect(parseArgs([]).fallbackTarget).toBe("main");
82+
expect(parseArgs([]).repos).toEqual([]);
7983
});
8084
});
8185

8286
describe("prepare-pr-base main", () => {
8387
it("fetches origin/<target> and sets origin/HEAD on success", () => {
8488
const { runners, calls } = makeRunners({ fetchStatus: 0, mergeBase: SHA_M });
8589
const { chdir, dirs } = recordingChdir();
86-
const rc = main({ targetBranch: "main", repoDirs: ["/src"] }, {}, runners, chdir);
87-
expect(rc).toBe(0);
88-
expect(dirs).toEqual(["/src"]);
89-
90-
const fetch = calls.find((c) => c[0] === "fetch");
91-
expect(fetch).toBeDefined();
92-
expect(fetch).toContain("+refs/heads/main:refs/remotes/origin/main");
93-
94-
const sym = calls.find((c) => c[0] === "symbolic-ref");
95-
expect(sym).toEqual([
96-
"symbolic-ref",
97-
"refs/remotes/origin/HEAD",
98-
"refs/remotes/origin/main",
99-
]);
100-
});
101-
102-
it("threads a non-default target through the fetch refspec and origin/HEAD", () => {
103-
const { runners, calls } = makeRunners({ fetchStatus: 0, mergeBase: SHA_M });
104-
const { chdir } = recordingChdir();
10590
const rc = main(
106-
{ targetBranch: "release/2.x", repoDirs: ["/src"] },
91+
{ repos: [{ dir: "/src", target: "main" }], fallbackTarget: "main" },
10792
{},
10893
runners,
10994
chdir,
11095
);
11196
expect(rc).toBe(0);
97+
expect(dirs).toEqual(["/src"]);
11298

11399
const fetch = calls.find((c) => c[0] === "fetch");
114-
expect(fetch).toContain("+refs/heads/release/2.x:refs/remotes/origin/release/2.x");
100+
expect(fetch).toBeDefined();
101+
expect(fetch).toContain("+refs/heads/main:refs/remotes/origin/main");
115102

116103
const sym = calls.find((c) => c[0] === "symbolic-ref");
117104
expect(sym).toEqual([
118105
"symbolic-ref",
119106
"refs/remotes/origin/HEAD",
120-
"refs/remotes/origin/release/2.x",
107+
"refs/remotes/origin/main",
121108
]);
122109
});
123110

124-
it("deepens every repo dir (self + aliases) and sets origin/HEAD in each", () => {
111+
it("deepens each repo dir with its OWN target branch (meta-repo)", () => {
125112
const { runners, calls } = makeRunners({ fetchStatus: 0, mergeBase: SHA_M });
126113
const { chdir, dirs } = recordingChdir();
127114
const rc = main(
128-
{ targetBranch: "main", repoDirs: ["/src", "/src/tools", "/src/lib"] },
115+
{
116+
repos: [
117+
{ dir: "/src", target: "main" },
118+
{ dir: "/src/tools", target: "release" },
119+
{ dir: "/src/docs", target: "gh-pages" },
120+
],
121+
fallbackTarget: "main",
122+
},
129123
{},
130124
runners,
131125
chdir,
132126
);
133127
expect(rc).toBe(0);
134-
// Entered each dir, in order.
135-
expect(dirs).toEqual(["/src", "/src/tools", "/src/lib"]);
136-
// One fetch + one origin/HEAD set per dir.
137-
expect(calls.filter((c) => c[0] === "fetch").length).toBe(3);
138-
expect(calls.filter((c) => c[0] === "symbolic-ref").length).toBe(3);
128+
expect(dirs).toEqual(["/src", "/src/tools", "/src/docs"]);
129+
130+
// Each dir fetched + set origin/HEAD to ITS target ref.
131+
const fetchRefspecs = calls
132+
.filter((c) => c[0] === "fetch")
133+
.map((c) => c.find((a) => a.startsWith("+refs/heads/")));
134+
expect(fetchRefspecs).toEqual([
135+
"+refs/heads/main:refs/remotes/origin/main",
136+
"+refs/heads/release:refs/remotes/origin/release",
137+
"+refs/heads/gh-pages:refs/remotes/origin/gh-pages",
138+
]);
139+
const symTargets = calls.filter((c) => c[0] === "symbolic-ref").map((c) => c[2]);
140+
expect(symTargets).toEqual([
141+
"refs/remotes/origin/main",
142+
"refs/remotes/origin/release",
143+
"refs/remotes/origin/gh-pages",
144+
]);
139145
});
140146

141147
it("isolates a per-dir chdir failure — other dirs still processed", () => {
@@ -148,38 +154,51 @@ describe("prepare-pr-base main", () => {
148154
}
149155
};
150156
const rc = main(
151-
{ targetBranch: "main", repoDirs: ["/src", "/src/broken", "/src/lib"] },
157+
{
158+
repos: [
159+
{ dir: "/src", target: "main" },
160+
{ dir: "/src/broken", target: "release" },
161+
{ dir: "/src/lib", target: "main" },
162+
],
163+
fallbackTarget: "main",
164+
},
152165
{},
153166
runners,
154167
chdir,
155168
);
156169
expect(rc).toBe(0);
157-
// All dirs attempted...
158170
expect(dirs).toEqual(["/src", "/src/broken", "/src/lib"]);
159-
// ...but only the two good dirs fetched + set origin/HEAD (broken skipped).
171+
// Only the two good dirs fetched + set origin/HEAD (broken skipped).
160172
expect(calls.filter((c) => c[0] === "fetch").length).toBe(2);
161173
expect(calls.filter((c) => c[0] === "symbolic-ref").length).toBe(2);
162174
});
163175

164176
it("exits 0 without setting origin/HEAD when every fetch fails (benign)", () => {
165177
const { runners, calls } = makeRunners({ fetchStatus: 1, mergeBase: null });
166178
const { chdir } = recordingChdir();
167-
const rc = main({ targetBranch: "main", repoDirs: ["/src"] }, {}, runners, chdir);
179+
const rc = main(
180+
{ repos: [{ dir: "/src", target: "main" }], fallbackTarget: "main" },
181+
{},
182+
runners,
183+
chdir,
184+
);
168185
// Non-fatal: the agent still runs; mcp.rs surfaces its own error if needed.
169186
expect(rc).toBe(0);
170187
expect(calls.some((c) => c[0] === "symbolic-ref")).toBe(false);
171188
});
172189

173-
it("falls back to BUILD_SOURCESDIRECTORY when no --repo-dir is given", () => {
174-
const { runners } = makeRunners({ fetchStatus: 0, mergeBase: SHA_M });
190+
it("falls back to BUILD_SOURCESDIRECTORY + fallback target when no repos given", () => {
191+
const { runners, calls } = makeRunners({ fetchStatus: 0, mergeBase: SHA_M });
175192
const { chdir, dirs } = recordingChdir();
176193
main(
177-
{ targetBranch: "main", repoDirs: [] },
194+
{ repos: [], fallbackTarget: "develop" },
178195
{ BUILD_SOURCESDIRECTORY: "/agent/src" },
179196
runners,
180197
chdir,
181198
);
182199
expect(dirs).toEqual(["/agent/src"]);
200+
const fetch = calls.find((c) => c[0] === "fetch");
201+
expect(fetch).toContain("+refs/heads/develop:refs/remotes/origin/develop");
183202
});
184203

185204
it("passes the SYSTEM_ACCESSTOKEN bearer into the git fetch env", () => {
@@ -193,7 +212,7 @@ describe("prepare-pr-base main", () => {
193212
};
194213
const { chdir } = recordingChdir();
195214
main(
196-
{ targetBranch: "main", repoDirs: ["/src"] },
215+
{ repos: [{ dir: "/src", target: "main" }], fallbackTarget: "main" },
197216
{ SYSTEM_ACCESSTOKEN: "tok" },
198217
runners,
199218
chdir,

0 commit comments

Comments
 (0)