diff --git a/.changeset/session-pr-link.md b/.changeset/session-pr-link.md new file mode 100644 index 0000000000..763ef462c0 --- /dev/null +++ b/.changeset/session-pr-link.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": minor +--- + +Add `kilo pr link `, `kilo pr unlink`, and `kilo pr status` to link the current worktree to a pull request. The checkout command moves to `kilo pr checkout `; `kilo pr ` no longer checks out a PR. diff --git a/packages/kilo-docs/markdoc/partials/cli-commands-table.md b/packages/kilo-docs/markdoc/partials/cli-commands-table.md index d8653a898a..2e40ca8067 100644 --- a/packages/kilo-docs/markdoc/partials/cli-commands-table.md +++ b/packages/kilo-docs/markdoc/partials/cli-commands-table.md @@ -20,7 +20,7 @@ | `kilo export [sessionID]` | export session data as JSON | | `kilo import ` | import session data from JSON file or URL | | `kilo github` | manage GitHub agent | -| `kilo pr ` | 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 | diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md b/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md index 05a1d48b13..56782a8c6a 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md @@ -784,6 +784,22 @@ Options: ## kilo pr +``` +manage pull requests + +Commands: + kilo pr checkout fetch and checkout a GitHub PR branch, then run kilo + kilo pr link 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 ``` diff --git a/packages/opencode/src/cli/cmd/pr.ts b/packages/opencode/src/cli/cmd/pr.ts index 22a8793127..144941af53 100644 --- a/packages/opencode/src/cli/cmd/pr.ts +++ b/packages/opencode/src/cli/cmd/pr.ts @@ -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} `, // 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 ", 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 ", + 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 diff --git a/packages/opencode/src/kilo-sessions/ingest-queue.ts b/packages/opencode/src/kilo-sessions/ingest-queue.ts index ed9eb0187c..1abe97bafd 100644 --- a/packages/opencode/src/kilo-sessions/ingest-queue.ts +++ b/packages/opencode/src/kilo-sessions/ingest-queue.ts @@ -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) { diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index ac56cfe0e1..d43ee120d8 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -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() + + 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 => !!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. */ diff --git a/packages/opencode/src/kilo-sessions/pr-link.test.ts b/packages/opencode/src/kilo-sessions/pr-link.test.ts new file mode 100644 index 0000000000..9001f0122a --- /dev/null +++ b/packages/opencode/src/kilo-sessions/pr-link.test.ts @@ -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(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() + }) +}) diff --git a/packages/opencode/src/kilo-sessions/pr-link.ts b/packages/opencode/src/kilo-sessions/pr-link.ts new file mode 100644 index 0000000000..17cc9c8f5b --- /dev/null +++ b/packages/opencode/src/kilo-sessions/pr-link.ts @@ -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 { + 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 { + const { AppRuntime } = await import("@/effect/app-runtime") + return AppRuntime.runPromise(Storage.Service.use((svc) => svc.read(overrideKey(worktree)))).catch( + () => undefined, + ) +} diff --git a/packages/opencode/src/kilo-sessions/remote-protocol.ts b/packages/opencode/src/kilo-sessions/remote-protocol.ts index 30a2202413..c578c96e3e 100644 --- a/packages/opencode/src/kilo-sessions/remote-protocol.ts +++ b/packages/opencode/src/kilo-sessions/remote-protocol.ts @@ -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 diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index 241f8bd376..4636cf1704 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -327,12 +327,15 @@ Options: `; exports[`Kilo CLI help-text snapshots every documented command emits stable help text: kilo pr --help 1`] = ` -"kilo pr +"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 fetch and checkout a GitHub PR branch, then run kilo + kilo pr link 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] diff --git a/packages/opencode/test/cli/pr-status.test.ts b/packages/opencode/test/cli/pr-status.test.ts new file mode 100644 index 0000000000..64e6e3d3ed --- /dev/null +++ b/packages/opencode/test/cli/pr-status.test.ts @@ -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"]) + }) +}) diff --git a/packages/opencode/test/kilocode/help.test.ts b/packages/opencode/test/kilocode/help.test.ts index 264fbb8984..fa087200b8 100644 --- a/packages/opencode/test/kilocode/help.test.ts +++ b/packages/opencode/test/kilocode/help.test.ts @@ -126,6 +126,14 @@ describe("kilo help ", () => { 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") diff --git a/packages/opencode/test/kilocode/kilo-sessions.test.ts b/packages/opencode/test/kilocode/kilo-sessions.test.ts index dd973dd965..f64cc2d032 100644 --- a/packages/opencode/test/kilocode/kilo-sessions.test.ts +++ b/packages/opencode/test/kilocode/kilo-sessions.test.ts @@ -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 { + 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 + } + + 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) +}) diff --git a/packages/opencode/test/kilocode/sessions/ingest-queue.test.ts b/packages/opencode/test/kilocode/sessions/ingest-queue.test.ts index f2ffb1e192..0a73fe9c05 100644 --- a/packages/opencode/test/kilocode/sessions/ingest-queue.test.ts +++ b/packages/opencode/test/kilocode/sessions/ingest-queue.test.ts @@ -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) diff --git a/packages/opencode/test/kilocode/sessions/remote-protocol.test.ts b/packages/opencode/test/kilocode/sessions/remote-protocol.test.ts index cc2573d0b8..fc786fec13 100644 --- a/packages/opencode/test/kilocode/sessions/remote-protocol.test.ts +++ b/packages/opencode/test/kilocode/sessions/remote-protocol.test.ts @@ -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, + }) + } + }) }) diff --git a/script/check-opencode-promise-facades.ts b/script/check-opencode-promise-facades.ts index d93a2274e5..4f0376731a 100644 --- a/script/check-opencode-promise-facades.ts +++ b/script/check-opencode-promise-facades.ts @@ -43,7 +43,7 @@ const testAllow: Record = { 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 = { "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" },