merge-steward 0.35.3 → 0.35.4

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.
@@ -33,6 +33,13 @@ export declare class GitHubPRClient implements GitHubPRApi {
33
33
  headSha: string;
34
34
  baseBranch: string;
35
35
  }>>;
36
+ listOpenPRsByBase(baseBranch: string): Promise<Array<{
37
+ number: number;
38
+ branch: string;
39
+ headSha: string;
40
+ baseBranch: string;
41
+ }>>;
42
+ setBaseBranch(prNumber: number, baseBranch: string): Promise<void>;
36
43
  deleteBranch(prNumber: number): Promise<void>;
37
44
  findPRByBranch(branch: string): Promise<number | null>;
38
45
  listLabels(prNumber: number): Promise<string[]>;
@@ -117,6 +117,35 @@ export class GitHubPRClient {
117
117
  throw new Error("GitHub returned malformed open PR data", { cause: error });
118
118
  }
119
119
  }
120
+ async listOpenPRsByBase(baseBranch) {
121
+ const result = await exec("gh", [
122
+ "api", "--method", "GET",
123
+ `repos/${this.repoFullName}/pulls`,
124
+ "-f", "state=open",
125
+ "-f", `base=${baseBranch}`,
126
+ "-f", "per_page=100",
127
+ "--paginate", "--slurp",
128
+ ], { githubRepoFullName: this.repoFullName });
129
+ try {
130
+ const pages = JSON.parse(result.stdout);
131
+ return pages.flat().map((pr) => ({
132
+ number: pr.number,
133
+ branch: pr.head.ref,
134
+ headSha: pr.head.sha,
135
+ baseBranch: pr.base.ref,
136
+ }));
137
+ }
138
+ catch (error) {
139
+ throw new Error("GitHub returned malformed stack child PR data", { cause: error });
140
+ }
141
+ }
142
+ async setBaseBranch(prNumber, baseBranch) {
143
+ await exec("gh", [
144
+ "api", "--method", "PATCH",
145
+ `repos/${this.repoFullName}/pulls/${prNumber}`,
146
+ "-f", `base=${baseBranch}`,
147
+ ], { githubRepoFullName: this.repoFullName });
148
+ }
120
149
  async deleteBranch(prNumber) {
121
150
  const status = await this.getStatus(prNumber);
122
151
  await exec("gh", [
@@ -57,8 +57,17 @@ export interface GitHubPRApi {
57
57
  headSha: string;
58
58
  baseBranch: string;
59
59
  }>>;
60
+ /** List every open PR whose current base is the given branch. */
61
+ listOpenPRsByBase(baseBranch: string): Promise<Array<{
62
+ number: number;
63
+ branch: string;
64
+ headSha: string;
65
+ baseBranch: string;
66
+ }>>;
60
67
  /** Find the open PR number for a branch, or null if none exists. */
61
68
  findPRByBranch(branch: string): Promise<number | null>;
69
+ /** Retarget a PR before deleting a branch used as its current base. */
70
+ setBaseBranch(prNumber: number, baseBranch: string): Promise<void>;
62
71
  /** Delete the PR's head branch from the remote (best-effort cleanup). */
63
72
  deleteBranch(prNumber: number): Promise<void>;
64
73
  /** List open PRs with a specific label (for startup scan). */
@@ -418,15 +418,35 @@ export async function deletePrBranchAfterGitHubMarksMerged(ctx, entry, options =
418
418
  const attempts = options.attempts ?? DEFAULT_PR_MERGED_POLL_ATTEMPTS;
419
419
  const delayMs = options.delayMs ?? DEFAULT_PR_MERGED_POLL_DELAY_MS;
420
420
  for (let attempt = 0; attempt < attempts; attempt++) {
421
- let merged = false;
421
+ let mergedStatus = null;
422
422
  try {
423
- merged = (await ctx.github.getStatus(entry.prNumber)).merged;
423
+ const status = await ctx.github.getStatus(entry.prNumber);
424
+ if (status.merged)
425
+ mergedStatus = status;
424
426
  }
425
427
  catch {
426
428
  // Keep polling briefly. If GitHub is unavailable, branch cleanup is
427
429
  // cosmetic; preserving correct PR merge classification matters more.
428
430
  }
429
- if (merged) {
431
+ if (mergedStatus) {
432
+ let children;
433
+ try {
434
+ children = await ctx.github.listOpenPRsByBase(mergedStatus.branch);
435
+ for (const child of children) {
436
+ await ctx.github.setBaseBranch(child.number, ctx.baseBranch);
437
+ }
438
+ }
439
+ catch {
440
+ emit(ctx, entry, "pr_branch_cleanup_deferred", {
441
+ detail: "preserving the merged PR branch because stacked children could not be safely retargeted",
442
+ });
443
+ return;
444
+ }
445
+ if (children.length > 0) {
446
+ emit(ctx, entry, "stack_children_retargeted", {
447
+ detail: `retargeted child PR ${children.map((child) => `#${child.number}`).join(", ")} to ${ctx.baseBranch} before deleting ${mergedStatus.branch}`,
448
+ });
449
+ }
430
450
  try {
431
451
  await ctx.github.deleteBranch(entry.prNumber);
432
452
  }
package/dist/types.d.ts CHANGED
@@ -243,7 +243,7 @@ export interface QueueConfig {
243
243
  flakyRetries: number;
244
244
  pollIntervalMs: number;
245
245
  }
246
- export type ReconcileAction = "promoted" | "fetch_started" | "main_broken" | "branch_mismatch" | "candidate_selected" | "integration_build_started" | "integration_build_succeeded" | "integration_build_conflict" | "ci_triggered" | "ci_pending" | "ci_passed" | "ci_failed" | "ci_flaky_retry" | "merge_revalidating" | "head_candidate_landed" | "merge_succeeded" | "merge_rejected" | "merge_external" | "policy_changed" | "evicted" | "invalidated" | "retry_gated" | "budget_exhausted" | "merge_waiting_approval" | "open_pr_ancestry_blocked" | "stack_dependency_waiting" | "merge_waiting_recognition" | "merge_waiting_main" | "main_pending_bypassed" | "pr_branch_cleanup_deferred" | "pr_branch_cleanup_failed" | "sanitized_closed" | "sanitized_duplicate" | "post_merge_verification_started" | "post_merge_verification_completed" | "queue_label_synced" | "branch_unreachable";
246
+ export type ReconcileAction = "promoted" | "fetch_started" | "main_broken" | "branch_mismatch" | "candidate_selected" | "integration_build_started" | "integration_build_succeeded" | "integration_build_conflict" | "ci_triggered" | "ci_pending" | "ci_passed" | "ci_failed" | "ci_flaky_retry" | "merge_revalidating" | "head_candidate_landed" | "merge_succeeded" | "merge_rejected" | "merge_external" | "policy_changed" | "evicted" | "invalidated" | "retry_gated" | "budget_exhausted" | "merge_waiting_approval" | "open_pr_ancestry_blocked" | "stack_dependency_waiting" | "merge_waiting_recognition" | "merge_waiting_main" | "main_pending_bypassed" | "pr_branch_cleanup_deferred" | "pr_branch_cleanup_failed" | "stack_children_retargeted" | "sanitized_closed" | "sanitized_duplicate" | "post_merge_verification_started" | "post_merge_verification_completed" | "queue_label_synced" | "branch_unreachable";
247
247
  export interface ReconcileEvent {
248
248
  at: string;
249
249
  entryId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.35.3",
3
+ "version": "0.35.4",
4
4
  "description": "Serial merge queue for GitHub — rebase, CI-gate, and merge PRs one at a time",
5
5
  "type": "module",
6
6
  "repository": {