This commit is contained in:
2026-04-02 10:26:33 +00:00
parent c907354a4f
commit 877a6f1ff4

View File

@@ -8,6 +8,7 @@ import path from "node:path"
type BookmarkRef = { name: string; remote?: string } type BookmarkRef = { name: string; remote?: string }
type Change = { changeId: string; title: string } type Change = { changeId: string; title: string }
type JjRemote = { name: string; url: string }
type ReviewTarget = type ReviewTarget =
| { type: "workingCopy" } | { type: "workingCopy" }
@@ -205,19 +206,30 @@ function parseChanges(stdout: string): Change[] {
function parsePrRef(ref: string): number | null { function parsePrRef(ref: string): number | null {
const trimmed = ref.trim() const trimmed = ref.trim()
const num = parseInt(trimmed, 10) if (/^\d+$/.test(trimmed)) {
if (!isNaN(num) && num > 0) return num const num = Number(trimmed)
const urlMatch = trimmed.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/) return Number.isSafeInteger(num) && num > 0 ? num : null
if (urlMatch) return parseInt(urlMatch[1], 10) }
const urlMatch = trimmed.match(
/^(?:https?:\/\/)?github\.com\/[^/]+\/[^/]+\/pull\/(\d+)(?:[/?#].*)?$/i,
)
if (urlMatch) {
const num = Number(urlMatch[1])
return Number.isSafeInteger(num) && num > 0 ? num : null
}
return null return null
} }
function normalizeRemoteUrl(value: string): string { function normalizeRemoteUrl(value: string): string {
return value return value
.trim() .trim()
.replace(/^git@github\.com:/, "https://github.com/") .replace(/^git@([^:]+):/, "https://$1/")
.replace(/^ssh:\/\/git@github\.com\//, "https://github.com/") .replace(/^ssh:\/\/git@([^/]+)\//, "https://$1/")
.replace(/^(https?:\/\/)[^/@]+@/i, "$1")
.replace(/\.git$/, "") .replace(/\.git$/, "")
.replace(/\/+$/, "")
.toLowerCase() .toLowerCase()
} }
@@ -228,6 +240,12 @@ function sanitizeRemoteName(value: string): string {
) )
} }
function getRepositoryUrl(value: string): string | null {
const match = normalizeRemoteUrl(value).match(/^https?:\/\/[^/]+\/[^/]+\/[^/]+/)
if (!match) return null
return match[0]
}
const plugin: TuiPlugin = async (api) => { const plugin: TuiPlugin = async (api) => {
const cwd = api.state.path.directory const cwd = api.state.path.directory
let reviewCustomInstructions = normalizeCustomInstructions( let reviewCustomInstructions = normalizeCustomInstructions(
@@ -239,8 +257,6 @@ const plugin: TuiPlugin = async (api) => {
api.kv.set(CUSTOM_INSTRUCTIONS_KEY, reviewCustomInstructions) api.kv.set(CUSTOM_INSTRUCTIONS_KEY, reviewCustomInstructions)
} }
// -- shell helpers -------------------------------------------------------
async function exec( async function exec(
cmd: string, cmd: string,
args: string[], args: string[],
@@ -276,8 +292,6 @@ const plugin: TuiPlugin = async (api) => {
return { stdout: r.stdout, ok: r.exitCode === 0, stderr: r.stderr } return { stdout: r.stdout, ok: r.exitCode === 0, stderr: r.stderr }
} }
// -- jj helpers ----------------------------------------------------------
async function isJjRepo(): Promise<boolean> { async function isJjRepo(): Promise<boolean> {
return (await jj("root")).ok return (await jj("root")).ok
} }
@@ -287,56 +301,6 @@ const plugin: TuiPlugin = async (api) => {
return r.ok && r.stdout.trim().length > 0 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[]> { async function getRecentChanges(limit = 20): Promise<Change[]> {
const r = await jj( const r = await jj(
"log", "log",
@@ -372,8 +336,6 @@ const plugin: TuiPlugin = async (api) => {
return lines.length === 1 ? lines[0].trim() : null return lines.length === 1 ? lines[0].trim() : null
} }
// -- PR materialization --------------------------------------------------
async function materializePr(prNumber: number): Promise< async function materializePr(prNumber: number): Promise<
| { | {
ok: true ok: true
@@ -393,24 +355,19 @@ const plugin: TuiPlugin = async (api) => {
} }
} }
const savedR = await jj( const savedChangeId = await getSingleChangeId("@")
"log", if (!savedChangeId) {
"-r", return { ok: false, error: "Failed to determine the current change" }
"@", }
"--no-graph",
"-T",
"change_id.shortest(8)",
)
const savedChangeId = savedR.stdout.trim()
const prR = await gh( const prResponse = await gh(
"pr", "pr",
"view", "view",
String(prNumber), String(prNumber),
"--json", "--json",
"baseRefName,title,headRefName,isCrossRepository,headRepository,headRepositoryOwner", "baseRefName,title,headRefName,isCrossRepository,headRepository,headRepositoryOwner,url",
) )
if (!prR.ok) { if (!prResponse.ok) {
return { return {
ok: false, ok: false,
error: `Could not find PR #${prNumber}. Check gh auth and that the PR exists.`, error: `Could not find PR #${prNumber}. Check gh auth and that the PR exists.`,
@@ -422,54 +379,36 @@ const plugin: TuiPlugin = async (api) => {
title: string title: string
headRefName: string headRefName: string
isCrossRepository: boolean isCrossRepository: boolean
url: string
headRepository?: { name: string; url: string } headRepository?: { name: string; url: string }
headRepositoryOwner?: { login: string } headRepositoryOwner?: { login: string }
} }
try { try {
prInfo = JSON.parse(prR.stdout) prInfo = JSON.parse(prResponse.stdout)
} catch { } catch {
return { ok: false, error: "Failed to parse PR info" } return { ok: false, error: "Failed to parse PR info" }
} }
const remotesR = await jj("git", "remote", "list") const remotes = await getJjRemotes()
const remotes = remotesR.stdout const defaultRemote = getDefaultRemote(remotes)
.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) { if (!defaultRemote) {
return { ok: false, error: "No jj remotes configured" } return { ok: false, error: "No jj remotes configured" }
} }
const baseRepoUrl = getRepositoryUrl(prInfo.url)
const baseRemote = baseRepoUrl
? remotes.find((remote) => getRepositoryUrl(remote.url) === baseRepoUrl)
: undefined
let remoteName = defaultRemote.name let remoteName = defaultRemote.name
let addedTempRemote = false let addedTempRemote = false
if (prInfo.isCrossRepository) { 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 forkUrl = prInfo.headRepository?.url
const forkRepoUrl = forkUrl ? getRepositoryUrl(forkUrl) : null
const existingRemote = remotes.find((r) => { const existingRemote = forkRepoUrl
if ( ? remotes.find((remote) => getRepositoryUrl(remote.url) === forkRepoUrl)
forkUrl && : undefined
normalizeRemoteUrl(r.url) === normalizeRemoteUrl(forkUrl)
)
return true
return repoSlug
? normalizeRemoteUrl(r.url).includes(
`github.com/${repoSlug}`,
)
: false
})
if (existingRemote) { if (existingRemote) {
remoteName = existingRemote.name remoteName = existingRemote.name
@@ -483,21 +422,23 @@ const plugin: TuiPlugin = async (api) => {
while (names.has(remoteName)) { while (names.has(remoteName)) {
remoteName = `${baseName}-${suffix++}` remoteName = `${baseName}-${suffix++}`
} }
const addR = await jj( const addRemoteResult = await jj(
"git", "git",
"remote", "remote",
"add", "add",
remoteName, remoteName,
forkUrl, forkUrl,
) )
if (!addR.ok) return { ok: false, error: "Failed to add PR remote" } if (!addRemoteResult.ok) {
return { ok: false, error: "Failed to add PR remote" }
}
addedTempRemote = true addedTempRemote = true
} else { } else {
return { ok: false, error: "PR fork URL is unavailable" } return { ok: false, error: "PR fork URL is unavailable" }
} }
} }
const fetchR = await jj( const fetchHeadResult = await jj(
"git", "git",
"fetch", "fetch",
"--remote", "--remote",
@@ -505,15 +446,15 @@ const plugin: TuiPlugin = async (api) => {
"--branch", "--branch",
prInfo.headRefName, prInfo.headRefName,
) )
if (!fetchR.ok) { if (!fetchHeadResult.ok) {
if (addedTempRemote) if (addedTempRemote)
await jj("git", "remote", "remove", remoteName) await jj("git", "remote", "remove", remoteName)
return { ok: false, error: "Failed to fetch PR branch" } return { ok: false, error: "Failed to fetch PR branch" }
} }
const revset = `remote_bookmarks(exact:${JSON.stringify(prInfo.headRefName)}, exact:${JSON.stringify(remoteName)})` const revset = `remote_bookmarks(exact:${JSON.stringify(prInfo.headRefName)}, exact:${JSON.stringify(remoteName)})`
const newR = await jj("new", revset) const createChangeResult = await jj("new", revset)
if (!newR.ok) { if (!createChangeResult.ok) {
if (addedTempRemote) if (addedTempRemote)
await jj("git", "remote", "remove", remoteName) await jj("git", "remote", "remove", remoteName)
return { ok: false, error: "Failed to create change on PR branch" } return { ok: false, error: "Failed to create change on PR branch" }
@@ -521,14 +462,11 @@ const plugin: TuiPlugin = async (api) => {
if (addedTempRemote) await jj("git", "remote", "remove", remoteName) if (addedTempRemote) await jj("git", "remote", "remove", remoteName)
// Resolve base bookmark remote
const baseRef = await resolveBookmarkRef(prInfo.baseRefName)
return { return {
ok: true, ok: true,
title: prInfo.title, title: prInfo.title,
baseBookmark: prInfo.baseRefName, baseBookmark: prInfo.baseRefName,
baseRemote: baseRef?.remote, baseRemote: baseRemote?.name,
headBookmark: prInfo.headRefName, headBookmark: prInfo.headRefName,
remote: remoteName, remote: remoteName,
savedChangeId, savedChangeId,
@@ -554,19 +492,6 @@ const plugin: TuiPlugin = async (api) => {
return left.name === right.name && left.remote === right.remote 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[] { function dedupeBookmarkRefs(bookmarks: BookmarkRef[]): BookmarkRef[] {
const seen = new Set<string>() const seen = new Set<string>()
const result: BookmarkRef[] = [] const result: BookmarkRef[] = []
@@ -625,7 +550,7 @@ const plugin: TuiPlugin = async (api) => {
return revisions.length === 1 ? revisions[0] : null return revisions.length === 1 ? revisions[0] : null
} }
async function getJjRemotes(): Promise<Array<{ name: string; url: string }>> { async function getJjRemotes(): Promise<JjRemote[]> {
const r = await jj("git", "remote", "list") const r = await jj("git", "remote", "list")
if (!r.ok) return [] if (!r.ok) return []
@@ -637,10 +562,16 @@ const plugin: TuiPlugin = async (api) => {
.filter((remote) => remote.name && remote.url) .filter((remote) => remote.name && remote.url)
} }
function getDefaultRemote(remotes: JjRemote[]): JjRemote | null {
return (
remotes.find((remote) => remote.name === "origin") ??
remotes[0] ??
null
)
}
async function getDefaultRemoteName(): Promise<string | null> { async function getDefaultRemoteName(): Promise<string | null> {
const remotes = await getJjRemotes() return getDefaultRemote(await getJjRemotes())?.name ?? null
if (remotes.length === 0) return null
return remotes.find((remote) => remote.name === "origin")?.name ?? remotes[0].name
} }
function preferBookmarkRef( function preferBookmarkRef(
@@ -759,8 +690,6 @@ const plugin: TuiPlugin = async (api) => {
} }
} }
// -- prompt building -----------------------------------------------------
async function buildTargetReviewPrompt( async function buildTargetReviewPrompt(
target: ReviewTarget, target: ReviewTarget,
options?: { includeLocalChanges?: boolean }, options?: { includeLocalChanges?: boolean },
@@ -841,7 +770,10 @@ const plugin: TuiPlugin = async (api) => {
} }
async function buildReviewPrompt(target: ReviewTarget): Promise<string> { async function buildReviewPrompt(target: ReviewTarget): Promise<string> {
const prompt = await buildTargetReviewPrompt(target) const prompt = await buildTargetReviewPrompt(target, {
includeLocalChanges:
target.type !== "workingCopy" && (await hasWorkingCopyChanges()),
})
const projectGuidelines = await loadProjectReviewGuidelines() const projectGuidelines = await loadProjectReviewGuidelines()
const sharedInstructions = normalizeCustomInstructions( const sharedInstructions = normalizeCustomInstructions(
reviewCustomInstructions, reviewCustomInstructions,
@@ -910,17 +842,13 @@ const plugin: TuiPlugin = async (api) => {
} }
} }
// -- review execution ---------------------------------------------------- async function startReview(target: ReviewTarget): Promise<boolean> {
async function startReview(target: ReviewTarget): Promise<void> {
const prompt = await buildReviewPrompt(target) const prompt = await buildReviewPrompt(target)
const hint = getUserFacingHint(target) const hint = getUserFacingHint(target)
const cleared = await api.client.tui.clearPrompt() const cleared = await api.client.tui.clearPrompt()
const appended = await api.client.tui.appendPrompt({ const appended = await api.client.tui.appendPrompt({
text: prompt, 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) await sleep(50)
const submitted = await api.client.tui.submitPrompt() const submitted = await api.client.tui.submitPrompt()
@@ -929,16 +857,16 @@ const plugin: TuiPlugin = async (api) => {
message: "Failed to start review prompt automatically", message: "Failed to start review prompt automatically",
variant: "error", variant: "error",
}) })
return return false
} }
api.ui.toast({ api.ui.toast({
message: `Starting review: ${hint}`, message: `Starting review: ${hint}`,
variant: "info", variant: "info",
}) })
}
// -- dialogs ------------------------------------------------------------- return true
}
async function showReviewSelector(): Promise<void> { async function showReviewSelector(): Promise<void> {
const smartDefault = await getSmartDefault() const smartDefault = await getSmartDefault()
@@ -1206,13 +1134,22 @@ const plugin: TuiPlugin = async (api) => {
variant: "info", variant: "info",
}) })
await startReview({ const started = await startReview({
type: "pullRequest", type: "pullRequest",
prNumber, prNumber,
baseBookmark: result.baseBookmark, baseBookmark: result.baseBookmark,
baseRemote: result.baseRemote, baseRemote: result.baseRemote,
title: result.title, title: result.title,
}) })
if (started) return
const restored = await jj("edit", result.savedChangeId)
api.ui.toast({
message: restored.ok
? "Restored the previous change after the review prompt failed"
: `Review prompt failed and restoring the previous change also failed (${result.savedChangeId})`,
variant: restored.ok ? "info" : "error",
})
} }
function showFolderInput(): void { function showFolderInput(): void {
@@ -1240,12 +1177,8 @@ const plugin: TuiPlugin = async (api) => {
) )
} }
// -- jj repo check -------------------------------------------------------
const inJjRepo = await isJjRepo() const inJjRepo = await isJjRepo()
// -- command registration ------------------------------------------------
api.command.register(() => api.command.register(() =>
inJjRepo inJjRepo
? [ ? [