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:
Jinjing
2026-06-08 21:54:24 -07:00
parent d43ce62412
commit 27f6c2f005
30 changed files with 1295 additions and 131 deletions
+104
View File
@@ -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
View File
@@ -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 } : {})
}
)
}
)
+42 -2
View File
@@ -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 = {
+36 -13
View File
@@ -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 }> {
+2 -1
View File
@@ -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({
+36 -1
View File
@@ -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',
+9 -1
View File
@@ -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,
+1
View File
@@ -2054,6 +2054,7 @@ export type PreloadApi = {
title: string
body: string
draft: boolean
linkedWorkItemUrl?: string | null
connectionId?: string
sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams
sourceControlAi?: SourceControlAiSettings
+1
View File
@@ -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)
+27
View File
@@ -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', () => {
+4
View File
@@ -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}', {
+6
View File
@@ -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'