Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pr-merge-readiness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---

Show pull request approvals and merge readiness in Agent Manager, with GitHub-backed branch updates, merge methods, auto-merge, and conflict resolution with Kilo.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ export class AgentManagerProvider implements Disposable {
presence: (presence) => this.onWorktreePresence(presence),
openExternal: (u) => this.host.openExternal(u),
log: (...args) => this.log(...args),
mergeMethods: this.host,
})
this.statsPoller = pollers.stats
this.prBridge = pollers.pr
Expand Down Expand Up @@ -451,8 +452,7 @@ export class AgentManagerProvider implements Disposable {
return
}
// When the .kilocode → .kilo migration rewrote git worktree refs, nudge
// VS Code's git extension to re-discover them. Without this, worktrees
// won't appear in Source Control until the next VS Code restart.
// VS Code's git extension to re-discover them and avoid stale Source Control.
if (init.refsFixed > 0) {
this.log(`Migration fixed ${init.refsFixed} git worktree ref(s), refreshing git`)
this.host.refreshGit()
Expand Down
67 changes: 67 additions & 0 deletions packages/kilo-vscode/src/agent-manager/GitOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from "./git-import"
import type { Semaphore } from "./semaphore"
import { lines } from "./git-stats-snapshot"
import { oid } from "../shared/pr-comment-preview"

interface GitOpsOptions {
log: (...args: unknown[]) => void
Expand Down Expand Up @@ -129,6 +130,7 @@ export class GitOps {
private readonly injected: boolean
private executableCache: Promise<string> | undefined
private readonly resolutionCache = new Map<string, { value: string; expires: number }>()
private readonly conflictCache = new Map<string, { value: Promise<string[]>; expires: number }>()
private static readonly CACHE_TTL_MS = 60000
private static readonly DEFAULT_BRANCH_CACHE_TTL_MS = 10 * 60_000
private static readonly MAX_CACHE_SIZE = 100
Expand Down Expand Up @@ -168,6 +170,7 @@ export class GitOps {
this.controller.abort()
}
this.resolutionCache.clear()
this.conflictCache.clear()
}

private getCached(key: string): string | undefined {
Expand Down Expand Up @@ -585,6 +588,60 @@ export class GitOps {
return [{ reason: "Patch does not apply cleanly" }]
}

/**
* Conflicting file paths between two commits, computed with `git merge-tree`
* so the worktree, index, and stash stay untouched. Missing commits are
* fetched first because a conflicting PR head often only exists remotely.
*/
conflicts(cwd: string, remote: string, base: string, head: string): Promise<string[]> {
if (!oid(base) || !oid(head)) return Promise.reject(new Error("Invalid pull request commit ID"))
if (!/^[A-Za-z0-9._-]+$/.test(remote)) return Promise.reject(new Error("Invalid Git remote"))
const key = `${cwd}\u0000${remote}\u0000${base}\u0000${head}`
const now = Date.now()
const cached = this.conflictCache.get(key)
if (cached && cached.expires > now) return cached.value
if (cached) this.conflictCache.delete(key)
const task = this.computeConflicts(cwd, remote, base, head)
if (this.conflictCache.size >= GitOps.MAX_CACHE_SIZE) {
let oldestKey: string | undefined
let oldestExpiry = Infinity
for (const [entryKey, entry] of this.conflictCache) {
if (entry.expires < oldestExpiry) {
oldestExpiry = entry.expires
oldestKey = entryKey
}
}
if (oldestKey) this.conflictCache.delete(oldestKey)
}
this.conflictCache.set(key, { value: task, expires: now + GitOps.CACHE_TTL_MS })
void task.catch(() => {
if (this.conflictCache.get(key)?.value === task) this.conflictCache.delete(key)
})
return task
}

private async computeConflicts(cwd: string, remote: string, base: string, head: string): Promise<string[]> {
const missing: string[] = []
for (const sha of [base, head]) {
const probe = await this.exec(["cat-file", "-e", `${sha}^{commit}`], cwd)
if (probe.code !== 0) missing.push(sha)
}
if (missing.length > 0) {
const fetch = await this.exec(["fetch", "--no-tags", remote, "--", ...missing], cwd, {
env: nonInteractiveEnv(),
timeout: 60_000,
})
if (fetch.code !== 0) throw new Error(fetch.stderr.trim() || "Failed to fetch pull request commits")
}
const result = await this.exec(
["-c", "core.quotePath=false", "merge-tree", "--write-tree", "--name-only", base, head],
cwd,
)
if (result.code === 0) return []
if (result.code !== 1) throw new Error(result.stderr.trim() || "Failed to compute merge conflicts")
return parseConflictPaths(result.stdout)
}

/**
* Run a git command returning `{code, stdout, stderr}`. Gated by the shared
* semaphore and respects the dispose abort signal. Never throws — commands
Expand Down Expand Up @@ -706,3 +763,13 @@ export class GitOps {
})
}
}

/** Conflicting paths from `git merge-tree --write-tree --name-only` output. */
export function parseConflictPaths(output: string): string[] {
const files: string[] = []
for (const line of output.split(/\r?\n/).slice(1)) {
if (!line) break
files.push(line)
}
return files
}
168 changes: 145 additions & 23 deletions packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ExecFileOptionsWithStringEncoding } from "child_process"
import { existsSync } from "fs"
import type { Worktree } from "./WorktreeStateManager"
import type { PRStatus, PRCheck, PRReviewer, PRTimelineItem } from "./types"
import type { PRMergeMethod, PRStatus, PRCheck, PRReviewer, PRTimelineItem } from "./types"
import { execWithShellEnv } from "./shell-env"
import { execGhRead } from "./gh"
import { classifyPRError } from "./git-import"
Expand Down Expand Up @@ -34,6 +34,16 @@ interface PRStatusPollerOptions {
/** Shared concurrency gate for child process spawning. */
semaphore?: Semaphore
getBranch?: (worktree: Worktree) => Promise<string | undefined>
getPRMergeMethod?: (repo: string) => PRMergeMethod | undefined
}

interface RepoInfo {
owner: string
name: string
root: string
methods: PRMergeMethod[]
autoAllowed: boolean
canWrite: boolean
}

const GH_PROBE_TTL = 300_000 // 5 minutes — gh installation state rarely changes at runtime
Expand All @@ -56,11 +66,13 @@ export class PRStatusPoller {
private ghProbeTime = 0
private rich = true
private activeWorktreeId: string | undefined
private cachedRepo: { owner: string; name: string; root: string } | undefined
private cachedRepo: RepoInfo | undefined
private repoRequest: { root: string; promise: Promise<RepoInfo> } | undefined
private prCache = new Map<string, { result: PRResult | null; expires: number }>()
/** Reviewer avatars are stable, so look them up once per login and reuse them. */
private readonly avatars = new Map<string, string>()
private readonly resolvedAvatars = new Set<string>()
private readonly refreshTimers = new Map<string, ReturnType<typeof setTimeout>[]>()
private lastFullSync = 0 // timestamp of last full (all-worktree) sync
private readonly intervalMs: number
private readonly semaphore: Semaphore | undefined
Expand Down Expand Up @@ -123,6 +135,7 @@ export class PRStatusPoller {
clearTimeout(this.timer)
this.timer = undefined
}
this.clearRefreshTimers()
}

stop(): void {
Expand All @@ -140,19 +153,49 @@ export class PRStatusPoller {
this.ghProbeTime = 0
this.rich = true
this.cachedRepo = undefined
this.repoRequest = undefined
this.prCache.clear()
this.avatars.clear()
this.resolvedAvatars.clear()
this.lastFullSync = 0
this.clearRefreshTimers()
}

/** Force-refresh a specific worktree immediately, bypassing the PR cache. */
refresh(worktreeId: string): void {
refresh(worktreeId: string, settle = false): void {
this.clearRefreshTimers(worktreeId)
const wt = this.options.getWorktrees().find((w) => w.id === worktreeId)
if (wt) this.prCache.delete(this.key(wt.branch, wt.path))
this.lastHash.delete(worktreeId)
if (!this.active) return
void this.fetchOne(worktreeId, this.generation, true)
const generation = this.generation
void this.fetchOne(worktreeId, generation, true).catch(() => undefined)
if (!settle || !this.visible) return
const delays = [2_000, 8_000]
this.refreshTimers.set(
worktreeId,
delays.map((delay) =>
setTimeout(() => {
if (!this.active || !this.visible || this.stale(generation)) return
const current = this.options.getWorktrees().find((w) => w.id === worktreeId)
if (current) this.prCache.delete(this.key(current.branch, current.path))
this.lastHash.delete(worktreeId)
void this.fetchOne(worktreeId, generation, true).catch(() => undefined)
}, delay),
),
)
}

private clearRefreshTimers(worktreeId?: string): void {
if (worktreeId) {
for (const timer of this.refreshTimers.get(worktreeId) ?? []) clearTimeout(timer)
this.refreshTimers.delete(worktreeId)
return
}
for (const timers of this.refreshTimers.values()) {
for (const timer of timers) clearTimeout(timer)
}
this.refreshTimers.clear()
}

setActiveWorktreeId(id: string | undefined): void {
Expand Down Expand Up @@ -273,22 +316,17 @@ export class PRStatusPoller {
if (this.stale(generation)) return
const pr = await this.cachedFetchPR(branch ?? wt.branch, wt.path)
if (this.stale(generation)) return
if (!pr) {
const hash = `${worktreeId}:${branch ?? wt.branch}:none`
if (this.lastHash.get(worktreeId) === hash) return
this.lastHash.set(worktreeId, hash)
this.options.onStatus(worktreeId, null, undefined, branch)
return
}
if (!pr) return this.empty(worktreeId, branch ?? wt.branch, branch)

const repo = await this.getRepoInfo(wt.path)
const [checks, reviewers, threads] = await Promise.all([
...this.extras(pr, wt.path),
this.fetchThreads(pr.number, wt.path, full),
])
if (this.stale(generation)) return
if (threads && (threads.baseRefOid !== pr.baseRefOid || threads.headRefOid !== pr.headRefOid))
this.prCache.delete(this.key(branch ?? wt.branch, wt.path))
this.invalidateThreadCache(pr, threads, branch ?? wt.branch, wt.path)

const merge = mergeStatus(pr.merge, repo, this.options.getPRMergeMethod?.(`${repo.owner}/${repo.name}`))
const status: PRStatus = {
id: pr.id,
number: pr.number,
Expand All @@ -301,6 +339,7 @@ export class PRStatusPoller {
url: pr.url,
state: pr.state,
review: pr.review,
...(merge ? { merge } : {}),
checks,
reviewers,
...threads,
Expand All @@ -325,6 +364,24 @@ export class PRStatusPoller {
return [pr.checks ?? this.fetchChecks(pr.number, cwd), this.reviewers(pr, cwd)] as const
}

private empty(worktreeId: string, fallback: string, branch: string | undefined): void {
const hash = `${worktreeId}:${fallback}:none`
if (this.lastHash.get(worktreeId) === hash) return
this.lastHash.set(worktreeId, hash)
this.options.onStatus(worktreeId, null, undefined, branch)
}

private invalidateThreadCache(
pr: PRResult,
threads: { baseRefOid?: string; headRefOid?: string } | undefined,
branch: string,
cwd: string,
): void {
if (!threads) return
if (threads.baseRefOid === pr.baseRefOid && threads.headRefOid === pr.headRefOid) return
this.prCache.delete(this.key(branch, cwd))
}

/**
* `gh pr view --json reviews` returns reviewer logins without avatars, so
* merge avatar URLs from the GraphQL query and cache them per login. States
Expand Down Expand Up @@ -367,7 +424,7 @@ export class PRStatusPoller {

private static readonly BASE_JSON_FIELDS =
"id,number,title,body,url,state,isDraft,reviewDecision,additions,deletions,changedFiles,headRefName,baseRefOid,headRefOid,author,createdAt"
private static readonly PR_JSON_FIELDS = `${PRStatusPoller.BASE_JSON_FIELDS},statusCheckRollup,reviewRequests,reviews`
private static readonly PR_JSON_FIELDS = `${PRStatusPoller.BASE_JSON_FIELDS},statusCheckRollup,reviewRequests,reviews,mergeable,mergeStateStatus,autoMergeRequest`

/** Return a cached PR lookup if still fresh, otherwise fetch and cache.
* Keyed by branch name so multiple worktrees on the same branch share
Expand Down Expand Up @@ -472,17 +529,67 @@ export class PRStatusPoller {
}
}

private async getRepoInfo(cwd: string): Promise<{ owner: string; name: string }> {
private async getRepoInfo(cwd: string): Promise<RepoInfo> {
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, root }
this.cachedRepo = info
return info
if (this.repoRequest?.root === root) return this.repoRequest.promise
const promise = this.fetchRepoInfo(cwd, root)
.then((info) => {
this.cachedRepo = info
return info
})
.catch((err) => {
if (this.repoRequest?.promise === promise) this.repoRequest = undefined
throw err
})
this.repoRequest = { root, promise }
return promise
}

private async fetchRepoInfo(cwd: string, root: string): Promise<RepoInfo> {
const fields = "owner,name,mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,viewerPermission"
const stdout = await this.gh(["repo", "view", "--json", fields], { cwd, timeout: 10_000 }).then(
(result) => result.stdout,
(err) => {
const msg = err instanceof Error ? err.message : String(err)
if (!/unknown.*field|does(?:n't| not) exist|not accessible/i.test(msg)) throw err
return this.gh(["repo", "view", "--json", "owner,name,viewerPermission"], { cwd, timeout: 10_000 }).then(
(result) => result.stdout,
(fallback) => {
const reason = fallback instanceof Error ? fallback.message : String(fallback)
if (!/unknown.*field|does(?:n't| not) exist|not accessible/i.test(reason)) throw fallback
return this.gh(["repo", "view", "--json", "owner,name"], { cwd, timeout: 10_000 }).then(
(result) => result.stdout,
)
},
)
},
)
const data = JSON.parse(stdout) as Record<string, unknown>
const owner = typeof data.owner === "string" ? data.owner : (data.owner as { login?: string } | undefined)?.login
const name = typeof data.name === "string" ? data.name : undefined
if (!owner || !name) throw new Error("GitHub repository identity is missing")
const settings = await this.gh(["api", `repos/${owner}/${name}`], { cwd, timeout: 10_000 }).then(
(result) => JSON.parse(result.stdout) as { allow_auto_merge?: boolean },
(err) => {
this.options.log("Failed to read GitHub auto-merge settings:", err)
return { allow_auto_merge: undefined }
},
)
const methods = [
...(data.squashMergeAllowed !== false ? (["squash"] as const) : []),
...(data.mergeCommitAllowed !== false ? (["merge"] as const) : []),
...(data.rebaseMergeAllowed !== false ? (["rebase"] as const) : []),
]
return {
owner,
name,
root,
methods: methods.length > 0 ? [...methods] : ["squash"],
autoAllowed: settings.allow_auto_merge === true,
canWrite:
data.viewerPermission === "WRITE" || data.viewerPermission === "MAINTAIN" || data.viewerPermission === "ADMIN",
}
}

private async fetchReviewers(prNumber: number, cwd: string): Promise<{ items: PRReviewer[]; ok: boolean }> {
Expand Down Expand Up @@ -737,3 +844,18 @@ function parseConversationPayload(stdout: string): { items?: PRTimelineItem[]; h
hasEarlier: page.pageInfo?.hasPreviousPage === true,
}
}

function mergeStatus(merge: PRResult["merge"], repo: RepoInfo, saved: PRMergeMethod | undefined): PRStatus["merge"] {
if (!merge) return undefined
const method =
saved && repo.methods.includes(saved)
? saved
: (repo.methods.find((item) => item === "squash") ?? repo.methods[0] ?? "squash")
return {
...merge,
methods: repo.methods,
method,
autoAllowed: repo.autoAllowed,
canWrite: repo.canWrite,
}
}
Loading
Loading