Skip to content

Commit e60aeae

Browse files
cklinCopilot
andcommitted
Preview Copilot repairs without PR changes
Collect failed-PR evidence read-only and invoke the isolated worker at the exact PR head, bypassing retries only for preview runs. Share the container launcher with live investigations. Mark preview evidence explicitly and report validated responses and file contents in workflow summaries and artifacts without publishing to PRs. Keep preview jobs free of App credentials and PR-write permissions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d63bcb34-3969-4d14-b422-33728176ec03
1 parent 3a7b678 commit e60aeae

8 files changed

Lines changed: 354 additions & 94 deletions

File tree

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

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,23 @@ State crosses invocations through:
3939
Pushes to `cklin/dependabot-automation` run a read-only preview using the pushed
4040
commit, not `main`. The `dry-run` job summary lists selected PRs, their heads,
4141
planned actions and reasons, native auto-merge disarming, and retry run IDs.
42-
Discovery and evaluation use the same decision logic as live processing.
43-
44-
The preview token has only contents, pull-request, and Actions read permissions.
45-
It does not change PRs, rerun CI, collect investigation artifacts, mint App tokens,
46-
or invoke Copilot. Live jobs are excluded from push events and still load trusted
47-
code from `main` for their existing triggers. The preview does not validate merge
48-
authorization or Copilot repair outcomes.
49-
50-
This preparation is kept in a separate commit so it can be dropped after testing.
42+
Discovery and evaluation use the same decision logic as live processing. Failed
43+
PRs get preview evidence and a `copilot-preview` job, including retry-eligible PRs:
44+
the retry is bypassed only for this experiment, not performed or consumed.
45+
46+
The planning job has only contents, pull-request, and Actions read permissions.
47+
Preview workers have only contents read and Copilot inference permissions, and
48+
use the same isolated container launcher as live investigations. Their evidence
49+
is marked `preview: true`, so Copilot knows no retry or review hold was applied.
50+
Copilot may edit and test its disposable checkout; a validated response and
51+
proposed file contents are reported in job summaries and `preview-repair-*`
52+
artifacts, never published to PRs.
53+
54+
No preview job changes PRs, reruns CI, or mints App tokens. Live jobs are excluded
55+
from push events and still load trusted code from `main` for their existing
56+
triggers. Preview runs consume Copilot credits and do not validate merge
57+
authorization. This preparation is isolated in temporary commits so it can be
58+
dropped after testing.
5159

5260
## Lifecycle
5361

@@ -91,7 +99,8 @@ to diagnose the blocker. Other merge API errors fail the run.
9199
| File | Responsibility |
92100
| --------------------- | --------------------------------------------------------------------------------------------- |
93101
| `controller.cjs` | Workflow commands, eligibility, transitions, and trusted GitHub writes. |
94-
| `dry-run.cjs` | Read-only branch-push preview and job summary, using the controller's shared planner. |
102+
| `dry-run.cjs` | Prepare read-only preview evidence and report validated Copilot results without publication. |
103+
| `run-worker.sh` | Shared isolated container launcher for live investigations and Copilot previews. |
95104
| `evidence.cjs` | Collect PR text, patches, failed-job logs, and their index. |
96105
| `repair-protocol.cjs` | Validate result shape, allowed repair paths, and size limits. Shared by worker and publisher. |
97106
| `worker.cjs` | Invoke Copilot and collect actual checkout changes, not model-supplied file contents. |

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

Lines changed: 169 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +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");
19+
const { dryRun, reportPreview } = require("./dry-run.cjs");
2020

2121
const head = "a".repeat(40);
2222
const repo = { owner: "github", repo: "vscode-codeql" };
@@ -80,6 +80,34 @@ function workerWorkspace(t) {
8080
return { work, task, output };
8181
}
8282

