@reneturcios/greenlight 0.10.1 → 0.11.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.
@@ -0,0 +1,903 @@
1
+ /**
2
+ * Autopilot (v0.11.0): PR feedback that fixes itself.
3
+ *
4
+ * Open PRs die waiting on bot reviews, CI failures, drift conflicts, and
5
+ * DCO gaps between manual babysit sweeps. Autopilot closes that loop:
6
+ *
7
+ * 1. Collect feedback per open PR (reviews, review comments, issue
8
+ * comments, check conclusions), incremental via watermarks.
9
+ * 2. Classify every item and verify bot findings against the code at head
10
+ * (REAL / FALSE_POSITIVE / NEEDS_HUMAN).
11
+ * 3. Triage CI: introduced vs pre-existing against the base branch.
12
+ * 4. In --apply mode, fix the mechanical items: DCO gaps (message-only
13
+ * surgery with byte-identical trees), merge conflicts (mechanical
14
+ * only, anything else aborts), formatting (whitespace-only diffs).
15
+ * No speculative source edits, ever.
16
+ * 5. Never post comments: replies are drafted and queued behind the
17
+ * account's comment block.
18
+ *
19
+ * Default run is report-only. --apply performs fixes, capped by
20
+ * --max-fixes (default 5). Autopilot consumes the v0.10.1 capability
21
+ * ledger when present (degrading gracefully when absent) and records new
22
+ * outcomes as it observes them.
23
+ */
24
+ import { apiGet } from "./github.js";
25
+ import { fetchAuthorCensus } from "./sync.js";
26
+ import { defaultAccountAuthor } from "./babysit.js";
27
+ import { triagePr } from "./ci-triage.js";
28
+ import { loadCapabilities, prCreateBlockReason, defaultCapabilitiesPath, } from "./capabilities.js";
29
+ import { triageReview, authorKindOf, } from "./review-triage.js";
30
+ import { defaultPackageVersion } from "./doctor.js";
31
+ import { applyDcoSurgery, hasSignoff, DCO_SIGNOFF_LINE, defaultGitOps, } from "./autopilot-git.js";
32
+ import { classifyReviewItem, verifyBotFinding, feedbackFromCiChecks, summarizeFeedback, } from "./autopilot-triage.js";
33
+ import { conflictFileStrategy, parseConflictHunks, resolveVersionBumpHunk, resolveChangelogMerge, lockfileManager, diffIsFormattingOnly, detectFormatter, defaultGitRunner, } from "./autopilot-fixes.js";
34
+ import { mkdirSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
35
+ import { homedir, tmpdir } from "node:os";
36
+ import { join } from "node:path";
37
+ /** Rene's identity for fix commits. Standing signatory authority, non-legal. */
38
+ export const AUTOPILOT_COMMITTER = { name: "Rene Turcios", email: "95189389+CloudCorpRecords@users.noreply.github.com" };
39
+ export function defaultAutopilotStatePath() {
40
+ return join(homedir(), ".greenlight", "autopilot.json");
41
+ }
42
+ export function defaultRepliesPath() {
43
+ return join(homedir(), ".greenlight", "autopilot-replies.json");
44
+ }
45
+ /**
46
+ * Autopilot consumes the v0.10.1 capability ledger (src/capabilities.ts)
47
+ * rather than duplicating it: loadCapabilities reads the canonical
48
+ * ~/.greenlight/capabilities.json shape, prCreateBlockReason answers the
49
+ * skip question, and fix outcomes are appended with autopilot-scoped
50
+ * actions so they never shadow the ledger's pr_create/claim outcomes.
51
+ */
52
+ export function recordAutopilotFixOutcome(capsPath, repo, kind, outcome, evidence, at = new Date().toISOString()) {
53
+ try {
54
+ const file = loadCapabilities(capsPath);
55
+ file.entries.push({
56
+ repo: repo.toLowerCase(),
57
+ action: ("autopilot_fix:" + kind),
58
+ outcome,
59
+ evidence: evidence.slice(0, 280),
60
+ at,
61
+ });
62
+ mkdirSync(dirName(capsPath), { recursive: true });
63
+ writeFileSync(capsPath, JSON.stringify(file, null, 2) + "\n");
64
+ }
65
+ catch {
66
+ // Best effort: a ledger that cannot be written is a missed memory.
67
+ }
68
+ }
69
+ function dirName(path) {
70
+ const idx = path.lastIndexOf("/");
71
+ return idx > 0 ? path.slice(0, idx) : ".";
72
+ }
73
+ function emptyWatermark() {
74
+ return { lastHeadSha: "", lastReviewId: 0, lastReviewCommentId: 0, lastIssueCommentId: 0, checkConclusions: {} };
75
+ }
76
+ export function loadAutopilotState(path) {
77
+ try {
78
+ const raw = JSON.parse(readFileSync(path, "utf8"));
79
+ if (raw && typeof raw === "object" && raw.prs)
80
+ return { version: 1, prs: raw.prs };
81
+ }
82
+ catch { /* first run */ }
83
+ return { version: 1, prs: {} };
84
+ }
85
+ export function saveAutopilotState(path, state) {
86
+ mkdirSync(join(homedir(), ".greenlight"), { recursive: true });
87
+ writeFileSync(path, JSON.stringify(state, null, 2));
88
+ }
89
+ async function paged(base) {
90
+ const out = [];
91
+ for (let page = 1; page <= 3; page++) {
92
+ const data = (await apiGet(`${base}?per_page=100&page=${page}`)) ?? [];
93
+ out.push(...data);
94
+ if (data.length < 100)
95
+ break;
96
+ }
97
+ return out;
98
+ }
99
+ const numOf = (v) => (typeof v === "number" ? v : 0);
100
+ const strOf = (v) => (typeof v === "string" ? v : "");
101
+ async function defaultDiscoverPrs(author) {
102
+ return (await fetchAuthorCensus(author)).filter((p) => p.state === "open");
103
+ }
104
+ async function defaultFetchPr(owner, repo, number) {
105
+ const p = (await apiGet(`/repos/${owner}/${repo}/pulls/${number}`));
106
+ const headRepo = p.head?.repo;
107
+ return {
108
+ owner,
109
+ repo,
110
+ number,
111
+ url: strOf(p.html_url) || `https://github.com/${owner}/${repo}/pull/${number}`,
112
+ title: strOf(p.title),
113
+ author: strOf(p.user?.login),
114
+ headSha: strOf(p.head?.sha),
115
+ baseRef: strOf(p.base?.ref),
116
+ baseSha: strOf(p.base?.sha),
117
+ mergeable: typeof p.mergeable === "boolean" ? p.mergeable : null,
118
+ mergeableState: typeof p.mergeable_state === "string" ? p.mergeable_state : null,
119
+ forkOwner: strOf(headRepo?.owner?.login),
120
+ forkRepo: strOf(headRepo?.name),
121
+ forkBranch: strOf(p.head?.ref),
122
+ forkCloneUrl: strOf(headRepo?.clone_url),
123
+ };
124
+ }
125
+ async function defaultFetchReviewSources(owner, repo, number, since) {
126
+ const [reviews, reviewComments, issueComments] = await Promise.all([
127
+ paged(`/repos/${owner}/${repo}/pulls/${number}/reviews`),
128
+ paged(`/repos/${owner}/${repo}/pulls/${number}/comments`),
129
+ paged(`/repos/${owner}/${repo}/issues/${number}/comments`),
130
+ ]);
131
+ const newReviews = reviews.filter((r) => numOf(r.id) > since.reviewId);
132
+ const newReviewComments = reviewComments.filter((c) => numOf(c.id) > since.reviewCommentId);
133
+ const newIssueComments = issueComments.filter((c) => numOf(c.id) > since.issueCommentId);
134
+ const max = (arr, prev) => arr.reduce((m, x) => Math.max(m, numOf(x.id)), prev);
135
+ return {
136
+ reviews: newReviews.map((r) => ({ id: numOf(r.id), state: strOf(r.state), body: strOf(r.body), user: { login: strOf(r.user?.login) } })),
137
+ reviewComments: newReviewComments.map((c) => ({
138
+ id: numOf(c.id),
139
+ body: strOf(c.body),
140
+ user: { login: strOf(c.user?.login) },
141
+ path: typeof c.path === "string" ? c.path : undefined,
142
+ line: typeof c.line === "number" ? c.line : undefined,
143
+ diff_hunk: typeof c.diff_hunk === "string" ? c.diff_hunk : undefined,
144
+ pull_request_review_id: typeof c.pull_request_review_id === "number" ? c.pull_request_review_id : undefined,
145
+ })),
146
+ issueComments: newIssueComments.map((c) => ({ id: numOf(c.id), body: strOf(c.body), user: { login: strOf(c.user?.login) } })),
147
+ maxIds: {
148
+ reviewId: max(reviews, since.reviewId),
149
+ reviewCommentId: max(reviewComments, since.reviewCommentId),
150
+ issueCommentId: max(issueComments, since.issueCommentId),
151
+ },
152
+ };
153
+ }
154
+ async function defaultFetchFileAtHead(owner, repo, headSha, path) {
155
+ try {
156
+ const data = (await apiGet(`/repos/${owner}/${repo}/contents/${path}?ref=${headSha}`));
157
+ if (strOf(data.encoding) !== "base64" || typeof data.content !== "string")
158
+ return null;
159
+ return Buffer.from(data.content.replace(/\n/g, ""), "base64").toString("utf8");
160
+ }
161
+ catch {
162
+ return null;
163
+ }
164
+ }
165
+ async function defaultFetchPrCommits(owner, repo, number) {
166
+ const items = (await apiGet(`/repos/${owner}/${repo}/pulls/${number}/commits?per_page=100`));
167
+ return (items ?? []).map((c) => strOf(c.sha)).filter(Boolean);
168
+ }
169
+ /**
170
+ * Draft reply for a fixed item: what a human would post on the PR.
171
+ * Autopilot never posts; these queue in autopilot-replies.json.
172
+ */
173
+ export function draftForFix(item, fix) {
174
+ switch (fix.kind) {
175
+ case "dco":
176
+ return `Fixed the DCO sign-off gap: all commits on this branch now carry the Signed-off-by trailer. No code changes, only commit messages (commit trees are byte-identical).`;
177
+ case "merge-conflict":
178
+ return `Rebased onto the latest base and resolved the conflicts mechanically (${fix.detail}). Please let me know if anything looks off.`;
179
+ case "format":
180
+ return `Ran the repo formatter; this push is formatting-only (verified whitespace-only diff).`;
181
+ default:
182
+ return `Addressed: ${item.title}`;
183
+ }
184
+ }
185
+ /** Draft reply for a rebutted bot finding. */
186
+ export function draftForRebuttal(item) {
187
+ return `Looked into this again: ${item.evidence ?? "the flagged code does not match what is at the PR head"}. Leaving it as is, happy to be shown otherwise.`;
188
+ }
189
+ async function defaultCloneFork(cloneUrl, branch, dest) {
190
+ const git = defaultGitRunner();
191
+ await git.run(tmpdir(), ["clone", "--no-checkout", cloneUrl, dest]);
192
+ await git.run(dest, ["fetch", "origin", branch]);
193
+ await git.run(dest, ["checkout", "-B", "autopilot-fix", `origin/${branch}`]);
194
+ }
195
+ async function runAutopilotOnPr(pr, watermark, opts, deps, capabilities, capsPath, state, expectedForkOwner) {
196
+ const items = [];
197
+ const fixes = [];
198
+ const plans = [];
199
+ const drafts = [];
200
+ // Capability gate: never rebuild where PR creation is blocked.
201
+ const blockReason = prCreateBlockReason(capabilities, `${pr.owner}/${pr.repo}`);
202
+ if (blockReason) {
203
+ return { pr, url: pr.url, skipped: `capability ledger: ${blockReason}`, items, fixes, plans, replyDrafts: 0 };
204
+ }
205
+ // 1. Review feedback (incremental).
206
+ const fetchReviewSources = deps.fetchReviewSources ?? defaultFetchReviewSources;
207
+ const sources = await fetchReviewSources(pr.owner, pr.repo, pr.number, {
208
+ reviewId: watermark.lastReviewId,
209
+ reviewCommentId: watermark.lastReviewCommentId,
210
+ issueCommentId: watermark.lastIssueCommentId,
211
+ });
212
+ const triageDeps = {
213
+ fetchPull: async () => ({ title: pr.title, author: pr.author }),
214
+ fetchReviews: async () => sources.reviews,
215
+ fetchReviewComments: async () => sources.reviewComments,
216
+ fetchIssueComments: async () => sources.issueComments,
217
+ };
218
+ const reviewResult = await triageReview(pr.url, triageDeps).catch(() => null);
219
+ // Review-level verdicts: an APPROVED or CHANGES_REQUESTED review is an
220
+ // actionable item in its own right, independent of comment classification.
221
+ for (const r of sources.reviews) {
222
+ const state = (r.state ?? "").toUpperCase();
223
+ if (state !== "APPROVED" && state !== "CHANGES_REQUESTED")
224
+ continue;
225
+ const login = r.user?.login ?? "unknown";
226
+ if (login === pr.author)
227
+ continue;
228
+ items.push({
229
+ id: `review:${r.id}`,
230
+ kind: state === "APPROVED" ? "approval" : "review_change_request",
231
+ title: `${login} ${state === "APPROVED" ? "approved" : "requested changes"}${r.body ? ": " + r.body.slice(0, 100) : ""}`,
232
+ body: r.body ?? "",
233
+ author: login,
234
+ authorKind: authorKindOf(login),
235
+ url: pr.url,
236
+ });
237
+ }
238
+ if (reviewResult) {
239
+ for (const f of reviewResult.findings) {
240
+ if (f.author === pr.author)
241
+ continue;
242
+ const kind = classifyReviewItem({ body: f.summary, author: f.author, authorKind: f.authorKind });
243
+ items.push({
244
+ id: `finding:${f.author}:${f.summary.slice(0, 60)}`,
245
+ kind: f.class === "likely-noise" ? "other" : kind,
246
+ title: f.summary.slice(0, 120),
247
+ body: f.summary,
248
+ author: f.author,
249
+ authorKind: f.authorKind,
250
+ path: f.path,
251
+ line: f.line,
252
+ evidence: f.reason,
253
+ draftReply: f.draft || undefined,
254
+ });
255
+ }
256
+ }
257
+ // 2. CI triage (introduced vs pre-existing).
258
+ const triageCi = deps.triageCi ?? triagePr;
259
+ const ciResult = await triageCi(pr.url).catch(() => null);
260
+ if (ciResult && "checks" in ciResult) {
261
+ for (const item of feedbackFromCiChecks(ciResult.checks))
262
+ items.push(item);
263
+ }
264
+ // 3. Merge conflict state.
265
+ if (pr.mergeableState === "dirty" || pr.mergeable === false) {
266
+ items.push({
267
+ id: `merge-conflict:${pr.headSha}`,
268
+ kind: "merge_conflict",
269
+ title: "Branch has merge conflicts with the base",
270
+ body: `mergeable_state=${pr.mergeableState}`,
271
+ author: "github",
272
+ authorKind: "bot",
273
+ });
274
+ }
275
+ // 4. DCO gaps: inspect commit messages.
276
+ const fetchPrCommits = deps.fetchPrCommits ?? defaultFetchPrCommits;
277
+ const commitShas = await fetchPrCommits(pr.owner, pr.repo, pr.number).catch(() => []);
278
+ const gitOps = deps.gitOps ?? defaultGitOps();
279
+ const commitObjs = [];
280
+ for (const sha of commitShas) {
281
+ try {
282
+ commitObjs.push(await gitOps.getCommit(pr.owner, pr.repo, sha));
283
+ }
284
+ catch { /* skip unreadable */ }
285
+ }
286
+ const unsigned = commitObjs.filter((c) => !hasSignoff(c.message));
287
+ if (unsigned.length > 0) {
288
+ items.push({
289
+ id: `dco:${pr.headSha}`,
290
+ kind: "dco_gap",
291
+ title: `DCO: ${unsigned.length} commit(s) lack a Signed-off-by trailer`,
292
+ body: `unsigned: ${unsigned.map((c) => c.sha.slice(0, 8)).join(", ")}`,
293
+ author: "autopilot",
294
+ authorKind: "bot",
295
+ evidence: `${unsigned.length} of ${commitObjs.length} commits lack the trailer`,
296
+ });
297
+ }
298
+ // 5. Verify bot findings against head.
299
+ const fetchFileAtHead = deps.fetchFileAtHead ?? defaultFetchFileAtHead;
300
+ for (const item of items) {
301
+ if (item.kind === "bot_finding") {
302
+ const { verdict, evidence } = await verifyBotFinding(item, {
303
+ fetchFileAtHead: (path) => fetchFileAtHead(pr.owner, pr.repo, pr.headSha, path),
304
+ }).catch(() => ({ verdict: "NEEDS_HUMAN", evidence: "verification errored" }));
305
+ item.verdict = verdict;
306
+ item.evidence = evidence;
307
+ if (verdict === "FALSE_POSITIVE" && item.draftReply) {
308
+ drafts.push({
309
+ pr: pr.url,
310
+ itemId: item.id,
311
+ kind: item.kind,
312
+ draft: item.draftReply,
313
+ queuedAt: new Date().toISOString(),
314
+ queuedReason: "queued behind the GitHub comment block",
315
+ });
316
+ }
317
+ else if (verdict === "FALSE_POSITIVE") {
318
+ drafts.push({
319
+ pr: pr.url,
320
+ itemId: item.id,
321
+ kind: item.kind,
322
+ draft: draftForRebuttal(item),
323
+ queuedAt: new Date().toISOString(),
324
+ queuedReason: "queued behind the GitHub comment block",
325
+ });
326
+ }
327
+ }
328
+ }
329
+ // 6. Apply fixes (report-only by default).
330
+ const apply = !!opts.apply;
331
+ const maxFixes = opts.maxFixes ?? 5;
332
+ let fixesUsed = 0;
333
+ const canFix = () => apply && fixesUsed < maxFixes;
334
+ const forkOk = pr.forkOwner.toLowerCase() === expectedForkOwner.toLowerCase() && pr.forkRepo.length > 0 && pr.forkBranch.length > 0;
335
+ if (apply && !forkOk) {
336
+ plans.push({
337
+ itemId: "guard:fork",
338
+ kind: "other",
339
+ title: "Fork ownership",
340
+ plan: `refusing fixes: branch ${pr.forkBranch} is on ${pr.forkOwner}/${pr.forkRepo}, not the ${expectedForkOwner} fork`,
341
+ });
342
+ }
343
+ // 6a. DCO surgery.
344
+ const dcoItem = items.find((i) => i.kind === "dco_gap");
345
+ if (dcoItem) {
346
+ if (canFix() && forkOk) {
347
+ try {
348
+ const res = await applyDcoSurgery(pr.forkOwner, pr.forkRepo, pr.forkBranch, commitObjs, gitOps);
349
+ fixes.push({
350
+ kind: "dco",
351
+ itemId: dcoItem.id,
352
+ oldHead: res.oldHead,
353
+ newHead: res.newHead,
354
+ detail: `${res.fixed} commit(s) signed, ${res.anchored} anchored, trees byte-identical`,
355
+ });
356
+ fixesUsed += 1;
357
+ drafts.push({
358
+ pr: pr.url,
359
+ itemId: dcoItem.id,
360
+ kind: dcoItem.kind,
361
+ draft: draftForFix(dcoItem, fixes[fixes.length - 1]),
362
+ queuedAt: new Date().toISOString(),
363
+ queuedReason: "queued behind the GitHub comment block",
364
+ });
365
+ recordAutopilotFixOutcome(capsPath, `${pr.owner}/${pr.repo}`, "dco", "ok", `${res.oldHead.slice(0, 8)} -> ${res.newHead.slice(0, 8)}`);
366
+ }
367
+ catch (err) {
368
+ plans.push({
369
+ itemId: dcoItem.id,
370
+ kind: dcoItem.kind,
371
+ title: dcoItem.title,
372
+ plan: `DCO surgery failed and was rolled back before the ref move: ${err instanceof Error ? err.message : String(err)}`,
373
+ });
374
+ }
375
+ }
376
+ else if (!apply) {
377
+ plans.push({
378
+ itemId: dcoItem.id,
379
+ kind: dcoItem.kind,
380
+ title: dcoItem.title,
381
+ plan: `report-only run: rerun with --apply to append the sign-off to ${unsigned.length} commit(s) (message-only, trees byte-identical)`,
382
+ });
383
+ }
384
+ }
385
+ // 6b. Merge conflicts: mechanical only.
386
+ const conflictItem = items.find((i) => i.kind === "merge_conflict");
387
+ if (conflictItem) {
388
+ if (canFix() && forkOk) {
389
+ const fix = await attemptMergeFix(pr, deps, expectedForkOwner).catch((err) => ({
390
+ ok: false,
391
+ reason: err instanceof Error ? err.message : String(err),
392
+ }));
393
+ if (fix.ok) {
394
+ fixes.push({
395
+ kind: "merge-conflict",
396
+ itemId: conflictItem.id,
397
+ oldHead: fix.oldHead,
398
+ newHead: fix.newHead,
399
+ detail: fix.detail,
400
+ });
401
+ fixesUsed += 1;
402
+ drafts.push({
403
+ pr: pr.url,
404
+ itemId: conflictItem.id,
405
+ kind: conflictItem.kind,
406
+ draft: draftForFix(conflictItem, fixes[fixes.length - 1]),
407
+ queuedAt: new Date().toISOString(),
408
+ queuedReason: "queued behind the GitHub comment block",
409
+ });
410
+ recordAutopilotFixOutcome(capsPath, `${pr.owner}/${pr.repo}`, "merge-conflict", "ok", fix.detail);
411
+ }
412
+ else {
413
+ plans.push({
414
+ itemId: conflictItem.id,
415
+ kind: conflictItem.kind,
416
+ title: conflictItem.title,
417
+ plan: fix.reason,
418
+ });
419
+ }
420
+ }
421
+ else if (!apply) {
422
+ plans.push({
423
+ itemId: conflictItem.id,
424
+ kind: conflictItem.kind,
425
+ title: conflictItem.title,
426
+ plan: "report-only run: rerun with --apply to attempt a mechanical merge (lockfiles, changelog, version bumps only; anything else aborts)",
427
+ });
428
+ }
429
+ }
430
+ // 6c. Format fix for introduced CI failures that name formatting/lint.
431
+ if (canFix() && forkOk) {
432
+ const fmtCandidate = items.find((i) => i.kind === "ci_failure" &&
433
+ i.verdict === "REAL" &&
434
+ /lint|format|prettier|eslint|style/i.test(`${i.title} ${i.body}`));
435
+ if (fmtCandidate) {
436
+ const fix = await attemptFormatFix(pr, deps, gitOps).catch((err) => ({
437
+ ok: false,
438
+ reason: err instanceof Error ? err.message : String(err),
439
+ }));
440
+ if (fix.ok) {
441
+ fixes.push({ kind: "format", itemId: fmtCandidate.id, oldHead: fix.oldHead, newHead: fix.newHead, detail: fix.detail });
442
+ fixesUsed += 1;
443
+ drafts.push({
444
+ pr: pr.url,
445
+ itemId: fmtCandidate.id,
446
+ kind: fmtCandidate.kind,
447
+ draft: draftForFix(fmtCandidate, fixes[fixes.length - 1]),
448
+ queuedAt: new Date().toISOString(),
449
+ queuedReason: "queued behind the GitHub comment block",
450
+ });
451
+ recordAutopilotFixOutcome(capsPath, `${pr.owner}/${pr.repo}`, "format", "ok", fix.detail);
452
+ }
453
+ else {
454
+ plans.push({ itemId: fmtCandidate.id, kind: fmtCandidate.kind, title: fmtCandidate.title, plan: fix.reason });
455
+ }
456
+ }
457
+ }
458
+ // 7. Plans for everything else actionable.
459
+ for (const item of items) {
460
+ if (item.kind === "bot_finding" && item.verdict === "REAL") {
461
+ plans.push({
462
+ itemId: item.id,
463
+ kind: item.kind,
464
+ title: item.title,
465
+ plan: `real finding, no mechanical fix available: ${item.evidence ?? "see item"}`,
466
+ });
467
+ }
468
+ if (item.kind === "review_change_request" || item.kind === "maintainer_question") {
469
+ plans.push({
470
+ itemId: item.id,
471
+ kind: item.kind,
472
+ title: item.title,
473
+ plan: `needs Rene's judgment; a reply draft ${item.draftReply ? "is queued" : "was not available"}`,
474
+ });
475
+ }
476
+ if (item.kind === "ci_failure" && item.verdict === "REAL" && !/lint|format|prettier|eslint|style/i.test(`${item.title} ${item.body}`)) {
477
+ plans.push({
478
+ itemId: item.id,
479
+ kind: item.kind,
480
+ title: item.title,
481
+ plan: `introduced failure with no mechanical fix: ${item.evidence ?? "see CI logs"}`,
482
+ });
483
+ }
484
+ }
485
+ // Update watermark.
486
+ state.prs[pr.url] = {
487
+ lastHeadSha: pr.headSha,
488
+ lastReviewId: Math.max(watermark.lastReviewId, sources.maxIds.reviewId),
489
+ lastReviewCommentId: Math.max(watermark.lastReviewCommentId, sources.maxIds.reviewCommentId),
490
+ lastIssueCommentId: Math.max(watermark.lastIssueCommentId, sources.maxIds.issueCommentId),
491
+ checkConclusions: watermark.checkConclusions,
492
+ };
493
+ return { pr, url: pr.url, items, fixes, plans, replyDrafts: drafts.length, error: undefined, __drafts: drafts };
494
+ }
495
+ /**
496
+ * Mechanical merge of the current base into the fork branch. Local git does
497
+ * the merge computation in a temp clone; the merged tree is committed
498
+ * through the Git Data API and the branch ref fast-forwarded. Returns
499
+ * ok:false with a NEEDS_HUMAN reason whenever a conflict is not mechanical.
500
+ */
501
+ export async function attemptMergeFix(pr, deps, expectedForkOwner) {
502
+ if (pr.forkOwner.toLowerCase() !== expectedForkOwner.toLowerCase()) {
503
+ return { ok: false, reason: `NEEDS_HUMAN: branch is on ${pr.forkOwner}, not ${expectedForkOwner}` };
504
+ }
505
+ const git = deps.gitRunner ?? defaultGitRunner();
506
+ const cloneFork = deps.cloneFork ?? defaultCloneFork;
507
+ const gitOps = deps.gitOps ?? defaultGitOps();
508
+ const work = mkdtempSync(join(tmpdir(), "autopilot-merge-"));
509
+ try {
510
+ await cloneFork(pr.forkCloneUrl, pr.forkBranch, work);
511
+ const upstreamUrl = `https://github.com/${pr.owner}/${pr.repo}.git`;
512
+ await git.run(work, ["fetch", upstreamUrl, pr.baseRef]);
513
+ const mergeBase = (await git.run(work, ["merge-base", "HEAD", "FETCH_HEAD"])).stdout.trim();
514
+ const baseHead = (await git.run(work, ["rev-parse", "FETCH_HEAD"])).stdout.trim();
515
+ const oldHead = (await git.run(work, ["rev-parse", "HEAD"])).stdout.trim();
516
+ let conflicted = [];
517
+ try {
518
+ await git.run(work, ["merge", "--no-commit", "--no-ff", "FETCH_HEAD"]);
519
+ }
520
+ catch {
521
+ conflicted = (await git.run(work, ["diff", "--name-only", "--diff-filter=U"])).stdout
522
+ .split("\n")
523
+ .map((s) => s.trim())
524
+ .filter(Boolean);
525
+ }
526
+ if (conflicted.length > 0) {
527
+ const unresolvable = [];
528
+ for (const file of conflicted) {
529
+ const strategy = conflictFileStrategy(file);
530
+ if (strategy === "lockfile") {
531
+ const manager = lockfileManager(file);
532
+ if (!manager) {
533
+ unresolvable.push(file);
534
+ continue;
535
+ }
536
+ const regenCmds = {
537
+ npm: ["npm", "install", "--package-lock-only", "--no-audit", "--no-fund"],
538
+ pnpm: ["pnpm", "install", "--lockfile-only"],
539
+ yarn: ["yarn", "install", "--mode", "update-lockfile"],
540
+ bun: ["bun", "install", "--lockfile-only"],
541
+ cargo: ["cargo", "generate-lockfile"],
542
+ go: ["go", "mod", "tidy"],
543
+ };
544
+ const cmd = regenCmds[manager];
545
+ if (!cmd) {
546
+ unresolvable.push(file);
547
+ continue;
548
+ }
549
+ try {
550
+ await git.run(work, cmd);
551
+ await git.run(work, ["add", file]);
552
+ }
553
+ catch {
554
+ unresolvable.push(file);
555
+ }
556
+ continue;
557
+ }
558
+ // Version-bump and changelog resolution on text conflicts.
559
+ const oursText = (await git.run(work, ["show", `:2:${file}`])).stdout;
560
+ const theirsText = (await git.run(work, ["show", `:3:${file}`])).stdout;
561
+ const baseText = (await git.run(work, ["show", `:1:${file}`])).stdout;
562
+ if (strategy === "changelog") {
563
+ const resolved = resolveChangelogMerge(baseText, oursText, theirsText);
564
+ writeFileSync(join(work, file), resolved);
565
+ await git.run(work, ["add", file]);
566
+ continue;
567
+ }
568
+ const baseLines = baseText.split("\n");
569
+ // Resolve every hunk in the working file mechanically; an empty
570
+ // result means some hunk was not mechanical.
571
+ const resolvedText = await resolveHunksInFile(git, work, file, baseLines);
572
+ if (resolvedText === "") {
573
+ unresolvable.push(file);
574
+ continue;
575
+ }
576
+ writeFileSync(join(work, file), resolvedText);
577
+ await git.run(work, ["add", file]);
578
+ }
579
+ if (unresolvable.length > 0) {
580
+ return {
581
+ ok: false,
582
+ reason: `NEEDS_HUMAN: ${unresolvable.length} conflicted file(s) are not mechanical: ${unresolvable.join(", ")}`,
583
+ };
584
+ }
585
+ }
586
+ const mergedTree = (await git.run(work, ["write-tree"])).stdout.trim();
587
+ const message = `Merge ${pr.baseRef} into ${pr.forkBranch}\n\nAutopilot mechanical merge (conflicts resolved: ${conflicted.length > 0 ? conflicted.join(", ") : "none"})\n\n${DCO_SIGNOFF_LINE}`;
588
+ const newHead = await gitOps.createCommit(pr.forkOwner, pr.forkRepo, {
589
+ message,
590
+ tree: mergedTree,
591
+ parents: [oldHead, baseHead],
592
+ author: { ...AUTOPILOT_COMMITTER },
593
+ committer: { ...AUTOPILOT_COMMITTER },
594
+ });
595
+ await gitOps.updateRef(pr.forkOwner, pr.forkRepo, pr.forkBranch, newHead, false);
596
+ const verifyTree = (await gitOps.getCommit(pr.forkOwner, pr.forkRepo, newHead)).treeSha;
597
+ if (verifyTree !== mergedTree)
598
+ throw new Error("merge commit tree mismatch after ref update");
599
+ void mergeBase;
600
+ return {
601
+ ok: true,
602
+ oldHead,
603
+ newHead,
604
+ detail: `merged ${pr.baseRef}@${baseHead.slice(0, 8)}; ${conflicted.length} conflicted file(s) resolved mechanically`,
605
+ };
606
+ }
607
+ finally {
608
+ try {
609
+ rmSync(work, { recursive: true, force: true });
610
+ }
611
+ catch { /* temp cleanup is best-effort */ }
612
+ }
613
+ }
614
+ /**
615
+ * Resolve every conflict hunk in a working file using the mechanical
616
+ * resolvers; returns "" when any hunk is not mechanical. Base content comes
617
+ * from git stage 1 for the file.
618
+ */
619
+ async function resolveHunksInFile(git, work, file, baseLines) {
620
+ const text = readFileSync(join(work, file), "utf8");
621
+ const hunks = parseConflictHunks(text);
622
+ if (!hunks)
623
+ return "";
624
+ // Align base lines to hunks: compare each hunk's surroundings. For the
625
+ // mechanical case we only accept single-line hunks where exactly one side
626
+ // kept a base token and the other is a strict increase.
627
+ const lines = text.split("\n");
628
+ const out = [];
629
+ let hunkIdx = 0;
630
+ let i = 0;
631
+ while (i < lines.length) {
632
+ if (lines[i].startsWith("<<<<<<<")) {
633
+ const hunk = hunks[hunkIdx++];
634
+ // Find the base single line: the first line that differs between
635
+ // ours/theirs is resolved token-wise against a base candidate.
636
+ const baseLine = findBaseLine(baseLines, hunk);
637
+ const resolved = baseLine !== null ? resolveVersionBumpHunk(hunk, [baseLine]) : null;
638
+ if (!resolved)
639
+ return "";
640
+ out.push(...resolved.resolved);
641
+ while (i < lines.length && !lines[i].startsWith(">>>>>>>"))
642
+ i++;
643
+ i++;
644
+ continue;
645
+ }
646
+ out.push(lines[i]);
647
+ i++;
648
+ }
649
+ return out.join("\n");
650
+ }
651
+ /** Heuristic base line for a single-line hunk: a base line sharing the most tokens. */
652
+ function findBaseLine(baseLines, hunk) {
653
+ if (hunk.ours.length !== 1 || hunk.theirs.length !== 1)
654
+ return null;
655
+ let best = null;
656
+ let bestScore = 0;
657
+ for (const line of baseLines) {
658
+ const a = new Set(line.split(/\s+/));
659
+ const scoreOurs = hunk.ours[0].split(/\s+/).filter((t) => a.has(t)).length;
660
+ const scoreTheirs = hunk.theirs[0].split(/\s+/).filter((t) => a.has(t)).length;
661
+ const score = Math.max(scoreOurs, scoreTheirs);
662
+ if (score > bestScore) {
663
+ bestScore = score;
664
+ best = line;
665
+ }
666
+ }
667
+ return bestScore > 0 ? best : null;
668
+ }
669
+ async function attemptFormatFix(pr, deps, gitOps) {
670
+ const git = deps.gitRunner ?? defaultGitRunner();
671
+ const cloneFork = deps.cloneFork ?? defaultCloneFork;
672
+ const work = mkdtempSync(join(tmpdir(), "autopilot-format-"));
673
+ try {
674
+ await cloneFork(pr.forkCloneUrl, pr.forkBranch, work);
675
+ const oldHead = (await git.run(work, ["rev-parse", "HEAD"])).stdout.trim();
676
+ const files = (await git.run(work, ["ls-files"])).stdout.split("\n").map((s) => s.trim()).filter(Boolean);
677
+ let scripts = {};
678
+ try {
679
+ const pkg = JSON.parse(readFileSync(join(work, "package.json"), "utf8"));
680
+ scripts = pkg.scripts ?? {};
681
+ }
682
+ catch { /* no package.json */ }
683
+ const formatter = detectFormatter(files, scripts);
684
+ if (!formatter)
685
+ return { ok: false, reason: "no formatter path found (no format script, prettier, or eslint --fix)" };
686
+ try {
687
+ await git.run(work, formatter.cmd);
688
+ }
689
+ catch (err) {
690
+ return { ok: false, reason: `formatter failed: ${err instanceof Error ? err.message : String(err)}` };
691
+ }
692
+ if (!(await diffIsFormattingOnly(git, work))) {
693
+ return { ok: false, reason: "formatter changed non-whitespace content: refusing (not formatting-only)" };
694
+ }
695
+ const newTree = (await git.run(work, ["write-tree"])).stdout.trim();
696
+ if (newTree === (await git.run(work, ["rev-parse", "HEAD^{tree}"])).stdout.trim()) {
697
+ return { ok: false, reason: "formatter produced no changes" };
698
+ }
699
+ const message = `Apply ${formatter.name} (formatting only)\n\nAutopilot: whitespace-only diff verified via git diff -w\n\n${DCO_SIGNOFF_LINE}`;
700
+ const newHead = await gitOps.createCommit(pr.forkOwner, pr.forkRepo, {
701
+ message,
702
+ tree: newTree,
703
+ parents: [oldHead],
704
+ author: { ...AUTOPILOT_COMMITTER },
705
+ committer: { ...AUTOPILOT_COMMITTER },
706
+ });
707
+ await gitOps.updateRef(pr.forkOwner, pr.forkRepo, pr.forkBranch, newHead, false);
708
+ return { ok: true, oldHead, newHead, detail: `formatter ${formatter.name}; whitespace-only diff` };
709
+ }
710
+ finally {
711
+ try {
712
+ rmSync(work, { recursive: true, force: true });
713
+ }
714
+ catch { /* best-effort */ }
715
+ }
716
+ }
717
+ /** Persist reply drafts to the replies file, appending to any existing queue. */
718
+ export function saveReplyDrafts(path, drafts) {
719
+ let existing = [];
720
+ try {
721
+ const raw = JSON.parse(readFileSync(path, "utf8"));
722
+ if (Array.isArray(raw.replies))
723
+ existing = raw.replies.filter((r) => r && typeof r === "object");
724
+ }
725
+ catch { /* new queue */ }
726
+ mkdirSync(join(homedir(), ".greenlight"), { recursive: true });
727
+ writeFileSync(path, JSON.stringify({ replies: [...existing, ...drafts] }, null, 2));
728
+ return drafts.length;
729
+ }
730
+ const KIND_LABEL = {
731
+ ci_failure: "CI failure",
732
+ review_change_request: "Change request",
733
+ bot_finding: "Bot finding",
734
+ merge_conflict: "Merge conflict",
735
+ dco_gap: "DCO gap",
736
+ maintainer_question: "Maintainer question",
737
+ approval: "Approval",
738
+ other: "Other",
739
+ };
740
+ export function autopilotMarkdown(run) {
741
+ const v = defaultPackageVersion();
742
+ const lines = [];
743
+ lines.push(`# Autopilot report`);
744
+ lines.push(``);
745
+ const s = run.summary;
746
+ lines.push(`Scanned ${s.prsScanned} PR(s)${s.prsSkippedBlocked > 0 ? ` (${s.prsSkippedBlocked} skipped: capability-blocked)` : ""}: ` +
747
+ `${s.itemsFound} feedback item(s), ${s.fixesPushed} fix(es) pushed, ${s.plans} plan(s), ${s.repliesQueued} reply draft(s) queued.`);
748
+ for (const r of run.perPr) {
749
+ lines.push(``);
750
+ lines.push(`## ${r.url}`);
751
+ if (r.skipped) {
752
+ lines.push(`Skipped: ${r.skipped}`);
753
+ continue;
754
+ }
755
+ if (r.error) {
756
+ lines.push(`Error: ${r.error}`);
757
+ continue;
758
+ }
759
+ if (r.items.length === 0 && r.fixes.length === 0) {
760
+ lines.push(`Quiet: no new feedback.`);
761
+ continue;
762
+ }
763
+ for (const item of r.items) {
764
+ const verdict = item.verdict ? ` [${item.verdict}]` : "";
765
+ lines.push(`- [${KIND_LABEL[item.kind]}]${verdict} ${item.title}`);
766
+ if (item.evidence)
767
+ lines.push(` evidence: ${item.evidence.slice(0, 200)}`);
768
+ }
769
+ for (const f of r.fixes) {
770
+ lines.push(`- FIX pushed (${f.kind}): ${f.oldHead.slice(0, 8)} -> ${f.newHead.slice(0, 8)} (${f.detail})`);
771
+ }
772
+ for (const p of r.plans) {
773
+ lines.push(`- PLAN [${KIND_LABEL[p.kind]}]: ${p.title}: ${p.plan}`);
774
+ }
775
+ }
776
+ lines.push(``);
777
+ lines.push(`Greenlight autopilot v${v}`);
778
+ return lines.join("\n");
779
+ }
780
+ export function autopilotJson(run) {
781
+ return {
782
+ perPr: run.perPr.map((r) => ({
783
+ url: r.url,
784
+ skipped: r.skipped ?? null,
785
+ error: r.error ?? null,
786
+ items: r.items.map((i) => ({
787
+ id: i.id,
788
+ kind: i.kind,
789
+ title: i.title,
790
+ author: i.author,
791
+ authorKind: i.authorKind,
792
+ verdict: i.verdict ?? null,
793
+ evidence: i.evidence ?? null,
794
+ url: i.url ?? null,
795
+ })),
796
+ fixes: r.fixes.map((f) => ({ kind: f.kind, itemId: f.itemId, oldHead: f.oldHead, newHead: f.newHead, detail: f.detail })),
797
+ plans: r.plans,
798
+ })),
799
+ summary: run.summary,
800
+ version: defaultPackageVersion(),
801
+ };
802
+ }
803
+ async function pool(items, concurrency, fn) {
804
+ const results = new Array(items.length);
805
+ let idx = 0;
806
+ const workers = Array.from({ length: Math.max(1, concurrency) }, async () => {
807
+ while (idx < items.length) {
808
+ const i = idx++;
809
+ results[i] = await fn(items[i]);
810
+ }
811
+ });
812
+ await Promise.all(workers);
813
+ return results;
814
+ }
815
+ /**
816
+ * Run Autopilot. Report-only by default; --apply performs fixes.
817
+ * Returns the run with markdown and json renderings.
818
+ */
819
+ export async function runAutopilot(opts) {
820
+ const deps = opts.deps ?? {};
821
+ const author = opts.author ?? defaultAccountAuthor();
822
+ const expectedForkOwner = deps.expectedForkOwner ?? defaultAccountAuthor();
823
+ const statePath = opts.statePath ?? defaultAutopilotStatePath();
824
+ const repliesPath = opts.repliesPath ?? defaultRepliesPath();
825
+ const capsPath = opts.capabilitiesPath ?? defaultCapabilitiesPath();
826
+ const state = loadAutopilotState(statePath);
827
+ const capabilities = loadCapabilities(capsPath);
828
+ let urls = [];
829
+ if (opts.prs && opts.prs.length > 0) {
830
+ urls = opts.prs;
831
+ }
832
+ else {
833
+ const discover = deps.discoverPrs ?? defaultDiscoverPrs;
834
+ const census = await discover(author);
835
+ urls = census.map((p) => p.url);
836
+ }
837
+ const maxPrs = opts.maxPrs ?? 50;
838
+ urls = urls.slice(0, maxPrs);
839
+ const fetchPr = deps.fetchPr ?? defaultFetchPr;
840
+ const perPr = await pool(urls, opts.concurrency ?? 3, async (url) => {
841
+ try {
842
+ const m = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/.exec(url);
843
+ if (!m)
844
+ return { pr: null, url, skipped: "not a PR url", items: [], fixes: [], plans: [], replyDrafts: 0 };
845
+ const pr = await fetchPr(m[1], m[2], Number(m[3]));
846
+ const watermark = state.prs[url] ?? emptyWatermark();
847
+ const report = await runAutopilotOnPr(pr, watermark, opts, { ...deps, fetchPr }, capabilities, capsPath, state, expectedForkOwner);
848
+ const drafts = report.__drafts ?? [];
849
+ if (drafts.length > 0)
850
+ saveReplyDrafts(repliesPath, drafts);
851
+ delete report.__drafts;
852
+ report.replyDrafts = drafts.length;
853
+ return report;
854
+ }
855
+ catch (err) {
856
+ return {
857
+ pr: null,
858
+ url,
859
+ items: [],
860
+ fixes: [],
861
+ plans: [],
862
+ replyDrafts: 0,
863
+ error: err instanceof Error ? err.message : String(err),
864
+ };
865
+ }
866
+ });
867
+ saveAutopilotState(statePath, state);
868
+ const summary = {
869
+ prsScanned: perPr.filter((r) => !r.skipped && !r.error).length,
870
+ prsSkippedBlocked: perPr.filter((r) => !!r.skipped).length,
871
+ prsErrored: perPr.filter((r) => !!r.error).length,
872
+ itemsFound: 0,
873
+ fixesPushed: 0,
874
+ plans: 0,
875
+ repliesQueued: 0,
876
+ itemsByKind: {
877
+ ci_failure: 0, review_change_request: 0, bot_finding: 0, merge_conflict: 0,
878
+ dco_gap: 0, maintainer_question: 0, approval: 0, other: 0,
879
+ },
880
+ botVerdicts: { real: 0, falsePositive: 0, needsHuman: 0 },
881
+ ciIntroduced: 0,
882
+ ciPreExistingOrExternal: 0,
883
+ };
884
+ for (const r of perPr) {
885
+ summary.itemsFound += r.items.length;
886
+ summary.fixesPushed += r.fixes.length;
887
+ summary.plans += r.plans.length;
888
+ summary.repliesQueued += r.replyDrafts;
889
+ const fb = summarizeFeedback(r.items);
890
+ for (const k of Object.keys(summary.itemsByKind))
891
+ summary.itemsByKind[k] += fb.byKind[k];
892
+ summary.botVerdicts.real += fb.botFindingsReal;
893
+ summary.botVerdicts.falsePositive += fb.botFindingsFalsePositive;
894
+ summary.botVerdicts.needsHuman += fb.botFindingsNeedsHuman;
895
+ summary.ciIntroduced += fb.ciIntroduced;
896
+ summary.ciPreExistingOrExternal += fb.ciPreExistingOrExternal;
897
+ }
898
+ const run = { perPr, summary };
899
+ return { perPr, summary, json: autopilotJson(run), markdown: autopilotMarkdown(run) };
900
+ }
901
+ // Re-export for callers that only need the author kind helper.
902
+ export { authorKindOf as autopilotAuthorKindOf };
903
+ //# sourceMappingURL=autopilot.js.map