@rmartz/pr-lifecycle 5.0.0 → 7.0.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.
package/README.md CHANGED
@@ -48,8 +48,9 @@ pnpm run hygiene
48
48
 
49
49
  Versioned by [semantic-release](.releaserc.json): a push to `main` analyzes the
50
50
  Conventional-Commit history since the last `v*` tag and, when a release is
51
- warranted, publishes the package to GitHub Packages and creates the tag and
52
- GitHub Release.
51
+ warranted, publishes the package to npmjs (public, via OIDC trusted publishing
52
+ with provenance) and creates the tag and GitHub Release. Versions up to 5.0.0
53
+ were published to GitHub Packages and stay there for existing pins.
53
54
 
54
55
  ## License
55
56
 
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runCli
4
- } from "../chunk-RQEBRPZU.js";
4
+ } from "../chunk-PJOTYSEK.js";
5
5
 
6
6
  // src/bin/pr-lifecycle.ts
7
7
  process.exitCode = await runCli(
@@ -1,7 +1,66 @@
1
+ // src/release-diff.ts
2
+ var MANIFEST = ".release-please-manifest.json";
3
+ var CHANGELOG = "CHANGELOG.md";
4
+ var PACKAGE_JSON = "package.json";
5
+ var RELEASE_STATUSES = /* @__PURE__ */ new Set(["added", "modified"]);
6
+ var VERSION_LINE = /^[ \t]*"version"[ \t]*:[ \t]*"[^"\\\n]*"[,\t ]*$/;
7
+ function basename(path) {
8
+ return path.slice(path.lastIndexOf("/") + 1);
9
+ }
10
+ function isVersionOnlyPatch(patch) {
11
+ let removed = 0;
12
+ let added = 0;
13
+ for (const line of patch.split("\n")) {
14
+ if (line.startsWith("@@") || line.startsWith("\\") || line.startsWith(" ") || line === "") {
15
+ continue;
16
+ }
17
+ const sign = line[0];
18
+ if (sign !== "+" && sign !== "-" || !VERSION_LINE.test(line.slice(1))) {
19
+ return false;
20
+ }
21
+ if (sign === "+") {
22
+ added += 1;
23
+ } else {
24
+ removed += 1;
25
+ }
26
+ }
27
+ return added === 1 && removed === 1;
28
+ }
29
+ function checkFile(file) {
30
+ if (!RELEASE_STATUSES.has(file.status)) {
31
+ return `${file.filename} was ${file.status}`;
32
+ }
33
+ const name = basename(file.filename);
34
+ if (file.filename === MANIFEST || name === CHANGELOG) {
35
+ return void 0;
36
+ }
37
+ if (name !== PACKAGE_JSON) {
38
+ return `${file.filename} is not a release file`;
39
+ }
40
+ if (file.patch === void 0) {
41
+ return `${file.filename} has no patch to verify`;
42
+ }
43
+ return isVersionOnlyPatch(file.patch) ? void 0 : `${file.filename} changes more than its version`;
44
+ }
45
+ function classifyReleaseDiff(files) {
46
+ if (files === void 0) {
47
+ return { release: false, reason: "changed files unavailable" };
48
+ }
49
+ if (files.length === 0) {
50
+ return { release: false, reason: "no changed files" };
51
+ }
52
+ for (const file of files) {
53
+ const problem = checkFile(file);
54
+ if (problem !== void 0) {
55
+ return { release: false, reason: problem };
56
+ }
57
+ }
58
+ return { release: true };
59
+ }
60
+
1
61
  // src/bot-eligibility.ts
2
62
  var DEPENDABOT_LOGIN = "dependabot[bot]";
3
63
  var RELEASE_PLEASE_BRANCH_PREFIX = "release-please--";
