mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
fix(agent-manager): reduce background Git and GitHub process churn
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Reduce Agent Manager background Git and GitHub activity without adding file watchers.
|
||||
@@ -5363,6 +5363,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
// ── Worktree stats polling (sidebar diff badge) ──────────────────
|
||||
private startStatsPolling(): void {
|
||||
if (this.opts.disableStatsPolling) return
|
||||
this.statsPoller?.stop()
|
||||
this.statsGitOps?.dispose()
|
||||
const git = new GitOps({ log: () => {} })
|
||||
|
||||
@@ -201,7 +201,7 @@ export class GitStatsPoller {
|
||||
const worktrees = this.options.getWorktrees()
|
||||
if (worktrees.length === 0) return
|
||||
|
||||
const presence = await this.probeWorktreePresence(worktrees)
|
||||
const presence = await this.probeWorktreePresence(worktrees, refs)
|
||||
if (generation !== this.generation) return
|
||||
this.options.onWorktreePresence?.(presence)
|
||||
|
||||
@@ -313,12 +313,18 @@ export class GitStatsPoller {
|
||||
.join("|")
|
||||
}
|
||||
|
||||
private async probeWorktreePresence(worktrees: Worktree[]): Promise<WorktreePresenceResult> {
|
||||
private async probeWorktreePresence(worktrees: Worktree[], refs?: RefSnapshot): Promise<WorktreePresenceResult> {
|
||||
const root = this.options.getWorkspaceRoot()
|
||||
if (!root) {
|
||||
return { worktrees: [], degraded: true }
|
||||
}
|
||||
|
||||
const paths = refs?.worktreePaths
|
||||
if (paths) {
|
||||
const items = await Promise.all(worktrees.map((wt) => this.presence(wt, root, paths)))
|
||||
if (items.every((item) => !item.missing)) return { worktrees: items, degraded: false }
|
||||
}
|
||||
|
||||
const tracked = await this.git.listWorktreePaths(root).catch((err) => {
|
||||
this.options.log("Failed to list worktree paths:", err)
|
||||
return undefined
|
||||
@@ -343,6 +349,16 @@ export class GitStatsPoller {
|
||||
return { worktrees: worktreeStatuses, degraded: false }
|
||||
}
|
||||
|
||||
private async presence(wt: Worktree, root: string, paths: Map<string, string>): Promise<WorktreePresence> {
|
||||
const abs = path.isAbsolute(wt.path) ? wt.path : path.join(root, wt.path)
|
||||
const exists = await fs.promises.access(abs).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
const branch = exists ? findTrackedBranch(paths, abs) : undefined
|
||||
return { worktreeId: wt.id, missing: !exists || branch === undefined, branch }
|
||||
}
|
||||
|
||||
private async fetchLocalStats(generation = this.generation, refs?: RefSnapshot, refresh = false): Promise<void> {
|
||||
const root = this.options.getWorkspaceRoot()
|
||||
if (!root) return
|
||||
|
||||
@@ -46,7 +46,7 @@ export class PRStatusPoller {
|
||||
private ghAvailable: boolean | undefined
|
||||
private ghProbeTime = 0
|
||||
private activeWorktreeId: string | undefined
|
||||
private cachedRepo: { owner: string; name: string; cwd: string } | undefined
|
||||
private cachedRepo: { owner: string; name: string; root: string } | undefined
|
||||
private prCache = new Map<string, { result: PRResult | null; expires: number }>()
|
||||
private lastFullSync = 0 // timestamp of last full (all-worktree) sync
|
||||
private readonly intervalMs: number
|
||||
@@ -134,7 +134,7 @@ export class PRStatusPoller {
|
||||
refresh(worktreeId: string): void {
|
||||
if (!this.active) return
|
||||
const wt = this.options.getWorktrees().find((w) => w.id === worktreeId)
|
||||
if (wt) this.prCache.delete(wt.branch)
|
||||
if (wt) this.prCache.delete(this.key(wt.branch, wt.path))
|
||||
void this.fetchOne(worktreeId)
|
||||
}
|
||||
|
||||
@@ -258,8 +258,7 @@ export class PRStatusPoller {
|
||||
}
|
||||
|
||||
const [checks, reviewers, comments] = await Promise.all([
|
||||
this.fetchChecks(pr.number, wt.path),
|
||||
this.fetchReviewers(pr.number, wt.path),
|
||||
...this.extras(pr, wt.path),
|
||||
this.activeWorktreeId === worktreeId ? this.fetchComments(pr.number, wt.path) : undefined,
|
||||
])
|
||||
if (this.stale(generation)) return
|
||||
@@ -294,6 +293,10 @@ export class PRStatusPoller {
|
||||
}
|
||||
}
|
||||
|
||||
private extras(pr: PRResult, cwd: string) {
|
||||
return [pr.checks ?? this.fetchChecks(pr.number, cwd), pr.reviewers ?? this.fetchReviewers(pr.number, cwd)] as const
|
||||
}
|
||||
|
||||
private handleError(worktreeId: string, branch: string, cwd: string, err: unknown): void {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
const kind = existsSync(cwd) ? classifyPRError(msg) : "unknown"
|
||||
@@ -313,20 +316,26 @@ export class PRStatusPoller {
|
||||
return worktree
|
||||
}
|
||||
|
||||
private static readonly PR_JSON_FIELDS =
|
||||
private static readonly BASE_JSON_FIELDS =
|
||||
"number,title,body,url,state,isDraft,reviewDecision,additions,deletions,changedFiles,headRefName,headRefOid"
|
||||
private static readonly PR_JSON_FIELDS = `${PRStatusPoller.BASE_JSON_FIELDS},statusCheckRollup,reviewRequests,reviews`
|
||||
|
||||
/** Return a cached PR lookup if still fresh, otherwise fetch and cache.
|
||||
* Keyed by branch name so multiple worktrees on the same branch share
|
||||
* the cache, and a branch switch in a worktree naturally misses. */
|
||||
private async cachedFetchPR(branch: string, cwd: string): Promise<PRResult | null> {
|
||||
const cached = this.prCache.get(branch)
|
||||
const key = this.key(branch, cwd)
|
||||
const cached = this.prCache.get(key)
|
||||
if (cached && Date.now() < cached.expires) return cached.result
|
||||
const result = await this.fetchPRForBranch(branch, cwd)
|
||||
this.prCache.set(branch, { result, expires: Date.now() + PR_LOOKUP_TTL })
|
||||
this.prCache.set(key, { result, expires: Date.now() + PR_LOOKUP_TTL })
|
||||
return result
|
||||
}
|
||||
|
||||
private key(branch: string, cwd: string): string {
|
||||
return `${this.options.getWorkspaceRoot() ?? cwd}\0${branch === "HEAD" ? cwd : branch}`
|
||||
}
|
||||
|
||||
private async fetchPRForBranch(branch: string, cwd: string): Promise<PRResult | null> {
|
||||
// Strategy 1: bare `gh pr view` — resolves via the branch's tracking ref.
|
||||
// Works for fork PRs checked out with `gh pr checkout` (tracking ref = refs/pull/N/head).
|
||||
@@ -340,10 +349,7 @@ export class PRStatusPoller {
|
||||
try {
|
||||
const args = ["pr", "view"]
|
||||
if (branch) args.push(branch)
|
||||
args.push("--json", PRStatusPoller.PR_JSON_FIELDS)
|
||||
|
||||
const { stdout } = await this.gh(args, { cwd, timeout: 15_000 })
|
||||
return parsePRResult(stdout)
|
||||
return parsePRResult(await this.query(args, cwd))
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (msg.includes("no pull requests found") || msg.includes("Could not resolve")) return null
|
||||
@@ -351,6 +357,16 @@ export class PRStatusPoller {
|
||||
}
|
||||
}
|
||||
|
||||
private async query(args: string[], cwd: string): Promise<string> {
|
||||
try {
|
||||
return (await this.gh([...args, "--json", PRStatusPoller.PR_JSON_FIELDS], { cwd, timeout: 15_000 })).stdout
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (!/unknown.*field|does(?:n't| not) exist|not accessible|insufficient|forbidden/i.test(msg)) throw err
|
||||
return (await this.gh([...args, "--json", PRStatusPoller.BASE_JSON_FIELDS], { cwd, timeout: 15_000 })).stdout
|
||||
}
|
||||
}
|
||||
|
||||
/** Search for PRs containing the current HEAD SHA. Finds PRs when branch name/tracking ref don't match. */
|
||||
private async ghPRListBySHA(cwd: string): Promise<PRResult | null> {
|
||||
try {
|
||||
@@ -358,20 +374,9 @@ export class PRStatusPoller {
|
||||
const head = sha.trim()
|
||||
if (!head) return null
|
||||
|
||||
const { stdout } = await this.gh(
|
||||
[
|
||||
"pr",
|
||||
"list",
|
||||
"--state",
|
||||
"all",
|
||||
"--search",
|
||||
`${head} is:pr`,
|
||||
"--limit",
|
||||
"5",
|
||||
"--json",
|
||||
PRStatusPoller.PR_JSON_FIELDS,
|
||||
],
|
||||
{ cwd, timeout: 15_000 },
|
||||
const stdout = await this.query(
|
||||
["pr", "list", "--state", "all", "--search", `${head} is:pr`, "--limit", "5"],
|
||||
cwd,
|
||||
)
|
||||
const items = JSON.parse(stdout) as unknown[]
|
||||
if (!Array.isArray(items) || items.length === 0) return null
|
||||
@@ -433,15 +438,14 @@ export class PRStatusPoller {
|
||||
}
|
||||
|
||||
private async getRepoInfo(cwd: string): Promise<{ owner: string; name: string }> {
|
||||
if (this.cachedRepo && this.cachedRepo.cwd === cwd) {
|
||||
return this.cachedRepo
|
||||
}
|
||||
const root = this.options.getWorkspaceRoot() ?? cwd
|
||||
if (this.cachedRepo?.root === root) return this.cachedRepo
|
||||
const { stdout } = await this.gh(["repo", "view", "--json", "owner,name"], {
|
||||
cwd,
|
||||
timeout: 10_000,
|
||||
})
|
||||
const data = JSON.parse(stdout)
|
||||
const info = { owner: data.owner.login as string, name: data.name as string, cwd }
|
||||
const info = { owner: data.owner.login as string, name: data.name as string, root }
|
||||
this.cachedRepo = info
|
||||
return info
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface StatusSnapshot {
|
||||
export interface RefSnapshot {
|
||||
oids: Map<string, string>
|
||||
upstreams: Map<string, string>
|
||||
worktreePaths?: Map<string, string>
|
||||
}
|
||||
|
||||
export interface GitStatsSource {
|
||||
@@ -46,6 +47,27 @@ function tail(record: string, fields: number): string | undefined {
|
||||
return record.slice(offset)
|
||||
}
|
||||
|
||||
function parse(raw: Buffer, linked: boolean): RefSnapshot {
|
||||
const fields = raw.toString("utf8").split("\0")
|
||||
const oids = new Map<string, string>()
|
||||
const upstreams = new Map<string, string>()
|
||||
const paths = linked ? new Map<string, string>() : undefined
|
||||
const size = linked ? 4 : 3
|
||||
|
||||
for (let i = 0; i + size - 1 < fields.length; i += size) {
|
||||
const ref = fields[i]?.replace(/^\n/, "")
|
||||
const oid = fields[i + 1]
|
||||
const upstream = fields[i + 2]
|
||||
const worktree = linked ? fields[i + 3] : undefined
|
||||
if (!ref || !oid) continue
|
||||
oids.set(ref, oid)
|
||||
if (upstream) upstreams.set(ref, upstream)
|
||||
if (worktree && ref.startsWith("refs/heads/")) paths?.set(worktree, ref.slice(11))
|
||||
}
|
||||
|
||||
return { oids, upstreams, ...(paths ? { worktreePaths: paths } : {}) }
|
||||
}
|
||||
|
||||
function records(raw: Buffer): { branch: string; head: string; paths: PathState[]; untracked: string[] } {
|
||||
const items = raw.toString("utf8").split("\0")
|
||||
const paths: PathState[] = []
|
||||
@@ -150,6 +172,8 @@ export function shortRef(ref: string): string {
|
||||
}
|
||||
|
||||
export class GitStatsSnapshot implements GitStatsSource {
|
||||
private supported: boolean | undefined
|
||||
|
||||
constructor(private readonly git: GitOps) {}
|
||||
|
||||
async status(dir: string): Promise<StatusSnapshot> {
|
||||
@@ -181,21 +205,30 @@ export class GitStatsSnapshot implements GitStatsSource {
|
||||
}
|
||||
|
||||
async refs(root: string): Promise<RefSnapshot> {
|
||||
if (this.supported !== false) {
|
||||
const result = await this.git.execGitBuffer(
|
||||
[
|
||||
"for-each-ref",
|
||||
"--format=%(refname)%00%(objectname)%00%(upstream)%00%(worktreepath)%00",
|
||||
"refs/heads",
|
||||
"refs/remotes",
|
||||
],
|
||||
root,
|
||||
)
|
||||
if (result.code === 0) {
|
||||
this.supported = true
|
||||
return parse(result.stdout, true)
|
||||
}
|
||||
const error = result.stderr.toLowerCase()
|
||||
if (error.includes("unknown field") || error.includes("unknown atom")) this.supported = false
|
||||
}
|
||||
|
||||
const result = await this.git.execGitBuffer(
|
||||
["for-each-ref", "--format=%(refname)%00%(objectname)%00%(upstream)%00", "refs/heads", "refs/remotes"],
|
||||
root,
|
||||
)
|
||||
if (result.code !== 0) throw new Error(result.stderr.trim() || "git for-each-ref failed")
|
||||
const oids = new Map<string, string>()
|
||||
const upstreams = new Map<string, string>()
|
||||
for (const line of result.stdout.toString("utf8").split("\n")) {
|
||||
if (!line) continue
|
||||
const [ref, oid, upstream] = line.split("\0")
|
||||
if (!ref || !oid) continue
|
||||
oids.set(ref, oid)
|
||||
if (upstream) upstreams.set(ref, upstream)
|
||||
}
|
||||
return { oids, upstreams }
|
||||
return parse(result.stdout, false)
|
||||
}
|
||||
|
||||
async diff(dir: string, base: string, untracked: string[]): Promise<DiffStats> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PRState, ReviewDecision } from "../types"
|
||||
import type { PRState, PRStatus, ReviewDecision } from "../types"
|
||||
|
||||
// Raw shapes returned by `gh pr view --json`
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface GhThread {
|
||||
}
|
||||
export interface GhReviewRequest {
|
||||
requestedReviewer?: GhAuthor
|
||||
login?: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
export interface GhReview {
|
||||
author?: GhAuthor
|
||||
@@ -41,4 +43,6 @@ export interface PRResult {
|
||||
additions: number
|
||||
deletions: number
|
||||
files: number
|
||||
checks?: PRStatus["checks"]
|
||||
reviewers?: PRStatus["reviewers"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash } from "node:crypto"
|
||||
import type { CheckStatus, PRComment, PRReviewer, PRStatus, ReviewerState } from "../types"
|
||||
import type { CheckStatus, PRCheck, PRComment, PRReviewer, PRStatus, ReviewerState } from "../types"
|
||||
import type { PRResult, GhThread, GhReviewRequest, GhReview } from "./am-pr-types"
|
||||
|
||||
export function parsePRResult(json: string): PRResult | null {
|
||||
@@ -15,7 +15,7 @@ export function parsePRResult(json: string): PRResult | null {
|
||||
: decision === "REVIEW_REQUIRED"
|
||||
? "pending"
|
||||
: null
|
||||
return {
|
||||
const result: PRResult = {
|
||||
number: data.number,
|
||||
title: data.title ?? "",
|
||||
body: data.body ?? "",
|
||||
@@ -26,6 +26,40 @@ export function parsePRResult(json: string): PRResult | null {
|
||||
deletions: data.deletions ?? 0,
|
||||
files: data.changedFiles ?? 0,
|
||||
}
|
||||
if (Array.isArray(data.statusCheckRollup)) result.checks = checks(data.statusCheckRollup)
|
||||
if (Array.isArray(data.reviewRequests) && Array.isArray(data.reviews)) {
|
||||
result.reviewers = parseReviewers(data.reviewRequests as GhReviewRequest[], data.reviews as GhReview[])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function checks(items: unknown[]): PRStatus["checks"] {
|
||||
const values = items.map((item): PRCheck => {
|
||||
const check = item as {
|
||||
name?: string
|
||||
context?: string
|
||||
state?: string
|
||||
status?: string
|
||||
conclusion?: string | null
|
||||
link?: string
|
||||
detailsUrl?: string
|
||||
targetUrl?: string
|
||||
startedAt?: string
|
||||
completedAt?: string
|
||||
}
|
||||
return {
|
||||
name: check.name ?? check.context ?? "Unknown check",
|
||||
status: checkStatus(check.conclusion ?? check.state ?? check.status ?? "PENDING"),
|
||||
url: check.detailsUrl ?? check.targetUrl ?? check.link,
|
||||
duration: formatCheckDuration(check.startedAt, check.completedAt),
|
||||
}
|
||||
})
|
||||
const total = values.filter((item) => item.status !== "skipped").length
|
||||
const passed = values.filter((item) => item.status === "success").length
|
||||
const failed = values.filter((item) => item.status === "failure" || item.status === "cancelled").length
|
||||
const pending = values.filter((item) => item.status === "pending").length
|
||||
const status = total === 0 ? "none" : failed > 0 ? "failure" : pending > 0 ? "pending" : "success"
|
||||
return { status, total, passed, failed, pending, checks: values }
|
||||
}
|
||||
|
||||
export function checkStatus(state: string): CheckStatus {
|
||||
@@ -100,14 +134,14 @@ export function parseComments(threads: GhThread[]): PRComment[] {
|
||||
export function parseReviewers(requests: GhReviewRequest[], reviews: GhReview[]): PRReviewer[] {
|
||||
const map = new Map<string, PRReviewer>()
|
||||
for (const node of requests) {
|
||||
const user = node.requestedReviewer
|
||||
const user = node.requestedReviewer ?? node
|
||||
if (!user?.login) continue
|
||||
map.set(user.login, { login: user.login, avatar: user.avatarUrl, state: "pending" })
|
||||
}
|
||||
for (const node of reviews) {
|
||||
const login = node.author?.login
|
||||
if (!login) continue
|
||||
const state = REVIEWER_STATE[node.state ?? ""] ?? "pending"
|
||||
const state = REVIEWER_STATE[node.state ?? ""]
|
||||
if (!login || !state) continue
|
||||
if (!map.has(login) || state !== "commented") {
|
||||
map.set(login, { login, avatar: node.author?.avatarUrl, state })
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ export class VscodeHost implements Host {
|
||||
worktreeDirectories: () => opts.worktreeDirectories?.() ?? [],
|
||||
rootDirectory: opts.workspaceRoot,
|
||||
disableViewedRegistration: true,
|
||||
disableStatsPolling: true,
|
||||
focusTargetContext: {
|
||||
prompt: "kilo-code.new.agentManagerPromptFocused",
|
||||
mainTerminal: "kilo-code.new.agentManagerMainTerminalFocused",
|
||||
|
||||
@@ -26,6 +26,7 @@ export type KiloProviderOptions = {
|
||||
rootDirectory?: () => string | undefined
|
||||
/** Composite hosts (Agent Manager) own viewed/presence registration themselves. */
|
||||
disableViewedRegistration?: boolean
|
||||
disableStatsPolling?: boolean
|
||||
/**
|
||||
* Project route registry shared by all Agent Manager panels. When set, the
|
||||
* provider resolves project-qualified session refs to exact directories and
|
||||
|
||||
@@ -6,6 +6,7 @@ const unresolveComment = mock(async (_threadId: string, _cwd: string) => {})
|
||||
mock.module("../../src/agent-manager/pr/PRActions", () => ({ resolveComment, unresolveComment }))
|
||||
|
||||
import { PRStatusBridge } from "../../src/agent-manager/pr-status-bridge"
|
||||
import { PRStatusPoller } from "../../src/agent-manager/PRStatusPoller"
|
||||
import type { AgentManagerOutMessage, PRStatus } from "../../src/agent-manager/types"
|
||||
|
||||
const pr: PRStatus = {
|
||||
@@ -45,6 +46,85 @@ function harness(opts: { hasPersisted?: boolean; projectId?: string } = {}) {
|
||||
return { bridge, sent, opened, onStatus, worktrees, reads }
|
||||
}
|
||||
|
||||
describe("PRStatusPoller batched GitHub queries", () => {
|
||||
it("loads checks and reviewers with one request and isolates projects and detached worktrees", async () => {
|
||||
let root = "/alpha"
|
||||
const tree = { id: "wt1", path: "/alpha/feature", branch: "feature" }
|
||||
const calls: string[][] = []
|
||||
const values: PRStatus[] = []
|
||||
const poller = new PRStatusPoller({
|
||||
getWorktrees: () => [tree] as never,
|
||||
getWorkspaceRoot: () => root,
|
||||
onStatus: (_id, status) => {
|
||||
if (status) values.push(status)
|
||||
},
|
||||
log: () => undefined,
|
||||
})
|
||||
const internal = poller as unknown as {
|
||||
fetchOne: (id: string) => Promise<void>
|
||||
target: (id: string) => typeof tree
|
||||
gh: (args: string[]) => Promise<{ stdout: string; stderr: string }>
|
||||
}
|
||||
internal.target = () => tree
|
||||
internal.gh = async (args) => {
|
||||
calls.push(args)
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
number: root === "/alpha" ? 1 : tree.branch === "HEAD" ? (tree.path.endsWith("one") ? 3 : 4) : 2,
|
||||
url: `https://github.com/example/${root.slice(1)}/pull/1`,
|
||||
statusCheckRollup: [{ name: "build", conclusion: "SUCCESS" }],
|
||||
reviewRequests: [{ login: "reviewer" }],
|
||||
reviews: [],
|
||||
}),
|
||||
stderr: "",
|
||||
}
|
||||
}
|
||||
|
||||
await internal.fetchOne("wt1")
|
||||
root = "/beta"
|
||||
tree.path = "/beta/feature"
|
||||
await internal.fetchOne("wt1")
|
||||
tree.branch = "HEAD"
|
||||
tree.path = "/beta/one"
|
||||
await internal.fetchOne("wt1")
|
||||
tree.path = "/beta/two"
|
||||
await internal.fetchOne("wt1")
|
||||
|
||||
expect(calls).toHaveLength(4)
|
||||
expect(calls.every((args) => args[0] === "pr" && args[1] === "view")).toBe(true)
|
||||
expect(calls[0]?.at(-1)).toContain("statusCheckRollup,reviewRequests,reviews")
|
||||
expect(values.map((item) => item.number)).toEqual([1, 2, 3, 4])
|
||||
expect(values[0]?.checks.passed).toBe(1)
|
||||
expect(values[0]?.reviewers).toEqual([{ login: "reviewer", avatar: undefined, state: "pending" }])
|
||||
})
|
||||
|
||||
it.each([['Unknown JSON field: "statusCheckRollup"'], ["GraphQL: Resource not accessible by integration"]])(
|
||||
"retries basic pull request fields after %s",
|
||||
async (message) => {
|
||||
const poller = new PRStatusPoller({
|
||||
getWorktrees: () => [],
|
||||
getWorkspaceRoot: () => "/repo",
|
||||
onStatus: () => undefined,
|
||||
log: () => undefined,
|
||||
})
|
||||
const calls: string[][] = []
|
||||
const internal = poller as unknown as {
|
||||
query: (args: string[], cwd: string) => Promise<string>
|
||||
gh: (args: string[]) => Promise<{ stdout: string; stderr: string }>
|
||||
}
|
||||
internal.gh = async (args) => {
|
||||
calls.push(args)
|
||||
if (args.at(-1)?.includes("statusCheckRollup")) throw new Error(message)
|
||||
return { stdout: '{"number":1}', stderr: "" }
|
||||
}
|
||||
|
||||
expect(await internal.query(["pr", "view"], "/repo")).toBe('{"number":1}')
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[1]?.at(-1)).not.toContain("statusCheckRollup")
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe("PRStatusBridge.handleMessage openPR", () => {
|
||||
it("opens an explicit URL from a background project", () => {
|
||||
const { bridge, opened } = harness({ projectId: "active" })
|
||||
|
||||
@@ -161,6 +161,77 @@ describe("parsePRResult", () => {
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ title: "", body: "", url: "", additions: 0, deletions: 0, files: 0 }),
|
||||
)
|
||||
expect(result).not.toHaveProperty("checks")
|
||||
expect(result).not.toHaveProperty("reviewers")
|
||||
})
|
||||
|
||||
it("parses check runs and status contexts from the pull request response", () => {
|
||||
const result = parsePRResult(
|
||||
JSON.stringify({
|
||||
number: 7,
|
||||
statusCheckRollup: [
|
||||
{
|
||||
name: "build",
|
||||
status: "COMPLETED",
|
||||
conclusion: "SUCCESS",
|
||||
detailsUrl: "https://example.com/build",
|
||||
startedAt: "2024-01-01T00:00:00Z",
|
||||
completedAt: "2024-01-01T00:01:00Z",
|
||||
},
|
||||
{ context: "lint", state: "PENDING", targetUrl: "https://example.com/lint" },
|
||||
{ name: "tests", conclusion: "FAILURE" },
|
||||
{ name: "docs", conclusion: "SKIPPED" },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result?.checks).toEqual({
|
||||
status: "failure",
|
||||
total: 3,
|
||||
passed: 1,
|
||||
failed: 1,
|
||||
pending: 1,
|
||||
checks: [
|
||||
{ name: "build", status: "success", url: "https://example.com/build", duration: "1m 0s" },
|
||||
{ name: "lint", status: "pending", url: "https://example.com/lint", duration: undefined },
|
||||
{ name: "tests", status: "failure", url: undefined, duration: undefined },
|
||||
{ name: "docs", status: "skipped", url: undefined, duration: undefined },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("does not mark cancelled checks as successful", () => {
|
||||
const result = parsePRResult(
|
||||
JSON.stringify({ number: 10, statusCheckRollup: [{ name: "build", conclusion: "CANCELLED" }] }),
|
||||
)
|
||||
expect(result?.checks?.status).toBe("failure")
|
||||
expect(result?.checks?.failed).toBe(1)
|
||||
})
|
||||
|
||||
it("preserves reviewer history and ignores dismissed reviews", () => {
|
||||
const result = parsePRResult(
|
||||
JSON.stringify({
|
||||
number: 8,
|
||||
reviewRequests: [{ login: "alice", avatarUrl: "https://example.com/alice" }],
|
||||
reviews: [
|
||||
{ author: { login: "bob" }, state: "APPROVED" },
|
||||
{ author: { login: "bob" }, state: "COMMENTED" },
|
||||
{ author: { login: "dismissed" }, state: "DISMISSED" },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result?.reviewers).toEqual([
|
||||
{ login: "alice", avatar: "https://example.com/alice", state: "pending" },
|
||||
{ login: "bob", avatar: undefined, state: "approved" },
|
||||
])
|
||||
})
|
||||
|
||||
it("keeps empty rich fields so legacy follow-up requests are unnecessary", () => {
|
||||
const result = parsePRResult(JSON.stringify({ number: 9, statusCheckRollup: [], reviewRequests: [], reviews: [] }))
|
||||
|
||||
expect(result?.checks).toEqual({ status: "none", total: 0, passed: 0, failed: 0, pending: 0, checks: [] })
|
||||
expect(result?.reviewers).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -9,6 +9,23 @@ import { Semaphore } from "../../src/agent-manager/semaphore"
|
||||
import type { Worktree } from "../../src/agent-manager/WorktreeStateManager"
|
||||
import type { WorktreeDiffEntry } from "../../src/agent-manager/types"
|
||||
|
||||
function run(dir: string, args: string[]): void {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: ["git", ...args],
|
||||
cwd: dir,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: "Test",
|
||||
GIT_AUTHOR_EMAIL: "test@example.com",
|
||||
GIT_COMMITTER_NAME: "Test",
|
||||
GIT_COMMITTER_EMAIL: "test@example.com",
|
||||
},
|
||||
})
|
||||
if (result.exitCode !== 0) throw new Error(Buffer.from(result.stderr).toString("utf8"))
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -78,6 +95,7 @@ function source(
|
||||
|
||||
class RecordingGitOps extends GitOps {
|
||||
readonly commands: Array<{ args: string[]; cwd: string }> = []
|
||||
worktreeCalls = 0
|
||||
aheadCalls = 0
|
||||
|
||||
constructor() {
|
||||
@@ -89,6 +107,11 @@ class RecordingGitOps extends GitOps {
|
||||
return super.execGitBuffer(args, cwd)
|
||||
}
|
||||
|
||||
override listWorktreePaths(cwd: string): Promise<Map<string, string>> {
|
||||
this.worktreeCalls++
|
||||
return super.listWorktreePaths(cwd)
|
||||
}
|
||||
|
||||
override aheadBehind(cwd: string, base: string): Promise<{ ahead: number; behind: number }> {
|
||||
this.aheadCalls++
|
||||
return super.aheadBehind(cwd, base)
|
||||
@@ -125,6 +148,132 @@ describe("GitOps", () => {
|
||||
})
|
||||
|
||||
describe("GitStatsPoller", () => {
|
||||
it("uses isolated ref worktree maps for linked worktrees in two repositories", async () => {
|
||||
const roots = await Promise.all([
|
||||
fs.promises.mkdtemp(path.join(os.tmpdir(), "gsp-isolated-one-")),
|
||||
fs.promises.mkdtemp(path.join(os.tmpdir(), "gsp-isolated-two-")),
|
||||
])
|
||||
const linked = roots.map((root) => path.join(root, "linked"))
|
||||
try {
|
||||
for (const [index, root] of roots.entries()) {
|
||||
run(root, ["init", "-b", "main"])
|
||||
run(root, ["config", "commit.gpgsign", "false"])
|
||||
await fs.promises.writeFile(path.join(root, "file.txt"), `${index}\n`)
|
||||
run(root, ["add", "."])
|
||||
run(root, ["commit", "-m", "base"])
|
||||
run(root, ["remote", "add", "origin", "."])
|
||||
run(root, ["update-ref", "refs/remotes/origin/main", "HEAD"])
|
||||
run(root, ["branch", "--set-upstream-to=origin/main", "main"])
|
||||
run(root, ["worktree", "add", "-b", "feature", linked[index]!, "main"])
|
||||
}
|
||||
|
||||
const recorders = roots.map(() => new RecordingGitOps())
|
||||
const pollers = roots.map(
|
||||
(root, index) =>
|
||||
new GitStatsPoller({
|
||||
getWorktrees: () => [{ ...worktree(`wt-${index}`), branch: "feature", path: linked[index]! }],
|
||||
getWorkspaceRoot: () => root,
|
||||
onStats: () => undefined,
|
||||
onLocalStats: () => undefined,
|
||||
log: () => undefined,
|
||||
intervalMs: 500,
|
||||
git: recorders[index]!,
|
||||
}),
|
||||
)
|
||||
|
||||
pollers.forEach((poller) => poller.setEnabled(true))
|
||||
await Promise.all(recorders.map((git) => waitFor(() => git.aheadCalls >= 2, 2_000)))
|
||||
pollers.forEach((poller) => poller.stop())
|
||||
|
||||
for (const [index, git] of recorders.entries()) {
|
||||
expect(git.worktreeCalls).toBe(0)
|
||||
expect(git.aheadCalls).toBe(2)
|
||||
expect(git.commands.some((item) => item.args[1]?.includes("%(worktreepath)"))).toBe(true)
|
||||
expect(git.commands.some((item) => item.cwd === roots[index])).toBe(true)
|
||||
}
|
||||
} finally {
|
||||
await Promise.all(roots.map((root) => fs.promises.rm(root, { recursive: true, force: true })))
|
||||
}
|
||||
})
|
||||
|
||||
it("falls back to worktree listing and aheadBehind for detached worktrees", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gsp-fallback-"))
|
||||
const named = path.join(root, "named")
|
||||
const detached = path.join(root, "detached")
|
||||
try {
|
||||
run(root, ["init", "-b", "main"])
|
||||
run(root, ["config", "commit.gpgsign", "false"])
|
||||
await fs.promises.writeFile(path.join(root, "file.txt"), "base\n")
|
||||
run(root, ["add", "."])
|
||||
run(root, ["commit", "-m", "base"])
|
||||
run(root, ["remote", "add", "origin", "."])
|
||||
run(root, ["update-ref", "refs/remotes/origin/main", "HEAD"])
|
||||
run(root, ["worktree", "add", "-b", "feature", named, "main"])
|
||||
run(root, ["worktree", "add", "--detach", detached, "main"])
|
||||
|
||||
const git = new RecordingGitOps()
|
||||
const poller = new GitStatsPoller({
|
||||
getWorktrees: () => [
|
||||
{ ...worktree("named"), branch: "feature", path: named },
|
||||
{ ...worktree("detached"), branch: "HEAD", path: detached },
|
||||
],
|
||||
getWorkspaceRoot: () => root,
|
||||
onStats: () => undefined,
|
||||
onLocalStats: () => undefined,
|
||||
log: () => undefined,
|
||||
intervalMs: 500,
|
||||
git,
|
||||
})
|
||||
|
||||
poller.setEnabled(true)
|
||||
await waitFor(() => git.worktreeCalls >= 1 && git.aheadCalls >= 2, 2_000)
|
||||
poller.stop()
|
||||
|
||||
expect(git.commands.filter((item) => item.args[0] === "for-each-ref")).toHaveLength(1)
|
||||
expect(git.worktreeCalls).toBe(1)
|
||||
expect(git.aheadCalls).toBeGreaterThan(0)
|
||||
} finally {
|
||||
await fs.promises.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("falls back to worktree listing when ref metadata is incomplete", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gsp-incomplete-"))
|
||||
const named = path.join(root, "named")
|
||||
try {
|
||||
run(root, ["init", "-b", "main"])
|
||||
run(root, ["config", "commit.gpgsign", "false"])
|
||||
await fs.promises.writeFile(path.join(root, "file.txt"), "base\n")
|
||||
run(root, ["add", "."])
|
||||
run(root, ["commit", "-m", "base"])
|
||||
run(root, ["worktree", "add", "-b", "feature", named, "main"])
|
||||
|
||||
const git = new RecordingGitOps()
|
||||
const poller = new GitStatsPoller({
|
||||
getWorktrees: () => [{ ...worktree("named"), branch: "feature", path: named }],
|
||||
getWorkspaceRoot: () => root,
|
||||
source: {
|
||||
status: async () => ({ branch: "feature", dirty: false, head: "head", fingerprint: "stamp", untracked: [] }),
|
||||
refs: async () => ({ oids: new Map([["refs/heads/feature", "head"]]), upstreams: new Map() }),
|
||||
diff: async () => ({ files: 0, additions: 0, deletions: 0 }),
|
||||
},
|
||||
onStats: () => undefined,
|
||||
onLocalStats: () => undefined,
|
||||
log: () => undefined,
|
||||
intervalMs: 500,
|
||||
git,
|
||||
})
|
||||
|
||||
poller.setEnabled(true)
|
||||
await waitFor(() => git.worktreeCalls >= 1, 2_000)
|
||||
poller.stop()
|
||||
|
||||
expect(git.worktreeCalls).toBe(1)
|
||||
} finally {
|
||||
await fs.promises.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("uses only status and shared snapshots on an unchanged second poll", async () => {
|
||||
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gsp-optimized-"))
|
||||
try {
|
||||
|
||||
@@ -96,6 +96,64 @@ describe("GitStatsSnapshot", () => {
|
||||
const refs = await snapshots.refs(dir)
|
||||
expect(refOID(refs, "origin/main")).toBe(run(dir, ["rev-parse", "HEAD"]))
|
||||
expect(refs.upstreams.get("refs/heads/main")).toBe("refs/remotes/origin/main")
|
||||
expect(refs.worktreePaths?.get(await fs.realpath(dir))).toBe("main")
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps linked worktree maps isolated for repositories with the same branch names", async () => {
|
||||
const roots = await Promise.all([
|
||||
fs.mkdtemp(path.join(os.tmpdir(), "git-stats-snapshot-one-")),
|
||||
fs.mkdtemp(path.join(os.tmpdir(), "git-stats-snapshot-two-")),
|
||||
])
|
||||
const worktrees = roots.map((root) => path.join(root, "linked"))
|
||||
try {
|
||||
for (const [index, root] of roots.entries()) {
|
||||
run(root, ["init", "-b", "main"])
|
||||
run(root, ["config", "commit.gpgsign", "false"])
|
||||
await fs.writeFile(path.join(root, "tracked.txt"), `${index}\n`)
|
||||
run(root, ["add", "."])
|
||||
run(root, ["commit", "-m", "base"])
|
||||
run(root, ["worktree", "add", "-b", "feature", worktrees[index]!, "main"])
|
||||
}
|
||||
|
||||
const snapshots = roots.map(() => new GitStatsSnapshot(new GitOps({ log: () => undefined })))
|
||||
const refs = await Promise.all(roots.map((root, index) => snapshots[index]!.refs(root)))
|
||||
const paths = await Promise.all(worktrees.map((worktree) => fs.realpath(worktree)))
|
||||
|
||||
expect(refs[0]!.worktreePaths?.get(paths[0]!)).toBe("feature")
|
||||
expect(refs[0]!.worktreePaths?.has(paths[1]!)).toBe(false)
|
||||
expect(refs[1]!.worktreePaths?.get(paths[1]!)).toBe("feature")
|
||||
expect(refs[1]!.worktreePaths?.has(paths[0]!)).toBe(false)
|
||||
} finally {
|
||||
await Promise.all(roots.map((root) => fs.rm(root, { recursive: true, force: true })))
|
||||
}
|
||||
})
|
||||
|
||||
it("parses a linked worktree path containing a newline", async () => {
|
||||
await repo(async (dir) => {
|
||||
const worktree = path.join(dir, "linked\nworktree")
|
||||
run(dir, ["worktree", "add", "-b", "feature", worktree, "main"])
|
||||
const refs = await new GitStatsSnapshot(new GitOps({ log: () => undefined })).refs(dir)
|
||||
expect(refs.worktreePaths?.get(await fs.realpath(worktree))).toBe("feature")
|
||||
})
|
||||
})
|
||||
|
||||
it("falls back to the plain ref query when worktreepath is unsupported", async () => {
|
||||
const calls: string[][] = []
|
||||
const git = new GitOps({ log: () => undefined })
|
||||
git.execGitBuffer = async (args): Promise<ExecBufferResult> => {
|
||||
calls.push(args)
|
||||
if (args[1]?.includes("%(worktreepath)")) {
|
||||
return { code: 1, stdout: Buffer.alloc(0), stderr: "unknown field name: worktreepath" }
|
||||
}
|
||||
return { code: 0, stdout: Buffer.from("refs/heads/main\0abc\0\0\0"), stderr: "" }
|
||||
}
|
||||
|
||||
const refs = await new GitStatsSnapshot(git).refs("/repo")
|
||||
expect(refs.oids.get("refs/heads/main")).toBe("abc")
|
||||
expect(refs.worktreePaths).toBeUndefined()
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0]![1]).toContain("%(worktreepath)")
|
||||
expect(calls[1]![1]).not.toContain("%(worktreepath)")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ type Internals = {
|
||||
handleEvent: (event: Event, directory?: string) => void
|
||||
refreshGitStatus: (directory?: string) => Promise<void>
|
||||
refreshGitStatusFromParts: (parts: unknown[], sessionID?: string) => Promise<boolean>
|
||||
resolveGitRoot: (directory: string) => Promise<string | undefined>
|
||||
initializeConnection: () => Promise<void>
|
||||
syncWebviewState: () => Promise<void>
|
||||
flushPendingSessionRefresh: () => Promise<void>
|
||||
@@ -27,6 +28,7 @@ type Internals = {
|
||||
seedSessionStatusMap: () => Promise<void>
|
||||
sendNotificationSettings: () => void
|
||||
startStatsPolling: () => void
|
||||
statsPoller: { stop: () => void } | null
|
||||
}
|
||||
|
||||
function created(input: { id: string; directory: string; parentID?: string }): Event {
|
||||
@@ -86,6 +88,8 @@ function connection() {
|
||||
onFavoritesChanged: () => () => undefined,
|
||||
onModelSelectorExpandedChanged: () => () => undefined,
|
||||
registerDirectoryProvider: () => () => undefined,
|
||||
unregisterVisible: () => undefined,
|
||||
unregisterAttached: () => undefined,
|
||||
getServerInfo: () => ({ port: 12345 }),
|
||||
getServerConfig: () => ({ baseUrl: "http://127.0.0.1:12345", password: "test" }),
|
||||
getConnectionState: () => "connected" as const,
|
||||
@@ -96,6 +100,12 @@ function connection() {
|
||||
}
|
||||
}
|
||||
|
||||
function git() {
|
||||
const service = connection()
|
||||
const client = { project: { current: async () => ({ data: { vcs: "git" } }) } }
|
||||
return { ...service, getClient: () => client as never }
|
||||
}
|
||||
|
||||
describe("KiloProvider follow-up sessions", () => {
|
||||
it("scopes shared session events to the active project directory", () => {
|
||||
const service = connection()
|
||||
@@ -199,6 +209,39 @@ describe("KiloProvider follow-up sessions", () => {
|
||||
expect(dirs).toEqual(["/workspace/frontend/src"])
|
||||
})
|
||||
|
||||
it("starts standalone stats polling and skips it for embedded providers", async () => {
|
||||
const standalone = new KiloProvider({} as never, connection() as never)
|
||||
const normal = standalone as unknown as Internals
|
||||
normal.startStatsPolling()
|
||||
expect(normal.statsPoller).not.toBeNull()
|
||||
standalone.dispose()
|
||||
|
||||
const embedded = new KiloProvider({} as never, git() as never, undefined, {
|
||||
disableStatsPolling: true,
|
||||
})
|
||||
const internal = embedded as unknown as Internals
|
||||
const sent: unknown[] = []
|
||||
let active = "a"
|
||||
internal.webview = {
|
||||
postMessage: async (message: unknown) => {
|
||||
sent.push(message)
|
||||
return true
|
||||
},
|
||||
}
|
||||
internal.resolveGitRoot = async () => undefined
|
||||
|
||||
await internal.refreshGitStatus(`/repo/${active}`)
|
||||
active = "b"
|
||||
await internal.refreshGitStatus(`/repo/${active}`)
|
||||
|
||||
expect(internal.statsPoller).toBeNull()
|
||||
expect(sent).toEqual([
|
||||
{ type: "gitStatus", repo: true },
|
||||
{ type: "gitStatus", repo: true },
|
||||
])
|
||||
embedded.dispose()
|
||||
})
|
||||
|
||||
it("ignores completed tool paths outside the active project", async () => {
|
||||
const service = connection()
|
||||
const provider = new KiloProvider({} as never, service as never, undefined, {
|
||||
|
||||
Reference in New Issue
Block a user