83+
function summaryFixture() {
84+
return {
85+
content: [],
86+
written: false,
87+
addHeading(text) {
88+
this.content.push(text);
89+
return this;
90+
},
91+
addRaw(text) {
92+
this.content.push(text);
93+
return this;
94+
},
95+
addTable(rows) {
96+
this.content.push(
97+
...rows.flat().filter((cell) => typeof cell === "string"),
98+
);
99+
return this;
100+
},
101+
addList(items) {
102+
this.content.push(...items);
103+
return this;
104+
},
105+
async write() {
106+
this.written = true;
107+
},
108+
};
109+
}
110+
83111
function harness({ workspace, runNumber = 1 } = {}) {
84112
const state = {
85113
pr: candidate(),
@@ -343,57 +371,146 @@ test("planning propagates unavailable CI instead of reporting a decision", async
343371
assert.deepEqual(state.outputs, {});
344372
});
345373

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, runNumber: 1 },
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, []);
374+
test("dry-run prepares only failed-PR previews without reruns or PR writes", async (t) => {
375+
for (const [name, attempt] of [
376+
["passing", null],
377+
["retry bypass", 1],
378+
["investigation", 2],
379+
]) {
380+
await t.test(name, async (t) => {
381+
const workspace = temporaryWorkspace(t);
382+
const { state, github } = harness({ workspace });
383+
state.pr.auto_merge = {};
384+
if (attempt !== null) {
385+
state.runs[0].conclusion = "failure";
386+
state.runs[0].run_attempt = attempt;
387+
}
388+
const originalPr = structuredClone(state.pr);
389+
const readOnlyGithub = {
390+
rest: {
391+
pulls: {
392+
list: github.rest.pulls.list,
393+
get: github.rest.pulls.get,
394+
listCommits: github.rest.pulls.listCommits,
395+
listFiles: github.rest.pulls.listFiles,
396+
},
397+
actions: {
398+
listWorkflowRunsForRepo:
399+
github.rest.actions.listWorkflowRunsForRepo,
400+
listJobsForWorkflowRun: github.rest.actions.listJobsForWorkflowRun,
401+
downloadJobLogsForWorkflowRun:
402+
github.rest.actions.downloadJobLogsForWorkflowRun,
403+
},
404+
},
405+
paginate: github.paginate,
406+
};
407+
const summary = summaryFixture();
408+
await dryRun({
409+
github: readOnlyGithub,
410+
context: { repo, runNumber: 1 },
411+
core: {
412+
info() {},
413+
setOutput: (name, value) => (state.outputs[name] = value),
414+
summary,
415+
},
416+
workspace,
417+
});
418+
assert(summary.written);
419+
const report = summary.content.join("\n");
420+
assert.match(report, /#42/);
421+
assert(report.includes(head));
422+
const previews = JSON.parse(state.outputs.previews);
423+
if (attempt === null) {
424+
assert.deepEqual(previews, []);
425+
assert.equal(state.outputs["has-previews"], false);
426+
assert.deepEqual(fs.readdirSync(workspace), []);
427+
} else {
428+
assert.deepEqual(previews, [{ pr: 42, head }]);
429+
assert.equal(state.outputs["has-previews"], true);
430+
const directory = path.join(workspace, "investigations/42");
431+
const evidence = JSON.parse(
432+
fs.readFileSync(path.join(directory, "context.json"), "utf8"),
433+
);
434+
assert.equal(evidence.preview, true);
435+
assert.equal(evidence.head, head);
436+
assert.deepEqual(
437+
evidence.runs.map((run) => run.id),
438+
[1],
439+
);
440+
assert.equal(
441+
fs.readFileSync(
442+
path.join(directory, evidence.jobs[0].log.file),
443+
"utf8",
444+
),
445+
state.log,
446+
);
447+
assert.equal(state.runs[0].run_attempt, attempt);
448+
if (attempt === 1) assert.match(report, /Yes \(retry bypassed\)/);
449+
}
450+
assert.deepEqual(state.calls, []);
451+
assert.deepEqual(state.comments, []);
452+
assert.deepEqual(state.pr, originalPr);
453+
});
454+
}
455+
});
456+
457+
test("Copilot preview reports validated outcomes as text without publishing", async (t) => {
458+
for (const outcome of ["patch", "needs-maintainer"]) {
459+
await t.test(outcome, async (t) => {
460+
const workspace = temporaryWorkspace(t);
461+
fs.mkdirSync(path.join(workspace, "result"));
462+
const files =
463+
outcome === "patch"
464+
? [
465+
{ path: "extensions/ql-vscode/src/a.ts", content: "fixed" },
466+
{ path: "extensions/ql-vscode/src/deleted.ts", content: null },
467+
]
468+
: [];
469+
fs.writeFileSync(
470+
path.join(workspace, "result/result.json"),
471+
JSON.stringify({
472+
outcome,
473+
summary: 'Explanation with <img src="untrusted"> & evidence.',
474+
files,
475+
}),
476+
);
477+
const summary = summaryFixture();
478+
await reportPreview({
479+
core: { summary },
480+
number: 42,
481+
head,
482+
workspace,
483+
});
484+
assert(summary.written);
485+
const report = summary.content.join("\n");
486+
assert.match(report, /PR #42/);
487+
assert(report.includes(head));
488+
assert(report.includes(outcome));
489+
assert(!report.includes("<img"));
490+
assert(report.includes('&lt;img src="untrusted"&gt; &amp; evidence.'));
491+
for (const file of files) assert(report.includes(file.path));
492+
});
493+
}
494+
});
495+
496+
test("Copilot preview rejects unsafe artifacts before writing its report", async (t) => {
497+
const workspace = temporaryWorkspace(t);
498+
fs.mkdirSync(path.join(workspace, "result"));
499+
fs.writeFileSync(
500+
path.join(workspace, "result/result.json"),
501+
JSON.stringify({
502+
outcome: "patch",
503+
summary: "Unsafe repair",
504+
files: [{ path: ".github/workflows/main.yml", content: "untrusted" }],
505+
}),
506+
);
507+
const summary = summaryFixture();
508+
await assert.rejects(
509+
reportPreview({ core: { summary }, number: 42, head, workspace }),
510+
/unsupported, or invalid file/,
511+
);
512+
assert.equal(summary.written, false);
513+
assert.deepEqual(summary.content, []);
397514
});
398515

399516
test("only open, non-draft, same-repository Dependabot PRs qualify", () => {

‎.github/dependabot-automation/dry-run.cjs‎

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
const fs = require("node:fs");
2+
const path = require("node:path");
13
const { createController } = require("./controller.cjs");
4+
const { writeInvestigation } = require("./evidence.cjs");
5+
const { validateResult } = require("./repair-protocol.cjs");
26

3-
async function dryRun({ github, context, core }) {
7+
async function dryRun({ github, context, core, workspace = process.cwd() }) {
48
const numbers = await createController({ github, context, core }).discover();
9+
const previews = [];
510
const actions = {
611
skip: "Skip",
712
wait: "Wait for CI",
@@ -18,6 +23,7 @@ async function dryRun({ github, context, core }) {
1823
"Reason",
1924
"Disable auto-merge",
2025
"Retry runs",
26+
"Copilot preview",
2127
].map((data) => ({ data, header: true })),
2228
];
2329
for (const number of numbers) {
@@ -27,6 +33,20 @@ async function dryRun({ github, context, core }) {
2733
core,
2834
number,
2935
}).plan();
36+
let previewStatus = "No";
37+
if (["retry", "investigate"].includes(decision.action)) {
38+
await writeInvestigation({
39+
github,
40+
repo: context.repo,
41+
pr: decision.pr,
42+
runs: decision.runs,
43+
outputDir: path.join(workspace, "investigations", String(number)),
44+
preview: true,
45+
});
46+
previews.push({ pr: number, head: decision.pr.head.sha });
47+
previewStatus =
48+
decision.action === "retry" ? "Yes (retry bypassed)" : "Yes";
49+
}
3050
rows.push([
3151
`#${number}`,
3252
decision.pr.head.sha,
@@ -36,18 +56,59 @@ async function dryRun({ github, context, core }) {
3656
decision.action === "retry"
3757
? decision.runs.map((run) => run.id).join(", ")
3858
: "",
59+
previewStatus,
3960
]);
4061
}
62+
core.setOutput("previews", JSON.stringify(previews));
63+
core.setOutput("has-previews", previews.length > 0);
4164
await core.summary
4265
.addHeading("Dependabot automation dry run")
4366
.addRaw(
44-
"Read-only preview: no PR changes, CI reruns, App tokens, or Copilot invocation. " +
45-
"Merge success and repair outcomes are not validated.",
67+
"Read-only planning and evidence collection: no PR changes, CI reruns, or App tokens. " +
68+
"Failed-PR previews run Copilot separately, bypassing retries only for the preview. " +
69+
"Copilot responses are reported, never published to PRs. Merge authorization is not validated.",
4670
true,
4771
)
4872
.addRaw(`Selected ${numbers.length} PRs.`, true)
4973
.addTable(rows)
5074
.write();
5175
}
5276

53-
module.exports = { dryRun };
77+
async function reportPreview({
78+
core,
79+
number,
80+
head,
81+
workspace = process.cwd(),
82+
}) {
83+
const result = validateResult(
84+
JSON.parse(
85+
fs.readFileSync(path.join(workspace, "result/result.json"), "utf8"),
86+
),
87+
);
88+
await core.summary
89+
.addHeading(`Copilot preview for PR #${number}`)
90+
.addRaw(`Investigated head: <code>${escapeHtml(head)}</code>.`, true)
91+
.addRaw(
92+
"Retry was bypassed for this preview; no review hold was applied. " +
93+
"No PR changes or CI reruns were performed. The validated response and proposed file contents are in this job's artifact.",
94+
true,
95+
)
96+
.addHeading(`Outcome: ${result.outcome}`, 2)
97+
.addRaw(`<pre>${escapeHtml(result.summary)}</pre>`, true)
98+
.addList(
99+
result.files.map(
100+
(file) =>
101+
`${file.content === null ? "Delete" : "Write"}: <code>${escapeHtml(file.path)}</code>`,
102+
),
103+
)
104+
.write();
105+
}
106+
107+
function escapeHtml(text) {
108+
return text
109+
.replaceAll("&", "&amp;")
110+
.replaceAll("<", "&lt;")
111+
.replaceAll(">", "&gt;");
112+
}
113+
114+
module.exports = { dryRun, reportPreview };

0 commit comments

Comments
 (0)