Skip to content

Commit 924f05a

Browse files
cklinCopilot
andcommitted
Randomly sample Dependabot discovery batches
Select up to 32 eligible PRs per run with a partial Fisher-Yates shuffle. Skip held PRs unless native auto-merge needs disarming. Document probabilistic fairness and cover filtering, empty sets, and sampling without replacement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d63bcb34-3969-4d14-b422-33728176ec03
1 parent fd081b8 commit 924f05a

3 files changed

Lines changed: 94 additions & 5 deletions

File tree

‎.github/dependabot-automation/README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ workflow completions, on an hourly schedule, or through manual dispatch. Each PR
1515
uses `../workflows/dependabot-pr.yml`, with concurrency scoped to that PR across
1616
the whole pipeline.
1717

18+
Discovery excludes held PRs unless native auto-merge needs disarming. It randomly
19+
selects at most 32 candidates per run without replacement, using a partial
20+
Fisher-Yates shuffle. Every candidate has an equal chance of selection, but
21+
selection within a fixed number of runs is not guaranteed. Discovery logs
22+
selected and deferred counts.
23+
1824
The controller is not a long-running process or an in-memory state machine. Each
1925
workflow step creates a fresh controller. Its inputs default to `PR_NUMBER`,
2026
`EXPECTED_SHA`, and the current working directory; tests can supply them explicitly.

‎.github/dependabot-automation/controller.cjs‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,27 @@ function createController({
4343
base: "main",
4444
per_page: 100,
4545
});
46-
const candidates = pulls.filter((pr) => isCandidate(pr, repository));
47-
if (candidates.length > 256)
48-
throw new Error("Too many PRs for the job matrix");
49-
core.setOutput("prs", JSON.stringify(candidates.map((pr) => pr.number)));
50-
core.setOutput("has-prs", candidates.length > 0);
46+
const candidates = pulls.filter(
47+
(pr) =>
48+
isCandidate(pr, repository) &&
49+
(!hasAutomationHold(pr) || Boolean(pr.auto_merge)),
50+
);
51+
const batchSize = 32;
52+
const count = Math.min(batchSize, candidates.length);
53+
for (let index = 0; index < count; index++) {
54+
const other =
55+
index + Math.floor(Math.random() * (candidates.length - index));
56+
[candidates[index], candidates[other]] = [
57+
candidates[other],
58+
candidates[index],
59+
];
60+
}
61+
const selected = candidates.slice(0, count);
62+
core.info(
63+
`Selected ${selected.length} of ${candidates.length} eligible PRs; deferred ${candidates.length - selected.length}.`,
64+
);
65+
core.setOutput("prs", JSON.stringify(selected.map((pr) => pr.number)));
66+
core.setOutput("has-prs", selected.length > 0);
5167
}
5268

5369
async function evaluate() {

‎.github/dependabot-automation/controller.test.cjs‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,73 @@ function harness({ workspace } = {}) {
191191
return { state, github, controller, result };
192192
}
193193

194+
test("discovery excludes held PRs unless native auto-merge needs disarming", async () => {
195+
const { controller, state, github } = harness();
196+
github.rest.pulls.list = () =>
197+
[
198+
{ ...candidate(), number: 10 },
199+
{ ...candidate(), number: 20, labels: [{ name: "dependencies" }] },
200+
{
201+
...candidate(),
202+
number: 30,
203+
labels: [{ name: reviewLabel }],
204+
},
205+
{
206+
...candidate(),
207+
number: 40,
208+
labels: [{ name: reviewLabel }],
209+
auto_merge: {},
210+
},
211+
{ ...candidate(), number: 50, draft: true, auto_merge: {} },
212+
].reverse();
213+
await controller.discover();
214+
assert.deepEqual(
215+
JSON.parse(state.outputs.prs).sort((a, b) => a - b),
216+
[10, 20, 40],
217+
);
218+
assert.equal(state.outputs["has-prs"], true);
219+
assert.deepEqual(state.calls, []);
220+
});
221+
222+
test("discovery produces an empty matrix when no candidates remain", async (t) => {
223+
for (const [name, pulls] of [
224+
["no PRs", []],
225+
["all held", [{ ...candidate(), labels: [{ name: reviewLabel }] }]],
226+
]) {
227+
await t.test(name, async () => {
228+
const { controller, state, github } = harness();
229+
github.rest.pulls.list = () => pulls;
230+
await controller.discover();
231+
assert.deepEqual(JSON.parse(state.outputs.prs), []);
232+
assert.equal(state.outputs["has-prs"], false);
233+
});
234+
}
235+
});
236+
237+
test("discovery randomly selects bounded batches without replacement", async (t) => {
238+
const numbers = Array.from({ length: 300 }, (_, index) => index + 1);
239+
const pulls = numbers.map((number) => ({ ...candidate(), number }));
240+
for (const [random, expected] of [
241+
[0, numbers.slice(0, 32)],
242+
[1 - Number.EPSILON, [300, ...numbers.slice(0, 31)]],
243+
]) {
244+
await t.test(`random value ${random}`, async (t) => {
245+
t.mock.method(Math, "random", () => random);
246+
const { controller, state, github } = harness();
247+
github.rest.pulls.list = () => pulls;
248+
await controller.discover();
249+
const batch = JSON.parse(state.outputs.prs);
250+
assert.deepEqual(batch, expected);
251+
assert.equal(new Set(batch).size, 32);
252+
assert.equal(state.outputs["has-prs"], true);
253+
assert.deepEqual(
254+
pulls.map((pr) => pr.number),
255+
numbers,
256+
);
257+
});
258+
}
259+
});
260+
194261
test("only open, non-draft, same-repository Dependabot PRs qualify", () => {
195262
const pr = candidate();
196263
assert(isCandidate(pr, "github/vscode-codeql"));

0 commit comments

Comments
 (0)