lastlight-evals 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/datasets/dependency-merge/README.md +69 -0
  2. package/datasets/dependency-merge/instances.json +168 -0
  3. package/datasets/dependency-merge/tier.json +5 -0
  4. package/datasets/fix/README.md +57 -0
  5. package/datasets/fix/instances.json +229 -0
  6. package/datasets/fix/repos/fix__env-mismatch-node-version/.github/workflows/ci.yml +15 -0
  7. package/datasets/fix/repos/fix__env-mismatch-node-version/README.md +3 -0
  8. package/datasets/fix/repos/fix__env-mismatch-node-version/package.json +15 -0
  9. package/datasets/fix/repos/fix__env-mismatch-node-version/src/group.js +4 -0
  10. package/datasets/fix/repos/fix__env-mismatch-node-version/test/group.test.js +9 -0
  11. package/datasets/fix/repos/fix__flaky-promoted/.github/workflows/ci.yml +15 -0
  12. package/datasets/fix/repos/fix__flaky-promoted/README.md +3 -0
  13. package/datasets/fix/repos/fix__flaky-promoted/package.json +12 -0
  14. package/datasets/fix/repos/fix__flaky-promoted/src/poll.js +9 -0
  15. package/datasets/fix/repos/fix__flaky-promoted/test/poll.test.js +8 -0
  16. package/datasets/fix/repos/fix__flaky-timeout/.github/workflows/ci.yml +15 -0
  17. package/datasets/fix/repos/fix__flaky-timeout/README.md +3 -0
  18. package/datasets/fix/repos/fix__flaky-timeout/package.json +12 -0
  19. package/datasets/fix/repos/fix__flaky-timeout/src/poll.js +9 -0
  20. package/datasets/fix/repos/fix__flaky-timeout/test/poll.test.js +8 -0
  21. package/datasets/fix/repos/fix__infra-dependent-e2e/.github/workflows/ci.yml +28 -0
  22. package/datasets/fix/repos/fix__infra-dependent-e2e/README.md +3 -0
  23. package/datasets/fix/repos/fix__infra-dependent-e2e/e2e/smoke.test.js +10 -0
  24. package/datasets/fix/repos/fix__infra-dependent-e2e/package.json +12 -0
  25. package/datasets/fix/repos/fix__infra-dependent-e2e/src/retry.js +13 -0
  26. package/datasets/fix/repos/fix__infra-dependent-e2e/test/retry.test.js +8 -0
  27. package/datasets/fix/repos/fix__reproducible-api-removal/.github/workflows/ci.yml +15 -0
  28. package/datasets/fix/repos/fix__reproducible-api-removal/README.md +3 -0
  29. package/datasets/fix/repos/fix__reproducible-api-removal/package.json +15 -0
  30. package/datasets/fix/repos/fix__reproducible-api-removal/src/slug.js +6 -0
  31. package/datasets/fix/repos/fix__reproducible-api-removal/test/slug.test.js +7 -0
  32. package/datasets/fix/repos/fix__reproducible-api-removal/vendor/tiny-case/index.js +4 -0
  33. package/datasets/fix/repos/fix__upstream-broken-base-red/.github/workflows/ci.yml +15 -0
  34. package/datasets/fix/repos/fix__upstream-broken-base-red/README.md +3 -0
  35. package/datasets/fix/repos/fix__upstream-broken-base-red/package.json +12 -0
  36. package/datasets/fix/repos/fix__upstream-broken-base-red/src/format.js +6 -0
  37. package/datasets/fix/repos/fix__upstream-broken-base-red/src/rates.js +6 -0
  38. package/datasets/fix/repos/fix__upstream-broken-base-red/test/format.test.js +7 -0
  39. package/datasets/fix/repos/fix__upstream-broken-base-red/test/rates.test.js +7 -0
  40. package/datasets/fix/repos-head/fix__env-mismatch-node-version/package.json +15 -0
  41. package/datasets/fix/repos-head/fix__flaky-promoted/package.json +12 -0
  42. package/datasets/fix/repos-head/fix__flaky-timeout/package.json +12 -0
  43. package/datasets/fix/repos-head/fix__infra-dependent-e2e/package.json +12 -0
  44. package/datasets/fix/repos-head/fix__reproducible-api-removal/package.json +15 -0
  45. package/datasets/fix/repos-head/fix__reproducible-api-removal/vendor/tiny-case/index.js +4 -0
  46. package/datasets/fix/repos-head/fix__upstream-broken-base-red/package.json +12 -0
  47. package/datasets/fix/tier.json +5 -0
  48. package/dist/fake-github.js +160 -5
  49. package/dist/fake-github.js.map +1 -1
  50. package/dist/grade.js +90 -1
  51. package/dist/grade.js.map +1 -1
  52. package/dist/mechanism.test.js +230 -1
  53. package/dist/mechanism.test.js.map +1 -1
  54. package/dist/pr-context.js +162 -0
  55. package/dist/pr-context.js.map +1 -0
  56. package/dist/run-instance.js +86 -3
  57. package/dist/run-instance.js.map +1 -1
  58. package/dist/run.js +8 -0
  59. package/dist/run.js.map +1 -1
  60. package/dist/schema.js +0 -13
  61. package/dist/schema.js.map +1 -1
  62. package/dist/seed.js +7 -0
  63. package/dist/seed.js.map +1 -1
  64. package/package.json +4 -4
