mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-17 17:43:04 +08:00
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:
@@ -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 }),
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user