238 lines
6.7 KiB
TypeScript
238 lines
6.7 KiB
TypeScript
import { type Plugin, tool } from "@opencode-ai/plugin"
|
|
|
|
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 {
|
|
const sanitized = value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "")
|
|
return sanitized || "gh-pr"
|
|
}
|
|
|
|
export const ReviewPlugin: Plugin = async ({ $ }) => {
|
|
return {
|
|
tool: {
|
|
review_materialize_pr: tool({
|
|
description:
|
|
"Materialize a GitHub pull request locally using jj for code review. " +
|
|
"Fetches the PR branch, creates a new jj change on top of it, and returns " +
|
|
"metadata needed for the review. Handles cross-repository (forked) PRs. " +
|
|
"Call this before reviewing a PR to set up the local state.",
|
|
args: {
|
|
prNumber: tool.schema
|
|
.number()
|
|
.describe("The PR number to materialize (e.g. 123)"),
|
|
},
|
|
async execute(args, context) {
|
|
const prNumber = args.prNumber
|
|
|
|
// Check for pending working-copy changes
|
|
const statusResult =
|
|
await $`jj diff --summary 2>/dev/null`.nothrow().quiet()
|
|
if (
|
|
statusResult.exitCode === 0 &&
|
|
statusResult.stdout.toString().trim().length > 0
|
|
) {
|
|
return JSON.stringify({
|
|
success: false,
|
|
error:
|
|
"Cannot materialize PR: you have local jj changes. Please snapshot or discard them first.",
|
|
})
|
|
}
|
|
|
|
// Save current position for later restoration
|
|
const currentChangeResult =
|
|
await $`jj log -r @ --no-graph -T 'change_id.shortest(8)'`
|
|
.nothrow()
|
|
.quiet()
|
|
const savedChangeId = currentChangeResult.stdout.toString().trim()
|
|
|
|
// Get PR info from GitHub CLI
|
|
const prInfoResult =
|
|
await $`gh pr view ${prNumber} --json baseRefName,title,headRefName,isCrossRepository,headRepository,headRepositoryOwner`
|
|
.nothrow()
|
|
.quiet()
|
|
if (prInfoResult.exitCode !== 0) {
|
|
return JSON.stringify({
|
|
success: false,
|
|
error: `Could not find PR #${prNumber}. Make sure gh is authenticated and 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(prInfoResult.stdout.toString())
|
|
} catch {
|
|
return JSON.stringify({
|
|
success: false,
|
|
error: "Failed to parse PR info from gh CLI",
|
|
})
|
|
}
|
|
|
|
// Determine the remote to use
|
|
const remotesResult =
|
|
await $`jj git remote list`.nothrow().quiet()
|
|
const remotes = remotesResult.stdout
|
|
.toString()
|
|
.trim()
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map((line: string) => {
|
|
const [name, ...urlParts] = line.split(/\s+/)
|
|
return { name, url: urlParts.join(" ") }
|
|
})
|
|
.filter(
|
|
(r: { name: string; url: string }) => r.name && r.url,
|
|
)
|
|
|
|
const defaultRemote =
|
|
remotes.find(
|
|
(r: { name: string; url: string }) =>
|
|
r.name === "origin",
|
|
) ?? remotes[0]
|
|
if (!defaultRemote) {
|
|
return JSON.stringify({
|
|
success: false,
|
|
error: "No jj remotes are configured for this repository",
|
|
})
|
|
}
|
|
|
|
let remoteName = defaultRemote.name
|
|
let addedTemporaryRemote = 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
|
|
|
|
// Check if we already have a remote for this fork
|
|
const existingRemote = remotes.find(
|
|
(r: { name: string; url: string }) => {
|
|
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 remoteBaseName = sanitizeRemoteName(
|
|
`gh-pr-${prInfo.headRepositoryOwner?.login ?? "remote"}-${prInfo.headRepository?.name ?? prNumber}`,
|
|
)
|
|
const existingNames = new Set(
|
|
remotes.map(
|
|
(r: { name: string; url: string }) =>
|
|
r.name,
|
|
),
|
|
)
|
|
remoteName = remoteBaseName
|
|
let suffix = 2
|
|
while (existingNames.has(remoteName)) {
|
|
remoteName = `${remoteBaseName}-${suffix}`
|
|
suffix += 1
|
|
}
|
|
const addResult =
|
|
await $`jj git remote add ${remoteName} ${forkUrl}`
|
|
.nothrow()
|
|
.quiet()
|
|
if (addResult.exitCode !== 0) {
|
|
return JSON.stringify({
|
|
success: false,
|
|
error:
|
|
addResult.stderr.toString() ||
|
|
"Failed to add PR remote",
|
|
})
|
|
}
|
|
addedTemporaryRemote = true
|
|
} else {
|
|
return JSON.stringify({
|
|
success: false,
|
|
error: "PR head repository URL is unavailable",
|
|
})
|
|
}
|
|
}
|
|
|
|
// Fetch the PR branch
|
|
const fetchResult =
|
|
await $`jj git fetch --remote ${remoteName} --branch ${prInfo.headRefName}`
|
|
.nothrow()
|
|
.quiet()
|
|
if (fetchResult.exitCode !== 0) {
|
|
if (addedTemporaryRemote) {
|
|
await $`jj git remote remove ${remoteName}`
|
|
.nothrow()
|
|
.quiet()
|
|
}
|
|
return JSON.stringify({
|
|
success: false,
|
|
error:
|
|
fetchResult.stderr.toString() ||
|
|
"Failed to fetch PR branch",
|
|
})
|
|
}
|
|
|
|
// Create a new change on top of the PR branch
|
|
const bookmarkRevset = `remote_bookmarks(exact:"${prInfo.headRefName}", exact:"${remoteName}")`
|
|
const editResult =
|
|
await $`jj new ${bookmarkRevset}`.nothrow().quiet()
|
|
if (editResult.exitCode !== 0) {
|
|
if (addedTemporaryRemote) {
|
|
await $`jj git remote remove ${remoteName}`
|
|
.nothrow()
|
|
.quiet()
|
|
}
|
|
return JSON.stringify({
|
|
success: false,
|
|
error:
|
|
editResult.stderr.toString() ||
|
|
"Failed to create change on PR branch",
|
|
})
|
|
}
|
|
|
|
// Clean up temporary remote
|
|
if (addedTemporaryRemote) {
|
|
await $`jj git remote remove ${remoteName}`
|
|
.nothrow()
|
|
.quiet()
|
|
}
|
|
|
|
return JSON.stringify({
|
|
success: true,
|
|
prNumber,
|
|
title: prInfo.title,
|
|
baseBookmark: prInfo.baseRefName,
|
|
headBookmark: prInfo.headRefName,
|
|
remote: remoteName,
|
|
savedChangeId,
|
|
})
|
|
},
|
|
}),
|
|
},
|
|
}
|
|
}
|