From d1a369222cfc8412c622fd92cbec6ffdedcd9fc2 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 01:39:57 -0400 Subject: [PATCH] fix(watch-pr): wait while the head branch is behind its base GitHub refuses the merge until the branch is updated, so a clean BEHIND pull request must not be reported READY. --- .../scripts/watch-pr/behind.test.ts | 191 ++++++++++++++++++ .../poteto-mode/scripts/watch-pr/policy.ts | 84 +++++++- .../poteto-mode/scripts/watch-pr/render.ts | 11 + .../poteto-mode/scripts/watch-pr/types.ts | 10 + .../scripts/watch-pr/behind.test.ts | 191 ++++++++++++++++++ .../poteto-mode/scripts/watch-pr/policy.ts | 84 +++++++- .../poteto-mode/scripts/watch-pr/render.ts | 11 + .../poteto-mode/scripts/watch-pr/types.ts | 10 + 8 files changed, 574 insertions(+), 18 deletions(-) create mode 100644 harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/behind.test.ts create mode 100644 pstack/skills/poteto-mode/scripts/watch-pr/behind.test.ts diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/behind.test.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/behind.test.ts new file mode 100644 index 000000000..1ddcfbc89 --- /dev/null +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/behind.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "bun:test"; +import { + createQueueState, + evaluateQueue, + readSnapshot, + runQueued, + runSimple, +} from "./policy.ts"; +import { fakeReader } from "./fakes.test-helper.ts"; +import type { + GitHubReader, + PollingOptions, + PrContext, + ProgressVerdict, + PullRequestFacts, +} from "./types.ts"; +import { parsePrNumber } from "./types.ts"; + +const context = (number: number): PrContext => ({ + owner: "owner", + repo: "repo", + number: parsePrNumber(number), +}); +const options = { + interval: 60, + sweepInterval: 300, + timeout: 0, + maxQueryErrors: 5, + allowDraft: false, +} satisfies PollingOptions; + +// Clean CI, no review threads. GitHub's BEHIND merge state means the head +// ref is out of date and the merge is refused until the branch is updated. +const behind = { mergeable: "MERGEABLE", mergeStateStatus: "BEHIND" } as const; + +function clockAt(start = 0) { + let now = start; + const sleeps: number[] = []; + return { + sleeps, + clock: { + now: () => now, + observedAt: () => "2026-10-24T00:00:00.000Z", + async sleep(seconds: number) { + sleeps.push(seconds); + now += seconds; + }, + }, + }; +} + +describe("head behind the base branch", () => { + it("does not report READY, then times out because the branch is behind", async () => { + const emitted: ProgressVerdict[] = []; + const { clock } = clockAt(); + const result = await runSimple({ + dependencies: { + reader: fakeReader({ facts: behind }), + clock, + emit: (verdict) => emitted.push(verdict), + }, + contexts: [context(1)], + mode: "single", + statusOnly: false, + options: { ...options, timeout: 30 }, + }); + expect(result.kind).not.toBe("READY"); + expect(emitted[0]).toMatchObject({ + kind: "WAITING", + frontier: { number: 1 }, + reason: { kind: "branch-behind" }, + }); + expect(result).toMatchObject({ + kind: "TIMEOUT", + exitCode: 5, + reason: { kind: "branch-behind", frontier: { number: 1 } }, + }); + }); + + it("reports READY once the branch is no longer behind", async () => { + let reads = 0; + const stale = fakeReader({ facts: behind }); + const current = fakeReader(); + const reader: GitHubReader = { + ...stale, + async pullRequest(pr): Promise { + reads += 1; + return reads === 1 ? stale.pullRequest(pr) : current.pullRequest(pr); + }, + }; + const emitted: ProgressVerdict[] = []; + const { clock, sleeps } = clockAt(); + const result = await runSimple({ + dependencies: { reader, clock, emit: (verdict) => emitted.push(verdict) }, + contexts: [context(1)], + mode: "single", + statusOnly: false, + options, + }); + expect(emitted).toMatchObject([ + { + kind: "WAITING", + frontier: { number: 1 }, + reason: { kind: "branch-behind" }, + }, + ]); + expect(sleeps).toEqual([60]); + expect(result).toMatchObject({ + kind: "READY", + scope: { kind: "single", pr: { kind: "ready-pr" } }, + }); + }); + + it("does not clear a stack while one PR is behind", async () => { + const { clock } = clockAt(); + const result = await runSimple({ + dependencies: { + reader: fakeReader({ facts: behind }), + clock, + emit() {}, + }, + contexts: [context(10), context(11)], + mode: "stack", + statusOnly: false, + options: { ...options, timeout: 30 }, + }); + expect(result.kind).not.toBe("READY"); + expect(result).toMatchObject({ + kind: "TIMEOUT", + exitCode: 5, + reason: { kind: "branch-behind", frontier: { number: 10 } }, + }); + }); + + it("does not treat a queued frontier as merge-ready while it is behind", async () => { + const frontier = await readSnapshot({ + reader: fakeReader({ facts: behind }), + context: context(20), + pendingHistory: "omit", + allowDraft: false, + }); + const state = { + ...createQueueState([context(20)], 0), + snapshots: new Map([[frontier.context.number, frontier]]), + work: null, + }; + expect(evaluateQueue(state, 0, options)).toMatchObject({ + kind: "waiting", + frontier: { number: 20 }, + reason: { kind: "branch-behind" }, + }); + }); + + it("times out a queued frontier as behind rather than ready", async () => { + const { clock } = clockAt(); + const result = await runQueued({ + dependencies: { + reader: fakeReader({ facts: behind }), + clock, + emit() {}, + }, + contexts: [context(20)], + options: { ...options, timeout: 30 }, + }); + expect(result.kind).not.toBe("READY"); + expect(result).toMatchObject({ + kind: "TIMEOUT", + exitCode: 5, + reason: { kind: "branch-behind", frontier: { number: 20 } }, + }); + }); + + it("still reports READY for UNSTABLE and UNKNOWN merge states", async () => { + for (const mergeStateStatus of ["UNSTABLE", "UNKNOWN"] as const) { + const result = await runSimple({ + dependencies: { + reader: fakeReader({ + facts: { mergeable: "MERGEABLE", mergeStateStatus }, + }), + clock: clockAt().clock, + emit() {}, + }, + contexts: [context(1)], + mode: "single", + statusOnly: false, + options, + }); + expect(result).toMatchObject({ kind: "READY" }); + } + }); +}); diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.ts index 8179f2d8b..8a53e57d8 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.ts +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.ts @@ -138,6 +138,14 @@ export async function readSnapshot(args: { ), }; } +// BEHIND means GitHub will refuse the merge until the head is updated with +// the base. UNSTABLE and UNKNOWN are left to the CI and mergeability paths. +const branchBehindBlocker = (row: T.PrSnapshot): T.MergeBlocker | null => + row.kind === "open" && + row.ci.kind !== "ci-pending" && + row.facts.mergeStateStatus === "BEHIND" + ? { kind: "branch-behind", pr: row.context } + : null; const conflictBlocker = (row: T.PrSnapshot): T.MergeBlocker | null => row.kind === "open" && (row.facts.mergeable === "CONFLICTING" || @@ -194,6 +202,7 @@ function readyContribution( row.kind !== "open" || row.ci.kind !== "ci-clean" || row.threads.length !== 0 || + row.facts.mergeStateStatus === "BEHIND" || conflictBlocker(row) !== null || gateReason(row, allowDraft) !== null ) @@ -224,6 +233,7 @@ export function classifyPr( threadBlocker(row), ciBlocker(row), gateBlocker(row, allowDraft), + branchBehindBlocker(row), ]) if (blocker !== null) return { kind: "blocker", blocker }; if (row.kind === "open" && row.ci.kind === "ci-pending") @@ -247,6 +257,10 @@ export function selectTierMajorStackDecision( const blocker = gateBlocker(row, allowDraft); if (blocker !== null) return { kind: "blocker", blocker }; } + for (const row of rows) { + const blocker = branchBehindBlocker(row); + if (blocker !== null) return { kind: "blocker", blocker }; + } for (const row of rows) if (row.kind === "open" && row.ci.kind === "ci-pending") return { @@ -321,6 +335,8 @@ function blockerVerdict( return stamp({ kind: "BLOCKER", terminal: true, exitCode: 4, blocker }); case "merge-gate": return stamp({ kind: "BLOCKER", terminal: true, exitCode: 6, blocker }); + case "branch-behind": + throw new Error("a behind branch waits until timeout"); default: { const exhaustive: never = blocker; return exhaustive; @@ -465,11 +481,37 @@ export async function runSimple(args: { args.mode === "single" ? classifyPr(complete[0], args.options.allowDraft) : selectTierMajorStackDecision(complete, args.options.allowDraft); - if (decision.kind === "blocker") + if (decision.kind === "blocker") { + if (decision.blocker.kind === "branch-behind") { + const frontier = decision.blocker.pr; + const unmergedCount = complete.filter( + (row) => branchBehindBlocker(row) !== null + ).length; + args.dependencies.emit( + stamp({ + kind: "WAITING", + terminal: false, + frontier, + reason: { kind: "branch-behind", unmergedCount }, + }) + ); + return { + kind: "sleep", + seconds: args.options.interval, + onDeadline: () => + stamp({ + kind: "TIMEOUT", + terminal: true, + exitCode: 5, + reason: { kind: "branch-behind", frontier, unmergedCount }, + }), + }; + } return { kind: "terminal", verdict: blockerVerdict(stamp, decision.blocker), }; + } if (decision.kind === "ready" || decision.kind === "merged") return { kind: "terminal", @@ -640,6 +682,7 @@ export type QueueEvaluation = readonly state: QueueState; readonly frontier: T.PrContext; readonly reason: + | { readonly kind: "branch-behind"; readonly unmergedCount: number } | { readonly kind: "pending-checks"; readonly pending: T.NonEmpty; @@ -673,9 +716,13 @@ export function evaluateQueue( const rows = nonEmpty(active); if (rows === null) throw new Error("active queue cannot be empty"); const decision = selectTierMajorStackDecision(rows, options.allowDraft); - if (decision.kind === "blocker") - return { kind: "blocker", state, blocker: decision.blocker }; const frontier = rows[0].context; + const frontierIsBehind = + decision.kind === "blocker" && + decision.blocker.kind === "branch-behind" && + decision.blocker.pr.number === frontier.number; + if (decision.kind === "blocker" && decision.blocker.kind !== "branch-behind") + return { kind: "blocker", state, blocker: decision.blocker }; if (state.frontier !== null && state.frontier.number !== frontier.number) return { kind: "advance", @@ -691,6 +738,16 @@ export function evaluateQueue( frontier, unmergedCount: active.length, }; + if (frontierIsBehind) { + const key = `behind:${frontier.number}`; + return { + kind: "waiting", + state: { ...state, frontier, lastWaitKey: key }, + frontier, + reason: { kind: "branch-behind", unmergedCount: active.length }, + emit: state.lastWaitKey !== key, + }; + } const row = rows[0]; const pending = row.kind === "open" && row.ci.kind === "ci-pending" ? row.ci.pending : null; @@ -802,20 +859,29 @@ export async function runQueued(args: { }) ); return { kind: "continue" }; - case "timeout": + case "timeout": { + const row = state.snapshots.get(evaluation.frontier.number); + const behind = row !== undefined && branchBehindBlocker(row) !== null; return { kind: "terminal", verdict: stamp({ kind: "TIMEOUT", terminal: true, exitCode: 5, - reason: { - kind: "queued-stack", - frontier: evaluation.frontier, - unmergedCount: evaluation.unmergedCount, - }, + reason: behind + ? { + kind: "branch-behind", + frontier: evaluation.frontier, + unmergedCount: evaluation.unmergedCount, + } + : { + kind: "queued-stack", + frontier: evaluation.frontier, + unmergedCount: evaluation.unmergedCount, + }, }), }; + } case "waiting": if (evaluation.emit) args.dependencies.emit( diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/render.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/render.ts index 0e9358393..f1824845d 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/render.ts +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/render.ts @@ -36,6 +36,7 @@ function mergeCell(row: T.PrSnapshot): string { if (row.facts.isDraft) return "⏸ draft"; if (row.facts.reviewDecision === "CHANGES_REQUESTED") return "⚠️ changes requested"; + if (row.facts.mergeStateStatus === "BEHIND") return "⏸ behind"; return row.facts.mergeable === "CONFLICTING" || row.facts.mergeStateStatus === "DIRTY" || row.facts.mergeStateStatus === "CONFLICTING" @@ -124,6 +125,12 @@ function renderBlocker(blocker: T.MergeBlocker | StatusQueryBlocker): string { `detail=${blocker.failure.detail}`, "action=verify current PR context, GitHub authentication, and API availability, then rearm", ].join("\n"); + case "branch-behind": + return [ + "BLOCKER: branch-behind", + `pr=${blocker.pr.number}`, + "action=update the branch with the base before merging", + ].join("\n"); default: { const exhaustive: never = blocker; return exhaustive; @@ -131,6 +138,10 @@ function renderBlocker(blocker: T.MergeBlocker | StatusQueryBlocker): string { } } export function renderPretty(verdict: T.WatcherVerdict): string { + if (verdict.kind === "WAITING" && verdict.reason.kind === "branch-behind") + return `WAITING: frontier=#${verdict.frontier.number}; head is behind the base branch\n`; + if (verdict.kind === "TIMEOUT" && verdict.reason.kind === "branch-behind") + return `TIMEOUT: head is behind the base branch; frontier=#${verdict.reason.frontier.number}\n`; switch (verdict.kind) { case "QUEUE": return `QUEUE: captured ${verdict.queue.length} PR${verdict.queue.length === 1 ? "" : "s"} bottom-to-top: ${verdict.queue.map((pr) => `#${pr.number}`).join(",")}\n`; diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/types.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/types.ts index 60b9abcac..765ee4f74 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/types.ts +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/types.ts @@ -200,6 +200,10 @@ export type MergeBlocker = readonly kind: "merge-gate"; readonly pr: PrContext; readonly reason: MergeGateReason; + } + | { + readonly kind: "branch-behind"; + readonly pr: PrContext; }; export type QueryFailure = | { @@ -286,6 +290,7 @@ export type ProgressVerdict = | (Progress<"WAITING"> & { readonly frontier: PrContext; readonly reason: + | { readonly kind: "branch-behind"; readonly unmergedCount: number } | { readonly kind: "pending-checks"; readonly pending: NonEmpty; @@ -333,6 +338,11 @@ export type BlockerVerdict = }); export type TimeoutVerdict = Terminal<"TIMEOUT", 5> & { readonly reason: + | { + readonly kind: "branch-behind"; + readonly frontier: PrContext; + readonly unmergedCount: number; + } | { readonly kind: "pending-checks"; readonly pending: NonEmpty; diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/behind.test.ts b/pstack/skills/poteto-mode/scripts/watch-pr/behind.test.ts new file mode 100644 index 000000000..1ddcfbc89 --- /dev/null +++ b/pstack/skills/poteto-mode/scripts/watch-pr/behind.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "bun:test"; +import { + createQueueState, + evaluateQueue, + readSnapshot, + runQueued, + runSimple, +} from "./policy.ts"; +import { fakeReader } from "./fakes.test-helper.ts"; +import type { + GitHubReader, + PollingOptions, + PrContext, + ProgressVerdict, + PullRequestFacts, +} from "./types.ts"; +import { parsePrNumber } from "./types.ts"; + +const context = (number: number): PrContext => ({ + owner: "owner", + repo: "repo", + number: parsePrNumber(number), +}); +const options = { + interval: 60, + sweepInterval: 300, + timeout: 0, + maxQueryErrors: 5, + allowDraft: false, +} satisfies PollingOptions; + +// Clean CI, no review threads. GitHub's BEHIND merge state means the head +// ref is out of date and the merge is refused until the branch is updated. +const behind = { mergeable: "MERGEABLE", mergeStateStatus: "BEHIND" } as const; + +function clockAt(start = 0) { + let now = start; + const sleeps: number[] = []; + return { + sleeps, + clock: { + now: () => now, + observedAt: () => "2026-10-24T00:00:00.000Z", + async sleep(seconds: number) { + sleeps.push(seconds); + now += seconds; + }, + }, + }; +} + +describe("head behind the base branch", () => { + it("does not report READY, then times out because the branch is behind", async () => { + const emitted: ProgressVerdict[] = []; + const { clock } = clockAt(); + const result = await runSimple({ + dependencies: { + reader: fakeReader({ facts: behind }), + clock, + emit: (verdict) => emitted.push(verdict), + }, + contexts: [context(1)], + mode: "single", + statusOnly: false, + options: { ...options, timeout: 30 }, + }); + expect(result.kind).not.toBe("READY"); + expect(emitted[0]).toMatchObject({ + kind: "WAITING", + frontier: { number: 1 }, + reason: { kind: "branch-behind" }, + }); + expect(result).toMatchObject({ + kind: "TIMEOUT", + exitCode: 5, + reason: { kind: "branch-behind", frontier: { number: 1 } }, + }); + }); + + it("reports READY once the branch is no longer behind", async () => { + let reads = 0; + const stale = fakeReader({ facts: behind }); + const current = fakeReader(); + const reader: GitHubReader = { + ...stale, + async pullRequest(pr): Promise { + reads += 1; + return reads === 1 ? stale.pullRequest(pr) : current.pullRequest(pr); + }, + }; + const emitted: ProgressVerdict[] = []; + const { clock, sleeps } = clockAt(); + const result = await runSimple({ + dependencies: { reader, clock, emit: (verdict) => emitted.push(verdict) }, + contexts: [context(1)], + mode: "single", + statusOnly: false, + options, + }); + expect(emitted).toMatchObject([ + { + kind: "WAITING", + frontier: { number: 1 }, + reason: { kind: "branch-behind" }, + }, + ]); + expect(sleeps).toEqual([60]); + expect(result).toMatchObject({ + kind: "READY", + scope: { kind: "single", pr: { kind: "ready-pr" } }, + }); + }); + + it("does not clear a stack while one PR is behind", async () => { + const { clock } = clockAt(); + const result = await runSimple({ + dependencies: { + reader: fakeReader({ facts: behind }), + clock, + emit() {}, + }, + contexts: [context(10), context(11)], + mode: "stack", + statusOnly: false, + options: { ...options, timeout: 30 }, + }); + expect(result.kind).not.toBe("READY"); + expect(result).toMatchObject({ + kind: "TIMEOUT", + exitCode: 5, + reason: { kind: "branch-behind", frontier: { number: 10 } }, + }); + }); + + it("does not treat a queued frontier as merge-ready while it is behind", async () => { + const frontier = await readSnapshot({ + reader: fakeReader({ facts: behind }), + context: context(20), + pendingHistory: "omit", + allowDraft: false, + }); + const state = { + ...createQueueState([context(20)], 0), + snapshots: new Map([[frontier.context.number, frontier]]), + work: null, + }; + expect(evaluateQueue(state, 0, options)).toMatchObject({ + kind: "waiting", + frontier: { number: 20 }, + reason: { kind: "branch-behind" }, + }); + }); + + it("times out a queued frontier as behind rather than ready", async () => { + const { clock } = clockAt(); + const result = await runQueued({ + dependencies: { + reader: fakeReader({ facts: behind }), + clock, + emit() {}, + }, + contexts: [context(20)], + options: { ...options, timeout: 30 }, + }); + expect(result.kind).not.toBe("READY"); + expect(result).toMatchObject({ + kind: "TIMEOUT", + exitCode: 5, + reason: { kind: "branch-behind", frontier: { number: 20 } }, + }); + }); + + it("still reports READY for UNSTABLE and UNKNOWN merge states", async () => { + for (const mergeStateStatus of ["UNSTABLE", "UNKNOWN"] as const) { + const result = await runSimple({ + dependencies: { + reader: fakeReader({ + facts: { mergeable: "MERGEABLE", mergeStateStatus }, + }), + clock: clockAt().clock, + emit() {}, + }, + contexts: [context(1)], + mode: "single", + statusOnly: false, + options, + }); + expect(result).toMatchObject({ kind: "READY" }); + } + }); +}); diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts b/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts index 8179f2d8b..8a53e57d8 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts @@ -138,6 +138,14 @@ export async function readSnapshot(args: { ), }; } +// BEHIND means GitHub will refuse the merge until the head is updated with +// the base. UNSTABLE and UNKNOWN are left to the CI and mergeability paths. +const branchBehindBlocker = (row: T.PrSnapshot): T.MergeBlocker | null => + row.kind === "open" && + row.ci.kind !== "ci-pending" && + row.facts.mergeStateStatus === "BEHIND" + ? { kind: "branch-behind", pr: row.context } + : null; const conflictBlocker = (row: T.PrSnapshot): T.MergeBlocker | null => row.kind === "open" && (row.facts.mergeable === "CONFLICTING" || @@ -194,6 +202,7 @@ function readyContribution( row.kind !== "open" || row.ci.kind !== "ci-clean" || row.threads.length !== 0 || + row.facts.mergeStateStatus === "BEHIND" || conflictBlocker(row) !== null || gateReason(row, allowDraft) !== null ) @@ -224,6 +233,7 @@ export function classifyPr( threadBlocker(row), ciBlocker(row), gateBlocker(row, allowDraft), + branchBehindBlocker(row), ]) if (blocker !== null) return { kind: "blocker", blocker }; if (row.kind === "open" && row.ci.kind === "ci-pending") @@ -247,6 +257,10 @@ export function selectTierMajorStackDecision( const blocker = gateBlocker(row, allowDraft); if (blocker !== null) return { kind: "blocker", blocker }; } + for (const row of rows) { + const blocker = branchBehindBlocker(row); + if (blocker !== null) return { kind: "blocker", blocker }; + } for (const row of rows) if (row.kind === "open" && row.ci.kind === "ci-pending") return { @@ -321,6 +335,8 @@ function blockerVerdict( return stamp({ kind: "BLOCKER", terminal: true, exitCode: 4, blocker }); case "merge-gate": return stamp({ kind: "BLOCKER", terminal: true, exitCode: 6, blocker }); + case "branch-behind": + throw new Error("a behind branch waits until timeout"); default: { const exhaustive: never = blocker; return exhaustive; @@ -465,11 +481,37 @@ export async function runSimple(args: { args.mode === "single" ? classifyPr(complete[0], args.options.allowDraft) : selectTierMajorStackDecision(complete, args.options.allowDraft); - if (decision.kind === "blocker") + if (decision.kind === "blocker") { + if (decision.blocker.kind === "branch-behind") { + const frontier = decision.blocker.pr; + const unmergedCount = complete.filter( + (row) => branchBehindBlocker(row) !== null + ).length; + args.dependencies.emit( + stamp({ + kind: "WAITING", + terminal: false, + frontier, + reason: { kind: "branch-behind", unmergedCount }, + }) + ); + return { + kind: "sleep", + seconds: args.options.interval, + onDeadline: () => + stamp({ + kind: "TIMEOUT", + terminal: true, + exitCode: 5, + reason: { kind: "branch-behind", frontier, unmergedCount }, + }), + }; + } return { kind: "terminal", verdict: blockerVerdict(stamp, decision.blocker), }; + } if (decision.kind === "ready" || decision.kind === "merged") return { kind: "terminal", @@ -640,6 +682,7 @@ export type QueueEvaluation = readonly state: QueueState; readonly frontier: T.PrContext; readonly reason: + | { readonly kind: "branch-behind"; readonly unmergedCount: number } | { readonly kind: "pending-checks"; readonly pending: T.NonEmpty; @@ -673,9 +716,13 @@ export function evaluateQueue( const rows = nonEmpty(active); if (rows === null) throw new Error("active queue cannot be empty"); const decision = selectTierMajorStackDecision(rows, options.allowDraft); - if (decision.kind === "blocker") - return { kind: "blocker", state, blocker: decision.blocker }; const frontier = rows[0].context; + const frontierIsBehind = + decision.kind === "blocker" && + decision.blocker.kind === "branch-behind" && + decision.blocker.pr.number === frontier.number; + if (decision.kind === "blocker" && decision.blocker.kind !== "branch-behind") + return { kind: "blocker", state, blocker: decision.blocker }; if (state.frontier !== null && state.frontier.number !== frontier.number) return { kind: "advance", @@ -691,6 +738,16 @@ export function evaluateQueue( frontier, unmergedCount: active.length, }; + if (frontierIsBehind) { + const key = `behind:${frontier.number}`; + return { + kind: "waiting", + state: { ...state, frontier, lastWaitKey: key }, + frontier, + reason: { kind: "branch-behind", unmergedCount: active.length }, + emit: state.lastWaitKey !== key, + }; + } const row = rows[0]; const pending = row.kind === "open" && row.ci.kind === "ci-pending" ? row.ci.pending : null; @@ -802,20 +859,29 @@ export async function runQueued(args: { }) ); return { kind: "continue" }; - case "timeout": + case "timeout": { + const row = state.snapshots.get(evaluation.frontier.number); + const behind = row !== undefined && branchBehindBlocker(row) !== null; return { kind: "terminal", verdict: stamp({ kind: "TIMEOUT", terminal: true, exitCode: 5, - reason: { - kind: "queued-stack", - frontier: evaluation.frontier, - unmergedCount: evaluation.unmergedCount, - }, + reason: behind + ? { + kind: "branch-behind", + frontier: evaluation.frontier, + unmergedCount: evaluation.unmergedCount, + } + : { + kind: "queued-stack", + frontier: evaluation.frontier, + unmergedCount: evaluation.unmergedCount, + }, }), }; + } case "waiting": if (evaluation.emit) args.dependencies.emit( diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/render.ts b/pstack/skills/poteto-mode/scripts/watch-pr/render.ts index 0e9358393..f1824845d 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/render.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/render.ts @@ -36,6 +36,7 @@ function mergeCell(row: T.PrSnapshot): string { if (row.facts.isDraft) return "⏸ draft"; if (row.facts.reviewDecision === "CHANGES_REQUESTED") return "⚠️ changes requested"; + if (row.facts.mergeStateStatus === "BEHIND") return "⏸ behind"; return row.facts.mergeable === "CONFLICTING" || row.facts.mergeStateStatus === "DIRTY" || row.facts.mergeStateStatus === "CONFLICTING" @@ -124,6 +125,12 @@ function renderBlocker(blocker: T.MergeBlocker | StatusQueryBlocker): string { `detail=${blocker.failure.detail}`, "action=verify current PR context, GitHub authentication, and API availability, then rearm", ].join("\n"); + case "branch-behind": + return [ + "BLOCKER: branch-behind", + `pr=${blocker.pr.number}`, + "action=update the branch with the base before merging", + ].join("\n"); default: { const exhaustive: never = blocker; return exhaustive; @@ -131,6 +138,10 @@ function renderBlocker(blocker: T.MergeBlocker | StatusQueryBlocker): string { } } export function renderPretty(verdict: T.WatcherVerdict): string { + if (verdict.kind === "WAITING" && verdict.reason.kind === "branch-behind") + return `WAITING: frontier=#${verdict.frontier.number}; head is behind the base branch\n`; + if (verdict.kind === "TIMEOUT" && verdict.reason.kind === "branch-behind") + return `TIMEOUT: head is behind the base branch; frontier=#${verdict.reason.frontier.number}\n`; switch (verdict.kind) { case "QUEUE": return `QUEUE: captured ${verdict.queue.length} PR${verdict.queue.length === 1 ? "" : "s"} bottom-to-top: ${verdict.queue.map((pr) => `#${pr.number}`).join(",")}\n`; diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/types.ts b/pstack/skills/poteto-mode/scripts/watch-pr/types.ts index 60b9abcac..765ee4f74 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/types.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/types.ts @@ -200,6 +200,10 @@ export type MergeBlocker = readonly kind: "merge-gate"; readonly pr: PrContext; readonly reason: MergeGateReason; + } + | { + readonly kind: "branch-behind"; + readonly pr: PrContext; }; export type QueryFailure = | { @@ -286,6 +290,7 @@ export type ProgressVerdict = | (Progress<"WAITING"> & { readonly frontier: PrContext; readonly reason: + | { readonly kind: "branch-behind"; readonly unmergedCount: number } | { readonly kind: "pending-checks"; readonly pending: NonEmpty; @@ -333,6 +338,11 @@ export type BlockerVerdict = }); export type TimeoutVerdict = Terminal<"TIMEOUT", 5> & { readonly reason: + | { + readonly kind: "branch-behind"; + readonly frontier: PrContext; + readonly unmergedCount: number; + } | { readonly kind: "pending-checks"; readonly pending: NonEmpty;