mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 08:03:09 +00:00
Add linked work item URL variable to AI pull request generation
- Resolve the active work item URL from GitHub or Linear issue caches based on the current branch.
- Display the linked work item in the pull request composer and creation dialog.
- Expose the `{linkedWorkItemUrl}` template variable for custom AI command templates during PR/MR field generation.
- Validate and normalize work item URLs across IPC and RPC layers.
This commit is contained in:
@@ -21,6 +21,7 @@ const {
|
||||
getBranchCompareMock,
|
||||
getBranchDiffMock,
|
||||
getStagedCommitContextMock,
|
||||
getPullRequestDraftContextMock,
|
||||
stageFileMock,
|
||||
bulkStageFilesMock,
|
||||
unstageFileMock,
|
||||
@@ -56,6 +57,7 @@ const {
|
||||
getBranchCompareMock: vi.fn(),
|
||||
getBranchDiffMock: vi.fn(),
|
||||
getStagedCommitContextMock: vi.fn(),
|
||||
getPullRequestDraftContextMock: vi.fn(),
|
||||
stageFileMock: vi.fn(),
|
||||
bulkStageFilesMock: vi.fn(),
|
||||
unstageFileMock: vi.fn(),
|
||||
@@ -150,6 +152,10 @@ vi.mock('../text-generation/commit-message-text-generation', () => ({
|
||||
cancelGeneratePullRequestFieldsLocal: cancelGeneratePullRequestFieldsLocalMock
|
||||
}))
|
||||
|
||||
vi.mock('../text-generation/pull-request-context', () => ({
|
||||
getPullRequestDraftContext: getPullRequestDraftContextMock
|
||||
}))
|
||||
|
||||
import { registerFilesystemHandlers } from './filesystem'
|
||||
import { invalidateAuthorizedRootsCache, registerWorktreeRootsForRepo } from './filesystem-auth'
|
||||
|
||||
@@ -216,6 +222,7 @@ describe('registerFilesystemHandlers', () => {
|
||||
getBranchCompareMock,
|
||||
getBranchDiffMock,
|
||||
getStagedCommitContextMock,
|
||||
getPullRequestDraftContextMock,
|
||||
stageFileMock,
|
||||
bulkStageFilesMock,
|
||||
unstageFileMock,
|
||||
@@ -1000,6 +1007,103 @@ describe('registerFilesystemHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('passes linked work item URLs into local pull-request generation', async () => {
|
||||
const context = {
|
||||
branch: 'feature/ai-pr',
|
||||
base: 'main',
|
||||
branchChangedByPreparation: false,
|
||||
currentTitle: 'Draft title',
|
||||
currentBody: 'Draft body',
|
||||
currentDraft: false,
|
||||
commitSummary: '- feat: update readme',
|
||||
changeSummary: 'M\tREADME.md',
|
||||
patch: '+hello'
|
||||
}
|
||||
const sourceControlAiResolvedParams = {
|
||||
agentId: 'custom' as const,
|
||||
model: '',
|
||||
customAgentCommand: 'agent'
|
||||
}
|
||||
getPullRequestDraftContextMock.mockResolvedValue(context)
|
||||
generatePullRequestFieldsFromContextMock.mockResolvedValue({
|
||||
success: true,
|
||||
fields: {
|
||||
base: 'main',
|
||||
title: 'Update README',
|
||||
body: 'Body',
|
||||
draft: false
|
||||
}
|
||||
})
|
||||
|
||||
registerFilesystemHandlers(store as never)
|
||||
|
||||
await expect(
|
||||
handlers.get('git:generatePullRequestFields')!(null, {
|
||||
worktreePath: WORKTREE_FEATURE_PATH,
|
||||
base: 'main',
|
||||
title: 'Draft title',
|
||||
body: 'Draft body',
|
||||
draft: false,
|
||||
linkedWorkItemUrl: 'https://linear.app/orca/issue/ORC-123/fix-pr-details',
|
||||
sourceControlAiResolvedParams
|
||||
})
|
||||
).resolves.toEqual({
|
||||
success: true,
|
||||
fields: {
|
||||
base: 'main',
|
||||
title: 'Update README',
|
||||
body: 'Body',
|
||||
draft: false
|
||||
}
|
||||
})
|
||||
|
||||
expect(getPullRequestDraftContextMock).toHaveBeenCalledWith(expect.any(Function), {
|
||||
base: 'main',
|
||||
currentTitle: 'Draft title',
|
||||
currentBody: 'Draft body',
|
||||
currentDraft: false
|
||||
})
|
||||
expect(generatePullRequestFieldsFromContextMock).toHaveBeenCalledWith(
|
||||
{
|
||||
...context,
|
||||
linkedWorkItemUrl: 'https://linear.app/orca/issue/ORC-123/fix-pr-details'
|
||||
},
|
||||
sourceControlAiResolvedParams,
|
||||
{
|
||||
kind: 'local',
|
||||
cwd: WORKTREE_FEATURE_PATH
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects malformed linked work item URLs before pull-request generation', async () => {
|
||||
const sourceControlAiResolvedParams = {
|
||||
agentId: 'custom' as const,
|
||||
model: '',
|
||||
customAgentCommand: 'agent'
|
||||
}
|
||||
|
||||
registerFilesystemHandlers(store as never)
|
||||
|
||||
await expect(
|
||||
handlers.get('git:generatePullRequestFields')!(null, {
|
||||
worktreePath: WORKTREE_FEATURE_PATH,
|
||||
base: 'main',
|
||||
title: 'Draft title',
|
||||
body: 'Draft body',
|
||||
draft: false,
|
||||
linkedWorkItemUrl: { href: 'https://linear.app/orca/issue/ORC-123/fix-pr-details' },
|
||||
sourceControlAiResolvedParams
|
||||
})
|
||||
).resolves.toEqual({
|
||||
success: false,
|
||||
error: 'Invalid linked work item URL.'
|
||||
})
|
||||
|
||||
expect(getPullRequestDraftContextMock).not.toHaveBeenCalled()
|
||||
expect(generatePullRequestFieldsFromContextMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prepares the selected Codex account home before local generation', async () => {
|
||||
const context = {
|
||||
branch: 'feature/ai',
|
||||
|
||||
+30
-12
@@ -70,6 +70,7 @@ import { checkIgnoredPaths } from '../git/check-ignored-paths'
|
||||
import { assertGitPushTargetShape } from '../../shared/git-push-target-validation'
|
||||
import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key'
|
||||
import type { ResolvedSourceControlAiGenerationParams } from '../../shared/source-control-ai'
|
||||
import { normalizeLinkedWorkItemUrl } from '../../shared/linked-work-item-url'
|
||||
import { validateGitPushTarget } from '../git/push-target-validation'
|
||||
import { getRemoteFileUrl } from '../git/repo'
|
||||
import {
|
||||
@@ -969,6 +970,7 @@ export function registerFilesystemHandlers(
|
||||
title: string
|
||||
body: string
|
||||
draft: boolean
|
||||
linkedWorkItemUrl?: unknown
|
||||
connectionId?: string
|
||||
sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams
|
||||
sourceControlAi?: GlobalSettings['sourceControlAi']
|
||||
@@ -995,6 +997,10 @@ export function registerFilesystemHandlers(
|
||||
if (!resolvedSettings.ok) {
|
||||
return { success: false, error: resolvedSettings.error }
|
||||
}
|
||||
const linkedWorkItemUrl = normalizeLinkedWorkItemUrl(args.linkedWorkItemUrl)
|
||||
if (!linkedWorkItemUrl.ok) {
|
||||
return { success: false, error: linkedWorkItemUrl.error }
|
||||
}
|
||||
if (args.connectionId) {
|
||||
const provider = getSshGitProvider(args.connectionId)
|
||||
if (!provider) {
|
||||
@@ -1024,13 +1030,19 @@ export function registerFilesystemHandlers(
|
||||
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, operation) =>
|
||||
provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation),
|
||||
missingBinaryLocation: 'remote PATH'
|
||||
})
|
||||
return generatePullRequestFieldsFromContext(
|
||||
linkedWorkItemUrl.linkedWorkItemUrl
|
||||
? { ...context, linkedWorkItemUrl: linkedWorkItemUrl.linkedWorkItemUrl }
|
||||
: context,
|
||||
resolvedSettings.params,
|
||||
{
|
||||
kind: 'remote',
|
||||
cwd: args.worktreePath,
|
||||
execute: (plan, cwd, timeoutMs, operation) =>
|
||||
provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation),
|
||||
missingBinaryLocation: 'remote PATH'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
||||
@@ -1061,11 +1073,17 @@ export function registerFilesystemHandlers(
|
||||
if (!localEnv.ok) {
|
||||
return { success: false, error: localEnv.error }
|
||||
}
|
||||
return generatePullRequestFieldsFromContext(context, resolvedSettings.params, {
|
||||
kind: 'local',
|
||||
cwd: worktreePath,
|
||||
...(localEnv.env ? { env: localEnv.env } : {})
|
||||
})
|
||||
return generatePullRequestFieldsFromContext(
|
||||
linkedWorkItemUrl.linkedWorkItemUrl
|
||||
? { ...context, linkedWorkItemUrl: linkedWorkItemUrl.linkedWorkItemUrl }
|
||||
: context,
|
||||
resolvedSettings.params,
|
||||
{
|
||||
kind: 'local',
|
||||
cwd: worktreePath,
|
||||
...(localEnv.env ? { env: localEnv.env } : {})
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -306,7 +306,13 @@ describe('RuntimeGitCommands', () => {
|
||||
await expect(
|
||||
commands.generateRuntimePullRequestFields(
|
||||
'id:wt-1',
|
||||
{ base: 'main', title: '', body: '', draft: false },
|
||||
{
|
||||
base: 'main',
|
||||
title: '',
|
||||
body: '',
|
||||
draft: false,
|
||||
linkedWorkItemUrl: 'https://linear.app/orca/issue/ORC-123/fix-pr-details'
|
||||
},
|
||||
{ sourceControlAiResolvedParams }
|
||||
)
|
||||
).resolves.toEqual({
|
||||
@@ -321,7 +327,10 @@ describe('RuntimeGitCommands', () => {
|
||||
|
||||
expect(mocks.resolveCommitMessageSettings).not.toHaveBeenCalled()
|
||||
expect(mocks.generatePullRequestFieldsFromContext).toHaveBeenCalledWith(
|
||||
context,
|
||||
{
|
||||
...context,
|
||||
linkedWorkItemUrl: 'https://linear.app/orca/issue/ORC-123/fix-pr-details'
|
||||
},
|
||||
sourceControlAiResolvedParams,
|
||||
expect.objectContaining({
|
||||
kind: 'local',
|
||||
@@ -330,6 +339,37 @@ describe('RuntimeGitCommands', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects invalid linked work item URLs before preparing pull-request context', async () => {
|
||||
const commands = new RuntimeGitCommands({
|
||||
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree('/repo') }),
|
||||
getRuntimeSettings: () =>
|
||||
({
|
||||
sourceControlAi: {
|
||||
pullRequest: {
|
||||
enabled: true,
|
||||
agentId: 'cursor'
|
||||
}
|
||||
}
|
||||
}) as unknown as GlobalSettings
|
||||
})
|
||||
|
||||
await expect(
|
||||
commands.generateRuntimePullRequestFields('id:wt-1', {
|
||||
base: 'main',
|
||||
title: '',
|
||||
body: '',
|
||||
draft: false,
|
||||
linkedWorkItemUrl: 'javascript:alert(1)'
|
||||
})
|
||||
).resolves.toEqual({
|
||||
success: false,
|
||||
error: 'Invalid linked work item URL.'
|
||||
})
|
||||
|
||||
expect(mocks.getPullRequestDraftContext).not.toHaveBeenCalled()
|
||||
expect(mocks.generatePullRequestFieldsFromContext).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves remote commit-message settings against the SSH host cache', async () => {
|
||||
const worktreePath = '/remote/repo'
|
||||
const context = {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
mergeLegacyCommitMessageAiIntoSourceControlAi,
|
||||
type ResolvedSourceControlAiGenerationParams
|
||||
} from '../../shared/source-control-ai'
|
||||
import { normalizeLinkedWorkItemUrl } from '../../shared/linked-work-item-url'
|
||||
import type { SourceControlAiOperation } from '../../shared/source-control-ai-types'
|
||||
import { getRemoteFileUrl } from '../git/repo'
|
||||
import {
|
||||
@@ -531,10 +532,20 @@ export class RuntimeGitCommands {
|
||||
|
||||
async generateRuntimePullRequestFields(
|
||||
worktreeSelector: string,
|
||||
input: { base: string; title: string; body: string; draft: boolean },
|
||||
input: {
|
||||
base: string
|
||||
title: string
|
||||
body: string
|
||||
draft: boolean
|
||||
linkedWorkItemUrl?: string | null
|
||||
},
|
||||
settingsOverride?: RuntimeCommitMessageSettingsOverride
|
||||
): Promise<GeneratePullRequestFieldsResult> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const linkedWorkItemUrl = normalizeLinkedWorkItemUrl(input.linkedWorkItemUrl)
|
||||
if (!linkedWorkItemUrl.ok) {
|
||||
return { success: false, error: linkedWorkItemUrl.error }
|
||||
}
|
||||
const discoveryHostKey =
|
||||
settingsOverride?.commitMessageDiscoveryHostKey ??
|
||||
getCommitMessageModelDiscoveryHostKey(target.connectionId ?? null)
|
||||
@@ -590,13 +601,19 @@ export class RuntimeGitCommands {
|
||||
}
|
||||
|
||||
if (target.connectionId) {
|
||||
return generatePullRequestFieldsFromContext(context, resolvedSettings.params, {
|
||||
kind: 'remote',
|
||||
cwd: target.worktree.path,
|
||||
execute: (plan, cwd, timeoutMs, operation) =>
|
||||
provider!.executeCommitMessagePlan(plan, cwd, timeoutMs, operation),
|
||||
missingBinaryLocation: 'remote PATH'
|
||||
})
|
||||
return generatePullRequestFieldsFromContext(
|
||||
linkedWorkItemUrl.linkedWorkItemUrl
|
||||
? { ...context, linkedWorkItemUrl: linkedWorkItemUrl.linkedWorkItemUrl }
|
||||
: context,
|
||||
resolvedSettings.params,
|
||||
{
|
||||
kind: 'remote',
|
||||
cwd: target.worktree.path,
|
||||
execute: (plan, cwd, timeoutMs, operation) =>
|
||||
provider!.executeCommitMessagePlan(plan, cwd, timeoutMs, operation),
|
||||
missingBinaryLocation: 'remote PATH'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const localEnv = await prepareLocalCommitMessageAgentEnv(
|
||||
@@ -606,11 +623,17 @@ export class RuntimeGitCommands {
|
||||
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 } : {})
|
||||
})
|
||||
return generatePullRequestFieldsFromContext(
|
||||
linkedWorkItemUrl.linkedWorkItemUrl
|
||||
? { ...context, linkedWorkItemUrl: linkedWorkItemUrl.linkedWorkItemUrl }
|
||||
: context,
|
||||
resolvedSettings.params,
|
||||
{
|
||||
kind: 'local',
|
||||
cwd: target.worktree.path,
|
||||
...(localEnv.env ? { env: localEnv.env } : {})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async cancelRuntimeGeneratePullRequestFields(worktreeSelector: string): Promise<{ ok: true }> {
|
||||
|
||||
@@ -172,7 +172,8 @@ export const GitGeneratePullRequestFields = GitGenerateCommitMessage.extend({
|
||||
base: z.string().min(1, 'Missing base branch'),
|
||||
title: z.string(),
|
||||
body: z.string(),
|
||||
draft: z.boolean()
|
||||
draft: z.boolean(),
|
||||
linkedWorkItemUrl: z.string().nullable().optional()
|
||||
})
|
||||
|
||||
export const GitBulkPaths = WorktreeSelector.extend({
|
||||
|
||||
@@ -412,17 +412,52 @@ describe('git RPC methods', () => {
|
||||
title: '',
|
||||
body: '',
|
||||
draft: false,
|
||||
linkedWorkItemUrl: 'https://linear.app/orca/issue/ORC-123/fix-pr-details',
|
||||
sourceControlAiResolvedParams
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.generateRuntimePullRequestFields).toHaveBeenCalledWith(
|
||||
'id:wt-1',
|
||||
{ base: 'main', title: '', body: '', draft: false },
|
||||
{
|
||||
base: 'main',
|
||||
title: '',
|
||||
body: '',
|
||||
draft: false,
|
||||
linkedWorkItemUrl: 'https://linear.app/orca/issue/ORC-123/fix-pr-details'
|
||||
},
|
||||
{ sourceControlAiResolvedParams }
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects invalid linked work item URLs before calling the runtime', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
generateRuntimePullRequestFields: vi.fn()
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('git.generatePullRequestFields', {
|
||||
worktree: 'id:wt-1',
|
||||
base: 'main',
|
||||
title: '',
|
||||
body: '',
|
||||
draft: false,
|
||||
linkedWorkItemUrl: 'ftp://example.com/work'
|
||||
})
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
success: false,
|
||||
error: 'Invalid linked work item URL.'
|
||||
}
|
||||
})
|
||||
expect(runtime.generateRuntimePullRequestFields).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects malformed commit-message settings before calling the runtime', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import type { ResolvedSourceControlAiGenerationParams } from '../../../../shared/source-control-ai'
|
||||
import { normalizeLinkedWorkItemUrl } from '../../../../shared/linked-work-item-url'
|
||||
import {
|
||||
GitBranchCompare,
|
||||
GitBranchDiff,
|
||||
@@ -254,11 +255,18 @@ export const GIT_METHODS: RpcMethod[] = [
|
||||
name: 'git.generatePullRequestFields',
|
||||
params: GitGeneratePullRequestFields,
|
||||
handler: async (params, { runtime }) => {
|
||||
const linkedWorkItemUrl = normalizeLinkedWorkItemUrl(params.linkedWorkItemUrl)
|
||||
if (!linkedWorkItemUrl.ok) {
|
||||
return { success: false, error: linkedWorkItemUrl.error }
|
||||
}
|
||||
const input = {
|
||||
base: params.base,
|
||||
title: params.title,
|
||||
body: params.body,
|
||||
draft: params.draft
|
||||
draft: params.draft,
|
||||
...(linkedWorkItemUrl.linkedWorkItemUrl
|
||||
? { linkedWorkItemUrl: linkedWorkItemUrl.linkedWorkItemUrl }
|
||||
: {})
|
||||
}
|
||||
const override = buildCommitMessageGenerationOverride(params)
|
||||
if (override === undefined) {
|
||||
|
||||
@@ -1069,6 +1069,96 @@ describe('generateCommitMessageFromContext', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('renders linkedWorkItemUrl in pull-request command templates when present', async () => {
|
||||
let prompt = ''
|
||||
const result = await generatePullRequestFieldsFromContext(
|
||||
{
|
||||
branch: 'feature/pr-fields',
|
||||
base: 'main',
|
||||
branchChangedByPreparation: false,
|
||||
currentTitle: '',
|
||||
currentBody: '',
|
||||
currentDraft: false,
|
||||
commitSummary: '- feat: update README',
|
||||
changeSummary: 'M\tREADME.md',
|
||||
patch: '+hello',
|
||||
linkedWorkItemUrl: 'https://linear.app/orca/issue/ORC-123/fix-pr-details'
|
||||
},
|
||||
{
|
||||
agentId: 'custom',
|
||||
model: '',
|
||||
customAgentCommand: 'agent',
|
||||
commandInputTemplate: '{basePrompt}\n\nLinked: {linkedWorkItemUrl}'
|
||||
},
|
||||
{
|
||||
kind: 'remote',
|
||||
cwd: '/repo',
|
||||
missingBinaryLocation: 'remote PATH',
|
||||
execute: async (plan) => {
|
||||
prompt = plan.stdinPayload ?? ''
|
||||
return {
|
||||
stdout: '{"base":"main","title":"Update README","body":"Body","draft":false}\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
timedOut: false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(prompt).toContain('Linked: https://linear.app/orca/issue/ORC-123/fix-pr-details')
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
fields: {
|
||||
base: 'main',
|
||||
title: 'Update README',
|
||||
body: 'Body',
|
||||
draft: false
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('renders linkedWorkItemUrl as empty in pull-request command templates when absent', async () => {
|
||||
let prompt = ''
|
||||
const result = await generatePullRequestFieldsFromContext(
|
||||
{
|
||||
branch: 'feature/pr-fields',
|
||||
base: 'main',
|
||||
branchChangedByPreparation: false,
|
||||
currentTitle: '',
|
||||
currentBody: '',
|
||||
currentDraft: false,
|
||||
commitSummary: '- feat: update README',
|
||||
changeSummary: 'M\tREADME.md',
|
||||
patch: '+hello'
|
||||
},
|
||||
{
|
||||
agentId: 'custom',
|
||||
model: '',
|
||||
customAgentCommand: 'agent',
|
||||
commandInputTemplate: '{basePrompt}\n\nLinked: {linkedWorkItemUrl}'
|
||||
},
|
||||
{
|
||||
kind: 'remote',
|
||||
cwd: '/repo',
|
||||
missingBinaryLocation: 'remote PATH',
|
||||
execute: async (plan) => {
|
||||
prompt = plan.stdinPayload ?? ''
|
||||
return {
|
||||
stdout: '{"base":"main","title":"Update README","body":"Body","draft":false}\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
timedOut: false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(prompt).toContain('Linked: ')
|
||||
expect(prompt).not.toContain('{linkedWorkItemUrl}')
|
||||
expect(result).toMatchObject({ success: true })
|
||||
})
|
||||
|
||||
it('reports branch changes when pull request field output cannot be parsed', async () => {
|
||||
const listeners = new Map<string, (value: unknown) => void>()
|
||||
spawnMock.mockReturnValue({
|
||||
|
||||
@@ -816,10 +816,12 @@ export async function generatePullRequestFieldsFromContext(
|
||||
target: CommitMessageGenerationTarget
|
||||
): Promise<GeneratePullRequestFieldsResult> {
|
||||
const basePrompt = buildPullRequestFieldsPrompt(context, '')
|
||||
const linkedWorkItemUrl = context.linkedWorkItemUrl?.trim()
|
||||
const prompt =
|
||||
params.commandInputTemplate !== undefined
|
||||
? renderSourceControlActionCommandTemplate(params.commandInputTemplate, {
|
||||
basePrompt,
|
||||
linkedWorkItemUrl: linkedWorkItemUrl ?? '',
|
||||
branch: context.branch ?? '(detached)',
|
||||
baseBranch: context.base,
|
||||
currentTitle: context.currentTitle,
|
||||
|
||||
@@ -2054,6 +2054,7 @@ export type PreloadApi = {
|
||||
title: string
|
||||
body: string
|
||||
draft: boolean
|
||||
linkedWorkItemUrl?: string | null
|
||||
connectionId?: string
|
||||
sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams
|
||||
sourceControlAi?: SourceControlAiSettings
|
||||
|
||||
@@ -2510,6 +2510,7 @@ const api = {
|
||||
title: string
|
||||
body: string
|
||||
draft: boolean
|
||||
linkedWorkItemUrl?: string | null
|
||||
connectionId?: string
|
||||
sourceControlAiResolvedParams?: unknown
|
||||
sourceControlAi?: unknown
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -13,22 +13,22 @@ import {
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { resolveSourceControlLinkedWorkItemUrl } from '@/lib/source-control-linked-work-item-url'
|
||||
import { useAppStore } from '@/store'
|
||||
import { findWorktreeById } from '@/store/slices/worktree-helpers'
|
||||
import type {
|
||||
CreateHostedReviewResult,
|
||||
HostedReviewCreationEligibility,
|
||||
HostedReviewProvider
|
||||
} from '../../../../shared/hosted-review'
|
||||
import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs'
|
||||
import { stripBaseRef, useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields'
|
||||
import {
|
||||
DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS,
|
||||
resolveSourceControlAiForOperation,
|
||||
resolveSourceControlAiPrCreationDefaults
|
||||
} from '../../../../shared/source-control-ai'
|
||||
import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../../shared/commit-message-host-key'
|
||||
import { getRuntimeGitScope } from '@/runtime/runtime-git-client'
|
||||
import { resolveCreatePullRequestAiDefaults } from './create-pull-request-ai-defaults'
|
||||
import { CreatePullRequestGenerateButton } from './CreatePullRequestGenerateButton'
|
||||
import { CreatePullRequestLinkedWorkItemRow } from './CreatePullRequestLinkedWorkItemRow'
|
||||
import {
|
||||
formatHostedReviewCreateError,
|
||||
getHostedReviewCreationCopy
|
||||
} from './hosted-review-creation-copy'
|
||||
|
||||
type CreatePullRequestDialogProps = {
|
||||
open: boolean
|
||||
@@ -49,42 +49,6 @@ type CreatePullRequestDialogProps = {
|
||||
}) => Promise<void>
|
||||
}
|
||||
|
||||
function reviewCopy(provider: HostedReviewProvider): {
|
||||
shortLabel: 'PR' | 'MR'
|
||||
reviewLabel: 'pull request' | 'merge request'
|
||||
titleLabel: 'Pull Request' | 'Merge Request'
|
||||
providerName: 'GitHub' | 'GitLab'
|
||||
} {
|
||||
return provider === 'gitlab'
|
||||
? {
|
||||
shortLabel: 'MR',
|
||||
reviewLabel: 'merge request',
|
||||
titleLabel: 'Merge Request',
|
||||
providerName: 'GitLab'
|
||||
}
|
||||
: {
|
||||
shortLabel: 'PR',
|
||||
reviewLabel: 'pull request',
|
||||
titleLabel: 'Pull Request',
|
||||
providerName: 'GitHub'
|
||||
}
|
||||
}
|
||||
|
||||
function formatCreateError(
|
||||
result: CreateHostedReviewResult,
|
||||
pushed: boolean,
|
||||
shortLabel: 'PR' | 'MR'
|
||||
): string {
|
||||
if (result.ok) {
|
||||
return ''
|
||||
}
|
||||
if (pushed) {
|
||||
const prefix = new RegExp(`^Create ${shortLabel} failed:\\s*`, 'i')
|
||||
return `Push succeeded, but ${shortLabel} creation failed: ${result.error.replace(prefix, '')}`
|
||||
}
|
||||
return result.error
|
||||
}
|
||||
|
||||
export function CreatePullRequestDialog({
|
||||
open,
|
||||
repoId,
|
||||
@@ -101,34 +65,30 @@ export function CreatePullRequestDialog({
|
||||
}: CreatePullRequestDialogProps): React.JSX.Element {
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const repo = useAppStore((s) => s.repos.find((candidate) => candidate.id === repoId) ?? null)
|
||||
const linkedWorkItemUrl = useAppStore((s) =>
|
||||
resolveSourceControlLinkedWorkItemUrl({
|
||||
worktree: worktreeId ? findWorktreeById(s.worktreesByRepo, worktreeId) : null,
|
||||
repo,
|
||||
branch,
|
||||
settings: s.settings,
|
||||
linearIssueCache: s.linearIssueCache,
|
||||
linearStatus: s.linearStatus,
|
||||
githubIssueCache: s.issueCache,
|
||||
githubPrCache: s.prCache,
|
||||
githubWorkItemsCache: s.workItemsCache,
|
||||
hostedReviewCache: s.hostedReviewCache
|
||||
})
|
||||
)
|
||||
const createHostedReview = useAppStore((s) => s.createHostedReview)
|
||||
const submitInFlightRef = useRef(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const provider = eligibility?.provider === 'gitlab' ? 'gitlab' : 'github'
|
||||
const copy = reviewCopy(provider)
|
||||
const prCreationDefaults = React.useMemo(() => {
|
||||
if (!settings) {
|
||||
return DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS
|
||||
}
|
||||
const hostKey = getCommitMessageModelDiscoveryHostKeyForScope(
|
||||
getRuntimeGitScope(settings, repo?.connectionId)
|
||||
)
|
||||
const resolved = resolveSourceControlAiForOperation({
|
||||
settings,
|
||||
repo,
|
||||
operation: 'pullRequest',
|
||||
discoveryHostKey: hostKey,
|
||||
prCreationProductDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS
|
||||
})
|
||||
return resolved.ok
|
||||
? resolved.value.prCreationDefaults
|
||||
: resolveSourceControlAiPrCreationDefaults({
|
||||
settings,
|
||||
repo,
|
||||
prCreationProductDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS
|
||||
})
|
||||
}, [repo, settings])
|
||||
const copy = getHostedReviewCreationCopy(provider)
|
||||
const prCreationDefaults = useMemo(
|
||||
() => resolveCreatePullRequestAiDefaults({ settings, repo }),
|
||||
[repo, settings]
|
||||
)
|
||||
const {
|
||||
aiGenerationEnabled,
|
||||
base,
|
||||
@@ -160,6 +120,7 @@ export function CreatePullRequestDialog({
|
||||
repo,
|
||||
settings,
|
||||
submitting,
|
||||
linkedWorkItemUrl,
|
||||
prCreationDefaults,
|
||||
onBranchChangedByGeneration
|
||||
})
|
||||
@@ -233,7 +194,7 @@ export function CreatePullRequestDialog({
|
||||
return
|
||||
}
|
||||
}
|
||||
setError(formatCreateError(result, pushed, copy.shortLabel))
|
||||
setError(formatHostedReviewCreateError(result, pushed, copy.shortLabel))
|
||||
} finally {
|
||||
submitInFlightRef.current = false
|
||||
setSubmitting(false)
|
||||
@@ -298,6 +259,9 @@ export function CreatePullRequestDialog({
|
||||
Confirm the target branch and {copy.shortLabel} details before creating the hosted
|
||||
review.
|
||||
</DialogDescription>
|
||||
{aiGenerationEnabled && linkedWorkItemUrl ? (
|
||||
<CreatePullRequestLinkedWorkItemRow linkedWorkItemUrl={linkedWorkItemUrl} />
|
||||
) : null}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type React from 'react'
|
||||
import { ExternalLink, Link2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
type CreatePullRequestLinkedWorkItemRowProps = {
|
||||
linkedWorkItemUrl: string
|
||||
}
|
||||
|
||||
export function CreatePullRequestLinkedWorkItemRow({
|
||||
linkedWorkItemUrl
|
||||
}: CreatePullRequestLinkedWorkItemRowProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Link2 className="size-3.5 shrink-0" />
|
||||
<span className="min-w-0 truncate">Linked work item</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label="Open linked work item"
|
||||
onClick={() => window.api.shell.openUrl(linkedWorkItemUrl)}
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Open linked work item</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import { getHostedReviewCacheKey } from '@/store/slices/hosted-review'
|
||||
import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import { basename, dirname, joinPath } from '@/lib/path'
|
||||
import { resolveSourceControlLinkedWorkItemUrl } from '@/lib/source-control-linked-work-item-url'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
|
||||
import { isFolderRepo } from '../../../../shared/repo-kind'
|
||||
@@ -56,11 +57,13 @@ import {
|
||||
type PrimaryAction,
|
||||
type RemoteOpKind
|
||||
} from './source-control-primary-action'
|
||||
import { CreatePullRequestLinkedWorkItemRow } from './CreatePullRequestLinkedWorkItemRow'
|
||||
import {
|
||||
resolveDropdownItems,
|
||||
type DropdownActionKind,
|
||||
type DropdownEntry
|
||||
} from './source-control-dropdown-items'
|
||||
import { getHostedReviewCreationCopy } from './hosted-review-creation-copy'
|
||||
import { BulkActionBar } from './BulkActionBar'
|
||||
import { useSourceControlSelection, type FlatEntry } from './useSourceControlSelection'
|
||||
import {
|
||||
@@ -421,27 +424,6 @@ type CreatedHostedReview = {
|
||||
url: string
|
||||
}
|
||||
|
||||
function hostedReviewCreationCopy(provider: HostedReviewProvider | null | undefined): {
|
||||
shortLabel: 'PR' | 'MR'
|
||||
reviewLabel: 'pull request' | 'merge request'
|
||||
titleLabel: 'Pull Request' | 'Merge Request'
|
||||
providerName: 'GitHub' | 'GitLab'
|
||||
} {
|
||||
return provider === 'gitlab'
|
||||
? {
|
||||
shortLabel: 'MR',
|
||||
reviewLabel: 'merge request',
|
||||
titleLabel: 'Merge Request',
|
||||
providerName: 'GitLab'
|
||||
}
|
||||
: {
|
||||
shortLabel: 'PR',
|
||||
reviewLabel: 'pull request',
|
||||
titleLabel: 'Pull Request',
|
||||
providerName: 'GitHub'
|
||||
}
|
||||
}
|
||||
|
||||
export function readCommitDraftForWorktree(
|
||||
drafts: CommitDraftsByWorktree,
|
||||
worktreeId: string | null | undefined
|
||||
@@ -773,6 +755,20 @@ function SourceControlInner(): React.JSX.Element {
|
||||
const worktreeMap = useWorktreeMap()
|
||||
const rightSidebarTab = useAppStore((s) => s.rightSidebarTab)
|
||||
const activeRepo = useRepoById(activeWorktree?.repoId ?? null)
|
||||
const activeLinkedWorkItemUrl = useAppStore((s) =>
|
||||
resolveSourceControlLinkedWorkItemUrl({
|
||||
worktree: activeWorktree,
|
||||
repo: activeRepo,
|
||||
branch: activeWorktree?.branch ?? null,
|
||||
settings: s.settings,
|
||||
linearIssueCache: s.linearIssueCache,
|
||||
linearStatus: s.linearStatus,
|
||||
githubIssueCache: s.issueCache,
|
||||
githubPrCache: s.prCache,
|
||||
githubWorkItemsCache: s.workItemsCache,
|
||||
hostedReviewCache: s.hostedReviewCache
|
||||
})
|
||||
)
|
||||
const entries = useAppStore((s) =>
|
||||
activeWorktreeId
|
||||
? (s.gitStatusByWorktree[activeWorktreeId] ?? EMPTY_GIT_STATUS_ENTRIES)
|
||||
@@ -1177,7 +1173,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
: null
|
||||
const hostedReviewCreateProvider =
|
||||
hostedReviewCreation?.provider === 'gitlab' ? 'gitlab' : 'github'
|
||||
const hostedReviewCreateCopy = hostedReviewCreationCopy(hostedReviewCreateProvider)
|
||||
const hostedReviewCreateCopy = getHostedReviewCreationCopy(hostedReviewCreateProvider)
|
||||
const hostedReviewCacheKey =
|
||||
activeRepo && branchName
|
||||
? getHostedReviewCacheKey(
|
||||
@@ -1957,7 +1953,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
if (!activeRepo || !branchName) {
|
||||
return
|
||||
}
|
||||
const copy = hostedReviewCreationCopy(result.provider)
|
||||
const copy = getHostedReviewCreationCopy(result.provider)
|
||||
setRightSidebarOpen(true)
|
||||
setRightSidebarTab('checks')
|
||||
try {
|
||||
@@ -2067,7 +2063,8 @@ function SourceControlInner(): React.JSX.Element {
|
||||
base: stripBaseRef(seed.base.trim()),
|
||||
title: seed.title,
|
||||
body: seed.body,
|
||||
draft: seed.draft
|
||||
draft: seed.draft,
|
||||
...(activeLinkedWorkItemUrl ? { linkedWorkItemUrl: activeLinkedWorkItemUrl } : {})
|
||||
},
|
||||
overrides
|
||||
)
|
||||
@@ -2138,6 +2135,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
},
|
||||
[
|
||||
activePullRequestGenerationKey,
|
||||
activeLinkedWorkItemUrl,
|
||||
activeRepo,
|
||||
activeWorktreeId,
|
||||
branchName,
|
||||
@@ -3999,6 +3997,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
generateError={prGenerateError}
|
||||
createError={createPrError}
|
||||
isCreating={isCreatingPr}
|
||||
linkedWorkItemUrl={activeLinkedWorkItemUrl}
|
||||
primaryAction={primaryAction}
|
||||
dropdownItems={dropdownItems}
|
||||
onGenerate={handleGeneratePullRequestFieldsClick}
|
||||
@@ -4471,6 +4470,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
settings={settings}
|
||||
repo={activeRepo ?? null}
|
||||
discoveryHostKey={sourceControlAiDiscoveryHostKey}
|
||||
variablePreviews={{ linkedWorkItemUrl: activeLinkedWorkItemUrl ?? '' }}
|
||||
onGenerate={(params) => {
|
||||
void handleGeneratePullRequestFields({ sourceControlAiResolvedParams: params })
|
||||
}}
|
||||
@@ -4506,6 +4506,7 @@ type PullRequestComposerProps = {
|
||||
generateError: string | null
|
||||
createError: string | null
|
||||
isCreating: boolean
|
||||
linkedWorkItemUrl: string | null
|
||||
primaryAction: PrimaryAction
|
||||
dropdownItems: DropdownEntry[]
|
||||
onGenerate: () => void
|
||||
@@ -4537,6 +4538,7 @@ function PullRequestComposer({
|
||||
generateError,
|
||||
createError,
|
||||
isCreating,
|
||||
linkedWorkItemUrl,
|
||||
primaryAction,
|
||||
dropdownItems,
|
||||
onGenerate,
|
||||
@@ -4544,7 +4546,7 @@ function PullRequestComposer({
|
||||
onPrimaryAction,
|
||||
onDropdownAction
|
||||
}: PullRequestComposerProps): React.JSX.Element {
|
||||
const copy = hostedReviewCreationCopy(provider)
|
||||
const copy = getHostedReviewCreationCopy(provider)
|
||||
const ReviewIcon = provider === 'gitlab' ? GitMerge : GitPullRequestArrow
|
||||
const normalizedBase = stripBaseRef(base)
|
||||
const strippedBranch = stripBaseRef(branch)
|
||||
@@ -4610,6 +4612,10 @@ function PullRequestComposer({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{aiGenerationEnabled && linkedWorkItemUrl ? (
|
||||
<CreatePullRequestLinkedWorkItemRow linkedWorkItemUrl={linkedWorkItemUrl} />
|
||||
) : null}
|
||||
|
||||
{/* Why: a single line that shows the head→base flow plain-language so
|
||||
the user can sanity-check the merge direction at a glance. */}
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
|
||||
@@ -27,6 +27,7 @@ type SourceControlTextGenerationBaseDialogProps = {
|
||||
settings: GlobalSettings | null
|
||||
repo?: Pick<Repo, 'id' | 'sourceControlAi'> | null
|
||||
discoveryHostKey: string
|
||||
variablePreviews?: Partial<Record<string, string>>
|
||||
onGenerate: (params: ResolvedSourceControlAiGenerationParams) => void
|
||||
onSaveDefaults: (
|
||||
target: SourceControlAiWriteTarget,
|
||||
@@ -51,6 +52,7 @@ export function SourceControlTextGenerationDialog({
|
||||
settings,
|
||||
repo,
|
||||
discoveryHostKey,
|
||||
variablePreviews,
|
||||
onGenerate,
|
||||
onSaveDefaults
|
||||
}: SourceControlTextGenerationDialogProps): React.JSX.Element {
|
||||
@@ -125,6 +127,7 @@ export function SourceControlTextGenerationDialog({
|
||||
settings={settings}
|
||||
baseParams={baseParams}
|
||||
saveTargets={saveTargets}
|
||||
variablePreviews={variablePreviews}
|
||||
onGenerate={onGenerate}
|
||||
onOpenChange={onOpenChange}
|
||||
onSaveDefaults={onSaveDefaults}
|
||||
|
||||
@@ -48,6 +48,7 @@ type SourceControlTextGenerationDialogFormProps = {
|
||||
settings: GlobalSettings | null
|
||||
baseParams: ResolvedSourceControlAiGenerationParams | null
|
||||
saveTargets: SourceControlTextGenerationSaveTarget[]
|
||||
variablePreviews?: Partial<Record<string, string>>
|
||||
onGenerate: (params: ResolvedSourceControlAiGenerationParams) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSaveDefaults: (
|
||||
@@ -66,6 +67,7 @@ export function SourceControlTextGenerationDialogForm({
|
||||
settings,
|
||||
baseParams,
|
||||
saveTargets,
|
||||
variablePreviews,
|
||||
onGenerate,
|
||||
onOpenChange,
|
||||
onSaveDefaults
|
||||
@@ -231,6 +233,7 @@ export function SourceControlTextGenerationDialogForm({
|
||||
/>
|
||||
<SourceControlActionVariableChips
|
||||
actionId={actionId}
|
||||
variablePreviews={variablePreviews}
|
||||
onInsert={(variable) => {
|
||||
const separator =
|
||||
commandTemplate.endsWith('\n') || commandTemplate.length === 0 ? '' : ' '
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { getRuntimeGitScope } from '@/runtime/runtime-git-client'
|
||||
import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../../shared/commit-message-host-key'
|
||||
import {
|
||||
DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS,
|
||||
resolveSourceControlAiForOperation,
|
||||
resolveSourceControlAiPrCreationDefaults
|
||||
} from '../../../../shared/source-control-ai'
|
||||
import type { SourceControlAiPrCreationDefaults } from '../../../../shared/source-control-ai-types'
|
||||
import type { GlobalSettings, Repo } from '../../../../shared/types'
|
||||
|
||||
export function resolveCreatePullRequestAiDefaults({
|
||||
settings,
|
||||
repo
|
||||
}: {
|
||||
settings: GlobalSettings | null | undefined
|
||||
repo: Pick<Repo, 'connectionId' | 'sourceControlAi'> | null | undefined
|
||||
}): Required<SourceControlAiPrCreationDefaults> {
|
||||
if (!settings) {
|
||||
return DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS
|
||||
}
|
||||
const hostKey = getCommitMessageModelDiscoveryHostKeyForScope(
|
||||
getRuntimeGitScope(settings, repo?.connectionId)
|
||||
)
|
||||
const resolved = resolveSourceControlAiForOperation({
|
||||
settings,
|
||||
repo,
|
||||
operation: 'pullRequest',
|
||||
discoveryHostKey: hostKey,
|
||||
prCreationProductDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS
|
||||
})
|
||||
return resolved.ok
|
||||
? resolved.value.prCreationDefaults
|
||||
: resolveSourceControlAiPrCreationDefaults({
|
||||
settings,
|
||||
repo,
|
||||
prCreationProductDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type {
|
||||
CreateHostedReviewResult,
|
||||
HostedReviewProvider
|
||||
} from '../../../../shared/hosted-review'
|
||||
|
||||
export type HostedReviewCreationCopy = {
|
||||
shortLabel: 'PR' | 'MR'
|
||||
reviewLabel: 'pull request' | 'merge request'
|
||||
titleLabel: 'Pull Request' | 'Merge Request'
|
||||
providerName: 'GitHub' | 'GitLab'
|
||||
}
|
||||
|
||||
export function getHostedReviewCreationCopy(
|
||||
provider: HostedReviewProvider | null | undefined
|
||||
): HostedReviewCreationCopy {
|
||||
return provider === 'gitlab'
|
||||
? {
|
||||
shortLabel: 'MR',
|
||||
reviewLabel: 'merge request',
|
||||
titleLabel: 'Merge Request',
|
||||
providerName: 'GitLab'
|
||||
}
|
||||
: {
|
||||
shortLabel: 'PR',
|
||||
reviewLabel: 'pull request',
|
||||
titleLabel: 'Pull Request',
|
||||
providerName: 'GitHub'
|
||||
}
|
||||
}
|
||||
|
||||
export function formatHostedReviewCreateError(
|
||||
result: CreateHostedReviewResult,
|
||||
pushed: boolean,
|
||||
shortLabel: 'PR' | 'MR'
|
||||
): string {
|
||||
if (result.ok) {
|
||||
return ''
|
||||
}
|
||||
if (pushed) {
|
||||
const prefix = new RegExp(`^Create ${shortLabel} failed:\\s*`, 'i')
|
||||
return `Push succeeded, but ${shortLabel} creation failed: ${result.error.replace(prefix, '')}`
|
||||
}
|
||||
return result.error
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeCreateReviewBaseSearchResults } from './useCreatePullRequestDialogFields'
|
||||
import {
|
||||
getCreateReviewDiscoveryHostKey,
|
||||
normalizeCreateReviewBaseSearchResults
|
||||
} from './useCreatePullRequestDialogFields'
|
||||
|
||||
describe('normalizeCreateReviewBaseSearchResults', () => {
|
||||
it('uses detailed local branch names for base refs from arbitrary remotes', () => {
|
||||
@@ -32,3 +35,35 @@ describe('normalizeCreateReviewBaseSearchResults', () => {
|
||||
).toEqual(['main', 'release/1.0'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCreateReviewDiscoveryHostKey', () => {
|
||||
it('uses runtime scope before SSH connection scope', () => {
|
||||
expect(
|
||||
getCreateReviewDiscoveryHostKey(
|
||||
{ activeRuntimeEnvironmentId: 'runtime-1' },
|
||||
{ connectionId: 'ssh-1' }
|
||||
)
|
||||
).toBe('runtime:runtime-1')
|
||||
})
|
||||
|
||||
it('uses SSH scope when no runtime environment is active', () => {
|
||||
expect(
|
||||
getCreateReviewDiscoveryHostKey(
|
||||
{ activeRuntimeEnvironmentId: null },
|
||||
{ connectionId: 'ssh-1' }
|
||||
)
|
||||
).toBe('ssh:ssh-1')
|
||||
})
|
||||
|
||||
it('uses local scope without runtime or SSH context', () => {
|
||||
expect(
|
||||
getCreateReviewDiscoveryHostKey({ activeRuntimeEnvironmentId: null }, { connectionId: null })
|
||||
).toBe('local')
|
||||
})
|
||||
|
||||
it('keeps unknown scope when the repo has not loaded yet', () => {
|
||||
expect(getCreateReviewDiscoveryHostKey({ activeRuntimeEnvironmentId: null }, null)).toBe(
|
||||
'unknown'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { useAppStore, type AppState } from '@/store'
|
||||
import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../../shared/commit-message-host-key'
|
||||
import {
|
||||
cancelRuntimeGeneratePullRequestFields,
|
||||
generateRuntimePullRequestFields,
|
||||
getRuntimeGitScope,
|
||||
type RuntimeGeneratePullRequestFieldsOverrides,
|
||||
type RuntimeGitContext
|
||||
} from '@/runtime/runtime-git-client'
|
||||
@@ -13,7 +15,7 @@ import {
|
||||
getRuntimeRepoBaseRefDefault,
|
||||
searchRuntimeRepoBaseRefDetails
|
||||
} from '@/runtime/runtime-repo-client'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
import type { GlobalSettings, Repo } from '../../../../shared/types'
|
||||
import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
|
||||
import { normalizeHostedReviewBaseRef } from '../../../../shared/hosted-review-refs'
|
||||
import type { BaseRefSearchResult } from '../../../../shared/types'
|
||||
@@ -39,9 +41,10 @@ type UseCreatePullRequestDialogFieldsOptions = {
|
||||
worktreePath: string
|
||||
branch: string
|
||||
eligibility: HostedReviewCreationEligibility | null
|
||||
repo?: Pick<Repo, 'sourceControlAi'> | null
|
||||
repo?: Pick<Repo, 'connectionId' | 'sourceControlAi'> | null
|
||||
settings: AppState['settings']
|
||||
submitting: boolean
|
||||
linkedWorkItemUrl?: string | null
|
||||
prCreationDefaults?: SourceControlAiPrCreationDefaults
|
||||
onBranchChangedByGeneration?: () => Promise<void>
|
||||
generation?: {
|
||||
@@ -93,6 +96,15 @@ export function normalizeCreateReviewBaseSearchResults(
|
||||
return branches
|
||||
}
|
||||
|
||||
export function getCreateReviewDiscoveryHostKey(
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
|
||||
repo: Pick<Repo, 'connectionId'> | null | undefined
|
||||
): string {
|
||||
return getCommitMessageModelDiscoveryHostKeyForScope(
|
||||
getRuntimeGitScope(settings, repo?.connectionId)
|
||||
)
|
||||
}
|
||||
|
||||
export function useCreatePullRequestDialogFields({
|
||||
open,
|
||||
repoId,
|
||||
@@ -103,6 +115,7 @@ export function useCreatePullRequestDialogFields({
|
||||
repo,
|
||||
settings,
|
||||
submitting,
|
||||
linkedWorkItemUrl,
|
||||
prCreationDefaults,
|
||||
onBranchChangedByGeneration,
|
||||
generation
|
||||
@@ -111,7 +124,8 @@ export function useCreatePullRequestDialogFields({
|
||||
? resolveSourceControlAiForOperation({
|
||||
settings,
|
||||
repo,
|
||||
operation: 'pullRequest'
|
||||
operation: 'pullRequest',
|
||||
discoveryHostKey: getCreateReviewDiscoveryHostKey(settings, repo)
|
||||
})
|
||||
: null
|
||||
const resolvedPrDefaults = {
|
||||
@@ -353,7 +367,8 @@ export function useCreatePullRequestDialogFields({
|
||||
base: stripBaseRef(base.trim()),
|
||||
title,
|
||||
body,
|
||||
draft
|
||||
draft,
|
||||
...(linkedWorkItemUrl ? { linkedWorkItemUrl } : {})
|
||||
},
|
||||
overrides
|
||||
)
|
||||
@@ -403,6 +418,7 @@ export function useCreatePullRequestDialogFields({
|
||||
applyGeneratedFields,
|
||||
generation,
|
||||
generateDisabled,
|
||||
linkedWorkItemUrl,
|
||||
onBranchChangedByGeneration,
|
||||
title,
|
||||
worktreeId,
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('planSourceControlCommitMessageGeneration', () => {
|
||||
const result = planSourceControlTextGeneration('pullRequest', {
|
||||
agentId: 'codex',
|
||||
model: 'gpt-5.5',
|
||||
commandInputTemplate: '{basePrompt}\n\nReview {changedFiles}'
|
||||
commandInputTemplate: '{basePrompt}\n\nReview {changedFiles}\n{linkedWorkItemUrl}'
|
||||
})
|
||||
|
||||
expect(result.ok && result.commandLabel).toContain('codex exec')
|
||||
|
||||
@@ -26,6 +26,7 @@ const SYNTHETIC_TEXT_GENERATION_CONTEXT: Record<
|
||||
},
|
||||
pullRequest: {
|
||||
basePrompt: SYNTHETIC_PULL_REQUEST_PROMPT,
|
||||
linkedWorkItemUrl: 'https://linear.app/orca/issue/ORC-123/fix-pr-description-generation',
|
||||
branch: 'feature/example',
|
||||
baseBranch: 'main',
|
||||
currentTitle: 'Draft title',
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
GlobalSettings,
|
||||
GitHubWorkItem,
|
||||
LinearConnectionStatus,
|
||||
LinearIssue,
|
||||
PRInfo,
|
||||
Repo,
|
||||
Worktree
|
||||
} from '../../../shared/types'
|
||||
import type { HostedReviewInfo } from '../../../shared/hosted-review'
|
||||
import {
|
||||
normalizeAbsoluteHttpUrl,
|
||||
resolveSourceControlLinkedWorkItemUrl
|
||||
} from './source-control-linked-work-item-url'
|
||||
|
||||
const repo: Pick<Repo, 'id' | 'path' | 'connectionId'> = { id: 'repo-1', path: '/repo' }
|
||||
|
||||
function worktree(overrides: Partial<Worktree> = {}): Worktree {
|
||||
return {
|
||||
id: 'repo-1::/repo/worktree',
|
||||
repoId: 'repo-1',
|
||||
path: '/repo/worktree',
|
||||
head: 'a'.repeat(40),
|
||||
branch: 'feature/test',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: 'Test',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
linkedGitLabIssue: null,
|
||||
linkedGitLabMR: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function linearIssue(overrides: Partial<LinearIssue> = {}): LinearIssue {
|
||||
return {
|
||||
id: 'issue-id',
|
||||
workspaceId: 'workspace-1',
|
||||
identifier: 'ORC-123',
|
||||
title: 'Fix PR details',
|
||||
url: 'https://linear.app/orca/issue/ORC-123/fix-pr-details',
|
||||
state: { name: 'Todo', type: 'unstarted', color: '#888' },
|
||||
team: { id: 'team-1', name: 'Orca', key: 'ORC' },
|
||||
labels: [],
|
||||
labelIds: [],
|
||||
priority: 0,
|
||||
updatedAt: '',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function linearStatus(overrides: Partial<LinearConnectionStatus> = {}): LinearConnectionStatus {
|
||||
return {
|
||||
connected: true,
|
||||
viewer: {
|
||||
displayName: 'Test User',
|
||||
email: null,
|
||||
organizationName: 'Orca',
|
||||
organizationUrlKey: 'orca'
|
||||
},
|
||||
workspaces: [
|
||||
{
|
||||
id: 'workspace-1',
|
||||
displayName: 'Test User',
|
||||
email: null,
|
||||
organizationId: 'organization-1',
|
||||
organizationName: 'Orca',
|
||||
organizationUrlKey: 'orca'
|
||||
}
|
||||
],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function pullRequest(overrides: Partial<PRInfo> = {}): PRInfo {
|
||||
return {
|
||||
number: 7,
|
||||
title: 'Review',
|
||||
state: 'open',
|
||||
url: 'https://github.com/o/r/pull/7',
|
||||
checksStatus: 'neutral',
|
||||
updatedAt: '',
|
||||
mergeable: 'UNKNOWN',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function gitHubPullRequestWorkItem(overrides: Partial<GitHubWorkItem> = {}): GitHubWorkItem {
|
||||
return {
|
||||
id: 'pr-7',
|
||||
type: 'pr',
|
||||
number: 7,
|
||||
title: 'Review',
|
||||
state: 'open',
|
||||
url: 'https://github.com/o/r/pull/7',
|
||||
labels: [],
|
||||
updatedAt: '',
|
||||
author: null,
|
||||
repoId: 'repo-1',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('normalizeAbsoluteHttpUrl', () => {
|
||||
it('accepts only absolute http and https URLs', () => {
|
||||
expect(normalizeAbsoluteHttpUrl(' https://example.com/work ')).toBe('https://example.com/work')
|
||||
expect(normalizeAbsoluteHttpUrl('http://example.com/work')).toBe('http://example.com/work')
|
||||
expect(normalizeAbsoluteHttpUrl('mailto:test@example.com')).toBeNull()
|
||||
expect(normalizeAbsoluteHttpUrl('/issues/1')).toBeNull()
|
||||
expect(normalizeAbsoluteHttpUrl('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveSourceControlLinkedWorkItemUrl', () => {
|
||||
it('prefers a cached Linear issue URL', () => {
|
||||
expect(
|
||||
resolveSourceControlLinkedWorkItemUrl({
|
||||
worktree: worktree({ linkedLinearIssue: 'ORC-123', linkedIssue: 42 }),
|
||||
repo,
|
||||
linearIssueCache: {
|
||||
'all::ORC-123': { data: linearIssue(), fetchedAt: 1 }
|
||||
},
|
||||
linearStatus: linearStatus(),
|
||||
githubIssueCache: {
|
||||
'repo-1::42': {
|
||||
data: {
|
||||
number: 42,
|
||||
title: 'GitHub issue',
|
||||
state: 'open',
|
||||
url: 'https://github.com/o/r/issues/42',
|
||||
labels: []
|
||||
},
|
||||
fetchedAt: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
).toBe('https://linear.app/orca/issue/ORC-123/fix-pr-details')
|
||||
})
|
||||
|
||||
it('constructs a Linear issue URL only when organization URL key and identifier exist', () => {
|
||||
expect(
|
||||
resolveSourceControlLinkedWorkItemUrl({
|
||||
worktree: worktree({ linkedLinearIssue: 'ORC-123' }),
|
||||
repo,
|
||||
linearStatus: linearStatus()
|
||||
})
|
||||
).toBe('https://linear.app/orca/issue/ORC-123')
|
||||
|
||||
expect(
|
||||
resolveSourceControlLinkedWorkItemUrl({
|
||||
worktree: worktree({ linkedLinearIssue: 'ORC-123' }),
|
||||
repo,
|
||||
linearStatus: linearStatus({ viewer: null, workspaces: [] })
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('uses cached GitHub issue and PR URLs without inventing bare-number URLs', () => {
|
||||
expect(
|
||||
resolveSourceControlLinkedWorkItemUrl({
|
||||
worktree: worktree({ linkedIssue: 42 }),
|
||||
repo,
|
||||
githubIssueCache: {
|
||||
'repo-1::42': {
|
||||
data: {
|
||||
number: 42,
|
||||
title: 'Issue',
|
||||
state: 'open',
|
||||
url: 'https://github.com/o/r/issues/42',
|
||||
labels: []
|
||||
},
|
||||
fetchedAt: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
).toBe('https://github.com/o/r/issues/42')
|
||||
|
||||
expect(
|
||||
resolveSourceControlLinkedWorkItemUrl({
|
||||
worktree: worktree({ linkedIssue: 42 }),
|
||||
repo
|
||||
})
|
||||
).toBeNull()
|
||||
|
||||
expect(
|
||||
resolveSourceControlLinkedWorkItemUrl({
|
||||
worktree: worktree({ linkedPR: 7 }),
|
||||
repo,
|
||||
githubWorkItemsCache: {
|
||||
'repo-1::20::': {
|
||||
data: [gitHubPullRequestWorkItem()],
|
||||
fetchedAt: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
).toBe('https://github.com/o/r/pull/7')
|
||||
})
|
||||
|
||||
it('uses scoped GitHub PR cache entries instead of stale local entries', () => {
|
||||
expect(
|
||||
resolveSourceControlLinkedWorkItemUrl({
|
||||
worktree: worktree({ linkedPR: 7 }),
|
||||
repo: { ...repo, connectionId: 'ssh-1' },
|
||||
branch: 'refs/heads/feature/test',
|
||||
githubPrCache: {
|
||||
'repo-1::feature/test': {
|
||||
data: pullRequest({ url: 'https://github.com/local/r/pull/7' }),
|
||||
fetchedAt: 1
|
||||
},
|
||||
'ssh:ssh-1::repo-1::feature/test': {
|
||||
data: pullRequest({ url: 'https://github.com/ssh/r/pull/7' }),
|
||||
fetchedAt: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
).toBe('https://github.com/ssh/r/pull/7')
|
||||
})
|
||||
|
||||
it('does not use unscoped GitHub work-item PR URLs for scoped execution contexts', () => {
|
||||
const githubWorkItemsCache = {
|
||||
'repo-1::20::': {
|
||||
data: [gitHubPullRequestWorkItem({ url: 'https://github.com/local/r/pull/7' })],
|
||||
fetchedAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
resolveSourceControlLinkedWorkItemUrl({
|
||||
worktree: worktree({ linkedPR: 7 }),
|
||||
repo: { ...repo, connectionId: 'ssh-1' },
|
||||
branch: 'refs/heads/feature/test',
|
||||
githubWorkItemsCache
|
||||
})
|
||||
).toBeNull()
|
||||
|
||||
expect(
|
||||
resolveSourceControlLinkedWorkItemUrl({
|
||||
worktree: worktree({ linkedPR: 7 }),
|
||||
repo,
|
||||
branch: 'refs/heads/feature/test',
|
||||
settings: { activeRuntimeEnvironmentId: 'runtime-1' } as GlobalSettings,
|
||||
githubWorkItemsCache
|
||||
})
|
||||
).toBeNull()
|
||||
|
||||
expect(
|
||||
resolveSourceControlLinkedWorkItemUrl({
|
||||
worktree: worktree({ linkedPR: 7 }),
|
||||
repo,
|
||||
branch: 'refs/heads/feature/test',
|
||||
settings: { activeRuntimeEnvironmentId: 'runtime-1' } as GlobalSettings,
|
||||
githubPrCache: {
|
||||
'runtime:runtime-1::repo-1::feature/test': {
|
||||
data: pullRequest({ url: 'https://github.com/runtime/r/pull/7' }),
|
||||
fetchedAt: 1
|
||||
}
|
||||
},
|
||||
githubWorkItemsCache
|
||||
})
|
||||
).toBe('https://github.com/runtime/r/pull/7')
|
||||
})
|
||||
|
||||
it('does not use provider-number caches when the active repo is unavailable', () => {
|
||||
expect(
|
||||
resolveSourceControlLinkedWorkItemUrl({
|
||||
worktree: worktree({ linkedIssue: 42 }),
|
||||
repo: null,
|
||||
githubIssueCache: {
|
||||
'repo-1::42': {
|
||||
data: {
|
||||
number: 42,
|
||||
title: 'Issue',
|
||||
state: 'open',
|
||||
url: 'https://github.com/o/r/issues/42',
|
||||
labels: []
|
||||
},
|
||||
fetchedAt: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('uses cached hosted GitLab merge request URLs', () => {
|
||||
const review: HostedReviewInfo = {
|
||||
provider: 'gitlab',
|
||||
number: 5,
|
||||
title: 'Review',
|
||||
state: 'open',
|
||||
url: 'https://gitlab.com/o/r/-/merge_requests/5',
|
||||
status: 'neutral',
|
||||
updatedAt: '',
|
||||
mergeable: 'UNKNOWN'
|
||||
}
|
||||
|
||||
expect(
|
||||
resolveSourceControlLinkedWorkItemUrl({
|
||||
worktree: worktree({ linkedGitLabMR: 5 }),
|
||||
repo,
|
||||
branch: 'feature/test',
|
||||
hostedReviewCache: {
|
||||
'local::repo-1::feature/test': { data: review, fetchedAt: 1 }
|
||||
}
|
||||
})
|
||||
).toBe('https://gitlab.com/o/r/-/merge_requests/5')
|
||||
})
|
||||
|
||||
it('uses scoped hosted GitLab merge request cache entries', () => {
|
||||
const localReview: HostedReviewInfo = {
|
||||
provider: 'gitlab',
|
||||
number: 5,
|
||||
title: 'Review',
|
||||
state: 'open',
|
||||
url: 'https://gitlab.com/local/r/-/merge_requests/5',
|
||||
status: 'neutral',
|
||||
updatedAt: '',
|
||||
mergeable: 'UNKNOWN'
|
||||
}
|
||||
const sshReview: HostedReviewInfo = {
|
||||
...localReview,
|
||||
url: 'https://gitlab.com/ssh/r/-/merge_requests/5'
|
||||
}
|
||||
|
||||
expect(
|
||||
resolveSourceControlLinkedWorkItemUrl({
|
||||
worktree: worktree({ linkedGitLabMR: 5 }),
|
||||
repo: { ...repo, connectionId: 'ssh-1' },
|
||||
branch: 'refs/heads/feature/test',
|
||||
hostedReviewCache: {
|
||||
'local::repo-1::feature/test': { data: localReview, fetchedAt: 1 },
|
||||
'ssh:ssh-1::repo-1::feature/test': { data: sshReview, fetchedAt: 1 }
|
||||
}
|
||||
})
|
||||
).toBe('https://gitlab.com/ssh/r/-/merge_requests/5')
|
||||
|
||||
expect(
|
||||
resolveSourceControlLinkedWorkItemUrl({
|
||||
worktree: worktree({ linkedGitLabMR: 5 }),
|
||||
repo: { ...repo, connectionId: 'ssh-1' },
|
||||
branch: 'refs/heads/feature/test',
|
||||
hostedReviewCache: {
|
||||
'local::repo-1::feature/test': { data: localReview, fetchedAt: 1 }
|
||||
}
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not invent GitLab issue URLs from bare issue numbers', () => {
|
||||
expect(
|
||||
resolveSourceControlLinkedWorkItemUrl({
|
||||
worktree: worktree({ linkedGitLabIssue: 5 }),
|
||||
repo
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects cached non-http URLs', () => {
|
||||
expect(
|
||||
resolveSourceControlLinkedWorkItemUrl({
|
||||
worktree: worktree({ linkedLinearIssue: 'ORC-123' }),
|
||||
repo,
|
||||
linearIssueCache: {
|
||||
'all::ORC-123': { data: linearIssue({ url: 'javascript:alert(1)' }), fetchedAt: 1 }
|
||||
},
|
||||
linearStatus: linearStatus()
|
||||
})
|
||||
).toBe('https://linear.app/orca/issue/ORC-123')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,242 @@
|
||||
import type { HostedReviewInfo } from '../../../shared/hosted-review'
|
||||
import type {
|
||||
GlobalSettings,
|
||||
GitHubWorkItem,
|
||||
IssueInfo,
|
||||
LinearConnectionStatus,
|
||||
LinearIssue,
|
||||
PRInfo,
|
||||
Repo,
|
||||
Worktree
|
||||
} from '../../../shared/types'
|
||||
import type { CacheEntry } from '@/store/slices/github'
|
||||
import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key'
|
||||
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity'
|
||||
|
||||
type NullableCache<T> = Record<string, CacheEntry<T>>
|
||||
type WorkItemsCache = Record<string, CacheEntry<GitHubWorkItem[]>>
|
||||
|
||||
export type SourceControlLinkedWorkItemUrlInput = {
|
||||
worktree: Worktree | null | undefined
|
||||
repo: Pick<Repo, 'id' | 'path' | 'connectionId'> | null | undefined
|
||||
branch?: string | null
|
||||
settings?: GlobalSettings | null
|
||||
linearIssueCache?: NullableCache<LinearIssue>
|
||||
linearStatus?: LinearConnectionStatus | null
|
||||
githubIssueCache?: NullableCache<IssueInfo>
|
||||
githubPrCache?: NullableCache<PRInfo>
|
||||
githubWorkItemsCache?: WorkItemsCache
|
||||
hostedReviewCache?: NullableCache<HostedReviewInfo>
|
||||
}
|
||||
|
||||
export function normalizeAbsoluteHttpUrl(value: string | null | undefined): string | null {
|
||||
const trimmed = value?.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function findCachedItem<T>(
|
||||
cache: NullableCache<T> | undefined,
|
||||
predicate: (item: T, key: string) => boolean
|
||||
): T | null {
|
||||
for (const [key, entry] of Object.entries(cache ?? {})) {
|
||||
const item = entry.data
|
||||
if (item && predicate(item, key)) {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function findCachedGitHubWorkItem(args: {
|
||||
cache: WorkItemsCache | undefined
|
||||
repo: Pick<Repo, 'id' | 'path' | 'connectionId'> | null | undefined
|
||||
type: GitHubWorkItem['type']
|
||||
number: number | null | undefined
|
||||
}): GitHubWorkItem | null {
|
||||
if (args.number == null) {
|
||||
return null
|
||||
}
|
||||
for (const entry of Object.values(args.cache ?? {})) {
|
||||
for (const item of entry.data ?? []) {
|
||||
if (item.repoId === args.repo?.id && item.type === args.type && item.number === args.number) {
|
||||
return item
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeBranchName(branch: string | null | undefined): string | null {
|
||||
const normalized = branch?.replace(/^refs\/heads\//, '').trim()
|
||||
return normalized || null
|
||||
}
|
||||
|
||||
function hasScopedExecution(args: SourceControlLinkedWorkItemUrlInput): boolean {
|
||||
return Boolean(
|
||||
args.settings?.activeRuntimeEnvironmentId?.trim() || args.repo?.connectionId?.trim()
|
||||
)
|
||||
}
|
||||
|
||||
function getLinearIssueFromCache(
|
||||
cache: NullableCache<LinearIssue> | undefined,
|
||||
linkedLinearIssue: string | null | undefined
|
||||
): LinearIssue | null {
|
||||
const linked = linkedLinearIssue?.trim()
|
||||
if (!linked) {
|
||||
return null
|
||||
}
|
||||
return findCachedItem(
|
||||
cache,
|
||||
(issue, key) =>
|
||||
issue.id === linked ||
|
||||
issue.identifier === linked ||
|
||||
key === linked ||
|
||||
key.endsWith(`::${linked}`)
|
||||
)
|
||||
}
|
||||
|
||||
function getLinearOrganizationUrlKey(
|
||||
linearStatus: LinearConnectionStatus | null | undefined,
|
||||
issue: LinearIssue | null
|
||||
): string | null {
|
||||
const workspaces = linearStatus?.workspaces ?? []
|
||||
const issueWorkspace = issue?.workspaceId
|
||||
? workspaces.find((workspace) => workspace.id === issue.workspaceId)
|
||||
: undefined
|
||||
const selectedWorkspaceId =
|
||||
linearStatus?.selectedWorkspaceId && linearStatus.selectedWorkspaceId !== 'all'
|
||||
? linearStatus.selectedWorkspaceId
|
||||
: linearStatus?.activeWorkspaceId
|
||||
const selectedWorkspace = selectedWorkspaceId
|
||||
? workspaces.find((workspace) => workspace.id === selectedWorkspaceId)
|
||||
: undefined
|
||||
return (
|
||||
issueWorkspace?.organizationUrlKey?.trim() ||
|
||||
selectedWorkspace?.organizationUrlKey?.trim() ||
|
||||
linearStatus?.viewer?.organizationUrlKey?.trim() ||
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
function buildLinearIssueUrl(args: {
|
||||
linkedLinearIssue: string | null | undefined
|
||||
issue: LinearIssue | null
|
||||
linearStatus: LinearConnectionStatus | null | undefined
|
||||
}): string | null {
|
||||
const cachedUrl = normalizeAbsoluteHttpUrl(args.issue?.url)
|
||||
if (cachedUrl) {
|
||||
return cachedUrl
|
||||
}
|
||||
|
||||
const identifier = (args.issue?.identifier || args.linkedLinearIssue || '').trim()
|
||||
const organizationUrlKey = getLinearOrganizationUrlKey(args.linearStatus, args.issue)
|
||||
if (!identifier || !organizationUrlKey) {
|
||||
return null
|
||||
}
|
||||
return normalizeAbsoluteHttpUrl(
|
||||
`https://linear.app/${encodeURIComponent(organizationUrlKey)}/issue/${encodeURIComponent(
|
||||
identifier
|
||||
)}`
|
||||
)
|
||||
}
|
||||
|
||||
function getGitHubIssueUrl(args: SourceControlLinkedWorkItemUrlInput): string | null {
|
||||
const number = args.worktree?.linkedIssue
|
||||
if (number == null || !args.repo) {
|
||||
return null
|
||||
}
|
||||
const item = findCachedGitHubWorkItem({
|
||||
cache: args.githubWorkItemsCache,
|
||||
repo: args.repo,
|
||||
type: 'issue',
|
||||
number
|
||||
})
|
||||
const cachedIssue =
|
||||
item ??
|
||||
args.githubIssueCache?.[`${args.repo.id}::${number}`]?.data ??
|
||||
args.githubIssueCache?.[`${args.repo.path}::${number}`]?.data ??
|
||||
null
|
||||
return normalizeAbsoluteHttpUrl(cachedIssue?.url)
|
||||
}
|
||||
|
||||
function getGitHubPullRequestUrl(args: SourceControlLinkedWorkItemUrlInput): string | null {
|
||||
const number = args.worktree?.linkedPR
|
||||
if (number == null || !args.repo) {
|
||||
return null
|
||||
}
|
||||
const branch = normalizeBranchName(args.branch)
|
||||
const scopedPR =
|
||||
branch && args.githubPrCache
|
||||
? args.githubPrCache[
|
||||
getGitHubPRCacheKey(
|
||||
args.repo.path,
|
||||
args.repo.id,
|
||||
branch,
|
||||
args.settings,
|
||||
args.repo.connectionId
|
||||
)
|
||||
]?.data
|
||||
: null
|
||||
if (scopedPR?.number === number) {
|
||||
return normalizeAbsoluteHttpUrl(scopedPR.url)
|
||||
}
|
||||
if (branch && hasScopedExecution(args)) {
|
||||
return null
|
||||
}
|
||||
const item = findCachedGitHubWorkItem({
|
||||
cache: args.githubWorkItemsCache,
|
||||
repo: args.repo,
|
||||
type: 'pr',
|
||||
number
|
||||
})
|
||||
return normalizeAbsoluteHttpUrl(item?.url)
|
||||
}
|
||||
|
||||
function getGitLabMergeRequestUrl(args: SourceControlLinkedWorkItemUrlInput): string | null {
|
||||
const number = args.worktree?.linkedGitLabMR
|
||||
const branch = normalizeBranchName(args.branch)
|
||||
if (number == null || !args.repo || !branch) {
|
||||
return null
|
||||
}
|
||||
const cacheKey = getHostedReviewCacheKey(
|
||||
args.repo.path,
|
||||
branch,
|
||||
args.settings,
|
||||
args.repo.id,
|
||||
args.repo.connectionId
|
||||
)
|
||||
const cachedReview = args.hostedReviewCache?.[cacheKey]?.data ?? null
|
||||
if (cachedReview?.provider !== 'gitlab' || cachedReview.number !== number) {
|
||||
return null
|
||||
}
|
||||
return normalizeAbsoluteHttpUrl(cachedReview?.url)
|
||||
}
|
||||
|
||||
export function resolveSourceControlLinkedWorkItemUrl(
|
||||
args: SourceControlLinkedWorkItemUrlInput
|
||||
): string | null {
|
||||
const worktree = args.worktree
|
||||
if (!worktree) {
|
||||
return null
|
||||
}
|
||||
|
||||
const linearIssue = getLinearIssueFromCache(args.linearIssueCache, worktree.linkedLinearIssue)
|
||||
return (
|
||||
buildLinearIssueUrl({
|
||||
linkedLinearIssue: worktree.linkedLinearIssue,
|
||||
issue: linearIssue,
|
||||
linearStatus: args.linearStatus
|
||||
}) ??
|
||||
getGitHubIssueUrl(args) ??
|
||||
getGitLabMergeRequestUrl(args) ??
|
||||
getGitHubPullRequestUrl(args)
|
||||
)
|
||||
}
|
||||
@@ -583,7 +583,13 @@ export async function cancelRuntimeGenerateCommitMessage(
|
||||
|
||||
export async function generateRuntimePullRequestFields(
|
||||
context: RuntimeGitContext,
|
||||
input: { base: string; title: string; body: string; draft: boolean },
|
||||
input: {
|
||||
base: string
|
||||
title: string
|
||||
body: string
|
||||
draft: boolean
|
||||
linkedWorkItemUrl?: string | null
|
||||
},
|
||||
overrides?: RuntimeGeneratePullRequestFieldsOverrides
|
||||
): Promise<RuntimeGeneratePullRequestFieldsResult> {
|
||||
const target = getActiveRuntimeTarget(context.settings)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export type LinkedWorkItemUrlNormalizationResult =
|
||||
| { ok: true; linkedWorkItemUrl: string | null }
|
||||
| { ok: false; error: string }
|
||||
|
||||
export function normalizeLinkedWorkItemUrl(value: unknown): LinkedWorkItemUrlNormalizationResult {
|
||||
if (value === undefined || value === null) {
|
||||
return { ok: true, linkedWorkItemUrl: null }
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return { ok: false, error: 'Invalid linked work item URL.' }
|
||||
}
|
||||
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return { ok: true, linkedWorkItemUrl: null }
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return { ok: false, error: 'Invalid linked work item URL.' }
|
||||
}
|
||||
return { ok: true, linkedWorkItemUrl: url.href }
|
||||
} catch {
|
||||
return { ok: false, error: 'Invalid linked work item URL.' }
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,24 @@ describe('buildPullRequestFieldsPrompt', () => {
|
||||
expect(prompt).toContain('Additional user prompt:')
|
||||
expect(prompt).toContain('Use conventional PR titles.')
|
||||
})
|
||||
|
||||
it('includes linked work item context when a URL exists', () => {
|
||||
const prompt = buildPullRequestFieldsPrompt(
|
||||
{ ...context, linkedWorkItemUrl: 'https://linear.app/orca/issue/ORC-123/fix-pr-details' },
|
||||
''
|
||||
)
|
||||
|
||||
expect(prompt).toContain(
|
||||
'Linked work item URL: https://linear.app/orca/issue/ORC-123/fix-pr-details'
|
||||
)
|
||||
expect(prompt).toContain('If a linked work item URL is provided')
|
||||
})
|
||||
|
||||
it('omits the linked work item section when no URL exists', () => {
|
||||
const prompt = buildPullRequestFieldsPrompt(context, '')
|
||||
|
||||
expect(prompt).not.toContain('Linked work item URL:')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseGeneratedPullRequestFields', () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ export type PullRequestDraftContext = {
|
||||
commitSummary: string
|
||||
changeSummary: string
|
||||
patch: string
|
||||
linkedWorkItemUrl?: string | null
|
||||
}
|
||||
|
||||
export type GeneratedPullRequestFields = {
|
||||
@@ -31,6 +32,7 @@ export function buildPullRequestFieldsPrompt(
|
||||
context: PullRequestDraftContext,
|
||||
customPrompt: string
|
||||
): string {
|
||||
const linkedWorkItemUrl = context.linkedWorkItemUrl?.trim()
|
||||
const base = [
|
||||
'You are generating pull request details.',
|
||||
'Return ONLY compact JSON with this exact shape:',
|
||||
@@ -42,6 +44,7 @@ export function buildPullRequestFieldsPrompt(
|
||||
'- 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.',
|
||||
'- If a linked work item URL is provided, use it as task context and include a concise reference to it in the body when it helps reviewers understand the change.',
|
||||
'- Do not include labels, reviewers, code fences, prose, or any keys beyond base/title/body/draft.',
|
||||
'',
|
||||
`Head branch: ${context.branch ?? '(detached)'}`,
|
||||
@@ -50,6 +53,7 @@ export function buildPullRequestFieldsPrompt(
|
||||
`Current description: ${context.currentBody || '(empty)'}`,
|
||||
`Current draft: ${context.currentDraft ? 'true' : 'false'}`,
|
||||
'',
|
||||
...(linkedWorkItemUrl ? [`Linked work item URL: ${linkedWorkItemUrl}`, ''] : []),
|
||||
'Commits:',
|
||||
limitSection(context.commitSummary || '(none)', 8_000),
|
||||
'',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
SOURCE_CONTROL_ACTION_VARIABLES,
|
||||
normalizeSourceControlAiActionDefaults,
|
||||
readSourceControlActionDefault,
|
||||
renderSourceControlActionCommandTemplate,
|
||||
@@ -117,6 +118,22 @@ describe('source-control AI launch action defaults', () => {
|
||||
).toBe('fix CI with {missing}')
|
||||
})
|
||||
|
||||
it('offers linkedWorkItemUrl for pull-request templates', () => {
|
||||
expect(SOURCE_CONTROL_ACTION_VARIABLES.pullRequest.slice(0, 2)).toEqual([
|
||||
'basePrompt',
|
||||
'linkedWorkItemUrl'
|
||||
])
|
||||
})
|
||||
|
||||
it('renders known optional template variables as empty when no value is provided', () => {
|
||||
expect(
|
||||
renderSourceControlActionCommandTemplate('{basePrompt}\n{linkedWorkItemUrl}', {
|
||||
basePrompt: 'Prompt',
|
||||
linkedWorkItemUrl: ''
|
||||
})
|
||||
).toBe('Prompt\n')
|
||||
})
|
||||
|
||||
it('leaves inherited prototype names visible instead of rendering function source', () => {
|
||||
expect(
|
||||
renderSourceControlActionCommandTemplate('use {constructor} and {toString}', {
|
||||
|
||||
@@ -68,6 +68,7 @@ export const SOURCE_CONTROL_ACTION_VARIABLES: Record<SourceControlActionId, stri
|
||||
commitMessage: ['basePrompt', 'branch', 'stagedFiles', 'stagedPatch'],
|
||||
pullRequest: [
|
||||
'basePrompt',
|
||||
'linkedWorkItemUrl',
|
||||
'branch',
|
||||
'baseBranch',
|
||||
'currentTitle',
|
||||
@@ -111,6 +112,11 @@ export const SOURCE_CONTROL_ACTION_VARIABLE_INFO: Record<string, SourceControlAc
|
||||
description: 'The target branch selected in the Create PR composer.',
|
||||
example: 'main'
|
||||
},
|
||||
linkedWorkItemUrl: {
|
||||
description:
|
||||
'Provider-neutral URL for the item linked to the current worktree: Linear issue, GitHub issue or pull request, or GitLab merge request.',
|
||||
example: 'https://github.com/stablyai/orca/issues/123'
|
||||
},
|
||||
currentTitle: {
|
||||
description: 'The PR title currently typed in the composer before generation starts.',
|
||||
example: 'Improve Source Control AI customization'
|
||||
|
||||
Reference in New Issue
Block a user