|
|
|
|
@@ -3,6 +3,8 @@ import type {
|
|
|
|
|
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 }
|
|
|
|
|
@@ -17,9 +19,143 @@ type ReviewTarget =
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
@@ -94,6 +230,14 @@ function sanitizeRemoteName(value: string): string {
|
|
|
|
|
|
|
|
|
|
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 -------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
@@ -114,6 +258,10 @@ const plugin: TuiPlugin = async (api) => {
|
|
|
|
|
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 }> {
|
|
|
|
|
@@ -206,7 +354,8 @@ const plugin: TuiPlugin = async (api) => {
|
|
|
|
|
bookmark: string,
|
|
|
|
|
remote?: string,
|
|
|
|
|
): Promise<string | null> {
|
|
|
|
|
const ref: BookmarkRef = { name: bookmark, remote }
|
|
|
|
|
const ref = await resolveBookmarkRef(bookmark, remote)
|
|
|
|
|
if (!ref) return null
|
|
|
|
|
const r = await jj(
|
|
|
|
|
"log",
|
|
|
|
|
"-r",
|
|
|
|
|
@@ -231,6 +380,8 @@ const plugin: TuiPlugin = async (api) => {
|
|
|
|
|
title: string
|
|
|
|
|
baseBookmark: string
|
|
|
|
|
baseRemote?: string
|
|
|
|
|
headBookmark: string
|
|
|
|
|
remote: string
|
|
|
|
|
savedChangeId: string
|
|
|
|
|
}
|
|
|
|
|
| { ok: false; error: string }
|
|
|
|
|
@@ -371,159 +522,467 @@ const plugin: TuiPlugin = async (api) => {
|
|
|
|
|
if (addedTempRemote) await jj("git", "remote", "remove", remoteName)
|
|
|
|
|
|
|
|
|
|
// Resolve base bookmark remote
|
|
|
|
|
const baseBms = await getBookmarks()
|
|
|
|
|
const baseRef = baseBms.find((b) => b.name === prInfo.baseRefName)
|
|
|
|
|
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 buildPrompt(target: ReviewTarget): Promise<string> {
|
|
|
|
|
async function buildTargetReviewPrompt(
|
|
|
|
|
target: ReviewTarget,
|
|
|
|
|
options?: { includeLocalChanges?: boolean },
|
|
|
|
|
): Promise<string> {
|
|
|
|
|
const includeLocalChanges = options?.includeLocalChanges === true
|
|
|
|
|
|
|
|
|
|
switch (target.type) {
|
|
|
|
|
case "workingCopy":
|
|
|
|
|
return "Review the current working-copy changes (including new files). Use `jj status`, `jj diff --summary`, and `jj diff` to inspect."
|
|
|
|
|
return WORKING_COPY_PROMPT
|
|
|
|
|
|
|
|
|
|
case "baseBookmark": {
|
|
|
|
|
const label = bookmarkLabel({
|
|
|
|
|
name: target.bookmark,
|
|
|
|
|
remote: target.remote,
|
|
|
|
|
})
|
|
|
|
|
const mergeBase = await getMergeBase(
|
|
|
|
|
const bookmark = await resolveBookmarkRef(
|
|
|
|
|
target.bookmark,
|
|
|
|
|
target.remote,
|
|
|
|
|
)
|
|
|
|
|
if (mergeBase) {
|
|
|
|
|
return `Review code changes against the base bookmark '${label}'. The merge-base change is ${mergeBase}. Run \`jj diff --from ${mergeBase} --to @\` to inspect the changes. Also check for local working-copy changes with \`jj diff --summary\`.`
|
|
|
|
|
}
|
|
|
|
|
return `Review code changes against the base bookmark '${label}'. Find the merge-base between @ and ${label}, then run \`jj diff --from <merge-base> --to @\`. Also check for local working-copy changes.`
|
|
|
|
|
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
|
|
|
|
|
? `Review the code changes introduced by change ${target.changeId} ("${target.title}"). Use \`jj show ${target.changeId}\` to inspect.`
|
|
|
|
|
: `Review the code changes introduced by change ${target.changeId}. Use \`jj show ${target.changeId}\` to inspect.`
|
|
|
|
|
? CHANGE_PROMPT_WITH_TITLE.replace(
|
|
|
|
|
"{changeId}",
|
|
|
|
|
target.changeId,
|
|
|
|
|
).replace("{title}", target.title)
|
|
|
|
|
: CHANGE_PROMPT.replace("{changeId}", target.changeId)
|
|
|
|
|
|
|
|
|
|
case "pullRequest": {
|
|
|
|
|
const label = bookmarkLabel({
|
|
|
|
|
name: target.baseBookmark,
|
|
|
|
|
remote: target.baseRemote,
|
|
|
|
|
})
|
|
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
if (mergeBase) {
|
|
|
|
|
return `Review pull request #${target.prNumber} ("${target.title}") against '${label}'. Merge-base is ${mergeBase}. Run \`jj diff --from ${mergeBase} --to @\` to inspect.`
|
|
|
|
|
}
|
|
|
|
|
return `Review pull request #${target.prNumber} ("${target.title}") against '${label}'. Find the merge-base and run \`jj diff --from <merge-base> --to @\`.`
|
|
|
|
|
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 `Review the code in the following paths: ${target.paths.join(", ")}. This is a snapshot review (not a diff). Read the files directly.`
|
|
|
|
|
return FOLDER_REVIEW_PROMPT.replace(
|
|
|
|
|
"{paths}",
|
|
|
|
|
target.paths.join(", "),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function buildReviewPrompt(target: ReviewTarget): Promise<string> {
|
|
|
|
|
const task = await buildPrompt(target)
|
|
|
|
|
return [
|
|
|
|
|
"You are acting as a code reviewer. Do not make code changes. Provide actionable feedback on code changes.",
|
|
|
|
|
"",
|
|
|
|
|
"Diffs alone are not enough. Read the full file(s) being modified to understand context. Code that looks wrong in isolation may be correct given surrounding logic.",
|
|
|
|
|
"",
|
|
|
|
|
"What to look for:",
|
|
|
|
|
"",
|
|
|
|
|
"Bugs — primary focus:",
|
|
|
|
|
"- Logic errors, off-by-one mistakes, incorrect conditionals",
|
|
|
|
|
"- Missing guards, unreachable code paths, broken error handling",
|
|
|
|
|
"- Edge cases: null/empty inputs, race conditions",
|
|
|
|
|
"- Security: injection, auth bypass, data exposure",
|
|
|
|
|
"",
|
|
|
|
|
"Structure:",
|
|
|
|
|
"- Does the code fit the codebase's patterns and conventions?",
|
|
|
|
|
"- Does it use established abstractions?",
|
|
|
|
|
"- Is there excessive nesting that should be flattened?",
|
|
|
|
|
"",
|
|
|
|
|
"Performance:",
|
|
|
|
|
"- Only flag obvious issues like O(n^2) on unbounded data, N+1 queries, or blocking I/O on hot paths.",
|
|
|
|
|
"",
|
|
|
|
|
"Before you flag something:",
|
|
|
|
|
"- Be certain. Investigate first if unsure.",
|
|
|
|
|
"- Do not invent hypothetical problems.",
|
|
|
|
|
"- Do not be a zealot about style.",
|
|
|
|
|
"- Only review the requested changes, not unrelated pre-existing issues.",
|
|
|
|
|
"",
|
|
|
|
|
"Output:",
|
|
|
|
|
"- Be direct about bugs and why they are bugs",
|
|
|
|
|
"- Communicate severity honestly",
|
|
|
|
|
"- Include file paths and line numbers",
|
|
|
|
|
"- Suggest fixes when appropriate",
|
|
|
|
|
"- Use a matter-of-fact tone, no flattery",
|
|
|
|
|
"",
|
|
|
|
|
"Task:",
|
|
|
|
|
task,
|
|
|
|
|
].join("\n")
|
|
|
|
|
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) {
|
|
|
|
|
if (!cleared || !appended || !submitted) {
|
|
|
|
|
api.ui.toast({
|
|
|
|
|
message: "Failed to draft review prompt",
|
|
|
|
|
message: "Failed to start review prompt automatically",
|
|
|
|
|
variant: "error",
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
api.ui.toast({
|
|
|
|
|
message: `Starting review: ${hint}`,
|
|
|
|
|
variant: "info",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- dialogs -------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function showReviewSelector(): void {
|
|
|
|
|
const options: TuiDialogSelectOption<string>[] = [
|
|
|
|
|
async function showReviewSelector(): Promise<void> {
|
|
|
|
|
const smartDefault = await getSmartDefault()
|
|
|
|
|
const options: TuiDialogSelectOption<ReviewSelectorValue>[] = [
|
|
|
|
|
{
|
|
|
|
|
title: "Working-copy changes",
|
|
|
|
|
title: "Review working-copy changes",
|
|
|
|
|
value: "workingCopy",
|
|
|
|
|
description: "Review uncommitted changes",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: "Against a bookmark",
|
|
|
|
|
title: "Review against a base bookmark",
|
|
|
|
|
value: "baseBookmark",
|
|
|
|
|
description: "PR-style review against a base",
|
|
|
|
|
description: "(local)",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: "A specific change",
|
|
|
|
|
title: "Review a change",
|
|
|
|
|
value: "change",
|
|
|
|
|
description: "Review a single jj change",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: "A pull request",
|
|
|
|
|
title: "Review a pull request",
|
|
|
|
|
value: "pullRequest",
|
|
|
|
|
description: "Materialize and review a GitHub PR",
|
|
|
|
|
description: "(GitHub PR)",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: "A folder (snapshot)",
|
|
|
|
|
title: "Review a folder (or more)",
|
|
|
|
|
value: "folder",
|
|
|
|
|
description: "Review files directly, no diff",
|
|
|
|
|
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: "Review",
|
|
|
|
|
title: "Select a review preset",
|
|
|
|
|
options,
|
|
|
|
|
current: smartDefault,
|
|
|
|
|
onSelect: (option) => {
|
|
|
|
|
api.ui.dialog.clear()
|
|
|
|
|
switch (option.value) {
|
|
|
|
|
@@ -542,43 +1001,88 @@ const plugin: TuiPlugin = async (api) => {
|
|
|
|
|
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 allBookmarks = await getBookmarks()
|
|
|
|
|
const currentBookmarks = await getCurrentBookmarks()
|
|
|
|
|
const defaultBookmark = await getDefaultBookmark()
|
|
|
|
|
const bookmarks = await getReviewBookmarks()
|
|
|
|
|
const currentBookmarks = await getCurrentReviewBookmarks()
|
|
|
|
|
const defaultBookmark = await getDefaultBookmarkRef()
|
|
|
|
|
|
|
|
|
|
const currentKeys = new Set(
|
|
|
|
|
currentBookmarks.map((b) => `${b.name}@${b.remote ?? ""}`),
|
|
|
|
|
)
|
|
|
|
|
const candidates = allBookmarks.filter(
|
|
|
|
|
(b) => !currentKeys.has(`${b.name}@${b.remote ?? ""}`),
|
|
|
|
|
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: "No other bookmarks found",
|
|
|
|
|
message: currentLabel
|
|
|
|
|
? `No other bookmarks found (current bookmark: ${currentLabel})`
|
|
|
|
|
: "No bookmarks found",
|
|
|
|
|
variant: "error",
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sort: default first, then local before remote
|
|
|
|
|
const defaultKey = defaultBookmark
|
|
|
|
|
? `${defaultBookmark.name}@${defaultBookmark.remote ?? ""}`
|
|
|
|
|
: null
|
|
|
|
|
const sorted = candidates.sort((a, b) => {
|
|
|
|
|
const aKey = `${a.name}@${a.remote ?? ""}`
|
|
|
|
|
const bKey = `${b.name}@${b.remote ?? ""}`
|
|
|
|
|
if (aKey === defaultKey) return -1
|
|
|
|
|
if (bKey === defaultKey) return 1
|
|
|
|
|
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))
|
|
|
|
|
})
|
|
|
|
|
@@ -588,10 +1092,10 @@ const plugin: TuiPlugin = async (api) => {
|
|
|
|
|
title: bookmarkLabel(b),
|
|
|
|
|
value: b,
|
|
|
|
|
description:
|
|
|
|
|
`${b.name}@${b.remote ?? ""}` === defaultKey
|
|
|
|
|
defaultBookmark && bookmarkRefsEqual(b, defaultBookmark)
|
|
|
|
|
? "(default)"
|
|
|
|
|
: b.remote
|
|
|
|
|
? `remote: ${b.remote}`
|
|
|
|
|
? `(remote ${b.remote})`
|
|
|
|
|
: undefined,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
@@ -599,7 +1103,7 @@ const plugin: TuiPlugin = async (api) => {
|
|
|
|
|
api.ui.dialog.replace(
|
|
|
|
|
() =>
|
|
|
|
|
api.ui.DialogSelect({
|
|
|
|
|
title: "Base bookmark",
|
|
|
|
|
title: "Select base bookmark",
|
|
|
|
|
placeholder: "Filter bookmarks...",
|
|
|
|
|
options,
|
|
|
|
|
onSelect: (option) => {
|
|
|
|
|
@@ -631,7 +1135,7 @@ const plugin: TuiPlugin = async (api) => {
|
|
|
|
|
api.ui.dialog.replace(
|
|
|
|
|
() =>
|
|
|
|
|
api.ui.DialogSelect({
|
|
|
|
|
title: "Change to review",
|
|
|
|
|
title: "Select change to review",
|
|
|
|
|
placeholder: "Filter changes...",
|
|
|
|
|
options,
|
|
|
|
|
onSelect: (option) => {
|
|
|
|
|
@@ -646,11 +1150,20 @@ const plugin: TuiPlugin = async (api) => {
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function showPrInput(): void {
|
|
|
|
|
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: "PR number or URL",
|
|
|
|
|
title: "Enter PR number or URL",
|
|
|
|
|
placeholder:
|
|
|
|
|
"123 or https://github.com/owner/repo/pull/123",
|
|
|
|
|
onConfirm: (value) => {
|
|
|
|
|
@@ -672,7 +1185,12 @@ const plugin: TuiPlugin = async (api) => {
|
|
|
|
|
|
|
|
|
|
async function handlePrReview(prNumber: number): Promise<void> {
|
|
|
|
|
api.ui.toast({
|
|
|
|
|
message: `Materializing PR #${prNumber}...`,
|
|
|
|
|
message: `Fetching PR #${prNumber} info...`,
|
|
|
|
|
variant: "info",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
api.ui.toast({
|
|
|
|
|
message: `Materializing PR #${prNumber} with jj...`,
|
|
|
|
|
variant: "info",
|
|
|
|
|
duration: 10000,
|
|
|
|
|
})
|
|
|
|
|
@@ -684,8 +1202,8 @@ const plugin: TuiPlugin = async (api) => {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
api.ui.toast({
|
|
|
|
|
message: `PR #${prNumber} materialized: ${result.title}`,
|
|
|
|
|
variant: "success",
|
|
|
|
|
message: `Materialized PR #${prNumber} (${result.headBookmark}@${result.remote})`,
|
|
|
|
|
variant: "info",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await startReview({
|
|
|
|
|
@@ -701,13 +1219,13 @@ const plugin: TuiPlugin = async (api) => {
|
|
|
|
|
api.ui.dialog.replace(
|
|
|
|
|
() =>
|
|
|
|
|
api.ui.DialogPrompt({
|
|
|
|
|
title: "Paths to review",
|
|
|
|
|
placeholder: "src docs lib/utils.ts",
|
|
|
|
|
title: "Enter folders/files to review",
|
|
|
|
|
placeholder: ".",
|
|
|
|
|
onConfirm: (value) => {
|
|
|
|
|
const paths = value
|
|
|
|
|
.split(/\s+/)
|
|
|
|
|
.map((p) => p.trim())
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.filter((p) => p.length > 0)
|
|
|
|
|
if (paths.length === 0) {
|
|
|
|
|
api.ui.toast({
|
|
|
|
|
message: "No paths provided",
|
|
|
|
|
@@ -732,12 +1250,12 @@ const plugin: TuiPlugin = async (api) => {
|
|
|
|
|
inJjRepo
|
|
|
|
|
? [
|
|
|
|
|
{
|
|
|
|
|
title: "Review code changes (jj)",
|
|
|
|
|
value: "jj-review",
|
|
|
|
|
title: "Review code changes",
|
|
|
|
|
value: "review",
|
|
|
|
|
description:
|
|
|
|
|
"Working-copy, bookmark, change, PR, or folder",
|
|
|
|
|
slash: { name: "jj-review" },
|
|
|
|
|
onSelect: () => showReviewSelector(),
|
|
|
|
|
"Review code changes (PR, working copy, bookmark, change, or folder)",
|
|
|
|
|
slash: { name: "review", aliases: ["jj-review"] },
|
|
|
|
|
onSelect: () => void showReviewSelector(),
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
: [],
|
|
|
|
|
@@ -745,6 +1263,6 @@ const plugin: TuiPlugin = async (api) => {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default {
|
|
|
|
|
id: "jj-review",
|
|
|
|
|
id: "review",
|
|
|
|
|
tui: plugin,
|
|
|
|
|
} satisfies TuiPluginModule
|
|
|
|
|
|