Files
orca/src/main/text-generation/pull-request-context.ts
T

260 lines
7.0 KiB
TypeScript

import type { PullRequestDraftContext } from '../../shared/pull-request-generation'
const MAX_PULL_REQUEST_CONTEXT_BYTES = 10 * 1024 * 1024
type GitExec = (
args: string[],
options?: { maxBuffer?: number }
) => Promise<{ stdout: string; stderr?: string }>
export type PullRequestContextInput = {
base: string
currentTitle: string
currentBody: string
currentDraft: boolean
}
async function safeExec(execGit: GitExec, args: string[]): Promise<string> {
try {
const { stdout } = await execGit(args, { maxBuffer: MAX_PULL_REQUEST_CONTEXT_BYTES })
return stdout.trim()
} catch {
return ''
}
}
function summarizeGitError(error: unknown): string {
if (!(error instanceof Error)) {
return 'Git command failed.'
}
for (const rawLine of iterateGitOutputLinesFromEnd(error.message)) {
const line = rawLine.trim()
if (line.length > 0) {
return line
}
}
return error.message
}
async function requiredExec(execGit: GitExec, args: string[], label: string): Promise<string> {
try {
const { stdout } = await execGit(args, { maxBuffer: MAX_PULL_REQUEST_CONTEXT_BYTES })
return stdout.trim()
} catch (error) {
throw new Error(`${label}: ${summarizeGitError(error)}`)
}
}
type RemoteState = {
remotes: string[]
refs: string[]
}
type RemoteBranch = {
remote: string
branch: string
ref: string
}
function splitGitLines(output: string): string[] {
const lines: string[] = []
for (const rawLine of iterateGitOutputLines(output)) {
const line = rawLine.trim()
if (line.length > 0) {
lines.push(line)
}
}
return lines
}
function* iterateGitOutputLines(output: string): Generator<string> {
let lineStart = 0
for (let index = 0; index < output.length; index++) {
const code = output.charCodeAt(index)
if (code !== 10 && code !== 13) {
continue
}
yield output.slice(lineStart, index)
if (code === 13 && output.charCodeAt(index + 1) === 10) {
index++
}
lineStart = index + 1
}
if (lineStart <= output.length) {
yield output.slice(lineStart)
}
}
function* iterateGitOutputLinesFromEnd(output: string): Generator<string> {
let lineEnd = output.length
let index = output.length - 1
while (index >= 0) {
const code = output.charCodeAt(index)
if (code !== 10 && code !== 13) {
index--
continue
}
const delimiterStart =
code === 10 && index > 0 && output.charCodeAt(index - 1) === 13 ? index - 1 : index
yield output.slice(index + 1, lineEnd)
lineEnd = delimiterStart
index = delimiterStart - 1
}
yield output.slice(0, lineEnd)
}
async function getRemoteState(execGit: GitExec): Promise<RemoteState> {
const [remoteOutput, refOutput] = await Promise.all([
safeExec(execGit, ['remote']),
safeExec(execGit, ['for-each-ref', '--format=%(refname:short)', 'refs/remotes'])
])
return {
remotes: splitGitLines(remoteOutput),
refs: splitGitLines(refOutput).filter((line) => !line.endsWith('/HEAD'))
}
}
function parseRemoteBranch(ref: string, remotes: string[]): RemoteBranch | null {
const remote = [...remotes]
.sort((a, b) => b.length - a.length)
.find((candidate) => ref.startsWith(`${candidate}/`))
if (!remote) {
return null
}
const branch = ref.slice(remote.length + 1)
return branch ? { remote, branch, ref } : null
}
function parseRemoteRef(ref: string, remotes: string[]): RemoteBranch | null {
const parsed = parseRemoteBranch(ref, remotes)
if (parsed) {
return parsed
}
const slashIndex = ref.indexOf('/')
if (slashIndex <= 0 || slashIndex === ref.length - 1) {
return null
}
return {
remote: ref.slice(0, slashIndex),
branch: ref.slice(slashIndex + 1),
ref
}
}
function resolveComparisonBase(
base: string,
state: RemoteState
): {
comparisonBase: string
fetchTarget: RemoteBranch | null
} {
const qualifiedBase = parseRemoteBranch(base, state.remotes)
if (qualifiedBase) {
return { comparisonBase: qualifiedBase.ref, fetchTarget: qualifiedBase }
}
if (state.refs.includes(base)) {
return { comparisonBase: base, fetchTarget: parseRemoteRef(base, state.remotes) }
}
const preferredRemoteRefs = [`origin/${base}`, `upstream/${base}`]
for (const ref of preferredRemoteRefs) {
const parsed = parseRemoteRef(ref, state.remotes)
if (parsed && (state.refs.includes(ref) || state.remotes.includes(parsed.remote))) {
return { comparisonBase: ref, fetchTarget: parsed }
}
}
const matchingRefs = state.refs.filter((ref) => ref.endsWith(`/${base}`))
if (matchingRefs.length === 1) {
const ref = matchingRefs[0]
return { comparisonBase: ref, fetchTarget: parseRemoteRef(ref, state.remotes) }
}
return { comparisonBase: base, fetchTarget: null }
}
async function fetchComparisonBase(execGit: GitExec, target: RemoteBranch | null): Promise<void> {
if (!target) {
return
}
await requiredExec(
execGit,
[
'fetch',
'--no-tags',
target.remote,
`+refs/heads/${target.branch}:refs/remotes/${target.remote}/${target.branch}`
],
'Fetch before generating PR details failed'
)
}
type PullRequestBranchPreparation = {
comparisonBase: string
branchChanged: boolean
}
async function preparePullRequestBranch(
execGit: GitExec,
base: string
): Promise<PullRequestBranchPreparation> {
const { comparisonBase, fetchTarget } = resolveComparisonBase(base, await getRemoteState(execGit))
// Why: PR generation only needs the selected base branch. A repo-wide
// `fetch --all` makes stale contributor fork remotes block unrelated PRs.
await fetchComparisonBase(execGit, fetchTarget)
return {
comparisonBase,
// Why: Generate must be read-only. Rebasing the live worktree can rewrite
// files under the running dev app and trigger a full Electron/Vite reload.
branchChanged: false
}
}
export async function getPullRequestDraftContext(
execGit: GitExec,
input: PullRequestContextInput
): Promise<PullRequestDraftContext | null> {
const base = input.base.trim()
if (!base || base.startsWith('-')) {
return null
}
const { comparisonBase, branchChanged } = await preparePullRequestBranch(execGit, base)
const [branch, mergeBase] = await Promise.all([
safeExec(execGit, ['branch', '--show-current']),
safeExec(execGit, ['merge-base', comparisonBase, 'HEAD'])
])
if (!mergeBase) {
return null
}
const range = `${mergeBase}..HEAD`
const [commitSummary, changeSummary, patch] = await Promise.all([
safeExec(execGit, ['log', '--pretty=format:- %s', '--max-count=50', range]),
safeExec(execGit, ['diff', '--name-status', range]),
safeExec(execGit, ['diff', '--patch', '--minimal', '--no-color', '--no-ext-diff', range])
])
if (!commitSummary && !changeSummary && !patch) {
return null
}
return {
branch: branch || null,
base,
branchChangedByPreparation: branchChanged,
currentTitle: input.currentTitle,
currentBody: input.currentBody,
currentDraft: input.currentDraft,
commitSummary,
changeSummary,
patch
}
}