pullfrog 0.1.32 → 0.1.33

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.
package/dist/cli.mjs CHANGED
@@ -102841,7 +102841,7 @@ var import_semver = __toESM(require_semver2(), 1);
102841
102841
  // package.json
102842
102842
  var package_default = {
102843
102843
  name: "pullfrog",
102844
- version: "0.1.32",
102844
+ version: "0.1.33",
102845
102845
  type: "module",
102846
102846
  bin: {
102847
102847
  pullfrog: "dist/cli.mjs",
@@ -159380,13 +159380,14 @@ async function approveAfterFix(ctx) {
159380
159380
  });
159381
159381
  if (pr.data.state !== "open") return;
159382
159382
  const headSha = pr.data.head.sha;
159383
+ const selfAuthored = isPullfrog(pr.data.user?.login);
159383
159384
  const summary2 = ctx.toolState.lastProgressBody?.trim();
159384
159385
  const body = "> \u2705 Pullfrog addressed all of its review feedback on this PR.\n\n" + (summary2 || "all Pullfrog-raised review threads are resolved \u2014 no outstanding findings remain.");
159385
159386
  const params = {
159386
159387
  owner: ctx.repo.owner,
159387
159388
  repo: ctx.repo.name,
159388
159389
  pull_number: pullNumber,
159389
- event: "APPROVE",
159390
+ event: selfAuthored ? "COMMENT" : "APPROVE",
159390
159391
  commit_id: headSha
159391
159392
  };
159392
159393
  const result = await createAndSubmitWithFooter(ctx, params, {
@@ -159558,22 +159559,39 @@ function CreatePullRequestReviewTool(ctx) {
159558
159559
  );
159559
159560
  }
159560
159561
  }
159562
+ const prSnapshot = (await ctx.octokit.rest.pulls.get({
159563
+ owner: ctx.repo.owner,
159564
+ repo: ctx.repo.name,
159565
+ pull_number
159566
+ })).data;
159567
+ const selfAuthored = isPullfrog(prSnapshot.user?.login);
159561
159568
  ctx.toolState.approval = { wouldApprove: approved === true, sha: primary.checkoutSha };
159562
159569
  const skip = reviewSkipDecision({
159563
159570
  approved: approved ?? false,
159564
159571
  requestChanges: request_changes ?? false,
159565
159572
  body,
159566
159573
  hasComments: comments.length > 0,
159567
- prApproveEnabled: ctx.prApproveEnabled
159574
+ // a self-authored approve downgrades to COMMENT (GitHub blocks a
159575
+ // self-APPROVE), so treat it as non-binding here — an empty self-
159576
+ // approve is then skipped (the verdict is still recorded above)
159577
+ // instead of POSTing an empty COMMENT that GitHub 422s.
159578
+ prApproveEnabled: ctx.prApproveEnabled && !selfAuthored
159568
159579
  });
159569
159580
  if (skip) {
159570
159581
  log.info(`skipping review submission: ${skip.reason}`);
159571
159582
  return { success: true, skipped: true, reason: skip.reason };
159572
159583
  }
159573
159584
  let event = approved ? "APPROVE" : request_changes ? "REQUEST_CHANGES" : "COMMENT";
159574
- if ((event === "APPROVE" || event === "REQUEST_CHANGES") && !ctx.prApproveEnabled) {
159575
- log.info(`prApproveEnabled is disabled \u2014 downgrading ${event} to COMMENT`);
159576
- event = "COMMENT";
159585
+ if (event === "APPROVE" || event === "REQUEST_CHANGES") {
159586
+ if (!ctx.prApproveEnabled) {
159587
+ log.info(`prApproveEnabled is disabled \u2014 downgrading ${event} to COMMENT`);
159588
+ event = "COMMENT";
159589
+ } else if (selfAuthored) {
159590
+ log.info(
159591
+ `self-authored PR \u2014 downgrading binding ${event} to COMMENT (verdict recorded internally)`
159592
+ );
159593
+ event = "COMMENT";
159594
+ }
159577
159595
  }
159578
159596
  const params = {
159579
159597
  owner: ctx.repo.owner,
@@ -159585,12 +159603,7 @@ function CreatePullRequestReviewTool(ctx) {
159585
159603
  if (commit_id) {
159586
159604
  params.commit_id = commit_id;
159587
159605
  } else {
159588
- const pr = await ctx.octokit.rest.pulls.get({
159589
- owner: ctx.repo.owner,
159590
- repo: ctx.repo.name,
159591
- pull_number
159592
- });
159593
- latestHeadSha = pr.data.head.sha;
159606
+ latestHeadSha = prSnapshot.head.sha;
159594
159607
  params.commit_id = primary.checkoutSha ?? latestHeadSha;
159595
159608
  if (primary.checkoutSha && latestHeadSha !== primary.checkoutSha) {
159596
159609
  log.info(
@@ -159658,6 +159671,7 @@ function CreatePullRequestReviewTool(ctx) {
159658
159671
  }
159659
159672
  )();
159660
159673
  } catch (err) {
159674
+ if (ctx.toolState.approval) ctx.toolState.approval.wouldApprove = false;
159661
159675
  if (isTransientReviewError(err)) {
159662
159676
  const rawMsg2 = err instanceof Error ? err.message : String(err);
159663
159677
  throw new Error(
@@ -165545,6 +165559,7 @@ var defaultSettings = {
165545
165559
  push: "restricted",
165546
165560
  shell: "restricted",
165547
165561
  prApproveEnabled: false,
165562
+ autoMergeEnabled: false,
165548
165563
  signedCommits: false,
165549
165564
  modeInstructions: {},
165550
165565
  learnings: null,
@@ -165865,6 +165880,108 @@ ${genericBody}`,
165865
165880
  // utils/runLifecycle.ts
165866
165881
  var core10 = __toESM(require_core(), 1);
165867
165882
 
165883
+ // utils/checksGate.ts
165884
+ var OK_CONCLUSIONS = /* @__PURE__ */ new Set(["success", "neutral", "skipped"]);
165885
+ var PULLFROG_CHECKS = /* @__PURE__ */ new Set(["pullfrog", "pullfrog-approval"]);
165886
+ function checkRunsGate(params) {
165887
+ for (const run4 of params.checkRuns) {
165888
+ if (PULLFROG_CHECKS.has(run4.name.toLowerCase())) continue;
165889
+ if (run4.status !== "completed")
165890
+ return { ok: false, reason: `check "${run4.name}" ${run4.status}` };
165891
+ if (!OK_CONCLUSIONS.has(run4.conclusion ?? "")) {
165892
+ return { ok: false, reason: `check "${run4.name}" concluded ${run4.conclusion}` };
165893
+ }
165894
+ }
165895
+ return { ok: true, reason: "all checks passed" };
165896
+ }
165897
+ function hasVerifiedCheck(params) {
165898
+ return params.checkRuns.some(
165899
+ (r) => !PULLFROG_CHECKS.has(r.name.toLowerCase()) && r.status === "completed"
165900
+ );
165901
+ }
165902
+
165903
+ // utils/autoMerge.ts
165904
+ var NON_MERGEABLE_STATES = /* @__PURE__ */ new Set(["dirty", "behind", "blocked"]);
165905
+ function hasBlockingHumanReview(reviews) {
165906
+ const latestByReviewer = /* @__PURE__ */ new Map();
165907
+ for (const review of reviews) {
165908
+ const login = review.user?.login;
165909
+ if (!login || isPullfrog(login)) continue;
165910
+ if (review.state === "APPROVED" || review.state === "CHANGES_REQUESTED") {
165911
+ latestByReviewer.set(login, review.state);
165912
+ } else if (review.state === "DISMISSED") {
165913
+ latestByReviewer.delete(login);
165914
+ }
165915
+ }
165916
+ for (const state of latestByReviewer.values()) {
165917
+ if (state === "CHANGES_REQUESTED") return true;
165918
+ }
165919
+ return false;
165920
+ }
165921
+ async function autoMergeAfterApprove(ctx) {
165922
+ if (!ctx.autoMergeEnabled) return;
165923
+ if (!ctx.prApproveEnabled) return;
165924
+ const approval = ctx.toolState.approval;
165925
+ if (!approval?.wouldApprove) return;
165926
+ const pullNumber = ctx.payload.event.issue_number;
165927
+ if (typeof pullNumber !== "number") return;
165928
+ const skip = (reason) => log.info(
165929
+ `autoMerge: pr=#${pullNumber} approvalSha=${approval.sha?.slice(0, 7)} \u2192 skipped: ${reason}`
165930
+ );
165931
+ const pr = await ctx.octokit.rest.pulls.get({
165932
+ owner: ctx.repo.owner,
165933
+ repo: ctx.repo.name,
165934
+ pull_number: pullNumber
165935
+ });
165936
+ if (pr.data.state !== "open") return skip("pr not open");
165937
+ if (pr.data.draft) return skip("pr is draft");
165938
+ const headSha = pr.data.head.sha;
165939
+ if (approval.sha !== headSha) return skip(`approval sha != head ${headSha.slice(0, 7)}`);
165940
+ const outstanding = await countOutstandingPullfrogThreads(ctx, pullNumber);
165941
+ if (outstanding > 0) return skip(`${outstanding} unresolved Pullfrog thread(s)`);
165942
+ const reviews = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviews, {
165943
+ owner: ctx.repo.owner,
165944
+ repo: ctx.repo.name,
165945
+ pull_number: pullNumber,
165946
+ per_page: 100
165947
+ });
165948
+ if (hasBlockingHumanReview(reviews)) return skip("human changes requested");
165949
+ if (pr.data.mergeable !== true) return skip(`not mergeable (mergeable=${pr.data.mergeable})`);
165950
+ if (pr.data.mergeable_state && NON_MERGEABLE_STATES.has(pr.data.mergeable_state)) {
165951
+ return skip(`mergeable_state=${pr.data.mergeable_state}`);
165952
+ }
165953
+ const checkRuns = await ctx.octokit.paginate(ctx.octokit.rest.checks.listForRef, {
165954
+ owner: ctx.repo.owner,
165955
+ repo: ctx.repo.name,
165956
+ ref: headSha,
165957
+ per_page: 100
165958
+ });
165959
+ const gate = checkRunsGate({ checkRuns });
165960
+ if (!gate.ok) return skip(`ci: ${gate.reason}`);
165961
+ if (!hasVerifiedCheck({ checkRuns })) return skip("no completed external check-run to verify");
165962
+ const merge4 = await ctx.octokit.rest.pulls.merge({
165963
+ owner: ctx.repo.owner,
165964
+ repo: ctx.repo.name,
165965
+ pull_number: pullNumber,
165966
+ sha: headSha,
165967
+ merge_method: "squash",
165968
+ commit_title: `${pr.data.title} (#${pullNumber})`,
165969
+ commit_message: "merged autonomously by Pullfrog after a clean approval."
165970
+ });
165971
+ log.info(
165972
+ `autoMerge: pr=#${pullNumber} approvalSha=${headSha.slice(0, 7)} outstanding=0 \u2192 merged ${merge4.data.sha?.slice(0, 7)}`
165973
+ );
165974
+ await ctx.octokit.rest.issues.createComment({
165975
+ owner: ctx.repo.owner,
165976
+ repo: ctx.repo.name,
165977
+ issue_number: pullNumber,
165978
+ body: addFooter(
165979
+ ctx,
165980
+ "> \u2705 Merged autonomously after Pullfrog approved this PR and resolved all of its own review threads."
165981
+ )
165982
+ }).catch((err) => log.debug(`autoMerge comment failed: ${err}`));
165983
+ }
165984
+
165868
165985
  // utils/reviewCleanup.ts
165869
165986
  var RE_REVIEW_PREAMBLE = "Incrementally re-review the new commits on this pull request. Use the IncrementalReview mode.";
165870
165987
  async function postReviewCleanup(ctx) {
@@ -166045,6 +166162,11 @@ async function finalizeSuccessRun(input) {
166045
166162
  await approveAfterFix(input.toolContext).catch((error49) => {
166046
166163
  log.debug(`fix auto-approval failed: ${error49}`);
166047
166164
  });
166165
+ await autoMergeAfterApprove(input.toolContext).catch((error49) => {
166166
+ const raced = typeof error49 === "object" && error49 !== null && "status" in error49 && error49.status === 409;
166167
+ if (raced) log.debug(`auto-merge skipped (head moved): ${error49}`);
166168
+ else log.warning(`auto-merge failed: ${error49}`);
166169
+ });
166048
166170
  }
166049
166171
  await reportStatusChecks(input.toolContext, { runSucceeded: input.result.success });
166050
166172
  }
@@ -166486,6 +166608,7 @@ async function main() {
166486
166608
  postCheckoutScript: runContext.repoSettings.postCheckoutScript,
166487
166609
  prepushScript: runContext.repoSettings.prepushScript,
166488
166610
  prApproveEnabled: runContext.repoSettings.prApproveEnabled,
166611
+ autoMergeEnabled: runContext.repoSettings.autoMergeEnabled,
166489
166612
  signedCommits: runContext.repoSettings.signedCommits,
166490
166613
  modeInstructions: runContext.repoSettings.modeInstructions,
166491
166614
  toolState,
@@ -167642,7 +167765,7 @@ async function run2() {
167642
167765
  }
167643
167766
 
167644
167767
  // cli.ts
167645
- var VERSION10 = "0.1.32";
167768
+ var VERSION10 = "0.1.33";
167646
167769
  var bin = basename2(process.argv[1] || "");
167647
167770
  var PROG = bin === "pf" || bin === "pullfrog" ? bin : "pullfrog";
167648
167771
  var rawArgs = process.argv.slice(2);
package/dist/index.js CHANGED
@@ -101041,7 +101041,7 @@ var import_semver = __toESM(require_semver2(), 1);
101041
101041
  // package.json
101042
101042
  var package_default = {
101043
101043
  name: "pullfrog",
101044
- version: "0.1.32",
101044
+ version: "0.1.33",
101045
101045
  type: "module",
101046
101046
  bin: {
101047
101047
  pullfrog: "dist/cli.mjs",
@@ -157622,13 +157622,14 @@ async function approveAfterFix(ctx) {
157622
157622
  });
157623
157623
  if (pr.data.state !== "open") return;
157624
157624
  const headSha = pr.data.head.sha;
157625
+ const selfAuthored = isPullfrog(pr.data.user?.login);
157625
157626
  const summary2 = ctx.toolState.lastProgressBody?.trim();
157626
157627
  const body = "> \u2705 Pullfrog addressed all of its review feedback on this PR.\n\n" + (summary2 || "all Pullfrog-raised review threads are resolved \u2014 no outstanding findings remain.");
157627
157628
  const params = {
157628
157629
  owner: ctx.repo.owner,
157629
157630
  repo: ctx.repo.name,
157630
157631
  pull_number: pullNumber,
157631
- event: "APPROVE",
157632
+ event: selfAuthored ? "COMMENT" : "APPROVE",
157632
157633
  commit_id: headSha
157633
157634
  };
157634
157635
  const result = await createAndSubmitWithFooter(ctx, params, {
@@ -157800,22 +157801,39 @@ function CreatePullRequestReviewTool(ctx) {
157800
157801
  );
157801
157802
  }
157802
157803
  }
157804
+ const prSnapshot = (await ctx.octokit.rest.pulls.get({
157805
+ owner: ctx.repo.owner,
157806
+ repo: ctx.repo.name,
157807
+ pull_number
157808
+ })).data;
157809
+ const selfAuthored = isPullfrog(prSnapshot.user?.login);
157803
157810
  ctx.toolState.approval = { wouldApprove: approved === true, sha: primary.checkoutSha };
157804
157811
  const skip = reviewSkipDecision({
157805
157812
  approved: approved ?? false,
157806
157813
  requestChanges: request_changes ?? false,
157807
157814
  body,
157808
157815
  hasComments: comments.length > 0,
157809
- prApproveEnabled: ctx.prApproveEnabled
157816
+ // a self-authored approve downgrades to COMMENT (GitHub blocks a
157817
+ // self-APPROVE), so treat it as non-binding here — an empty self-
157818
+ // approve is then skipped (the verdict is still recorded above)
157819
+ // instead of POSTing an empty COMMENT that GitHub 422s.
157820
+ prApproveEnabled: ctx.prApproveEnabled && !selfAuthored
157810
157821
  });
157811
157822
  if (skip) {
157812
157823
  log.info(`skipping review submission: ${skip.reason}`);
157813
157824
  return { success: true, skipped: true, reason: skip.reason };
157814
157825
  }
157815
157826
  let event = approved ? "APPROVE" : request_changes ? "REQUEST_CHANGES" : "COMMENT";
157816
- if ((event === "APPROVE" || event === "REQUEST_CHANGES") && !ctx.prApproveEnabled) {
157817
- log.info(`prApproveEnabled is disabled \u2014 downgrading ${event} to COMMENT`);
157818
- event = "COMMENT";
157827
+ if (event === "APPROVE" || event === "REQUEST_CHANGES") {
157828
+ if (!ctx.prApproveEnabled) {
157829
+ log.info(`prApproveEnabled is disabled \u2014 downgrading ${event} to COMMENT`);
157830
+ event = "COMMENT";
157831
+ } else if (selfAuthored) {
157832
+ log.info(
157833
+ `self-authored PR \u2014 downgrading binding ${event} to COMMENT (verdict recorded internally)`
157834
+ );
157835
+ event = "COMMENT";
157836
+ }
157819
157837
  }
157820
157838
  const params = {
157821
157839
  owner: ctx.repo.owner,
@@ -157827,12 +157845,7 @@ function CreatePullRequestReviewTool(ctx) {
157827
157845
  if (commit_id) {
157828
157846
  params.commit_id = commit_id;
157829
157847
  } else {
157830
- const pr = await ctx.octokit.rest.pulls.get({
157831
- owner: ctx.repo.owner,
157832
- repo: ctx.repo.name,
157833
- pull_number
157834
- });
157835
- latestHeadSha = pr.data.head.sha;
157848
+ latestHeadSha = prSnapshot.head.sha;
157836
157849
  params.commit_id = primary.checkoutSha ?? latestHeadSha;
157837
157850
  if (primary.checkoutSha && latestHeadSha !== primary.checkoutSha) {
157838
157851
  log.info(
@@ -157900,6 +157913,7 @@ function CreatePullRequestReviewTool(ctx) {
157900
157913
  }
157901
157914
  )();
157902
157915
  } catch (err) {
157916
+ if (ctx.toolState.approval) ctx.toolState.approval.wouldApprove = false;
157903
157917
  if (isTransientReviewError(err)) {
157904
157918
  const rawMsg2 = err instanceof Error ? err.message : String(err);
157905
157919
  throw new Error(
@@ -163787,6 +163801,7 @@ var defaultSettings = {
163787
163801
  push: "restricted",
163788
163802
  shell: "restricted",
163789
163803
  prApproveEnabled: false,
163804
+ autoMergeEnabled: false,
163790
163805
  signedCommits: false,
163791
163806
  modeInstructions: {},
163792
163807
  learnings: null,
@@ -164107,6 +164122,108 @@ ${genericBody}`,
164107
164122
  // utils/runLifecycle.ts
164108
164123
  var core10 = __toESM(require_core(), 1);
164109
164124
 
164125
+ // utils/checksGate.ts
164126
+ var OK_CONCLUSIONS = /* @__PURE__ */ new Set(["success", "neutral", "skipped"]);
164127
+ var PULLFROG_CHECKS = /* @__PURE__ */ new Set(["pullfrog", "pullfrog-approval"]);
164128
+ function checkRunsGate(params) {
164129
+ for (const run of params.checkRuns) {
164130
+ if (PULLFROG_CHECKS.has(run.name.toLowerCase())) continue;
164131
+ if (run.status !== "completed")
164132
+ return { ok: false, reason: `check "${run.name}" ${run.status}` };
164133
+ if (!OK_CONCLUSIONS.has(run.conclusion ?? "")) {
164134
+ return { ok: false, reason: `check "${run.name}" concluded ${run.conclusion}` };
164135
+ }
164136
+ }
164137
+ return { ok: true, reason: "all checks passed" };
164138
+ }
164139
+ function hasVerifiedCheck(params) {
164140
+ return params.checkRuns.some(
164141
+ (r) => !PULLFROG_CHECKS.has(r.name.toLowerCase()) && r.status === "completed"
164142
+ );
164143
+ }
164144
+
164145
+ // utils/autoMerge.ts
164146
+ var NON_MERGEABLE_STATES = /* @__PURE__ */ new Set(["dirty", "behind", "blocked"]);
164147
+ function hasBlockingHumanReview(reviews) {
164148
+ const latestByReviewer = /* @__PURE__ */ new Map();
164149
+ for (const review of reviews) {
164150
+ const login = review.user?.login;
164151
+ if (!login || isPullfrog(login)) continue;
164152
+ if (review.state === "APPROVED" || review.state === "CHANGES_REQUESTED") {
164153
+ latestByReviewer.set(login, review.state);
164154
+ } else if (review.state === "DISMISSED") {
164155
+ latestByReviewer.delete(login);
164156
+ }
164157
+ }
164158
+ for (const state of latestByReviewer.values()) {
164159
+ if (state === "CHANGES_REQUESTED") return true;
164160
+ }
164161
+ return false;
164162
+ }
164163
+ async function autoMergeAfterApprove(ctx) {
164164
+ if (!ctx.autoMergeEnabled) return;
164165
+ if (!ctx.prApproveEnabled) return;
164166
+ const approval = ctx.toolState.approval;
164167
+ if (!approval?.wouldApprove) return;
164168
+ const pullNumber = ctx.payload.event.issue_number;
164169
+ if (typeof pullNumber !== "number") return;
164170
+ const skip = (reason) => log.info(
164171
+ `autoMerge: pr=#${pullNumber} approvalSha=${approval.sha?.slice(0, 7)} \u2192 skipped: ${reason}`
164172
+ );
164173
+ const pr = await ctx.octokit.rest.pulls.get({
164174
+ owner: ctx.repo.owner,
164175
+ repo: ctx.repo.name,
164176
+ pull_number: pullNumber
164177
+ });
164178
+ if (pr.data.state !== "open") return skip("pr not open");
164179
+ if (pr.data.draft) return skip("pr is draft");
164180
+ const headSha = pr.data.head.sha;
164181
+ if (approval.sha !== headSha) return skip(`approval sha != head ${headSha.slice(0, 7)}`);
164182
+ const outstanding = await countOutstandingPullfrogThreads(ctx, pullNumber);
164183
+ if (outstanding > 0) return skip(`${outstanding} unresolved Pullfrog thread(s)`);
164184
+ const reviews = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviews, {
164185
+ owner: ctx.repo.owner,
164186
+ repo: ctx.repo.name,
164187
+ pull_number: pullNumber,
164188
+ per_page: 100
164189
+ });
164190
+ if (hasBlockingHumanReview(reviews)) return skip("human changes requested");
164191
+ if (pr.data.mergeable !== true) return skip(`not mergeable (mergeable=${pr.data.mergeable})`);
164192
+ if (pr.data.mergeable_state && NON_MERGEABLE_STATES.has(pr.data.mergeable_state)) {
164193
+ return skip(`mergeable_state=${pr.data.mergeable_state}`);
164194
+ }
164195
+ const checkRuns = await ctx.octokit.paginate(ctx.octokit.rest.checks.listForRef, {
164196
+ owner: ctx.repo.owner,
164197
+ repo: ctx.repo.name,
164198
+ ref: headSha,
164199
+ per_page: 100
164200
+ });
164201
+ const gate = checkRunsGate({ checkRuns });
164202
+ if (!gate.ok) return skip(`ci: ${gate.reason}`);
164203
+ if (!hasVerifiedCheck({ checkRuns })) return skip("no completed external check-run to verify");
164204
+ const merge4 = await ctx.octokit.rest.pulls.merge({
164205
+ owner: ctx.repo.owner,
164206
+ repo: ctx.repo.name,
164207
+ pull_number: pullNumber,
164208
+ sha: headSha,
164209
+ merge_method: "squash",
164210
+ commit_title: `${pr.data.title} (#${pullNumber})`,
164211
+ commit_message: "merged autonomously by Pullfrog after a clean approval."
164212
+ });
164213
+ log.info(
164214
+ `autoMerge: pr=#${pullNumber} approvalSha=${headSha.slice(0, 7)} outstanding=0 \u2192 merged ${merge4.data.sha?.slice(0, 7)}`
164215
+ );
164216
+ await ctx.octokit.rest.issues.createComment({
164217
+ owner: ctx.repo.owner,
164218
+ repo: ctx.repo.name,
164219
+ issue_number: pullNumber,
164220
+ body: addFooter(
164221
+ ctx,
164222
+ "> \u2705 Merged autonomously after Pullfrog approved this PR and resolved all of its own review threads."
164223
+ )
164224
+ }).catch((err) => log.debug(`autoMerge comment failed: ${err}`));
164225
+ }
164226
+
164110
164227
  // utils/reviewCleanup.ts
164111
164228
  var RE_REVIEW_PREAMBLE = "Incrementally re-review the new commits on this pull request. Use the IncrementalReview mode.";
164112
164229
  async function postReviewCleanup(ctx) {
@@ -164287,6 +164404,11 @@ async function finalizeSuccessRun(input) {
164287
164404
  await approveAfterFix(input.toolContext).catch((error49) => {
164288
164405
  log.debug(`fix auto-approval failed: ${error49}`);
164289
164406
  });
164407
+ await autoMergeAfterApprove(input.toolContext).catch((error49) => {
164408
+ const raced = typeof error49 === "object" && error49 !== null && "status" in error49 && error49.status === 409;
164409
+ if (raced) log.debug(`auto-merge skipped (head moved): ${error49}`);
164410
+ else log.warning(`auto-merge failed: ${error49}`);
164411
+ });
164290
164412
  }
164291
164413
  await reportStatusChecks(input.toolContext, { runSucceeded: input.result.success });
164292
164414
  }
@@ -164728,6 +164850,7 @@ async function main() {
164728
164850
  postCheckoutScript: runContext.repoSettings.postCheckoutScript,
164729
164851
  prepushScript: runContext.repoSettings.prepushScript,
164730
164852
  prApproveEnabled: runContext.repoSettings.prApproveEnabled,
164853
+ autoMergeEnabled: runContext.repoSettings.autoMergeEnabled,
164731
164854
  signedCommits: runContext.repoSettings.signedCommits,
164732
164855
  modeInstructions: runContext.repoSettings.modeInstructions,
164733
164856
  toolState,
@@ -8,6 +8,7 @@ export type { Mode } from "../modes.ts";
8
8
  export { modes } from "../modes.ts";
9
9
  export type { BuildPullfrogFooterParams, WorkflowRunFooterInfo, } from "../utils/buildPullfrogFooter.ts";
10
10
  export { buildPullfrogFooter, PULLFROG_DIVIDER, stripExistingFooter, } from "../utils/buildPullfrogFooter.ts";
11
+ export { checkRunsGate, hasVerifiedCheck } from "../utils/checksGate.ts";
11
12
  export type { CodexAuthBody } from "../utils/codexOAuth.ts";
12
13
  export { decodeJwtExpMs, OAuthInvalidGrantError, parseCodexAuthBody, refreshCodexAuthBody, stringifyCodexAuthBody, } from "../utils/codexOAuth.ts";
13
14
  export type { ResourceUsage, UsageSummary } from "../utils/github.ts";
package/dist/internal.js CHANGED
@@ -1245,6 +1245,26 @@ function stripExistingFooter(body) {
1245
1245
  return body.substring(0, dividerIndex).trimEnd();
1246
1246
  }
1247
1247
 
1248
+ // utils/checksGate.ts
1249
+ var OK_CONCLUSIONS = /* @__PURE__ */ new Set(["success", "neutral", "skipped"]);
1250
+ var PULLFROG_CHECKS = /* @__PURE__ */ new Set(["pullfrog", "pullfrog-approval"]);
1251
+ function checkRunsGate(params) {
1252
+ for (const run of params.checkRuns) {
1253
+ if (PULLFROG_CHECKS.has(run.name.toLowerCase())) continue;
1254
+ if (run.status !== "completed")
1255
+ return { ok: false, reason: `check "${run.name}" ${run.status}` };
1256
+ if (!OK_CONCLUSIONS.has(run.conclusion ?? "")) {
1257
+ return { ok: false, reason: `check "${run.name}" concluded ${run.conclusion}` };
1258
+ }
1259
+ }
1260
+ return { ok: true, reason: "all checks passed" };
1261
+ }
1262
+ function hasVerifiedCheck(params) {
1263
+ return params.checkRuns.some(
1264
+ (r) => !PULLFROG_CHECKS.has(r.name.toLowerCase()) && r.status === "completed"
1265
+ );
1266
+ }
1267
+
1248
1268
  // utils/codexOAuth.ts
1249
1269
  var CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
1250
1270
  var CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token";
@@ -1472,6 +1492,7 @@ export {
1472
1492
  PULLFROG_DIVIDER,
1473
1493
  TIMEOUT_DISABLED,
1474
1494
  buildPullfrogFooter,
1495
+ checkRunsGate,
1475
1496
  createLeapingProgressComment,
1476
1497
  decodeJwtExpMs,
1477
1498
  defaultAutoTier,
@@ -1482,6 +1503,7 @@ export {
1482
1503
  getModelProvider,
1483
1504
  getProgressComment,
1484
1505
  getProviderDisplayName,
1506
+ hasVerifiedCheck,
1485
1507
  isAutoTier,
1486
1508
  isCardGatedModel,
1487
1509
  isLeapingIntoActionCommentBody,
@@ -22,6 +22,7 @@ export interface ToolContext {
22
22
  postCheckoutScript: string | null;
23
23
  prepushScript: string | null;
24
24
  prApproveEnabled: boolean;
25
+ autoMergeEnabled: boolean;
25
26
  signedCommits: boolean;
26
27
  modeInstructions: Record<string, string>;
27
28
  toolState: ToolState;
@@ -0,0 +1,37 @@
1
+ import type { ToolContext } from "../mcp/server.ts";
2
+ /**
3
+ * Run-end lifecycle action: merge ANY open PR that this run mechanically
4
+ * approved on its current head — Pullfrog acting as a full repo maintainer,
5
+ * contributor PRs included, not just merging its own work. There is deliberately
6
+ * NO agent-callable merge tool — the merge is a pure, deterministic consequence
7
+ * of the runtime observing a clean approved state, so a prompt-injected agent has
8
+ * no merge surface to reach for. Every clause is fail-closed and re-verified
9
+ * against GitHub's own state at merge time; any failure logs why and returns
10
+ * without merging. Best-effort — the caller wraps this in a `.catch`, and a
11
+ * merge/comment failure can never flip the run's outcome.
12
+ *
13
+ * There is NO self-authorship restriction: the approval verdict IS the trust
14
+ * decision (Pullfrog reviewed it and would approve), exactly like a human
15
+ * maintainer merging a contributor PR. The population of mergeable PRs is bounded
16
+ * upstream by the review triggers (`prCreated` / `prCreatedAllowNonCollaborator`)
17
+ * — Pullfrog can only merge what it was configured to review + approve — and the
18
+ * whole capability is opt-in per repo (clause 1). The remaining clauses are the
19
+ * maintainer's controls.
20
+ *
21
+ * The invariant (all must hold):
22
+ * 1. `ctx.autoMergeEnabled` — the per-repo toggle ANDed with the global
23
+ * `isAutonomousMaintenanceEnabled()` kill switch, server-side (run-context).
24
+ * 2. `ctx.prApproveEnabled` — cannot auto-merge what it may not approve.
25
+ * 3. this run recorded an APPROVE verdict (`toolState.approval.wouldApprove`) —
26
+ * Pullfrog's review is the merge decision.
27
+ * 4. the PR is open and not a draft.
28
+ * 5. `toolState.approval.sha === <current head sha>` — approved THIS head, so a
29
+ * commit pushed after the review can't ride the approval in (409-safe below).
30
+ * 6. `countOutstandingPullfrogThreads === 0` — re-queried at merge time.
31
+ * 7. no un-dismissed human CHANGES_REQUESTED — the human veto always wins.
32
+ * 8. GitHub reports the PR mergeable and not blocked/dirty/behind.
33
+ * 9. every external check-run/commit-status on the head is complete and
34
+ * non-failing (`checkRunsGate`) — red or in-flight CI never auto-merges,
35
+ * even on a repo without branch protection.
36
+ */
37
+ export declare function autoMergeAfterApprove(ctx: ToolContext): Promise<void>;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Shared, dependency-free CI green/red predicate — the single source of truth for
3
+ * "given these check-runs, is this PR head safe to merge?". Consumed by the
4
+ * auto-merge path: `autoMerge.ts` gates the PR head at merge time, and the
5
+ * `pr-merge-completion` webhook re-checks it before dispatching a slow-CI re-wake.
6
+ * A red or in-flight sha never merges, and a sha with no confirmable green
7
+ * check-run is treated as unverified (refused, not merged blind).
8
+ *
9
+ * Gates on the Checks API only. The Pullfrog App deliberately holds no `statuses`
10
+ * permission (requesting it would force re-approval on every install — see
11
+ * wiki/app-permissions.md), so the legacy Commit Statuses API is unreadable
12
+ * (`listCommitStatusesForRef` 403s). Modern CI (GitHub Actions, etc.) reports via
13
+ * check-runs; a repo relying on the legacy Statuses API is covered by branch
14
+ * protection instead — a failing required status makes `mergeable_state` `blocked`,
15
+ * which the merge invariant already refuses (`NON_MERGEABLE_STATES`).
16
+ */
17
+ export type CheckRun = {
18
+ name: string;
19
+ status: string;
20
+ conclusion: string | null;
21
+ };
22
+ /**
23
+ * Refuse if any external check-run is incomplete (queued/in_progress) or concluded
24
+ * anything other than success/neutral/skipped. Fail-closed on unknown/absent
25
+ * conclusions. Pure so the pass/fail logic is inspectable directly.
26
+ */
27
+ export declare function checkRunsGate(params: {
28
+ checkRuns: CheckRun[];
29
+ }): {
30
+ ok: boolean;
31
+ reason: string;
32
+ };
33
+ /**
34
+ * true when the head has ≥1 CONCLUDED non-failing EXTERNAL check-run — CI
35
+ * positively ran, not merely "no red was seen". paired with `checkRunsGate.ok`
36
+ * (any completed check is non-failing), this == "≥1 concluded non-failing check".
37
+ * the merge requires it: a head with ZERO check-runs passes
38
+ * `checkRunsGate` (nothing red) but has no verification, and since the app can't
39
+ * read legacy commit statuses, "zero check-runs" is indistinguishable from "green
40
+ * Actions but a red CircleCI status" — so we only act on a head we can positively
41
+ * confirm is green. Pullfrog's own verdict checks don't count as external CI.
42
+ */
43
+ export declare function hasVerifiedCheck(params: {
44
+ checkRuns: CheckRun[];
45
+ }): boolean;
@@ -31,6 +31,7 @@ export interface RepoSettings {
31
31
  push: PushPermission;
32
32
  shell: ShellPermission;
33
33
  prApproveEnabled: boolean;
34
+ autoMergeEnabled: boolean;
34
35
  signedCommits: boolean;
35
36
  modeInstructions: Record<string, string>;
36
37
  learnings: string | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pullfrog",
3
- "version": "0.1.32",
3
+ "version": "0.1.33",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "pullfrog": "dist/cli.mjs",