feat(opencode): link sessions to their pull request (#13137)

* feat(opencode): add PR link detect and parse helpers

detectPrLink runs gh pr view, parsePrUrl accepts GitHub and GitLab PR
URLs, and a Storage helper holds the manual override.

* feat(opencode): add kilo pr link, unlink, and status commands

kilo pr becomes a parent command with checkout preserved. link and
unlink write the Storage override; the next heartbeat persists it.

* feat(opencode): advertise prLink on the heartbeat and ingest it

getSessions resolves the Storage override, cleared, or detected link,
puts prLink on the heartbeat, and syncs the session_pr_link item.

* fix(opencode): encode worktree in pr link override storage key

The manual override key used the raw absolute worktree path. Storage
builds the file with path.join, so a Windows drive colon made an invalid
filename and kilo pr link failed. Encode the worktree so the key is one
valid path segment on both platforms.

* test(opencode): cover kilo pr status outputs

Extract the status handler body so it is testable, then assert the four
outputs: stored link, cleared, detected, and no link. Split captured
output on os.EOL so the test passes on Windows.

* refactor(opencode): remove dead pr-link code found in simplify pass

detectPrLink now reuses parsePrUrl instead of hand-parsing the URL and
number, drop the dead github.com special case, and inline the
prLinkTripleKey helper.

* fix(opencode): clear pr-link dedupe map on session delete

* docs(kilo-docs): regenerate CLI reference for kilo pr subcommands

* chore: add session-pr-link changeset and bump facade allowlist
This commit is contained in:
Igor Šćekić
2026-08-16 09:33:14 +01:00
committed by GitHub
parent c8271ad6f4
commit 90a93a7aa2
16 changed files with 1013 additions and 13 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": minor
---
Add `kilo pr link <url>`, `kilo pr unlink`, and `kilo pr status` to link the current worktree to a pull request. The checkout command moves to `kilo pr checkout <number>`; `kilo pr <number>` no longer checks out a PR.
+1 -1
View File
@@ -20,7 +20,7 @@
| `kilo export [sessionID]` | export session data as JSON |
| `kilo import <file>` | import session data from JSON file or URL |
| `kilo github` | manage GitHub agent |
| `kilo pr <number>` | fetch and checkout a GitHub PR branch, then run kilo |
| `kilo pr` | manage pull requests |
| `kilo session` | manage sessions |
| `kilo remote` | enable remote connection for real-time session relay |
| `kilo daemon` | manage the local kilo daemon |
@@ -784,6 +784,22 @@ Options:
## kilo pr
```
manage pull requests
Commands:
kilo pr checkout <number> fetch and checkout a GitHub PR branch, then run kilo
kilo pr link <url> link the current worktree to a pull request
kilo pr unlink clear the linked pull request
kilo pr status show the linked pull request
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo pr checkout
```
fetch and checkout a GitHub PR branch, then run kilo
@@ -795,6 +811,39 @@ Options:
--version Show version number [boolean]
```
### kilo pr link
```
link the current worktree to a pull request
Positionals:
url PR URL to link [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo pr unlink
```
clear the linked pull request
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo pr status
```
show the linked pull request
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
## kilo session
```
+85 -3
View File
@@ -1,10 +1,13 @@
import { Effect } from "effect"
import type { Argv } from "yargs"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { effectCmd, fail } from "../effect-cmd"
import { Git } from "@/git"
import { InstanceRef } from "@/effect/instance-ref"
import { Process } from "@/util/process"
import { existsSync } from "node:fs" // kilocode_change
import { detectPrLink, parsePrUrl, readPrLinkOverride, writePrLinkOverride } from "@/kilo-sessions/pr-link" // kilocode_change
const subcommand = "pr" // kilocode_change
@@ -26,8 +29,21 @@ export function cliCommand(
}
// kilocode_change end
export const PrCommand = effectCmd({
command: `${subcommand} <number>`, // kilocode_change
export const PrCommand = cmd({
command: subcommand,
describe: "manage pull requests", // kilocode_change
builder: (yargs: Argv) =>
yargs
.command(PrCheckoutCommand)
.command(PrLinkCommand)
.command(PrUnlinkCommand)
.command(PrStatusCommand)
.demandCommand(),
async handler() {},
})
export const PrCheckoutCommand = effectCmd({
command: "checkout <number>",
describe: "fetch and checkout a GitHub PR branch, then run kilo", // kilocode_change
builder: (yargs) =>
yargs.positional("number", {
@@ -35,7 +51,7 @@ export const PrCommand = effectCmd({
describe: "PR number to checkout",
demandOption: true,
}),
handler: Effect.fn("Cli.pr")(function* (args) {
handler: Effect.fn("Cli.pr.checkout")(function* (args) {
const ctx = yield* InstanceRef
if (!ctx) return yield* fail("Could not load instance context")
if (ctx.project.vcs !== "git") {
@@ -137,3 +153,69 @@ export const PrCommand = effectCmd({
if (code !== 0) return yield* Effect.die(new Error(`kilo exited with code ${code}`)) // kilocode_change
}),
})
// kilocode_change start - link/unlink/status write and read the manual PR override in Storage
export const PrLinkCommand = effectCmd({
command: "link <url>",
describe: "link the current worktree to a pull request",
builder: (yargs) =>
yargs.positional("url", {
type: "string",
describe: "PR URL to link",
demandOption: true,
}),
handler: Effect.fn("Cli.pr.link")(function* (args) {
const ctx = yield* InstanceRef
if (!ctx) return yield* fail("Could not load instance context")
const link = parsePrUrl(args.url)
if (!link) return yield* fail(`Invalid PR URL: ${args.url}`)
yield* Effect.promise(() => writePrLinkOverride(ctx.worktree, link))
UI.println(`Linked PR #${link.prNumber} (${link.platform})`)
UI.println(link.prUrl)
}),
})
export const PrUnlinkCommand = effectCmd({
command: "unlink",
describe: "clear the linked pull request",
handler: Effect.fn("Cli.pr.unlink")(function* () {
const ctx = yield* InstanceRef
if (!ctx) return yield* fail("Could not load instance context")
yield* Effect.promise(() => writePrLinkOverride(ctx.worktree, { cleared: true }))
UI.println("PR link cleared")
}),
})
export const prStatusHandler = Effect.fn("Cli.pr.status")(function* () {
const ctx = yield* InstanceRef
if (!ctx) return yield* fail("Could not load instance context")
const override = yield* Effect.promise(() => readPrLinkOverride(ctx.worktree))
if (override && "cleared" in override) {
UI.println("PR link cleared")
return
}
if (override) {
UI.println(`Linked PR #${override.prNumber} (${override.platform})`)
UI.println(override.prUrl)
return
}
const detected = yield* Effect.promise(() => detectPrLink())
if (detected) {
UI.println(`Detected PR #${detected.prNumber} (${detected.platform})`)
UI.println(detected.prUrl)
return
}
UI.println("no PR linked")
})
export const PrStatusCommand = effectCmd({
command: "status",
describe: "show the linked pull request",
handler: prStatusHandler,
})
// kilocode_change end
@@ -52,6 +52,10 @@ export namespace IngestQueue {
type: "session_status"
data: { status: "idle" | "busy" | "question" | "permission" | "retry" }
}
| {
type: "session_pr_link"
data: { platform: string | null; prUrl: string | null; prNumber: number | null }
}
| {
type: "agent_notification"
data: { id: string; message: string }
@@ -145,6 +149,7 @@ export namespace IngestQueue {
if (item.type === "session_open") return "session_open"
if (item.type === "session_close") return "session_close"
if (item.type === "session_status") return "session_status"
if (item.type === "session_pr_link") return "session_pr_link"
if (item.type === "message") {
const value = id(item.data)
@@ -376,8 +381,10 @@ export namespace IngestQueue {
// The next flush is scheduled ~1s after the first queued event (throttled), but never earlier
// than the current backoff window (if retries are active).
// - A batch containing session_close is terminal: flush ASAP (respecting backoff only).
// Returns true when the item was queued, false when skipped (no client) so
// callers can defer dedupe bookkeeping until the item is actually accepted.
const client = await options.getClient()
if (!client) return
if (!client) return false
if (options.log.info) {
const types = data.map((d) => d.type).join(",")
@@ -390,6 +397,7 @@ export namespace IngestQueue {
const base = terminal ? now() : (queue.get(sessionId)?.due ?? now() + 1000)
const due = Math.max(base, until)
enqueue(sessionId, data, "overwrite", due, terminal)
return true
}
async function drain(bound = 3_000) {
@@ -28,6 +28,8 @@ import { RemoteWS } from "@/kilo-sessions/remote-ws"
import { RemoteSender } from "@/kilo-sessions/remote-sender"
import { RemoteProtocol } from "@/kilo-sessions/remote-protocol"
import { buildInstanceAdvertisement } from "@/kilo-sessions/instance-advertisement"
import { detectPrLink, readPrLinkOverride } from "@/kilo-sessions/pr-link"
import type { PrLink } from "@/kilo-sessions/pr-link"
import { AttachedState } from "@/kilo-sessions/attached-state"
import {
clear as clearRenameMarks,
@@ -328,6 +330,54 @@ export namespace KiloSessions {
await ingest.sync(sessionID, [{ type: "session_status", data: { status } }])
}
// kilocode_change - PR link advertise (plan 8.2/8.4): resolve the worktree PR
// link (Storage override → detect) and persist it as a `session_pr_link`
// ingest item. The heartbeat alone does not write Postgres — ingest does.
type PrLinkTriple = { platform: string | null; prUrl: string | null; prNumber: number | null }
// Last triple synced per session id so the ~10s heartbeat does not re-ingest
// an unchanged link. Module-level (process-wide) like the instance advertisement.
const lastPrLinkTriple = new Map<string, string>()
async function syncPrLinkTriple(sessionId: string, triple: PrLinkTriple) {
const key = JSON.stringify(triple)
if (lastPrLinkTriple.get(sessionId) === key) return
// Record the triple only after ingest accepts (queues) it. A missing client
// makes ingest.sync return false without queueing; recording before would
// poison the dedupe map and skip the persist after a later login.
const accepted = await ingest.sync(sessionId, [{ type: "session_pr_link", data: triple }])
if (accepted) lastPrLinkTriple.set(sessionId, key)
}
// Resolve the worktree PR link: a stored override wins (link or clear), then
// detection. Returns the heartbeat value (undefined when cleared or missing)
// and the ingest triple (undefined when nothing should be ingested — a
// missing detect is not a clear).
async function resolvePrLink(): Promise<{ prLink?: PrLink; triple?: PrLinkTriple }> {
const override = await readPrLinkOverride(Instance.worktree)
if (override) {
if ("cleared" in override) return { triple: { platform: null, prUrl: null, prNumber: null } }
return {
prLink: override,
triple: { platform: override.platform, prUrl: override.prUrl, prNumber: override.prNumber },
}
}
const detected = await detectPrLink()
if (detected) {
return {
prLink: detected,
triple: { platform: detected.platform, prUrl: detected.prUrl, prNumber: detected.prNumber },
}
}
return {}
}
async function syncPrLinkForSession(sessionId: string) {
const pr = await resolvePrLink()
if (!pr.triple) return
await syncPrLinkTriple(sessionId, pr.triple)
}
async function cumulative(sessionId: string, local: Snapshot.FileDiff[]) {
const { AppRuntime } = await import("@/effect/app-runtime")
return AppRuntime.runPromise(
@@ -425,6 +475,7 @@ export namespace KiloSessions {
{ type: "kilo_meta", data: await meta(sessionID, session) },
{ type: "session", data: transport(session) },
])
await syncPrLinkForSession(sessionID)
} catch (error) {
restoreTitleState()
log.error("session updated ingest failed", { sessionID, error })
@@ -461,6 +512,7 @@ export namespace KiloSessions {
watch(Session.Event.Deleted, (evt) => {
const sessionID = evt.properties.sessionID
knownTitles.delete(sessionID)
lastPrLinkTriple.delete(sessionID)
clearRenameMarks(sessionID)
})
watch(MessageV2.Event.Updated, async (evt) => {
@@ -707,8 +759,16 @@ export namespace KiloSessions {
),
)
const sessions = results.filter((r): r is NonNullable<typeof r> => !!r)
// kilocode_change - PR link advertise (plan 8.2): resolve once
// (worktree-scoped) and attach to every advertised row, then ingest the
// triple per session (deduped by last-sent triple).
const pr = await resolvePrLink()
if (pr.triple) {
for (const row of sessions) await syncPrLinkTriple(row.id, pr.triple)
}
const advertised = pr.prLink ? sessions.map((row) => ({ ...row, prLink: pr.prLink })) : sessions
const instance = instanceAdvertisement
return { type: "heartbeat", sessions, ...(instance ? { instance } : {}) }
return { type: "heartbeat", sessions: advertised, ...(instance ? { instance } : {}) }
}
const conn = RemoteWS.connect({
@@ -1280,6 +1340,7 @@ export namespace KiloSessions {
data: { status: await deriveStatus(sessionId) },
},
])
await syncPrLinkForSession(sessionId)
}
/** Normalize a git remote URL: strip credentials, query params, and hash. Returns undefined for unrecognized formats. */
@@ -0,0 +1,159 @@
import { beforeEach, describe, expect, mock, test } from "bun:test"
// Mock @/util/process before importing the module under test. Bun's
// mock.module is process-wide; spread the real exports and only override
// `Process.text` so nothing else that imports the process util breaks.
const realProcess = await import("@/util/process")
let outcome: { code: number; text: string } | { error: Error } = { code: 0, text: "" }
const ghText = mock(async (_cmd: string[]) => {
if ("error" in outcome) throw outcome.error
return { code: outcome.code, text: outcome.text, stdout: Buffer.from(outcome.text), stderr: Buffer.alloc(0) }
})
void mock.module("@/util/process", () => ({
...realProcess,
Process: {
...realProcess.Process,
text: ghText,
},
}))
import { detectPrLink, overrideKey, parsePrUrl } from "@/kilo-sessions/pr-link"
import { Instance } from "@/kilocode/instance"
import type { InstanceContext } from "@/project/instance-context"
function restoreWorktree<T>(worktree: string, fn: () => T): T {
const ctx = {} as InstanceContext
ctx.worktree = worktree
ctx.directory = worktree
return Instance.restore(ctx, fn)
}
describe("parsePrUrl", () => {
test("GitHub pull", () => {
const link = parsePrUrl("https://github.com/owner/repo/pull/123")
expect(link).toEqual({ platform: "github", prUrl: "https://github.com/owner/repo/pull/123", prNumber: 123 })
})
test("GitHub pull with /files subpath", () => {
const link = parsePrUrl("https://github.com/owner/repo/pull/123/files")
expect(link).toEqual({ platform: "github", prUrl: "https://github.com/owner/repo/pull/123/files", prNumber: 123 })
})
test("GitHub pull with /commits subpath", () => {
const link = parsePrUrl("https://github.com/owner/repo/pull/123/commits")
expect(link).toEqual({ platform: "github", prUrl: "https://github.com/owner/repo/pull/123/commits", prNumber: 123 })
})
test("GitHub pull with query", () => {
const link = parsePrUrl("https://github.com/owner/repo/pull/123?diff=split")
expect(link).toEqual({ platform: "github", prUrl: "https://github.com/owner/repo/pull/123", prNumber: 123 })
})
test("GitHub pull with hash", () => {
const link = parsePrUrl("https://github.com/owner/repo/pull/123#discussion_r1")
expect(link).toEqual({ platform: "github", prUrl: "https://github.com/owner/repo/pull/123", prNumber: 123 })
})
test("GitHub pull on www host", () => {
const link = parsePrUrl("https://www.github.com/owner/repo/pull/123")
expect(link).toEqual({ platform: "github", prUrl: "https://www.github.com/owner/repo/pull/123", prNumber: 123 })
})
test("GitLab merge_requests", () => {
const link = parsePrUrl("https://gitlab.com/group/proj/merge_requests/45")
expect(link).toEqual({ platform: "gitlab", prUrl: "https://gitlab.com/group/proj/merge_requests/45", prNumber: 45 })
})
test("GitLab /-/merge_requests", () => {
const link = parsePrUrl("https://gitlab.com/group/proj/-/merge_requests/45")
expect(link).toEqual({
platform: "gitlab",
prUrl: "https://gitlab.com/group/proj/-/merge_requests/45",
prNumber: 45,
})
})
test("generic /pull/N", () => {
const link = parsePrUrl("https://example.com/pull/7")
expect(link).toEqual({ platform: "example", prUrl: "https://example.com/pull/7", prNumber: 7 })
})
test("generic /pull-requests/N", () => {
const link = parsePrUrl("https://bitbucket.org/team/repo/pull-requests/9")
expect(link).toEqual({
platform: "bitbucket",
prUrl: "https://bitbucket.org/team/repo/pull-requests/9",
prNumber: 9,
})
})
test("invalid", () => {
expect(parsePrUrl("not a url")).toBeUndefined()
expect(parsePrUrl("https://github.com/owner/repo/issues/1")).toBeUndefined()
expect(parsePrUrl("https://github.com/owner/repo/pull/abc")).toBeUndefined()
expect(parsePrUrl("ftp://github.com/owner/repo/pull/1")).toBeUndefined()
})
test("rejects non-positive PR number", () => {
expect(parsePrUrl("https://github.com/owner/repo/pull/0")).toBeUndefined()
expect(parsePrUrl("https://gitlab.com/group/proj/merge_requests/0")).toBeUndefined()
})
})
describe("overrideKey", () => {
test("encodes a Windows worktree into a single path segment", () => {
const key = overrideKey("C:\\Users\\igor\\Projects\\foo")
expect(key).toEqual(["session_pr_link", "C%3A%5CUsers%5Cigor%5CProjects%5Cfoo"])
expect(key[1]).not.toContain(":")
expect(key[1]).not.toContain("\\")
expect(key[1]).not.toContain("/")
})
test("encodes a POSIX worktree into a single path segment", () => {
const key = overrideKey("/Users/igor/Projects/foo")
expect(key).toEqual(["session_pr_link", "%2FUsers%2Figor%2FProjects%2Ffoo"])
expect(key[1]).not.toContain(":")
expect(key[1]).not.toContain("/")
})
})
describe("detectPrLink", () => {
let n = 0
const nextWorktree = () => `/tmp/pr-link-${process.pid}-${n++}`
beforeEach(() => {
outcome = { code: 0, text: "" }
ghText.mockClear()
})
test("detects a PR from gh", async () => {
outcome = {
code: 0,
text: JSON.stringify({ url: "https://github.com/owner/repo/pull/123", number: 123 }),
}
const link = await restoreWorktree(nextWorktree(), () => detectPrLink())
expect(link).toEqual({ platform: "github", prUrl: "https://github.com/owner/repo/pull/123", prNumber: 123 })
expect(ghText.mock.calls[0]?.[0]).toEqual(["gh", "pr", "view", "--json", "url"])
})
test("returns undefined when gh is missing", async () => {
outcome = { error: new Error("spawn gh ENOENT") }
const link = await restoreWorktree(nextWorktree(), () => detectPrLink())
expect(link).toBeUndefined()
})
test("returns undefined when there is no PR", async () => {
outcome = { code: 1, text: "" }
const link = await restoreWorktree(nextWorktree(), () => detectPrLink())
expect(link).toBeUndefined()
})
test("returns undefined on bad JSON", async () => {
outcome = { code: 0, text: "not json" }
const link = await restoreWorktree(nextWorktree(), () => detectPrLink())
expect(link).toBeUndefined()
})
})
@@ -0,0 +1,107 @@
// Detection of the pull request (PR) linked to the current worktree, plus the
// manual override stored in session storage. The detection runs `gh pr view`
// and caches like `getGitUrl` (in-flight + TTL); the override is the same
// Storage shape used for `session_share`.
import { Instance } from "@/kilocode/instance"
import { Storage } from "@/storage/storage"
import { Process } from "@/util/process"
import { withInFlightCache } from "@/kilo-sessions/inflight-cache"
export type PrLink = {
platform: string
prUrl: string
prNumber: number
}
export type PrLinkOverride = PrLink | { cleared: true }
const ttlMs = 10_000
const prLinkKeyPrefix = "kilo-sessions:pr-link:"
function platformFromHost(host: string): string {
const label = host.replace(/^www\./, "").split(".")[0]
return label || host
}
function extractPrNumber(pathname: string): number | undefined {
// GitHub: /owner/repo/pull/N
let match = pathname.match(/^\/[^/]+\/[^/]+\/pull\/(\d+)(?:\/.*)?$/)
if (match) return Number(match[1])
// GitLab: /owner/repo/merge_requests/N and /owner/repo/-/merge_requests/N
match = pathname.match(/\/merge_requests\/(\d+)\/?$/)
if (match) return Number(match[1])
// Generic: /pull/N and /pull-requests/N
match = pathname.match(/\/(?:pull|pull-requests)\/(\d+)\/?$/)
if (match) return Number(match[1])
return undefined
}
export function parsePrUrl(url: string): PrLink | undefined {
let parsed: URL
try {
parsed = new URL(url)
} catch {
return undefined
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return undefined
const number = extractPrNumber(parsed.pathname)
if (number === undefined || number <= 0) return undefined
parsed.hash = ""
parsed.search = ""
parsed.username = ""
parsed.password = ""
return {
platform: platformFromHost(parsed.hostname),
prUrl: parsed.toString(),
prNumber: number,
}
}
export async function detectPrLink(): Promise<PrLink | undefined> {
return withInFlightCache(prLinkKeyPrefix + Instance.worktree, ttlMs, async () => {
const result = await Process.text(["gh", "pr", "view", "--json", "url"], {
nothrow: true,
cwd: Instance.worktree,
}).catch(() => undefined)
if (!result || result.code !== 0) return undefined
const raw = result.text.trim()
if (!raw) return undefined
let parsed: { url?: unknown }
try {
parsed = JSON.parse(raw)
} catch {
return undefined
}
if (typeof parsed.url !== "string" || parsed.url === "") return undefined
return parsePrUrl(parsed.url)
})
}
// Encode the worktree so it is a single valid path segment. Storage builds the
// file as `path.join(dir, ...key) + ".json"`; a raw absolute worktree carries a
// drive colon and path separators, which Windows rejects in a filename.
export function overrideKey(worktree: string) {
return ["session_pr_link", encodeURIComponent(worktree)]
}
export async function writePrLinkOverride(worktree: string, value: PrLinkOverride) {
const { AppRuntime } = await import("@/effect/app-runtime")
return AppRuntime.runPromise(Storage.Service.use((svc) => svc.write(overrideKey(worktree), value)))
}
export async function readPrLinkOverride(worktree: string): Promise<PrLinkOverride | undefined> {
const { AppRuntime } = await import("@/effect/app-runtime")
return AppRuntime.runPromise(Storage.Service.use((svc) => svc.read<PrLinkOverride>(overrideKey(worktree)))).catch(
() => undefined,
)
}
@@ -15,6 +15,17 @@ export namespace RemoteProtocol {
// KiloSession.resolvePlatform(id) || process.env["KILO_PLATFORM"] || "cli"
// Optional so legacy CLIs (no field) remain wire-compatible.
platform: z.string().max(32).optional(),
// kilocode_change - PR link: the pull request linked to the worktree this
// session is advertised from. Optional so legacy CLIs (no field) remain
// wire-compatible. `platform` here is the PR host (e.g. "github"), distinct
// from the session's `platform` (client OS) above.
prLink: z
.object({
platform: z.string().min(1).max(32),
prUrl: z.string().max(2048),
prNumber: z.number().int().positive(),
})
.optional(),
})
export type SessionInfo = z.infer<typeof SessionInfo>
@@ -327,12 +327,15 @@ Options:
`;
exports[`Kilo CLI help-text snapshots every documented command emits stable help text: kilo pr --help 1`] = `
"kilo pr <number>
"kilo pr
fetch and checkout a GitHub PR branch, then run kilo
manage pull requests
Positionals:
number PR number to checkout [number] [required]
Commands:
kilo pr checkout <number> fetch and checkout a GitHub PR branch, then run kilo
kilo pr link <url> link the current worktree to a pull request
kilo pr unlink clear the linked pull request
kilo pr status show the linked pull request
Options:
-h, --help show help [boolean]
@@ -0,0 +1,73 @@
// kilocode_change - new file
import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
import { EOL } from "node:os"
import { Effect } from "effect"
// Mock @/kilo-sessions/pr-link before importing the command so the status
// handler reads the override/detection from these stubs instead of spawning
// `gh` or touching real Storage.
const realPrLink = await import("@/kilo-sessions/pr-link")
let override: { platform: string; prUrl: string; prNumber: number } | { cleared: true } | undefined
let detected: { platform: string; prUrl: string; prNumber: number } | undefined
const readOverride = mock(async (_worktree: string) => override)
const detect = mock(async () => detected)
void mock.module("@/kilo-sessions/pr-link", () => ({
...realPrLink,
readPrLinkOverride: readOverride,
detectPrLink: detect,
}))
import { prStatusHandler } from "../../src/cli/cmd/pr"
import { InstanceRef } from "../../src/effect/instance-ref"
import type { InstanceContext } from "../../src/project/instance-context"
const writeSpy = spyOn(process.stderr, "write")
function lines() {
return writeSpy.mock.calls
.map((call) => String(call[0]))
.join("")
.split(EOL)
.filter(Boolean)
}
function runStatus(worktree: string) {
const ctx = { directory: worktree, worktree, project: {} } as unknown as InstanceContext
return Effect.runPromise(prStatusHandler().pipe(Effect.provideService(InstanceRef, ctx)))
}
describe("pr status", () => {
beforeEach(() => {
override = undefined
detected = undefined
readOverride.mockClear()
detect.mockClear()
writeSpy.mockClear()
})
test("prints the stored link", async () => {
override = { platform: "github", prUrl: "https://github.com/owner/repo/pull/123", prNumber: 123 }
await runStatus("/tmp/foo")
expect(lines()).toEqual(["Linked PR #123 (github)", "https://github.com/owner/repo/pull/123"])
})
test("prints cleared", async () => {
override = { cleared: true }
await runStatus("/tmp/foo")
expect(lines()).toEqual(["PR link cleared"])
})
test("prints the detected link", async () => {
detected = { platform: "gitlab", prUrl: "https://gitlab.com/group/proj/-/merge_requests/45", prNumber: 45 }
await runStatus("/tmp/foo")
expect(lines()).toEqual(["Detected PR #45 (gitlab)", "https://gitlab.com/group/proj/-/merge_requests/45"])
})
test("prints no PR linked", async () => {
await runStatus("/tmp/foo")
expect(lines()).toEqual(["no PR linked"])
})
})
@@ -126,6 +126,14 @@ describe("kilo help <command>", () => {
expect(output).not.toContain("## kilo debug")
})
test("documents pr subcommands", async () => {
const output = await generateHelp({ command: "pr", format: "md", commands })
expect(output).toContain("kilo pr checkout")
expect(output).toContain("kilo pr link")
expect(output).toContain("kilo pr unlink")
expect(output).toContain("kilo pr status")
})
test("documents console stop and foreground mode", async () => {
const output = await generateHelp({ command: "console", format: "md", commands })
expect(output).toContain("kilo console stop")
@@ -10,7 +10,9 @@ import { GlobalBus } from "../../src/bus/global"
import type { Config } from "../../src/config/config"
import { clearInFlightCache } from "../../src/kilo-sessions/inflight-cache"
import { KiloSessions } from "../../src/kilo-sessions/kilo-sessions"
import { provide } from "../../src/kilocode/instance"
import { provide, Instance } from "../../src/kilocode/instance"
import { writePrLinkOverride } from "../../src/kilo-sessions/pr-link"
import * as PrLink from "../../src/kilo-sessions/pr-link"
import { RemoteWS } from "../../src/kilo-sessions/remote-ws"
import { RemoteSender } from "../../src/kilo-sessions/remote-sender"
import { ProjectV2 } from "@opencode-ai/core/project"
@@ -984,3 +986,253 @@ describe("KiloSessions heartbeat attention status (DEF-3)", () => {
})
}, 30000)
})
// kilocode_change - PR link advertise (plan 8.2): the heartbeat resolves the
// worktree PR link (Storage override → cleared → detect) and both advertises it
// on the row and ingests the set/clear triple, deduped by last-sent triple.
describe("KiloSessions PR link advertise (plan 8.2)", () => {
let ingestBodies: { data: { type: string; data: unknown }[] }[] = []
beforeEach(() => {
ingestBodies = []
process.env["KILO_DISABLE_SESSION_INGEST"] = "0"
delete process.env["KILO_SESSION_INGEST_URL"]
process.env["KILO_API_KEY"] = "tok"
reset("tok")
KiloSessions.resetInstanceAdvertisementForTests()
spyOn(RemoteSender, "create").mockImplementation(
() =>
({
handle() {},
dispose() {},
}) as RemoteSender.Sender,
)
spyOn(RemoteWS, "connect").mockImplementation(
(options) =>
({
connectionId: "test-conn",
send() {},
heartbeat: () => options.getSessions().then(() => undefined),
close() {},
get connected() {
return true
},
}) as RemoteWS.Connection,
)
clearInFlightCache("kilo-sessions:token")
clearInFlightCache("kilo-sessions:token-valid:tok")
globalThis.fetch = mock(async (input, init) => {
const url = String(input)
if (url.endsWith("/api/user")) return new Response(null, { status: 200 })
if (url.endsWith("/api/session")) return Response.json({ id: "remote-test", ingestPath: "/api/ingest/test" })
if (url.includes("/ingest")) {
ingestBodies.push(JSON.parse((init?.body as string) ?? "{}"))
return new Response("{}", { status: 200 })
}
throw new Error(`unexpected fetch in test: ${url}`)
}) as unknown as typeof fetch
})
afterEach(async () => {
const pub = spyOn(Bus, "publish").mockResolvedValue(undefined as never)
await using tmp = await tmpdir({ git: true })
await provide({
directory: tmp.path,
fn: async () => {
KiloSessions.disableRemote()
},
})
pub.mockRestore()
mock.restore()
delete process.env["KILO_DISABLE_SESSION_INGEST"]
delete process.env["KILO_SESSION_INGEST_URL"]
delete process.env["KILO_PLATFORM"]
delete process.env["KILO_API_KEY"]
reset("tok")
})
function capturedGetSessions(): () => Promise<RemoteProtocol.Heartbeat> {
const calls = (RemoteWS.connect as unknown as { mock: { calls: { 0: RemoteWS.Options }[] } }).mock.calls
const getSessions = calls[0]?.[0].getSessions
if (!getSessions) throw new Error("RemoteWS.connect was not called")
return getSessions as () => Promise<RemoteProtocol.Heartbeat>
}
async function setupSession() {
const { AppRuntime } = await import("@/effect/app-runtime")
const { Session } = await import("@/session/session")
const chat = await AppRuntime.runPromise(Session.Service.use((svc) => svc.create({})))
return chat.id
}
function prLinkItems() {
return ingestBodies.flatMap((b) => b.data).filter((d) => d.type === "session_pr_link")
}
test("stored override advertises prLink and ingests the set triple", async () => {
await using tmp = await tmpdir({ git: true })
await provide({
directory: tmp.path,
fn: async () => {
const id = await setupSession()
await KiloSessions.bootstrap(id)
await writePrLinkOverride(Instance.worktree, {
platform: "github",
prUrl: "https://github.com/o/r/pull/1",
prNumber: 1,
})
await KiloSessions.enableRemote()
await KiloSessions.attachRemoteSession(id)
const payload = await capturedGetSessions()()
const row = payload.sessions.find((s) => s.id === id)
expect(row?.prLink).toEqual({ platform: "github", prUrl: "https://github.com/o/r/pull/1", prNumber: 1 })
await new Promise((r) => setTimeout(r, 1200))
const links = prLinkItems()
expect(links.length).toBeGreaterThan(0)
expect(links[0]!.data).toEqual({ platform: "github", prUrl: "https://github.com/o/r/pull/1", prNumber: 1 })
},
})
}, 30000)
test("cleared override omits prLink and ingests the clear triple", async () => {
await using tmp = await tmpdir({ git: true })
await provide({
directory: tmp.path,
fn: async () => {
const id = await setupSession()
await KiloSessions.bootstrap(id)
await writePrLinkOverride(Instance.worktree, { cleared: true })
await KiloSessions.enableRemote()
await KiloSessions.attachRemoteSession(id)
const payload = await capturedGetSessions()()
const row = payload.sessions.find((s) => s.id === id)
expect(row).toBeDefined()
expect(row!.prLink).toBeUndefined()
await new Promise((r) => setTimeout(r, 1200))
const links = prLinkItems()
expect(links.length).toBeGreaterThan(0)
expect(links[0]!.data).toEqual({ platform: null, prUrl: null, prNumber: null })
},
})
}, 30000)
test("unchanged triple is not re-ingested (dedupe)", async () => {
await using tmp = await tmpdir({ git: true })
await provide({
directory: tmp.path,
fn: async () => {
const id = await setupSession()
await KiloSessions.bootstrap(id)
await writePrLinkOverride(Instance.worktree, {
platform: "github",
prUrl: "https://github.com/o/r/pull/1",
prNumber: 1,
})
await KiloSessions.enableRemote()
await KiloSessions.attachRemoteSession(id)
await capturedGetSessions()()
await new Promise((r) => setTimeout(r, 1200))
expect(prLinkItems().length).toBe(1)
// Same session, same override: the triple is unchanged, so the second
// heartbeat must not enqueue another session_pr_link item.
await capturedGetSessions()()
await new Promise((r) => setTimeout(r, 1200))
expect(prLinkItems().length).toBe(1)
},
})
}, 30000)
test("detected link advertises prLink and ingests the set triple", async () => {
const detect = spyOn(PrLink, "detectPrLink").mockResolvedValue({
platform: "github",
prUrl: "https://github.com/o/r/pull/2",
prNumber: 2,
})
try {
await using tmp = await tmpdir({ git: true })
await provide({
directory: tmp.path,
fn: async () => {
const id = await setupSession()
await KiloSessions.bootstrap(id)
await KiloSessions.enableRemote()
await KiloSessions.attachRemoteSession(id)
const payload = await capturedGetSessions()()
const row = payload.sessions.find((s) => s.id === id)
expect(row?.prLink).toEqual({ platform: "github", prUrl: "https://github.com/o/r/pull/2", prNumber: 2 })
await new Promise((r) => setTimeout(r, 1200))
const links = prLinkItems()
expect(links.length).toBeGreaterThan(0)
expect(links[0]!.data).toEqual({ platform: "github", prUrl: "https://github.com/o/r/pull/2", prNumber: 2 })
},
})
} finally {
detect.mockRestore()
}
}, 30000)
test("no detected link omits prLink and sends no clear ingest", async () => {
const detect = spyOn(PrLink, "detectPrLink").mockResolvedValue(undefined)
try {
await using tmp = await tmpdir({ git: true })
await provide({
directory: tmp.path,
fn: async () => {
const id = await setupSession()
await KiloSessions.bootstrap(id)
await KiloSessions.enableRemote()
await KiloSessions.attachRemoteSession(id)
const payload = await capturedGetSessions()()
const row = payload.sessions.find((s) => s.id === id)
expect(row).toBeDefined()
expect(row!.prLink).toBeUndefined()
await new Promise((r) => setTimeout(r, 1200))
expect(prLinkItems().length).toBe(0)
},
})
} finally {
detect.mockRestore()
}
}, 30000)
test("override present wins and skips detection", async () => {
const detect = spyOn(PrLink, "detectPrLink")
try {
await using tmp = await tmpdir({ git: true })
await provide({
directory: tmp.path,
fn: async () => {
const id = await setupSession()
await KiloSessions.bootstrap(id)
await writePrLinkOverride(Instance.worktree, {
platform: "github",
prUrl: "https://github.com/o/r/pull/1",
prNumber: 1,
})
await KiloSessions.enableRemote()
await KiloSessions.attachRemoteSession(id)
const payload = await capturedGetSessions()()
const row = payload.sessions.find((s) => s.id === id)
expect(row?.prLink).toEqual({ platform: "github", prUrl: "https://github.com/o/r/pull/1", prNumber: 1 })
expect(detect).not.toHaveBeenCalled()
},
})
} finally {
detect.mockRestore()
}
}, 30000)
})
@@ -373,6 +373,86 @@ describe("share ingest queue", () => {
expect((statuses[0]!.data as { status: string }).status).toBe("idle")
})
test("session_pr_link uses stable key and coalesces", async () => {
const sent: unknown[] = []
const sched = scheduler(() => clock.now)
const q = IngestQueue.create({
now: () => clock.now,
setTimeout: sched.setTimeout,
clearTimeout: sched.clearTimeout,
log: { error: () => {} },
getShare: async () => ({ ingestPath: "/ingest" }),
getClient: async () => ({
url: "https://ingest.test",
fetch: async (_input, init) => {
sent.push(JSON.parse((init?.body as string) ?? "{}"))
return new Response("{}", { status: 200 })
},
}),
})
await q.sync("s-pr", [
{ type: "session_pr_link", data: { platform: "github", prUrl: "https://github.com/o/r/pull/1", prNumber: 1 } },
])
clock.now = 100
await q.sync("s-pr", [
{
type: "session_pr_link",
data: { platform: "gitlab", prUrl: "https://gitlab.com/o/r/-/merge_requests/2", prNumber: 2 },
},
])
clock.now = 1000
sched.run()
await Bun.sleep(0)
expect(sent.length).toBe(1)
const payload = sent[0] as { data: { type: string; data: unknown }[] }
const links = payload.data.filter((d) => d.type === "session_pr_link")
expect(links.length).toBe(1)
expect(links[0]!.data).toEqual({
platform: "gitlab",
prUrl: "https://gitlab.com/o/r/-/merge_requests/2",
prNumber: 2,
})
})
test("session_pr_link clear coalesces over a prior set", async () => {
const sent: unknown[] = []
const sched = scheduler(() => clock.now)
const q = IngestQueue.create({
now: () => clock.now,
setTimeout: sched.setTimeout,
clearTimeout: sched.clearTimeout,
log: { error: () => {} },
getShare: async () => ({ ingestPath: "/ingest" }),
getClient: async () => ({
url: "https://ingest.test",
fetch: async (_input, init) => {
sent.push(JSON.parse((init?.body as string) ?? "{}"))
return new Response("{}", { status: 200 })
},
}),
})
await q.sync("s-pr", [
{ type: "session_pr_link", data: { platform: "github", prUrl: "https://github.com/o/r/pull/1", prNumber: 1 } },
])
clock.now = 100
await q.sync("s-pr", [{ type: "session_pr_link", data: { platform: null, prUrl: null, prNumber: null } }])
clock.now = 1000
sched.run()
await Bun.sleep(0)
const payload = sent[0] as { data: { type: string; data: unknown }[] }
const links = payload.data.filter((d) => d.type === "session_pr_link")
expect(links.length).toBe(1)
expect(links[0]!.data).toEqual({ platform: null, prUrl: null, prNumber: null })
})
test("flush sends request with ?v=2 query parameter", async () => {
const urls: string[] = []
const sched = scheduler(() => clock.now)
@@ -460,4 +460,105 @@ describe("RemoteProtocol", () => {
expect(result.data.type).toBe("heartbeat")
}
})
// kilocode_change - PR link advertise (plan 8.4)
test("session info accepts optional prLink", () => {
const msg = {
type: "heartbeat",
sessions: [
{
id: "s1",
status: "busy",
title: "t",
prLink: { platform: "github", prUrl: "https://github.com/o/r/pull/1", prNumber: 1 },
},
],
}
const result = RemoteProtocol.Heartbeat.safeParse(msg)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.sessions[0].prLink).toEqual({
platform: "github",
prUrl: "https://github.com/o/r/pull/1",
prNumber: 1,
})
}
})
test("session info prLink optional (legacy)", () => {
const msg = {
type: "heartbeat",
sessions: [{ id: "s1", status: "busy", title: "t" }],
}
const result = RemoteProtocol.Heartbeat.safeParse(msg)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.sessions[0].prLink).toBeUndefined()
}
})
test("session info rejects empty prLink platform", () => {
const msg = {
type: "heartbeat",
sessions: [
{ id: "s1", status: "busy", title: "t", prLink: { platform: "", prUrl: "https://x/pull/1", prNumber: 1 } },
],
}
expect(RemoteProtocol.Heartbeat.safeParse(msg).success).toBe(false)
})
test("session info rejects oversized prLink prUrl", () => {
const msg = {
type: "heartbeat",
sessions: [
{
id: "s1",
status: "busy",
title: "t",
prLink: { platform: "github", prUrl: "https://x/" + "a".repeat(2048), prNumber: 1 },
},
],
}
expect(RemoteProtocol.Heartbeat.safeParse(msg).success).toBe(false)
})
test("session info rejects non-positive prLink prNumber", () => {
const msg = {
type: "heartbeat",
sessions: [
{
id: "s1",
status: "busy",
title: "t",
prLink: { platform: "github", prUrl: "https://x/pull/0", prNumber: 0 },
},
],
}
expect(RemoteProtocol.Heartbeat.safeParse(msg).success).toBe(false)
})
test("full heartbeat round-trips prLink", () => {
const msg = {
type: "heartbeat",
sessions: [
{
id: "s1",
status: "busy",
title: "t",
prLink: { platform: "gitlab", prUrl: "https://gitlab.com/g/p/-/merge_requests/7", prNumber: 7 },
},
],
}
const json = JSON.parse(JSON.stringify(msg))
const result = RemoteProtocol.Heartbeat.safeParse(json)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.sessions[0].prLink).toEqual({
platform: "gitlab",
prUrl: "https://gitlab.com/g/p/-/merge_requests/7",
prNumber: 7,
})
}
})
})
+3 -2
View File
@@ -43,7 +43,7 @@ const testAllow: Record<string, { count: number; reason: string }> = {
reason: "disk-backed instance integration test cleanup",
},
"kilocode/kilo-sessions.test.ts": {
count: 29,
count: 31,
reason:
"K1 W1: real integration test for SessionStatus→detach→heartbeat-fence; " +
"the test creates a session and sets its status via the global AppRuntime, " +
@@ -52,7 +52,8 @@ const testAllow: Record<string, { count: number; reason: string }> = {
"resolves pending question/permission from the global Question.Service and " +
"Permission.Service, so a test can only assert it by raising and replying to " +
"real requests through that same runtime. Scoped layers cannot express this — " +
"the global-runtime coupling is exactly what is under test.",
"the global-runtime coupling is exactly what is under test. " +
"PR-link advertise tests extend this with session creation through the same global AppRuntime.",
},
"kilocode/session/platform-attribution.test.ts": { count: 2, reason: "existing runtime integration test" },
"kilocode/session-prompt-queue.test.ts": { count: 6, reason: "prompt queue legacy instance bridge regression" },