4
- var RELEASE_PLEASE_PENDING_LABEL = "autorelease: pending";
5
64
  var DEPENDABOT_UPDATE_TYPES = [
6
65
  "version-update:semver-patch",
7
66
  "version-update:semver-minor",
@@ -72,8 +131,13 @@ function classifyBotPr(facts) {
72
131
  if (facts.authorLogin === DEPENDABOT_LOGIN) {
73
132
  return classifyDependabot(facts.commits);
74
133
  }
75
- if (facts.headRef.startsWith(RELEASE_PLEASE_BRANCH_PREFIX) || facts.labels.includes(RELEASE_PLEASE_PENDING_LABEL)) {
76
- return result(true, "release-please release PR \u2014 eligible", "release-please");
134
+ if (facts.headRef.startsWith(RELEASE_PLEASE_BRANCH_PREFIX)) {
135
+ const diff = classifyReleaseDiff(facts.changedFiles);
136
+ return diff.release ? result(true, "release-please release PR \u2014 eligible", "release-please") : result(
137
+ false,
138
+ `release-please branch, but not a pure release (${diff.reason}) \u2014 held for review`,
139
+ "release-please"
140
+ );
77
141
  }
78
142
  return result(false, "not a recognized bot PR");
79
143
  }
@@ -90,6 +154,7 @@ function parseList(value) {
90
154
  function parseReconcile(args) {
91
155
  const values = {};
92
156
  let armAutoMerge = false;
157
+ let autoUpdate = false;
93
158
  let skipCopilotReview = false;
94
159
  let dryRun = false;
95
160
  let json = false;
@@ -100,6 +165,9 @@ function parseReconcile(args) {
100
165
  case "--arm-auto-merge":
101
166
  armAutoMerge = true;
102
167
  break;
168
+ case "--auto-update":
169
+ autoUpdate = true;
170
+ break;
103
171
  case "--skip-copilot-review":
104
172
  skipCopilotReview = true;
105
173
  break;
@@ -151,6 +219,7 @@ function parseReconcile(args) {
151
219
  pr: Number(prArg),
152
220
  policy: {
153
221
  armAutoMerge,
222
+ autoUpdate,
154
223
  skipCopilotReview,
155
224
  ...trustedAuthors === void 0 ? {} : { trustedAuthors },
156
225
  ...holdChecks === void 0 ? {} : { holdChecks },
@@ -181,7 +250,7 @@ function parseArgs(argv) {
181
250
  // src/cli/output.ts
182
251
  var SCHEMA_VERSION = 1;
183
252
  function toReconcileJson(target, result2) {
184
- const { plan, botEligibility, lineage, skippedAutoMerge } = result2;
253
+ const { plan, botEligibility, lineage, skippedAutoMerge, skippedUpdate } = result2;
185
254
  return {
186
255
  schemaVersion: SCHEMA_VERSION,
187
256
  repo: `${target.owner}/${target.repo}`,
@@ -199,7 +268,9 @@ function toReconcileJson(target, result2) {
199
268
  },
200
269
  carryOver: lineage === void 0 ? null : { cleanAncestors: lineage.cleanAncestors, stoppedBecause: lineage.stoppedBecause },
201
270
  // The CLI makes release actions unavailable only for a missing token.
202
- autoMergeSkipped: skippedAutoMerge === void 0 ? null : { action: skippedAutoMerge, reason: "token-missing" }
271
+ autoMergeSkipped: skippedAutoMerge === void 0 ? null : { action: skippedAutoMerge, reason: "token-missing" },
272
+ update: plan.update,
273
+ updateSkipped: skippedUpdate === void 0 ? null : { action: skippedUpdate, reason: "token-missing" }
203
274
  };
204
275
  }
205
276
  function formatSummary(target, result2) {
@@ -208,7 +279,9 @@ function formatSummary(target, result2) {
208
279
  ...plan.addLabels.map((label) => `+${label}`),
209
280
  ...plan.removeLabels.map((label) => `-${label}`),
210
281
  ...plan.autoMerge === "none" ? [] : [`auto-merge ${plan.autoMerge}`],
211
- ...result2.skippedAutoMerge === void 0 ? [] : [`auto-merge ${result2.skippedAutoMerge} skipped (no release token)`]
282
+ ...result2.skippedAutoMerge === void 0 ? [] : [`auto-merge ${result2.skippedAutoMerge} skipped (no release token)`],
283
+ ...plan.update === "none" ? [] : [`update ${plan.update}`],
284
+ ...result2.skippedUpdate === void 0 ? [] : [`update ${result2.skippedUpdate} skipped (no release token)`]
212
285
  ];
213
286
  const prefix = target.dryRun ? "[dry-run] " : "";
214
287
  const body = changes.length === 0 ? "no changes" : changes.join(", ");
@@ -239,38 +312,11 @@ var REVIEW_STATES = [
239
312
  "PENDING"
240
313
  ];
241
314
  var PR_STATUSES = ["closed", "merged", "open"];
315
+ var BRANCH_UPDATERS = ["dependabot", "github"];
242
316
 
243
- // src/github/rest-payloads.ts
244
- function toReviewState(state) {
245
- return REVIEW_STATES.find((known) => known === state) ?? "DISMISSED";
246
- }
247
- function toActorType(type) {
248
- return type === "User" ? "User" : "Bot";
249
- }
250
- function toReviewData(review) {
251
- return {
252
- id: review.id,
253
- login: review.user?.login,
254
- type: review.user === null ? "User" : toActorType(review.user.type),
255
- commitSha: review.commit_id,
256
- state: toReviewState(review.state),
257
- body: review.body ?? "",
258
- submittedAt: review.submitted_at ?? void 0
259
- };
260
- }
261
-
262
- // src/github/http-client.ts
317
+ // src/github/http-transport.ts
263
318
  var PAGE_SIZE = 100;
264
- var ENABLE_AUTO_MERGE = `mutation($id: ID!, $head: GitObjectID!) {
265
- enablePullRequestAutoMerge(input: { pullRequestId: $id, mergeMethod: SQUASH, expectedHeadOid: $head }) { clientMutationId }
266
- }`;
267
- var MERGE_PULL_REQUEST = `mutation($id: ID!, $head: GitObjectID!) {
268
- mergePullRequest(input: { pullRequestId: $id, mergeMethod: SQUASH, expectedHeadOid: $head }) { clientMutationId }
269
- }`;
270
- var DISABLE_AUTO_MERGE = `mutation($id: ID!) {
271
- disablePullRequestAutoMerge(input: { pullRequestId: $id }) { clientMutationId }
272
- }`;
273
- function createHttpClient(options) {
319
+ function createTransport(options) {
274
320
  let apiUrl = options.apiUrl ?? "https://api.github.com";
275
321
  while (apiUrl.endsWith("/")) {
276
322
  apiUrl = apiUrl.slice(0, -1);
@@ -315,7 +361,79 @@ function createHttpClient(options) {
315
361
  throw new GitHubApiError(200, `GraphQL error: ${messages}`);
316
362
  }
317
363
  }
364
+ return { repoPath, request, paginate, graphql };
365
+ }
366
+
367
+ // src/github/http-checks.ts
368
+ function createCheckMethods({ repoPath, request, paginate }) {
369
+ return {
370
+ async getRequiredStatusChecks(branch) {
371
+ const rules = await paginate(
372
+ `${repoPath}/rules/branches/${encodeURIComponent(branch)}`
373
+ );
374
+ const contexts = rules.filter((rule) => rule.type === "required_status_checks").flatMap((rule) => rule.parameters?.required_status_checks ?? []).map((check) => check.context);
375
+ return [...new Set(contexts)];
376
+ },
377
+ async listCheckRuns(sha) {
378
+ const runs = [];
379
+ for (let page = 1; ; page += 1) {
380
+ const batch = await request(
381
+ "GET",
382
+ `${repoPath}/commits/${sha}/check-runs?per_page=${PAGE_SIZE}&page=${page}`
383
+ );
384
+ runs.push(...batch.check_runs);
385
+ if (batch.check_runs.length < PAGE_SIZE) {
386
+ break;
387
+ }
388
+ }
389
+ return runs.map((run) => ({
390
+ name: run.name,
391
+ status: run.status,
392
+ conclusion: run.conclusion,
393
+ completedAt: run.completed_at
394
+ }));
395
+ },
396
+ async listCommitStatuses(sha) {
397
+ const combined = await request("GET", `${repoPath}/commits/${sha}/status`);
398
+ return combined.statuses.map((status) => ({ context: status.context, state: status.state }));
399
+ }
400
+ };
401
+ }
402
+
403
+ // src/github/rest-payloads.ts
404
+ function toReviewState(state) {
405
+ return REVIEW_STATES.find((known) => known === state) ?? "DISMISSED";
406
+ }
407
+ function toActorType(type) {
408
+ return type === "User" ? "User" : "Bot";
409
+ }
410
+ function toReviewData(review) {
318
411
  return {
412
+ id: review.id,
413
+ login: review.user?.login,
414
+ type: review.user === null ? "User" : toActorType(review.user.type),
415
+ commitSha: review.commit_id,
416
+ state: toReviewState(review.state),
417
+ body: review.body ?? "",
418
+ submittedAt: review.submitted_at ?? void 0
419
+ };
420
+ }
421
+
422
+ // src/github/http-client.ts
423
+ var ENABLE_AUTO_MERGE = `mutation($id: ID!, $head: GitObjectID!) {
424
+ enablePullRequestAutoMerge(input: { pullRequestId: $id, mergeMethod: SQUASH, expectedHeadOid: $head }) { clientMutationId }
425
+ }`;
426
+ var MERGE_PULL_REQUEST = `mutation($id: ID!, $head: GitObjectID!) {
427
+ mergePullRequest(input: { pullRequestId: $id, mergeMethod: SQUASH, expectedHeadOid: $head }) { clientMutationId }
428
+ }`;
429
+ var DISABLE_AUTO_MERGE = `mutation($id: ID!) {
430
+ disablePullRequestAutoMerge(input: { pullRequestId: $id }) { clientMutationId }
431
+ }`;
432
+ function createHttpClient(options) {
433
+ const transport = createTransport(options);
434
+ const { repoPath, request, paginate, graphql } = transport;
435
+ return {
436
+ ...createCheckMethods(transport),
319
437
  async getPullRequest(pr) {
320
438
  const pull = await request("GET", `${repoPath}/pulls/${pr}`);
321
439
  return {
@@ -324,6 +442,7 @@ function createHttpClient(options) {
324
442
  merged: pull.merged,
325
443
  draft: pull.draft === true,
326
444
  title: pull.title,
445
+ body: pull.body ?? "",
327
446
  headSha: pull.head.sha,
328
447
  labels: pull.labels.map((label) => label.name),
329
448
  autoMergeEnabled: pull.auto_merge !== null && pull.auto_merge !== void 0,
@@ -335,16 +454,10 @@ function createHttpClient(options) {
335
454
  // repo is treated as a fork, so it can never look like a trusted branch.
336
455
  isCrossRepository: pull.head.repo?.id !== pull.base.repo.id,
337
456
  mergeable: pull.mergeable ?? void 0,
338
- mergeState: pull.mergeable_state
457
+ mergeState: pull.mergeable_state,
458
+ changedFileCount: pull.changed_files
339
459
  };
340
460
  },
341
- async getRequiredStatusChecks(branch) {
342
- const rules = await paginate(
343
- `${repoPath}/rules/branches/${encodeURIComponent(branch)}`
344
- );
345
- const contexts = rules.filter((rule) => rule.type === "required_status_checks").flatMap((rule) => rule.parameters?.required_status_checks ?? []).map((check) => check.context);
346
- return [...new Set(contexts)];
347
- },
348
461
  async getBranchHeadSha(branch) {
349
462
  const result2 = await request(
350
463
  "GET",
@@ -352,25 +465,6 @@ function createHttpClient(options) {
352
465
  );
353
466
  return result2.commit.sha;
354
467
  },
355
- async listCheckRuns(sha) {
356
- const runs = [];
357
- for (let page = 1; ; page += 1) {
358
- const batch = await request(
359
- "GET",
360
- `${repoPath}/commits/${sha}/check-runs?per_page=${PAGE_SIZE}&page=${page}`
361
- );
362
- runs.push(...batch.check_runs);
363
- if (batch.check_runs.length < PAGE_SIZE) {
364
- break;
365
- }
366
- }
367
- return runs.map((run) => ({
368
- name: run.name,
369
- status: run.status,
370
- conclusion: run.conclusion,
371
- completedAt: run.completed_at
372
- }));
373
- },
374
468
  async getCommit(sha) {
375
469
  const commit = await request("GET", `${repoPath}/git/commits/${sha}`);
376
470
  return { treeSha: commit.tree.sha, parents: commit.parents.map((parent) => parent.sha) };
@@ -379,9 +473,13 @@ function createHttpClient(options) {
379
473
  const result2 = await request("GET", `${repoPath}/compare/${base}...${head}?per_page=1`);
380
474
  return { status: result2.status, mergeBaseSha: result2.merge_base_commit.sha };
381
475
  },
382
- async listCommitStatuses(sha) {
383
- const combined = await request("GET", `${repoPath}/commits/${sha}/status`);
384
- return combined.statuses.map((status) => ({ context: status.context, state: status.state }));
476
+ async listPullRequestFiles(pr) {
477
+ const files = await paginate(`${repoPath}/pulls/${pr}/files`);
478
+ return files.map((file) => ({
479
+ filename: file.filename,
480
+ status: file.status,
481
+ patch: file.patch
482
+ }));
385
483
  },
386
484
  async listCommits(pr) {
387
485
  const commits = await paginate(`${repoPath}/pulls/${pr}/commits`);
@@ -427,6 +525,11 @@ function createHttpClient(options) {
427
525
  async mergePullRequest(pullRequestNodeId, expectedHeadOid) {
428
526
  await graphql(MERGE_PULL_REQUEST, { id: pullRequestNodeId, head: expectedHeadOid });
429
527
  },
528
+ async updateBranch(pr, expectedHeadSha) {
529
+ await request("PUT", `${repoPath}/pulls/${pr}/update-branch`, {
530
+ expected_head_sha: expectedHeadSha
531
+ });
532
+ },
430
533
  async disableAutoMerge(pullRequestNodeId) {
431
534
  await graphql(DISABLE_AUTO_MERGE, { id: pullRequestNodeId });
432
535
  }
@@ -574,6 +677,7 @@ var LIFECYCLE_LABELS = [
574
677
  "review requested"
575
678
  ];
576
679
  var AUTO_MERGE_LABEL = "auto-merge enabled";
680
+ var UPDATE_REQUIRED_LABEL = "update required";
577
681
  function lifecycleLabels(state) {
578
682
  switch (state) {
579
683
  case "approved":
@@ -596,10 +700,27 @@ function lifecycleLabels(state) {
596
700
  return [];
597
701
  }
598
702
  }
703
+ function updateFor(updater, rebasePending) {
704
+ switch (updater) {
705
+ case "dependabot":
706
+ return rebasePending ? "none" : "dependabot-rebase";
707
+ case "github":
708
+ return "update-branch";
709
+ }
710
+ }
711
+ function planUpdate(facts, policy, state, autoMerge) {
712
+ if (policy.autoUpdate !== true || state !== "approved" || autoMerge === "merge") {
713
+ return "none";
714
+ }
715
+ if (!facts.labels.includes(UPDATE_REQUIRED_LABEL)) {
716
+ return "none";
717
+ }
718
+ return updateFor(facts.updater, facts.rebasePending);
719
+ }
599
720
  function planReconcile(facts, policy) {
600
721
  const state = computeState(facts, policy);
601
722
  if (state === "closed") {
602
- return { state, addLabels: [], removeLabels: [], autoMerge: "none" };
723
+ return { state, addLabels: [], removeLabels: [], autoMerge: "none", update: "none" };
603
724
  }
604
725
  const arming = policy.armAutoMerge === true;
605
726
  const shouldArm = arming && state === "approved";
@@ -624,20 +745,39 @@ function planReconcile(facts, policy) {
624
745
  state,
625
746
  addLabels: [...desired].filter((name) => !present.has(name)),
626
747
  removeLabels: owned.filter((name) => present.has(name) && !desired.has(name)),
627
- autoMerge
748
+ autoMerge,
749
+ update: planUpdate(facts, policy, state, autoMerge)
628
750
  };
629
751
  }
630
- function withoutArming(plan) {
631
- if (plan.autoMerge !== "arm" && plan.autoMerge !== "merge") {
632
- return plan;
633
- }
752
+ function withoutReleaseActions(plan) {
753
+ const arming = plan.autoMerge === "arm" || plan.autoMerge === "merge";
634
754
  return {
635
755
  ...plan,
636
- addLabels: plan.addLabels.filter((name) => name !== AUTO_MERGE_LABEL),
637
- autoMerge: "none"
756
+ addLabels: arming ? plan.addLabels.filter((name) => name !== AUTO_MERGE_LABEL) : plan.addLabels,
757
+ autoMerge: arming ? "none" : plan.autoMerge,
758
+ update: "none"
638
759
  };
639
760
  }
640
761
 
762
+ // src/github/dependabot-rebase.ts
763
+ var DEPENDABOT_REBASE_COMMAND = "@dependabot rebase";
764
+ var DEPENDABOT_REBASING_NOTICE = "Dependabot is rebasing this PR";
765
+ function rebaseRequestMarker(headSha) {
766
+ return `<!-- pr-lifecycle:dependabot-rebase head=${headSha} -->`;
767
+ }
768
+ function buildRebaseRequestBody(headSha) {
769
+ return `${DEPENDABOT_REBASE_COMMAND}
770
+
771
+ ${rebaseRequestMarker(headSha)}`;
772
+ }
773
+ function isRebasePending(prBody, comments, headSha) {
774
+ if (prBody.includes(DEPENDABOT_REBASING_NOTICE)) {
775
+ return true;
776
+ }
777
+ const marker = rebaseRequestMarker(headSha);
778
+ return comments.some((comment) => comment.includes(marker));
779
+ }
780
+
641
781
  // src/github/label-roster.ts
642
782
  var OWNED_LABEL_DEFINITIONS = {
643
783
  approved: {
@@ -734,6 +874,25 @@ async function executePlan(client, target, plan, release = client) {
734
874
  if (plan.autoMerge === "merge") {
735
875
  await release.mergePullRequest(target.nodeId, target.headSha);
736
876
  }
877
+ switch (plan.update) {
878
+ case "update-branch":
879
+ await updateBranch(release, target);
880
+ break;
881
+ case "dependabot-rebase":
882
+ await release.createIssueComment(target.pr, buildRebaseRequestBody(target.headSha));
883
+ break;
884
+ case "none":
885
+ break;
886
+ }
887
+ }
888
+ async function updateBranch(release, target) {
889
+ try {
890
+ await release.updateBranch(target.pr, target.headSha);
891
+ } catch (error) {
892
+ if (!isApiStatus(error, 422)) {
893
+ throw error;
894
+ }
895
+ }
737
896
  }
738
897
 
739
898
  // src/lineage/git.ts
@@ -917,6 +1076,28 @@ async function gatherLineage(client, git, input) {
917
1076
  }
918
1077
  }
919
1078
 
1079
+ // src/github/bot-facts.ts
1080
+ async function listAllChangedFiles(client, pr, pull) {
1081
+ const files = await client.listPullRequestFiles(pr);
1082
+ return files.length === pull.changedFileCount ? files : void 0;
1083
+ }
1084
+ async function gatherBotEligibility(client, pr, pull) {
1085
+ const sameRepo = !pull.isCrossRepository;
1086
+ const isDependabot = pull.authorLogin === DEPENDABOT_LOGIN;
1087
+ const isReleaseBranch = !isDependabot && pull.headRef.startsWith(RELEASE_PLEASE_BRANCH_PREFIX);
1088
+ const [commits, changedFiles] = await Promise.all([
1089
+ sameRepo && isDependabot ? client.listCommits(pr) : [],
1090
+ sameRepo && isReleaseBranch ? listAllChangedFiles(client, pr, pull) : void 0
1091
+ ]);
1092
+ return classifyBotPr({
1093
+ authorLogin: pull.authorLogin ?? "ghost",
1094
+ headRef: pull.headRef,
1095
+ isCrossRepository: pull.isCrossRepository,
1096
+ commits,
1097
+ changedFiles
1098
+ });
1099
+ }
1100
+
920
1101
  // src/ci.ts
921
1102
  var DEFAULT_HOLD_CHECKS = ["pr-policy"];
922
1103
  var DEFAULT_IGNORED_CHECKS = ["merge-safety"];
@@ -1010,16 +1191,6 @@ async function gatherCiFacts(client, pull, policy) {
1010
1191
  }
1011
1192
 
1012
1193
  // src/github/gather.ts
1013
- async function gatherBotEligibility(client, pr, pull) {
1014
- const needsCommits = pull.authorLogin === DEPENDABOT_LOGIN && !pull.isCrossRepository;
1015
- return classifyBotPr({
1016
- authorLogin: pull.authorLogin ?? "ghost",
1017
- headRef: pull.headRef,
1018
- isCrossRepository: pull.isCrossRepository,
1019
- labels: pull.labels,
1020
- commits: needsCommits ? await client.listCommits(pr) : []
1021
- });
1022
- }
1023
1194
  function isRepoPermission(value) {
1024
1195
  return REPO_PERMISSIONS.some((permission) => permission === value);
1025
1196
  }
@@ -1084,14 +1255,29 @@ async function gatherLineageFor(client, pull, reviews, options) {
1084
1255
  return { cleanAncestors: [], stoppedBecause: `verification failed: ${message}` };
1085
1256
  }
1086
1257
  }
1258
+ async function gatherRebasePending(client, pr, pull, updater, policy) {
1259
+ if (updater !== "dependabot") {
1260
+ return false;
1261
+ }
1262
+ if (pull.body.includes(DEPENDABOT_REBASING_NOTICE)) {
1263
+ return true;
1264
+ }
1265
+ const couldUpdate = policy.autoUpdate === true && pull.state === "open" && pull.labels.includes(UPDATE_REQUIRED_LABEL);
1266
+ if (!couldUpdate) {
1267
+ return false;
1268
+ }
1269
+ return isRebasePending(pull.body, await client.listIssueComments(pr), pull.headSha);
1270
+ }
1087
1271
  var IMMEDIATE_MERGE_STATES = /* @__PURE__ */ new Set(["clean", "has_hooks", "unstable"]);
1088
1272
  async function gatherFacts(client, pr, policy = {}, options = {}) {
1089
1273
  const [pull, reviews] = await Promise.all([client.getPullRequest(pr), client.listReviews(pr)]);
1090
- const [permissions, botEligibility, ci, lineage] = await Promise.all([
1274
+ const updater = pull.authorLogin === DEPENDABOT_LOGIN ? "dependabot" : "github";
1275
+ const [permissions, botEligibility, ci, lineage, rebasePending] = await Promise.all([
1091
1276
  lookupPermissions(client, reviews),
1092
1277
  gatherBotEligibility(client, pr, pull),
1093
1278
  gatherCiFacts(client, pull, policy),
1094
- gatherLineageFor(client, pull, reviews, options)
1279
+ gatherLineageFor(client, pull, reviews, options),
1280
+ gatherRebasePending(client, pr, pull, updater, policy)
1095
1281
  ]);
1096
1282
  return {
1097
1283
  nodeId: pull.nodeId,
@@ -1110,6 +1296,8 @@ async function gatherFacts(client, pr, policy = {}, options = {}) {
1110
1296
  ciStatus: ci.ciStatus,
1111
1297
  baseCiFailing: ci.baseCiFailing,
1112
1298
  cleanAncestors: lineage?.cleanAncestors ?? [],
1299
+ updater,
1300
+ rebasePending,
1113
1301
  reviews: reviews.map((review) => toReviewFact(review, permissions))
1114
1302
  }
1115
1303
  };
@@ -1117,21 +1305,24 @@ async function gatherFacts(client, pr, policy = {}, options = {}) {
1117
1305
 
1118
1306
  // src/github/reconcile.ts
1119
1307
  var REFUSE_RELEASE = {
1308
+ createIssueComment: () => Promise.reject(new Error("rebase requests need a release token")),
1120
1309
  enableAutoMerge: () => Promise.reject(new Error("arming needs a release token")),
1121
- mergePullRequest: () => Promise.reject(new Error("merging needs a release token"))
1310
+ mergePullRequest: () => Promise.reject(new Error("merging needs a release token")),
1311
+ updateBranch: () => Promise.reject(new Error("updating needs a release token"))
1122
1312
  };
1123
1313
  async function reconcilePullRequest(client, pr, policy, options = {}) {
1124
1314
  const { facts, nodeId, botEligibility, lineage } = await gatherFacts(client, pr, policy, options);
1125
1315
  const planned = planReconcile(facts, policy);
1126
1316
  const unavailable = options.release === "unavailable";
1127
- const plan = unavailable ? withoutArming(planned) : planned;
1317
+ const plan = unavailable ? withoutReleaseActions(planned) : planned;
1128
1318
  if (options.dryRun !== true) {
1129
1319
  const release = options.release === "unavailable" ? REFUSE_RELEASE : options.release ?? client;
1130
1320
  await executePlan(client, { pr, nodeId, headSha: facts.headSha }, plan, release);
1131
1321
  }
1132
1322
  const wanted = planned.autoMerge;
1133
1323
  const skippedAutoMerge = unavailable && (wanted === "arm" || wanted === "merge") ? wanted : void 0;
1134
- return { plan, botEligibility, lineage, skippedAutoMerge };
1324
+ const skippedUpdate = unavailable && planned.update !== "none" ? planned.update : void 0;
1325
+ return { plan, botEligibility, lineage, skippedAutoMerge, skippedUpdate };
1135
1326
  }
1136
1327
 
1137
1328
  // src/github/token-advisory.ts
@@ -1168,6 +1359,8 @@ reconcile options:
1168
1359
  --repo <owner/repo> Repository (required)
1169
1360
  --pr <number> Pull request number (required)
1170
1361
  --arm-auto-merge Arm/disarm native auto-merge from the state
1362
+ --auto-update Update approved PRs merge-safety flags 'update required'
1363
+ (Dependabot PRs are asked to rebase, never updated)
1171
1364
  --trusted-authors <a,b> Only these logins (with write access) may cast verdicts
1172
1365
  --skip-copilot-review Don't wait for a Copilot review
1173
1366
  --hold-checks <a,b> Required checks whose pending is a hold (default: pr-policy)
@@ -1179,8 +1372,8 @@ reconcile options:
1179
1372
  Environment:
1180
1373
  GITHUB_TOKEN Token for reads and writes (required)
1181
1374
  PR_LIFECYCLE_TOKEN
1182
- Real-actor token for arming and merging; without it,
1183
- --arm-auto-merge keeps labels but skips arm/merge
1375
+ Real-actor token for arming, merging, and updating;
1376
+ without it, labels are kept but those are skipped
1184
1377
  GITHUB_API_URL API base URL (GitHub Enterprise Server)`;
1185
1378
  async function runCli(argv, io, deps) {
1186
1379
  const args = parseArgs(argv);
@@ -1216,8 +1409,8 @@ async function runCli(argv, io, deps) {
1216
1409
  const result2 = await reconcilePullRequest(client, args.pr, args.policy, {
1217
1410
  dryRun: args.dryRun,
1218
1411
  lineage: { git: deps.git ?? createGitRunner(), token },
1219
- // Only arming and merging use the release token (never reads). Without one
1220
- // they are skipped, not done with GITHUB_TOKEN; see docs/cli.md.
1412
+ // Only arming, merging, and updating use the release token (never reads).
1413
+ // Without one they are skipped, not done with GITHUB_TOKEN; see docs/cli.md.
1221
1414
  release: hasReleaseToken ? clientFor(releaseToken) : "unavailable"
1222
1415
  });
1223
1416
  if (result2.skippedAutoMerge !== void 0) {
@@ -1228,6 +1421,11 @@ async function runCli(argv, io, deps) {
1228
1421
  await adviseMissingToken(client, args.pr, result2.skippedAutoMerge, io);
1229
1422
  }
1230
1423
  }
1424
+ if (result2.skippedUpdate !== void 0) {
1425
+ io.stderr(
1426
+ `warning: ${args.owner}/${args.repo}#${args.pr} needs an update, but ${result2.skippedUpdate} was skipped: PR_LIFECYCLE_TOKEN is not set`
1427
+ );
1428
+ }
1231
1429
  if (args.json) {
1232
1430
  io.stdout(JSON.stringify(toReconcileJson(target, result2)));
1233
1431
  } else {
@@ -1250,9 +1448,10 @@ async function adviseMissingToken(client, pr, skipped, io) {
1250
1448
  }
1251
1449
 
1252
1450
  export {
1451
+ isVersionOnlyPatch,
1452
+ classifyReleaseDiff,
1253
1453
  DEPENDABOT_LOGIN,
1254
1454
  RELEASE_PLEASE_BRANCH_PREFIX,
1255
- RELEASE_PLEASE_PENDING_LABEL,
1256
1455
  DEPENDABOT_UPDATE_TYPES,
1257
1456
  parseDependabotUpdateType,
1258
1457
  classifyBotPr,
@@ -1266,6 +1465,7 @@ export {
1266
1465
  ACTOR_TYPES,
1267
1466
  REVIEW_STATES,
1268
1467
  PR_STATUSES,
1468
+ BRANCH_UPDATERS,
1269
1469
  createHttpClient,
1270
1470
  VERDICTS,
1271
1471
  parseVerdict,
@@ -1276,8 +1476,14 @@ export {
1276
1476
  computeState,
1277
1477
  LIFECYCLE_LABELS,
1278
1478
  AUTO_MERGE_LABEL,
1479
+ UPDATE_REQUIRED_LABEL,
1279
1480
  planReconcile,
1280
- withoutArming,
1481
+ withoutReleaseActions,
1482
+ DEPENDABOT_REBASE_COMMAND,
1483
+ DEPENDABOT_REBASING_NOTICE,
1484
+ rebaseRequestMarker,
1485
+ buildRebaseRequestBody,
1486
+ isRebasePending,
1281
1487
  OWNED_LABEL_DEFINITIONS,
1282
1488
  executePlan,
1283
1489
  createGitRunner,
package/dist/index.d.ts CHANGED
@@ -1,14 +1,53 @@
1
+ /**
2
+ * Is a release-please PR's diff a pure release: only the files release-please
3
+ * writes, with `package.json` changed in its `version` line and nowhere else?
4
+ * The branch name alone can't say (any write user can push a `release-please--`
5
+ * branch with arbitrary code), and release-please commits as whoever owns its
6
+ * token, so its commit authors can't either. See docs/bot-eligibility.md.
7
+ *
8
+ * FAIL-SAFE: a file outside the release set, a status other than added or
9
+ * modified, a missing patch, or any doubt about a `package.json` line means the
10
+ * diff is not a release.
11
+ */
12
+ /** One changed file, as the PR files API reports it. */
13
+ interface ChangedFile {
14
+ /** Repository-relative path. */
15
+ filename: string;
16
+ /** `added`, `modified`, `removed`, `renamed`, `copied`, `changed`, or `unchanged`. */
17
+ status: string;
18
+ /** The unified-diff hunk text; undefined when GitHub omits it (e.g. a large diff). */
19
+ patch: string | undefined;
20
+ }
21
+ type ReleaseDiffVerdict = {
22
+ release: true;
23
+ } | {
24
+ release: false;
25
+ reason: string;
26
+ };
27
+ /**
28
+ * Whether a `package.json` patch changes exactly one line, its `version`: one
29
+ * removed and one added line, both a bare `"version"` member. Context lines,
30
+ * hunk headers, and the no-newline marker are ignored.
31
+ */
32
+ declare function isVersionOnlyPatch(patch: string): boolean;
33
+ /**
34
+ * The verdict over a PR's changed files. `undefined` means the list couldn't be
35
+ * read in full (truncated or not fetched), which is never a release.
36
+ */
37
+ declare function classifyReleaseDiff(files: readonly ChangedFile[] | undefined): ReleaseDiffVerdict;
38
+
1
39
  /**
2
40
  * Bot-PR eligibility: is this a bot PR trusted enough to count as `approved`
3
41
  * without a review (state priority 7 in docs/reconciler-design.md)? Ported from
4
- * @rmartz/bot-automerge's classifier, with one deliberate tightening: the head
5
- * branch must live in the base repository. See docs/bot-eligibility.md.
42
+ * @rmartz/bot-automerge's classifier, with deliberate tightenings: the head
43
+ * branch must live in the base repository, and a release-please PR is recognized
44
+ * by its branch and a release-only diff, never by a label. See
45
+ * docs/bot-eligibility.md.
6
46
  *
7
47
  * FAIL-SAFE: every uncertain or unrecognized case is not eligible.
8
48
  */
9
49
  declare const DEPENDABOT_LOGIN = "dependabot[bot]";
10
50
  declare const RELEASE_PLEASE_BRANCH_PREFIX = "release-please--";
11
- declare const RELEASE_PLEASE_PENDING_LABEL = "autorelease: pending";
12
51
  /** Dependabot's semver update types, lowest to highest risk. */
13
52
  declare const DEPENDABOT_UPDATE_TYPES: readonly ["version-update:semver-patch", "version-update:semver-minor", "version-update:semver-major"];
14
53
  type DependabotUpdateType = (typeof DEPENDABOT_UPDATE_TYPES)[number];
@@ -23,9 +62,13 @@ interface BotPrFacts {
23
62
  headRef: string;
24
63
  /** True when the head branch lives in a fork, not the base repository. */
25
64
  isCrossRepository: boolean;
26
- labels: readonly string[];
27
65
  /** The PR's commits; only consulted for Dependabot PRs. */
28
66
  commits: readonly BotPrCommit[];
67
+ /**
68
+ * The PR's changed files; only consulted for release-please PRs. Undefined when
69
+ * not fetched or not read in full, which is never eligible.
70
+ */
71
+ changedFiles: readonly ChangedFile[] | undefined;
29
72
  }
30
73
  interface BotEligibility {
31
74
  eligible: boolean;
@@ -73,6 +116,12 @@ type ActorType = (typeof ACTOR_TYPES)[number];
73
116
  declare const REVIEW_STATES: readonly ["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED", "PENDING"];
74
117
  type ReviewState = (typeof REVIEW_STATES)[number];
75
118
  declare const PR_STATUSES: readonly ["closed", "merged", "open"];
119
+ /**
120
+ * Who brings a PR's branch up to date with its base. Dependabot owns its branches
121
+ * and rebases them itself when asked; anything else uses GitHub's update-branch.
122
+ */
123
+ declare const BRANCH_UPDATERS: readonly ["dependabot", "github"];
124
+ type BranchUpdater = (typeof BRANCH_UPDATERS)[number];
76
125
  type PullRequestStatus = (typeof PR_STATUSES)[number];
77
126
  interface ReviewAuthor {
78
127
  login: string;
@@ -120,6 +169,15 @@ interface PullRequestFacts {
120
169
  * docs/reconciler-design.md §Approval carry-over). Verified at the edge.
121
170
  */
122
171
  cleanAncestors: readonly string[];
172
+ /** Who updates the branch (see `BranchUpdater`). */
173
+ updater: BranchUpdater;
174
+ /**
175
+ * For a Dependabot PR: a rebase is running (the PR body says so) or was already
176
+ * requested for this head, so asking again would only add noise. A request is
177
+ * made at most once per head, whether Dependabot then rebases or replies with an
178
+ * error. Always `false` for other PRs.
179
+ */
180
+ rebasePending: boolean;
123
181
  reviews: readonly ReviewFact[];
124
182
  }
125
183
  interface ReconcilePolicy {
@@ -131,6 +189,12 @@ interface ReconcilePolicy {
131
189
  trustedAuthors?: readonly string[];
132
190
  /** Arm/disarm native auto-merge from the lifecycle state. Off by default. */
133
191
  armAutoMerge?: boolean;
192
+ /**
193
+ * Bring an approved PR up to date when merge-safety labels it `update required`:
194
+ * `update-branch` for most PRs, a `@dependabot rebase` request for Dependabot's.
195
+ * Off by default.
196
+ */
197
+ autoUpdate?: boolean;
134
198
  /**
135
199
  * Don't wait for a Copilot review before `review-requested`, for repos without
136
200
  * Copilot code review (where the wait would never end). Off by default.
@@ -159,6 +223,8 @@ interface PullRequestData {
159
223
  merged: boolean;
160
224
  draft: boolean;
161
225
  title: string;
226
+ /** The PR description (empty when none); Dependabot reports a running rebase here. */
227
+ body: string;
162
228
  headSha: string;
163
229
  labels: string[];
164
230
  autoMergeEnabled: boolean;
@@ -175,6 +241,8 @@ interface PullRequestData {
175
241
  mergeable: boolean | undefined;
176
242
  /** GitHub's merge state (`clean`, `blocked`, `behind`, …); undefined while computing. */
177
243
  mergeState: string | undefined;
244
+ /** How many files the PR changes (`changed_files`), to tell a full file list from a truncated one. */
245
+ changedFileCount: number;
178
246
  }
179
247
  /** A check-run on a commit (the latest run per name). */
180
248
  interface CheckRunData {
@@ -231,6 +299,8 @@ interface GitHubClient {
231
299
  getPullRequest(pr: number): Promise<PullRequestData>;
232
300
  listReviews(pr: number): Promise<ReviewData[]>;
233
301
  listCommits(pr: number): Promise<CommitData[]>;
302
+ /** The PR's changed files. GitHub lists at most 3,000, so compare with `changedFileCount`. */
303
+ listPullRequestFiles(pr: number): Promise<ChangedFile[]>;
234
304
  /** The contexts the branch's rulesets require (empty when none are configured). */
235
305
  getRequiredStatusChecks(branch: string): Promise<string[]>;
236
306
  getBranchHeadSha(branch: string): Promise<string>;
@@ -257,13 +327,19 @@ interface GitHubClient {
257
327
  /** Squash-merge now, bound to `expectedHeadOid` like `enableAutoMerge`. */
258
328
  mergePullRequest(pullRequestNodeId: string, expectedHeadOid: string): Promise<void>;
259
329
  disableAutoMerge(pullRequestNodeId: string): Promise<void>;
330
+ /**
331
+ * Merge the base into the PR branch, bound to `expectedHeadSha`. Throws a
332
+ * GitHubApiError with status 422 when the head moved or there is nothing to merge.
333
+ */
334
+ updateBranch(pr: number, expectedHeadSha: string): Promise<void>;
260
335
  }
261
336
  /**
262
- * The writes that must come from a real actor, so the merge they cause re-triggers
263
- * `on: push` workflows (a `GITHUB_TOKEN` merge triggers nothing). Kept to these two
264
- * calls so the release token is never used for anything else, reads included.
337
+ * The writes that must come from a real actor: a merge or branch update made with
338
+ * `GITHUB_TOKEN` triggers no workflows (so no release, and no CI on the new head),
339
+ * and Dependabot only takes commands from people. Kept to these calls so the
340
+ * release token is never used for anything else, reads included.
265
341
  */
266
- type ReleaseActions = Pick<GitHubClient, 'enableAutoMerge' | 'mergePullRequest'>;
342
+ type ReleaseActions = Pick<GitHubClient, 'createIssueComment' | 'enableAutoMerge' | 'mergePullRequest' | 'updateBranch'>;
267
343
  declare class GitHubApiError extends Error {
268
344
  readonly status: number;
269
345
  constructor(status: number, message: string);
@@ -271,10 +347,9 @@ declare class GitHubApiError extends Error {
271
347
  declare function isApiStatus(error: unknown, status: number): boolean;
272
348
 
273
349
  /**
274
- * The real GitHubClient: REST for reads and labels, GraphQL for the auto-merge
275
- * mutations (REST has no endpoint for them). Deliberately thin — it maps requests
276
- * and responses and throws GitHubApiError on failure; every decision lives in
277
- * gather.ts / execute.ts. `fetch` is injectable so tests use a fake transport.
350
+ * The HTTP transport under the real GitHubClient: authenticated REST requests,
351
+ * pagination, and GraphQL, each throwing GitHubApiError on failure. Split out of
352
+ * http-client.ts so the client modules only map requests and responses.
278
353
  */
279
354
  interface HttpClientOptions {
280
355
  token: string;
@@ -284,6 +359,7 @@ interface HttpClientOptions {
284
359
  apiUrl?: string;
285
360
  fetch?: typeof fetch;
286
361
  }
362
+
287
363
  declare function createHttpClient(options: HttpClientOptions): GitHubClient;
288
364
 
289
365
  /**
@@ -324,7 +400,7 @@ interface CliDeps {
324
400
  /** Runs git for approval carry-over; tests inject one. Defaults to the real binary. */
325
401
  git?: GitRunner;
326
402
  }
327
- declare const USAGE = "Usage: ai-pr-lifecycle <command> [options]\n\nCommands:\n reconcile Recompute a PR's lifecycle state and converge its labels\n help Show this message\n\nreconcile options:\n --repo <owner/repo> Repository (required)\n --pr <number> Pull request number (required)\n --arm-auto-merge Arm/disarm native auto-merge from the state\n --trusted-authors <a,b> Only these logins (with write access) may cast verdicts\n --skip-copilot-review Don't wait for a Copilot review\n --hold-checks <a,b> Required checks whose pending is a hold (default: pr-policy)\n --ignore-checks <a,b> Required checks CI never counts (default: merge-safety)\n --dry-run Compute the plan without writing anything\n --json Print one JSON object (schemaVersion 1) on stdout\n --no-token-advisory Don't comment on the PR when arming lacks a release token\n\nEnvironment:\n GITHUB_TOKEN Token for reads and writes (required)\n PR_LIFECYCLE_TOKEN\n Real-actor token for arming and merging; without it,\n --arm-auto-merge keeps labels but skips arm/merge\n GITHUB_API_URL API base URL (GitHub Enterprise Server)";
403
+ declare const USAGE = "Usage: ai-pr-lifecycle <command> [options]\n\nCommands:\n reconcile Recompute a PR's lifecycle state and converge its labels\n help Show this message\n\nreconcile options:\n --repo <owner/repo> Repository (required)\n --pr <number> Pull request number (required)\n --arm-auto-merge Arm/disarm native auto-merge from the state\n --auto-update Update approved PRs merge-safety flags 'update required'\n (Dependabot PRs are asked to rebase, never updated)\n --trusted-authors <a,b> Only these logins (with write access) may cast verdicts\n --skip-copilot-review Don't wait for a Copilot review\n --hold-checks <a,b> Required checks whose pending is a hold (default: pr-policy)\n --ignore-checks <a,b> Required checks CI never counts (default: merge-safety)\n --dry-run Compute the plan without writing anything\n --json Print one JSON object (schemaVersion 1) on stdout\n --no-token-advisory Don't comment on the PR when arming lacks a release token\n\nEnvironment:\n GITHUB_TOKEN Token for reads and writes (required)\n PR_LIFECYCLE_TOKEN\n Real-actor token for arming, merging, and updating;\n without it, labels are kept but those are skipped\n GITHUB_API_URL API base URL (GitHub Enterprise Server)";
328
404
  declare function runCli(argv: readonly string[], io: CliIo, deps: CliDeps): Promise<number>;
329
405
 
330
406
  /**
@@ -369,24 +445,33 @@ declare function computeState(facts: PullRequestFacts, policy: ReconcilePolicy):
369
445
  declare const LIFECYCLE_LABELS: readonly ["approved", "changes requested", "escalation needed", "fix required", "ci failing", "review requested"];
370
446
  type LifecycleLabel = (typeof LIFECYCLE_LABELS)[number];
371
447
  declare const AUTO_MERGE_LABEL = "auto-merge enabled";
448
+ /** merge-safety's label for a PR whose base moved in a way that matters. Read, never written. */
449
+ declare const UPDATE_REQUIRED_LABEL = "update required";
372
450
  /**
373
451
  * `merge` is arming's twin for a PR GitHub would already merge: auto-merge can't
374
452
  * be armed then, so it is merged directly (see `immediatelyMergeable`).
375
453
  */
376
454
  type AutoMergeAction = 'arm' | 'disarm' | 'merge' | 'none';
455
+ /**
456
+ * How an approved PR flagged `update required` is brought up to date. A Dependabot
457
+ * PR is never updated directly — a foreign commit on its branch permanently breaks
458
+ * its own rebasing — so it is only ever asked to rebase itself.
459
+ */
460
+ type UpdateAction = 'dependabot-rebase' | 'none' | 'update-branch';
377
461
  interface ReconcilePlan {
378
462
  state: LifecycleState;
379
463
  addLabels: string[];
380
464
  removeLabels: string[];
381
465
  autoMerge: AutoMergeAction;
466
+ update: UpdateAction;
382
467
  }
383
468
  declare function planReconcile(facts: PullRequestFacts, policy: ReconcilePolicy): ReconcilePlan;
384
469
  /**
385
- * The plan with arming and merging taken out, for when no real-actor token is
386
- * available to perform them (docs/cli.md §Release token). Disarming stays: it is
387
- * safety, and any token may do it. Pure, like the planner.
470
+ * The plan with arming, merging, and updating taken out, for when no real-actor
471
+ * token is available to perform them (docs/cli.md §Release token). Disarming
472
+ * stays: it is safety, and any token may do it. Pure, like the planner.
388
473
  */
389
- declare function withoutArming(plan: ReconcilePlan): ReconcilePlan;
474
+ declare function withoutReleaseActions(plan: ReconcilePlan): ReconcilePlan;
390
475
 
391
476
  interface MergeStep {
392
477
  /** The merge base of the two parents. */
@@ -469,9 +554,9 @@ interface ReconcileOptions extends GatherOptions {
469
554
  /** Compute and return the plan without writing anything. */
470
555
  dryRun?: boolean;
471
556
  /**
472
- * Who arms and merges. Defaults to `client`, right when its token is a real
473
- * actor. `'unavailable'` (no real-actor token configured) skips arming and
474
- * merging rather than doing them with a token whose merge triggers no workflows.
557
+ * Who arms, merges, and updates. Defaults to `client`, right when its token is a
558
+ * real actor. `'unavailable'` (no real-actor token configured) skips them rather
559
+ * than doing them with a token whose pushes and merges trigger no workflows.
475
560
  */
476
561
  release?: ReleaseActions | 'unavailable';
477
562
  }
@@ -483,6 +568,8 @@ interface ReconcileResult {
483
568
  lineage: Lineage | undefined;
484
569
  /** The arm or merge the plan wanted but skipped because `release` was unavailable. */
485
570
  skippedAutoMerge: 'arm' | 'merge' | undefined;
571
+ /** The branch update the plan wanted but skipped because `release` was unavailable. */
572
+ skippedUpdate: Exclude<UpdateAction, 'none'> | undefined;
486
573
  }
487
574
  /**
488
575
  * One full reconcile pass for a PR: gather its facts, plan, and (unless dry-run)
@@ -530,16 +617,45 @@ interface ReconcileJson {
530
617
  action: 'arm' | 'merge';
531
618
  reason: 'token-missing';
532
619
  } | null;
620
+ /** The branch update performed: `none`, `update-branch`, or `dependabot-rebase`. */
621
+ update: string;
622
+ /** A branch update the plan wanted but skipped; `null` when nothing was skipped. */
623
+ updateSkipped: {
624
+ action: 'dependabot-rebase' | 'update-branch';
625
+ reason: 'token-missing';
626
+ } | null;
533
627
  }
534
628
  declare function toReconcileJson(target: ReconcileTarget, result: ReconcileResult): ReconcileJson;
535
629
  /** One human-readable line, e.g. `rmartz/x#7 → approved: +approved, auto-merge arm`. */
536
630
  declare function formatSummary(target: ReconcileTarget, result: ReconcileResult): string;
537
631
 
632
+ /**
633
+ * Asking Dependabot to rebase its own PR — the only way a Dependabot PR is ever
634
+ * brought up to date. The request is made at most once per head: after asking,
635
+ * the reconciler waits for Dependabot to either rebase (a new head, which may be
636
+ * asked again) or reply with an error (the head is unchanged, so it stays quiet
637
+ * and leaves the PR to a person). See docs/github-edge-layer.md §Auto-update.
638
+ */
639
+ declare const DEPENDABOT_REBASE_COMMAND = "@dependabot rebase";
640
+ /** Dependabot puts this in the PR body while a rebase runs, and removes it after. */
641
+ declare const DEPENDABOT_REBASING_NOTICE = "Dependabot is rebasing this PR";
642
+ /** Hidden marker recording which head a request was made for. */
643
+ declare function rebaseRequestMarker(headSha: string): string;
644
+ /** The command on its own first line, as Dependabot parses it, then the marker. */
645
+ declare function buildRebaseRequestBody(headSha: string): string;
646
+ /**
647
+ * Whether asking again would only add noise: a rebase is already running, or one
648
+ * was already requested for this head. A forged marker can only suppress a
649
+ * request, never cause one, so the comment author doesn't matter.
650
+ */
651
+ declare function isRebasePending(prBody: string, comments: readonly string[], headSha: string): boolean;
652
+
538
653
  /**
539
654
  * Executes a reconcile plan against GitHub. Writes happen in a fail-safe order —
540
- * disarm, remove labels, add labels, arm — so a run that dies midway never leaves
541
- * auto-merge armed on a PR that is not approved. Arming and merging go through
542
- * `release` (a real-actor token) and are bound to the planned head. See
655
+ * disarm, remove labels, add labels, arm, update — so a run that dies midway never
656
+ * leaves auto-merge armed on a PR that is not approved, and an update (which moves
657
+ * the head) comes last. Arming, merging, and updating go through `release` (a
658
+ * real-actor token) and are bound to the planned head. See
543
659
  * docs/github-edge-layer.md.
544
660
  */
545
661
  interface PlanTarget {
@@ -593,4 +709,4 @@ declare function parseVerdict(review: ReviewFact): ParsedVerdict | undefined;
593
709
  declare function isTrustedAuthor(author: ReviewAuthor, policy: ReconcilePolicy): boolean;
594
710
  declare function currentVerdict(facts: PullRequestFacts, policy: ReconcilePolicy): Verdict | undefined;
595
711
 
596
- export { ACTOR_TYPES, AUTO_MERGE_LABEL, type ActorType, type AutoMergeAction, type BotEligibility, type BotPrCommit, type BotPrFacts, type BotPrType, COPILOT_REVIEWER_LOGIN, type CliDeps, type CliIo, type CollaboratorPermission, type CommitData, DEPENDABOT_LOGIN, DEPENDABOT_UPDATE_TYPES, type DependabotUpdateType, type GatherOptions, type GatheredPullRequest, GitHubApiError, type GitHubClient, type GitResult, type GitRunner, type HttpClientOptions, LIFECYCLE_LABELS, LIFECYCLE_STATES, type LabelDefinition, type LifecycleLabel, type LifecycleState, type Lineage, type LineageInput, MAX_CHAIN_STEPS, type MergeStep, type MergeVerifier, OWNED_LABEL_DEFINITIONS, PR_STATUSES, type ParsedArgs, type ParsedVerdict, type PlanTarget, type PullRequestData, type PullRequestFacts, type PullRequestStatus, RELEASE_PLEASE_BRANCH_PREFIX, RELEASE_PLEASE_PENDING_LABEL, RELEASE_TOKEN_DOCS_URL, REPO_PERMISSIONS, REVIEW_STATES, type ReconcileArgs, type ReconcileJson, type ReconcileOptions, type ReconcilePlan, type ReconcilePolicy, type ReconcileResult, type ReconcileTarget, type ReleaseActions, type RepoPermission, type RepoSource, type ReviewAuthor, type ReviewData, type ReviewFact, type ReviewState, SCHEMA_VERSION, TOKEN_ADVISORY_MARKER, USAGE, VERDICTS, type Verdict, buildTokenAdvisoryBody, classifyBotPr, computeState, createGitRunner, createHttpClient, createMergeVerifier, currentVerdict, executePlan, formatSummary, gatherFacts, gatherLineage, isApiStatus, isTrustedAuthor, parseArgs, parseDependabotUpdateType, parseGitVersion, parseVerdict, planReconcile, postTokenAdvisory, reconcilePullRequest, runCli, toReconcileJson, withoutArming };
712
+ export { ACTOR_TYPES, AUTO_MERGE_LABEL, type ActorType, type AutoMergeAction, BRANCH_UPDATERS, type BotEligibility, type BotPrCommit, type BotPrFacts, type BotPrType, type BranchUpdater, COPILOT_REVIEWER_LOGIN, type ChangedFile, type CliDeps, type CliIo, type CollaboratorPermission, type CommitData, DEPENDABOT_LOGIN, DEPENDABOT_REBASE_COMMAND, DEPENDABOT_REBASING_NOTICE, DEPENDABOT_UPDATE_TYPES, type DependabotUpdateType, type GatherOptions, type GatheredPullRequest, GitHubApiError, type GitHubClient, type GitResult, type GitRunner, type HttpClientOptions, LIFECYCLE_LABELS, LIFECYCLE_STATES, type LabelDefinition, type LifecycleLabel, type LifecycleState, type Lineage, type LineageInput, MAX_CHAIN_STEPS, type MergeStep, type MergeVerifier, OWNED_LABEL_DEFINITIONS, PR_STATUSES, type ParsedArgs, type ParsedVerdict, type PlanTarget, type PullRequestData, type PullRequestFacts, type PullRequestStatus, RELEASE_PLEASE_BRANCH_PREFIX, RELEASE_TOKEN_DOCS_URL, REPO_PERMISSIONS, REVIEW_STATES, type ReconcileArgs, type ReconcileJson, type ReconcileOptions, type ReconcilePlan, type ReconcilePolicy, type ReconcileResult, type ReconcileTarget, type ReleaseActions, type ReleaseDiffVerdict, type RepoPermission, type RepoSource, type ReviewAuthor, type ReviewData, type ReviewFact, type ReviewState, SCHEMA_VERSION, TOKEN_ADVISORY_MARKER, UPDATE_REQUIRED_LABEL, USAGE, type UpdateAction, VERDICTS, type Verdict, buildRebaseRequestBody, buildTokenAdvisoryBody, classifyBotPr, classifyReleaseDiff, computeState, createGitRunner, createHttpClient, createMergeVerifier, currentVerdict, executePlan, formatSummary, gatherFacts, gatherLineage, isApiStatus, isRebasePending, isTrustedAuthor, isVersionOnlyPatch, parseArgs, parseDependabotUpdateType, parseGitVersion, parseVerdict, planReconcile, postTokenAdvisory, rebaseRequestMarker, reconcilePullRequest, runCli, toReconcileJson, withoutReleaseActions };
package/dist/index.js CHANGED
@@ -1,8 +1,11 @@
1
1
  import {
2
2
  ACTOR_TYPES,
3
3
  AUTO_MERGE_LABEL,
4
+ BRANCH_UPDATERS,
4
5
  COPILOT_REVIEWER_LOGIN,
5
6
  DEPENDABOT_LOGIN,
7
+ DEPENDABOT_REBASE_COMMAND,
8
+ DEPENDABOT_REBASING_NOTICE,
6
9
  DEPENDABOT_UPDATE_TYPES,
7
10
  GitHubApiError,
8
11
  LIFECYCLE_LABELS,
@@ -11,16 +14,18 @@ import {
11
14
  OWNED_LABEL_DEFINITIONS,
12
15
  PR_STATUSES,
13
16
  RELEASE_PLEASE_BRANCH_PREFIX,
14
- RELEASE_PLEASE_PENDING_LABEL,
15
17
  RELEASE_TOKEN_DOCS_URL,
16
18
  REPO_PERMISSIONS,
17
19
  REVIEW_STATES,
18
20
  SCHEMA_VERSION,
19
21
  TOKEN_ADVISORY_MARKER,
22
+ UPDATE_REQUIRED_LABEL,
20
23
  USAGE,
21
24
  VERDICTS,
25
+ buildRebaseRequestBody,
22
26
  buildTokenAdvisoryBody,
23
27
  classifyBotPr,
28
+ classifyReleaseDiff,
24
29
  computeState,
25
30
  createGitRunner,
26
31
  createHttpClient,
@@ -31,23 +36,29 @@ import {
31
36
  gatherFacts,
32
37
  gatherLineage,
33
38
  isApiStatus,
39
+ isRebasePending,
34
40
  isTrustedAuthor,
41
+ isVersionOnlyPatch,
35
42
  parseArgs,
36
43
  parseDependabotUpdateType,
37
44
  parseGitVersion,
38
45
  parseVerdict,
39
46
  planReconcile,
40
47
  postTokenAdvisory,
48
+ rebaseRequestMarker,
41
49
  reconcilePullRequest,
42
50
  runCli,
43
51
  toReconcileJson,
44
- withoutArming
45
- } from "./chunk-RQEBRPZU.js";
52
+ withoutReleaseActions
53
+ } from "./chunk-PJOTYSEK.js";
46
54
  export {
47
55
  ACTOR_TYPES,
48
56
  AUTO_MERGE_LABEL,
57
+ BRANCH_UPDATERS,
49
58
  COPILOT_REVIEWER_LOGIN,
50
59
  DEPENDABOT_LOGIN,
60
+ DEPENDABOT_REBASE_COMMAND,
61
+ DEPENDABOT_REBASING_NOTICE,
51
62
  DEPENDABOT_UPDATE_TYPES,
52
63
  GitHubApiError,
53
64
  LIFECYCLE_LABELS,
@@ -56,16 +67,18 @@ export {
56
67
  OWNED_LABEL_DEFINITIONS,
57
68
  PR_STATUSES,
58
69
  RELEASE_PLEASE_BRANCH_PREFIX,
59
- RELEASE_PLEASE_PENDING_LABEL,
60
70
  RELEASE_TOKEN_DOCS_URL,
61
71
  REPO_PERMISSIONS,
62
72
  REVIEW_STATES,
63
73
  SCHEMA_VERSION,
64
74
  TOKEN_ADVISORY_MARKER,
75
+ UPDATE_REQUIRED_LABEL,
65
76
  USAGE,
66
77
  VERDICTS,
78
+ buildRebaseRequestBody,
67
79
  buildTokenAdvisoryBody,
68
80
  classifyBotPr,
81
+ classifyReleaseDiff,
69
82
  computeState,
70
83
  createGitRunner,
71
84
  createHttpClient,
@@ -76,15 +89,18 @@ export {
76
89
  gatherFacts,
77
90
  gatherLineage,
78
91
  isApiStatus,
92
+ isRebasePending,
79
93
  isTrustedAuthor,
94
+ isVersionOnlyPatch,
80
95
  parseArgs,
81
96
  parseDependabotUpdateType,
82
97
  parseGitVersion,
83
98
  parseVerdict,
84
99
  planReconcile,
85
100
  postTokenAdvisory,
101
+ rebaseRequestMarker,
86
102
  reconcilePullRequest,
87
103
  runCli,
88
104
  toReconcileJson,
89
- withoutArming
105
+ withoutReleaseActions
90
106
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmartz/pr-lifecycle",
3
- "version": "5.0.0",
3
+ "version": "7.0.0",
4
4
  "description": "Event-driven PR lifecycle reconciler: recomputes a pull request's lifecycle labels from its current facts on every GitHub event and arms native auto-merge on approval.",
5
5
  "keywords": [
6
6
  "ci",