@@ -0,0 +1,15 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: actions/setup-node@v4
13
+ with:
14
+ node-version: "22"
15
+ - run: npm test
@@ -0,0 +1,3 @@
1
+ # poller
2
+
3
+ A polling helper. `npm test` runs the suite.
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "poller",
3
+ "version": "2.1.0",
4
+ "type": "module",
5
+ "description": "Polling helper.",
6
+ "scripts": {
7
+ "test": "node --test"
8
+ },
9
+ "devDependencies": {
10
+ "tap-reporter": "^4.2.0"
11
+ }
12
+ }
@@ -0,0 +1,9 @@
1
+ /** Resolve once `check()` returns true, polling every `intervalMs`. */
2
+ export async function pollUntil(check, { intervalMs = 10, timeoutMs = 200 } = {}) {
3
+ const deadline = Date.now() + timeoutMs;
4
+ for (;;) {
5
+ if (await check()) return true;
6
+ if (Date.now() > deadline) throw new Error("pollUntil: timed out");
7
+ await new Promise((r) => setTimeout(r, intervalMs));
8
+ }
9
+ }
@@ -0,0 +1,8 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { pollUntil } from "../src/poll.js";
4
+
5
+ test("resolves once the condition holds", async () => {
6
+ let n = 0;
7
+ assert.equal(await pollUntil(() => ++n > 2), true);
8
+ });
@@ -0,0 +1,28 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: actions/setup-node@v4
13
+ with:
14
+ node-version: "22"
15
+ - run: npm test
16
+
17
+ e2e:
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ - uses: actions/setup-node@v4
22
+ with:
23
+ node-version: "22"
24
+ # Needs the deployed staging API and its credentials.
25
+ - run: node --test e2e/
26
+ env:
27
+ STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL }}
28
+ STAGING_TOKEN: ${{ secrets.STAGING_TOKEN }}
@@ -0,0 +1,3 @@
1
+ # retryer
2
+
3
+ A retry helper. `npm test` runs the suite.
@@ -0,0 +1,10 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ // Runs only in the `e2e` job, against the deployed staging API.
5
+ test("staging answers /health", async () => {
6
+ const base = process.env.STAGING_BASE_URL;
7
+ assert.ok(base, "STAGING_BASE_URL is not set");
8
+ const res = await fetch(new URL("/health", base));
9
+ assert.equal(res.status, 200);
10
+ });
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "retryer",
3
+ "version": "3.2.1",
4
+ "type": "module",
5
+ "description": "Retry helper.",
6
+ "scripts": {
7
+ "test": "node --test"
8
+ },
9
+ "dependencies": {
10
+ "tiny-backoff": "^3.8.0"
11
+ }
12
+ }
@@ -0,0 +1,13 @@
1
+ /** Retry `fn` up to `attempts` times with a fixed delay. */
2
+ export async function retry(fn, { attempts = 3, delayMs = 0 } = {}) {
3
+ let last;
4
+ for (let i = 0; i < attempts; i++) {
5
+ try {
6
+ return await fn();
7
+ } catch (err) {
8
+ last = err;
9
+ if (delayMs) await new Promise((r) => setTimeout(r, delayMs));
10
+ }
11
+ }
12
+ throw last;
13
+ }
@@ -0,0 +1,8 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { retry } from "../src/retry.js";
4
+
5
+ test("retries until it succeeds", async () => {
6
+ let n = 0;
7
+ assert.equal(await retry(() => (++n < 3 ? Promise.reject(new Error("no")) : "ok")), "ok");
8
+ });
@@ -0,0 +1,15 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: actions/setup-node@v4
13
+ with:
14
+ node-version: "22"
15
+ - run: npm test
@@ -0,0 +1,3 @@
1
+ # slugger
2
+
3
+ A slug helper. `npm test` runs the suite.
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "slugger",
3
+ "version": "1.4.0",
4
+ "type": "module",
5
+ "description": "Slug helper.",
6
+ "scripts": {
7
+ "test": "node --test"
8
+ },
9
+ "imports": {
10
+ "#tiny-case": "./vendor/tiny-case/index.js"
11
+ },
12
+ "dependencies": {
13
+ "tiny-case": "^2.4.0"
14
+ }
15
+ }
@@ -0,0 +1,6 @@
1
+ import { kebab } from "#tiny-case";
2
+
3
+ /** Turn a title into a URL slug. */
4
+ export function slug(title) {
5
+ return kebab(title);
6
+ }
@@ -0,0 +1,7 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { slug } from "../src/slug.js";
4
+
5
+ test("slugs a title", () => {
6
+ assert.equal(slug("Hello There World"), "hello-there-world");
7
+ });
@@ -0,0 +1,4 @@
1
+ // tiny-case@2.4.0
2
+ export function kebab(input) {
3
+ return String(input).trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
4
+ }
@@ -0,0 +1,15 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: actions/setup-node@v4
13
+ with:
14
+ node-version: "22"
15
+ - run: npm test
@@ -0,0 +1,3 @@
1
+ # money
2
+
3
+ Currency formatting. `npm test` runs the suite.
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "money",
3
+ "version": "5.0.0",
4
+ "type": "module",
5
+ "description": "Currency formatting.",
6
+ "scripts": {
7
+ "test": "node --test"
8
+ },
9
+ "dependencies": {
10
+ "tiny-intl": "^6.4.0"
11
+ }
12
+ }
@@ -0,0 +1,6 @@
1
+ /** Format minor units as a currency string. */
2
+ export function formatMoney(minorUnits, currency = "USD") {
3
+ return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(
4
+ minorUnits / 100,
5
+ );
6
+ }
@@ -0,0 +1,6 @@
1
+ /** Convert minor units at a rate, rounding to whole minor units. */
2
+ export function convert(minorUnits, rate) {
3
+ // Regression landed on main in #874: the rounding was dropped, so this
4
+ // returns a fractional amount and every downstream total is wrong.
5
+ return minorUnits * rate;
6
+ }
@@ -0,0 +1,7 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { formatMoney } from "../src/format.js";
4
+
5
+ test("formats dollars", () => {
6
+ assert.equal(formatMoney(12345), "$123.45");
7
+ });
@@ -0,0 +1,7 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { convert } from "../src/rates.js";
4
+
5
+ test("converts to whole minor units", () => {
6
+ assert.equal(convert(1000, 1.2345), 1235);
7
+ });
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "grouper",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Grouping helper.",
6
+ "engines": {
7
+ "node": ">=22"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test"
11
+ },
12
+ "dependencies": {
13
+ "tiny-group": "^2.0.0"
14
+ }
15
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "poller",
3
+ "version": "2.1.0",
4
+ "type": "module",
5
+ "description": "Polling helper.",
6
+ "scripts": {
7
+ "test": "node --test"
8
+ },
9
+ "devDependencies": {
10
+ "tap-reporter": "^5.0.0"
11
+ }
12
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "poller",
3
+ "version": "2.1.0",
4
+ "type": "module",
5
+ "description": "Polling helper.",
6
+ "scripts": {
7
+ "test": "node --test"
8
+ },
9
+ "devDependencies": {
10
+ "tap-reporter": "^5.0.0"
11
+ }
12
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "retryer",
3
+ "version": "3.2.1",
4
+ "type": "module",
5
+ "description": "Retry helper.",
6
+ "scripts": {
7
+ "test": "node --test"
8
+ },
9
+ "dependencies": {
10
+ "tiny-backoff": "^4.0.0"
11
+ }
12
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "slugger",
3
+ "version": "1.4.0",
4
+ "type": "module",
5
+ "description": "Slug helper.",
6
+ "scripts": {
7
+ "test": "node --test"
8
+ },
9
+ "imports": {
10
+ "#tiny-case": "./vendor/tiny-case/index.js"
11
+ },
12
+ "dependencies": {
13
+ "tiny-case": "^3.0.0"
14
+ }
15
+ }
@@ -0,0 +1,4 @@
1
+ // tiny-case@3.0.0 — `kebab` was renamed to `kebabCase` in 3.0.0.
2
+ export function kebabCase(input) {
3
+ return String(input).trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
4
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "money",
3
+ "version": "5.0.0",
4
+ "type": "module",
5
+ "description": "Currency formatting.",
6
+ "scripts": {
7
+ "test": "node --test"
8
+ },
9
+ "dependencies": {
10
+ "tiny-intl": "^7.0.0"
11
+ }
12
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "fix",
3
+ "defaultWorkflow": "dependabot-ci-fix",
4
+ "description": "The fix loop on a RED dependency PR: does the agent reach the right diagnosis class, and does it act on it? One case per class in `DIAGNOSIS_CLASSES`, plus the promoted-flaky attempt."
5
+ }
@@ -181,6 +181,62 @@ export async function startFakeGitHub(opts) {
181
181
  }
