Skip to content

Commit 647c047

Browse files
cklinCopilot
andcommitted
Add read-only Dependabot decision previews
Run read-only planning on pushes to cklin/dependabot-automation using the pushed commit. Share evaluation decisions with the live controller and summarize intended actions without changing PRs or rerunning CI. Keep live processing off push events and restrict the planning job to read permissions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d63bcb34-3969-4d14-b422-33728176ec03
1 parent 924f05a commit 647c047

5 files changed

Lines changed: 320 additions & 29 deletions

File tree

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,21 @@ State crosses invocations through:
3333
- **Artifacts:** evidence goes to the worker; its repair or diagnosis goes to the
3434
trusted publisher.
3535

36+
## Temporary branch-push dry run
37+
38+
Pushes to `cklin/dependabot-automation` run a read-only preview using the pushed
39+
commit, not `main`. The `dry-run` job summary lists selected PRs, their heads,
40+
planned actions and reasons, native auto-merge disarming, and retry run IDs.
41+
Discovery and evaluation use the same decision logic as live processing.
42+
43+
The preview token has only contents, pull-request, and Actions read permissions.
44+
It does not change PRs, rerun CI, collect investigation artifacts, mint App tokens,
45+
or invoke Copilot. Live jobs are excluded from push events and still load trusted
46+
code from `main` for their existing triggers. The preview does not validate merge
47+
authorization or Copilot repair outcomes.
48+
49+
This preparation is kept in a separate commit so it can be dropped after testing.
50+
3651
## Lifecycle
3752

3853
```text
@@ -75,6 +90,7 @@ to diagnose the blocker. Other merge API errors fail the run.
7590
| File | Responsibility |
7691
| --------------------- | --------------------------------------------------------------------------------------------- |
7792
| `controller.cjs` | Workflow commands, eligibility, transitions, and trusted GitHub writes. |
93+
| `dry-run.cjs` | Read-only branch-push preview and job summary, using the controller's shared planner. |
7894
| `evidence.cjs` | Collect PR text, patches, failed-job logs, and their index. |
7995
| `repair-protocol.cjs` | Validate result shape, allowed repair paths, and size limits. Shared by worker and publisher. |
8096
| `worker.cjs` | Invoke Copilot and collect actual checkout changes, not model-supplied file contents. |

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

Lines changed: 90 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ function createController({
2727
const repository = `${repo.owner}/${repo.repo}`;
2828
return {
2929
discover,
30+
plan,
3031
evaluate,
3132
retry,
3233
approve,
@@ -64,42 +65,52 @@ function createController({
6465
);
6566
core.setOutput("prs", JSON.stringify(selected.map((pr) => pr.number)));
6667
core.setOutput("has-prs", selected.length > 0);
68+
return selected.map((pr) => pr.number);
69+
}
70+
71+
async function plan() {
72+
const pr = await getPr();
73+
return {
74+
pr,
75+
disableAutoMerge: isCandidate(pr, repository) && Boolean(pr.auto_merge),
76+
...(await planPr(pr)),
77+
};
6778
}
6879

6980
async function evaluate() {
7081
const pr = await getPr();
7182
if (!isCandidate(pr, repository)) return;
7283
// Do not leave native auto-merge armed while diagnosis can change PR state.
7384
await disableAutoMerge(pr);
74-
if (hasAutomationHold(pr)) return;
75-
if (!(await hasPristineHistory(pr))) {
76-
await requireHumanReview(
77-
"This PR contains commits other than verified Dependabot changes.",
78-
);
79-
return;
80-
}
81-
const { runs, state } = await readCiState(pr.head.sha);
82-
core.info(`PR #${number} at ${pr.head.sha}: ${state}`);
83-
if (state === "waiting") return;
84-
core.setOutput("head", pr.head.sha);
85-
if (state === "passing") {
86-
core.setOutput("action", "merge");
87-
return;
88-
}
89-
if (canRetry(runs.filter((run) => run.conclusion !== "success"))) {
90-
core.setOutput("action", "retry");
91-
return;
85+
const decision = await planPr(pr);
86+
switch (decision.action) {
87+
case "skip":
88+
case "wait":
89+
return;
90+
case "needs-review":
91+
await requireHumanReview(decision.reason);
92+
return;
93+
case "merge":
94+
case "retry":
95+
core.setOutput("head", pr.head.sha);
96+
core.setOutput("action", decision.action);
97+
return;
98+
case "investigate":
99+
core.setOutput("head", pr.head.sha);
100+
// Entering Copilot is a one-way merge handoff, even if it makes no edits.
101+
await addLabel(reviewLabel);
102+
await writeInvestigation({
103+
github,
104+
repo,
105+
pr,
106+
runs: decision.runs,
107+
outputDir: path.join(workspace, "investigation"),
108+
});
109+
core.setOutput("action", "investigate");
110+
return;
111+
default:
112+
throw new Error(`Unsupported evaluation action: ${decision.action}`);
92113
}
93-
// Entering Copilot is a one-way merge handoff, even if it makes no edits.
94-
await addLabel(reviewLabel);
95-
await writeInvestigation({
96-
github,
97-
repo,
98-
pr,
99-
runs,
100-
outputDir: path.join(workspace, "investigation"),
101-
});
102-
core.setOutput("action", "investigate");
103114
}
104115

