From 838670adbe256dda8ee60ad46c258e0a6e4465e2 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 6 May 2026 12:19:20 +0300 Subject: [PATCH] feat(cli): ask for local-review base --- .changeset/local-review-base-branch.md | 5 + packages/opencode/src/kilocode/review/base.ts | 33 +++++++ .../opencode/src/kilocode/review/review.ts | 4 +- packages/opencode/src/session/prompt.ts | 10 +- .../test/kilocode/local-review-base.test.ts | 93 +++++++++++++++++++ 5 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 .changeset/local-review-base-branch.md create mode 100644 packages/opencode/src/kilocode/review/base.ts create mode 100644 packages/opencode/test/kilocode/local-review-base.test.ts diff --git a/.changeset/local-review-base-branch.md b/.changeset/local-review-base-branch.md new file mode 100644 index 00000000000..f6a3b242724 --- /dev/null +++ b/.changeset/local-review-base-branch.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Ask which base branch `/local-review` should review against before starting the review. diff --git a/packages/opencode/src/kilocode/review/base.ts b/packages/opencode/src/kilocode/review/base.ts new file mode 100644 index 00000000000..28a8dfecf08 --- /dev/null +++ b/packages/opencode/src/kilocode/review/base.ts @@ -0,0 +1,33 @@ +import { Question } from "@/question" +import { SessionID } from "@/session/schema" +import { Review } from "./review" + +export namespace ReviewBranch { + export async function resolve(input: { sessionID: SessionID }) { + const base = await Review.getBaseBranch() + const answers = await Question.ask({ + sessionID: input.sessionID, + blocking: true, + questions: [ + { + header: "Base branch", + question: "Which base branch should I review against?", + custom: true, + options: [ + { + label: base, + description: "Review the current branch against this base branch", + }, + ], + }, + ], + }) + const answer = answers[0]?.[0]?.trim() + return answer || base + } + + export async function template(input: { sessionID: SessionID }) { + const base = await resolve(input) + return Review.buildReviewPromptBranch(base) + } +} diff --git a/packages/opencode/src/kilocode/review/review.ts b/packages/opencode/src/kilocode/review/review.ts index 093fb4189ef..365a2a8514d 100644 --- a/packages/opencode/src/kilocode/review/review.ts +++ b/packages/opencode/src/kilocode/review/review.ts @@ -280,8 +280,8 @@ export namespace Review { * * @returns Complete prompt string ready for LLM */ - export async function buildReviewPromptBranch(): Promise { - const base = await getBaseBranch() + export async function buildReviewPromptBranch(baseBranch?: string): Promise { + const base = baseBranch ?? (await getBaseBranch()) const currentBranch = await getCurrentBranch() const diff = await getBranchChanges(base) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 481c27e75a0..ef3d5026c74 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -8,6 +8,7 @@ import { KiloCostPropagation } from "@/kilocode/session/cost-propagation" // kil import { KiloSessionProcessor } from "@/kilocode/session/processor" // kilocode_change import { Suggestion } from "@/kilocode/suggestion" // kilocode_change import { Question } from "@/question" // kilocode_change +import { ReviewBranch } from "@/kilocode/review/base" // kilocode_change import z from "zod" import * as EffectZod from "@/util/effect-zod" import { SessionID, MessageID, PartID } from "./schema" @@ -1711,7 +1712,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the const raw = input.arguments.match(argsRegex) ?? [] const args = raw.map((arg) => arg.replace(quoteTrimRegex, "")) - const templateCommand = yield* Effect.promise(async () => cmd.template) + const templateCommand = yield* Effect.gen(function* () { + // kilocode_change start - ask for the /local-review base branch before building the prompt + if (input.command === Command.Default.LOCAL_REVIEW && cmd.source === undefined) { + return yield* Effect.promise(() => ReviewBranch.template({ sessionID: input.sessionID })) + } + // kilocode_change end + return yield* Effect.promise(async () => cmd.template) + }) const placeholders = templateCommand.match(placeholderRegex) ?? [] let last = 0 diff --git a/packages/opencode/test/kilocode/local-review-base.test.ts b/packages/opencode/test/kilocode/local-review-base.test.ts new file mode 100644 index 00000000000..9cac3d54544 --- /dev/null +++ b/packages/opencode/test/kilocode/local-review-base.test.ts @@ -0,0 +1,93 @@ +import { $ } from "bun" +import { describe, expect, test } from "bun:test" +import path from "path" +import { Effect } from "effect" +import * as Log from "@opencode-ai/core/util/log" +import { Instance } from "../../src/project/instance" +import { Question } from "../../src/question" +import { ReviewBranch } from "../../src/kilocode/review/base" +import { Review } from "../../src/kilocode/review/review" +import { SessionID } from "../../src/session/schema" +import { SessionPrompt } from "../../src/session/prompt" +import { tmpdir } from "../fixture/fixture" + +void Log.init({ print: false }) + +async function withInstance(fn: (dir: string) => Promise) { + await using tmp = await tmpdir({ git: true }) + await $`git branch main`.cwd(tmp.path).quiet().nothrow() + await Instance.provide({ directory: tmp.path, fn: () => fn(tmp.path) }) +} + +async function wait(sessionID: SessionID) { + for (const _ of Array.from({ length: 50 })) { + const list = await Question.list() + const question = list.find((item) => item.sessionID === sessionID) + if (question) return question + await Bun.sleep(10) + } + throw new Error("timed out waiting for question") +} + +function run(fx: Effect.Effect) { + return Effect.runPromise(fx.pipe(Effect.scoped, Effect.provide(SessionPrompt.defaultLayer))) +} + +describe("local-review base branch", () => { + test("built-in local-review asks for a base branch before continuing", () => + withInstance(async () => { + const sessionID = SessionID.make("ses_local_review_base") + const pending = run( + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + return yield* prompt.command({ + sessionID, + command: "local-review", + arguments: "", + }) + }), + ).catch((err) => err) + + const question = await wait(sessionID) + expect(question.blocking).toBe(true) + expect(question.questions).toHaveLength(1) + expect(question.questions[0]?.header).toBe("Base branch") + expect(question.questions[0]?.custom).toBe(true) + expect(question.questions[0]?.options[0]?.label).toBe("main") + + await Question.reject(question.id) + expect(await pending).toBeInstanceOf(Question.RejectedError) + expect(await Question.list()).toEqual([]) + })) + + test("base resolver returns a typed custom branch", () => + withInstance(async () => { + const sessionID = SessionID.make("ses_local_review_custom") + const pending = ReviewBranch.resolve({ sessionID }) + const question = await wait(sessionID) + + await Question.reply({ + requestID: question.id, + answers: [[" release/next "]], + }) + + await expect(pending).resolves.toBe("release/next") + expect(await Question.list()).toEqual([]) + })) + + test("branch prompt uses the provided base branch", () => + withInstance(async (dir) => { + await $`git branch release`.cwd(dir).quiet() + await $`git checkout -b feature`.cwd(dir).quiet() + await Bun.write(path.join(dir, "feature.txt"), "feature\n") + await $`git add feature.txt`.cwd(dir).quiet() + await $`git commit -m "feature"`.cwd(dir).quiet() + + const prompt = await Review.buildReviewPromptBranch("release") + + expect(prompt).toContain("**branch diff**: `feature` -> `release`") + expect(prompt).toContain("These are the commits on `feature` since diverging from `release`:") + expect(prompt).toContain("`git diff release...feature`") + expect(prompt).toContain("`git log release..feature --oneline`") + })) +})