182
182
  return { status: "ok", defaultBranch, treeSha: repoConfigTreeSha, etag: repoConfigEtag, files, truncated };
183
183
  }
184
+ // ── Actions fixture ───────────────────────────────────────────────────
185
+ // Ids are synthetic but stable within a run, which is all the tools need:
186
+ // `github_list_workflow_runs` hands the agent a run id and it asks for that
187
+ // run's jobs, then that job's logs.
188
+ const ACTIONS_RUN_ID = 900001;
189
+ const actionsJobs = (opts.actions?.jobs ?? []).map((j, i) => ({
190
+ id: 800001 + i,
191
+ name: j.name,
192
+ conclusion: j.conclusion ?? "failure",
193
+ log: j.log,
194
+ workflowPath: j.workflowPath,
195
+ failingStep: j.failingStep,
196
+ }));
197
+ function serializeRun() {
198
+ const a = opts.actions;
199
+ return {
200
+ id: ACTIONS_RUN_ID,
201
+ name: a.workflowName ?? "CI",
202
+ head_sha: a.headSha,
203
+ head_branch: a.headBranch ?? defaultBranch,
204
+ path: a.jobs[0]?.workflowPath ?? ".github/workflows/ci.yml",
205
+ event: "pull_request",
206
+ status: "completed",
207
+ conclusion: "failure",
208
+ created_at: NOW,
209
+ updated_at: NOW,
210
+ html_url: `https://github.com/${owner}/${repo}/actions/runs/${ACTIONS_RUN_ID}`,
211
+ };
212
+ }
213
+ function serializeJob(j) {
214
+ return {
215
+ id: j.id,
216
+ run_id: ACTIONS_RUN_ID,
217
+ name: j.name,
218
+ status: "completed",
219
+ conclusion: j.conclusion,
220
+ started_at: NOW,
221
+ completed_at: NOW,
222
+ html_url: `https://github.com/${owner}/${repo}/actions/runs/${ACTIONS_RUN_ID}/job/${j.id}`,
223
+ steps: j.failingStep
224
+ ? [{ name: j.failingStep, status: "completed", conclusion: "failure", number: 1 }]
225
+ : [],
226
+ };
227
+ }
228
+ /**
229
+ * The PR's patch, assembled from the per-file `patch` hunks already seeded
230
+ * for `GET /pulls/:n/files`. One fixture, two representations — a case that
231
+ * declares its changed files gets both the file list and the diff, and they
232
+ * cannot disagree.
233
+ */
234
+ function diffOf(prNumber) {
235
+ const files = pullFiles.get(prNumber) ?? [];
236
+ return files
237
+ .map((f) => `diff --git a/${f.filename} b/${f.filename}\n${f.patch ?? ""}`)
238
+ .join("\n");
239
+ }
184
240
  const repoBase = `/repos/${owner}/${repo}`;
