fix: address review findings (#2138)
This commit is contained in:
parent
c7ac8a1f2f
commit
40e903d833
|
|
@ -3,7 +3,7 @@ import { ipcMain, shell } from 'electron'
|
|||
import { readdir, readFile, writeFile, stat, lstat, open } from 'fs/promises'
|
||||
import { extname, join } from 'path'
|
||||
import type { ChildProcess } from 'child_process'
|
||||
import { wslAwareSpawn } from '../git/runner'
|
||||
import { gitExecFileAsync, wslAwareSpawn } from '../git/runner'
|
||||
import { parseWslPath, toWindowsWslPath } from '../wsl'
|
||||
import type { Store } from '../persistence'
|
||||
import type {
|
||||
|
|
@ -48,10 +48,14 @@ import {
|
|||
import { getHistory } from '../git/history'
|
||||
import {
|
||||
cancelGenerateCommitMessageLocal,
|
||||
cancelGeneratePullRequestFieldsLocal,
|
||||
generateCommitMessageFromContext,
|
||||
generatePullRequestFieldsFromContext,
|
||||
resolveCommitMessageSettings,
|
||||
type GenerateCommitMessageResult
|
||||
type GenerateCommitMessageResult,
|
||||
type GeneratePullRequestFieldsResult
|
||||
} from '../text-generation/commit-message-text-generation'
|
||||
import { getPullRequestDraftContext } from '../text-generation/pull-request-context'
|
||||
import { getUpstreamStatus } from '../git/upstream'
|
||||
import { gitFetch, gitPull, gitPush } from '../git/remote'
|
||||
import { assertGitPushTargetShape } from '../../shared/git-push-target-validation'
|
||||
|
|
@ -691,6 +695,96 @@ export function registerFilesystemHandlers(
|
|||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'git:generatePullRequestFields',
|
||||
async (
|
||||
_event,
|
||||
args: {
|
||||
worktreePath: string
|
||||
base: string
|
||||
title: string
|
||||
body: string
|
||||
draft: boolean
|
||||
connectionId?: string
|
||||
}
|
||||
): Promise<GeneratePullRequestFieldsResult> => {
|
||||
const resolvedSettings = resolveCommitMessageSettings(store.getSettings())
|
||||
if (!resolvedSettings.ok) {
|
||||
return { success: false, error: resolvedSettings.error }
|
||||
}
|
||||
if (args.connectionId) {
|
||||
const provider = getSshGitProvider(args.connectionId)
|
||||
if (!provider) {
|
||||
return {
|
||||
success: false,
|
||||
error: `No git provider for connection "${args.connectionId}"`
|
||||
}
|
||||
}
|
||||
const context = await getPullRequestDraftContext(
|
||||
(argv) => provider.exec(argv, args.worktreePath),
|
||||
{
|
||||
base: args.base,
|
||||
currentTitle: args.title,
|
||||
currentBody: args.body,
|
||||
currentDraft: args.draft
|
||||
}
|
||||
)
|
||||
if (!context) {
|
||||
return { success: false, error: 'No branch changes to summarize.' }
|
||||
}
|
||||
return generatePullRequestFieldsFromContext(context, resolvedSettings.params, {
|
||||
kind: 'remote',
|
||||
cwd: args.worktreePath,
|
||||
execute: (plan, cwd, timeoutMs) =>
|
||||
provider.executeCommitMessagePlan(plan, cwd, timeoutMs),
|
||||
missingBinaryLocation: 'remote PATH'
|
||||
})
|
||||
}
|
||||
|
||||
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
||||
const context = await getPullRequestDraftContext(
|
||||
(argv, options) => gitExecFileAsync(argv, { cwd: worktreePath, ...options }),
|
||||
{
|
||||
base: args.base,
|
||||
currentTitle: args.title,
|
||||
currentBody: args.body,
|
||||
currentDraft: args.draft
|
||||
}
|
||||
)
|
||||
if (!context) {
|
||||
return { success: false, error: 'No branch changes to summarize.' }
|
||||
}
|
||||
const localEnv = await prepareLocalCommitMessageAgentEnv(
|
||||
resolvedSettings.params.agentId,
|
||||
commitMessageAgentEnv
|
||||
)
|
||||
if (!localEnv.ok) {
|
||||
return { success: false, error: localEnv.error }
|
||||
}
|
||||
return generatePullRequestFieldsFromContext(context, resolvedSettings.params, {
|
||||
kind: 'local',
|
||||
cwd: worktreePath,
|
||||
...(localEnv.env ? { env: localEnv.env } : {})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'git:cancelGeneratePullRequestFields',
|
||||
async (_event, args: { worktreePath: string; connectionId?: string }): Promise<void> => {
|
||||
if (args.connectionId) {
|
||||
const provider = getSshGitProvider(args.connectionId)
|
||||
if (!provider) {
|
||||
return
|
||||
}
|
||||
await provider.cancelGenerateCommitMessage(args.worktreePath)
|
||||
return
|
||||
}
|
||||
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
||||
cancelGeneratePullRequestFieldsLocal(worktreePath)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'git:branchCompare',
|
||||
async (
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import {
|
|||
getHostedReviewCreationEligibility
|
||||
} from '../source-control/hosted-review-creation'
|
||||
import { getHostedReviewForBranch } from '../source-control/hosted-review'
|
||||
import { resolveRegisteredWorktreePath } from './filesystem-auth'
|
||||
import { listRepoWorktrees } from '../repo-worktrees'
|
||||
|
||||
function assertRegisteredRepo(repoPath: string, store: Store, repoId?: string): Repo {
|
||||
if (repoId) {
|
||||
|
|
@ -30,6 +32,22 @@ function assertRegisteredRepo(repoPath: string, store: Store, repoId?: string):
|
|||
return repo
|
||||
}
|
||||
|
||||
async function resolveHostedReviewWorktreePath(
|
||||
repo: Repo,
|
||||
store: Store,
|
||||
worktreePath?: string
|
||||
): Promise<string> {
|
||||
if (!worktreePath) {
|
||||
return repo.path
|
||||
}
|
||||
const resolvedWorktreePath = await resolveRegisteredWorktreePath(worktreePath, store)
|
||||
const repoWorktrees = await listRepoWorktrees(repo)
|
||||
if (!repoWorktrees.some((worktree) => resolve(worktree.path) === resolvedWorktreePath)) {
|
||||
throw new Error('Access denied: worktree does not belong to repository')
|
||||
}
|
||||
return resolvedWorktreePath
|
||||
}
|
||||
|
||||
export function registerHostedReviewHandlers(store: Store, stats: StatsCollector): void {
|
||||
ipcMain.handle('hostedReview:forBranch', async (_event, args: HostedReviewForBranchArgs) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store, args.repoId)
|
||||
|
|
@ -71,7 +89,8 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector
|
|||
body: null
|
||||
}
|
||||
}
|
||||
return getHostedReviewCreationEligibility({ ...args, repoPath: repo.path })
|
||||
const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath)
|
||||
return getHostedReviewCreationEligibility({ ...args, repoPath: worktreePath })
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -84,7 +103,8 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector
|
|||
error: 'Creating pull requests from SSH worktrees is not supported yet.'
|
||||
}
|
||||
}
|
||||
const result = await createHostedReview(repo.path, {
|
||||
const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath)
|
||||
const result = await createHostedReview(worktreePath, {
|
||||
provider: args.provider,
|
||||
base: args.base,
|
||||
head: args.head,
|
||||
|
|
|
|||
|
|
@ -37,13 +37,18 @@ import { gitFetch, gitPull, gitPush } from '../git/remote'
|
|||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import {
|
||||
cancelGenerateCommitMessageLocal,
|
||||
cancelGeneratePullRequestFieldsLocal,
|
||||
generateCommitMessageFromContext,
|
||||
generatePullRequestFieldsFromContext,
|
||||
resolveCommitMessageSettings,
|
||||
type GenerateCommitMessageResult
|
||||
type GenerateCommitMessageResult,
|
||||
type GeneratePullRequestFieldsResult
|
||||
} from '../text-generation/commit-message-text-generation'
|
||||
import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment'
|
||||
import { prepareLocalCommitMessageAgentEnv } from '../text-generation/commit-message-agent-environment'
|
||||
import { getPullRequestDraftContext } from '../text-generation/pull-request-context'
|
||||
import { normalizeRuntimeRelativePath } from './runtime-relative-paths'
|
||||
import { gitExecFileAsync } from '../git/runner'
|
||||
|
||||
export type ResolvedRuntimeGitWorktree = Worktree & { git: GitWorktreeInfo }
|
||||
type RuntimeCommitMessageSettingsOverride = Partial<
|
||||
|
|
@ -379,6 +384,81 @@ export class RuntimeGitCommands {
|
|||
return { ok: true }
|
||||
}
|
||||
|
||||
async generateRuntimePullRequestFields(
|
||||
worktreeSelector: string,
|
||||
input: { base: string; title: string; body: string; draft: boolean },
|
||||
settingsOverride?: RuntimeCommitMessageSettingsOverride
|
||||
): Promise<GeneratePullRequestFieldsResult> {
|
||||
const resolvedSettings = resolveCommitMessageSettings({
|
||||
...this.host.getRuntimeSettings(),
|
||||
...settingsOverride
|
||||
})
|
||||
if (!resolvedSettings.ok) {
|
||||
return { success: false, error: resolvedSettings.error }
|
||||
}
|
||||
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId && !provider) {
|
||||
return {
|
||||
success: false,
|
||||
error: `No git provider for connection "${target.connectionId}"`
|
||||
}
|
||||
}
|
||||
const context = target.connectionId
|
||||
? await getPullRequestDraftContext((argv) => provider!.exec(argv, target.worktree.path), {
|
||||
base: input.base,
|
||||
currentTitle: input.title,
|
||||
currentBody: input.body,
|
||||
currentDraft: input.draft
|
||||
})
|
||||
: await getPullRequestDraftContext(
|
||||
(argv, options) => gitExecFileAsync(argv, { cwd: target.worktree.path, ...options }),
|
||||
{
|
||||
base: input.base,
|
||||
currentTitle: input.title,
|
||||
currentBody: input.body,
|
||||
currentDraft: input.draft
|
||||
}
|
||||
)
|
||||
if (!context) {
|
||||
return { success: false, error: 'No branch changes to summarize.' }
|
||||
}
|
||||
|
||||
if (target.connectionId) {
|
||||
return generatePullRequestFieldsFromContext(context, resolvedSettings.params, {
|
||||
kind: 'remote',
|
||||
cwd: target.worktree.path,
|
||||
execute: (plan, cwd, timeoutMs) => provider!.executeCommitMessagePlan(plan, cwd, timeoutMs),
|
||||
missingBinaryLocation: 'remote PATH'
|
||||
})
|
||||
}
|
||||
|
||||
const localEnv = await prepareLocalCommitMessageAgentEnv(
|
||||
resolvedSettings.params.agentId,
|
||||
this.host.getCommitMessageAgentEnvironment?.()
|
||||
)
|
||||
if (!localEnv.ok) {
|
||||
return { success: false, error: localEnv.error }
|
||||
}
|
||||
return generatePullRequestFieldsFromContext(context, resolvedSettings.params, {
|
||||
kind: 'local',
|
||||
cwd: target.worktree.path,
|
||||
...(localEnv.env ? { env: localEnv.env } : {})
|
||||
})
|
||||
}
|
||||
|
||||
async cancelRuntimeGeneratePullRequestFields(worktreeSelector: string): Promise<{ ok: true }> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
await provider?.cancelGenerateCommitMessage(target.worktree.path)
|
||||
return { ok: true }
|
||||
}
|
||||
cancelGeneratePullRequestFieldsLocal(target.worktree.path)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async stageRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const relativePath = normalizeRuntimeGitRelativePath(filePath)
|
||||
|
|
|
|||
|
|
@ -38,7 +38,9 @@ const {
|
|||
getSshGitProviderMock,
|
||||
registerSshGitProviderMock,
|
||||
unregisterSshGitProviderMock,
|
||||
invalidateAuthorizedRootsCacheMock
|
||||
invalidateAuthorizedRootsCacheMock,
|
||||
createHostedReviewMock,
|
||||
getHostedReviewCreationEligibilityMock
|
||||
} = vi.hoisted(() => {
|
||||
// Why: SSH runtime tests register providers through the public dispatcher API,
|
||||
// so the mock needs the same registry semantics as the real module.
|
||||
|
|
@ -66,7 +68,9 @@ const {
|
|||
unregisterSshGitProviderMock: vi.fn((connectionId: string) => {
|
||||
sshGitProviders.delete(connectionId)
|
||||
}),
|
||||
invalidateAuthorizedRootsCacheMock: vi.fn()
|
||||
invalidateAuthorizedRootsCacheMock: vi.fn(),
|
||||
createHostedReviewMock: vi.fn(),
|
||||
getHostedReviewCreationEligibilityMock: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -110,6 +114,11 @@ vi.mock('../ipc/filesystem-auth', () => ({
|
|||
Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')
|
||||
}))
|
||||
|
||||
vi.mock('../source-control/hosted-review-creation', () => ({
|
||||
createHostedReview: createHostedReviewMock,
|
||||
getHostedReviewCreationEligibility: getHostedReviewCreationEligibilityMock
|
||||
}))
|
||||
|
||||
// Why: the CLI create-worktree path calls getDefaultBaseRef to resolve a
|
||||
// fallback base branch. Real resolution shells out to `git` against the
|
||||
// test's fabricated repo path, which has no refs, so we stub it to a
|
||||
|
|
@ -157,6 +166,25 @@ afterEach(() => {
|
|||
computeWorktreePathMock.mockReset()
|
||||
ensurePathWithinWorkspaceMock.mockReset()
|
||||
invalidateAuthorizedRootsCacheMock.mockReset()
|
||||
createHostedReviewMock.mockReset()
|
||||
createHostedReviewMock.mockResolvedValue({
|
||||
ok: true,
|
||||
provider: 'github',
|
||||
number: 1,
|
||||
url: 'https://example.com/pull/1'
|
||||
})
|
||||
getHostedReviewCreationEligibilityMock.mockReset()
|
||||
getHostedReviewCreationEligibilityMock.mockResolvedValue({
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: true,
|
||||
blockedReason: null,
|
||||
nextAction: null,
|
||||
defaultBaseRef: 'main',
|
||||
head: 'feature/foo',
|
||||
title: null,
|
||||
body: null
|
||||
})
|
||||
})
|
||||
|
||||
function syncSinglePty(runtime: OrcaRuntimeService, ptyId: string | null = 'pty-1'): void {
|
||||
|
|
@ -810,6 +838,73 @@ describe('OrcaRuntimeService', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('rejects hosted review worktree selectors outside the selected repo', async () => {
|
||||
vi.mocked(listWorktrees).mockImplementation(async (repoPath: string) => {
|
||||
if (repoPath === '/tmp/repo-b') {
|
||||
return [
|
||||
{
|
||||
path: '/tmp/worktree-b',
|
||||
head: 'def',
|
||||
branch: 'feature/bar',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
]
|
||||
}
|
||||
return MOCK_GIT_WORKTREES
|
||||
})
|
||||
const repos = [
|
||||
{
|
||||
id: TEST_REPO_ID,
|
||||
path: TEST_REPO_PATH,
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1
|
||||
},
|
||||
{
|
||||
id: 'repo-2',
|
||||
path: '/tmp/repo-b',
|
||||
displayName: 'repo-b',
|
||||
badgeColor: 'green',
|
||||
addedAt: 2
|
||||
}
|
||||
]
|
||||
const multiRepoStore = {
|
||||
...store,
|
||||
getRepos: () => repos,
|
||||
getRepo: (id: string) => repos.find((repo) => repo.id === id)
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(multiRepoStore as never)
|
||||
|
||||
await expect(
|
||||
runtime.getHostedReviewCreationEligibility({
|
||||
repoSelector: 'id:repo-1',
|
||||
worktreeSelector: 'id:repo-2::/tmp/worktree-b',
|
||||
branch: 'feature/bar',
|
||||
base: 'main',
|
||||
hasUncommittedChanges: false,
|
||||
hasUpstream: true,
|
||||
ahead: 1,
|
||||
behind: 0
|
||||
})
|
||||
).rejects.toThrow('Access denied: worktree does not belong to repository')
|
||||
await expect(
|
||||
runtime.createHostedReview({
|
||||
repoSelector: 'id:repo-1',
|
||||
worktreeSelector: 'id:repo-2::/tmp/worktree-b',
|
||||
provider: 'github',
|
||||
base: 'main',
|
||||
head: 'feature/bar',
|
||||
title: 'Create PR',
|
||||
body: '',
|
||||
draft: false
|
||||
})
|
||||
).rejects.toThrow('Access denied: worktree does not belong to repository')
|
||||
|
||||
expect(getHostedReviewCreationEligibilityMock).not.toHaveBeenCalled()
|
||||
expect(createHostedReviewMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats SSH worktree drift as unknown without local git probes', async () => {
|
||||
vi.mocked(listWorktrees).mockClear()
|
||||
vi.mocked(getDefaultBaseRef).mockClear()
|
||||
|
|
|
|||
|
|
@ -1239,6 +1239,10 @@ export class OrcaRuntimeService {
|
|||
this.gitCommands.generateRuntimeCommitMessage.bind(this.gitCommands)
|
||||
cancelRuntimeGenerateCommitMessage: RuntimeGitCommands['cancelRuntimeGenerateCommitMessage'] =
|
||||
this.gitCommands.cancelRuntimeGenerateCommitMessage.bind(this.gitCommands)
|
||||
generateRuntimePullRequestFields: RuntimeGitCommands['generateRuntimePullRequestFields'] =
|
||||
this.gitCommands.generateRuntimePullRequestFields.bind(this.gitCommands)
|
||||
cancelRuntimeGeneratePullRequestFields: RuntimeGitCommands['cancelRuntimeGeneratePullRequestFields'] =
|
||||
this.gitCommands.cancelRuntimeGeneratePullRequestFields.bind(this.gitCommands)
|
||||
stageRuntimeGitPath: RuntimeGitCommands['stageRuntimeGitPath'] =
|
||||
this.gitCommands.stageRuntimeGitPath.bind(this.gitCommands)
|
||||
unstageRuntimeGitPath: RuntimeGitCommands['unstageRuntimeGitPath'] =
|
||||
|
|
@ -4467,6 +4471,22 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
}
|
||||
|
||||
private async resolveHostedReviewTarget(args: {
|
||||
repoSelector: string
|
||||
worktreeSelector?: string
|
||||
}): Promise<{ repo: Repo; repoPath: string }> {
|
||||
const repo = await this.resolveRepoSelector(args.repoSelector)
|
||||
if (!args.worktreeSelector) {
|
||||
return { repo, repoPath: repo.path }
|
||||
}
|
||||
|
||||
const worktree = await this.resolveWorktreeSelector(args.worktreeSelector)
|
||||
if (worktree.repoId !== repo.id) {
|
||||
throw new Error('Access denied: worktree does not belong to repository')
|
||||
}
|
||||
return { repo, repoPath: worktree.path }
|
||||
}
|
||||
|
||||
async getRepoSlug(repoSelector: string): Promise<{ owner: string; repo: string } | null> {
|
||||
const repo = await this.resolveRepoSelector(repoSelector)
|
||||
this.assertHostIntegrationRepoIsLocal(repo, 'repo_slug')
|
||||
|
|
@ -4583,12 +4603,15 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
|
||||
async getHostedReviewCreationEligibility(
|
||||
args: Omit<HostedReviewCreationEligibilityArgs, 'repoPath'> & { repoSelector: string }
|
||||
args: Omit<HostedReviewCreationEligibilityArgs, 'repoPath'> & {
|
||||
repoSelector: string
|
||||
worktreeSelector?: string
|
||||
}
|
||||
): Promise<HostedReviewCreationEligibility> {
|
||||
const repo = await this.resolveRepoSelector(args.repoSelector)
|
||||
const { repo, repoPath } = await this.resolveHostedReviewTarget(args)
|
||||
this.assertHostIntegrationRepoIsLocal(repo, 'hosted_review')
|
||||
return getHostedReviewCreationEligibilityFromRepo({
|
||||
repoPath: repo.path,
|
||||
repoPath,
|
||||
branch: args.branch,
|
||||
base: args.base ?? null,
|
||||
hasUncommittedChanges: args.hasUncommittedChanges,
|
||||
|
|
@ -4604,11 +4627,11 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
|
||||
async createHostedReview(
|
||||
args: CreateHostedReviewInput & { repoSelector: string }
|
||||
args: CreateHostedReviewInput & { repoSelector: string; worktreeSelector?: string }
|
||||
): Promise<CreateHostedReviewResult> {
|
||||
const repo = await this.resolveRepoSelector(args.repoSelector)
|
||||
const { repo, repoPath } = await this.resolveHostedReviewTarget(args)
|
||||
this.assertHostIntegrationRepoIsLocal(repo, 'hosted_review')
|
||||
const result = await createHostedReviewFromRepo(repo.path, {
|
||||
const result = await createHostedReviewFromRepo(repoPath, {
|
||||
provider: args.provider,
|
||||
base: args.base,
|
||||
head: args.head,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
export const WorktreeSelector = z.object({
|
||||
worktree: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing worktree selector'))
|
||||
})
|
||||
|
||||
export const GitStatusParams = WorktreeSelector.extend({
|
||||
includeIgnored: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const GitFilePath = WorktreeSelector.extend({
|
||||
filePath: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing file path'))
|
||||
})
|
||||
|
||||
export const GitDiff = GitFilePath.extend({
|
||||
staged: z.boolean(),
|
||||
compareAgainstHead: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const GitBranchCompare = WorktreeSelector.extend({
|
||||
baseRef: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(
|
||||
z
|
||||
.string()
|
||||
.min(1, 'Missing base ref')
|
||||
.refine((value) => !value.startsWith('-'), 'Base ref must not start with -')
|
||||
)
|
||||
})
|
||||
|
||||
const FullGitObjectId = z
|
||||
.string()
|
||||
.regex(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/, 'Expected a full git object id')
|
||||
|
||||
export const GitCommitCompare = WorktreeSelector.extend({
|
||||
commitId: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(FullGitObjectId)
|
||||
})
|
||||
|
||||
export const GitHistory = WorktreeSelector.extend({
|
||||
limit: z.number().int().min(1).max(200).optional(),
|
||||
baseRef: z.string().nullable().optional()
|
||||
})
|
||||
|
||||
export const GitBranchDiff = GitFilePath.extend({
|
||||
compare: z.object({
|
||||
baseRef: z.string().optional(),
|
||||
baseOid: FullGitObjectId.optional(),
|
||||
headOid: FullGitObjectId,
|
||||
mergeBase: FullGitObjectId
|
||||
}),
|
||||
oldPath: z.string().optional()
|
||||
})
|
||||
|
||||
export const GitCommitDiff = GitFilePath.extend({
|
||||
commitOid: FullGitObjectId,
|
||||
parentOid: FullGitObjectId.nullable().optional(),
|
||||
oldPath: z.string().optional()
|
||||
})
|
||||
|
||||
export const GitCommit = WorktreeSelector.extend({
|
||||
message: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing commit message'))
|
||||
})
|
||||
|
||||
const CommitMessageAiSettings = z.object({
|
||||
enabled: z.boolean(),
|
||||
agentId: z.string().nullable(),
|
||||
selectedModelByAgent: z.record(z.string(), z.string()),
|
||||
selectedThinkingByModel: z.record(z.string(), z.string()),
|
||||
customPrompt: z.string(),
|
||||
customAgentCommand: z.string()
|
||||
})
|
||||
|
||||
export const GitGenerateCommitMessage = WorktreeSelector.extend({
|
||||
commitMessageAi: CommitMessageAiSettings.optional(),
|
||||
agentCmdOverrides: z.record(z.string(), z.string()).optional(),
|
||||
enableGitHubAttribution: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const GitGeneratePullRequestFields = GitGenerateCommitMessage.extend({
|
||||
base: z.string().min(1, 'Missing base branch'),
|
||||
title: z.string(),
|
||||
body: z.string(),
|
||||
draft: z.boolean()
|
||||
})
|
||||
|
||||
export const GitBulkPaths = WorktreeSelector.extend({
|
||||
filePaths: z.array(z.string().min(1, 'Missing file path'))
|
||||
})
|
||||
|
||||
export const GitPush = WorktreeSelector.extend({
|
||||
publish: z.boolean().optional(),
|
||||
pushTarget: z.unknown().optional()
|
||||
})
|
||||
|
||||
export const GitRemoteFileUrl = WorktreeSelector.extend({
|
||||
relativePath: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing relative path')),
|
||||
line: z.number().int().min(1)
|
||||
})
|
||||
|
|
@ -1,112 +1,22 @@
|
|||
import { z } from 'zod'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
|
||||
const WorktreeSelector = z.object({
|
||||
worktree: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing worktree selector'))
|
||||
})
|
||||
|
||||
const GitStatusParams = WorktreeSelector.extend({
|
||||
includeIgnored: z.boolean().optional()
|
||||
})
|
||||
|
||||
const GitFilePath = WorktreeSelector.extend({
|
||||
filePath: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing file path'))
|
||||
})
|
||||
|
||||
const GitDiff = GitFilePath.extend({
|
||||
staged: z.boolean(),
|
||||
compareAgainstHead: z.boolean().optional()
|
||||
})
|
||||
|
||||
const GitBranchCompare = WorktreeSelector.extend({
|
||||
baseRef: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(
|
||||
z
|
||||
.string()
|
||||
.min(1, 'Missing base ref')
|
||||
.refine((value) => !value.startsWith('-'), 'Base ref must not start with -')
|
||||
)
|
||||
})
|
||||
|
||||
const FullGitObjectId = z
|
||||
.string()
|
||||
.regex(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/, 'Expected a full git object id')
|
||||
|
||||
const GitCommitCompare = WorktreeSelector.extend({
|
||||
commitId: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(FullGitObjectId)
|
||||
})
|
||||
|
||||
const GitHistory = WorktreeSelector.extend({
|
||||
limit: z.number().int().min(1).max(200).optional(),
|
||||
baseRef: z.string().nullable().optional()
|
||||
})
|
||||
|
||||
const GitBranchDiff = GitFilePath.extend({
|
||||
compare: z.object({
|
||||
baseRef: z.string().optional(),
|
||||
baseOid: FullGitObjectId.optional(),
|
||||
headOid: FullGitObjectId,
|
||||
mergeBase: FullGitObjectId
|
||||
}),
|
||||
oldPath: z.string().optional()
|
||||
})
|
||||
|
||||
const GitCommitDiff = GitFilePath.extend({
|
||||
commitOid: FullGitObjectId,
|
||||
parentOid: FullGitObjectId.nullable().optional(),
|
||||
oldPath: z.string().optional()
|
||||
})
|
||||
|
||||
const GitCommit = WorktreeSelector.extend({
|
||||
message: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing commit message'))
|
||||
})
|
||||
|
||||
const CommitMessageAiSettings = z.object({
|
||||
enabled: z.boolean(),
|
||||
agentId: z.string().nullable(),
|
||||
selectedModelByAgent: z.record(z.string(), z.string()),
|
||||
selectedThinkingByModel: z.record(z.string(), z.string()),
|
||||
customPrompt: z.string(),
|
||||
customAgentCommand: z.string()
|
||||
})
|
||||
|
||||
const GitGenerateCommitMessage = WorktreeSelector.extend({
|
||||
commitMessageAi: CommitMessageAiSettings.optional(),
|
||||
agentCmdOverrides: z.record(z.string(), z.string()).optional(),
|
||||
enableGitHubAttribution: z.boolean().optional()
|
||||
})
|
||||
|
||||
const GitBulkPaths = WorktreeSelector.extend({
|
||||
filePaths: z.array(z.string().min(1, 'Missing file path'))
|
||||
})
|
||||
|
||||
const GitPush = WorktreeSelector.extend({
|
||||
publish: z.boolean().optional(),
|
||||
pushTarget: z.unknown().optional()
|
||||
})
|
||||
|
||||
const GitRemoteFileUrl = WorktreeSelector.extend({
|
||||
relativePath: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing relative path')),
|
||||
line: z.number().int().min(1)
|
||||
})
|
||||
import {
|
||||
GitBranchCompare,
|
||||
GitBranchDiff,
|
||||
GitBulkPaths,
|
||||
GitCommit,
|
||||
GitCommitCompare,
|
||||
GitCommitDiff,
|
||||
GitDiff,
|
||||
GitFilePath,
|
||||
GitGenerateCommitMessage,
|
||||
GitGeneratePullRequestFields,
|
||||
GitHistory,
|
||||
GitPush,
|
||||
GitRemoteFileUrl,
|
||||
GitStatusParams,
|
||||
WorktreeSelector
|
||||
} from './git-params'
|
||||
|
||||
export const GIT_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
|
|
@ -235,6 +145,44 @@ export const GIT_METHODS: RpcMethod[] = [
|
|||
handler: async (params, { runtime }) =>
|
||||
runtime.cancelRuntimeGenerateCommitMessage(params.worktree)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.generatePullRequestFields',
|
||||
params: GitGeneratePullRequestFields,
|
||||
handler: async (params, { runtime }) => {
|
||||
const input = {
|
||||
base: params.base,
|
||||
title: params.title,
|
||||
body: params.body,
|
||||
draft: params.draft
|
||||
}
|
||||
if (
|
||||
params.commitMessageAi === undefined &&
|
||||
params.agentCmdOverrides === undefined &&
|
||||
params.enableGitHubAttribution === undefined
|
||||
) {
|
||||
return runtime.generateRuntimePullRequestFields(params.worktree, input)
|
||||
}
|
||||
return runtime.generateRuntimePullRequestFields(params.worktree, input, {
|
||||
...(params.commitMessageAi !== undefined
|
||||
? { commitMessageAi: params.commitMessageAi as GlobalSettings['commitMessageAi'] }
|
||||
: {}),
|
||||
...(params.agentCmdOverrides !== undefined
|
||||
? {
|
||||
agentCmdOverrides: params.agentCmdOverrides as GlobalSettings['agentCmdOverrides']
|
||||
}
|
||||
: {}),
|
||||
...(params.enableGitHubAttribution !== undefined
|
||||
? { enableGitHubAttribution: params.enableGitHubAttribution }
|
||||
: {})
|
||||
})
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.cancelGeneratePullRequestFields',
|
||||
params: WorktreeSelector,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.cancelRuntimeGeneratePullRequestFields(params.worktree)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.stage',
|
||||
params: GitFilePath,
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ describe('hosted review RPC methods', () => {
|
|||
const response = await dispatcher.dispatch(
|
||||
makeRequest('hostedReview.getCreationEligibility', {
|
||||
repo: 'repo-1',
|
||||
worktree: 'path:/worktrees/feature',
|
||||
branch: 'feature/create-pr',
|
||||
base: 'origin/main',
|
||||
hasUncommittedChanges: false,
|
||||
|
|
@ -79,6 +80,7 @@ describe('hosted review RPC methods', () => {
|
|||
|
||||
expect(runtime.getHostedReviewCreationEligibility).toHaveBeenCalledWith({
|
||||
repoSelector: 'repo-1',
|
||||
worktreeSelector: 'path:/worktrees/feature',
|
||||
branch: 'feature/create-pr',
|
||||
base: 'origin/main',
|
||||
hasUncommittedChanges: false,
|
||||
|
|
@ -111,6 +113,7 @@ describe('hosted review RPC methods', () => {
|
|||
const response = await dispatcher.dispatch(
|
||||
makeRequest('hostedReview.create', {
|
||||
repo: 'repo-1',
|
||||
worktree: 'path:/worktrees/feature',
|
||||
provider: 'github',
|
||||
base: 'main',
|
||||
head: 'feature/create-pr',
|
||||
|
|
@ -122,6 +125,7 @@ describe('hosted review RPC methods', () => {
|
|||
|
||||
expect(runtime.createHostedReview).toHaveBeenCalledWith({
|
||||
repoSelector: 'repo-1',
|
||||
worktreeSelector: 'path:/worktrees/feature',
|
||||
provider: 'github',
|
||||
base: 'main',
|
||||
head: 'feature/create-pr',
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const HostedReviewForBranch = z.object({
|
|||
|
||||
const HostedReviewCreationEligibility = z.object({
|
||||
repo: requiredString('Missing repo selector'),
|
||||
worktree: z.string().min(1, 'Missing worktree selector').optional(),
|
||||
branch: requiredString('Missing branch'),
|
||||
base: z.string().nullable().optional(),
|
||||
hasUncommittedChanges: z.boolean().optional(),
|
||||
|
|
@ -29,6 +30,7 @@ const HostedReviewCreationEligibility = z.object({
|
|||
|
||||
const HostedReviewCreate = z.object({
|
||||
repo: requiredString('Missing repo selector'),
|
||||
worktree: z.string().min(1, 'Missing worktree selector').optional(),
|
||||
provider: z.enum(['github', 'gitlab', 'bitbucket', 'azure-devops', 'gitea', 'unsupported']),
|
||||
base: requiredString('Missing base branch'),
|
||||
head: z.string().optional(),
|
||||
|
|
@ -58,6 +60,7 @@ export const HOSTED_REVIEW_METHODS: RpcMethod[] = [
|
|||
handler: async (params, { runtime }) =>
|
||||
runtime.getHostedReviewCreationEligibility({
|
||||
repoSelector: params.repo,
|
||||
worktreeSelector: params.worktree,
|
||||
branch: params.branch,
|
||||
base: params.base ?? null,
|
||||
hasUncommittedChanges: params.hasUncommittedChanges,
|
||||
|
|
@ -77,6 +80,7 @@ export const HOSTED_REVIEW_METHODS: RpcMethod[] = [
|
|||
handler: async (params, { runtime }) =>
|
||||
runtime.createHostedReview({
|
||||
repoSelector: params.repo,
|
||||
worktreeSelector: params.worktree,
|
||||
provider: params.provider,
|
||||
base: params.base,
|
||||
head: params.head,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ import type * as ChildProcess from 'child_process'
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../shared/constants'
|
||||
import {
|
||||
cancelGenerateCommitMessageLocal,
|
||||
cancelGeneratePullRequestFieldsLocal,
|
||||
generateCommitMessageFromContext,
|
||||
generatePullRequestFieldsFromContext,
|
||||
resolveCommitMessageSettings,
|
||||
trimGeneratedCommitMessage
|
||||
} from './commit-message-text-generation'
|
||||
|
|
@ -390,6 +393,94 @@ describe('generateCommitMessageFromContext', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('keeps local commit-message and pull-request cancellation lanes separate', async () => {
|
||||
const children: {
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
listeners: Map<string, (value: unknown) => void>
|
||||
}[] = []
|
||||
spawnMock.mockImplementation(() => {
|
||||
const listeners = new Map<string, (value: unknown) => void>()
|
||||
const child = {
|
||||
pid: 123 + children.length,
|
||||
kill: vi.fn(),
|
||||
stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) },
|
||||
stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) },
|
||||
stdin: { end: vi.fn() },
|
||||
on: vi.fn((event, callback) => listeners.set(event, callback))
|
||||
}
|
||||
children.push({ kill: child.kill, listeners })
|
||||
return child as never
|
||||
})
|
||||
|
||||
const commit = generateCommitMessageFromContext(
|
||||
{
|
||||
branch: 'main',
|
||||
stagedSummary: 'M\tREADME.md',
|
||||
stagedPatch: '+hello'
|
||||
},
|
||||
{
|
||||
agentId: 'custom',
|
||||
model: '',
|
||||
customAgentCommand: 'agent'
|
||||
},
|
||||
{
|
||||
kind: 'local',
|
||||
cwd: '/repo'
|
||||
}
|
||||
)
|
||||
const pullRequest = generatePullRequestFieldsFromContext(
|
||||
{
|
||||
branch: 'feature/pr-fields',
|
||||
base: 'main',
|
||||
currentTitle: '',
|
||||
currentBody: '',
|
||||
currentDraft: false,
|
||||
commitSummary: '- feat: update README',
|
||||
changeSummary: 'M\tREADME.md',
|
||||
patch: '+hello'
|
||||
},
|
||||
{
|
||||
agentId: 'custom',
|
||||
model: '',
|
||||
customAgentCommand: 'agent'
|
||||
},
|
||||
{
|
||||
kind: 'local',
|
||||
cwd: '/repo'
|
||||
}
|
||||
)
|
||||
|
||||
cancelGenerateCommitMessageLocal('/repo')
|
||||
|
||||
expect(children[0]?.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
expect(children[1]?.kill).not.toHaveBeenCalled()
|
||||
|
||||
children[0]?.listeners.get('close')?.(null)
|
||||
const pullRequestStdout = children[1]?.listeners.get('stdout:data')
|
||||
pullRequestStdout?.(
|
||||
Buffer.from('{"base":"main","title":"Update README","body":"Details","draft":false}')
|
||||
)
|
||||
children[1]?.listeners.get('close')?.(0)
|
||||
|
||||
await expect(commit).resolves.toEqual({
|
||||
success: false,
|
||||
error: 'Generation canceled.',
|
||||
canceled: true
|
||||
})
|
||||
await expect(pullRequest).resolves.toMatchObject({
|
||||
success: true,
|
||||
fields: {
|
||||
base: 'main',
|
||||
title: 'Update README',
|
||||
body: 'Details',
|
||||
draft: false
|
||||
}
|
||||
})
|
||||
|
||||
cancelGeneratePullRequestFieldsLocal('/repo')
|
||||
expect(children[1]?.kill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes Windows batch-script agent commands through cmd.exe', async () => {
|
||||
const originalComSpec = process.env.ComSpec
|
||||
process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe'
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@ import {
|
|||
type CommitMessageDraftContext,
|
||||
type GeneratedCommitMessage
|
||||
} from '../../shared/commit-message-generation'
|
||||
import {
|
||||
buildPullRequestFieldsPrompt,
|
||||
parseGeneratedPullRequestFields,
|
||||
type GeneratedPullRequestFields,
|
||||
type PullRequestDraftContext
|
||||
} from '../../shared/pull-request-generation'
|
||||
import {
|
||||
cleanGeneratedCommitMessage,
|
||||
extractAgentErrorMessage
|
||||
|
|
@ -47,6 +53,10 @@ export type GenerateCommitMessageResult =
|
|||
| { success: true; message: string; agentLabel?: string }
|
||||
| { success: false; error: string; canceled?: boolean }
|
||||
|
||||
export type GeneratePullRequestFieldsResult =
|
||||
| { success: true; fields: GeneratedPullRequestFields; agentLabel?: string }
|
||||
| { success: false; error: string; canceled?: boolean }
|
||||
|
||||
export type RemoteCommitMessageExecResult = {
|
||||
stdout: string
|
||||
stderr: string
|
||||
|
|
@ -73,8 +83,8 @@ type ResolveCommitMessageSettingsResult =
|
|||
| { ok: true; params: GenerateCommitMessageParams }
|
||||
| { ok: false; error: string }
|
||||
|
||||
type InternalCommitMessageGenerationResult =
|
||||
| { success: true; commitMessage: GeneratedCommitMessage; agentLabel?: string }
|
||||
type InternalTextGenerationResult =
|
||||
| { success: true; rawOutput: string; agentLabel?: string }
|
||||
| { success: false; error: string; canceled?: boolean }
|
||||
|
||||
export function trimGeneratedCommitMessage(message: string): string {
|
||||
|
|
@ -190,23 +200,27 @@ function killProcessTree(child: ChildProcess): void {
|
|||
}
|
||||
}
|
||||
|
||||
// Keying by `local:${cwd}` keeps local cancellation independent from any SSH
|
||||
// worktree with the same remote path.
|
||||
type LocalGenerationOperation = 'commit-message' | 'pull-request-fields'
|
||||
|
||||
// Keying by operation plus `local:${cwd}` keeps local cancellation independent
|
||||
// from SSH worktrees and from other generation features in the same worktree.
|
||||
const cancelTokensByLane = new Map<string, () => void>()
|
||||
|
||||
function localLaneKey(cwd: string): string {
|
||||
return `local:${cwd}`
|
||||
function localLaneKey(operation: LocalGenerationOperation, cwd: string): string {
|
||||
return `${operation}:local:${cwd}`
|
||||
}
|
||||
|
||||
export function cancelGenerateCommitMessageLocal(cwd: string): void {
|
||||
cancelTokensByLane.get(localLaneKey(cwd))?.()
|
||||
cancelTokensByLane.get(localLaneKey('commit-message', cwd))?.()
|
||||
}
|
||||
|
||||
async function runLocalPlan(
|
||||
plan: CommitMessagePlan,
|
||||
cwd: string,
|
||||
env: NodeJS.ProcessEnv | undefined
|
||||
): Promise<InternalCommitMessageGenerationResult> {
|
||||
env: NodeJS.ProcessEnv | undefined,
|
||||
emptyResultName = 'message',
|
||||
operation: LocalGenerationOperation = 'commit-message'
|
||||
): Promise<InternalTextGenerationResult> {
|
||||
const { binary, args, stdinPayload, label } = plan
|
||||
return new Promise((resolve) => {
|
||||
let child: ChildProcess
|
||||
|
|
@ -246,20 +260,24 @@ async function runLocalPlan(
|
|||
let outputLimitExceeded = false
|
||||
let settled = false
|
||||
let canceledByUser = false
|
||||
const laneKey = localLaneKey(cwd)
|
||||
const finalize = (result: InternalCommitMessageGenerationResult): void => {
|
||||
const laneKey = localLaneKey(operation, cwd)
|
||||
let cancelToken: (() => void) | null = null
|
||||
const finalize = (result: InternalTextGenerationResult): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cancelTokensByLane.delete(laneKey)
|
||||
if (cancelToken && cancelTokensByLane.get(laneKey) === cancelToken) {
|
||||
cancelTokensByLane.delete(laneKey)
|
||||
}
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
cancelTokensByLane.set(laneKey, () => {
|
||||
cancelToken = () => {
|
||||
canceledByUser = true
|
||||
killProcessTree(child)
|
||||
})
|
||||
}
|
||||
cancelTokensByLane.set(laneKey, cancelToken)
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
killProcessTree(child)
|
||||
|
|
@ -313,7 +331,7 @@ async function runLocalPlan(
|
|||
finalize({ success: false, error: userFacingAgentFailure(label) })
|
||||
return
|
||||
}
|
||||
finalizeFromAgentOutput({ code, stdout, stderr, label, finalize })
|
||||
finalizeFromAgentOutput({ code, stdout, stderr, label, emptyResultName, finalize })
|
||||
})
|
||||
|
||||
child.stdin?.end(stdinPayload ?? undefined)
|
||||
|
|
@ -325,9 +343,10 @@ function finalizeFromAgentOutput(args: {
|
|||
stdout: string
|
||||
stderr: string
|
||||
label: string
|
||||
finalize: (result: InternalCommitMessageGenerationResult) => void
|
||||
emptyResultName: string
|
||||
finalize: (result: InternalTextGenerationResult) => void
|
||||
}): void {
|
||||
const { code, stdout, stderr, label, finalize } = args
|
||||
const { code, stdout, stderr, label, emptyResultName, finalize } = args
|
||||
if (code !== 0) {
|
||||
const safeDetail = sanitizeAgentFailureDetail(extractAgentErrorMessage(stdout, stderr))
|
||||
console.error('[commit-message] Generator failed:', {
|
||||
|
|
@ -342,21 +361,21 @@ function finalizeFromAgentOutput(args: {
|
|||
}
|
||||
const cleaned = cleanGeneratedCommitMessage(stdout)
|
||||
if (!cleaned) {
|
||||
finalize({ success: false, error: `${label} returned an empty message.` })
|
||||
finalize({ success: false, error: `${label} returned an empty ${emptyResultName}.` })
|
||||
return
|
||||
}
|
||||
const commitMessage = splitGeneratedCommitMessage(cleaned)
|
||||
finalize({
|
||||
success: true,
|
||||
commitMessage,
|
||||
rawOutput: cleaned,
|
||||
agentLabel: label
|
||||
})
|
||||
}
|
||||
|
||||
async function runRemotePlan(
|
||||
plan: CommitMessagePlan,
|
||||
target: Extract<CommitMessageGenerationTarget, { kind: 'remote' }>
|
||||
): Promise<InternalCommitMessageGenerationResult> {
|
||||
target: Extract<CommitMessageGenerationTarget, { kind: 'remote' }>,
|
||||
emptyResultName = 'message'
|
||||
): Promise<InternalTextGenerationResult> {
|
||||
const { binary, label } = plan
|
||||
let result: RemoteCommitMessageExecResult
|
||||
try {
|
||||
|
|
@ -403,20 +422,27 @@ async function runRemotePlan(
|
|||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
label,
|
||||
emptyResultName,
|
||||
finalize: resolve
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function formatCommitMessageGenerationResult(
|
||||
result: InternalCommitMessageGenerationResult
|
||||
result: InternalTextGenerationResult
|
||||
): GenerateCommitMessageResult {
|
||||
if (!result.success) {
|
||||
return result
|
||||
}
|
||||
let commitMessage: GeneratedCommitMessage
|
||||
try {
|
||||
commitMessage = splitGeneratedCommitMessage(result.rawOutput)
|
||||
} catch {
|
||||
return { success: false, error: 'Generated commit message could not be parsed.' }
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: trimGeneratedCommitMessage(result.commitMessage.message),
|
||||
message: trimGeneratedCommitMessage(commitMessage.message),
|
||||
agentLabel: result.agentLabel
|
||||
}
|
||||
}
|
||||
|
|
@ -434,7 +460,47 @@ export async function generateCommitMessageFromContext(
|
|||
|
||||
const internalResult =
|
||||
target.kind === 'remote'
|
||||
? await runRemotePlan(planned.plan, target)
|
||||
: await runLocalPlan(planned.plan, target.cwd, target.env)
|
||||
? await runRemotePlan(planned.plan, target, 'details')
|
||||
: await runLocalPlan(planned.plan, target.cwd, target.env, 'details')
|
||||
return formatCommitMessageGenerationResult(internalResult)
|
||||
}
|
||||
|
||||
export function cancelGeneratePullRequestFieldsLocal(cwd: string): void {
|
||||
cancelTokensByLane.get(localLaneKey('pull-request-fields', cwd))?.()
|
||||
}
|
||||
|
||||
function formatPullRequestFieldsGenerationResult(
|
||||
result: InternalTextGenerationResult,
|
||||
context: PullRequestDraftContext
|
||||
): GeneratePullRequestFieldsResult {
|
||||
if (!result.success) {
|
||||
return result
|
||||
}
|
||||
try {
|
||||
return {
|
||||
success: true,
|
||||
fields: parseGeneratedPullRequestFields(result.rawOutput, context),
|
||||
agentLabel: result.agentLabel
|
||||
}
|
||||
} catch {
|
||||
return { success: false, error: 'Generated pull request details could not be parsed.' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function generatePullRequestFieldsFromContext(
|
||||
context: PullRequestDraftContext,
|
||||
params: GenerateCommitMessageParams,
|
||||
target: CommitMessageGenerationTarget
|
||||
): Promise<GeneratePullRequestFieldsResult> {
|
||||
const prompt = buildPullRequestFieldsPrompt(context, params.customPrompt ?? '')
|
||||
const planned = planCommitMessageGeneration(params, prompt)
|
||||
if (!planned.ok) {
|
||||
return { success: false, error: planned.error }
|
||||
}
|
||||
|
||||
const internalResult =
|
||||
target.kind === 'remote'
|
||||
? await runRemotePlan(planned.plan, target)
|
||||
: await runLocalPlan(planned.plan, target.cwd, target.env, 'details', 'pull-request-fields')
|
||||
return formatPullRequestFieldsGenerationResult(internalResult, context)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
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 ''
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPullRequestDraftContext(
|
||||
execGit: GitExec,
|
||||
input: PullRequestContextInput
|
||||
): Promise<PullRequestDraftContext | null> {
|
||||
const base = input.base.trim()
|
||||
if (!base || base.startsWith('-')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [branch, mergeBase] = await Promise.all([
|
||||
safeExec(execGit, ['branch', '--show-current']),
|
||||
safeExec(execGit, ['merge-base', base, '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,
|
||||
currentTitle: input.currentTitle,
|
||||
currentBody: input.currentBody,
|
||||
currentDraft: input.currentDraft,
|
||||
commitSummary,
|
||||
changeSummary,
|
||||
patch
|
||||
}
|
||||
}
|
||||
|
|
@ -1431,6 +1431,25 @@ export type PreloadApi = {
|
|||
worktreePath: string
|
||||
connectionId?: string
|
||||
}) => Promise<void>
|
||||
generatePullRequestFields: (args: {
|
||||
worktreePath: string
|
||||
base: string
|
||||
title: string
|
||||
body: string
|
||||
draft: boolean
|
||||
connectionId?: string
|
||||
}) => Promise<
|
||||
| {
|
||||
success: true
|
||||
fields: { base: string; title: string; body: string; draft: boolean }
|
||||
agentLabel?: string
|
||||
}
|
||||
| { success: false; error: string; canceled?: boolean }
|
||||
>
|
||||
cancelGeneratePullRequestFields: (args: {
|
||||
worktreePath: string
|
||||
connectionId?: string
|
||||
}) => Promise<void>
|
||||
stage: (args: {
|
||||
worktreePath: string
|
||||
filePath: string
|
||||
|
|
|
|||
|
|
@ -1972,6 +1972,18 @@ const api = {
|
|||
worktreePath: string
|
||||
connectionId?: string
|
||||
}): Promise<void> => ipcRenderer.invoke('git:cancelGenerateCommitMessage', args),
|
||||
generatePullRequestFields: (args: {
|
||||
worktreePath: string
|
||||
base: string
|
||||
title: string
|
||||
body: string
|
||||
draft: boolean
|
||||
connectionId?: string
|
||||
}): Promise<unknown> => ipcRenderer.invoke('git:generatePullRequestFields', args),
|
||||
cancelGeneratePullRequestFields: (args: {
|
||||
worktreePath: string
|
||||
connectionId?: string
|
||||
}): Promise<void> => ipcRenderer.invoke('git:cancelGeneratePullRequestFields', args),
|
||||
stage: (args: {
|
||||
worktreePath: string
|
||||
filePath: string
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@ export default function ChecksPanel(): React.JSX.Element {
|
|||
// differs from the PR's head ref) resolve via the number-based fallback.
|
||||
const linkedPR = activeWorktree?.linkedPR ?? null
|
||||
const linkedGitLabMR = activeWorktree?.linkedGitLabMR ?? null
|
||||
const activeWorktreePath = activeWorktree?.path ?? null
|
||||
const stateRequestKey = repo && branch ? checksPanelAsyncResultKey(repo.id, branch, prNumber) : ''
|
||||
asyncResultKeyRef.current = stateRequestKey
|
||||
|
||||
|
|
@ -160,6 +161,7 @@ export default function ChecksPanel(): React.JSX.Element {
|
|||
let stale = false
|
||||
void getHostedReviewCreationEligibility({
|
||||
repoPath: repo.path,
|
||||
...(activeWorktreePath ? { worktreePath: activeWorktreePath } : {}),
|
||||
branch,
|
||||
base: repo.worktreeBaseRef ?? null,
|
||||
hasUncommittedChanges,
|
||||
|
|
@ -182,6 +184,7 @@ export default function ChecksPanel(): React.JSX.Element {
|
|||
stale = true
|
||||
}
|
||||
}, [
|
||||
activeWorktreePath,
|
||||
branch,
|
||||
getHostedReviewCreationEligibility,
|
||||
hasUncommittedChanges,
|
||||
|
|
@ -792,6 +795,8 @@ export default function ChecksPanel(): React.JSX.Element {
|
|||
open={createPrDialogOpen}
|
||||
repoId={repo.id}
|
||||
repoPath={repo.path}
|
||||
worktreeId={activeWorktreeId}
|
||||
worktreePath={activeWorktreePath ?? repo.path}
|
||||
branch={branch}
|
||||
eligibility={hostedReviewCreation}
|
||||
pushBeforeCreate={createPrPushFirst}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'
|
||||
import { Check, ChevronsUpDown, Loader2, Sparkles, Square, RefreshCw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
|
|
@ -12,25 +12,22 @@ import {
|
|||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
getRuntimeRepoBaseRefDefault,
|
||||
searchRuntimeRepoBaseRefs
|
||||
} from '@/runtime/runtime-repo-client'
|
||||
import type {
|
||||
CreateHostedReviewResult,
|
||||
HostedReviewCreationEligibility
|
||||
} from '../../../../shared/hosted-review'
|
||||
import {
|
||||
normalizeHostedReviewBaseRef,
|
||||
normalizeHostedReviewHeadRef
|
||||
} from '../../../../shared/hosted-review-refs'
|
||||
import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs'
|
||||
import { stripBaseRef, useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields'
|
||||
|
||||
type CreatePullRequestDialogProps = {
|
||||
open: boolean
|
||||
repoId: string
|
||||
repoPath: string
|
||||
worktreeId: string | null
|
||||
worktreePath: string
|
||||
branch: string
|
||||
eligibility: HostedReviewCreationEligibility | null
|
||||
pushBeforeCreate: boolean
|
||||
|
|
@ -39,10 +36,6 @@ type CreatePullRequestDialogProps = {
|
|||
onCreated: (result: { number: number; url: string }) => Promise<void>
|
||||
}
|
||||
|
||||
function stripBaseRef(ref: string): string {
|
||||
return normalizeHostedReviewBaseRef(ref)
|
||||
}
|
||||
|
||||
function formatCreateError(result: CreateHostedReviewResult, pushed: boolean): string {
|
||||
if (result.ok) {
|
||||
return ''
|
||||
|
|
@ -57,6 +50,8 @@ export function CreatePullRequestDialog({
|
|||
open,
|
||||
repoId,
|
||||
repoPath,
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
branch,
|
||||
eligibility,
|
||||
pushBeforeCreate,
|
||||
|
|
@ -67,93 +62,52 @@ export function CreatePullRequestDialog({
|
|||
const settings = useAppStore((s) => s.settings)
|
||||
const createHostedReview = useAppStore((s) => s.createHostedReview)
|
||||
const submitInFlightRef = useRef(false)
|
||||
const initializedFromEligibilityRef = useRef<string | null>(null)
|
||||
const [base, setBase] = useState('')
|
||||
const [title, setTitle] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [draft, setDraft] = useState(false)
|
||||
const [baseQuery, setBaseQuery] = useState('')
|
||||
const [baseResults, setBaseResults] = useState<string[]>([])
|
||||
const [baseSearchError, setBaseSearchError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const {
|
||||
aiGenerationEnabled,
|
||||
base,
|
||||
setBase,
|
||||
title,
|
||||
setTitle,
|
||||
body,
|
||||
setBody,
|
||||
draft,
|
||||
setDraft,
|
||||
baseQuery,
|
||||
setBaseQuery,
|
||||
baseResults,
|
||||
setBaseResults,
|
||||
baseSearchError,
|
||||
generating,
|
||||
generateError,
|
||||
generateDisabled,
|
||||
generateDisabledReason,
|
||||
handleGenerate,
|
||||
handleCancelGenerate
|
||||
} = useCreatePullRequestDialogFields({
|
||||
open,
|
||||
repoId,
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
branch,
|
||||
eligibility,
|
||||
settings,
|
||||
submitting
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
submitInFlightRef.current = false
|
||||
initializedFromEligibilityRef.current = null
|
||||
setSubmitting(false)
|
||||
setError(null)
|
||||
if (open) {
|
||||
return
|
||||
}
|
||||
if (!eligibility) {
|
||||
return
|
||||
}
|
||||
const initializationKey = `${repoId}:${branch}`
|
||||
if (initializedFromEligibilityRef.current === initializationKey) {
|
||||
return
|
||||
}
|
||||
// Why: eligibility refreshes while the dialog is open; only seed fields
|
||||
// once per branch so late refreshes (including populated→null transitions
|
||||
// when a background eligibility fetch errors out) do not overwrite user edits.
|
||||
initializedFromEligibilityRef.current = initializationKey
|
||||
const initialBase = eligibility.defaultBaseRef ?? ''
|
||||
setBase(stripBaseRef(initialBase))
|
||||
setTitle(eligibility.title ?? '')
|
||||
setBody(eligibility.body ?? '')
|
||||
setDraft(false)
|
||||
setBaseQuery('')
|
||||
setBaseResults([])
|
||||
setBaseSearchError(null)
|
||||
}, [branch, eligibility, open, repoId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || base) {
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
void getRuntimeRepoBaseRefDefault(settings, repoId)
|
||||
.then((result) => {
|
||||
if (!stale && result.defaultBaseRef) {
|
||||
setBase(stripBaseRef(result.defaultBaseRef))
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [base, open, repoId, settings])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || baseQuery.trim().length < 2) {
|
||||
setBaseResults([])
|
||||
setBaseSearchError(null)
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
const timer = window.setTimeout(() => {
|
||||
void searchRuntimeRepoBaseRefs(settings, repoId, baseQuery.trim(), 20)
|
||||
.then((results) => {
|
||||
if (!stale) {
|
||||
setBaseResults(results.map(stripBaseRef))
|
||||
setBaseSearchError(null)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!stale) {
|
||||
setBaseResults([])
|
||||
setBaseSearchError('Branch discovery failed.')
|
||||
}
|
||||
})
|
||||
}, 200)
|
||||
return () => {
|
||||
stale = true
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [baseQuery, open, repoId, settings])
|
||||
submitInFlightRef.current = false
|
||||
setSubmitting(false)
|
||||
setError(null)
|
||||
}, [open])
|
||||
|
||||
const submitDisabled =
|
||||
submitting ||
|
||||
generating ||
|
||||
title.trim().length === 0 ||
|
||||
base.trim().length === 0 ||
|
||||
stripBaseRef(base).toLowerCase() === stripBaseRef(branch).toLowerCase()
|
||||
|
|
@ -181,7 +135,8 @@ export function CreatePullRequestDialog({
|
|||
head: normalizeHostedReviewHeadRef(branch),
|
||||
title: title.trim(),
|
||||
body,
|
||||
draft
|
||||
draft,
|
||||
worktreePath
|
||||
})
|
||||
if (result.ok) {
|
||||
toast.success(`Pull request #${result.number} created`, {
|
||||
|
|
@ -228,7 +183,8 @@ export function CreatePullRequestDialog({
|
|||
pushBeforeCreate,
|
||||
repoPath,
|
||||
submitDisabled,
|
||||
title
|
||||
title,
|
||||
worktreePath
|
||||
])
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
|
|
@ -247,7 +203,47 @@ export function CreatePullRequestDialog({
|
|||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Pull Request</DialogTitle>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 pr-8">
|
||||
<DialogTitle className="min-w-0 truncate">Create Pull Request</DialogTitle>
|
||||
{aiGenerationEnabled ? (
|
||||
<div className="shrink-0">
|
||||
{generating ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancelGenerate}
|
||||
title="Stop generating"
|
||||
aria-label="Stop generating pull request details"
|
||||
>
|
||||
<RefreshCw className="size-4 animate-spin" />
|
||||
Generating…
|
||||
<Square className="size-3 fill-current" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" sideOffset={6}>
|
||||
Generating PR details. Click to stop.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={generateDisabled}
|
||||
onClick={() => void handleGenerate()}
|
||||
title={generateDisabledReason ?? 'Generate pull request details with AI'}
|
||||
aria-label="Generate pull request details with AI"
|
||||
>
|
||||
<Sparkles className="size-4" />
|
||||
Generate with AI
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogDescription>
|
||||
Confirm the target branch and PR details before creating the hosted review.
|
||||
</DialogDescription>
|
||||
|
|
@ -343,6 +339,7 @@ export function CreatePullRequestDialog({
|
|||
Choose a different base branch before creating a pull request.
|
||||
</p>
|
||||
) : null}
|
||||
{generateError ? <p className="text-xs text-destructive">{generateError}</p> : null}
|
||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -700,6 +700,7 @@ function SourceControlInner(): React.JSX.Element {
|
|||
let stale = false
|
||||
void getHostedReviewCreationEligibility({
|
||||
repoPath: activeRepo.path,
|
||||
...(worktreePath ? { worktreePath } : {}),
|
||||
branch: branchName,
|
||||
base: effectiveBaseRef ?? null,
|
||||
hasUncommittedChanges: hasUncommittedEntries,
|
||||
|
|
@ -735,7 +736,8 @@ function SourceControlInner(): React.JSX.Element {
|
|||
linkedGitLabMR,
|
||||
remoteStatus?.ahead,
|
||||
remoteStatus?.behind,
|
||||
remoteStatus?.hasUpstream
|
||||
remoteStatus?.hasUpstream,
|
||||
worktreePath
|
||||
])
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
|
|
@ -1306,7 +1308,9 @@ function SourceControlInner(): React.JSX.Element {
|
|||
prState: hostedReview?.state ?? null,
|
||||
isPRStateLoading: isHostedReviewStateLoading,
|
||||
inFlightRemoteOpKind,
|
||||
hostedReviewCreation
|
||||
hostedReviewCreation,
|
||||
branchCommitsAhead:
|
||||
branchSummary?.status === 'ready' ? (branchSummary.commitsAhead ?? 0) : undefined
|
||||
}),
|
||||
[
|
||||
commitMessage,
|
||||
|
|
@ -1319,6 +1323,8 @@ function SourceControlInner(): React.JSX.Element {
|
|||
hostedReviewCreation,
|
||||
isHostedReviewStateLoading,
|
||||
hostedReview?.state,
|
||||
branchSummary?.commitsAhead,
|
||||
branchSummary?.status,
|
||||
remoteStatus,
|
||||
unresolvedConflicts.length
|
||||
]
|
||||
|
|
@ -1338,7 +1344,9 @@ function SourceControlInner(): React.JSX.Element {
|
|||
prState: hostedReview?.state ?? null,
|
||||
isPRStateLoading: isHostedReviewStateLoading,
|
||||
inFlightRemoteOpKind,
|
||||
hostedReviewCreation
|
||||
hostedReviewCreation,
|
||||
branchCommitsAhead:
|
||||
branchSummary?.status === 'ready' ? (branchSummary.commitsAhead ?? 0) : undefined
|
||||
}),
|
||||
[
|
||||
commitMessage,
|
||||
|
|
@ -1351,6 +1359,8 @@ function SourceControlInner(): React.JSX.Element {
|
|||
hostedReviewCreation,
|
||||
isHostedReviewStateLoading,
|
||||
hostedReview?.state,
|
||||
branchSummary?.commitsAhead,
|
||||
branchSummary?.status,
|
||||
remoteStatus,
|
||||
unresolvedConflicts.length
|
||||
]
|
||||
|
|
@ -2395,6 +2405,8 @@ function SourceControlInner(): React.JSX.Element {
|
|||
open={createPrDialogOpen}
|
||||
repoId={activeRepo.id}
|
||||
repoPath={activeRepo.path}
|
||||
worktreeId={currentWorktreeId}
|
||||
worktreePath={activeWorktree.path}
|
||||
branch={branchName}
|
||||
eligibility={hostedReviewCreation}
|
||||
pushBeforeCreate={createPrPushFirst}
|
||||
|
|
|
|||
|
|
@ -190,6 +190,21 @@ describe('resolveDropdownItems', () => {
|
|||
expect(byKind.publish.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('does not show Publish Branch when an unpublished branch has no commits ahead', () => {
|
||||
const items = resolveDropdownItems(
|
||||
inputs({
|
||||
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
|
||||
branchCommitsAhead: 0
|
||||
})
|
||||
)
|
||||
const byKind = Object.fromEntries(
|
||||
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
|
||||
)
|
||||
expect(byKind.publish.label).toBe('No Branch Changes')
|
||||
expect(byKind.publish.title).toBe('Nothing to publish')
|
||||
expect(byKind.publish.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('does not mention Publish Branch when the linked PR is already merged', () => {
|
||||
const items = resolveDropdownItems(
|
||||
inputs({
|
||||
|
|
|
|||
|
|
@ -65,7 +65,8 @@ export function resolveDropdownItems(inputs: PrimaryActionInputs): DropdownEntry
|
|||
upstreamStatus,
|
||||
prState,
|
||||
isPRStateLoading,
|
||||
hostedReviewCreation
|
||||
hostedReviewCreation,
|
||||
branchCommitsAhead
|
||||
} = inputs
|
||||
|
||||
const hasStaged = stagedCount > 0
|
||||
|
|
@ -80,6 +81,7 @@ export function resolveDropdownItems(inputs: PrimaryActionInputs): DropdownEntry
|
|||
const hasUpstream = upstreamStatus?.hasUpstream ?? false
|
||||
const publishBlockedByMergedPR = !hasUpstream && prState === 'merged'
|
||||
const publishBlockedByPRLoading = !hasUpstream && !!isPRStateLoading
|
||||
const publishBlockedByNoBranchCommits = !hasUpstream && branchCommitsAhead === 0
|
||||
const ahead = upstreamStatus?.ahead ?? 0
|
||||
const behind = upstreamStatus?.behind ?? 0
|
||||
|
||||
|
|
@ -229,22 +231,30 @@ export function resolveDropdownItems(inputs: PrimaryActionInputs): DropdownEntry
|
|||
|
||||
const publishItem: DropdownItem = {
|
||||
kind: 'publish',
|
||||
label: publishBlockedByMergedPR || publishBlockedByPRLoading ? 'PR Status' : 'Publish Branch',
|
||||
label:
|
||||
publishBlockedByMergedPR || publishBlockedByPRLoading
|
||||
? 'PR Status'
|
||||
: publishBlockedByNoBranchCommits
|
||||
? 'No Branch Changes'
|
||||
: 'Publish Branch',
|
||||
title: upstreamLoading
|
||||
? 'Checking branch status…'
|
||||
: publishBlockedByPRLoading
|
||||
? 'Checking PR status…'
|
||||
: publishBlockedByMergedPR
|
||||
? 'PR is already merged'
|
||||
: hasUpstream
|
||||
? 'Branch is already published'
|
||||
: 'Publish this branch to origin',
|
||||
: publishBlockedByNoBranchCommits
|
||||
? 'Nothing to publish'
|
||||
: hasUpstream
|
||||
? 'Branch is already published'
|
||||
: 'Publish this branch to origin',
|
||||
disabled:
|
||||
globalBusy ||
|
||||
upstreamLoading ||
|
||||
hasUpstream ||
|
||||
publishBlockedByPRLoading ||
|
||||
publishBlockedByMergedPR
|
||||
publishBlockedByMergedPR ||
|
||||
publishBlockedByNoBranchCommits
|
||||
}
|
||||
|
||||
const createBlockedHint = (() => {
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ describe('resolvePrimaryAction', () => {
|
|||
|
||||
it('returns Publish Branch on a clean tree when no upstream exists', () => {
|
||||
const result = resolvePrimaryAction(
|
||||
inputs({ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } })
|
||||
inputs({ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }, branchCommitsAhead: 1 })
|
||||
)
|
||||
expect(result).toEqual({
|
||||
kind: 'publish',
|
||||
|
|
@ -184,6 +184,18 @@ describe('resolvePrimaryAction', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('does not offer Publish Branch when an unpublished branch has no commits ahead', () => {
|
||||
const result = resolvePrimaryAction(
|
||||
inputs({ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }, branchCommitsAhead: 0 })
|
||||
)
|
||||
expect(result).toEqual({
|
||||
kind: 'commit',
|
||||
label: 'Commit',
|
||||
title: 'Nothing to commit. Branch has no changes to publish.',
|
||||
disabled: true
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ prState: 'merged' as const }, 'Nothing to commit. PR is already merged.'],
|
||||
[{ isPRStateLoading: true }, 'Checking PR status…']
|
||||
|
|
|
|||
|
|
@ -56,6 +56,10 @@ export type PrimaryActionInputs = {
|
|||
// stale label that no longer matches what the slice is doing.
|
||||
inFlightRemoteOpKind?: RemoteOpKind | null
|
||||
hostedReviewCreation?: HostedReviewCreationEligibility | null
|
||||
// Why: an unpublished branch is only worth publishing when it actually
|
||||
// carries commits beyond the compare base. Undefined preserves the old
|
||||
// behavior while the branch compare request is still unavailable/loading.
|
||||
branchCommitsAhead?: number
|
||||
}
|
||||
|
||||
const PRIMARY_LABEL_BY_KIND: Record<Exclude<PrimaryActionKind, 'commit'>, string> = {
|
||||
|
|
@ -112,7 +116,8 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction
|
|||
prState,
|
||||
isPRStateLoading,
|
||||
inFlightRemoteOpKind,
|
||||
hostedReviewCreation
|
||||
hostedReviewCreation,
|
||||
branchCommitsAhead
|
||||
} = inputs
|
||||
|
||||
// 1. Commit in flight — lock the primary no matter what else is true.
|
||||
|
|
@ -245,6 +250,15 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction
|
|||
}
|
||||
|
||||
if (!upstreamStatus.hasUpstream) {
|
||||
if (branchCommitsAhead === 0) {
|
||||
return {
|
||||
kind: 'commit',
|
||||
label: 'Commit',
|
||||
title: 'Nothing to commit. Branch has no changes to publish.',
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
if (isPRStateLoading) {
|
||||
return {
|
||||
kind: 'commit',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,299 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { useAppStore, type AppState } from '@/store'
|
||||
import {
|
||||
cancelRuntimeGeneratePullRequestFields,
|
||||
generateRuntimePullRequestFields
|
||||
} from '@/runtime/runtime-git-client'
|
||||
import {
|
||||
getRuntimeRepoBaseRefDefault,
|
||||
searchRuntimeRepoBaseRefs
|
||||
} from '@/runtime/runtime-repo-client'
|
||||
import {
|
||||
isCustomAgentId,
|
||||
resolveCommitMessageAgentChoice
|
||||
} from '../../../../shared/commit-message-agent-spec'
|
||||
import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
|
||||
import { normalizeHostedReviewBaseRef } from '../../../../shared/hosted-review-refs'
|
||||
|
||||
type UseCreatePullRequestDialogFieldsOptions = {
|
||||
open: boolean
|
||||
repoId: string
|
||||
worktreeId: string | null
|
||||
worktreePath: string
|
||||
branch: string
|
||||
eligibility: HostedReviewCreationEligibility | null
|
||||
settings: AppState['settings']
|
||||
submitting: boolean
|
||||
}
|
||||
|
||||
type GenerationSeed = {
|
||||
requestId: number
|
||||
base: string
|
||||
title: string
|
||||
body: string
|
||||
draft: boolean
|
||||
}
|
||||
|
||||
export function stripBaseRef(ref: string): string {
|
||||
return normalizeHostedReviewBaseRef(ref)
|
||||
}
|
||||
|
||||
export function useCreatePullRequestDialogFields({
|
||||
open,
|
||||
repoId,
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
branch,
|
||||
eligibility,
|
||||
settings,
|
||||
submitting
|
||||
}: UseCreatePullRequestDialogFieldsOptions) {
|
||||
const commitMessageAi = settings?.commitMessageAi
|
||||
const effectiveCommitMessageAgentId = resolveCommitMessageAgentChoice(
|
||||
commitMessageAi?.agentId,
|
||||
settings?.defaultTuiAgent
|
||||
)
|
||||
const initializedFromEligibilityRef = useRef<string | null>(null)
|
||||
const generateInFlightRef = useRef(false)
|
||||
const generationRequestIdRef = useRef(0)
|
||||
const generationSeedRef = useRef<GenerationSeed | null>(null)
|
||||
const latestFieldsRef = useRef({
|
||||
base: '',
|
||||
title: '',
|
||||
body: '',
|
||||
draft: false
|
||||
})
|
||||
const [base, setBase] = useState('')
|
||||
const [title, setTitle] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [draft, setDraft] = useState(false)
|
||||
const [baseQuery, setBaseQuery] = useState('')
|
||||
const [baseResults, setBaseResults] = useState<string[]>([])
|
||||
const [baseSearchError, setBaseSearchError] = useState<string | null>(null)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [generateError, setGenerateError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
latestFieldsRef.current = { base, title, body, draft }
|
||||
}, [base, body, draft, title])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
generationRequestIdRef.current += 1
|
||||
if (generateInFlightRef.current && worktreePath) {
|
||||
const connectionId = getConnectionId(worktreeId) ?? undefined
|
||||
void cancelRuntimeGeneratePullRequestFields({
|
||||
settings,
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
connectionId
|
||||
})
|
||||
}
|
||||
generateInFlightRef.current = false
|
||||
generationSeedRef.current = null
|
||||
initializedFromEligibilityRef.current = null
|
||||
setGenerating(false)
|
||||
setGenerateError(null)
|
||||
return
|
||||
}
|
||||
if (!eligibility) {
|
||||
return
|
||||
}
|
||||
const initializationKey = `${repoId}:${branch}`
|
||||
if (initializedFromEligibilityRef.current === initializationKey) {
|
||||
return
|
||||
}
|
||||
// Why: eligibility refreshes while the dialog is open; only seed fields
|
||||
// once per branch so late refreshes do not overwrite user edits.
|
||||
initializedFromEligibilityRef.current = initializationKey
|
||||
const initialBase = eligibility.defaultBaseRef ?? ''
|
||||
setBase(stripBaseRef(initialBase))
|
||||
setTitle(eligibility.title ?? '')
|
||||
setBody(eligibility.body ?? '')
|
||||
setDraft(false)
|
||||
setBaseQuery('')
|
||||
setBaseResults([])
|
||||
setBaseSearchError(null)
|
||||
setGenerateError(null)
|
||||
}, [branch, eligibility, open, repoId, settings, worktreeId, worktreePath])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || base) {
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
void getRuntimeRepoBaseRefDefault(settings, repoId)
|
||||
.then((result) => {
|
||||
if (!stale && result.defaultBaseRef) {
|
||||
setBase(stripBaseRef(result.defaultBaseRef))
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [base, open, repoId, settings])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || baseQuery.trim().length < 2) {
|
||||
setBaseResults([])
|
||||
setBaseSearchError(null)
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
const timer = window.setTimeout(() => {
|
||||
void searchRuntimeRepoBaseRefs(settings, repoId, baseQuery.trim(), 20)
|
||||
.then((results) => {
|
||||
if (!stale) {
|
||||
setBaseResults(results.map(stripBaseRef))
|
||||
setBaseSearchError(null)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!stale) {
|
||||
setBaseResults([])
|
||||
setBaseSearchError('Branch discovery failed.')
|
||||
}
|
||||
})
|
||||
}, 200)
|
||||
return () => {
|
||||
stale = true
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [baseQuery, open, repoId, settings])
|
||||
|
||||
let generateDisabledReason: string | undefined
|
||||
if (submitting) {
|
||||
generateDisabledReason = 'Create PR in progress...'
|
||||
} else if (!commitMessageAi?.enabled) {
|
||||
generateDisabledReason = 'Enable AI commit messages in Settings -> Git.'
|
||||
} else if (!effectiveCommitMessageAgentId) {
|
||||
generateDisabledReason = 'Pick an agent in Settings -> Git -> AI Commit Messages.'
|
||||
} else if (isCustomAgentId(effectiveCommitMessageAgentId)) {
|
||||
const command = commitMessageAi.customAgentCommand?.trim() ?? ''
|
||||
if (!command) {
|
||||
generateDisabledReason =
|
||||
'Custom command is empty. Add one in Settings -> Git -> AI Commit Messages.'
|
||||
}
|
||||
} else if (!base.trim()) {
|
||||
generateDisabledReason = 'Choose a base branch before generating.'
|
||||
}
|
||||
const generateDisabled = !generating && Boolean(generateDisabledReason)
|
||||
|
||||
const handleGenerate = useCallback(async (): Promise<void> => {
|
||||
if (!worktreePath || !base.trim() || generateInFlightRef.current || generateDisabled) {
|
||||
return
|
||||
}
|
||||
const requestId = generationRequestIdRef.current + 1
|
||||
generationRequestIdRef.current = requestId
|
||||
const seed = { requestId, base, title, body, draft }
|
||||
generationSeedRef.current = seed
|
||||
generateInFlightRef.current = true
|
||||
setGenerating(true)
|
||||
setGenerateError(null)
|
||||
try {
|
||||
const connectionId = getConnectionId(worktreeId) ?? undefined
|
||||
const result = await generateRuntimePullRequestFields(
|
||||
{
|
||||
settings: useAppStore.getState().settings,
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
connectionId
|
||||
},
|
||||
{
|
||||
base: stripBaseRef(base.trim()),
|
||||
title,
|
||||
body,
|
||||
draft
|
||||
}
|
||||
)
|
||||
if (generationRequestIdRef.current !== requestId) {
|
||||
return
|
||||
}
|
||||
if (!result.success) {
|
||||
if (result.canceled) {
|
||||
setGenerateError(null)
|
||||
return
|
||||
}
|
||||
setGenerateError(result.error)
|
||||
return
|
||||
}
|
||||
|
||||
const currentSeed = generationSeedRef.current
|
||||
const latestFields = latestFieldsRef.current
|
||||
if (
|
||||
!currentSeed ||
|
||||
currentSeed.requestId !== requestId ||
|
||||
currentSeed.base !== latestFields.base ||
|
||||
currentSeed.title !== latestFields.title ||
|
||||
currentSeed.body !== latestFields.body ||
|
||||
currentSeed.draft !== latestFields.draft
|
||||
) {
|
||||
setGenerateError('Fields changed while generating. Run generate again for a fresh draft.')
|
||||
return
|
||||
}
|
||||
setBase(stripBaseRef(result.fields.base))
|
||||
setBaseQuery('')
|
||||
setBaseResults([])
|
||||
setTitle(result.fields.title)
|
||||
setBody(result.fields.body)
|
||||
setDraft(result.fields.draft)
|
||||
setGenerateError(null)
|
||||
} catch (error) {
|
||||
if (generationRequestIdRef.current !== requestId) {
|
||||
return
|
||||
}
|
||||
setGenerateError(
|
||||
error instanceof Error ? error.message : 'Failed to generate pull request details'
|
||||
)
|
||||
} finally {
|
||||
if (generationRequestIdRef.current === requestId) {
|
||||
generateInFlightRef.current = false
|
||||
generationSeedRef.current = null
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
}, [base, body, draft, generateDisabled, title, worktreeId, worktreePath])
|
||||
|
||||
const handleCancelGenerate = useCallback((): void => {
|
||||
if (!worktreePath || !generateInFlightRef.current) {
|
||||
return
|
||||
}
|
||||
generationRequestIdRef.current += 1
|
||||
generateInFlightRef.current = false
|
||||
generationSeedRef.current = null
|
||||
setGenerating(false)
|
||||
setGenerateError(null)
|
||||
const connectionId = getConnectionId(worktreeId) ?? undefined
|
||||
void cancelRuntimeGeneratePullRequestFields({
|
||||
settings: useAppStore.getState().settings,
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
connectionId
|
||||
})
|
||||
}, [worktreeId, worktreePath])
|
||||
|
||||
return {
|
||||
aiGenerationEnabled: commitMessageAi?.enabled === true,
|
||||
base,
|
||||
setBase,
|
||||
title,
|
||||
setTitle,
|
||||
body,
|
||||
setBody,
|
||||
draft,
|
||||
setDraft,
|
||||
baseQuery,
|
||||
setBaseQuery,
|
||||
baseResults,
|
||||
setBaseResults,
|
||||
baseSearchError,
|
||||
generating,
|
||||
generateError,
|
||||
generateDisabled,
|
||||
generateDisabledReason,
|
||||
handleGenerate,
|
||||
handleCancelGenerate
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,14 @@ export type RuntimeGenerateCommitMessageResult =
|
|||
| { success: true; message: string; agentLabel?: string }
|
||||
| { success: false; error: string; canceled?: boolean }
|
||||
|
||||
export type RuntimeGeneratePullRequestFieldsResult =
|
||||
| {
|
||||
success: true
|
||||
fields: { base: string; title: string; body: string; draft: boolean }
|
||||
agentLabel?: string
|
||||
}
|
||||
| { success: false; error: string; canceled?: boolean }
|
||||
|
||||
type RuntimeGitSettings = Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> &
|
||||
Partial<Pick<GlobalSettings, 'commitMessageAi' | 'agentCmdOverrides' | 'enableGitHubAttribution'>>
|
||||
|
||||
|
|
@ -356,6 +364,49 @@ export async function cancelRuntimeGenerateCommitMessage(
|
|||
)
|
||||
}
|
||||
|
||||
export async function generateRuntimePullRequestFields(
|
||||
context: RuntimeGitContext,
|
||||
input: { base: string; title: string; body: string; draft: boolean }
|
||||
): Promise<RuntimeGeneratePullRequestFieldsResult> {
|
||||
const target = getActiveRuntimeTarget(context.settings)
|
||||
if (target.kind === 'local' || !context.worktreeId) {
|
||||
return window.api.git.generatePullRequestFields({
|
||||
worktreePath: context.worktreePath,
|
||||
connectionId: context.connectionId,
|
||||
...input
|
||||
}) as Promise<RuntimeGeneratePullRequestFieldsResult>
|
||||
}
|
||||
return callRuntimeRpc<RuntimeGeneratePullRequestFieldsResult>(
|
||||
target,
|
||||
'git.generatePullRequestFields',
|
||||
{
|
||||
worktree: context.worktreeId,
|
||||
...input,
|
||||
...getRuntimeCommitMessageSettings(context.settings)
|
||||
},
|
||||
{ timeoutMs: 75_000 }
|
||||
)
|
||||
}
|
||||
|
||||
export async function cancelRuntimeGeneratePullRequestFields(
|
||||
context: RuntimeGitContext
|
||||
): Promise<void> {
|
||||
const target = getActiveRuntimeTarget(context.settings)
|
||||
if (target.kind === 'local' || !context.worktreeId) {
|
||||
await window.api.git.cancelGeneratePullRequestFields({
|
||||
worktreePath: context.worktreePath,
|
||||
connectionId: context.connectionId
|
||||
})
|
||||
return
|
||||
}
|
||||
await callRuntimeRpc(
|
||||
target,
|
||||
'git.cancelGeneratePullRequestFields',
|
||||
{ worktree: context.worktreeId },
|
||||
{ timeoutMs: 5_000 }
|
||||
)
|
||||
}
|
||||
|
||||
export async function stageRuntimeGitPath(
|
||||
context: RuntimeGitContext,
|
||||
filePath: string
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { create } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
import { createHostedReviewSlice, getHostedReviewCacheKey } from './hosted-review'
|
||||
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
|
||||
|
||||
const runtimeRpc = vi.hoisted(() => ({
|
||||
callRuntimeRpc: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({
|
||||
callRuntimeRpc: runtimeRpc.callRuntimeRpc,
|
||||
getActiveRuntimeTarget: (
|
||||
settings: { activeRuntimeEnvironmentId?: string | null } | null | undefined
|
||||
) => {
|
||||
const environmentId = settings?.activeRuntimeEnvironmentId?.trim()
|
||||
return environmentId ? { kind: 'environment', environmentId } : { kind: 'local' }
|
||||
}
|
||||
}))
|
||||
|
||||
const mockApi = {
|
||||
hostedReview: {
|
||||
forBranch: vi.fn(),
|
||||
getCreationEligibility: vi.fn(),
|
||||
create: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.window = { api: mockApi } as never
|
||||
|
||||
function makeStore(settings: AppState['settings'] = null) {
|
||||
return create<
|
||||
Pick<
|
||||
AppState,
|
||||
| 'hostedReviewCache'
|
||||
| 'fetchHostedReviewForBranch'
|
||||
| 'getHostedReviewCreationEligibility'
|
||||
| 'createHostedReview'
|
||||
| 'settings'
|
||||
| 'repos'
|
||||
>
|
||||
>()((...args) => ({
|
||||
settings,
|
||||
repos: [{ id: 'repo-1', path: '/repo', connectionId: null } as AppState['repos'][number]],
|
||||
...createHostedReviewSlice(...(args as Parameters<typeof createHostedReviewSlice>))
|
||||
}))
|
||||
}
|
||||
|
||||
const review: HostedReviewInfo = {
|
||||
provider: 'gitlab',
|
||||
number: 5,
|
||||
title: 'Shared MR status',
|
||||
state: 'open',
|
||||
url: 'https://gitlab.com/g/p/-/merge_requests/5',
|
||||
status: 'success',
|
||||
updatedAt: '2026-05-10T00:00:00.000Z',
|
||||
mergeable: 'MERGEABLE'
|
||||
}
|
||||
|
||||
describe('hosted review cache revalidation', () => {
|
||||
beforeEach(() => {
|
||||
mockApi.hostedReview.forBranch.mockReset()
|
||||
mockApi.hostedReview.getCreationEligibility.mockReset()
|
||||
mockApi.hostedReview.create.mockReset()
|
||||
runtimeRpc.callRuntimeRpc.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('dedupes repeated linked PR retries while a stronger lookup is in flight', async () => {
|
||||
let resolveLinkedLookup: (value: typeof review) => void = () => {}
|
||||
const linkedLookup = new Promise<typeof review>((resolve) => {
|
||||
resolveLinkedLookup = resolve
|
||||
})
|
||||
mockApi.hostedReview.forBranch.mockResolvedValueOnce(null).mockReturnValueOnce(linkedLookup)
|
||||
const store = makeStore()
|
||||
|
||||
await expect(store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr')).resolves.toBe(
|
||||
null
|
||||
)
|
||||
|
||||
const firstLinkedFetch = store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', {
|
||||
linkedGitHubPR: 42
|
||||
})
|
||||
const secondLinkedFetch = store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', {
|
||||
linkedGitHubPR: 42
|
||||
})
|
||||
|
||||
expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2)
|
||||
resolveLinkedLookup(review)
|
||||
await expect(firstLinkedFetch).resolves.toEqual(review)
|
||||
await expect(secondLinkedFetch).resolves.toEqual(review)
|
||||
})
|
||||
|
||||
it('serves stale hosted review metadata while revalidating in the background', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
const updatedReview: HostedReviewInfo = {
|
||||
...review,
|
||||
title: 'Updated linked PR status',
|
||||
status: 'failure',
|
||||
updatedAt: '2026-05-10T00:01:01.000Z'
|
||||
}
|
||||
let resolveRefresh: (value: typeof updatedReview) => void = () => {}
|
||||
const refresh = new Promise<typeof updatedReview>((resolve) => {
|
||||
resolveRefresh = resolve
|
||||
})
|
||||
mockApi.hostedReview.forBranch
|
||||
.mockResolvedValueOnce(review)
|
||||
.mockReturnValueOnce(refresh as Promise<HostedReviewInfo>)
|
||||
const store = makeStore()
|
||||
|
||||
await expect(
|
||||
store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', {
|
||||
linkedGitHubPR: 42
|
||||
})
|
||||
).resolves.toEqual(review)
|
||||
vi.setSystemTime(60_001)
|
||||
await expect(
|
||||
store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', {
|
||||
linkedGitHubPR: 42,
|
||||
staleWhileRevalidate: true
|
||||
})
|
||||
).resolves.toEqual(review)
|
||||
await expect(
|
||||
store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', {
|
||||
linkedGitHubPR: 42,
|
||||
staleWhileRevalidate: true
|
||||
})
|
||||
).resolves.toEqual(review)
|
||||
|
||||
expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2)
|
||||
const cacheKey = getHostedReviewCacheKey('/repo', 'feature/pr')
|
||||
expect(store.getState().hostedReviewCache[cacheKey]?.data).toEqual(review)
|
||||
|
||||
resolveRefresh(updatedReview)
|
||||
await refresh
|
||||
await Promise.resolve()
|
||||
|
||||
expect(store.getState().hostedReviewCache[cacheKey]?.data).toEqual(updatedReview)
|
||||
})
|
||||
|
||||
it('does not serve stale metadata when a stronger linked PR hint changes the lookup', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
const linkedReview: HostedReviewInfo = {
|
||||
...review,
|
||||
provider: 'github',
|
||||
number: 42,
|
||||
title: 'Exact linked PR',
|
||||
url: 'https://github.com/acme/orca/pull/42'
|
||||
}
|
||||
mockApi.hostedReview.forBranch.mockResolvedValueOnce(review).mockResolvedValueOnce(linkedReview)
|
||||
const store = makeStore()
|
||||
|
||||
await expect(store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr')).resolves.toBe(
|
||||
review
|
||||
)
|
||||
vi.setSystemTime(60_001)
|
||||
await expect(
|
||||
store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', {
|
||||
linkedGitHubPR: 42,
|
||||
staleWhileRevalidate: true
|
||||
})
|
||||
).resolves.toEqual(linkedReview)
|
||||
|
||||
expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,11 +1,7 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { create } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
import {
|
||||
createHostedReviewSlice,
|
||||
getHostedReviewCacheKey,
|
||||
refreshHostedReviewCard
|
||||
} from './hosted-review'
|
||||
import { createHostedReviewSlice, refreshHostedReviewCard } from './hosted-review'
|
||||
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
|
||||
|
||||
const runtimeRpc = vi.hoisted(() => ({
|
||||
|
|
@ -24,19 +20,30 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({
|
|||
|
||||
const mockApi = {
|
||||
hostedReview: {
|
||||
forBranch: vi.fn()
|
||||
forBranch: vi.fn(),
|
||||
getCreationEligibility: vi.fn(),
|
||||
create: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.window = { api: mockApi } as never
|
||||
|
||||
function makeStore(settings: AppState['settings'] = null) {
|
||||
return create<Pick<AppState, 'hostedReviewCache' | 'fetchHostedReviewForBranch' | 'settings'>>()(
|
||||
(...args) => ({
|
||||
settings,
|
||||
...createHostedReviewSlice(...(args as Parameters<typeof createHostedReviewSlice>))
|
||||
})
|
||||
)
|
||||
return create<
|
||||
Pick<
|
||||
AppState,
|
||||
| 'hostedReviewCache'
|
||||
| 'fetchHostedReviewForBranch'
|
||||
| 'getHostedReviewCreationEligibility'
|
||||
| 'createHostedReview'
|
||||
| 'settings'
|
||||
| 'repos'
|
||||
>
|
||||
>()((...args) => ({
|
||||
settings,
|
||||
repos: [{ id: 'repo-1', path: '/repo', connectionId: null } as AppState['repos'][number]],
|
||||
...createHostedReviewSlice(...(args as Parameters<typeof createHostedReviewSlice>))
|
||||
}))
|
||||
}
|
||||
|
||||
const review: HostedReviewInfo = {
|
||||
|
|
@ -53,6 +60,8 @@ const review: HostedReviewInfo = {
|
|||
describe('hosted review slice', () => {
|
||||
beforeEach(() => {
|
||||
mockApi.hostedReview.forBranch.mockReset()
|
||||
mockApi.hostedReview.getCreationEligibility.mockReset()
|
||||
mockApi.hostedReview.create.mockReset()
|
||||
runtimeRpc.callRuntimeRpc.mockReset()
|
||||
})
|
||||
|
||||
|
|
@ -115,6 +124,100 @@ describe('hosted review slice', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('forwards the selected worktree path when creating a local pull request', async () => {
|
||||
mockApi.hostedReview.create.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
number: 12,
|
||||
url: 'https://github.com/acme/orca/pull/12'
|
||||
})
|
||||
const store = makeStore()
|
||||
|
||||
await expect(
|
||||
store.getState().createHostedReview('/repo', {
|
||||
provider: 'github',
|
||||
base: 'main',
|
||||
head: 'feature/create-pr',
|
||||
title: 'Create PR',
|
||||
worktreePath: '/worktrees/feature'
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true, number: 12 })
|
||||
|
||||
expect(mockApi.hostedReview.create).toHaveBeenCalledWith({
|
||||
repoPath: '/repo',
|
||||
connectionId: null,
|
||||
provider: 'github',
|
||||
base: 'main',
|
||||
head: 'feature/create-pr',
|
||||
title: 'Create PR',
|
||||
worktreePath: '/worktrees/feature'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the selected worktree selector for runtime pull request creation', async () => {
|
||||
runtimeRpc.callRuntimeRpc.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
number: 12,
|
||||
url: 'https://github.com/acme/orca/pull/12'
|
||||
})
|
||||
const store = makeStore({
|
||||
activeRuntimeEnvironmentId: 'env-win'
|
||||
} as AppState['settings'])
|
||||
|
||||
await store.getState().createHostedReview('/repo', {
|
||||
provider: 'github',
|
||||
base: 'main',
|
||||
head: 'feature/create-pr',
|
||||
title: 'Create PR',
|
||||
worktreePath: 'C:\\worktrees\\feature'
|
||||
})
|
||||
|
||||
expect(runtimeRpc.callRuntimeRpc).toHaveBeenCalledWith(
|
||||
{ kind: 'environment', environmentId: 'env-win' },
|
||||
'hostedReview.create',
|
||||
{
|
||||
repo: 'repo-1',
|
||||
worktree: 'path:C:\\worktrees\\feature',
|
||||
provider: 'github',
|
||||
base: 'main',
|
||||
head: 'feature/create-pr',
|
||||
title: 'Create PR'
|
||||
},
|
||||
{ timeoutMs: 60_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the selected worktree selector for runtime pull request creation eligibility', async () => {
|
||||
runtimeRpc.callRuntimeRpc.mockResolvedValueOnce({
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: true,
|
||||
blockedReason: null,
|
||||
nextAction: null
|
||||
})
|
||||
const store = makeStore({
|
||||
activeRuntimeEnvironmentId: 'env-win'
|
||||
} as AppState['settings'])
|
||||
|
||||
await store.getState().getHostedReviewCreationEligibility({
|
||||
repoPath: '/repo',
|
||||
worktreePath: 'C:\\worktrees\\feature',
|
||||
branch: 'feature/create-pr',
|
||||
base: 'main'
|
||||
})
|
||||
|
||||
expect(runtimeRpc.callRuntimeRpc).toHaveBeenCalledWith(
|
||||
{ kind: 'environment', environmentId: 'env-win' },
|
||||
'hostedReview.getCreationEligibility',
|
||||
{
|
||||
repo: 'repo-1',
|
||||
worktree: 'path:C:\\worktrees\\feature',
|
||||
branch: 'feature/create-pr',
|
||||
base: 'main'
|
||||
},
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('forces card refresh with repo-scoped identity and linked review ids', async () => {
|
||||
const fetchHostedReviewForBranch = vi.fn().mockResolvedValue(null)
|
||||
await refreshHostedReviewCard(fetchHostedReviewForBranch, {
|
||||
|
|
@ -187,104 +290,4 @@ describe('hosted review slice', () => {
|
|||
await expect(plainFetch).resolves.toBeNull()
|
||||
await expect(linkedFetch).resolves.toEqual(review)
|
||||
})
|
||||
|
||||
it('dedupes repeated linked PR retries while a stronger lookup is in flight', async () => {
|
||||
let resolveLinkedLookup: (value: typeof review) => void = () => {}
|
||||
const linkedLookup = new Promise<typeof review>((resolve) => {
|
||||
resolveLinkedLookup = resolve
|
||||
})
|
||||
mockApi.hostedReview.forBranch.mockResolvedValueOnce(null).mockReturnValueOnce(linkedLookup)
|
||||
const store = makeStore()
|
||||
|
||||
await expect(store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr')).resolves.toBe(
|
||||
null
|
||||
)
|
||||
|
||||
const firstLinkedFetch = store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', {
|
||||
linkedGitHubPR: 42
|
||||
})
|
||||
const secondLinkedFetch = store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', {
|
||||
linkedGitHubPR: 42
|
||||
})
|
||||
|
||||
expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2)
|
||||
resolveLinkedLookup(review)
|
||||
await expect(firstLinkedFetch).resolves.toEqual(review)
|
||||
await expect(secondLinkedFetch).resolves.toEqual(review)
|
||||
})
|
||||
|
||||
it('serves stale hosted review metadata while revalidating in the background', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
const updatedReview: HostedReviewInfo = {
|
||||
...review,
|
||||
title: 'Updated linked PR status',
|
||||
status: 'failure',
|
||||
updatedAt: '2026-05-10T00:01:01.000Z'
|
||||
}
|
||||
let resolveRefresh: (value: typeof updatedReview) => void = () => {}
|
||||
const refresh = new Promise<typeof updatedReview>((resolve) => {
|
||||
resolveRefresh = resolve
|
||||
})
|
||||
mockApi.hostedReview.forBranch
|
||||
.mockResolvedValueOnce(review)
|
||||
.mockReturnValueOnce(refresh as Promise<HostedReviewInfo>)
|
||||
const store = makeStore()
|
||||
|
||||
await expect(
|
||||
store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', {
|
||||
linkedGitHubPR: 42
|
||||
})
|
||||
).resolves.toEqual(review)
|
||||
vi.setSystemTime(60_001)
|
||||
await expect(
|
||||
store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', {
|
||||
linkedGitHubPR: 42,
|
||||
staleWhileRevalidate: true
|
||||
})
|
||||
).resolves.toEqual(review)
|
||||
await expect(
|
||||
store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', {
|
||||
linkedGitHubPR: 42,
|
||||
staleWhileRevalidate: true
|
||||
})
|
||||
).resolves.toEqual(review)
|
||||
|
||||
expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2)
|
||||
const cacheKey = getHostedReviewCacheKey('/repo', 'feature/pr')
|
||||
expect(store.getState().hostedReviewCache[cacheKey]?.data).toEqual(review)
|
||||
|
||||
resolveRefresh(updatedReview)
|
||||
await refresh
|
||||
await Promise.resolve()
|
||||
|
||||
expect(store.getState().hostedReviewCache[cacheKey]?.data).toEqual(updatedReview)
|
||||
})
|
||||
|
||||
it('does not serve stale metadata when a stronger linked PR hint changes the lookup', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
const linkedReview: HostedReviewInfo = {
|
||||
...review,
|
||||
provider: 'github',
|
||||
number: 42,
|
||||
title: 'Exact linked PR',
|
||||
url: 'https://github.com/acme/orca/pull/42'
|
||||
}
|
||||
mockApi.hostedReview.forBranch.mockResolvedValueOnce(review).mockResolvedValueOnce(linkedReview)
|
||||
const store = makeStore()
|
||||
|
||||
await expect(store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr')).resolves.toBe(
|
||||
review
|
||||
)
|
||||
vi.setSystemTime(60_001)
|
||||
await expect(
|
||||
store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', {
|
||||
linkedGitHubPR: 42,
|
||||
staleWhileRevalidate: true
|
||||
})
|
||||
).resolves.toEqual(linkedReview)
|
||||
|
||||
expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -134,12 +134,16 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie
|
|||
const target = getActiveRuntimeTarget(settings)
|
||||
if (target.kind === 'environment') {
|
||||
const repo = get().repos.find((candidate) => candidate.path === args.repoPath)
|
||||
const { repoPath: _repoPath, ...runtimeArgs } = args
|
||||
const { repoPath: _repoPath, worktreePath, ...runtimeArgs } = args
|
||||
void _repoPath
|
||||
return callRuntimeRpc<HostedReviewCreationEligibility>(
|
||||
target,
|
||||
'hostedReview.getCreationEligibility',
|
||||
{ repo: repo?.id ?? args.repoPath, ...runtimeArgs },
|
||||
{
|
||||
repo: repo?.id ?? args.repoPath,
|
||||
...(worktreePath ? { worktree: `path:${worktreePath}` } : {}),
|
||||
...runtimeArgs
|
||||
},
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
}
|
||||
|
|
@ -155,10 +159,15 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie
|
|||
const target = getActiveRuntimeTarget(settings)
|
||||
if (target.kind === 'environment') {
|
||||
const repo = get().repos.find((candidate) => candidate.path === repoPath)
|
||||
const { worktreePath, ...runtimeInput } = input
|
||||
return callRuntimeRpc<CreateHostedReviewResult>(
|
||||
target,
|
||||
'hostedReview.create',
|
||||
{ repo: repo?.id ?? repoPath, ...input },
|
||||
{
|
||||
repo: repo?.id ?? repoPath,
|
||||
...(worktreePath ? { worktree: `path:${worktreePath}` } : {}),
|
||||
...runtimeInput
|
||||
},
|
||||
{ timeoutMs: 60_000 }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -553,6 +553,11 @@ function createGitApi(): NonNullable<Partial<PreloadApi>['git']> {
|
|||
error: 'Commit message generation is unavailable in the web client.'
|
||||
}),
|
||||
cancelGenerateCommitMessage: () => Promise.resolve(),
|
||||
generatePullRequestFields: async () => ({
|
||||
success: false,
|
||||
error: 'Pull request detail generation is unavailable in the web client.'
|
||||
}),
|
||||
cancelGeneratePullRequestFields: () => Promise.resolve(),
|
||||
stage: async ({ worktreePath, filePath }) => mutateGitPath('git.stage', worktreePath, filePath),
|
||||
bulkStage: async ({ worktreePath, filePaths }) =>
|
||||
mutateGitPaths('git.bulkStage', worktreePath, filePaths),
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export type CreateHostedReviewInput = {
|
|||
title: string
|
||||
body?: string
|
||||
draft?: boolean
|
||||
worktreePath?: string
|
||||
}
|
||||
|
||||
export type CreateHostedReviewArgs = CreateHostedReviewInput & {
|
||||
|
|
@ -108,6 +109,7 @@ export type HostedReviewCreationEligibility = {
|
|||
|
||||
export type HostedReviewCreationEligibilityArgs = {
|
||||
repoPath: string
|
||||
worktreePath?: string
|
||||
connectionId?: string | null
|
||||
branch: string
|
||||
base?: string | null
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildPullRequestFieldsPrompt,
|
||||
parseGeneratedPullRequestFields,
|
||||
type PullRequestDraftContext
|
||||
} from './pull-request-generation'
|
||||
|
||||
const context: PullRequestDraftContext = {
|
||||
branch: 'feature/pr-details',
|
||||
base: 'main',
|
||||
currentTitle: 'Feature pr details',
|
||||
currentBody: '- Add form',
|
||||
currentDraft: false,
|
||||
commitSummary: '- feat: add generated PR details',
|
||||
changeSummary: 'M\tsrc/file.ts',
|
||||
patch: 'diff --git a/src/file.ts b/src/file.ts\n+export const value = true'
|
||||
}
|
||||
|
||||
describe('buildPullRequestFieldsPrompt', () => {
|
||||
it('asks for compact JSON and includes PR context', () => {
|
||||
const prompt = buildPullRequestFieldsPrompt(context, 'Use conventional PR titles.')
|
||||
|
||||
expect(prompt).toContain('Return ONLY compact JSON')
|
||||
expect(prompt).toContain('Head branch: feature/pr-details')
|
||||
expect(prompt).toContain('Current base: main')
|
||||
expect(prompt).toContain('Additional instructions from user:')
|
||||
expect(prompt).toContain('Use conventional PR titles.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseGeneratedPullRequestFields', () => {
|
||||
it('parses fenced JSON output', () => {
|
||||
const fields = parseGeneratedPullRequestFields(
|
||||
'```json\n{"base":"main","title":"fix: add details.","body":"Summary","draft":true}\n```',
|
||||
context
|
||||
)
|
||||
|
||||
expect(fields).toEqual({
|
||||
base: 'main',
|
||||
title: 'fix: add details',
|
||||
body: 'Summary',
|
||||
draft: true
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back for missing optional values', () => {
|
||||
const fields = parseGeneratedPullRequestFields('{"title":""}', context)
|
||||
|
||||
expect(fields).toEqual({
|
||||
base: 'main',
|
||||
title: 'Feature pr details',
|
||||
body: '- Add form',
|
||||
draft: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import { truncateDiffForPrompt } from './commit-message-prompt'
|
||||
|
||||
export type PullRequestDraftContext = {
|
||||
branch: string | null
|
||||
base: string
|
||||
currentTitle: string
|
||||
currentBody: string
|
||||
currentDraft: boolean
|
||||
commitSummary: string
|
||||
changeSummary: string
|
||||
patch: string
|
||||
}
|
||||
|
||||
export type GeneratedPullRequestFields = {
|
||||
base: string
|
||||
title: string
|
||||
body: string
|
||||
draft: boolean
|
||||
}
|
||||
|
||||
function limitSection(value: string, maxChars: number): string {
|
||||
if (value.length <= maxChars) {
|
||||
return value
|
||||
}
|
||||
const omitted = value.length - maxChars
|
||||
return `${value.slice(0, maxChars)}\n\n[truncated: ${omitted} characters omitted]`
|
||||
}
|
||||
|
||||
export function buildPullRequestFieldsPrompt(
|
||||
context: PullRequestDraftContext,
|
||||
customInstructions: string
|
||||
): string {
|
||||
const base = [
|
||||
'You are generating pull request details.',
|
||||
'Return ONLY compact JSON with this exact shape:',
|
||||
'{"base":"branch-name","title":"short title","body":"markdown description","draft":false}',
|
||||
'',
|
||||
'Rules:',
|
||||
'- Use the branch diff and commits below as source of truth.',
|
||||
'- Keep the base branch as the current base unless the diff clearly targets a different branch.',
|
||||
'- Title: concise, specific, no trailing period.',
|
||||
'- Body: useful Markdown summary for reviewers. Include testing notes only when evidence exists.',
|
||||
'- draft: true only when the changes clearly look unfinished, WIP, or unsafe to review.',
|
||||
'- Do not include labels, reviewers, code fences, prose, or any keys beyond base/title/body/draft.',
|
||||
'',
|
||||
`Head branch: ${context.branch ?? '(detached)'}`,
|
||||
`Current base: ${context.base}`,
|
||||
`Current title: ${context.currentTitle || '(empty)'}`,
|
||||
`Current description: ${context.currentBody || '(empty)'}`,
|
||||
`Current draft: ${context.currentDraft ? 'true' : 'false'}`,
|
||||
'',
|
||||
'Commits:',
|
||||
limitSection(context.commitSummary || '(none)', 8_000),
|
||||
'',
|
||||
'Changed files:',
|
||||
limitSection(context.changeSummary || '(none)', 8_000),
|
||||
'',
|
||||
'Patch:',
|
||||
'```diff',
|
||||
truncateDiffForPrompt(context.patch),
|
||||
'```'
|
||||
].join('\n')
|
||||
|
||||
const trimmedInstructions = customInstructions.trim()
|
||||
if (!trimmedInstructions) {
|
||||
return base
|
||||
}
|
||||
return [
|
||||
base,
|
||||
'',
|
||||
'Additional instructions from user:',
|
||||
limitSection(trimmedInstructions, 4_000)
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function stripJsonFence(raw: string): string {
|
||||
let text = raw.replace(/\r\n/g, '\n').trim()
|
||||
const fenced = text.match(/^```(?:json)?\n([\s\S]*?)\n```$/i)
|
||||
if (fenced) {
|
||||
text = fenced[1].trim()
|
||||
}
|
||||
const start = text.indexOf('{')
|
||||
const end = text.lastIndexOf('}')
|
||||
if (start !== -1 && end > start) {
|
||||
return text.slice(start, end + 1)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
export function parseGeneratedPullRequestFields(
|
||||
raw: string,
|
||||
fallback: Pick<PullRequestDraftContext, 'base' | 'currentTitle' | 'currentBody' | 'currentDraft'>
|
||||
): GeneratedPullRequestFields {
|
||||
const parsed = JSON.parse(stripJsonFence(raw)) as unknown
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
throw new Error('Expected a JSON object.')
|
||||
}
|
||||
const record = parsed as Record<string, unknown>
|
||||
const base = typeof record.base === 'string' ? record.base.trim() : fallback.base
|
||||
const title =
|
||||
typeof record.title === 'string' && record.title.trim()
|
||||
? record.title.trim().replace(/[.]+$/g, '')
|
||||
: fallback.currentTitle.trim()
|
||||
const body =
|
||||
typeof record.body === 'string' ? record.body.replace(/\s+$/g, '') : fallback.currentBody
|
||||
const draft = typeof record.draft === 'boolean' ? record.draft : fallback.currentDraft
|
||||
|
||||
return {
|
||||
base: base || fallback.base,
|
||||
title: title || 'Update project files',
|
||||
body,
|
||||
draft
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue