diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/mergeability.test.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/mergeability.test.ts new file mode 100644 index 000000000..7fe26e855 --- /dev/null +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/mergeability.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "bun:test"; +import { + createQueueState, + evaluateQueue, + readSnapshot, + runSimple, + selectTierMajorStackDecision, +} 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; + +// GitHub answers UNKNOWN while it recomputes the merge after a push or a base +// change; CI and review state are already clean at that point. +const computing = { mergeable: "UNKNOWN", mergeStateStatus: "UNKNOWN" } 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("mergeability still being computed", () => { + it("waits instead of reporting READY, then reports READY once GitHub settles", async () => { + let reads = 0; + const unknown = fakeReader({ facts: computing }); + const settled = fakeReader(); + const reader: GitHubReader = { + ...unknown, + async pullRequest(pr): Promise { + reads += 1; + return reads === 1 ? unknown.pullRequest(pr) : settled.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: "mergeability-unknown" }, + }, + ]); + expect(sleeps).toEqual([60]); + expect(result).toMatchObject({ + kind: "READY", + scope: { kind: "single", pr: { kind: "ready-pr" } }, + }); + }); + + it("times out on a deadline while mergeability stays unknown", async () => { + const { clock } = clockAt(); + const result = await runSimple({ + dependencies: { reader: fakeReader({ facts: computing }), clock, emit() {} }, + contexts: [context(1)], + mode: "single", + statusOnly: false, + options: { ...options, timeout: 30 }, + }); + expect(result).toMatchObject({ + kind: "TIMEOUT", + exitCode: 5, + reason: { kind: "mergeability-unknown", frontier: { number: 1 } }, + }); + }); + + it("does not clear a stack while one PR's mergeability is unknown", async () => { + const bottom = await readSnapshot({ + reader: fakeReader(), + context: context(10), + pendingHistory: "include", + allowDraft: false, + }); + const upstack = await readSnapshot({ + reader: fakeReader({ facts: computing }), + context: context(11), + pendingHistory: "include", + allowDraft: false, + }); + expect(selectTierMajorStackDecision([bottom, upstack])).toEqual({ + kind: "computing-mergeability", + frontier: context(11), + }); + }); + + it("does not report a queued frontier as merge-ready while its mergeability is unknown", async () => { + const frontier = await readSnapshot({ + reader: fakeReader({ facts: computing }), + 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: "mergeability-unknown" }, + }); + }); +}); 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..372a37ca3 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 @@ -180,6 +180,8 @@ function gateBlocker( ? null : { kind: "merge-gate", pr: row.context, reason }; } +const mergeabilityUnknown = (row: T.PrSnapshot): boolean => + row.kind === "open" && row.facts.mergeable === "UNKNOWN"; function readyContribution( row: T.PrSnapshot, allowDraft: boolean @@ -228,6 +230,8 @@ export function classifyPr( if (blocker !== null) return { kind: "blocker", blocker }; if (row.kind === "open" && row.ci.kind === "ci-pending") return { kind: "waiting", frontier: row.context, pending: row.ci.pending }; + if (mergeabilityUnknown(row)) + return { kind: "computing-mergeability", frontier: row.context }; const ready = readyContribution(row, allowDraft); if (ready === null) throw new Error("snapshot has no classified decision"); return ready.kind === "merged-pr" @@ -254,6 +258,9 @@ export function selectTierMajorStackDecision( frontier: row.context, pending: row.ci.pending, }; + for (const row of rows) + if (mergeabilityUnknown(row)) + return { kind: "computing-mergeability", frontier: row.context }; const prs = nonEmpty( rows .map((row) => readyContribution(row, allowDraft)) @@ -496,6 +503,28 @@ export async function runSimple(args: { args.mode ), }; + if (decision.kind === "computing-mergeability") { + const frontier = decision.frontier; + args.dependencies.emit( + stamp({ + kind: "WAITING", + terminal: false, + frontier, + reason: { kind: "mergeability-unknown" }, + }) + ); + return { + kind: "sleep", + seconds: args.options.interval, + onDeadline: () => + stamp({ + kind: "TIMEOUT", + terminal: true, + exitCode: 5, + reason: { kind: "mergeability-unknown", frontier }, + }), + }; + } args.dependencies.emit( stamp({ kind: "WAITING", @@ -644,6 +673,7 @@ export type QueueEvaluation = readonly kind: "pending-checks"; readonly pending: T.NonEmpty; } + | { readonly kind: "mergeability-unknown" } | { readonly kind: "merge-queue"; readonly unmergedCount: number }; readonly emit: boolean; }; @@ -695,13 +725,17 @@ export function evaluateQueue( const pending = row.kind === "open" && row.ci.kind === "ci-pending" ? row.ci.pending : null; const reason = - pending === null - ? ({ kind: "merge-queue", unmergedCount: active.length } as const) - : ({ kind: "pending-checks", pending } as const); + pending !== null + ? ({ kind: "pending-checks", pending } as const) + : mergeabilityUnknown(row) + ? ({ kind: "mergeability-unknown" } as const) + : ({ kind: "merge-queue", unmergedCount: active.length } as const); const key = reason.kind === "pending-checks" ? `pending:${frontier.number}:${reason.pending.length}` - : `queue:${frontier.number}:${reason.unmergedCount}`; + : reason.kind === "mergeability-unknown" + ? `mergeability:${frontier.number}` + : `queue:${frontier.number}:${reason.unmergedCount}`; return { kind: "waiting", state: { ...state, frontier, lastWaitKey: key }, 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..8f8aae1c9 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 @@ -137,6 +137,8 @@ export function renderPretty(verdict: T.WatcherVerdict): string { case "STATUS": return renderStatusTable(verdict.rows); case "WAITING": + if (verdict.reason.kind === "mergeability-unknown") + return `WAITING: frontier=#${verdict.frontier.number}; GitHub is still computing mergeability\n`; return verdict.reason.kind === "pending-checks" ? `WAITING: frontier=#${verdict.frontier.number}; ${verdict.reason.pending.length} check${verdict.reason.pending.length === 1 ? "" : "s"} pending\n` : `WAITING: frontier=#${verdict.frontier.number} is blocker-free; waiting for merge queue (${verdict.reason.unmergedCount} PR${verdict.reason.unmergedCount === 1 ? "" : "s"} unmerged)\n`; @@ -160,6 +162,8 @@ export function renderPretty(verdict: T.WatcherVerdict): string { return "TIMEOUT: checks still pending\n"; if (verdict.reason.kind === "status-unavailable") return "TIMEOUT: GitHub status remained unavailable\n"; + if (verdict.reason.kind === "mergeability-unknown") + return `TIMEOUT: GitHub mergeability still unknown; frontier=#${verdict.reason.frontier.number}\n`; return `TIMEOUT: queued stack still has ${verdict.reason.unmergedCount} PR${verdict.reason.unmergedCount === 1 ? "" : "s"} unmerged; frontier=#${verdict.reason.frontier.number}\n`; default: { const exhaustive: never = verdict; 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..36b17bc77 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 @@ -246,14 +246,24 @@ export interface WaitingDecision { readonly frontier: PrContext; readonly pending: NonEmpty; } +/** + * GitHub reports `mergeable: UNKNOWN` while it recomputes the merge after a + * push or a base change, so a clean read cannot yet prove there is no conflict. + */ +export interface MergeabilityDecision { + readonly kind: "computing-mergeability"; + readonly frontier: PrContext; +} export type PrDecision = | { readonly kind: "blocker"; readonly blocker: MergeBlocker } | WaitingDecision + | MergeabilityDecision | { readonly kind: "ready"; readonly pr: ReadyPr } | { readonly kind: "merged"; readonly pr: MergedPr }; export type StackDecision = | { readonly kind: "blocker"; readonly blocker: MergeBlocker } | WaitingDecision + | MergeabilityDecision | { readonly kind: "clear"; readonly prs: NonEmpty }; export type WatchMode = "single" | "stack" | "queued-stack"; interface EventBase { @@ -290,6 +300,7 @@ export type ProgressVerdict = readonly kind: "pending-checks"; readonly pending: NonEmpty; } + | { readonly kind: "mergeability-unknown" } | { readonly kind: "merge-queue"; readonly unmergedCount: number }; }) | (Progress<"ADVANCE", "queued-stack"> & { @@ -338,6 +349,7 @@ export type TimeoutVerdict = Terminal<"TIMEOUT", 5> & { readonly pending: NonEmpty; } | { readonly kind: "status-unavailable"; readonly failure: QueryFailure } + | { readonly kind: "mergeability-unknown"; readonly frontier: PrContext } | { readonly kind: "queued-stack"; readonly frontier: PrContext; diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/mergeability.test.ts b/pstack/skills/poteto-mode/scripts/watch-pr/mergeability.test.ts new file mode 100644 index 000000000..7fe26e855 --- /dev/null +++ b/pstack/skills/poteto-mode/scripts/watch-pr/mergeability.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "bun:test"; +import { + createQueueState, + evaluateQueue, + readSnapshot, + runSimple, + selectTierMajorStackDecision, +} 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; + +// GitHub answers UNKNOWN while it recomputes the merge after a push or a base +// change; CI and review state are already clean at that point. +const computing = { mergeable: "UNKNOWN", mergeStateStatus: "UNKNOWN" } 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("mergeability still being computed", () => { + it("waits instead of reporting READY, then reports READY once GitHub settles", async () => { + let reads = 0; + const unknown = fakeReader({ facts: computing }); + const settled = fakeReader(); + const reader: GitHubReader = { + ...unknown, + async pullRequest(pr): Promise { + reads += 1; + return reads === 1 ? unknown.pullRequest(pr) : settled.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: "mergeability-unknown" }, + }, + ]); + expect(sleeps).toEqual([60]); + expect(result).toMatchObject({ + kind: "READY", + scope: { kind: "single", pr: { kind: "ready-pr" } }, + }); + }); + + it("times out on a deadline while mergeability stays unknown", async () => { + const { clock } = clockAt(); + const result = await runSimple({ + dependencies: { reader: fakeReader({ facts: computing }), clock, emit() {} }, + contexts: [context(1)], + mode: "single", + statusOnly: false, + options: { ...options, timeout: 30 }, + }); + expect(result).toMatchObject({ + kind: "TIMEOUT", + exitCode: 5, + reason: { kind: "mergeability-unknown", frontier: { number: 1 } }, + }); + }); + + it("does not clear a stack while one PR's mergeability is unknown", async () => { + const bottom = await readSnapshot({ + reader: fakeReader(), + context: context(10), + pendingHistory: "include", + allowDraft: false, + }); + const upstack = await readSnapshot({ + reader: fakeReader({ facts: computing }), + context: context(11), + pendingHistory: "include", + allowDraft: false, + }); + expect(selectTierMajorStackDecision([bottom, upstack])).toEqual({ + kind: "computing-mergeability", + frontier: context(11), + }); + }); + + it("does not report a queued frontier as merge-ready while its mergeability is unknown", async () => { + const frontier = await readSnapshot({ + reader: fakeReader({ facts: computing }), + 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: "mergeability-unknown" }, + }); + }); +}); diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts b/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts index 8179f2d8b..372a37ca3 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts @@ -180,6 +180,8 @@ function gateBlocker( ? null : { kind: "merge-gate", pr: row.context, reason }; } +const mergeabilityUnknown = (row: T.PrSnapshot): boolean => + row.kind === "open" && row.facts.mergeable === "UNKNOWN"; function readyContribution( row: T.PrSnapshot, allowDraft: boolean @@ -228,6 +230,8 @@ export function classifyPr( if (blocker !== null) return { kind: "blocker", blocker }; if (row.kind === "open" && row.ci.kind === "ci-pending") return { kind: "waiting", frontier: row.context, pending: row.ci.pending }; + if (mergeabilityUnknown(row)) + return { kind: "computing-mergeability", frontier: row.context }; const ready = readyContribution(row, allowDraft); if (ready === null) throw new Error("snapshot has no classified decision"); return ready.kind === "merged-pr" @@ -254,6 +258,9 @@ export function selectTierMajorStackDecision( frontier: row.context, pending: row.ci.pending, }; + for (const row of rows) + if (mergeabilityUnknown(row)) + return { kind: "computing-mergeability", frontier: row.context }; const prs = nonEmpty( rows .map((row) => readyContribution(row, allowDraft)) @@ -496,6 +503,28 @@ export async function runSimple(args: { args.mode ), }; + if (decision.kind === "computing-mergeability") { + const frontier = decision.frontier; + args.dependencies.emit( + stamp({ + kind: "WAITING", + terminal: false, + frontier, + reason: { kind: "mergeability-unknown" }, + }) + ); + return { + kind: "sleep", + seconds: args.options.interval, + onDeadline: () => + stamp({ + kind: "TIMEOUT", + terminal: true, + exitCode: 5, + reason: { kind: "mergeability-unknown", frontier }, + }), + }; + } args.dependencies.emit( stamp({ kind: "WAITING", @@ -644,6 +673,7 @@ export type QueueEvaluation = readonly kind: "pending-checks"; readonly pending: T.NonEmpty; } + | { readonly kind: "mergeability-unknown" } | { readonly kind: "merge-queue"; readonly unmergedCount: number }; readonly emit: boolean; }; @@ -695,13 +725,17 @@ export function evaluateQueue( const pending = row.kind === "open" && row.ci.kind === "ci-pending" ? row.ci.pending : null; const reason = - pending === null - ? ({ kind: "merge-queue", unmergedCount: active.length } as const) - : ({ kind: "pending-checks", pending } as const); + pending !== null + ? ({ kind: "pending-checks", pending } as const) + : mergeabilityUnknown(row) + ? ({ kind: "mergeability-unknown" } as const) + : ({ kind: "merge-queue", unmergedCount: active.length } as const); const key = reason.kind === "pending-checks" ? `pending:${frontier.number}:${reason.pending.length}` - : `queue:${frontier.number}:${reason.unmergedCount}`; + : reason.kind === "mergeability-unknown" + ? `mergeability:${frontier.number}` + : `queue:${frontier.number}:${reason.unmergedCount}`; return { kind: "waiting", state: { ...state, frontier, lastWaitKey: key }, diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/render.ts b/pstack/skills/poteto-mode/scripts/watch-pr/render.ts index 0e9358393..8f8aae1c9 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/render.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/render.ts @@ -137,6 +137,8 @@ export function renderPretty(verdict: T.WatcherVerdict): string { case "STATUS": return renderStatusTable(verdict.rows); case "WAITING": + if (verdict.reason.kind === "mergeability-unknown") + return `WAITING: frontier=#${verdict.frontier.number}; GitHub is still computing mergeability\n`; return verdict.reason.kind === "pending-checks" ? `WAITING: frontier=#${verdict.frontier.number}; ${verdict.reason.pending.length} check${verdict.reason.pending.length === 1 ? "" : "s"} pending\n` : `WAITING: frontier=#${verdict.frontier.number} is blocker-free; waiting for merge queue (${verdict.reason.unmergedCount} PR${verdict.reason.unmergedCount === 1 ? "" : "s"} unmerged)\n`; @@ -160,6 +162,8 @@ export function renderPretty(verdict: T.WatcherVerdict): string { return "TIMEOUT: checks still pending\n"; if (verdict.reason.kind === "status-unavailable") return "TIMEOUT: GitHub status remained unavailable\n"; + if (verdict.reason.kind === "mergeability-unknown") + return `TIMEOUT: GitHub mergeability still unknown; frontier=#${verdict.reason.frontier.number}\n`; return `TIMEOUT: queued stack still has ${verdict.reason.unmergedCount} PR${verdict.reason.unmergedCount === 1 ? "" : "s"} unmerged; frontier=#${verdict.reason.frontier.number}\n`; default: { const exhaustive: never = verdict; diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/types.ts b/pstack/skills/poteto-mode/scripts/watch-pr/types.ts index 60b9abcac..36b17bc77 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/types.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/types.ts @@ -246,14 +246,24 @@ export interface WaitingDecision { readonly frontier: PrContext; readonly pending: NonEmpty; } +/** + * GitHub reports `mergeable: UNKNOWN` while it recomputes the merge after a + * push or a base change, so a clean read cannot yet prove there is no conflict. + */ +export interface MergeabilityDecision { + readonly kind: "computing-mergeability"; + readonly frontier: PrContext; +} export type PrDecision = | { readonly kind: "blocker"; readonly blocker: MergeBlocker } | WaitingDecision + | MergeabilityDecision | { readonly kind: "ready"; readonly pr: ReadyPr } | { readonly kind: "merged"; readonly pr: MergedPr }; export type StackDecision = | { readonly kind: "blocker"; readonly blocker: MergeBlocker } | WaitingDecision + | MergeabilityDecision | { readonly kind: "clear"; readonly prs: NonEmpty }; export type WatchMode = "single" | "stack" | "queued-stack"; interface EventBase { @@ -290,6 +300,7 @@ export type ProgressVerdict = readonly kind: "pending-checks"; readonly pending: NonEmpty; } + | { readonly kind: "mergeability-unknown" } | { readonly kind: "merge-queue"; readonly unmergedCount: number }; }) | (Progress<"ADVANCE", "queued-stack"> & { @@ -338,6 +349,7 @@ export type TimeoutVerdict = Terminal<"TIMEOUT", 5> & { readonly pending: NonEmpty; } | { readonly kind: "status-unavailable"; readonly failure: QueryFailure } + | { readonly kind: "mergeability-unknown"; readonly frontier: PrContext } | { readonly kind: "queued-stack"; readonly frontier: PrContext;