185
241
  const server = createServer((req, res) => {
186
242
  const method = req.method ?? "GET";
@@ -207,7 +263,8 @@ export async function startFakeGitHub(opts) {
207
263
  res.end(text);
208
264
  };
209
265
  try {
210
- const handled = route(method, path, body, json);
266
+ const accept = String(req.headers.accept ?? "");
267
+ const handled = route(method, path, body, json, accept, res);
211
268
  if (!handled)
212
269
  json(404, { message: `fake-github: no route for ${method} ${path}` });
213
270
  }
@@ -216,7 +273,73 @@ export async function startFakeGitHub(opts) {
216
273
  }
217
274
  });
218
275
  });
219
- function route(method, path, body, json) {
276
+ function route(method, path, body, json,
277
+ /** Request `Accept` — the diff media type is how `pulls.get` asks for a patch. */
278
+ accept, res) {
279
+ // ── GraphQL: the auto-merge mutation ────────────────────────────────
280
+ //
281
+ // `github_enable_auto_merge` has NO REST equivalent — GitHub exposes
282
+ // `enablePullRequestAutoMerge` only through GraphQL, so a REST-only fake
283
+ // 404s the one call the merge workflow's preferred path depends on, and the
284
+ // agent silently falls back to a direct merge. That would make every
285
+ // dependency-merge case measure the fallback.
286
+ if (method === "POST" && path === "/graphql") {
287
+ const q = String(body?.query ?? "");
288
+ const vars = (body?.variables ??
289
+ {});
290
+ if (q.includes("enablePullRequestAutoMerge")) {
291
+ // The node id the client resolved via REST is `PR_<number>` here (see
292
+ // `serializePull`), so the mutation can find the PR the same way.
293
+ const num = Number(String(vars.id ?? "").replace(/^PR_/, ""));
294
+ const pr = pulls.find((p) => p.number === num);
295
+ if (!pr) {
296
+ json(200, { data: null, errors: [{ message: `Could not resolve to a node: ${vars.id}` }] });
297
+ return true;
298
+ }
299
+ const method_ = String(vars.mergeMethod ?? "SQUASH").toLowerCase();
300
+ pr.autoMerge = { method: method_, enabledAt: NOW };
301
+ json(200, {
302
+ data: {
303
+ enablePullRequestAutoMerge: {
304
+ pullRequest: { number: pr.number, autoMergeRequest: { enabledAt: NOW } },
305
+ },
306
+ },
307
+ });
308
+ return true;
309
+ }
310
+ json(200, { data: null, errors: [{ message: `fake-github: unsupported GraphQL operation` }] });
311
+ return true;
312
+ }
313
+ // ── Actions (CI reads) ──────────────────────────────────────────────
314
+ //
315
+ // Served from the `actions` fixture, which is built from the same seed as
316
+ // the prompt's CI summary — so `github_get_job_logs` corroborates
317
+ // `{{ciSection}}` instead of contradicting it. With NO fixture these stay
318
+ // 404, which is the loud default the rest of this file keeps.
319
+ if (opts.actions && method === "GET" && path.startsWith(`${repoBase}/actions/`)) {
320
+ const rest = path.slice(`${repoBase}/actions/`.length);
321
+ if (rest === "runs" || /^workflows\/[^/]+\/runs$/.test(rest)) {
322
+ json(200, { total_count: 1, workflow_runs: [serializeRun()] });
323
+ return true;
324
+ }
325
+ const jobsOf = /^runs\/(\d+)\/jobs$/.exec(rest);
326
+ if (jobsOf) {
327
+ json(200, { total_count: actionsJobs.length, jobs: actionsJobs.map(serializeJob) });
328
+ return true;
329
+ }
330
+ const logsOf = /^jobs\/(\d+)\/logs$/.exec(rest);
331
+ if (logsOf) {
332
+ const job = actionsJobs.find((j) => j.id === Number(logsOf[1]));
333
+ if (!job)
334
+ return false;
335
+ // Real GitHub 302s to a signed blob URL and octokit follows it; a direct
336
+ // 200 with the text is the same thing to the caller.
337
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
338
+ res.end(job.log);
339
+ return true;
340
+ }
341
+ return false;
342
+ }
220
343
  // GET /repos/:owner/:repo
221
344
  if (method === "GET" && path === repoBase) {
222
345
  json(200, {
@@ -353,8 +476,8 @@ export async function startFakeGitHub(opts) {
353
476
  return true;
354
477
  }
355
478
  }
356
- // Per-PR routes: /repos/:owner/:repo/pulls/:n[/reviews|/comments|/files]
357
- const pullMatch = path.match(new RegExp(`^${escapeRe(repoBase)}/pulls/(\\d+)(/reviews|/comments|/files)?$`));
479
+ // Per-PR routes: /repos/:owner/:repo/pulls/:n[/reviews|/comments|/files|/merge]
480
+ const pullMatch = path.match(new RegExp(`^${escapeRe(repoBase)}/pulls/(\\d+)(/reviews|/comments|/files|/merge)?$`));
358
481
  if (pullMatch) {
359
482
  const num = Number(pullMatch[1]);
360
483
  const sub = pullMatch[2];
@@ -363,11 +486,36 @@ export async function startFakeGitHub(opts) {
363
486
  json(404, { message: `pull ${num} not found` });
364
487
  return true;
365
488
  }
366
- // /pulls/:n
489
+ // /pulls/:n — JSON, or the raw patch when the caller asked for the diff
490
+ // media type. `github_get_pull_request_diff` is `pulls.get` with
491
+ // `mediaType: { format: "diff" }`, so serving JSON to it would hand the
492
+ // agent an object where it expects a patch — the impact assessment then
493
+ // reasons about the wrong artifact entirely.
367
494
  if (!sub && method === "GET") {
495
+ if (/vnd\.github(\.[^+\s]*)?\.diff/.test(accept) || /vnd\.github\.v3\.diff/.test(accept)) {
496
+ // `charset=utf-8` is load-bearing: octokit decodes a response body as
497
+ // TEXT only for `application/json`, `text/*` or a `charset=utf-8`
498
+ // content-type, and hands anything else back as an ArrayBuffer.
499
+ res.writeHead(200, { "content-type": "application/vnd.github.v3.diff; charset=utf-8" });
500
+ res.end(diffOf(pr.number));
501
+ return true;
502
+ }
368
503
  json(200, serializePull(pr));
369
504
  return true;
370
505
  }
506
+ // /pulls/:n/merge — the direct merge (`github_merge_pull_request`).
507
+ if (sub === "/merge" && method === "PUT") {
508
+ const b = (body ?? {});
509
+ if (pr.merged) {
510
+ json(405, { message: "Pull Request is not mergeable" });
511
+ return true;
512
+ }
513
+ pr.merged = true;
514
+ pr.state = "closed";
515
+ pr.mergedBy = { method: b.merge_method ?? "merge" };
516
+ json(200, { sha: pr.head.sha, merged: true, message: "Pull Request successfully merged" });
517
+ return true;
518
+ }
371
519
  // /pulls/:n/reviews — list existing, or SUBMIT one (create_pull_request_review).
372
520
  if (sub === "/reviews") {
373
521
  if (method === "GET") {
@@ -419,10 +567,15 @@ export async function startFakeGitHub(opts) {
419
567
  function serializePull(pr) {
420
568
  return {
421
569
  number: pr.number,
570
+ // The GraphQL node id `enablePullRequestAutoMerge` is given. The real one
571
+ // is opaque; the client only ever round-trips it from here to the
572
+ // mutation, so a derivable form keeps the fake stateless about it.
573
+ node_id: `PR_${pr.number}`,
422
574
  title: pr.title,
423
575
  body: pr.body,
424
576
  state: pr.state,
425
577
  merged: pr.merged,
578
+ auto_merge: pr.autoMerge ? { enabled_by: { login: "last-light[bot]" }, merge_method: pr.autoMerge.method } : null,
426
579
  head: pr.head,
427
580
  base: pr.base,
428
581
  user: pr.user,
@@ -473,6 +626,8 @@ export async function startFakeGitHub(opts) {
473
626
  setPullFiles: (n, files) => pullFiles.set(n, files),
474
627
  fetchRepoConfigTree,
475
628
  repoConfigFetches: () => repoConfigFetches,
629
+ mergeOf: (n) => pulls.find((p) => p.number === n)?.mergedBy,
630
+ autoMergeOf: (n) => pulls.find((p) => p.number === n)?.autoMerge,
476
631
  };
477
632
  }
478
633
  /** Inverse of {@link eventToState} — the grader reports the review's event. */