105116
async function retry() {
@@ -210,6 +221,57 @@ function createController({
210221
);
211222
}
212223

224+
async function planPr(pr) {
225+
if (!isCandidate(pr, repository)) {
226+
return {
227+
action: "skip",
228+
reason: "PR is no longer an eligible Dependabot update.",
229+
};
230+
}
231+
if (hasAutomationHold(pr)) {
232+
return {
233+
action: "skip",
234+
reason: "An automation hold requires a maintainer decision.",
235+
};
236+
}
237+
if (!(await hasPristineHistory(pr))) {
238+
return {
239+
action: "needs-review",
240+
reason:
241+
"This PR contains commits other than verified Dependabot changes.",
242+
};
243+
}
244+
const { runs, state } = await readCiState(pr.head.sha);
245+
core.info(`PR #${number} at ${pr.head.sha}: ${state}`);
246+
if (state === "waiting") {
247+
return {
248+
action: "wait",
249+
reason: "Configured PR CI workflows are missing or still running.",
250+
};
251+
}
252+
if (state === "passing") {
253+
return {
254+
action: "merge",
255+
reason:
256+
"Verified Dependabot-only history; all configured PR CI workflows passed.",
257+
};
258+
}
259+
const failed = runs.filter((run) => run.conclusion !== "success");
260+
if (canRetry(failed)) {
261+
return {
262+
action: "retry",
263+
reason:
264+
"All failed configured workflows qualify for their first retry.",
265+
runs: failed,
266+
};
267+
}
268+
return {
269+
action: "investigate",
270+
reason: "CI failed and is not eligible for an automatic retry.",
271+
runs,
272+
};
273+
}
274+
213275
async function requireExpectedPr() {
214276
const pr = await getPr();
215277
if (!isCandidate(pr, repository) || pr.head.sha !== expectedSha) {

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

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const {
1616
const { writeInvestigation } = require("./evidence.cjs");
1717
const { allowedFile, validateResult } = require("./repair-protocol.cjs");
1818
const { collectFiles, run } = require("./worker.cjs");
19+
const { dryRun } = require("./dry-run.cjs");
1920

2021
const head = "a".repeat(40);
2122
const 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(), /Cannot read CI/);
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, /#42/);
391+
assert(report.includes(head));
392+
assert.match(report, /Add review hold, collect evidence, and invoke Copilot/);
393+
assert.match(report, /Read-only preview/);
394+
assert.match(report, /not validated/);
395+
assert.deepEqual(state.calls, []);
396+
assert.deepEqual(state.pr.labels, []);
397+
});
398+
261399
test("only open, non-draft, same-repository Dependabot PRs qualify", () => {
262400
const pr = candidate();
263401
assert(isCandidate(pr, "github/vscode-codeql"));
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
const { createController } = require("./controller.cjs");
2+
3+
async function dryRun({ github, context, core }) {
4+
const numbers = await createController({ github, context, core }).discover();
5+
const actions = {
6+
skip: "Skip",
7+
wait: "Wait for CI",
8+
"needs-review": "Add review hold and post explanation",
9+
merge: "Approve if needed and attempt merge",
10+
retry: "Comment and rerun failed jobs once",
11+
investigate: "Add review hold, collect evidence, and invoke Copilot",
12+
};
13+
const rows = [
14+
[
15+
"PR",
16+
"Head",
17+
"Planned action",
18+
"Reason",
19+
"Disable auto-merge",
20+
"Retry runs",
21+
].map((data) => ({ data, header: true })),
22+
];
23+
for (const number of numbers) {
24+
const decision = await createController({
25+
github,
26+
context,
27+
core,
28+
number,
29+
}).plan();
30+
rows.push([
31+
`#${number}`,
32+
decision.pr.head.sha,
33+
actions[decision.action],
34+
decision.reason,
35+
decision.disableAutoMerge ? "Yes" : "No",
36+
decision.action === "retry"
37+
? decision.runs.map((run) => run.id).join(", ")
38+
: "",
39+
]);
40+
}
41+
await core.summary
42+
.addHeading("Dependabot automation dry run")
43+
.addRaw(
44+
"Read-only preview: no PR changes, CI reruns, App tokens, or Copilot invocation. " +
45+
"Merge success and repair outcomes are not validated.",
46+
true,
47+
)
48+
.addRaw(`Selected ${numbers.length} PRs.`, true)
49+
.addTable(rows)
50+
.write();
51+
}
52+
53+
module.exports = { dryRun };

0 commit comments

Comments
 (0)