Files
nixos-config/modules/_opencode/plugin/review.ts
2026-04-01 11:40:32 +00:00

1269 lines
38 KiB
TypeScript

import type {
TuiPlugin,
TuiPluginModule,
TuiDialogSelectOption,
} from "@opencode-ai/plugin/tui"
import { promises as fs } from "node:fs"
import path from "node:path"
type BookmarkRef = { name: string; remote?: string }
type Change = { changeId: string; title: string }
type ReviewTarget =
| { type: "workingCopy" }
| { type: "baseBookmark"; bookmark: string; remote?: string }
| { type: "change"; changeId: string; title?: string }
| {
type: "pullRequest"
prNumber: number
baseBookmark: string
baseRemote?: string
title: string
}
| { type: "folder"; paths: string[] }
type ReviewSelectorValue = ReviewTarget["type"] | "toggleCustomInstructions"
const CUSTOM_INSTRUCTIONS_KEY = "review.customInstructions"
const WORKING_COPY_PROMPT =
"Review the current working-copy changes (including new files) and provide prioritized findings."
const LOCAL_CHANGES_REVIEW_INSTRUCTIONS =
"Also include local working-copy changes (including new files) on top of this bookmark. Use `jj status`, `jj diff --summary`, and `jj diff` so local fixes are part of this review cycle."
const BASE_BOOKMARK_PROMPT_WITH_MERGE_BASE =
"Review the code changes against the base bookmark '{baseBookmark}'. The merge-base change for this comparison is {mergeBaseChangeId}. Run `jj diff --from {mergeBaseChangeId} --to @` to inspect the changes relative to {baseBookmark}. Provide prioritized, actionable findings."
const BASE_BOOKMARK_PROMPT_FALLBACK =
"Review the code changes against the base bookmark '{bookmark}'. Start by finding the merge-base revision between the working copy and {bookmark}, then run `jj diff --from <merge-base> --to @` to see what changes would land on the {bookmark} bookmark. Provide prioritized, actionable findings."
const CHANGE_PROMPT_WITH_TITLE =
'Review the code changes introduced by change {changeId} ("{title}"). Provide prioritized, actionable findings.'
const CHANGE_PROMPT =
"Review the code changes introduced by change {changeId}. Provide prioritized, actionable findings."
const PULL_REQUEST_PROMPT =
'Review pull request #{prNumber} ("{title}") against the base bookmark \'{baseBookmark}\'. The merge-base change for this comparison is {mergeBaseChangeId}. Run `jj diff --from {mergeBaseChangeId} --to @` to inspect the changes that would be merged. Provide prioritized, actionable findings.'
const PULL_REQUEST_PROMPT_FALLBACK =
'Review pull request #{prNumber} ("{title}") against the base bookmark \'{baseBookmark}\'. Start by finding the merge-base revision between the working copy and {baseBookmark}, then run `jj diff --from <merge-base> --to @` to see the changes that would be merged. Provide prioritized, actionable findings.'
const FOLDER_REVIEW_PROMPT =
"Review the code in the following paths: {paths}. This is a snapshot review (not a diff). Read the files directly in these paths and provide prioritized, actionable findings."
const REVIEW_RUBRIC = `# Review Guidelines
You are acting as a code reviewer for a proposed code change made by another engineer.
Below are default guidelines for determining what to flag. These are not the final word — if you encounter more specific guidelines elsewhere (in a developer message, user message, file, or project review guidelines appended below), those override these general instructions.
## Determining what to flag
Flag issues that:
1. Meaningfully impact the accuracy, performance, security, or maintainability of the code.
2. Are discrete and actionable (not general issues or multiple combined issues).
3. Don't demand rigor inconsistent with the rest of the codebase.
4. Were introduced in the changes being reviewed (not pre-existing bugs).
5. The author would likely fix if aware of them.
6. Don't rely on unstated assumptions about the codebase or author's intent.
7. Have provable impact on other parts of the code — it is not enough to speculate that a change may disrupt another part, you must identify the parts that are provably affected.
8. Are clearly not intentional changes by the author.
9. Be particularly careful with untrusted user input and follow the specific guidelines to review.
10. Treat silent local error recovery (especially parsing/IO/network fallbacks) as high-signal review candidates unless there is explicit boundary-level justification.
## Untrusted User Input
1. Be careful with open redirects, they must always be checked to only go to trusted domains (?next_page=...)
2. Always flag SQL that is not parametrized
3. In systems with user supplied URL input, http fetches always need to be protected against access to local resources (intercept DNS resolver!)
4. Escape, don't sanitize if you have the option (eg: HTML escaping)
## Comment guidelines
1. Be clear about why the issue is a problem.
2. Communicate severity appropriately - don't exaggerate.
3. Be brief - at most 1 paragraph.
4. Keep code snippets under 3 lines, wrapped in inline code or code blocks.
5. Use \`\`\`suggestion blocks ONLY for concrete replacement code (minimal lines; no commentary inside the block). Preserve the exact leading whitespace of the replaced lines.
6. Explicitly state scenarios/environments where the issue arises.
7. Use a matter-of-fact tone - helpful AI assistant, not accusatory.
8. Write for quick comprehension without close reading.
9. Avoid excessive flattery or unhelpful phrases like "Great job...".
## Review priorities
1. Surface critical non-blocking human callouts (migrations, dependency churn, auth/permissions, compatibility, destructive operations) at the end.
2. Prefer simple, direct solutions over wrappers or abstractions without clear value.
3. Treat back pressure handling as critical to system stability.
4. Apply system-level thinking; flag changes that increase operational risk or on-call wakeups.
5. Ensure that errors are always checked against codes or stable identifiers, never error messages.
## Fail-fast error handling (strict)
When reviewing added or modified error handling, default to fail-fast behavior.
1. Evaluate every new or changed \`try/catch\`: identify what can fail and why local handling is correct at that exact layer.
2. Prefer propagation over local recovery. If the current scope cannot fully recover while preserving correctness, rethrow (optionally with context) instead of returning fallbacks.
3. Flag catch blocks that hide failure signals (e.g. returning \`null\`/\`[]\`/\`false\`, swallowing JSON parse failures, logging-and-continue, or “best effort” silent recovery).
4. JSON parsing/decoding should fail loudly by default. Quiet fallback parsing is only acceptable with an explicit compatibility requirement and clear tested behavior.
5. Boundary handlers (HTTP routes, CLI entrypoints, supervisors) may translate errors, but must not pretend success or silently degrade.
6. If a catch exists only to satisfy lint/style without real handling, treat it as a bug.
7. When uncertain, prefer crashing fast over silent degradation.
## Required human callouts (non-blocking, at the very end)
After findings/verdict, you MUST append this final section:
## Human Reviewer Callouts (Non-Blocking)
Include only applicable callouts (no yes/no lines):
- **This change adds a database migration:** <files/details>
- **This change introduces a new dependency:** <package(s)/details>
- **This change changes a dependency (or the lockfile):** <files/package(s)/details>
- **This change modifies auth/permission behavior:** <what changed and where>
- **This change introduces backwards-incompatible public schema/API/contract changes:** <what changed and where>
- **This change includes irreversible or destructive operations:** <operation and scope>
Rules for this section:
1. These are informational callouts for the human reviewer, not fix items.
2. Do not include them in Findings unless there is an independent defect.
3. These callouts alone must not change the verdict.
4. Only include callouts that apply to the reviewed change.
5. Keep each emitted callout bold exactly as written.
6. If none apply, write "- (none)".
## Priority levels
Tag each finding with a priority level in the title:
- [P0] - Drop everything to fix. Blocking release/operations. Only for universal issues that do not depend on assumptions about inputs.
- [P1] - Urgent. Should be addressed in the next cycle.
- [P2] - Normal. To be fixed eventually.
- [P3] - Low. Nice to have.
## Output format
Provide your findings in a clear, structured format:
1. List each finding with its priority tag, file location, and explanation.
2. Findings must reference locations that overlap with the actual diff — don't flag pre-existing code.
3. Keep line references as short as possible (avoid ranges over 5-10 lines; pick the most suitable subrange).
4. Provide an overall verdict: "correct" (no blocking issues) or "needs attention" (has blocking issues).
5. Ignore trivial style issues unless they obscure meaning or violate documented standards.
6. Do not generate a full PR fix — only flag issues and optionally provide short suggestion blocks.
7. End with the required "Human Reviewer Callouts (Non-Blocking)" section and all applicable bold callouts (no yes/no).
Output all findings the author would fix if they knew about them. If there are no qualifying findings, explicitly state the code looks good. Don't stop at the first finding - list every qualifying issue. Then append the required non-blocking callouts section.`
function bookmarkLabel(b: BookmarkRef): string {
return b.remote ? `${b.name}@${b.remote}` : b.name
}
function bookmarkRevset(b: BookmarkRef): string {
const q = JSON.stringify(b.name)
if (b.remote) {
return `remote_bookmarks(exact:${q}, exact:${JSON.stringify(b.remote)})`
}
return `bookmarks(exact:${q})`
}
function parseBookmarks(stdout: string): BookmarkRef[] {
const seen = new Set<string>()
return stdout
.trim()
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [name, remote = ""] = line.split("\t")
return {
name: name.trim(),
remote: remote.trim() || undefined,
}
})
.filter((b) => b.name && b.remote !== "git")
.filter((b) => {
const key = `${b.name}@${b.remote ?? ""}`
if (seen.has(key)) return false
seen.add(key)
return true
})
}
function parseChanges(stdout: string): Change[] {
return stdout
.trim()
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [changeId, ...rest] = line.split("\t")
return { changeId, title: rest.join(" ") }
})
}
function parsePrRef(ref: string): number | null {
const trimmed = ref.trim()
const num = parseInt(trimmed, 10)
if (!isNaN(num) && num > 0) return num
const urlMatch = trimmed.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/)
if (urlMatch) return parseInt(urlMatch[1], 10)
return null
}
function normalizeRemoteUrl(value: string): string {
return value
.trim()
.replace(/^git@github\.com:/, "https://github.com/")
.replace(/^ssh:\/\/git@github\.com\//, "https://github.com/")
.replace(/\.git$/, "")
.toLowerCase()
}
function sanitizeRemoteName(value: string): string {
return (
value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") ||
"gh-pr"
)
}
const plugin: TuiPlugin = async (api) => {
const cwd = api.state.path.directory
let reviewCustomInstructions = normalizeCustomInstructions(
api.kv.get<string | undefined>(CUSTOM_INSTRUCTIONS_KEY, undefined),
)
function setReviewCustomInstructions(value?: string): void {
reviewCustomInstructions = normalizeCustomInstructions(value)
api.kv.set(CUSTOM_INSTRUCTIONS_KEY, reviewCustomInstructions)
}
// -- shell helpers -------------------------------------------------------
async function exec(
cmd: string,
args: string[],
): Promise<{ stdout: string; exitCode: number; stderr: string }> {
const proc = Bun.spawn([cmd, ...args], {
cwd,
stdout: "pipe",
stderr: "pipe",
})
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
])
const exitCode = await proc.exited
return { stdout, exitCode, stderr }
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function jj(
...args: string[]
): Promise<{ stdout: string; ok: boolean }> {
const r = await exec("jj", args)
return { stdout: r.stdout, ok: r.exitCode === 0 }
}
async function gh(
...args: string[]
): Promise<{ stdout: string; ok: boolean; stderr: string }> {
const r = await exec("gh", args)
return { stdout: r.stdout, ok: r.exitCode === 0, stderr: r.stderr }
}
// -- jj helpers ----------------------------------------------------------
async function isJjRepo(): Promise<boolean> {
return (await jj("root")).ok
}
async function hasWorkingCopyChanges(): Promise<boolean> {
const r = await jj("diff", "--summary")
return r.ok && r.stdout.trim().length > 0
}
async function getBookmarks(): Promise<BookmarkRef[]> {
const r = await jj(
"bookmark",
"list",
"--all-remotes",
"-T",
'name ++ "\\t" ++ remote ++ "\\n"',
)
if (!r.ok) return []
return parseBookmarks(r.stdout)
}
async function getCurrentBookmarks(): Promise<BookmarkRef[]> {
const headRevset = (await hasWorkingCopyChanges()) ? "@" : "@-"
const r = await jj(
"bookmark",
"list",
"--all-remotes",
"-r",
headRevset,
"-T",
'name ++ "\\t" ++ remote ++ "\\n"',
)
if (!r.ok) return []
return parseBookmarks(r.stdout)
}
async function getDefaultBookmark(): Promise<BookmarkRef | null> {
const trunkR = await jj(
"bookmark",
"list",
"--all-remotes",
"-r",
"trunk()",
"-T",
'name ++ "\\t" ++ remote ++ "\\n"',
)
if (trunkR.ok) {
const bookmarks = parseBookmarks(trunkR.stdout)
if (bookmarks.length > 0) return bookmarks[0]
}
const all = await getBookmarks()
return (
all.find((b) => !b.remote && b.name === "main") ??
all.find((b) => !b.remote && b.name === "master") ??
all[0] ??
null
)
}
async function getRecentChanges(limit = 20): Promise<Change[]> {
const r = await jj(
"log",
"-n",
String(limit),
"--no-graph",
"-T",
'change_id.shortest(8) ++ "\\t" ++ description.first_line() ++ "\\n"',
)
if (!r.ok) return []
return parseChanges(r.stdout)
}
async function getMergeBase(
bookmark: string,
remote?: string,
): Promise<string | null> {
const ref = await resolveBookmarkRef(bookmark, remote)
if (!ref) return null
const r = await jj(
"log",
"-r",
`heads(::@ & ::${bookmarkRevset(ref)})`,
"--no-graph",
"-T",
'change_id.shortest(8) ++ "\\n"',
)
if (!r.ok) return null
const lines = r.stdout
.trim()
.split("\n")
.filter((l) => l.trim())
return lines.length === 1 ? lines[0].trim() : null
}
// -- PR materialization --------------------------------------------------
async function materializePr(prNumber: number): Promise<
| {
ok: true
title: string
baseBookmark: string
baseRemote?: string
headBookmark: string
remote: string
savedChangeId: string
}
| { ok: false; error: string }
> {
if (await hasWorkingCopyChanges()) {
return {
ok: false,
error: "You have local jj changes. Snapshot or discard them first.",
}
}
const savedR = await jj(
"log",
"-r",
"@",
"--no-graph",
"-T",
"change_id.shortest(8)",
)
const savedChangeId = savedR.stdout.trim()
const prR = await gh(
"pr",
"view",
String(prNumber),
"--json",
"baseRefName,title,headRefName,isCrossRepository,headRepository,headRepositoryOwner",
)
if (!prR.ok) {
return {
ok: false,
error: `Could not find PR #${prNumber}. Check gh auth and that the PR exists.`,
}
}
let prInfo: {
baseRefName: string
title: string
headRefName: string
isCrossRepository: boolean
headRepository?: { name: string; url: string }
headRepositoryOwner?: { login: string }
}
try {
prInfo = JSON.parse(prR.stdout)
} catch {
return { ok: false, error: "Failed to parse PR info" }
}
const remotesR = await jj("git", "remote", "list")
const remotes = remotesR.stdout
.trim()
.split("\n")
.filter(Boolean)
.map((line) => {
const [name, ...urlParts] = line.split(/\s+/)
return { name, url: urlParts.join(" ") }
})
.filter((r) => r.name && r.url)
const defaultRemote =
remotes.find((r) => r.name === "origin") ?? remotes[0]
if (!defaultRemote) {
return { ok: false, error: "No jj remotes configured" }
}
let remoteName = defaultRemote.name
let addedTempRemote = false
if (prInfo.isCrossRepository) {
const repoSlug =
prInfo.headRepositoryOwner?.login && prInfo.headRepository?.name
? `${prInfo.headRepositoryOwner.login}/${prInfo.headRepository.name}`.toLowerCase()
: undefined
const forkUrl = prInfo.headRepository?.url
const existingRemote = remotes.find((r) => {
if (
forkUrl &&
normalizeRemoteUrl(r.url) === normalizeRemoteUrl(forkUrl)
)
return true
return repoSlug
? normalizeRemoteUrl(r.url).includes(
`github.com/${repoSlug}`,
)
: false
})
if (existingRemote) {
remoteName = existingRemote.name
} else if (forkUrl) {
const baseName = sanitizeRemoteName(
`gh-pr-${prInfo.headRepositoryOwner?.login ?? "remote"}-${prInfo.headRepository?.name ?? prNumber}`,
)
const names = new Set(remotes.map((r) => r.name))
remoteName = baseName
let suffix = 2
while (names.has(remoteName)) {
remoteName = `${baseName}-${suffix++}`
}
const addR = await jj(
"git",
"remote",
"add",
remoteName,
forkUrl,
)
if (!addR.ok) return { ok: false, error: "Failed to add PR remote" }
addedTempRemote = true
} else {
return { ok: false, error: "PR fork URL is unavailable" }
}
}
const fetchR = await jj(
"git",
"fetch",
"--remote",
remoteName,
"--branch",
prInfo.headRefName,
)
if (!fetchR.ok) {
if (addedTempRemote)
await jj("git", "remote", "remove", remoteName)
return { ok: false, error: "Failed to fetch PR branch" }
}
const revset = `remote_bookmarks(exact:${JSON.stringify(prInfo.headRefName)}, exact:${JSON.stringify(remoteName)})`
const newR = await jj("new", revset)
if (!newR.ok) {
if (addedTempRemote)
await jj("git", "remote", "remove", remoteName)
return { ok: false, error: "Failed to create change on PR branch" }
}
if (addedTempRemote) await jj("git", "remote", "remove", remoteName)
// Resolve base bookmark remote
const baseRef = await resolveBookmarkRef(prInfo.baseRefName)
return {
ok: true,
title: prInfo.title,
baseBookmark: prInfo.baseRefName,
baseRemote: baseRef?.remote,
headBookmark: prInfo.headRefName,
remote: remoteName,
savedChangeId,
}
}
function normalizeCustomInstructions(
value: string | undefined,
): string | undefined {
const normalized = value?.trim()
return normalized ? normalized : undefined
}
function parseNonEmptyLines(stdout: string): string[] {
return stdout
.trim()
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
}
function bookmarkRefsEqual(left: BookmarkRef, right: BookmarkRef): boolean {
return left.name === right.name && left.remote === right.remote
}
function parseBookmarkReference(value: string): BookmarkRef {
const trimmed = value.trim()
const separatorIndex = trimmed.lastIndexOf("@")
if (separatorIndex <= 0 || separatorIndex === trimmed.length - 1) {
return { name: trimmed }
}
return {
name: trimmed.slice(0, separatorIndex),
remote: trimmed.slice(separatorIndex + 1),
}
}
function dedupeBookmarkRefs(bookmarks: BookmarkRef[]): BookmarkRef[] {
const seen = new Set<string>()
const result: BookmarkRef[] = []
for (const bookmark of bookmarks) {
const key = `${bookmark.name}@${bookmark.remote ?? ""}`
if (seen.has(key)) continue
seen.add(key)
result.push(bookmark)
}
return result
}
async function getBookmarkRefs(options?: {
revset?: string
includeRemotes?: boolean
}): Promise<BookmarkRef[]> {
const args = ["bookmark", "list"]
if (options?.includeRemotes) args.push("--all-remotes")
if (options?.revset) args.push("-r", options.revset)
args.push("-T", 'name ++ "\\t" ++ remote ++ "\\n"')
const r = await jj(...args)
if (!r.ok) return []
return dedupeBookmarkRefs(parseBookmarks(r.stdout))
}
async function getSingleRevisionId(revset: string): Promise<string | null> {
const r = await jj(
"log",
"-r",
revset,
"--no-graph",
"-T",
'commit_id ++ "\\n"',
)
if (!r.ok) return null
const revisions = parseNonEmptyLines(r.stdout)
return revisions.length === 1 ? revisions[0] : null
}
async function getSingleChangeId(revset: string): Promise<string | null> {
const r = await jj(
"log",
"-r",
revset,
"--no-graph",
"-T",
'change_id.shortest(8) ++ "\\n"',
)
if (!r.ok) return null
const revisions = parseNonEmptyLines(r.stdout)
return revisions.length === 1 ? revisions[0] : null
}
async function getJjRemotes(): Promise<Array<{ name: string; url: string }>> {
const r = await jj("git", "remote", "list")
if (!r.ok) return []
return parseNonEmptyLines(r.stdout)
.map((line) => {
const [name, ...urlParts] = line.split(/\s+/)
return { name, url: urlParts.join(" ") }
})
.filter((remote) => remote.name && remote.url)
}
async function getDefaultRemoteName(): Promise<string | null> {
const remotes = await getJjRemotes()
if (remotes.length === 0) return null
return remotes.find((remote) => remote.name === "origin")?.name ?? remotes[0].name
}
function preferBookmarkRef(
bookmarks: BookmarkRef[],
preferredRemote?: string | null,
): BookmarkRef | null {
if (bookmarks.length === 0) return null
return (
bookmarks.find((bookmark) => !bookmark.remote) ??
(preferredRemote
? bookmarks.find((bookmark) => bookmark.remote === preferredRemote)
: undefined) ??
bookmarks[0]
)
}
async function resolveBookmarkRef(
bookmark: string,
remote?: string,
): Promise<BookmarkRef | null> {
if (remote) return { name: bookmark, remote }
const localBookmark = (await getBookmarkRefs()).find(
(entry) => entry.name === bookmark,
)
if (localBookmark) return localBookmark
const matchingRemoteBookmarks = (
await getBookmarkRefs({ includeRemotes: true })
).filter((entry) => entry.remote && entry.name === bookmark)
if (matchingRemoteBookmarks.length === 0) return null
return preferBookmarkRef(
matchingRemoteBookmarks,
await getDefaultRemoteName(),
)
}
async function getReviewBookmarks(): Promise<BookmarkRef[]> {
const localBookmarks = await getBookmarkRefs()
const localNames = new Set(localBookmarks.map((bookmark) => bookmark.name))
const defaultRemoteName = await getDefaultRemoteName()
const remoteOnlyBookmarks = (
await getBookmarkRefs({ includeRemotes: true })
)
.filter((bookmark) => bookmark.remote && !localNames.has(bookmark.name))
.sort((left, right) => {
if (left.name !== right.name) return left.name.localeCompare(right.name)
if (left.remote === defaultRemoteName) return -1
if (right.remote === defaultRemoteName) return 1
return (left.remote ?? "").localeCompare(right.remote ?? "")
})
return dedupeBookmarkRefs([...localBookmarks, ...remoteOnlyBookmarks])
}
async function getReviewHeadRevset(): Promise<string> {
return (await hasWorkingCopyChanges()) ? "@" : "@-"
}
async function getCurrentReviewBookmarks(): Promise<BookmarkRef[]> {
return getBookmarkRefs({
revset: await getReviewHeadRevset(),
includeRemotes: true,
})
}
async function getDefaultBookmarkRef(): Promise<BookmarkRef | null> {
const defaultRemoteName = await getDefaultRemoteName()
const trunkBookmarks = await getBookmarkRefs({
revset: "trunk()",
includeRemotes: true,
})
const trunkBookmark = preferBookmarkRef(trunkBookmarks, defaultRemoteName)
if (trunkBookmark) return trunkBookmark
const bookmarks = await getReviewBookmarks()
const mainBookmark =
bookmarks.find((bookmark) => !bookmark.remote && bookmark.name === "main") ??
bookmarks.find((bookmark) => !bookmark.remote && bookmark.name === "master") ??
bookmarks.find(
(bookmark) =>
bookmark.remote === defaultRemoteName && bookmark.name === "main",
) ??
bookmarks.find(
(bookmark) =>
bookmark.remote === defaultRemoteName && bookmark.name === "master",
)
return mainBookmark ?? bookmarks[0] ?? null
}
async function loadProjectReviewGuidelines(): Promise<string | null> {
let currentDir = path.resolve(cwd)
while (true) {
const opencodeDir = path.join(currentDir, ".opencode")
const guidelinesPath = path.join(currentDir, "REVIEW_GUIDELINES.md")
const opencodeStats = await fs.stat(opencodeDir).catch(() => null)
if (opencodeStats?.isDirectory()) {
const guidelineStats = await fs.stat(guidelinesPath).catch(() => null)
if (!guidelineStats?.isFile()) return null
try {
const content = await fs.readFile(guidelinesPath, "utf8")
const trimmed = content.trim()
return trimmed ? trimmed : null
} catch {
return null
}
}
const parentDir = path.dirname(currentDir)
if (parentDir === currentDir) return null
currentDir = parentDir
}
}
// -- prompt building -----------------------------------------------------
async function buildTargetReviewPrompt(
target: ReviewTarget,
options?: { includeLocalChanges?: boolean },
): Promise<string> {
const includeLocalChanges = options?.includeLocalChanges === true
switch (target.type) {
case "workingCopy":
return WORKING_COPY_PROMPT
case "baseBookmark": {
const bookmark = await resolveBookmarkRef(
target.bookmark,
target.remote,
)
const bookmarkLabelValue = bookmarkLabel(
bookmark ?? { name: target.bookmark, remote: target.remote },
)
const mergeBase = await getMergeBase(target.bookmark, target.remote)
const basePrompt = mergeBase
? BASE_BOOKMARK_PROMPT_WITH_MERGE_BASE
.replace(/{baseBookmark}/g, bookmarkLabelValue)
.replace(/{mergeBaseChangeId}/g, mergeBase)
: BASE_BOOKMARK_PROMPT_FALLBACK.replace(
/{bookmark}/g,
bookmarkLabelValue,
)
return includeLocalChanges
? `${basePrompt} ${LOCAL_CHANGES_REVIEW_INSTRUCTIONS}`
: basePrompt
}
case "change":
return target.title
? CHANGE_PROMPT_WITH_TITLE.replace(
"{changeId}",
target.changeId,
).replace("{title}", target.title)
: CHANGE_PROMPT.replace("{changeId}", target.changeId)
case "pullRequest": {
const bookmark = await resolveBookmarkRef(
target.baseBookmark,
target.baseRemote,
)
const baseBookmarkLabel = bookmarkLabel(
bookmark ?? {
name: target.baseBookmark,
remote: target.baseRemote,
},
)
const mergeBase = await getMergeBase(
target.baseBookmark,
target.baseRemote,
)
const basePrompt = mergeBase
? PULL_REQUEST_PROMPT.replace(/{prNumber}/g, String(target.prNumber))
.replace(/{title}/g, target.title)
.replace(/{baseBookmark}/g, baseBookmarkLabel)
.replace(/{mergeBaseChangeId}/g, mergeBase)
: PULL_REQUEST_PROMPT_FALLBACK.replace(
/{prNumber}/g,
String(target.prNumber),
)
.replace(/{title}/g, target.title)
.replace(/{baseBookmark}/g, baseBookmarkLabel)
return includeLocalChanges
? `${basePrompt} ${LOCAL_CHANGES_REVIEW_INSTRUCTIONS}`
: basePrompt
}
case "folder":
return FOLDER_REVIEW_PROMPT.replace(
"{paths}",
target.paths.join(", "),
)
}
}
async function buildReviewPrompt(target: ReviewTarget): Promise<string> {
const prompt = await buildTargetReviewPrompt(target)
const projectGuidelines = await loadProjectReviewGuidelines()
const sharedInstructions = normalizeCustomInstructions(
reviewCustomInstructions,
)
let fullPrompt = `${REVIEW_RUBRIC}\n\n---\n\nPlease perform a code review with the following focus:\n\n${prompt}`
if (sharedInstructions) {
fullPrompt += `\n\nShared custom review instructions (applies to all reviews):\n\n${sharedInstructions}`
}
if (projectGuidelines) {
fullPrompt += `\n\nThis project has additional instructions for code reviews:\n\n${projectGuidelines}`
}
return fullPrompt
}
async function getSmartDefault(): Promise<
"workingCopy" | "baseBookmark" | "change"
> {
if (await hasWorkingCopyChanges()) return "workingCopy"
const defaultBookmark = await getDefaultBookmarkRef()
if (defaultBookmark) {
const reviewHeadRevision = await getSingleRevisionId(
await getReviewHeadRevset(),
)
const defaultBookmarkRevision = await getSingleRevisionId(
bookmarkRevset(defaultBookmark),
)
if (
reviewHeadRevision &&
defaultBookmarkRevision &&
reviewHeadRevision !== defaultBookmarkRevision
) {
return "baseBookmark"
}
}
return "change"
}
function getUserFacingHint(target: ReviewTarget): string {
switch (target.type) {
case "workingCopy":
return "working-copy changes"
case "baseBookmark":
return `changes against '${bookmarkLabel({ name: target.bookmark, remote: target.remote })}'`
case "change":
return target.title
? `change ${target.changeId}: ${target.title}`
: `change ${target.changeId}`
case "pullRequest": {
const shortTitle =
target.title.length > 30
? `${target.title.slice(0, 27)}...`
: target.title
return `PR #${target.prNumber}: ${shortTitle}`
}
case "folder": {
const joined = target.paths.join(", ")
return joined.length > 40
? `folders: ${joined.slice(0, 37)}...`
: `folders: ${joined}`
}
}
}
// -- review execution ----------------------------------------------------
async function startReview(target: ReviewTarget): Promise<void> {
const prompt = await buildReviewPrompt(target)
const hint = getUserFacingHint(target)
const cleared = await api.client.tui.clearPrompt()
const appended = await api.client.tui.appendPrompt({
text: prompt,
})
// `prompt.submit` is ignored unless the prompt input is focused.
// When this runs from a dialog, focus returns on the next tick.
await sleep(50)
const submitted = await api.client.tui.submitPrompt()
if (!cleared || !appended || !submitted) {
api.ui.toast({
message: "Failed to start review prompt automatically",
variant: "error",
})
return
}
api.ui.toast({
message: `Starting review: ${hint}`,
variant: "info",
})
}
// -- dialogs -------------------------------------------------------------
async function showReviewSelector(): Promise<void> {
const smartDefault = await getSmartDefault()
const options: TuiDialogSelectOption<ReviewSelectorValue>[] = [
{
title: "Review working-copy changes",
value: "workingCopy",
},
{
title: "Review against a base bookmark",
value: "baseBookmark",
description: "(local)",
},
{
title: "Review a change",
value: "change",
},
{
title: "Review a pull request",
value: "pullRequest",
description: "(GitHub PR)",
},
{
title: "Review a folder (or more)",
value: "folder",
description: "(snapshot, not diff)",
},
{
title: reviewCustomInstructions
? "Remove custom review instructions"
: "Add custom review instructions",
value: "toggleCustomInstructions",
description: reviewCustomInstructions
? "(currently set)"
: "(applies to all review modes)",
},
]
api.ui.dialog.replace(
() =>
api.ui.DialogSelect({
title: "Select a review preset",
options,
current: smartDefault,
onSelect: (option) => {
api.ui.dialog.clear()
switch (option.value) {
case "workingCopy":
void startReview({ type: "workingCopy" })
break
case "baseBookmark":
void showBookmarkSelector()
break
case "change":
void showChangeSelector()
break
case "pullRequest":
void showPrInput()
break
case "folder":
showFolderInput()
break
case "toggleCustomInstructions":
if (reviewCustomInstructions) {
setReviewCustomInstructions(undefined)
api.ui.toast({
message: "Custom review instructions removed",
variant: "info",
})
void showReviewSelector()
break
}
showCustomInstructionsInput()
break
}
},
}),
)
}
function showCustomInstructionsInput(): void {
api.ui.dialog.replace(
() =>
api.ui.DialogPrompt({
title: "Custom review instructions",
placeholder: "focus on performance regressions",
value: reviewCustomInstructions,
onConfirm: (value) => {
const next = normalizeCustomInstructions(value)
api.ui.dialog.clear()
if (!next) {
api.ui.toast({
message: "Custom review instructions not changed",
variant: "info",
})
void showReviewSelector()
return
}
setReviewCustomInstructions(next)
api.ui.toast({
message: "Custom review instructions saved",
variant: "success",
})
void showReviewSelector()
},
onCancel: () => {
api.ui.dialog.clear()
void showReviewSelector()
},
}),
)
}
async function showBookmarkSelector(): Promise<void> {
api.ui.toast({ message: "Loading bookmarks...", variant: "info" })
const bookmarks = await getReviewBookmarks()
const currentBookmarks = await getCurrentReviewBookmarks()
const defaultBookmark = await getDefaultBookmarkRef()
const candidates = bookmarks.filter(
(bookmark) =>
!currentBookmarks.some((currentBookmark) =>
bookmarkRefsEqual(bookmark, currentBookmark),
),
)
if (candidates.length === 0) {
const currentLabel = currentBookmarks[0]
? bookmarkLabel(currentBookmarks[0])
: undefined
api.ui.toast({
message: currentLabel
? `No other bookmarks found (current bookmark: ${currentLabel})`
: "No bookmarks found",
variant: "error",
})
return
}
const sorted = candidates.sort((a, b) => {
if (defaultBookmark && bookmarkRefsEqual(a, defaultBookmark)) return -1
if (defaultBookmark && bookmarkRefsEqual(b, defaultBookmark)) return 1
if (!!a.remote !== !!b.remote) return a.remote ? 1 : -1
return bookmarkLabel(a).localeCompare(bookmarkLabel(b))
})
const options: TuiDialogSelectOption<BookmarkRef>[] = sorted.map(
(b) => ({
title: bookmarkLabel(b),
value: b,
description:
defaultBookmark && bookmarkRefsEqual(b, defaultBookmark)
? "(default)"
: b.remote
? `(remote ${b.remote})`
: undefined,
}),
)
api.ui.dialog.replace(
() =>
api.ui.DialogSelect({
title: "Select base bookmark",
placeholder: "Filter bookmarks...",
options,
onSelect: (option) => {
api.ui.dialog.clear()
void startReview({
type: "baseBookmark",
bookmark: option.value.name,
remote: option.value.remote,
})
},
}),
)
}
async function showChangeSelector(): Promise<void> {
api.ui.toast({ message: "Loading changes...", variant: "info" })
const changes = await getRecentChanges()
if (changes.length === 0) {
api.ui.toast({ message: "No changes found", variant: "error" })
return
}
const options: TuiDialogSelectOption<Change>[] = changes.map((c) => ({
title: `${c.changeId} ${c.title}`,
value: c,
}))
api.ui.dialog.replace(
() =>
api.ui.DialogSelect({
title: "Select change to review",
placeholder: "Filter changes...",
options,
onSelect: (option) => {
api.ui.dialog.clear()
void startReview({
type: "change",
changeId: option.value.changeId,
title: option.value.title,
})
},
}),
)
}
async function showPrInput(): Promise<void> {
if (await hasWorkingCopyChanges()) {
api.ui.toast({
message:
"Cannot materialize PR: you have local jj changes. Please snapshot or discard them first.",
variant: "error",
})
return
}
api.ui.dialog.replace(
() =>
api.ui.DialogPrompt({
title: "Enter PR number or URL",
placeholder:
"123 or https://github.com/owner/repo/pull/123",
onConfirm: (value) => {
const prNumber = parsePrRef(value)
if (!prNumber) {
api.ui.toast({
message:
"Invalid PR reference. Enter a number or GitHub PR URL.",
variant: "error",
})
return
}
api.ui.dialog.clear()
void handlePrReview(prNumber)
},
}),
)
}
async function handlePrReview(prNumber: number): Promise<void> {
api.ui.toast({
message: `Fetching PR #${prNumber} info...`,
variant: "info",
})
api.ui.toast({
message: `Materializing PR #${prNumber} with jj...`,
variant: "info",
duration: 10000,
})
const result = await materializePr(prNumber)
if (!result.ok) {
api.ui.toast({ message: result.error, variant: "error" })
return
}
api.ui.toast({
message: `Materialized PR #${prNumber} (${result.headBookmark}@${result.remote})`,
variant: "info",
})
await startReview({
type: "pullRequest",
prNumber,
baseBookmark: result.baseBookmark,
baseRemote: result.baseRemote,
title: result.title,
})
}
function showFolderInput(): void {
api.ui.dialog.replace(
() =>
api.ui.DialogPrompt({
title: "Enter folders/files to review",
placeholder: ".",
onConfirm: (value) => {
const paths = value
.split(/\s+/)
.map((p) => p.trim())
.filter((p) => p.length > 0)
if (paths.length === 0) {
api.ui.toast({
message: "No paths provided",
variant: "error",
})
return
}
api.ui.dialog.clear()
void startReview({ type: "folder", paths })
},
}),
)
}
// -- jj repo check -------------------------------------------------------
const inJjRepo = await isJjRepo()
// -- command registration ------------------------------------------------
api.command.register(() =>
inJjRepo
? [
{
title: "Review code changes",
value: "review",
description:
"Review code changes (PR, working copy, bookmark, change, or folder)",
slash: { name: "review", aliases: ["jj-review"] },
onSelect: () => void showReviewSelector(),
},
]
: [],
)
}
export default {
id: "review",
tui: plugin,
} satisfies TuiPluginModule