merge-steward 0.26.1 → 0.26.2

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.
@@ -32,11 +32,20 @@ export async function evictEntry(ctx, entry, failureClass, extra) {
32
32
  const retryHistory = [];
33
33
  for (const event of events) {
34
34
  const eventBaseSha = event.baseSha || "unknown";
35
+ const detail = event.detail ?? "";
35
36
  if (event.fromStatus === "preparing_head" && event.toStatus === "validating") {
36
37
  retryHistory.push({ at: event.at, baseSha: eventBaseSha, outcome: "passed_to_validation" });
37
38
  }
38
39
  else if (event.fromStatus === "validating" && event.toStatus === "preparing_head") {
39
- retryHistory.push({ at: event.at, baseSha: eventBaseSha, outcome: "ci_failed_retry" });
40
+ const outcome = detail.startsWith("invalidated:")
41
+ ? "invalidated"
42
+ : detail.toLowerCase().includes("ci failed")
43
+ ? "ci_failed_retry"
44
+ : "validation_reset";
45
+ retryHistory.push({ at: event.at, baseSha: eventBaseSha, outcome });
46
+ }
47
+ else if (event.fromStatus === "merging" && event.toStatus === "preparing_head") {
48
+ retryHistory.push({ at: event.at, baseSha: eventBaseSha, outcome: "push_failed_retry" });
40
49
  }
41
50
  else if (event.fromStatus === "preparing_head" && event.toStatus === "preparing_head") {
42
51
  retryHistory.push({ at: event.at, baseSha: eventBaseSha, outcome: "conflict_retry" });
@@ -7,6 +7,136 @@ const DEFAULT_PR_MERGED_POLL_DELAY_MS = 2_000;
7
7
  function delay(ms) {
8
8
  return new Promise((resolve) => setTimeout(resolve, ms));
9
9
  }
10
+ function shortSha(sha) {
11
+ return sha ? sha.slice(0, 8) : "unknown";
12
+ }
13
+ function sanitizeCommandOutput(value) {
14
+ return value
15
+ .replace(/x-access-token:[^@\s]+@/g, "x-access-token:[redacted]@")
16
+ .replace(/\bgh[psu]_[A-Za-z0-9_]+\b/g, "[redacted-token]")
17
+ .replace(/\s+/g, " ")
18
+ .trim()
19
+ .slice(0, 1200);
20
+ }
21
+ function describeError(error) {
22
+ if (!(error instanceof Error)) {
23
+ return sanitizeCommandOutput(String(error));
24
+ }
25
+ const failure = error;
26
+ const parts = [
27
+ failure.stderr,
28
+ failure.stdout,
29
+ error.message,
30
+ typeof failure.exitCode === "number" ? `exit code ${failure.exitCode}` : undefined,
31
+ failure.signal ? `signal ${failure.signal}` : undefined,
32
+ ].filter((part) => Boolean(part && part.trim()));
33
+ return sanitizeCommandOutput(parts.join(" "));
34
+ }
35
+ function classifyPushFailure(error, detail) {
36
+ const failure = error instanceof Error ? error : undefined;
37
+ const lower = detail.toLowerCase();
38
+ if (failure?.timedOut || lower.includes("timed out")) {
39
+ return "timeout";
40
+ }
41
+ if (lower.includes("refusing to allow a github app to create or update workflow")) {
42
+ return "workflow_permission";
43
+ }
44
+ if (lower.includes("non-fast-forward")
45
+ || lower.includes("fetch first")
46
+ || lower.includes("stale info")
47
+ || lower.includes("cannot lock ref")) {
48
+ return "non_fast_forward";
49
+ }
50
+ if (lower.includes("protected branch")
51
+ || lower.includes("gh006")
52
+ || lower.includes("required status check")
53
+ || lower.includes("required approving review")
54
+ || lower.includes("changes must be made through a pull request")) {
55
+ return "protected_branch";
56
+ }
57
+ if (lower.includes("authentication failed")
58
+ || lower.includes("permission denied")
59
+ || lower.includes("write access")
60
+ || lower.includes("403")
61
+ || lower.includes("not authorized")) {
62
+ return "auth_or_permission";
63
+ }
64
+ return "github_push_rejected";
65
+ }
66
+ function normalizeCheckName(name) {
67
+ return name.trim().toLowerCase();
68
+ }
69
+ function getMissingRequiredChecks(requiredChecks, checks) {
70
+ if (requiredChecks.length === 0) {
71
+ return [];
72
+ }
73
+ const available = new Set(checks.map((check) => normalizeCheckName(check.name)).filter(Boolean));
74
+ return requiredChecks.filter((check) => !available.has(normalizeCheckName(check)));
75
+ }
76
+ function summarizeChecks(checks, missingRequiredChecks) {
77
+ const visible = checks
78
+ .filter((check) => check.name.trim())
79
+ .map((check) => `${check.name}=${check.conclusion}`);
80
+ const parts = [];
81
+ if (visible.length > 0) {
82
+ parts.push(`spec checks ${visible.slice(0, 5).join(", ")}`);
83
+ if (visible.length > 5) {
84
+ parts.push(`+${visible.length - 5} more`);
85
+ }
86
+ }
87
+ else {
88
+ parts.push("no spec checks visible");
89
+ }
90
+ if (missingRequiredChecks.length > 0) {
91
+ parts.push(`missing required ${missingRequiredChecks.join(", ")}`);
92
+ }
93
+ return parts.join("; ");
94
+ }
95
+ async function inspectSpecChecks(ctx, specSha) {
96
+ if (!specSha) {
97
+ return {
98
+ detail: "spec checks unavailable: no spec SHA",
99
+ failingChecks: [],
100
+ pendingChecks: [],
101
+ missingRequiredChecks: ctx.policy.getRequiredChecks(),
102
+ };
103
+ }
104
+ try {
105
+ const checks = await ctx.github.listChecksForRef(specSha);
106
+ const failingChecks = checks.filter((check) => check.conclusion === "failure");
107
+ const pendingChecks = checks.filter((check) => check.conclusion === "pending");
108
+ const missingRequiredChecks = getMissingRequiredChecks(ctx.policy.getRequiredChecks(), checks);
109
+ return {
110
+ detail: summarizeChecks(checks, missingRequiredChecks),
111
+ failingChecks,
112
+ pendingChecks,
113
+ missingRequiredChecks,
114
+ };
115
+ }
116
+ catch (error) {
117
+ return {
118
+ detail: `spec checks unavailable: ${describeError(error)}`,
119
+ failingChecks: [],
120
+ pendingChecks: [],
121
+ missingRequiredChecks: ctx.policy.getRequiredChecks(),
122
+ };
123
+ }
124
+ }
125
+ async function verifySpecStillFastForwards(ctx, specSha) {
126
+ try {
127
+ await ctx.git.fetch();
128
+ const currentBase = await ctx.git.headSha(ref(ctx, ctx.baseBranch));
129
+ const isFastForward = await ctx.git.isAncestor(currentBase, specSha);
130
+ return { currentBase, isFastForward };
131
+ }
132
+ catch (error) {
133
+ return {
134
+ currentBase: null,
135
+ isFastForward: null,
136
+ detail: `fast-forward verification unavailable: ${describeError(error)}`,
137
+ };
138
+ }
139
+ }
10
140
  export async function mergeHead(ctx, entry) {
11
141
  emit(ctx, entry, "merge_revalidating");
12
142
  const prStatus = await ctx.github.getStatus(entry.prNumber);
@@ -93,7 +223,12 @@ export async function mergeHead(ctx, entry) {
93
223
  try {
94
224
  await ctx.git.push(entry.specBranch, false, ctx.baseBranch);
95
225
  }
96
- catch {
226
+ catch (error) {
227
+ const pushErrorDetail = describeError(error);
228
+ const pushFailureKind = classifyPushFailure(error, pushErrorDetail);
229
+ const fastForward = entry.specSha
230
+ ? await verifySpecStillFastForwards(ctx, entry.specSha)
231
+ : { currentBase: null, isFastForward: null, detail: "fast-forward verification unavailable: no spec SHA" };
97
232
  try {
98
233
  const refresh = await ctx.policy.refreshOnIssue("merge_push_rejected");
99
234
  if (refresh.attempted && refresh.changed) {
@@ -109,20 +244,52 @@ export async function mergeHead(ctx, entry) {
109
244
  catch {
110
245
  // Fall through to the normal push failure handling when policy refresh is unavailable.
111
246
  }
112
- emit(ctx, entry, "merge_rejected", { detail: "push to main failed" });
113
- const allActive = ctx.store.listActive(ctx.repoId);
114
- if (isBudgetExhausted(entry)) {
115
- emit(ctx, entry, "budget_exhausted");
116
- await evictEntry(ctx, entry, "integration_conflict");
247
+ const checkState = await inspectSpecChecks(ctx, entry.specSha);
248
+ const detail = [
249
+ `push to ${ctx.baseBranch} failed (${pushFailureKind})`,
250
+ `spec ${shortSha(entry.specSha)}`,
251
+ `main ${shortSha(fastForward.currentBase ?? currentBase)}`,
252
+ fastForward.isFastForward === null
253
+ ? fastForward.detail
254
+ : `spec fast-forward ${fastForward.isFastForward ? "yes" : "no"}`,
255
+ checkState.detail,
256
+ pushErrorDetail,
257
+ ].filter((part) => Boolean(part && part.trim())).join("; ");
258
+ emit(ctx, entry, "merge_rejected", {
259
+ detail,
260
+ baseSha: fastForward.currentBase ?? currentBase ?? undefined,
261
+ failingChecks: checkState.failingChecks,
262
+ pendingChecks: checkState.pendingChecks,
263
+ missingRequiredChecks: checkState.missingRequiredChecks,
264
+ });
265
+ const mustRebuild = pushFailureKind === "non_fast_forward" || fastForward.isFastForward === false;
266
+ if (mustRebuild) {
267
+ const allActive = ctx.store.listActive(ctx.repoId);
268
+ if (isBudgetExhausted(entry)) {
269
+ emit(ctx, entry, "budget_exhausted", {
270
+ detail: "push retry budget exhausted after non-fast-forward rejection",
271
+ });
272
+ await evictEntry(ctx, entry, "integration_conflict");
273
+ }
274
+ else {
275
+ ctx.store.transition(entry.id, "preparing_head", {
276
+ retryAttempts: entry.retryAttempts + 1,
277
+ ...CLEAN_CI,
278
+ ...CLEAN_SPEC,
279
+ }, `push failed, retry ${entry.retryAttempts + 1}/${entry.maxRetries}`);
280
+ }
281
+ await invalidateDownstream(ctx, allActive, 0);
282
+ return;
117
283
  }
118
- else {
119
- ctx.store.transition(entry.id, "preparing_head", {
120
- retryAttempts: entry.retryAttempts + 1,
121
- ...CLEAN_CI,
122
- ...CLEAN_SPEC,
123
- }, `push failed, retry ${entry.retryAttempts + 1}/${entry.maxRetries}`);
284
+ if (isBudgetExhausted(entry)) {
285
+ emit(ctx, entry, "budget_exhausted", {
286
+ detail: "push retry budget exhausted; keeping validated spec for GitHub recovery",
287
+ });
124
288
  }
125
- await invalidateDownstream(ctx, allActive, 0);
289
+ ctx.store.transition(entry.id, "merging", {
290
+ retryAttempts: Math.min(entry.retryAttempts + 1, entry.maxRetries),
291
+ waitDetail: detail,
292
+ }, `push failed, keeping validated spec: ${detail}`);
126
293
  return;
127
294
  }
128
295
  emit(ctx, entry, "merge_succeeded");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.26.1",
3
+ "version": "0.26.2",
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": {