@rmartz/pr-lifecycle 5.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.
@@ -0,0 +1,1296 @@
1
+ // src/bot-eligibility.ts
2
+ var DEPENDABOT_LOGIN = "dependabot[bot]";
3
+ var RELEASE_PLEASE_BRANCH_PREFIX = "release-please--";
4
+ var RELEASE_PLEASE_PENDING_LABEL = "autorelease: pending";
5
+ var DEPENDABOT_UPDATE_TYPES = [
6
+ "version-update:semver-patch",
7
+ "version-update:semver-minor",
8
+ "version-update:semver-major"
9
+ ];
10
+ var ELIGIBLE_UPDATE_TYPES = /* @__PURE__ */ new Set([
11
+ "version-update:semver-patch",
12
+ "version-update:semver-minor"
13
+ ]);
14
+ function isUpdateType(value) {
15
+ return DEPENDABOT_UPDATE_TYPES.some((type) => type === value);
16
+ }
17
+ function parseDependabotUpdateType(message) {
18
+ const lines = message.split("\n");
19
+ const start = lines.findIndex((line) => line.trim() === "updated-dependencies:");
20
+ if (start === -1) {
21
+ return void 0;
22
+ }
23
+ let highest = -1;
24
+ for (const line of lines.slice(start + 1)) {
25
+ const trimmed = line.trim();
26
+ if (trimmed === "...") {
27
+ break;
28
+ }
29
+ if (!trimmed.startsWith("update-type:")) {
30
+ continue;
31
+ }
32
+ const value = trimmed.slice("update-type:".length).trim();
33
+ if (!isUpdateType(value)) {
34
+ return void 0;
35
+ }
36
+ highest = Math.max(highest, DEPENDABOT_UPDATE_TYPES.indexOf(value));
37
+ }
38
+ return DEPENDABOT_UPDATE_TYPES[highest];
39
+ }
40
+ function result(eligible, reason, prType, updateType) {
41
+ return { eligible, reason, prType, updateType };
42
+ }
43
+ function classifyDependabot(commits) {
44
+ if (commits.length === 0) {
45
+ return result(false, "Dependabot PR with no commits \u2014 not eligible", "dependabot");
46
+ }
47
+ if (commits.some((commit) => commit.authorLogin !== DEPENDABOT_LOGIN)) {
48
+ return result(
49
+ false,
50
+ "Dependabot PR has non-Dependabot commits \u2014 held for review",
51
+ "dependabot"
52
+ );
53
+ }
54
+ let highest = -1;
55
+ for (const commit of commits) {
56
+ const type = parseDependabotUpdateType(commit.message);
57
+ if (type === void 0) {
58
+ return result(false, "Dependabot update type unavailable \u2014 not eligible", "dependabot");
59
+ }
60
+ highest = Math.max(highest, DEPENDABOT_UPDATE_TYPES.indexOf(type));
61
+ }
62
+ const updateType = DEPENDABOT_UPDATE_TYPES[highest];
63
+ if (updateType !== void 0 && ELIGIBLE_UPDATE_TYPES.has(updateType)) {
64
+ return result(true, `Dependabot ${updateType} \u2014 eligible`, "dependabot", updateType);
65
+ }
66
+ return result(false, `Dependabot ${updateType} \u2014 held for review`, "dependabot", updateType);
67
+ }
68
+ function classifyBotPr(facts) {
69
+ if (facts.isCrossRepository) {
70
+ return result(false, "head branch is in a fork \u2014 never a trusted bot PR");
71
+ }
72
+ if (facts.authorLogin === DEPENDABOT_LOGIN) {
73
+ return classifyDependabot(facts.commits);
74
+ }
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");
77
+ }
78
+ return result(false, "not a recognized bot PR");
79
+ }
80
+
81
+ // src/cli/args.ts
82
+ var REPO_PATTERN = /^([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+)$/;
83
+ var PR_PATTERN = /^[1-9][0-9]*$/;
84
+ function usageError(message) {
85
+ return { command: "error", message };
86
+ }
87
+ function parseList(value) {
88
+ return value.split(",").map((item) => item.trim()).filter((item) => item.length > 0);
89
+ }
90
+ function parseReconcile(args) {
91
+ const values = {};
92
+ let armAutoMerge = false;
93
+ let skipCopilotReview = false;
94
+ let dryRun = false;
95
+ let json = false;
96
+ let tokenAdvisory = true;
97
+ for (let index = 0; index < args.length; index += 1) {
98
+ const arg = args[index] ?? "";
99
+ switch (arg) {
100
+ case "--arm-auto-merge":
101
+ armAutoMerge = true;
102
+ break;
103
+ case "--skip-copilot-review":
104
+ skipCopilotReview = true;
105
+ break;
106
+ case "--dry-run":
107
+ dryRun = true;
108
+ break;
109
+ case "--json":
110
+ json = true;
111
+ break;
112
+ case "--no-token-advisory":
113
+ tokenAdvisory = false;
114
+ break;
115
+ case "--repo":
116
+ case "--pr":
117
+ case "--trusted-authors":
118
+ case "--hold-checks":
119
+ case "--ignore-checks": {
120
+ const value = args[index + 1];
121
+ if (value === void 0 || value.startsWith("--")) {
122
+ return usageError(`${arg} requires a value`);
123
+ }
124
+ index += 1;
125
+ values[arg] = value;
126
+ break;
127
+ }
128
+ default:
129
+ return usageError(`Unknown option: ${arg}`);
130
+ }
131
+ }
132
+ const repoArg = values["--repo"];
133
+ const prArg = values["--pr"];
134
+ const repoMatch = repoArg === void 0 ? null : REPO_PATTERN.exec(repoArg);
135
+ if (repoMatch?.[1] === void 0 || repoMatch[2] === void 0) {
136
+ return usageError("--repo <owner/repo> is required");
137
+ }
138
+ if (prArg === void 0 || !PR_PATTERN.test(prArg)) {
139
+ return usageError("--pr <number> is required and must be a positive integer");
140
+ }
141
+ const trustedAuthors = listOption(values["--trusted-authors"]);
142
+ if (trustedAuthors?.length === 0) {
143
+ return usageError("--trusted-authors needs at least one login");
144
+ }
145
+ const holdChecks = listOption(values["--hold-checks"]);
146
+ const ignoredChecks = listOption(values["--ignore-checks"]);
147
+ return {
148
+ command: "reconcile",
149
+ owner: repoMatch[1],
150
+ repo: repoMatch[2],
151
+ pr: Number(prArg),
152
+ policy: {
153
+ armAutoMerge,
154
+ skipCopilotReview,
155
+ ...trustedAuthors === void 0 ? {} : { trustedAuthors },
156
+ ...holdChecks === void 0 ? {} : { holdChecks },
157
+ ...ignoredChecks === void 0 ? {} : { ignoredChecks }
158
+ },
159
+ dryRun,
160
+ json,
161
+ tokenAdvisory
162
+ };
163
+ }
164
+ function listOption(value) {
165
+ return value === void 0 ? void 0 : parseList(value);
166
+ }
167
+ function parseArgs(argv) {
168
+ const [command, ...rest] = argv;
169
+ if (command === void 0 || command === "help" || command === "--help" || command === "-h") {
170
+ return { command: "help" };
171
+ }
172
+ if (rest.includes("--help") || rest.includes("-h")) {
173
+ return { command: "help" };
174
+ }
175
+ if (command === "reconcile") {
176
+ return parseReconcile(rest);
177
+ }
178
+ return usageError(`Unknown command: ${command}`);
179
+ }
180
+
181
+ // src/cli/output.ts
182
+ var SCHEMA_VERSION = 1;
183
+ function toReconcileJson(target, result2) {
184
+ const { plan, botEligibility, lineage, skippedAutoMerge } = result2;
185
+ return {
186
+ schemaVersion: SCHEMA_VERSION,
187
+ repo: `${target.owner}/${target.repo}`,
188
+ pr: target.pr,
189
+ dryRun: target.dryRun,
190
+ state: plan.state,
191
+ addLabels: plan.addLabels,
192
+ removeLabels: plan.removeLabels,
193
+ autoMerge: plan.autoMerge,
194
+ botEligibility: {
195
+ eligible: botEligibility.eligible,
196
+ reason: botEligibility.reason,
197
+ prType: botEligibility.prType ?? null,
198
+ updateType: botEligibility.updateType ?? null
199
+ },
200
+ carryOver: lineage === void 0 ? null : { cleanAncestors: lineage.cleanAncestors, stoppedBecause: lineage.stoppedBecause },
201
+ // The CLI makes release actions unavailable only for a missing token.
202
+ autoMergeSkipped: skippedAutoMerge === void 0 ? null : { action: skippedAutoMerge, reason: "token-missing" }
203
+ };
204
+ }
205
+ function formatSummary(target, result2) {
206
+ const { plan, botEligibility } = result2;
207
+ const changes = [
208
+ ...plan.addLabels.map((label) => `+${label}`),
209
+ ...plan.removeLabels.map((label) => `-${label}`),
210
+ ...plan.autoMerge === "none" ? [] : [`auto-merge ${plan.autoMerge}`],
211
+ ...result2.skippedAutoMerge === void 0 ? [] : [`auto-merge ${result2.skippedAutoMerge} skipped (no release token)`]
212
+ ];
213
+ const prefix = target.dryRun ? "[dry-run] " : "";
214
+ const body = changes.length === 0 ? "no changes" : changes.join(", ");
215
+ return `${prefix}${target.owner}/${target.repo}#${target.pr} \u2192 ${plan.state}: ${body} (bot: ${botEligibility.reason})`;
216
+ }
217
+
218
+ // src/github/client.ts
219
+ var GitHubApiError = class extends Error {
220
+ status;
221
+ constructor(status, message) {
222
+ super(message);
223
+ this.name = "GitHubApiError";
224
+ this.status = status;
225
+ }
226
+ };
227
+ function isApiStatus(error, status) {
228
+ return error instanceof GitHubApiError && error.status === status;
229
+ }
230
+
231
+ // src/facts.ts
232
+ var REPO_PERMISSIONS = ["admin", "maintain", "none", "read", "triage", "write"];
233
+ var ACTOR_TYPES = ["Bot", "User"];
234
+ var REVIEW_STATES = [
235
+ "APPROVED",
236
+ "CHANGES_REQUESTED",
237
+ "COMMENTED",
238
+ "DISMISSED",
239
+ "PENDING"
240
+ ];
241
+ var PR_STATUSES = ["closed", "merged", "open"];
242
+
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
263
+ 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) {
274
+ let apiUrl = options.apiUrl ?? "https://api.github.com";
275
+ while (apiUrl.endsWith("/")) {
276
+ apiUrl = apiUrl.slice(0, -1);
277
+ }
278
+ const doFetch = options.fetch ?? fetch;
279
+ const repoPath = `/repos/${encodeURIComponent(options.owner)}/${encodeURIComponent(options.repo)}`;
280
+ async function request(method, path, body) {
281
+ const response = await doFetch(`${apiUrl}${path}`, {
282
+ method,
283
+ headers: {
284
+ Accept: "application/vnd.github+json",
285
+ Authorization: `Bearer ${options.token}`,
286
+ "Content-Type": "application/json",
287
+ "User-Agent": "rmartz-pr-lifecycle",
288
+ "X-GitHub-Api-Version": "2022-11-28"
289
+ },
290
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
291
+ });
292
+ if (!response.ok) {
293
+ const detail = await response.text();
294
+ throw new GitHubApiError(
295
+ response.status,
296
+ `${method} ${path} \u2192 ${response.status}: ${detail}`
297
+ );
298
+ }
299
+ return response.status === 204 ? void 0 : response.json();
300
+ }
301
+ async function paginate(path) {
302
+ const items = [];
303
+ for (let page = 1; ; page += 1) {
304
+ const batch = await request("GET", `${path}?per_page=${PAGE_SIZE}&page=${page}`);
305
+ items.push(...batch);
306
+ if (batch.length < PAGE_SIZE) {
307
+ return items;
308
+ }
309
+ }
310
+ }
311
+ async function graphql(query, variables) {
312
+ const result2 = await request("POST", "/graphql", { query, variables });
313
+ if (result2.errors !== void 0 && result2.errors.length > 0) {
314
+ const messages = result2.errors.map((error) => error.message).join("; ");
315
+ throw new GitHubApiError(200, `GraphQL error: ${messages}`);
316
+ }
317
+ }
318
+ return {
319
+ async getPullRequest(pr) {
320
+ const pull = await request("GET", `${repoPath}/pulls/${pr}`);
321
+ return {
322
+ nodeId: pull.node_id,
323
+ state: pull.state,
324
+ merged: pull.merged,
325
+ draft: pull.draft === true,
326
+ title: pull.title,
327
+ headSha: pull.head.sha,
328
+ labels: pull.labels.map((label) => label.name),
329
+ autoMergeEnabled: pull.auto_merge !== null && pull.auto_merge !== void 0,
330
+ authorLogin: pull.user?.login,
331
+ headRef: pull.head.ref,
332
+ baseRef: pull.base.ref,
333
+ cloneUrl: pull.base.repo.clone_url,
334
+ // Compare repo ids, not names (names change on rename); a deleted head
335
+ // repo is treated as a fork, so it can never look like a trusted branch.
336
+ isCrossRepository: pull.head.repo?.id !== pull.base.repo.id,
337
+ mergeable: pull.mergeable ?? void 0,
338
+ mergeState: pull.mergeable_state
339
+ };
340
+ },
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
+ async getBranchHeadSha(branch) {
349
+ const result2 = await request(
350
+ "GET",
351
+ `${repoPath}/branches/${encodeURIComponent(branch)}`
352
+ );
353
+ return result2.commit.sha;
354
+ },
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
+ async getCommit(sha) {
375
+ const commit = await request("GET", `${repoPath}/git/commits/${sha}`);
376
+ return { treeSha: commit.tree.sha, parents: commit.parents.map((parent) => parent.sha) };
377
+ },
378
+ async compareCommits(base, head) {
379
+ const result2 = await request("GET", `${repoPath}/compare/${base}...${head}?per_page=1`);
380
+ return { status: result2.status, mergeBaseSha: result2.merge_base_commit.sha };
381
+ },
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 }));
385
+ },
386
+ async listCommits(pr) {
387
+ const commits = await paginate(`${repoPath}/pulls/${pr}/commits`);
388
+ return commits.map((commit) => ({
389
+ authorLogin: commit.author?.login,
390
+ message: commit.commit.message
391
+ }));
392
+ },
393
+ async listReviews(pr) {
394
+ const reviews = await paginate(`${repoPath}/pulls/${pr}/reviews`);
395
+ return reviews.map(toReviewData);
396
+ },
397
+ async getCollaboratorPermission(login) {
398
+ const result2 = await request(
399
+ "GET",
400
+ `${repoPath}/collaborators/${encodeURIComponent(login)}/permission`
401
+ );
402
+ return { permission: result2.permission, roleName: result2.role_name };
403
+ },
404
+ async listRepoLabels() {
405
+ const labels = await paginate(`${repoPath}/labels`);
406
+ return labels.map((label) => label.name);
407
+ },
408
+ async createLabel(label) {
409
+ await request("POST", `${repoPath}/labels`, label);
410
+ },
411
+ async addLabels(pr, names) {
412
+ await request("POST", `${repoPath}/issues/${pr}/labels`, { labels: names });
413
+ },
414
+ async removeLabel(pr, name) {
415
+ await request("DELETE", `${repoPath}/issues/${pr}/labels/${encodeURIComponent(name)}`);
416
+ },
417
+ async listIssueComments(pr) {
418
+ const comments = await paginate(`${repoPath}/issues/${pr}/comments`);
419
+ return comments.map((comment) => comment.body ?? "");
420
+ },
421
+ async createIssueComment(pr, body) {
422
+ await request("POST", `${repoPath}/issues/${pr}/comments`, { body });
423
+ },
424
+ async enableAutoMerge(pullRequestNodeId, expectedHeadOid) {
425
+ await graphql(ENABLE_AUTO_MERGE, { id: pullRequestNodeId, head: expectedHeadOid });
426
+ },
427
+ async mergePullRequest(pullRequestNodeId, expectedHeadOid) {
428
+ await graphql(MERGE_PULL_REQUEST, { id: pullRequestNodeId, head: expectedHeadOid });
429
+ },
430
+ async disableAutoMerge(pullRequestNodeId) {
431
+ await graphql(DISABLE_AUTO_MERGE, { id: pullRequestNodeId });
432
+ }
433
+ };
434
+ }
435
+
436
+ // src/verdict.ts
437
+ var VERDICTS = ["approved", "changes-requested", "escalation-needed"];
438
+ var TRUSTED_PERMISSIONS = /* @__PURE__ */ new Set([
439
+ "admin",
440
+ "maintain",
441
+ "write"
442
+ ]);
443
+ var SKILL_META_OPENING = /<!--\s*skill-meta:/;
444
+ var COMMENT_CLOSE = "-->";
445
+ function isVerdict(value) {
446
+ return VERDICTS.some((verdict) => verdict === value);
447
+ }
448
+ function readSkillMeta(body) {
449
+ const opening = SKILL_META_OPENING.exec(body);
450
+ if (opening === null) {
451
+ return void 0;
452
+ }
453
+ const payloadStart = opening.index + opening[0].length;
454
+ const payloadEnd = body.indexOf(COMMENT_CLOSE, payloadStart);
455
+ if (payloadEnd === -1) {
456
+ return void 0;
457
+ }
458
+ try {
459
+ const parsed = JSON.parse(body.slice(payloadStart, payloadEnd).trim());
460
+ return typeof parsed === "object" && parsed !== null ? parsed : void 0;
461
+ } catch {
462
+ return void 0;
463
+ }
464
+ }
465
+ function parseVerdict(review) {
466
+ if (review.state === "DISMISSED" || review.state === "PENDING") {
467
+ return void 0;
468
+ }
469
+ const meta = readSkillMeta(review.body);
470
+ if (meta?.skill === "review") {
471
+ if (!isVerdict(meta.outcome)) {
472
+ return void 0;
473
+ }
474
+ return {
475
+ verdict: meta.outcome,
476
+ markerHead: typeof meta.pr_head === "string" ? meta.pr_head : void 0
477
+ };
478
+ }
479
+ switch (review.state) {
480
+ case "APPROVED":
481
+ return { verdict: "approved", markerHead: void 0 };
482
+ case "CHANGES_REQUESTED":
483
+ return { verdict: "changes-requested", markerHead: void 0 };
484
+ case "COMMENTED":
485
+ return void 0;
486
+ }
487
+ }
488
+ function isTrustedAuthor(author, policy) {
489
+ if (author.type !== "User" || !TRUSTED_PERMISSIONS.has(author.permission)) {
490
+ return false;
491
+ }
492
+ if (policy.trustedAuthors === void 0) {
493
+ return true;
494
+ }
495
+ const login = author.login.toLowerCase();
496
+ return policy.trustedAuthors.some((trusted) => trusted.toLowerCase() === login);
497
+ }
498
+ function compareSubmission(a, b) {
499
+ return Date.parse(a.submittedAt) - Date.parse(b.submittedAt) || a.id - b.id;
500
+ }
501
+ function countingCommits(facts) {
502
+ return /* @__PURE__ */ new Set([facts.headSha, ...facts.cleanAncestors]);
503
+ }
504
+ function currentVerdict(facts, policy) {
505
+ const countingShas = countingCommits(facts);
506
+ let latest;
507
+ for (const review of facts.reviews) {
508
+ const parsed = parseVerdict(review);
509
+ const counts = parsed !== void 0 && isTrustedAuthor(review.author, policy) && countingShas.has(review.commitSha) && // The marker, when present, must name the same commit the review is bound to.
510
+ (parsed.markerHead === void 0 || parsed.markerHead === review.commitSha);
511
+ if (counts && (latest === void 0 || compareSubmission(review, latest.review) > 0)) {
512
+ latest = { review, verdict: parsed.verdict };
513
+ }
514
+ }
515
+ return latest?.verdict;
516
+ }
517
+
518
+ // src/state.ts
519
+ var LIFECYCLE_STATES = [
520
+ "approved",
521
+ "awaiting-ci",
522
+ "awaiting-copilot",
523
+ "blocked-base-red",
524
+ "changes-requested",
525
+ "ci-failing",
526
+ "closed",
527
+ "draft",
528
+ "escalation-needed",
529
+ "fix-required",
530
+ "review-requested"
531
+ ];
532
+ var COPILOT_REVIEWER_LOGIN = "copilot-pull-request-reviewer[bot]";
533
+ var WIP_PATTERN = /\[wip\]/i;
534
+ function computeState(facts, policy) {
535
+ if (facts.status !== "open") {
536
+ return "closed";
537
+ }
538
+ if (facts.isDraft || WIP_PATTERN.test(facts.title)) {
539
+ return "draft";
540
+ }
541
+ if (facts.mergeable === false) {
542
+ return "fix-required";
543
+ }
544
+ if (facts.ciStatus === "failing") {
545
+ return facts.baseCiFailing ? "blocked-base-red" : "ci-failing";
546
+ }
547
+ const verdict = currentVerdict(facts, policy);
548
+ if (verdict !== void 0) {
549
+ return verdict;
550
+ }
551
+ if (facts.botEligible) {
552
+ return "approved";
553
+ }
554
+ if (facts.ciStatus === "pending") {
555
+ return "awaiting-ci";
556
+ }
557
+ if (policy.skipCopilotReview === true) {
558
+ return "review-requested";
559
+ }
560
+ const reviewedCommits = countingCommits(facts);
561
+ const copilotReviewedHead = facts.reviews.some(
562
+ (review) => review.author.login === COPILOT_REVIEWER_LOGIN && reviewedCommits.has(review.commitSha)
563
+ );
564
+ return copilotReviewedHead ? "review-requested" : "awaiting-copilot";
565
+ }
566
+
567
+ // src/plan.ts
568
+ var LIFECYCLE_LABELS = [
569
+ "approved",
570
+ "changes requested",
571
+ "escalation needed",
572
+ "fix required",
573
+ "ci failing",
574
+ "review requested"
575
+ ];
576
+ var AUTO_MERGE_LABEL = "auto-merge enabled";
577
+ function lifecycleLabels(state) {
578
+ switch (state) {
579
+ case "approved":
580
+ return ["approved"];
581
+ case "changes-requested":
582
+ return ["changes requested"];
583
+ case "escalation-needed":
584
+ return ["escalation needed"];
585
+ case "fix-required":
586
+ return ["fix required"];
587
+ case "ci-failing":
588
+ return ["fix required", "ci failing"];
589
+ case "review-requested":
590
+ return ["review requested"];
591
+ case "awaiting-ci":
592
+ case "awaiting-copilot":
593
+ case "blocked-base-red":
594
+ case "closed":
595
+ case "draft":
596
+ return [];
597
+ }
598
+ }
599
+ function planReconcile(facts, policy) {
600
+ const state = computeState(facts, policy);
601
+ if (state === "closed") {
602
+ return { state, addLabels: [], removeLabels: [], autoMerge: "none" };
603
+ }
604
+ const arming = policy.armAutoMerge === true;
605
+ const shouldArm = arming && state === "approved";
606
+ let autoMerge = "none";
607
+ if (arming && shouldArm !== facts.autoMergeEnabled) {
608
+ if (!shouldArm) {
609
+ autoMerge = "disarm";
610
+ } else {
611
+ autoMerge = facts.immediatelyMergeable ? "merge" : "arm";
612
+ }
613
+ }
614
+ const owned = [...LIFECYCLE_LABELS];
615
+ const desired = new Set(lifecycleLabels(state));
616
+ if (arming) {
617
+ owned.push(AUTO_MERGE_LABEL);
618
+ if (shouldArm && autoMerge !== "merge") {
619
+ desired.add(AUTO_MERGE_LABEL);
620
+ }
621
+ }
622
+ const present = new Set(facts.labels);
623
+ return {
624
+ state,
625
+ addLabels: [...desired].filter((name) => !present.has(name)),
626
+ removeLabels: owned.filter((name) => present.has(name) && !desired.has(name)),
627
+ autoMerge
628
+ };
629
+ }
630
+ function withoutArming(plan) {
631
+ if (plan.autoMerge !== "arm" && plan.autoMerge !== "merge") {
632
+ return plan;
633
+ }
634
+ return {
635
+ ...plan,
636
+ addLabels: plan.addLabels.filter((name) => name !== AUTO_MERGE_LABEL),
637
+ autoMerge: "none"
638
+ };
639
+ }
640
+
641
+ // src/github/label-roster.ts
642
+ var OWNED_LABEL_DEFINITIONS = {
643
+ approved: {
644
+ name: "approved",
645
+ color: "2DA44E",
646
+ description: "Code review passed \u2014 safe to merge pending UAT."
647
+ },
648
+ "auto-merge enabled": {
649
+ name: AUTO_MERGE_LABEL,
650
+ color: "1F883D",
651
+ description: "Native auto-merge is armed on this PR; it merges once required checks pass."
652
+ },
653
+ "changes requested": {
654
+ name: "changes requested",
655
+ color: "DA3633",
656
+ description: "Fix-review pass needed \u2014 reviewer flagged addressable issues."
657
+ },
658
+ "escalation needed": {
659
+ name: "escalation needed",
660
+ color: "DB2777",
661
+ description: "Needs author input or judgment \u2014 not addressable by automated fix-review."
662
+ },
663
+ // Placeholder colors until the fleet roster assigns them (rmartz/dotfiles#1573).
664
+ "ci failing": {
665
+ name: "ci failing",
666
+ color: "B60205",
667
+ description: "The PR's required CI (excluding gate checks) is failing on its head."
668
+ },
669
+ "fix required": {
670
+ name: "fix required",
671
+ color: "D93F0B",
672
+ description: "A statically detected problem (merge conflict or failing CI) needs a fix."
673
+ },
674
+ "review requested": {
675
+ name: "review requested",
676
+ color: "E3B341",
677
+ description: "Review pass needed on the current head."
678
+ }
679
+ };
680
+ function labelDefinition(name) {
681
+ const known = Object.values(OWNED_LABEL_DEFINITIONS).find((label) => label.name === name);
682
+ return known ?? { name, color: "EDEDED", description: "" };
683
+ }
684
+
685
+ // src/github/execute.ts
686
+ function isCleanStatusError(error) {
687
+ return error instanceof GitHubApiError && /clean status/i.test(error.message);
688
+ }
689
+ async function armOrMerge(release, target) {
690
+ try {
691
+ await release.enableAutoMerge(target.nodeId, target.headSha);
692
+ } catch (error) {
693
+ if (!isCleanStatusError(error)) {
694
+ throw error;
695
+ }
696
+ await release.mergePullRequest(target.nodeId, target.headSha);
697
+ }
698
+ }
699
+ async function ensureLabelsExist(client, names) {
700
+ const existing = new Set(await client.listRepoLabels());
701
+ for (const name of names) {
702
+ if (existing.has(name)) {
703
+ continue;
704
+ }
705
+ try {
706
+ await client.createLabel(labelDefinition(name));
707
+ } catch (error) {
708
+ if (!isApiStatus(error, 422)) {
709
+ throw error;
710
+ }
711
+ }
712
+ }
713
+ }
714
+ async function executePlan(client, target, plan, release = client) {
715
+ if (plan.autoMerge === "disarm") {
716
+ await client.disableAutoMerge(target.nodeId);
717
+ }
718
+ for (const name of plan.removeLabels) {
719
+ try {
720
+ await client.removeLabel(target.pr, name);
721
+ } catch (error) {
722
+ if (!isApiStatus(error, 404)) {
723
+ throw error;
724
+ }
725
+ }
726
+ }
727
+ if (plan.addLabels.length > 0) {
728
+ await ensureLabelsExist(client, plan.addLabels);
729
+ await client.addLabels(target.pr, plan.addLabels);
730
+ }
731
+ if (plan.autoMerge === "arm") {
732
+ await armOrMerge(release, target);
733
+ }
734
+ if (plan.autoMerge === "merge") {
735
+ await release.mergePullRequest(target.nodeId, target.headSha);
736
+ }
737
+ }
738
+
739
+ // src/lineage/git.ts
740
+ import { execFile } from "child_process";
741
+ var TIMEOUT_MS = 12e4;
742
+ var MAX_BUFFER = 16 * 1024 * 1024;
743
+ function createGitRunner() {
744
+ return {
745
+ run(args, env = {}) {
746
+ return new Promise((resolve, reject) => {
747
+ execFile(
748
+ "git",
749
+ [...args],
750
+ {
751
+ timeout: TIMEOUT_MS,
752
+ maxBuffer: MAX_BUFFER,
753
+ // Never prompt for credentials; fail instead.
754
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...env }
755
+ },
756
+ (error, stdout, stderr) => {
757
+ if (error === null) {
758
+ resolve({ code: 0, stdout, stderr });
759
+ return;
760
+ }
761
+ if (typeof error.code === "number" && !error.killed) {
762
+ resolve({ code: error.code, stdout, stderr });
763
+ return;
764
+ }
765
+ reject(new Error(`git ${args[0] ?? ""} failed: ${error.message}`, { cause: error }));
766
+ }
767
+ );
768
+ });
769
+ }
770
+ };
771
+ }
772
+ function parseGitVersion(output) {
773
+ const match = /git version (\d+)\.(\d+)/.exec(output);
774
+ if (match?.[1] === void 0 || match[2] === void 0) {
775
+ return void 0;
776
+ }
777
+ return [Number(match[1]), Number(match[2])];
778
+ }
779
+
780
+ // src/lineage/verify.ts
781
+ import { mkdtemp, rm } from "fs/promises";
782
+ import { tmpdir } from "os";
783
+ import { join } from "path";
784
+ var MIN_GIT = [2, 40];
785
+ var OBJECT_ID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
786
+ function isStepSafe(step) {
787
+ return [step.mergeBase, step.first, step.second, step.tree].every((id) => OBJECT_ID.test(id));
788
+ }
789
+ function authEnv(source) {
790
+ if (source.token === void 0) {
791
+ return {};
792
+ }
793
+ const basic = Buffer.from(`x-access-token:${source.token}`).toString("base64");
794
+ return {
795
+ GIT_CONFIG_COUNT: "1",
796
+ GIT_CONFIG_KEY_0: "http.extraHeader",
797
+ GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${basic}`
798
+ };
799
+ }
800
+ async function createMergeVerifier(git, source) {
801
+ const version = parseGitVersion((await git.run(["version"])).stdout);
802
+ if (version === void 0 || version[0] < MIN_GIT[0] || version[0] === MIN_GIT[0] && version[1] < MIN_GIT[1]) {
803
+ return void 0;
804
+ }
805
+ if (!/^(https|file):\/\//.test(source.url)) {
806
+ return void 0;
807
+ }
808
+ const gitDir = await mkdtemp(join(tmpdir(), "pr-lifecycle-lineage-"));
809
+ const init = await git.run(["init", "--bare", "--quiet", gitDir]);
810
+ if (init.code !== 0) {
811
+ await rm(gitDir, { recursive: true, force: true });
812
+ return void 0;
813
+ }
814
+ const env = authEnv(source);
815
+ return {
816
+ async verify(step) {
817
+ if (!isStepSafe(step)) {
818
+ return false;
819
+ }
820
+ const fetched = await git.run(
821
+ [
822
+ "--git-dir",
823
+ gitDir,
824
+ "fetch",
825
+ "--quiet",
826
+ "--no-tags",
827
+ "--depth=1",
828
+ "--end-of-options",
829
+ source.url,
830
+ step.mergeBase,
831
+ step.first,
832
+ step.second
833
+ ],
834
+ env
835
+ );
836
+ if (fetched.code !== 0) {
837
+ return false;
838
+ }
839
+ const merged = await git.run([
840
+ "--git-dir",
841
+ gitDir,
842
+ "merge-tree",
843
+ "--write-tree",
844
+ `--merge-base=${step.mergeBase}`,
845
+ step.first,
846
+ step.second
847
+ ]);
848
+ return merged.code === 0 && merged.stdout.split("\n")[0]?.trim() === step.tree;
849
+ },
850
+ async dispose() {
851
+ await rm(gitDir, { recursive: true, force: true });
852
+ }
853
+ };
854
+ }
855
+
856
+ // src/github/lineage-facts.ts
857
+ var MAX_CHAIN_STEPS = 20;
858
+ function reviewedAncestors(input) {
859
+ return new Set(
860
+ input.reviews.filter((review) => review.commitSha !== input.headSha).filter(
861
+ (review) => review.login === COPILOT_REVIEWER_LOGIN || review.state === "APPROVED" || review.state === "CHANGES_REQUESTED" || review.body.includes("skill-meta")
862
+ ).map((review) => review.commitSha)
863
+ );
864
+ }
865
+ async function onBaseBranch(client, commit, baseHeadSha) {
866
+ const { status } = await client.compareCommits(commit, baseHeadSha);
867
+ return status === "identical" || status === "ahead";
868
+ }
869
+ async function walk(client, git, input, wanted) {
870
+ const verifier = await createMergeVerifier(git, input.source);
871
+ if (verifier === void 0) {
872
+ return { cleanAncestors: [], stoppedBecause: "git unavailable or older than 2.40" };
873
+ }
874
+ const cleanAncestors = [];
875
+ try {
876
+ let current = input.headSha;
877
+ for (let step = 0; step < MAX_CHAIN_STEPS; step += 1) {
878
+ const commit = await client.getCommit(current);
879
+ const [first, second, ...extra] = commit.parents;
880
+ if (first === void 0 || second === void 0 || extra.length > 0) {
881
+ return { cleanAncestors, stoppedBecause: `${current} is not a two-parent merge` };
882
+ }
883
+ if (!await onBaseBranch(client, second, input.baseHeadSha)) {
884
+ return { cleanAncestors, stoppedBecause: `${current} merges a commit not on the base` };
885
+ }
886
+ const { mergeBaseSha } = await client.compareCommits(first, second);
887
+ const clean = await verifier.verify({
888
+ mergeBase: mergeBaseSha,
889
+ first,
890
+ second,
891
+ tree: commit.treeSha
892
+ });
893
+ if (!clean) {
894
+ return { cleanAncestors, stoppedBecause: `${current} is not the clean automatic merge` };
895
+ }
896
+ cleanAncestors.push(first);
897
+ current = first;
898
+ if ([...wanted].every((sha) => cleanAncestors.includes(sha))) {
899
+ return { cleanAncestors, stoppedBecause: "every reviewed ancestor reached" };
900
+ }
901
+ }
902
+ return { cleanAncestors, stoppedBecause: `chain longer than ${MAX_CHAIN_STEPS} steps` };
903
+ } finally {
904
+ await verifier.dispose();
905
+ }
906
+ }
907
+ async function gatherLineage(client, git, input) {
908
+ const wanted = reviewedAncestors(input);
909
+ if (wanted.size === 0) {
910
+ return { cleanAncestors: [], stoppedBecause: "no reviews on earlier commits" };
911
+ }
912
+ try {
913
+ return await walk(client, git, input, wanted);
914
+ } catch (error) {
915
+ const message = error instanceof Error ? error.message : String(error);
916
+ return { cleanAncestors: [], stoppedBecause: `verification failed: ${message}` };
917
+ }
918
+ }
919
+
920
+ // src/ci.ts
921
+ var DEFAULT_HOLD_CHECKS = ["pr-policy"];
922
+ var DEFAULT_IGNORED_CHECKS = ["merge-safety"];
923
+ var PASSING_CONCLUSIONS = /* @__PURE__ */ new Set(["neutral", "skipped", "success"]);
924
+ var FAILING_CONCLUSIONS = /* @__PURE__ */ new Set([
925
+ "action_required",
926
+ "cancelled",
927
+ "failure",
928
+ "startup_failure",
929
+ "timed_out"
930
+ ]);
931
+ function checkRunOutcome(run) {
932
+ const concluded = run.status === "completed" || run.completedAt !== null;
933
+ if (!concluded || run.conclusion === null) {
934
+ return "running";
935
+ }
936
+ if (PASSING_CONCLUSIONS.has(run.conclusion)) {
937
+ return "passed";
938
+ }
939
+ return FAILING_CONCLUSIONS.has(run.conclusion) ? "failed" : "running";
940
+ }
941
+ function commitStatusOutcome(state) {
942
+ if (state === "success") {
943
+ return "passed";
944
+ }
945
+ return state === "failure" || state === "error" ? "failed" : "running";
946
+ }
947
+ function computeCiStatus(results, requiredChecks2, policy = {}) {
948
+ const holds = new Set(policy.holdChecks ?? DEFAULT_HOLD_CHECKS);
949
+ const ignored = new Set(policy.ignoredChecks ?? DEFAULT_IGNORED_CHECKS);
950
+ let pending = false;
951
+ for (const name of new Set(requiredChecks2)) {
952
+ if (ignored.has(name)) {
953
+ continue;
954
+ }
955
+ const outcomes = results.filter((result2) => result2.name === name).map((r) => r.outcome);
956
+ if (outcomes.includes("failed")) {
957
+ return "failing";
958
+ }
959
+ const reported = outcomes.length > 0;
960
+ const running = outcomes.includes("running");
961
+ if (!holds.has(name) && (!reported || running)) {
962
+ pending = true;
963
+ }
964
+ }
965
+ return pending ? "pending" : "passing";
966
+ }
967
+
968
+ // src/github/ci-facts.ts
969
+ var NO_CI = { ciStatus: "passing", baseCiFailing: false };
970
+ var PLAN_LIMITATION = /upgrade to github pro|make this repository public/i;
971
+ async function requiredChecks(client, branch) {
972
+ try {
973
+ return await client.getRequiredStatusChecks(branch);
974
+ } catch (error) {
975
+ if (error instanceof GitHubApiError && error.status === 403 && PLAN_LIMITATION.test(error.message)) {
976
+ return [];
977
+ }
978
+ throw error;
979
+ }
980
+ }
981
+ async function statusAt(client, sha, required, policy) {
982
+ const [runs, statuses] = await Promise.all([
983
+ client.listCheckRuns(sha),
984
+ client.listCommitStatuses(sha)
985
+ ]);
986
+ const results = [
987
+ ...runs.map((run) => ({ name: run.name, outcome: checkRunOutcome(run) })),
988
+ ...statuses.map((status) => ({
989
+ name: status.context,
990
+ outcome: commitStatusOutcome(status.state)
991
+ }))
992
+ ];
993
+ return computeCiStatus(results, required, policy);
994
+ }
995
+ async function gatherCiFacts(client, pull, policy) {
996
+ if (pull.state !== "open") {
997
+ return NO_CI;
998
+ }
999
+ const required = await requiredChecks(client, pull.baseRef);
1000
+ if (required.length === 0) {
1001
+ return NO_CI;
1002
+ }
1003
+ const ciStatus = await statusAt(client, pull.headSha, required, policy);
1004
+ if (ciStatus !== "failing") {
1005
+ return { ciStatus, baseCiFailing: false };
1006
+ }
1007
+ const baseSha = await client.getBranchHeadSha(pull.baseRef);
1008
+ const baseStatus = await statusAt(client, baseSha, required, policy);
1009
+ return { ciStatus, baseCiFailing: baseStatus === "failing" };
1010
+ }
1011
+
1012
+ // 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
+ function isRepoPermission(value) {
1024
+ return REPO_PERMISSIONS.some((permission) => permission === value);
1025
+ }
1026
+ async function lookupPermission(client, login) {
1027
+ try {
1028
+ const { permission, roleName } = await client.getCollaboratorPermission(login);
1029
+ if (isRepoPermission(roleName)) {
1030
+ return roleName;
1031
+ }
1032
+ return isRepoPermission(permission) ? permission : "none";
1033
+ } catch (error) {
1034
+ if (isApiStatus(error, 404)) {
1035
+ return "none";
1036
+ }
1037
+ throw error;
1038
+ }
1039
+ }
1040
+ async function lookupPermissions(client, reviews) {
1041
+ const logins = /* @__PURE__ */ new Set();
1042
+ for (const review of reviews) {
1043
+ if (review.login !== void 0 && review.type === "User") {
1044
+ logins.add(review.login);
1045
+ }
1046
+ }
1047
+ const entries = await Promise.all(
1048
+ [...logins].map(async (login) => [login, await lookupPermission(client, login)])
1049
+ );
1050
+ return new Map(entries);
1051
+ }
1052
+ function toReviewFact(review, permissions) {
1053
+ const permission = review.login === void 0 ? void 0 : permissions.get(review.login);
1054
+ return {
1055
+ id: review.id,
1056
+ author: {
1057
+ login: review.login ?? "ghost",
1058
+ type: review.type,
1059
+ permission: permission ?? "none"
1060
+ },
1061
+ commitSha: review.commitSha,
1062
+ state: review.state,
1063
+ body: review.body,
1064
+ submittedAt: review.submittedAt ?? ""
1065
+ };
1066
+ }
1067
+ async function gatherLineageFor(client, pull, reviews, options) {
1068
+ if (options.lineage === void 0 || pull.state !== "open") {
1069
+ return void 0;
1070
+ }
1071
+ try {
1072
+ const baseHeadSha = await client.getBranchHeadSha(pull.baseRef);
1073
+ return await gatherLineage(client, options.lineage.git, {
1074
+ headSha: pull.headSha,
1075
+ baseHeadSha,
1076
+ source: {
1077
+ url: pull.cloneUrl,
1078
+ ...options.lineage.token === void 0 ? {} : { token: options.lineage.token }
1079
+ },
1080
+ reviews
1081
+ });
1082
+ } catch (error) {
1083
+ const message = error instanceof Error ? error.message : String(error);
1084
+ return { cleanAncestors: [], stoppedBecause: `verification failed: ${message}` };
1085
+ }
1086
+ }
1087
+ var IMMEDIATE_MERGE_STATES = /* @__PURE__ */ new Set(["clean", "has_hooks", "unstable"]);
1088
+ async function gatherFacts(client, pr, policy = {}, options = {}) {
1089
+ const [pull, reviews] = await Promise.all([client.getPullRequest(pr), client.listReviews(pr)]);
1090
+ const [permissions, botEligibility, ci, lineage] = await Promise.all([
1091
+ lookupPermissions(client, reviews),
1092
+ gatherBotEligibility(client, pr, pull),
1093
+ gatherCiFacts(client, pull, policy),
1094
+ gatherLineageFor(client, pull, reviews, options)
1095
+ ]);
1096
+ return {
1097
+ nodeId: pull.nodeId,
1098
+ botEligibility,
1099
+ lineage,
1100
+ facts: {
1101
+ status: pull.merged ? "merged" : pull.state,
1102
+ isDraft: pull.draft,
1103
+ title: pull.title,
1104
+ headSha: pull.headSha,
1105
+ labels: pull.labels,
1106
+ autoMergeEnabled: pull.autoMergeEnabled,
1107
+ botEligible: botEligibility.eligible,
1108
+ mergeable: pull.mergeable,
1109
+ immediatelyMergeable: IMMEDIATE_MERGE_STATES.has(pull.mergeState ?? ""),
1110
+ ciStatus: ci.ciStatus,
1111
+ baseCiFailing: ci.baseCiFailing,
1112
+ cleanAncestors: lineage?.cleanAncestors ?? [],
1113
+ reviews: reviews.map((review) => toReviewFact(review, permissions))
1114
+ }
1115
+ };
1116
+ }
1117
+
1118
+ // src/github/reconcile.ts
1119
+ var REFUSE_RELEASE = {
1120
+ enableAutoMerge: () => Promise.reject(new Error("arming needs a release token")),
1121
+ mergePullRequest: () => Promise.reject(new Error("merging needs a release token"))
1122
+ };
1123
+ async function reconcilePullRequest(client, pr, policy, options = {}) {
1124
+ const { facts, nodeId, botEligibility, lineage } = await gatherFacts(client, pr, policy, options);
1125
+ const planned = planReconcile(facts, policy);
1126
+ const unavailable = options.release === "unavailable";
1127
+ const plan = unavailable ? withoutArming(planned) : planned;
1128
+ if (options.dryRun !== true) {
1129
+ const release = options.release === "unavailable" ? REFUSE_RELEASE : options.release ?? client;
1130
+ await executePlan(client, { pr, nodeId, headSha: facts.headSha }, plan, release);
1131
+ }
1132
+ const wanted = planned.autoMerge;
1133
+ const skippedAutoMerge = unavailable && (wanted === "arm" || wanted === "merge") ? wanted : void 0;
1134
+ return { plan, botEligibility, lineage, skippedAutoMerge };
1135
+ }
1136
+
1137
+ // src/github/token-advisory.ts
1138
+ var TOKEN_ADVISORY_MARKER = "<!-- pr-lifecycle:token-missing -->";
1139
+ var RELEASE_TOKEN_DOCS_URL = "https://github.com/rmartz/pr-lifecycle/blob/main/docs/cli.md#release-token";
1140
+ function buildTokenAdvisoryBody(skipped) {
1141
+ const action = skipped === "merge" ? "merged" : "armed for auto-merge";
1142
+ return [
1143
+ TOKEN_ADVISORY_MARKER,
1144
+ "## PR lifecycle: auto-merge skipped",
1145
+ `This PR is approved and would have been **${action}**, but no release token is configured (the action's \`token\` input, or \`PR_LIFECYCLE_TOKEN\` for the CLI).`,
1146
+ "A merge made with `GITHUB_TOKEN` doesn't trigger `on: push` workflows such as releases, so the lifecycle doesn't fall back to it. Labels are still kept up to date; merge this PR by hand, or add the token and re-run.",
1147
+ `To fix it, store a fine-grained PAT with **Contents** and **Pull requests** read/write on this repository as the \`PR_LIFECYCLE_TOKEN\` Actions secret, **and** as a Dependabot secret of the same name if bot PRs are auto-merged (Dependabot-triggered runs only see Dependabot secrets). See ${RELEASE_TOKEN_DOCS_URL}.`,
1148
+ "<sub>pr-lifecycle</sub>"
1149
+ ].join("\n\n");
1150
+ }
1151
+ async function postTokenAdvisory(client, pr, skipped) {
1152
+ const comments = await client.listIssueComments(pr);
1153
+ if (comments.some((body) => body.includes(TOKEN_ADVISORY_MARKER))) {
1154
+ return false;
1155
+ }
1156
+ await client.createIssueComment(pr, buildTokenAdvisoryBody(skipped));
1157
+ return true;
1158
+ }
1159
+
1160
+ // src/cli.ts
1161
+ var USAGE = `Usage: ai-pr-lifecycle <command> [options]
1162
+
1163
+ Commands:
1164
+ reconcile Recompute a PR's lifecycle state and converge its labels
1165
+ help Show this message
1166
+
1167
+ reconcile options:
1168
+ --repo <owner/repo> Repository (required)
1169
+ --pr <number> Pull request number (required)
1170
+ --arm-auto-merge Arm/disarm native auto-merge from the state
1171
+ --trusted-authors <a,b> Only these logins (with write access) may cast verdicts
1172
+ --skip-copilot-review Don't wait for a Copilot review
1173
+ --hold-checks <a,b> Required checks whose pending is a hold (default: pr-policy)
1174
+ --ignore-checks <a,b> Required checks CI never counts (default: merge-safety)
1175
+ --dry-run Compute the plan without writing anything
1176
+ --json Print one JSON object (schemaVersion 1) on stdout
1177
+ --no-token-advisory Don't comment on the PR when arming lacks a release token
1178
+
1179
+ Environment:
1180
+ GITHUB_TOKEN Token for reads and writes (required)
1181
+ PR_LIFECYCLE_TOKEN
1182
+ Real-actor token for arming and merging; without it,
1183
+ --arm-auto-merge keeps labels but skips arm/merge
1184
+ GITHUB_API_URL API base URL (GitHub Enterprise Server)`;
1185
+ async function runCli(argv, io, deps) {
1186
+ const args = parseArgs(argv);
1187
+ switch (args.command) {
1188
+ case "help":
1189
+ io.stdout(USAGE);
1190
+ return 0;
1191
+ case "error":
1192
+ io.stderr(args.message);
1193
+ io.stderr(USAGE);
1194
+ return 2;
1195
+ case "reconcile":
1196
+ break;
1197
+ }
1198
+ const token = deps.env["GITHUB_TOKEN"];
1199
+ if (token === void 0 || token === "") {
1200
+ io.stderr("GITHUB_TOKEN is not set");
1201
+ return 2;
1202
+ }
1203
+ const createClient = deps.createClient ?? createHttpClient;
1204
+ const apiUrl = deps.env["GITHUB_API_URL"];
1205
+ const clientFor = (clientToken) => createClient({
1206
+ token: clientToken,
1207
+ owner: args.owner,
1208
+ repo: args.repo,
1209
+ ...apiUrl === void 0 || apiUrl === "" ? {} : { apiUrl }
1210
+ });
1211
+ const client = clientFor(token);
1212
+ const releaseToken = deps.env["PR_LIFECYCLE_TOKEN"];
1213
+ const hasReleaseToken = releaseToken !== void 0 && releaseToken !== "";
1214
+ const target = { owner: args.owner, repo: args.repo, pr: args.pr, dryRun: args.dryRun };
1215
+ try {
1216
+ const result2 = await reconcilePullRequest(client, args.pr, args.policy, {
1217
+ dryRun: args.dryRun,
1218
+ 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.
1221
+ release: hasReleaseToken ? clientFor(releaseToken) : "unavailable"
1222
+ });
1223
+ if (result2.skippedAutoMerge !== void 0) {
1224
+ io.stderr(
1225
+ `warning: ${args.owner}/${args.repo}#${args.pr} is approved, but auto-merge ${result2.skippedAutoMerge} was skipped: PR_LIFECYCLE_TOKEN is not set`
1226
+ );
1227
+ if (args.tokenAdvisory && !args.dryRun) {
1228
+ await adviseMissingToken(client, args.pr, result2.skippedAutoMerge, io);
1229
+ }
1230
+ }
1231
+ if (args.json) {
1232
+ io.stdout(JSON.stringify(toReconcileJson(target, result2)));
1233
+ } else {
1234
+ io.stdout(formatSummary(target, result2));
1235
+ }
1236
+ return 0;
1237
+ } catch (error) {
1238
+ const message = error instanceof Error ? error.message : String(error);
1239
+ io.stderr(`reconcile failed for ${args.owner}/${args.repo}#${args.pr}: ${message}`);
1240
+ return 1;
1241
+ }
1242
+ }
1243
+ async function adviseMissingToken(client, pr, skipped, io) {
1244
+ try {
1245
+ await postTokenAdvisory(client, pr, skipped);
1246
+ } catch (error) {
1247
+ const message = error instanceof Error ? error.message : String(error);
1248
+ io.stderr(`warning: could not post the missing-token advisory: ${message}`);
1249
+ }
1250
+ }
1251
+
1252
+ export {
1253
+ DEPENDABOT_LOGIN,
1254
+ RELEASE_PLEASE_BRANCH_PREFIX,
1255
+ RELEASE_PLEASE_PENDING_LABEL,
1256
+ DEPENDABOT_UPDATE_TYPES,
1257
+ parseDependabotUpdateType,
1258
+ classifyBotPr,
1259
+ parseArgs,
1260
+ SCHEMA_VERSION,
1261
+ toReconcileJson,
1262
+ formatSummary,
1263
+ GitHubApiError,
1264
+ isApiStatus,
1265
+ REPO_PERMISSIONS,
1266
+ ACTOR_TYPES,
1267
+ REVIEW_STATES,
1268
+ PR_STATUSES,
1269
+ createHttpClient,
1270
+ VERDICTS,
1271
+ parseVerdict,
1272
+ isTrustedAuthor,
1273
+ currentVerdict,
1274
+ LIFECYCLE_STATES,
1275
+ COPILOT_REVIEWER_LOGIN,
1276
+ computeState,
1277
+ LIFECYCLE_LABELS,
1278
+ AUTO_MERGE_LABEL,
1279
+ planReconcile,
1280
+ withoutArming,
1281
+ OWNED_LABEL_DEFINITIONS,
1282
+ executePlan,
1283
+ createGitRunner,
1284
+ parseGitVersion,
1285
+ createMergeVerifier,
1286
+ MAX_CHAIN_STEPS,
1287
+ gatherLineage,
1288
+ gatherFacts,
1289
+ reconcilePullRequest,
1290
+ TOKEN_ADVISORY_MARKER,
1291
+ RELEASE_TOKEN_DOCS_URL,
1292
+ buildTokenAdvisoryBody,
1293
+ postTokenAdvisory,
1294
+ USAGE,
1295
+ runCli
1296
+ };