@kungfu-tech/buildchain 2.14.1 → 2.14.2-alpha.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.
@@ -7,6 +7,15 @@ const DEFAULT_ALLOWED_HEAD_PREFIXES = ["feature/", "fix/", "chore/", "docs/", "c
7
7
  const DEFAULT_REQUIRED_CHECKS = ["check"];
8
8
  const SUCCESS_STATES = new Set(["success"]);
9
9
  const SUCCESS_CONCLUSIONS = new Set(["success", "neutral", "skipped"]);
10
+ const VALID_LANDING_MODES = new Set(["auto", "direct", "queue"]);
11
+ const STATIC_SKIP_REASONS = new Set([
12
+ "draft",
13
+ "fork-or-cross-repository-head",
14
+ "head-prefix-not-allowed",
15
+ "missing-ready-label",
16
+ "blocked-label",
17
+ ]);
18
+ const ADMISSION_CONTRACT = "kungfu-buildchain-dev-merge-queue-admission";
10
19
 
11
20
  function splitList(value, fallback = []) {
12
21
  if (Array.isArray(value)) return value.map((entry) => String(entry).trim()).filter(Boolean);
@@ -30,8 +39,16 @@ function intOption(value, fallback) {
30
39
  return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : fallback;
31
40
  }
32
41
 
42
+ function choiceOption(value, valid, fallback, field) {
43
+ const normalized = String(value || fallback).trim().toLowerCase();
44
+ if (!valid.has(normalized)) {
45
+ throw new Error(`${field} must be one of ${[...valid].join(", ")}, got: ${value || "<empty>"}`);
46
+ }
47
+ return normalized;
48
+ }
49
+
33
50
  function normalizeRepo(value) {
34
- const text = String(value || "").trim();
51
+ const text = String(value?.fullName || value || "").trim();
35
52
  const match = text.match(/^([^/\s]+)\/([^/\s]+)$/);
36
53
  if (!match) throw new Error(`repository must be owner/repo, got: ${text || "<empty>"}`);
37
54
  return { owner: match[1], repo: match[2], fullName: `${match[1]}/${match[2]}` };
@@ -49,6 +66,7 @@ function normalizeOptions(options = {}) {
49
66
  sameRepositoryOnly: boolOption(options.sameRepositoryOnly, true),
50
67
  maxMerges: intOption(options.maxMerges, 1),
51
68
  mergeMethod: String(options.mergeMethod || "merge").trim(),
69
+ landingMode: choiceOption(options.landingMode, VALID_LANDING_MODES, "auto", "landing mode"),
52
70
  dryRun: boolOption(options.dryRun, true),
53
71
  pollMergeableAttempts: intOption(options.pollMergeableAttempts, 3),
54
72
  pollMergeableDelayMs: intOption(options.pollMergeableDelayMs, 1000),
@@ -165,6 +183,12 @@ export async function evaluatePullRequest(pr, options, client) {
165
183
  attempts: options.pollMergeableAttempts,
166
184
  delayMs: options.pollMergeableDelayMs,
167
185
  });
186
+ if (detailed.base?.ref && detailed.base.ref !== options.targetBranch) {
187
+ return skip("base-branch-drift", {
188
+ expectedBaseRef: options.targetBranch,
189
+ observedBaseRef: detailed.base.ref,
190
+ });
191
+ }
168
192
  if (!mergeableAccepted(detailed)) {
169
193
  return skip("not-mergeable", {
170
194
  mergeable: detailed.mergeable,
@@ -172,19 +196,25 @@ export async function evaluatePullRequest(pr, options, client) {
172
196
  });
173
197
  }
174
198
 
199
+ const approval = { required: options.requireApproval, passed: true };
175
200
  if (options.requireApproval) {
176
201
  const reviews = await client.listReviews(pr.number);
177
- if (!hasRequiredApproval(reviews)) return skip("missing-approval");
202
+ approval.passed = hasRequiredApproval(reviews);
203
+ if (!approval.passed) return skip("missing-approval", { approval });
178
204
  }
179
205
 
180
- const checks = await client.listCommitChecks(pr.head?.sha);
206
+ const observedHeadSha = detailed.head?.sha || pr.head?.sha || "";
207
+ const checks = await client.listCommitChecks(observedHeadSha);
181
208
  const checkSummary = summarizeChecks(checks, options.requiredChecks);
182
- if (!checkSummary.passed) return skip("required-checks-not-passing", { checks: checkSummary });
209
+ if (!checkSummary.passed) return skip("required-checks-not-passing", { checks: checkSummary, approval });
183
210
 
184
211
  return {
185
212
  action: options.dryRun ? "would-merge" : "merge",
186
213
  reason: options.dryRun ? "dry-run" : "eligible",
187
214
  checks: checkSummary,
215
+ approval,
216
+ pullRequestId: detailed.node_id || pr.node_id || "",
217
+ observedHeadSha,
188
218
  };
189
219
  }
190
220
 
@@ -302,6 +332,178 @@ export class GitHubClient {
302
332
  const { data } = await this.request("GET", `/repos/${this.repository.owner}/${this.repository.repo}/git/ref/${ref}`);
303
333
  return data.object?.sha || "";
304
334
  }
335
+
336
+ async graphql(query, variables = {}) {
337
+ const { data } = await this.request("POST", "/graphql", {
338
+ body: { query, variables },
339
+ });
340
+ if (Array.isArray(data?.errors) && data.errors.length > 0) {
341
+ const error = new Error(data.errors.map((entry) => entry.message).filter(Boolean).join("; ") || "GitHub GraphQL request failed");
342
+ error.data = data;
343
+ throw error;
344
+ }
345
+ return data?.data || {};
346
+ }
347
+
348
+ async getMergeQueueState(branch) {
349
+ const data = await this.graphql(
350
+ `query BuildchainMergeQueueState($owner: String!, $repo: String!, $branch: String!) {
351
+ repository(owner: $owner, name: $repo) {
352
+ mergeQueue(branch: $branch) {
353
+ id
354
+ entries(first: 100) {
355
+ nodes {
356
+ id
357
+ position
358
+ state
359
+ baseCommit { oid }
360
+ headCommit { oid }
361
+ pullRequest { number headRefOid }
362
+ }
363
+ }
364
+ }
365
+ }
366
+ }`,
367
+ {
368
+ owner: this.repository.owner,
369
+ repo: this.repository.repo,
370
+ branch,
371
+ },
372
+ );
373
+ const queue = data.repository?.mergeQueue || null;
374
+ return {
375
+ enabled: Boolean(queue),
376
+ id: queue?.id || "",
377
+ entries: (queue?.entries?.nodes || []).map((entry) => ({
378
+ id: entry.id || "",
379
+ position: entry.position,
380
+ state: entry.state || "",
381
+ pullRequestNumber: entry.pullRequest?.number || null,
382
+ pullRequestHeadSha: entry.pullRequest?.headRefOid || "",
383
+ baseSha: entry.baseCommit?.oid || "",
384
+ headSha: entry.headCommit?.oid || "",
385
+ })),
386
+ };
387
+ }
388
+
389
+ async enqueuePullRequest({ pullRequestId, expectedHeadOid }) {
390
+ const data = await this.graphql(
391
+ `mutation BuildchainEnqueuePullRequest($input: EnqueuePullRequestInput!) {
392
+ enqueuePullRequest(input: $input) {
393
+ mergeQueueEntry {
394
+ id
395
+ position
396
+ state
397
+ baseCommit { oid }
398
+ headCommit { oid }
399
+ pullRequest { number headRefOid }
400
+ }
401
+ }
402
+ }`,
403
+ {
404
+ input: {
405
+ pullRequestId,
406
+ expectedHeadOid,
407
+ },
408
+ },
409
+ );
410
+ const entry = data.enqueuePullRequest?.mergeQueueEntry;
411
+ if (!entry?.id) throw new Error("GitHub did not return a merge queue entry");
412
+ return {
413
+ id: entry.id,
414
+ position: entry.position,
415
+ state: entry.state || "",
416
+ pullRequestNumber: entry.pullRequest?.number || null,
417
+ pullRequestHeadSha: entry.pullRequest?.headRefOid || "",
418
+ baseSha: entry.baseCommit?.oid || "",
419
+ headSha: entry.headCommit?.oid || "",
420
+ };
421
+ }
422
+ }
423
+
424
+ function queuePredecessor(queueState, fallback = {}) {
425
+ const entry = queueState?.entries?.[0];
426
+ if (entry) {
427
+ return {
428
+ queueEntryId: entry.id,
429
+ pullRequestNumber: entry.pullRequestNumber,
430
+ headSha: entry.pullRequestHeadSha || entry.headSha || "",
431
+ state: entry.state || "",
432
+ };
433
+ }
434
+ return {
435
+ queueEntryId: fallback.queueEntryId || "",
436
+ pullRequestNumber: fallback.pullRequestNumber || null,
437
+ headSha: fallback.headSha || "",
438
+ state: fallback.state || "",
439
+ };
440
+ }
441
+
442
+ function admissionReceipt({
443
+ options,
444
+ pr,
445
+ expectedBaseSha,
446
+ observedBaseSha,
447
+ expectedHeadSha,
448
+ observedHeadSha,
449
+ decision,
450
+ reason,
451
+ checks,
452
+ approval,
453
+ predecessor,
454
+ } = {}) {
455
+ return {
456
+ schemaVersion: 1,
457
+ contract: ADMISSION_CONTRACT,
458
+ repository: options.repository.fullName,
459
+ targetBranch: options.targetBranch,
460
+ pullRequestNumber: pr.number,
461
+ expectedBaseSha: expectedBaseSha || "",
462
+ observedBaseSha: observedBaseSha || "",
463
+ expectedHeadSha: expectedHeadSha || "",
464
+ observedHeadSha: observedHeadSha || "",
465
+ approvalRequired: options.requireApproval,
466
+ approval: approval || { required: options.requireApproval, passed: false },
467
+ checks: checks || { required: options.requiredChecks, entries: [], passed: false },
468
+ decision,
469
+ reason,
470
+ predecessor: predecessor || null,
471
+ finalSafetyBoundary: "github-merge-group",
472
+ };
473
+ }
474
+
475
+ function evaluatedEntry(pr, decision) {
476
+ return {
477
+ number: pr.number,
478
+ title: pr.title || "",
479
+ headRef: pr.head?.ref || "",
480
+ headSha: pr.head?.sha || "",
481
+ action: decision.action,
482
+ reason: decision.reason,
483
+ checks: decision.checks,
484
+ };
485
+ }
486
+
487
+ function blockRemainingPullRequests(result, pullRequests, startIndex, options, expectedBaseSha, predecessor) {
488
+ for (const pr of pullRequests.slice(startIndex)) {
489
+ const entry = evaluatedEntry(pr, {
490
+ action: "skip",
491
+ reason: "blocked-by-predecessor",
492
+ });
493
+ entry.admissionReceipt = admissionReceipt({
494
+ options,
495
+ pr,
496
+ expectedBaseSha,
497
+ observedBaseSha: expectedBaseSha,
498
+ expectedHeadSha: pr.head?.sha || "",
499
+ observedHeadSha: pr.head?.sha || "",
500
+ decision: "blocked",
501
+ reason: "blocked-by-predecessor",
502
+ predecessor,
503
+ });
504
+ result.evaluated.push(entry);
505
+ result.skipped.push(entry);
506
+ }
305
507
  }
306
508
 
307
509
  export async function runDevPrAutoMerge(optionsInput = {}, clientInput) {
@@ -313,21 +515,51 @@ export async function runDevPrAutoMerge(optionsInput = {}, clientInput) {
313
515
  apiUrl: optionsInput.apiUrl || process.env.GITHUB_API_URL || "https://api.github.com",
314
516
  });
315
517
 
316
- const pullRequests = await client.listPullRequests(options.targetBranch);
518
+ const [pullRequests, initialBaseSha, initialQueueState] = await Promise.all([
519
+ client.listPullRequests(options.targetBranch),
520
+ client.getBranchSha(options.targetBranch).catch(() => ""),
521
+ client.getMergeQueueState(options.targetBranch),
522
+ ]);
523
+ const landingMode = initialQueueState.enabled ? "queue" : options.landingMode === "queue" ? "queue" : "direct";
524
+ const orderedPullRequests = landingMode === "queue"
525
+ ? [...pullRequests].sort((left, right) => Number(left.number) - Number(right.number))
526
+ : pullRequests;
317
527
  const result = {
318
528
  schemaVersion: 1,
319
529
  contract: "kungfu-buildchain-dev-pr-auto-merge",
320
530
  repository: options.repository.fullName,
321
531
  targetBranch: options.targetBranch,
532
+ requestedLandingMode: options.landingMode,
533
+ landingMode,
322
534
  dryRun: options.dryRun,
323
535
  maxMerges: options.maxMerges,
324
536
  evaluated: [],
537
+ actions: [],
325
538
  merged: [],
539
+ enqueued: [],
326
540
  skipped: [],
327
- finalBaseSha: "",
541
+ initialBaseSha,
542
+ finalBaseSha: initialBaseSha,
543
+ mergeQueue: initialQueueState,
328
544
  };
329
545
 
330
- for (const pr of pullRequests) {
546
+ if (landingMode === "queue" && !initialQueueState.enabled) {
547
+ blockRemainingPullRequests(result, orderedPullRequests, 0, options, initialBaseSha, null);
548
+ for (const entry of result.evaluated) {
549
+ entry.reason = "merge-queue-not-enabled";
550
+ entry.admissionReceipt.reason = "merge-queue-not-enabled";
551
+ entry.admissionReceipt.decision = "rejected";
552
+ }
553
+ return result;
554
+ }
555
+
556
+ if (landingMode === "queue" && initialQueueState.entries.length > 0) {
557
+ blockRemainingPullRequests(result, orderedPullRequests, 0, options, initialBaseSha, queuePredecessor(initialQueueState));
558
+ return result;
559
+ }
560
+
561
+ for (let index = 0; index < orderedPullRequests.length; index += 1) {
562
+ const pr = orderedPullRequests[index];
331
563
  if (result.merged.length >= options.maxMerges) {
332
564
  const entry = { number: pr.number, title: pr.title || "", action: "skip", reason: "max-merges-reached" };
333
565
  result.evaluated.push(entry);
@@ -336,29 +568,134 @@ export async function runDevPrAutoMerge(optionsInput = {}, clientInput) {
336
568
  }
337
569
 
338
570
  const decision = await evaluatePullRequest(pr, options, client);
339
- const entry = {
340
- number: pr.number,
341
- title: pr.title || "",
342
- headRef: pr.head?.ref || "",
343
- headSha: pr.head?.sha || "",
344
- action: decision.action,
345
- reason: decision.reason,
346
- checks: decision.checks,
347
- };
571
+ const entry = evaluatedEntry(pr, decision);
348
572
  result.evaluated.push(entry);
349
573
 
350
- if (decision.action === "merge") {
574
+ if (landingMode === "direct" && decision.action === "merge") {
351
575
  const mergeResult = await client.mergePullRequest(pr.number, {
352
576
  method: options.mergeMethod,
353
577
  sha: pr.head?.sha,
354
578
  });
355
579
  const mergedEntry = { ...entry, mergeSha: mergeResult.sha || "" };
356
580
  result.merged.push(mergedEntry);
581
+ result.actions.push(mergedEntry);
357
582
  result.evaluated[result.evaluated.length - 1] = mergedEntry;
358
- } else if (decision.action === "would-merge") {
583
+ } else if (landingMode === "direct" && decision.action === "would-merge") {
359
584
  result.merged.push(entry);
360
- } else {
585
+ result.actions.push(entry);
586
+ } else if (landingMode === "direct" || STATIC_SKIP_REASONS.has(decision.reason)) {
587
+ result.skipped.push(entry);
588
+ } else if (decision.action === "skip") {
589
+ entry.admissionReceipt = admissionReceipt({
590
+ options,
591
+ pr,
592
+ expectedBaseSha: initialBaseSha,
593
+ observedBaseSha: initialBaseSha,
594
+ expectedHeadSha: decision.observedHeadSha || pr.head?.sha || "",
595
+ observedHeadSha: decision.observedHeadSha || pr.head?.sha || "",
596
+ decision: "rejected",
597
+ reason: decision.reason,
598
+ checks: decision.checks,
599
+ approval: decision.approval,
600
+ });
361
601
  result.skipped.push(entry);
602
+ blockRemainingPullRequests(result, orderedPullRequests, index + 1, options, initialBaseSha, queuePredecessor(null, {
603
+ pullRequestNumber: pr.number,
604
+ headSha: decision.observedHeadSha || pr.head?.sha || "",
605
+ state: "ADMISSION_REJECTED",
606
+ }));
607
+ break;
608
+ } else {
609
+ const expectedHeadSha = decision.observedHeadSha || pr.head?.sha || "";
610
+ const [observedPullRequest, observedBaseSha, observedQueueState] = await Promise.all([
611
+ client.getPullRequest(pr.number, {
612
+ attempts: options.pollMergeableAttempts,
613
+ delayMs: options.pollMergeableDelayMs,
614
+ }),
615
+ client.getBranchSha(options.targetBranch),
616
+ client.getMergeQueueState(options.targetBranch),
617
+ ]);
618
+ const observedHeadSha = observedPullRequest.head?.sha || "";
619
+ let admissionDecision = options.dryRun ? "planned" : "accepted";
620
+ let admissionReason = options.dryRun ? "dry-run" : "eligible";
621
+ let predecessor = null;
622
+ if (observedQueueState.entries.length > 0) {
623
+ admissionDecision = "blocked";
624
+ admissionReason = "blocked-by-predecessor";
625
+ predecessor = queuePredecessor(observedQueueState);
626
+ } else if (observedBaseSha !== initialBaseSha) {
627
+ admissionDecision = "rejected";
628
+ admissionReason = "base-sha-drift";
629
+ } else if (observedHeadSha !== expectedHeadSha) {
630
+ admissionDecision = "rejected";
631
+ admissionReason = "head-sha-drift";
632
+ } else if (!mergeableAccepted(observedPullRequest)) {
633
+ admissionDecision = "rejected";
634
+ admissionReason = "not-mergeable-on-admission-recheck";
635
+ }
636
+
637
+ entry.action = admissionDecision === "planned" ? "would-enqueue" : admissionDecision === "accepted" ? "enqueue" : "skip";
638
+ entry.reason = admissionReason;
639
+ entry.headSha = expectedHeadSha;
640
+ entry.admissionReceipt = admissionReceipt({
641
+ options,
642
+ pr,
643
+ expectedBaseSha: initialBaseSha,
644
+ observedBaseSha,
645
+ expectedHeadSha,
646
+ observedHeadSha,
647
+ decision: admissionDecision,
648
+ reason: admissionReason,
649
+ checks: decision.checks,
650
+ approval: decision.approval,
651
+ predecessor,
652
+ });
653
+
654
+ if (admissionDecision === "planned") {
655
+ result.actions.push(entry);
656
+ } else if (admissionDecision === "accepted") {
657
+ if (!decision.pullRequestId) {
658
+ entry.action = "skip";
659
+ entry.reason = "missing-pull-request-node-id";
660
+ entry.admissionReceipt.decision = "rejected";
661
+ entry.admissionReceipt.reason = entry.reason;
662
+ result.skipped.push(entry);
663
+ } else {
664
+ try {
665
+ const queueEntry = await client.enqueuePullRequest({
666
+ pullRequestId: decision.pullRequestId,
667
+ expectedHeadOid: expectedHeadSha,
668
+ });
669
+ entry.action = "enqueued";
670
+ entry.reason = "enqueued-with-expected-head";
671
+ entry.queueEntry = queueEntry;
672
+ entry.admissionReceipt.reason = entry.reason;
673
+ result.actions.push(entry);
674
+ result.enqueued.push(entry);
675
+ } catch (error) {
676
+ entry.action = "skip";
677
+ entry.reason = "enqueue-rejected";
678
+ entry.enqueueError = {
679
+ status: error.status || null,
680
+ message: error.message || "GitHub rejected merge queue admission",
681
+ };
682
+ entry.admissionReceipt.decision = "rejected";
683
+ entry.admissionReceipt.reason = entry.reason;
684
+ result.skipped.push(entry);
685
+ }
686
+ }
687
+ } else {
688
+ result.skipped.push(entry);
689
+ }
690
+
691
+ const activePredecessor = queuePredecessor(null, {
692
+ queueEntryId: entry.queueEntry?.id || predecessor?.queueEntryId || "",
693
+ pullRequestNumber: pr.number,
694
+ headSha: expectedHeadSha,
695
+ state: entry.queueEntry?.state || (admissionDecision === "planned" ? "ADMISSION_PLANNED" : "ADMISSION_REJECTED"),
696
+ });
697
+ blockRemainingPullRequests(result, orderedPullRequests, index + 1, options, observedBaseSha || initialBaseSha, activePredecessor);
698
+ break;
362
699
  }
363
700
  }
364
701
 
@@ -372,9 +709,10 @@ export function renderMarkdownSummary(result) {
372
709
  "",
373
710
  `Repository: \`${result.repository}\``,
374
711
  `Target branch: \`${result.targetBranch}\``,
375
- `Mode: \`${result.dryRun ? "dry-run" : "merge"}\``,
712
+ `Landing mode: \`${result.landingMode}\``,
713
+ `Execution: \`${result.dryRun ? "dry-run" : "apply"}\``,
376
714
  `Evaluated PRs: ${result.evaluated.length}`,
377
- `Eligible ${result.dryRun ? "dry-run" : "merged"} PRs: ${result.merged.length}`,
715
+ `Actions ${result.dryRun ? "planned" : "taken"}: ${result.actions.length}`,
378
716
  "",
379
717
  "| PR | Action | Reason | Head |",
380
718
  "| --- | --- | --- | --- |",
@@ -410,6 +748,7 @@ async function main() {
410
748
  sameRepositoryOnly: process.env.BUILDCHAIN_DEV_PR_SAME_REPOSITORY_ONLY,
411
749
  maxMerges: process.env.BUILDCHAIN_DEV_PR_MAX_MERGES,
412
750
  mergeMethod: process.env.BUILDCHAIN_DEV_PR_MERGE_METHOD,
751
+ landingMode: process.env.BUILDCHAIN_DEV_PR_LANDING_MODE,
413
752
  dryRun: process.env.BUILDCHAIN_DEV_PR_DRY_RUN,
414
753
  outputPath: process.env.BUILDCHAIN_DEV_PR_OUTPUT_PATH,
415
754
  });
@@ -422,6 +761,8 @@ async function main() {
422
761
  writeGitHubOutputs({
423
762
  "evaluated-count": result.evaluated.length,
424
763
  "merged-count": result.merged.length,
764
+ "enqueued-count": result.enqueued.length,
765
+ "action-count": result.actions.length,
425
766
  "skipped-count": result.skipped.length,
426
767
  "final-base-sha": result.finalBaseSha,
427
768
  "result-path": options.outputPath,