Compare commits

...

2 Commits

Author SHA1 Message Date
89c430b940 review more 2026-04-02 12:57:50 +00:00
1dd7d8a2d8 fix review 2026-04-02 12:57:50 +00:00
3 changed files with 265 additions and 38 deletions

View File

@@ -15,6 +15,16 @@ import path from "node:path"
type BookmarkRef = { name: string; remote?: string }
type Change = { changeId: string; title: string }
type JjRemote = { name: string; url: string }
type PullRequestListItem = {
prNumber: number
title: string
updatedAt: string
reviewRequested: boolean
author?: string
baseRefName?: string
headRefName?: string
isManualEntry?: boolean
}
type ReviewTarget =
| { type: "workingCopy" }
@@ -32,6 +42,9 @@ type ReviewTarget =
type ReviewSelectorValue = ReviewTarget["type"] | "toggleCustomInstructions"
const CUSTOM_INSTRUCTIONS_KEY = "review.customInstructions"
const MIN_CHANGE_REVIEW_OPTIONS = 10
const RECENT_PULL_REQUEST_LIMIT = 5
const PULL_REQUEST_MAX_AGE_DAYS = 7
const WORKING_COPY_PROMPT =
"Review the current working-copy changes (including new files) and provide prioritized findings."
@@ -163,6 +176,13 @@ Provide your findings in a clear, structured format:
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 normalizeCustomInstructions(
value: string | undefined,
): string | undefined {
const normalized = value?.trim()
return normalized ? normalized : undefined
}
function bookmarkLabel(b: BookmarkRef): string {
return b.remote ? `${b.name}@${b.remote}` : b.name
}
@@ -228,6 +248,115 @@ function parsePrRef(ref: string): number | null {
return null
}
function formatRelativeTime(value: string): string | null {
const timestamp = Date.parse(value)
if (!Number.isFinite(timestamp)) return null
const deltaMs = Date.now() - timestamp
const future = deltaMs < 0
const absoluteSeconds = Math.round(Math.abs(deltaMs) / 1000)
if (absoluteSeconds < 60) return future ? "in <1m" : "just now"
const units = [
{ label: "y", seconds: 60 * 60 * 24 * 365 },
{ label: "mo", seconds: 60 * 60 * 24 * 30 },
{ label: "d", seconds: 60 * 60 * 24 },
{ label: "h", seconds: 60 * 60 },
{ label: "m", seconds: 60 },
]
for (const unit of units) {
if (absoluteSeconds >= unit.seconds) {
const count = Math.floor(absoluteSeconds / unit.seconds)
return future ? `in ${count}${unit.label}` : `${count}${unit.label} ago`
}
}
return future ? "soon" : "just now"
}
function getPullRequestUpdatedSinceDate(days: number): string {
const timestamp = Date.now() - days * 24 * 60 * 60 * 1000
return new Date(timestamp).toISOString().slice(0, 10)
}
function parsePullRequests(stdout: string): PullRequestListItem[] {
let parsed: unknown
try {
parsed = JSON.parse(stdout)
} catch {
return []
}
if (!Array.isArray(parsed)) return []
return parsed.flatMap((entry) => {
if (!entry || typeof entry !== "object") return []
const prNumber =
typeof entry.number === "number" && Number.isSafeInteger(entry.number)
? entry.number
: null
const title = typeof entry.title === "string" ? entry.title.trim() : ""
const updatedAt =
typeof entry.updatedAt === "string" ? entry.updatedAt.trim() : ""
if (!prNumber || !title || !updatedAt) return []
return [
{
prNumber,
title,
updatedAt,
reviewRequested: entry.reviewRequested === true,
author:
entry.author &&
typeof entry.author === "object" &&
typeof entry.author.login === "string"
? entry.author.login
: undefined,
baseRefName:
typeof entry.baseRefName === "string"
? entry.baseRefName.trim() || undefined
: undefined,
headRefName:
typeof entry.headRefName === "string"
? entry.headRefName.trim() || undefined
: undefined,
},
]
})
}
function dedupePullRequests(
pullRequests: PullRequestListItem[],
): PullRequestListItem[] {
const seen = new Set<number>()
const result: PullRequestListItem[] = []
for (const pullRequest of pullRequests) {
if (seen.has(pullRequest.prNumber)) continue
seen.add(pullRequest.prNumber)
result.push(pullRequest)
}
return result
}
function buildPullRequestOptionDescription(pr: PullRequestListItem): string {
if (pr.isManualEntry) return "(enter PR number or URL)"
const parts = [pr.reviewRequested ? "review requested" : "recent"]
const relativeTime = formatRelativeTime(pr.updatedAt)
if (relativeTime) parts.push(`updated ${relativeTime}`)
if (pr.author) parts.push(`@${pr.author}`)
if (pr.baseRefName && pr.headRefName) {
parts.push(`${pr.headRefName}${pr.baseRefName}`)
}
return `(${parts.join(" · ")})`
}
function normalizeRemoteUrl(value: string): string {
return value
.trim()
@@ -254,14 +383,6 @@ function getRepositoryUrl(value: string): string | null {
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)
}
async function exec(
cmd: string,
@@ -302,16 +423,29 @@ const plugin: TuiPlugin = async (api) => {
return (await jj("root")).ok
}
const reviewCustomInstructionsKey = `${CUSTOM_INSTRUCTIONS_KEY}-${cwd}`
let reviewCustomInstructions = normalizeCustomInstructions(
api.kv.get<string | undefined>(reviewCustomInstructionsKey, undefined),
)
function setReviewCustomInstructions(value?: string): void {
reviewCustomInstructions = normalizeCustomInstructions(value)
api.kv.set(reviewCustomInstructionsKey, reviewCustomInstructions)
}
async function hasWorkingCopyChanges(): Promise<boolean> {
const r = await jj("diff", "--summary")
return r.ok && r.stdout.trim().length > 0
}
async function getRecentChanges(limit = 20): Promise<Change[]> {
const effectiveLimit = Math.max(limit, MIN_CHANGE_REVIEW_OPTIONS)
const r = await jj(
"log",
"-r",
"all()",
"-n",
String(limit),
String(effectiveLimit),
"--no-graph",
"-T",
'change_id.shortest(8) ++ "\\t" ++ description.first_line() ++ "\\n"',
@@ -320,6 +454,53 @@ const plugin: TuiPlugin = async (api) => {
return parseChanges(r.stdout)
}
async function getPullRequests(
args: string[],
reviewRequested: boolean,
): Promise<PullRequestListItem[]> {
const response = await gh(
"pr",
"list",
...args,
"--json",
"number,title,updatedAt,author,baseRefName,headRefName",
)
if (!response.ok) return []
return parsePullRequests(response.stdout).map((pr) => ({
...pr,
reviewRequested,
}))
}
async function getSelectablePullRequests(): Promise<PullRequestListItem[]> {
const updatedSince = getPullRequestUpdatedSinceDate(
PULL_REQUEST_MAX_AGE_DAYS,
)
const [reviewRequested, recent] = await Promise.all([
getPullRequests(
[
"--search",
`review-requested:@me updated:>=${updatedSince} sort:updated-desc`,
"--limit",
"50",
],
true,
),
getPullRequests(
[
"--search",
`updated:>=${updatedSince} sort:updated-desc`,
"--limit",
String(RECENT_PULL_REQUEST_LIMIT),
],
false,
),
])
return dedupePullRequests([...reviewRequested, ...recent])
}
async function getMergeBase(
bookmark: string,
remote?: string,
@@ -479,13 +660,6 @@ const plugin: TuiPlugin = async (api) => {
}
}
function normalizeCustomInstructions(
value: string | undefined,
): string | undefined {
const normalized = value?.trim()
return normalized ? normalized : undefined
}
function parseNonEmptyLines(stdout: string): string[] {
return stdout
.trim()
@@ -781,13 +955,10 @@ const plugin: TuiPlugin = async (api) => {
target.type !== "workingCopy" && (await hasWorkingCopyChanges()),
})
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 (reviewCustomInstructions) {
fullPrompt += `\n\nCustom review instructions for this working directory (applies to all review modes here):\n\n${reviewCustomInstructions}`
}
if (projectGuidelines) {
@@ -906,8 +1077,8 @@ const plugin: TuiPlugin = async (api) => {
: "Add custom review instructions",
value: "toggleCustomInstructions",
description: reviewCustomInstructions
? "(currently set)"
: "(applies to all review modes)",
? "(set for this directory)"
: "(this directory, all review modes)",
},
]
@@ -930,7 +1101,7 @@ const plugin: TuiPlugin = async (api) => {
void showChangeSelector()
break
case "pullRequest":
void showPrInput()
void showPrSelector()
break
case "folder":
showFolderInput()
@@ -939,7 +1110,8 @@ const plugin: TuiPlugin = async (api) => {
if (reviewCustomInstructions) {
setReviewCustomInstructions(undefined)
api.ui.toast({
message: "Custom review instructions removed",
message:
"Custom review instructions removed for this directory",
variant: "info",
})
void showReviewSelector()
@@ -974,7 +1146,8 @@ const plugin: TuiPlugin = async (api) => {
setReviewCustomInstructions(next)
api.ui.toast({
message: "Custom review instructions saved",
message:
"Custom review instructions saved for this directory",
variant: "success",
})
void showReviewSelector()
@@ -1084,16 +1257,7 @@ const plugin: TuiPlugin = async (api) => {
)
}
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
}
function showPrManualInput(): void {
api.ui.dialog.replace(
() =>
api.ui.DialogPrompt({
@@ -1113,6 +1277,69 @@ const plugin: TuiPlugin = async (api) => {
api.ui.dialog.clear()
void handlePrReview(prNumber)
},
onCancel: () => {
api.ui.dialog.clear()
void showPrSelector()
},
}),
)
}
async function showPrSelector(): 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.toast({ message: "Loading pull requests...", variant: "info" })
const pullRequests = await getSelectablePullRequests()
const options: TuiDialogSelectOption<PullRequestListItem>[] = [
{
title: "Enter a PR number or URL",
value: {
prNumber: -1,
title: "Manual entry",
updatedAt: "",
reviewRequested: false,
isManualEntry: true,
},
description: "(override the list)",
},
...pullRequests.map((pr) => ({
title: `#${pr.prNumber} ${pr.title}`,
value: pr,
description: buildPullRequestOptionDescription(pr),
})),
]
if (pullRequests.length === 0) {
api.ui.toast({
message:
"No pull requests found from GitHub; you can still enter a PR number or URL.",
variant: "info",
})
}
api.ui.dialog.replace(
() =>
api.ui.DialogSelect({
title: "Select pull request to review",
placeholder: "Filter pull requests...",
options,
onSelect: (option) => {
api.ui.dialog.clear()
if (option.value.isManualEntry) {
showPrManualInput()
return
}
void handlePrReview(option.value.prNumber)
},
}),
)
}

View File

@@ -26,7 +26,7 @@ in {
package = inputs'.llm-agents.packages.opencode;
settings = {
model = "openai/gpt-5.4";
small_model = "openai/gpt-5.1-mini";
small_model = "openai/gpt-5.1-codex-mini";
theme = "rosepine";
plugin = [
"opencode-claude-auth"
@@ -125,7 +125,7 @@ in {
disable = true;
};
explore = {
model = "openai/gpt-5.1-mini";
model = "openai/gpt-5.1-codex-mini";
};
};
instructions = [