perf(agent-manager): batch PR status lookups into one GraphQL request per sync

A full sync resolved each worktree with 2 to 3 `gh` calls, and GraphQL is
POST so ETag revalidation cannot reduce that cost. Resolve every worktree in
one `gh api graphql` request per 10 worktrees instead, then hand the results
to the per-worktree fetch so it skips its own lookups.

Merged PRs keep their badge, forks that only share a branch name are not
attributed to a worktree, and anything the batch cannot decide falls back to
the existing per-worktree path unchanged.
This commit is contained in:
marius-kilocode
2026-09-14 13:11:46 +02:00
parent 38ee3e6607
commit 81b9c21c24
7 changed files with 1051 additions and 2 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Reduce GitHub API usage of Agent Manager PR status polling by resolving all worktrees in one request per sync.
@@ -16,6 +16,8 @@ import {
summarize,
} from "./pr/am-pr-utils"
import { TIMELINE_QUERY, parseTimeline } from "./pr/timeline"
import { seed } from "./pr/am-pr-seed"
import type { SeedHost, Seeds } from "./pr/am-pr-seed"
import type { PRResult, GhThread, GhReviewRequest, GhReview, GhTimelineItem } from "./pr/am-pr-types"
import { withContext } from "./pr/pr-comment-context"
import { oid } from "../shared/pr-comment-preview"
@@ -289,7 +291,12 @@ export class PRStatusPoller {
return
}
const thunks = targets.map((wt) => () => this.fetchOne(wt.id, generation))
// Full syncs resolve every worktree in one GraphQL request (see pr/am-pr-seed.ts)
// so the per-worktree `fetchOne` calls below skip their own `gh` lookups.
const seeds: Seeds = full ? await seed(targets, this.host(generation)) : new Map()
if (this.stale(generation)) return
const thunks = targets.map((wt) => () => this.fetchOne(wt.id, generation, undefined, seeds.get(wt.id)))
const results = full
? await settled(thunks, FULL_SYNC_CONCURRENCY)
: await Promise.allSettled(thunks.map((fn) => fn()))
@@ -302,10 +309,27 @@ export class PRStatusPoller {
this.failures++
}
/** Callbacks the batched seed needs, bound to one poll generation. */
private host(generation: number): SeedHost {
return {
branch: (wt) => (this.options.getBranch ? this.options.getBranch(wt) : Promise.resolve(wt.branch)),
git: (args, cwd) => this.shell("git", args, { cwd, timeout: 5_000 }).then((r) => r.stdout),
gh: (args, cwd) => this.gh(args, { cwd, timeout: 20_000 }).then((r) => r.stdout),
repo: (cwd) => this.getRepoInfo(cwd),
rich: () => this.rich,
degrade: () => {
this.rich = false
},
stale: () => this.stale(generation),
log: (...args) => this.options.log(...args),
}
}
private async fetchOne(
worktreeId: string,
generation = this.generation,
full = this.activeWorktreeId === worktreeId,
seeded?: PRResult | null,
): Promise<void> {
const wt = this.target(worktreeId)
if (!wt) return
@@ -314,7 +338,7 @@ export class PRStatusPoller {
try {
branch = this.options.getBranch ? await this.options.getBranch(wt) : wt.branch
if (this.stale(generation)) return
const pr = await this.cachedFetchPR(branch ?? wt.branch, wt.path)
const pr = seeded === undefined ? await this.cachedFetchPR(branch ?? wt.branch, wt.path) : seeded
if (this.stale(generation)) return
if (!pr) return this.empty(worktreeId, branch ?? wt.branch, branch)
@@ -0,0 +1,221 @@
/**
* Batched PR lookup for a full Agent Manager sync.
*
* One GraphQL document resolves every worktree in a chunk by head ref name,
* with a HEAD-SHA fallback for same-repo PRs whose local branch was renamed.
* The result is reshaped into the `gh pr view --json` shape so the shared
* `parsePRResult` parser is reused unchanged.
*/
export const CHUNK = 10
export interface BatchItem {
branch: string
head?: string
}
export interface BatchNode {
number?: number
state?: string
headRefOid?: string
isCrossRepository?: boolean
[key: string]: unknown
}
/** `home` is the repository default branch, used to hide stale merged PRs for it like gh does. */
export type BatchResult = { nodes: BatchNode[]; home?: string } | { error: string }
const FIELDS =
"id number title body url state isDraft reviewDecision additions deletions changedFiles headRefName baseRefOid headRefOid isCrossRepository createdAt author { login }"
// Mirrors the limits and shape `gh pr view --json` uses (cli/cli api/query_builder.go)
// so full-sync results and active-tick results hash identically.
const RICH = `${FIELDS} mergeable mergeStateStatus autoMergeRequest { mergeMethod } reviewRequests(first: 100) { nodes { requestedReviewer { ... on User { login avatarUrl } ... on Team { name } } } } reviews(first: 100) { nodes { author { login avatarUrl } state } } commits(last: 1) { nodes { commit { statusCheckRollup { contexts(first: 100) { totalCount nodes { __typename ... on CheckRun { name status conclusion detailsUrl startedAt completedAt checkSuite { workflowRun { workflow { name } } } } ... on StatusContext { context state targetUrl createdAt } } } } } } }`
/** Build one GraphQL document for a chunk of worktrees. */
export function query(items: BatchItem[], rich: boolean): string {
const fields = rich ? RICH : FIELDS
const aliases: string[] = []
items.forEach((item, index) => {
if (item.branch && item.branch !== "HEAD") {
// Same states and ordering as gh's finder (pkg/cmd/pr/shared/finder.go) so a
// merged PR keeps showing as merged instead of disappearing.
aliases.push(
`b${index}: pullRequests(headRefName: ${JSON.stringify(item.branch)}, states: [OPEN, CLOSED, MERGED], first: 5, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { ${fields} } }`,
)
}
if (item.head) {
aliases.push(
`c${index}: object(oid: ${JSON.stringify(item.head)}) { ... on Commit { associatedPullRequests(first: 3) { nodes { ${fields} } } } }`,
)
}
})
// Nothing to resolve; callers skip the request instead of sending unused variables.
if (aliases.length === 0) return ""
return `query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
defaultBranchRef { name }
${aliases.join("\n ")}
}
rateLimit { cost }
}`
}
/** Convert one GraphQL PR node into the `gh pr view --json` shape. */
export function reshape(node: BatchNode): Record<string, unknown> {
const result: Record<string, unknown> = {
id: node.id,
number: node.number,
title: node.title,
body: node.body,
url: node.url,
state: node.state,
isDraft: node.isDraft,
reviewDecision: node.reviewDecision,
additions: node.additions,
deletions: node.deletions,
changedFiles: node.changedFiles,
headRefName: node.headRefName,
baseRefOid: node.baseRefOid,
headRefOid: node.headRefOid,
isCrossRepository: node.isCrossRepository,
createdAt: node.createdAt,
author: node.author,
}
if (node.mergeable !== undefined) result.mergeable = node.mergeable
if (node.mergeStateStatus !== undefined) result.mergeStateStatus = node.mergeStateStatus
if (node.autoMergeRequest !== undefined) result.autoMergeRequest = node.autoMergeRequest
const requests = selection(node.reviewRequests)
if (requests) result.reviewRequests = requests
const reviews = selection(node.reviews)
if (reviews) result.reviews = reviews
const checks = flatten(node)
if (checks) result.statusCheckRollup = checks
return result
}
/**
* Parse a batch response into one result per item. Errors are mapped back to
* their alias; an error without a usable path fails the whole chunk.
*/
export function parse(json: string, items: BatchItem[]): BatchResult[] {
const payload = JSON.parse(json) as { data?: Record<string, unknown>; errors?: unknown }
const errors = Array.isArray(payload.errors) ? (payload.errors as Array<{ message?: unknown; path?: unknown }>) : []
const results: BatchResult[] = items.map(() => ({ nodes: [] }))
const build = () => {
const message = errors.map((entry) => String(entry.message ?? "Batch request failed")).at(0)
return { error: message ?? "Batch request failed" }
}
for (const entry of errors) {
const alias = Array.isArray(entry.path) ? entry.path[1] : undefined
const index = typeof alias === "string" ? aliasIndex(alias, items.length) : -1
if (index < 0) return items.map(build)
results[index] = { error: String(entry.message ?? "Batch request failed") }
}
const data = payload.data?.repository
const repo = data && typeof data === "object" ? (data as Record<string, unknown>) : undefined
// A response without the repository payload is a failed request, not "no PRs".
if (!repo) return items.map(() => ({ error: "Batch response has no repository data" }))
const ref = repo.defaultBranchRef as { name?: unknown } | null | undefined
const home = typeof ref?.name === "string" ? ref.name : undefined
for (let i = 0; i < items.length; i++) {
if ("error" in results[i]!) continue
results[i] = { nodes: merge(repo, i, items[i]!.head), home }
}
return results
}
/**
* Choose the PR for a worktree the way gh's finder does: open PRs win, then the
* newest merged or closed PR unless the branch is the default branch.
*
* `headRefName` also matches fork PRs whose branch merely shares the name
* (`main` alone matches dozens of fork PRs), while gh compares the
* `owner:branch` head label. A fork PR is therefore only a candidate when the
* local HEAD SHA proves the checkout is that fork branch. Several remaining
* open candidates are ambiguous and return undefined so the legacy path decides.
*/
export function pick(nodes: BatchNode[], head?: string, closed = true): BatchNode | undefined {
const mine = own(nodes, head)
const open = mine.filter((node) => node.state === "OPEN")
if (head) {
const match = open.find((node) => node.headRefOid === head)
if (match) return match
}
if (open.length === 1) return open.at(0)
if (open.length > 1) return undefined
if (!closed) return undefined
const rest = mine.filter((node) => node.state === "CLOSED" || node.state === "MERGED")
return (head && rest.find((node) => node.headRefOid === head)) || rest.at(0)
}
/** Candidates that can belong to this checkout: same-repo PRs, or fork PRs proven by the local HEAD SHA. */
export function own(nodes: BatchNode[], head?: string): BatchNode[] {
return nodes.filter((node) => node.isCrossRepository === false || (head !== undefined && node.headRefOid === head))
}
/** Match the rich-to-base degradation messages used by PRStatusPoller.query. */
export function unknown(message: string): boolean {
return /unknown.*field|does(?:n't| not) exist|not accessible|insufficient|forbidden/i.test(message)
}
function aliasIndex(alias: string, total: number): number {
const match = /^[bc](\d+)$/.exec(alias)
if (!match) return -1
const index = Number(match[1])
return index < total ? index : -1
}
function selection(value: unknown): unknown[] | undefined {
if (!value || typeof value !== "object") return undefined
const list = (value as { nodes?: unknown }).nodes
return Array.isArray(list) ? list : undefined
}
/** Flatten commits[0].commit.statusCheckRollup.contexts.nodes into a gh-style list. */
function flatten(node: BatchNode): unknown[] | undefined {
const commits = selection(node.commits)
const first = commits?.at(0) as { commit?: { statusCheckRollup?: { contexts?: unknown } } } | undefined
const contexts = selection(first?.commit?.statusCheckRollup?.contexts)
if (!contexts) return undefined
return contexts.map((item) => {
const check = item as Record<string, unknown>
if (check.__typename !== "CheckRun") return check
const suite = check.checkSuite as { workflowRun?: { workflow?: { name?: unknown } } } | undefined
const rest = { ...check }
delete rest.checkSuite
return { ...rest, workflowName: suite?.workflowRun?.workflow?.name }
})
}
function aliasNodes(value: unknown): BatchNode[] {
if (!value || typeof value !== "object") return []
const record = value as Record<string, unknown>
if (Array.isArray(record.nodes)) return record.nodes as BatchNode[]
const associated = record.associatedPullRequests
if (associated && typeof associated === "object") {
const list = (associated as { nodes?: unknown }).nodes
if (Array.isArray(list)) return list as BatchNode[]
}
return []
}
/**
* Merge the branch-name alias with the HEAD-SHA alias. A commit is associated
* with every PR that contains it, including the squash-merge commit of the last
* merged PR that a fresh branch off main starts from. Like the legacy
* `gh pr list --search <sha> --state open` fallback, a SHA candidate therefore
* only counts when it is open and its head is exactly the local HEAD.
*/
function merge(repo: Record<string, unknown> | undefined, index: number, head: string | undefined): BatchNode[] {
const nodes = new Map<number, BatchNode>()
const add = (node: BatchNode) => {
if (typeof node.number !== "number" || typeof node.state !== "string") return
if (!nodes.has(node.number)) nodes.set(node.number, node)
}
for (const node of aliasNodes(repo?.[`b${index}`])) add(node)
for (const node of aliasNodes(repo?.[`c${index}`])) {
if (head !== undefined && node.headRefOid === head && node.state === "OPEN") add(node)
}
return [...nodes.values()]
}
@@ -0,0 +1,128 @@
/**
* Batched PR resolution for an Agent Manager full sync.
*
* Resolves every worktree in one `gh api graphql` request per chunk and hands
* the results to the poller, which passes them straight into its per-worktree
* fetch. Anything the batch cannot decide is left out of the result so the
* poller's legacy per-worktree lookup runs for it unchanged. A failed batch is
* therefore never worse than the previous behavior.
*/
import { existsSync } from "fs"
import type { Worktree } from "../WorktreeStateManager"
import type { PRResult } from "./am-pr-types"
import { CHUNK, own, query, parse, pick, reshape, unknown } from "./am-pr-batch"
import type { BatchItem, BatchResult } from "./am-pr-batch"
import { parsePRResult } from "./am-pr-utils"
/** Everything the seed needs from the poller, as plain callbacks. */
export interface SeedHost {
branch(wt: Worktree): Promise<string | undefined>
/** Run a read-only git command and return stdout. Rejects on failure. */
git(args: string[], cwd: string): Promise<string>
/** Run a read-only gh command and return stdout. Rejects on failure. */
gh(args: string[], cwd: string): Promise<string>
repo(cwd: string): Promise<{ owner: string; name: string }>
/** Whether rich fields (merge state, auto merge) are still assumed readable. */
rich(): boolean
/** Switch the poller to base fields after an unknown-field error. */
degrade(): void
/** Whether the poll generation that started this seed has been superseded. */
stale(): boolean
log(...args: unknown[]): void
}
/** Resolved PR (or null for "no PR") per worktree id. Missing ids fall back to the legacy lookup. */
export type Seeds = Map<string, PRResult | null>
interface Item extends BatchItem {
id: string
cwd: string
}
export async function seed(targets: Worktree[], host: SeedHost): Promise<Seeds> {
const seeds: Seeds = new Map()
const items = await collect(targets, host)
if (host.stale() || items.length === 0) return seeds
const repo = await host.repo(items[0]!.cwd).catch((err: unknown) => {
host.log("Batched PR lookup failed:", message(err))
return undefined
})
if (!repo || host.stale()) return seeds
for (let start = 0; start < items.length; start += CHUNK) {
await chunk(items.slice(start, start + CHUNK), repo, host, seeds)
if (host.stale()) return seeds
}
return seeds
}
async function collect(targets: Worktree[], host: SeedHost): Promise<Item[]> {
const items: Item[] = []
for (const wt of targets) {
if (!existsSync(wt.path)) continue
const branch = await host.branch(wt)
if (host.stale()) return items
if (!branch) continue
const head = await host.git(["rev-parse", "HEAD"], wt.path).then(
(out) => out.trim() || undefined,
() => undefined,
)
if (host.stale()) return items
items.push({ id: wt.id, branch, head, cwd: wt.path })
}
return items
}
async function chunk(
items: Item[],
repo: { owner: string; name: string },
host: SeedHost,
seeds: Seeds,
): Promise<void> {
const doc = query(items, host.rich())
if (!doc) return
const args = ["api", "graphql", "-f", `query=${doc}`, "-F", `owner=${repo.owner}`, "-F", `repo=${repo.name}`]
try {
const out = await host.gh(args, items[0]!.cwd)
if (host.stale()) return
const parsed = parse(out, items)
cost(out, host)
for (let i = 0; i < items.length; i++) {
await resolve(items[i]!, parsed[i], host, seeds)
if (host.stale()) return
}
} catch (err) {
const msg = message(err)
if (host.rich() && unknown(msg)) {
host.degrade()
await chunk(items, repo, host, seeds)
return
}
host.log("Batched PR lookup failed:", msg)
}
}
async function resolve(item: Item, result: BatchResult | undefined, host: SeedHost, seeds: Seeds): Promise<void> {
if (!result || "error" in result) return
// Like gh, a default-branch worktree must not show its latest merged PR.
const node = pick(result.nodes, item.head, item.branch !== result.home)
if (node) {
seeds.set(item.id, parsePRResult(JSON.stringify(reshape(node))))
return
}
// Ambiguous candidates stay unresolved so the legacy per-worktree path decides.
if (own(result.nodes, item.head).length > 0) return
// A tracking ref such as refs/pull/N/head is only resolvable by `gh pr view`.
const merge = await host.git(["config", `branch.${item.branch}.merge`], item.cwd).catch(() => "")
if (merge.trim().startsWith("refs/pull/")) return
seeds.set(item.id, null)
}
function cost(out: string, host: SeedHost): void {
const value = (JSON.parse(out) as { data?: { rateLimit?: { cost?: number } } }).data?.rateLimit?.cost
if (value !== undefined) host.log(`Batched PR lookup cost: ${value}`)
}
function message(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}
@@ -0,0 +1,352 @@
import { describe, expect, it } from "bun:test"
import { CHUNK, own, query, reshape, parse, pick, unknown } from "../../src/agent-manager/pr/am-pr-batch"
import { parsePRResult } from "../../src/agent-manager/pr/am-pr-utils"
const base = "a".repeat(40)
const head = "b".repeat(40)
describe("am-pr-batch query", () => {
it("builds one alias per branch and head and skips b for detached HEAD", () => {
const doc = query([{ branch: "feature", head: "abc" }, { branch: "HEAD", head: "def" }, { branch: "other" }], true)
expect(doc).toContain(
`b0: pullRequests(headRefName: "feature", states: [OPEN, CLOSED, MERGED], first: 5, orderBy: { field: CREATED_AT, direction: DESC })`,
)
expect(doc).toContain("defaultBranchRef { name }")
expect(doc).toContain('c0: object(oid: "abc")')
expect(doc).not.toContain("b1:")
expect(doc).toContain('c1: object(oid: "def")')
expect(doc).toContain(`b2: pullRequests(headRefName: "other", states: [OPEN, CLOSED, MERGED]`)
expect(doc).not.toContain("c2:")
expect(doc).toContain("rateLimit { cost }")
})
it("escapes branch names as GraphQL strings", () => {
const doc = query([{ branch: 'feat"ure\\x' }], false)
expect(doc).toContain(`headRefName: ${JSON.stringify('feat"ure\\x')}`)
})
it("switches the selection between rich and base fields", () => {
expect(query([{ branch: "feature" }], true)).toContain("statusCheckRollup")
expect(query([{ branch: "feature" }], true)).toContain("mergeStateStatus")
expect(query([{ branch: "feature" }], false)).not.toContain("statusCheckRollup")
expect(query([{ branch: "feature" }], false)).not.toContain("mergeStateStatus")
})
it("mirrors the gh pr view limits so full-sync and active-tick results hash identically", () => {
const doc = query([{ branch: "feature" }], true)
expect(doc).toContain("reviewRequests(first: 100)")
expect(doc).toContain("reviews(first: 100) {")
expect(doc).not.toContain("states: [APPROVED")
expect(doc).not.toContain("event")
})
it("returns an empty document when nothing can be resolved", () => {
expect(query([{ branch: "HEAD" }], true)).toBe("")
expect(query([], false)).toBe("")
})
it("exports the chunk size used by the poller", () => {
expect(CHUNK).toBe(10)
})
})
describe("am-pr-batch reshape", () => {
const graph = {
id: "PR_1",
number: 42,
title: "Add batching",
body: "Body",
url: "https://github.com/o/r/pull/42",
state: "OPEN",
isDraft: false,
reviewDecision: "APPROVED",
additions: 10,
deletions: 2,
changedFiles: 3,
headRefName: "feature",
baseRefOid: base,
headRefOid: head,
isCrossRepository: false,
createdAt: "2026-09-01T00:00:00Z",
author: { login: "alice" },
mergeable: "MERGEABLE",
mergeStateStatus: "CLEAN",
autoMergeRequest: { mergeMethod: "SQUASH" },
reviewRequests: { nodes: [{ requestedReviewer: { login: "bob", avatarUrl: "https://avatar/bob" } }] },
reviews: { nodes: [{ author: { login: "carol", avatarUrl: "https://avatar/carol" }, state: "APPROVED" }] },
commits: {
nodes: [
{
commit: {
statusCheckRollup: {
contexts: {
totalCount: 2,
nodes: [
{
__typename: "CheckRun",
name: "build",
status: "COMPLETED",
conclusion: "SUCCESS",
detailsUrl: "https://checks/build",
startedAt: "2026-09-01T00:00:00Z",
completedAt: "2026-09-01T00:01:00Z",
checkSuite: { workflowRun: { event: "push", workflow: { name: "CI" } } },
},
{
__typename: "StatusContext",
context: "ci/legacy",
state: "SUCCESS",
targetUrl: "https://status/legacy",
createdAt: "2026-09-01T00:00:00Z",
},
],
},
},
},
},
],
},
}
const gh = {
id: "PR_1",
number: 42,
title: "Add batching",
body: "Body",
url: "https://github.com/o/r/pull/42",
state: "OPEN",
isDraft: false,
reviewDecision: "APPROVED",
additions: 10,
deletions: 2,
changedFiles: 3,
headRefName: "feature",
baseRefOid: base,
headRefOid: head,
isCrossRepository: false,
createdAt: "2026-09-01T00:00:00Z",
author: { login: "alice" },
mergeable: "MERGEABLE",
mergeStateStatus: "CLEAN",
autoMergeRequest: { mergeMethod: "SQUASH" },
reviewRequests: [{ requestedReviewer: { login: "bob", avatarUrl: "https://avatar/bob" } }],
reviews: [{ author: { login: "carol", avatarUrl: "https://avatar/carol" }, state: "APPROVED" }],
statusCheckRollup: [
{
__typename: "CheckRun",
name: "build",
status: "COMPLETED",
conclusion: "SUCCESS",
detailsUrl: "https://checks/build",
startedAt: "2026-09-01T00:00:00Z",
completedAt: "2026-09-01T00:01:00Z",
workflowName: "CI",
},
{
__typename: "StatusContext",
context: "ci/legacy",
state: "SUCCESS",
targetUrl: "https://status/legacy",
createdAt: "2026-09-01T00:00:00Z",
},
],
}
it("produces the same parsed PR as the equivalent gh JSON", () => {
const parsed = parsePRResult(JSON.stringify(reshape(graph)))
expect(parsed).toEqual(parsePRResult(JSON.stringify(gh)))
expect(parsed?.reviewers).toEqual([
{ login: "bob", avatar: "https://avatar/bob", state: "pending" },
{ login: "carol", avatar: "https://avatar/carol", state: "approved" },
])
expect(parsed?.merge).toEqual({ mergeable: "mergeable", state: "clean", auto: "squash" })
expect(parsed?.review).toBe("approved")
expect(parsed?.checks?.passed).toBe(2)
})
it("omits rich-only fields for base selections", () => {
const data = reshape({ number: 1, state: "OPEN", commits: { nodes: [] } })
expect(data.mergeable).toBeUndefined()
expect(data.reviewRequests).toBeUndefined()
expect(data.statusCheckRollup).toBeUndefined()
})
it("flattens the newest status check rollup with the workflow name", () => {
const data = reshape(graph)
const checks = data.statusCheckRollup as Array<{ name?: string; workflowName?: string }>
expect(checks).toHaveLength(2)
expect(checks.at(0)).toMatchObject({ name: "build", workflowName: "CI" })
})
it("emits exactly the gh statusCheckRollup shape with no extra keys", () => {
// The check dedupe key in parsePRResult includes workflowName and event, so any
// extra key here would make batch results hash differently from `gh pr view`.
expect(reshape(graph).statusCheckRollup).toEqual(gh.statusCheckRollup)
})
})
describe("am-pr-batch pick", () => {
const node = (number: number, ref: string, cross: boolean) => ({
number,
state: "OPEN",
headRefOid: ref,
isCrossRepository: cross,
})
it("prefers the node whose head matches the worktree HEAD", () => {
expect(pick([node(1, "aaa", true), node(2, "bbb", true)], "bbb")?.number).toBe(2)
})
it("returns the only same-repo node when the head does not match (local commits)", () => {
expect(pick([node(1, "aaa", false)], "zzz")?.number).toBe(1)
})
it("never attributes a fork PR that only shares the branch name, like gh's owner:branch label check", () => {
// `pullRequests(headRefName: "main")` on the base repo matches dozens of fork PRs.
expect(pick([node(1, "aaa", true)], "zzz")).toBeUndefined()
expect(pick([node(1, "aaa", true)], undefined)).toBeUndefined()
expect(own([node(1, "aaa", true), node(2, "bbb", false)], "zzz").map((n) => n.number)).toEqual([2])
})
it("accepts a fork PR only when the local HEAD SHA proves the checkout is that branch", () => {
expect(pick([node(1, "aaa", true), node(2, "bbb", true)], "bbb")?.number).toBe(2)
})
it("prefers the same-repo PR when a fork shares the name and the head matches neither", () => {
expect(pick([node(1, "aaa", true), node(2, "bbb", false)], "zzz")?.number).toBe(2)
})
it("falls back to the newest merged or closed PR when nothing is open, like gh", () => {
const merged = { number: 9, state: "MERGED", headRefOid: "bbb", isCrossRepository: false }
const older = { number: 3, state: "CLOSED", headRefOid: "ccc", isCrossRepository: false }
expect(pick([merged, older], "zzz")?.number).toBe(9)
expect(pick([merged, older], "ccc")?.number).toBe(3)
})
it("prefers an open PR over a newer merged one", () => {
expect(
pick([{ number: 9, state: "MERGED", headRefOid: "bbb", isCrossRepository: false }, node(1, "aaa", false)], "zzz")
?.number,
).toBe(1)
})
it("hides merged and closed PRs for the default branch", () => {
expect(
pick([{ number: 9, state: "MERGED", headRefOid: "bbb", isCrossRepository: false }], "bbb", false),
).toBeUndefined()
expect(pick([node(1, "aaa", false)], "aaa", false)?.number).toBe(1)
})
})
describe("am-pr-batch parse", () => {
it("merges b and c aliases and dedupes by number", () => {
const items = [{ branch: "one", head: "h1" }, { branch: "two" }]
const json = JSON.stringify({
data: {
repository: {
b0: {
nodes: [
{ number: 1, state: "OPEN" },
{ number: 2, state: "OPEN" },
],
},
c0: {
__typename: "Commit",
associatedPullRequests: {
nodes: [
{ number: 2, state: "OPEN", headRefOid: "h1" },
{ number: 3, state: "CLOSED", headRefOid: "h1" },
{ number: 4, state: "OPEN", headRefOid: "other" },
],
},
},
b1: { nodes: [] },
},
},
})
expect(parse(json, items)).toEqual([
{
nodes: [
{ number: 1, state: "OPEN" },
{ number: 2, state: "OPEN" },
],
},
{ nodes: [] },
])
})
it("ignores SHA-associated PRs unless open with the exact local HEAD, like the legacy sha search", () => {
// A fresh branch off main sits on the squash-merge commit of the last merged PR.
const json = JSON.stringify({
data: {
repository: {
b0: { nodes: [] },
c0: {
__typename: "Commit",
associatedPullRequests: {
nodes: [{ number: 14113, state: "MERGED", headRefOid: "prhead", isCrossRepository: false }],
},
},
},
},
})
expect(parse(json, [{ branch: "fresh-branch", head: "mergecommit" }])).toEqual([{ nodes: [] }])
})
it("reports the default branch so the poller can hide stale merged PRs for it", () => {
const json = JSON.stringify({ data: { repository: { defaultBranchRef: { name: "main" }, b0: { nodes: [] } } } })
expect(parse(json, [{ branch: "main" }])).toEqual([{ nodes: [], home: "main" }])
})
it("maps an aliased error to its item only", () => {
const json = JSON.stringify({
data: { repository: { b0: { nodes: [] } } },
errors: [{ message: "bad field", path: ["repository", "b0", "pullRequests"] }],
})
expect(parse(json, [{ branch: "one" }, { branch: "two" }])).toEqual([{ error: "bad field" }, { nodes: [] }])
})
it("fails the whole chunk for an error without an alias path", () => {
const json = JSON.stringify({ errors: [{ message: "auth" }] })
expect(parse(json, [{ branch: "one" }, { branch: "two" }])).toEqual([{ error: "auth" }, { error: "auth" }])
})
it("fails the chunk when the repository payload is missing instead of reporting no PRs", () => {
const items = [{ branch: "one" }, { branch: "two" }]
for (const json of [
JSON.stringify({}),
JSON.stringify({ data: {} }),
JSON.stringify({ data: { repository: null } }),
]) {
const results = parse(json, items)
expect(results).toHaveLength(2)
for (const result of results) expect("error" in result).toBe(true)
}
})
it("matches no nodes for a detached branch", () => {
const json = JSON.stringify({
data: {
repository: {
c0: {
__typename: "Commit",
associatedPullRequests: { nodes: [{ number: 1, state: "OPEN", headRefOid: "h1" }] },
},
},
},
})
expect(parse(json, [{ branch: "HEAD", head: "h1" }])).toEqual([
{ nodes: [{ number: 1, state: "OPEN", headRefOid: "h1" }] },
])
})
})
describe("am-pr-batch unknown", () => {
it("matches the degradation messages used by the poller", () => {
expect(unknown('Unknown JSON field: "statusCheckRollup"')).toBe(true)
expect(unknown("GraphQL: Resource not accessible by integration")).toBe(true)
expect(unknown("insufficient permissions")).toBe(true)
expect(unknown("forbidden")).toBe(true)
expect(unknown("network timeout")).toBe(false)
})
})
@@ -0,0 +1,70 @@
import { describe, expect, it } from "bun:test"
import { seed } from "../../src/agent-manager/pr/am-pr-seed"
import type { SeedHost } from "../../src/agent-manager/pr/am-pr-seed"
import type { Worktree } from "../../src/agent-manager/WorktreeStateManager"
const head = "a".repeat(40)
function worktree(id: string, branch: string): Worktree {
return { id, branch, path: process.cwd(), parentBranch: "main", createdAt: "2026-09-01T00:00:00Z" }
}
function node(number: number, extra: Record<string, unknown> = {}) {
return { number, state: "OPEN", isCrossRepository: false, headRefOid: head, title: `PR ${number}`, ...extra }
}
function host(reply: (query: string) => unknown, tracking = ""): SeedHost & { calls: string[][] } {
const calls: string[][] = []
return {
calls,
branch: async (wt) => wt.branch,
git: async (args) => (args[0] === "rev-parse" ? `${head}\n` : tracking),
gh: async (args) => {
calls.push(args)
const payload = reply(args[3] ?? "")
if (payload instanceof Error) throw payload
return JSON.stringify(payload)
},
repo: async () => ({ owner: "o", name: "r" }),
rich: () => true,
degrade: () => {},
stale: () => false,
log: () => {},
}
}
describe("am-pr-seed", () => {
it("resolves all worktrees with one request and marks branches without a PR as null", async () => {
const h = host(() => ({
data: { repository: { defaultBranchRef: { name: "main" }, b0: { nodes: [node(7)] }, b1: { nodes: [] } } },
}))
const seeds = await seed([worktree("w1", "feature"), worktree("w2", "fresh")], h)
expect(h.calls).toHaveLength(1)
expect(seeds.get("w1")?.number).toBe(7)
expect(seeds.get("w2")).toBeNull()
})
it("leaves a worktree unresolved for a tracking ref, an ambiguous match, or an alias error", async () => {
const h = host(
() => ({
data: {
repository: {
b0: { nodes: [] },
b1: { nodes: [node(1, { headRefOid: "x" }), node(2, { headRefOid: "y" })] },
b2: { nodes: [] },
},
},
errors: [{ message: "boom", path: ["repository", "b2", "pullRequests"] }],
}),
"refs/pull/9/head\n",
)
const seeds = await seed([worktree("w1", "imported"), worktree("w2", "dup"), worktree("w3", "broken")], h)
expect(seeds.size).toBe(0)
})
it("returns nothing when the batch request fails so the legacy path runs", async () => {
const h = host(() => new Error("network"))
const seeds = await seed([worktree("w1", "feature")], h)
expect(seeds.size).toBe(0)
})
})
@@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises"
import { describe, expect, it, beforeEach, afterEach, afterAll, spyOn } from "bun:test"
import * as actions from "../../src/agent-manager/pr/PRActions"
import * as gh from "../../src/agent-manager/gh"
import * as shellEnv from "../../src/agent-manager/shell-env"
const resolveComment = spyOn(actions, "resolveComment").mockResolvedValue(undefined)
const unresolveComment = spyOn(actions, "unresolveComment").mockResolvedValue(undefined)
@@ -1422,3 +1423,251 @@ describe("PRStatusBridge.handleMessage commentReaction", () => {
)
})
})
// --- batched full-sync lookups ---
function graphQuery(args: string[]): string {
return args.find((arg) => arg.startsWith("query=")) ?? ""
}
const batchNode = {
id: "PR_7",
number: 7,
title: "Batched PR",
body: "Body",
url: "https://github.com/example/repo/pull/7",
state: "OPEN",
isDraft: false,
reviewDecision: null,
additions: 1,
deletions: 0,
changedFiles: 1,
headRefName: "feature",
baseRefOid: refs.baseRefOid,
headRefOid: refs.headRefOid,
isCrossRepository: false,
createdAt: "2026-09-01T00:00:00Z",
author: { login: "alice" },
mergeable: "MERGEABLE",
mergeStateStatus: "CLEAN",
autoMergeRequest: null,
reviewRequests: { nodes: [] },
reviews: { nodes: [] },
commits: { nodes: [{ commit: { statusCheckRollup: { contexts: { totalCount: 0, nodes: [] } } } }] },
}
const legacy = {
number: 9,
title: "Legacy PR",
body: "",
url: "https://github.com/example/repo/pull/9",
state: "OPEN",
isDraft: false,
reviewDecision: null,
additions: 0,
deletions: 0,
changedFiles: 0,
headRefName: "feature",
baseRefOid: refs.baseRefOid,
headRefOid: refs.headRefOid,
statusCheckRollup: [],
reviewRequests: [],
reviews: [],
}
function batchPayload(query: string, node: unknown): unknown {
const repo: Record<string, unknown> = {}
for (const match of query.matchAll(/([bc]\d+):/g)) {
const alias = match[1]!
repo[alias] = alias === "b0" && node ? { nodes: [node] } : { nodes: [] }
}
return { data: { repository: repo, rateLimit: { cost: 2 } } }
}
function ghRouter(calls: string[][], node: unknown) {
return async (args: string[]) => {
calls.push(args)
if (args[0] === "--version") return { stdout: "gh version 2", stderr: "" }
if (args[0] === "repo")
return {
stdout: JSON.stringify({
owner: { login: "example" },
name: "repo",
squashMergeAllowed: true,
mergeCommitAllowed: true,
rebaseMergeAllowed: true,
viewerPermission: "WRITE",
}),
stderr: "",
}
if (args[0] === "api" && args[1] === "repos")
return { stdout: JSON.stringify({ allow_auto_merge: false }), stderr: "" }
if (args[0] === "api") {
const query = graphQuery(args)
if (query.includes("pullRequests(headRefName"))
return { stdout: JSON.stringify(batchPayload(query, node)), stderr: "" }
if (query.includes("reviewThreads")) return { stdout: JSON.stringify(page([])), stderr: "" }
return {
stdout: JSON.stringify({
data: { repository: { pullRequest: { reviewRequests: { nodes: [] }, reviews: { nodes: [] } } } },
}),
stderr: "",
}
}
if (args[0] === "pr" && args[1] === "checks") return { stdout: "[]", stderr: "" }
return { stdout: JSON.stringify(legacy), stderr: "" }
}
}
describe("PRStatusPoller batched full sync", () => {
const git = spyOn(shellEnv, "execWithShellEnv")
beforeEach(() => {
execute.mockReset()
git.mockReset()
git.mockImplementation(async (cmd: string, args: string[]) => {
if (cmd === "git" && args[0] === "rev-parse") return { stdout: `${refs.headRefOid}\n`, stderr: "" }
if (cmd === "git" && args[0] === "config") return { stdout: "", stderr: "" }
return { stdout: "", stderr: "" }
})
})
afterEach(() => {
execute.mockReset()
git.mockReset()
})
afterAll(() => git.mockRestore())
it("resolves every worktree in one GraphQL request and skips pr view", async () => {
const { bridge, sent, worktrees } = harness()
worktrees.at(0)!.path = process.cwd()
const calls: string[][] = []
execute.mockImplementation(ghRouter(calls, batchNode))
const internal = bridge.poller as unknown as { fetchAll: () => Promise<void> }
await internal.fetchAll()
expect(calls.filter((args) => graphQuery(args).includes("pullRequests(headRefName"))).toHaveLength(1)
expect(calls.filter((args) => args[0] === "pr")).toEqual([])
expect(sent).toEqual([
expect.objectContaining({
type: "agentManager.prStatus",
worktreeId: "wt1",
pr: expect.objectContaining({ number: 7 }),
}),
])
})
it("keeps showing a merged PR from the batch instead of dropping it", async () => {
const { bridge, sent, worktrees } = harness()
worktrees.at(0)!.path = process.cwd()
const calls: string[][] = []
execute.mockImplementation(ghRouter(calls, { ...batchNode, state: "MERGED", mergeStateStatus: "UNKNOWN" }))
const internal = bridge.poller as unknown as { fetchAll: () => Promise<void> }
await internal.fetchAll()
expect(calls.filter((args) => args[0] === "pr")).toEqual([])
expect(sent).toEqual([
expect.objectContaining({
type: "agentManager.prStatus",
worktreeId: "wt1",
pr: expect.objectContaining({ number: 7, state: "merged" }),
}),
])
})
it("does not attribute a fork PR that only shares the branch name and skips legacy lookups", async () => {
// headRefName "main" on the base repo matches fork PRs opened from the fork's main.
const { bridge, sent, worktrees } = harness()
worktrees.at(0)!.path = process.cwd()
const calls: string[][] = []
execute.mockImplementation(
ghRouter(calls, { ...batchNode, number: 13207, isCrossRepository: true, headRefOid: "f".repeat(40) }),
)
const internal = bridge.poller as unknown as { fetchAll: () => Promise<void> }
await internal.fetchAll()
expect(calls.filter((args) => args[0] === "pr")).toEqual([])
expect(sent).toEqual([expect.objectContaining({ type: "agentManager.prStatus", worktreeId: "wt1", pr: null })])
})
it("runs the legacy pr view when the batch returns no nodes for a tracking ref", async () => {
const { bridge, sent, worktrees } = harness()
worktrees.at(0)!.path = process.cwd()
const calls: string[][] = []
execute.mockImplementation(ghRouter(calls, undefined))
git.mockImplementation(async (cmd: string, args: string[]) => {
if (cmd === "git" && args[0] === "rev-parse") return { stdout: `${refs.headRefOid}\n`, stderr: "" }
if (cmd === "git" && args[0] === "config") return { stdout: "refs/pull/9/head\n", stderr: "" }
return { stdout: "", stderr: "" }
})
const internal = bridge.poller as unknown as { fetchAll: () => Promise<void> }
await internal.fetchAll()
expect(calls.filter((args) => args[0] === "pr" && args[1] === "view").length).toBeGreaterThan(0)
expect(sent).toEqual([
expect.objectContaining({
type: "agentManager.prStatus",
worktreeId: "wt1",
pr: expect.objectContaining({ number: 9 }),
}),
])
})
it("falls back to legacy lookups when the batch request rejects", async () => {
const { bridge, sent, worktrees } = harness()
worktrees.at(0)!.path = process.cwd()
const calls: string[][] = []
const router = ghRouter(calls, batchNode)
execute.mockImplementation(async (args: string[]) => {
if (graphQuery(args).includes("pullRequests(headRefName")) throw new Error("network error")
return router(args)
})
const internal = bridge.poller as unknown as { fetchAll: () => Promise<void> }
await internal.fetchAll()
expect(calls.filter((args) => args[0] === "pr" && args[1] === "view").length).toBeGreaterThan(0)
expect(sent).toEqual([
expect.objectContaining({
type: "agentManager.prStatus",
worktreeId: "wt1",
pr: expect.objectContaining({ number: 9 }),
}),
])
})
it("retries the batch with base fields after an unknown-field error", async () => {
const { bridge, sent, worktrees } = harness()
worktrees.at(0)!.path = process.cwd()
const calls: string[][] = []
const router = ghRouter(calls, { number: 7, state: "OPEN", isCrossRepository: false, headRefOid: refs.headRefOid })
execute.mockImplementation(async (args: string[]) => {
const query = graphQuery(args)
if (query.includes("pullRequests(headRefName") && query.includes("statusCheckRollup")) {
calls.push(args)
throw new Error('GraphQL: Unknown field "mergeStateStatus"')
}
return router(args)
})
const internal = bridge.poller as unknown as { fetchAll: () => Promise<void> }
await internal.fetchAll()
const batches = calls.filter((args) => graphQuery(args).includes("pullRequests(headRefName"))
expect(batches).toHaveLength(2)
expect(graphQuery(batches[0]!)).toContain("statusCheckRollup")
expect(graphQuery(batches[1]!)).not.toContain("statusCheckRollup")
expect(sent).toEqual([
expect.objectContaining({
type: "agentManager.prStatus",
worktreeId: "wt1",
pr: expect.objectContaining({ number: 7 }),
}),
])
})
})