From 54eac27a57fd5e95ab7fdbf9ce771f548ad86f8e Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 01:23:46 -0400 Subject: [PATCH] fix(watch-pr): keep stack parents beyond the first 300 open PRs --- .../poteto-mode/scripts/watch-pr/github.ts | 37 ++++++++++------- .../scripts/watch-pr/open-pulls.test.ts | 41 +++++++++++++++++++ .../poteto-mode/scripts/watch-pr/github.ts | 37 ++++++++++------- .../scripts/watch-pr/open-pulls.test.ts | 41 +++++++++++++++++++ 4 files changed, 128 insertions(+), 28 deletions(-) create mode 100644 harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/open-pulls.test.ts create mode 100644 pstack/skills/poteto-mode/scripts/watch-pr/open-pulls.test.ts diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/github.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/github.ts index 123a33b19..b7e748586 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/github.ts +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/github.ts @@ -489,20 +489,29 @@ export class GhGitHubReader implements T.GitHubReader { async openPullRequests( repository: T.Repository ): Promise { - const value = await runJson([ - "gh", - "pr", - "list", - "--repo", - `${repository.owner}/${repository.repo}`, - "--state", - "open", - "--limit", - "300", - "--json", - "number,headRefName,baseRefName,isCrossRepository", - ]); - return list(value, "open PRs").map((item, index) => { + // gh lists the newest PRs first and stops at --limit, so a full page may + // have cut off an older stack parent. Ask again until the list is short. + let value: readonly unknown[]; + for (let limit = 300; ; limit *= 2) { + value = list( + await runJson([ + "gh", + "pr", + "list", + "--repo", + `${repository.owner}/${repository.repo}`, + "--state", + "open", + "--limit", + String(limit), + "--json", + "number,headRefName,baseRefName,isCrossRepository", + ]), + "open PRs" + ); + if (value.length < limit) break; + } + return value.map((item, index) => { const object = record(item, `open PRs[${index}]`); if (typeof object.isCrossRepository !== "boolean") missing(`open PRs[${index}].isCrossRepository`, object.isCrossRepository); diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/open-pulls.test.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/open-pulls.test.ts new file mode 100644 index 000000000..c4e03bb40 --- /dev/null +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/open-pulls.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +describe("open pull request discovery", () => { + it("keeps a stack parent that is older than the first 300 open PRs", () => { + const directory = mkdtempSync(join(tmpdir(), "watch-pr-open-pulls-")); + try { + // Like gh, the fake returns the newest open PRs first and stops at --limit. + writeFileSync( + join(directory, "gh"), + `#!${process.execPath} +const args = process.argv.slice(2); +const limit = Number(args[args.indexOf("--limit") + 1]); +const rows = []; +for (let number = 400; number > 1; number -= 1) + rows.push({ number, headRefName: "other-" + number, baseRefName: "main", isCrossRepository: false }); +rows[0] = { number: 400, headRefName: "feature", baseRefName: "base-feature", isCrossRepository: false }; +rows.push({ number: 1, headRefName: "base-feature", baseRefName: "main", isCrossRepository: false }); +console.log(JSON.stringify(rows.slice(0, limit))); +`, + { mode: 0o755 } + ); + const program = `import { GhGitHubReader, discoverStack } from ${JSON.stringify(import.meta.dir + "/github.ts")}; + const stack = await discoverStack(new GhGitHubReader(), { owner: "owner", repo: "repo", number: 400 }); + console.log(JSON.stringify(stack.map((item) => item.number)));`; + const child = spawnSync(process.execPath, ["-e", program], { + encoding: "utf8", + timeout: 5000, + env: { ...process.env, PATH: directory + ":" + process.env.PATH }, + }); + expect(child.error).toBeUndefined(); + expect(child.status).toBe(0); + expect(JSON.parse(child.stdout)).toEqual([1, 400]); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/github.ts b/pstack/skills/poteto-mode/scripts/watch-pr/github.ts index 123a33b19..b7e748586 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/github.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/github.ts @@ -489,20 +489,29 @@ export class GhGitHubReader implements T.GitHubReader { async openPullRequests( repository: T.Repository ): Promise { - const value = await runJson([ - "gh", - "pr", - "list", - "--repo", - `${repository.owner}/${repository.repo}`, - "--state", - "open", - "--limit", - "300", - "--json", - "number,headRefName,baseRefName,isCrossRepository", - ]); - return list(value, "open PRs").map((item, index) => { + // gh lists the newest PRs first and stops at --limit, so a full page may + // have cut off an older stack parent. Ask again until the list is short. + let value: readonly unknown[]; + for (let limit = 300; ; limit *= 2) { + value = list( + await runJson([ + "gh", + "pr", + "list", + "--repo", + `${repository.owner}/${repository.repo}`, + "--state", + "open", + "--limit", + String(limit), + "--json", + "number,headRefName,baseRefName,isCrossRepository", + ]), + "open PRs" + ); + if (value.length < limit) break; + } + return value.map((item, index) => { const object = record(item, `open PRs[${index}]`); if (typeof object.isCrossRepository !== "boolean") missing(`open PRs[${index}].isCrossRepository`, object.isCrossRepository); diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/open-pulls.test.ts b/pstack/skills/poteto-mode/scripts/watch-pr/open-pulls.test.ts new file mode 100644 index 000000000..c4e03bb40 --- /dev/null +++ b/pstack/skills/poteto-mode/scripts/watch-pr/open-pulls.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +describe("open pull request discovery", () => { + it("keeps a stack parent that is older than the first 300 open PRs", () => { + const directory = mkdtempSync(join(tmpdir(), "watch-pr-open-pulls-")); + try { + // Like gh, the fake returns the newest open PRs first and stops at --limit. + writeFileSync( + join(directory, "gh"), + `#!${process.execPath} +const args = process.argv.slice(2); +const limit = Number(args[args.indexOf("--limit") + 1]); +const rows = []; +for (let number = 400; number > 1; number -= 1) + rows.push({ number, headRefName: "other-" + number, baseRefName: "main", isCrossRepository: false }); +rows[0] = { number: 400, headRefName: "feature", baseRefName: "base-feature", isCrossRepository: false }; +rows.push({ number: 1, headRefName: "base-feature", baseRefName: "main", isCrossRepository: false }); +console.log(JSON.stringify(rows.slice(0, limit))); +`, + { mode: 0o755 } + ); + const program = `import { GhGitHubReader, discoverStack } from ${JSON.stringify(import.meta.dir + "/github.ts")}; + const stack = await discoverStack(new GhGitHubReader(), { owner: "owner", repo: "repo", number: 400 }); + console.log(JSON.stringify(stack.map((item) => item.number)));`; + const child = spawnSync(process.execPath, ["-e", program], { + encoding: "utf8", + timeout: 5000, + env: { ...process.env, PATH: directory + ":" + process.env.PATH }, + }); + expect(child.error).toBeUndefined(); + expect(child.status).toBe(0); + expect(JSON.parse(child.stdout)).toEqual([1, 400]); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); +});