From 27f6c2f00523fae7c9cb8cb46cd552147e3d9e7e Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:54:24 -0700 Subject: [PATCH] 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. --- src/main/ipc/filesystem.test.ts | 104 +++++ src/main/ipc/filesystem.ts | 42 +- src/main/runtime/orca-runtime-git.test.ts | 44 +- src/main/runtime/orca-runtime-git.ts | 49 ++- src/main/runtime/rpc/methods/git-params.ts | 3 +- src/main/runtime/rpc/methods/git.test.ts | 37 +- src/main/runtime/rpc/methods/git.ts | 10 +- .../commit-message-text-generation.test.ts | 90 +++++ .../commit-message-text-generation.ts | 2 + src/preload/api-types.ts | 1 + src/preload/index.ts | 1 + .../right-sidebar/CreatePullRequestDialog.tsx | 102 ++--- .../CreatePullRequestLinkedWorkItemRow.tsx | 33 ++ .../right-sidebar/SourceControl.tsx | 56 +-- .../SourceControlTextGenerationDialog.tsx | 3 + .../SourceControlTextGenerationDialogForm.tsx | 3 + .../create-pull-request-ai-defaults.ts | 38 ++ .../hosted-review-creation-copy.ts | 44 ++ .../useCreatePullRequestDialogFields.test.ts | 37 +- .../useCreatePullRequestDialogFields.ts | 24 +- .../source-control-generation-plan.test.ts | 2 +- .../src/lib/source-control-generation-plan.ts | 1 + ...ource-control-linked-work-item-url.test.ts | 378 ++++++++++++++++++ .../source-control-linked-work-item-url.ts | 242 +++++++++++ .../src/runtime/runtime-git-client.ts | 8 +- src/shared/linked-work-item-url.ts | 27 ++ src/shared/pull-request-generation.test.ts | 18 + src/shared/pull-request-generation.ts | 4 + src/shared/source-control-ai-actions.test.ts | 17 + src/shared/source-control-ai-actions.ts | 6 + 30 files changed, 1295 insertions(+), 131 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/CreatePullRequestLinkedWorkItemRow.tsx create mode 100644 src/renderer/src/components/right-sidebar/create-pull-request-ai-defaults.ts create mode 100644 src/renderer/src/components/right-sidebar/hosted-review-creation-copy.ts create mode 100644 src/renderer/src/lib/source-control-linked-work-item-url.test.ts create mode 100644 src/renderer/src/lib/source-control-linked-work-item-url.ts create mode 100644 src/shared/linked-work-item-url.ts diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index aa6466ef2fb..c9f7804a083 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -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', diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index f28f7244548..ccbf3a7afab 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -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 } : {}) + } + ) } ) diff --git a/src/main/runtime/orca-runtime-git.test.ts b/src/main/runtime/orca-runtime-git.test.ts index 603372cee4f..fb6117c86bf 100644 --- a/src/main/runtime/orca-runtime-git.test.ts +++ b/src/main/runtime/orca-runtime-git.test.ts @@ -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 = { diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index 9adc9dc0e6b..45fa5bb3418 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -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 { 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 }> { diff --git a/src/main/runtime/rpc/methods/git-params.ts b/src/main/runtime/rpc/methods/git-params.ts index f4adb156bc8..c50d106ff7f 100644 --- a/src/main/runtime/rpc/methods/git-params.ts +++ b/src/main/runtime/rpc/methods/git-params.ts @@ -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({ diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts index 06452036631..47e77ebb992 100644 --- a/src/main/runtime/rpc/methods/git.test.ts +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -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', diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index 3754bc8ee9a..ec4cbd6c124 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -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) { diff --git a/src/main/text-generation/commit-message-text-generation.test.ts b/src/main/text-generation/commit-message-text-generation.test.ts index d640cc160e0..7ead6d59b45 100644 --- a/src/main/text-generation/commit-message-text-generation.test.ts +++ b/src/main/text-generation/commit-message-text-generation.test.ts @@ -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 void>() spawnMock.mockReturnValue({ diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts index bfa1ff6cc7e..75a9aff5513 100644 --- a/src/main/text-generation/commit-message-text-generation.ts +++ b/src/main/text-generation/commit-message-text-generation.ts @@ -816,10 +816,12 @@ export async function generatePullRequestFieldsFromContext( target: CommitMessageGenerationTarget ): Promise { 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, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 951d71b53f1..dc8d138e760 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2054,6 +2054,7 @@ export type PreloadApi = { title: string body: string draft: boolean + linkedWorkItemUrl?: string | null connectionId?: string sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams sourceControlAi?: SourceControlAiSettings diff --git a/src/preload/index.ts b/src/preload/index.ts index e479ef67851..18449322f51 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2510,6 +2510,7 @@ const api = { title: string body: string draft: boolean + linkedWorkItemUrl?: string | null connectionId?: string sourceControlAiResolvedParams?: unknown sourceControlAi?: unknown diff --git a/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx b/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx index 160fa93c135..4a29f61c567 100644 --- a/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx +++ b/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx @@ -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 } -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(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. + {aiGenerationEnabled && linkedWorkItemUrl ? ( + + ) : null}
diff --git a/src/renderer/src/components/right-sidebar/CreatePullRequestLinkedWorkItemRow.tsx b/src/renderer/src/components/right-sidebar/CreatePullRequestLinkedWorkItemRow.tsx new file mode 100644 index 00000000000..9786d229944 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/CreatePullRequestLinkedWorkItemRow.tsx @@ -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 ( +
+ + Linked work item + + + + + Open linked work item + +
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 9b88b2d3071..69103be7480 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -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}
+ {aiGenerationEnabled && 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. */}
diff --git a/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialog.tsx b/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialog.tsx index 2e6ffc6052c..bd325b9aa1e 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialog.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialog.tsx @@ -27,6 +27,7 @@ type SourceControlTextGenerationBaseDialogProps = { settings: GlobalSettings | null repo?: Pick | null discoveryHostKey: string + variablePreviews?: Partial> 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} diff --git a/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialogForm.tsx b/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialogForm.tsx index 56c48a39852..68da5b11d3e 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialogForm.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialogForm.tsx @@ -48,6 +48,7 @@ type SourceControlTextGenerationDialogFormProps = { settings: GlobalSettings | null baseParams: ResolvedSourceControlAiGenerationParams | null saveTargets: SourceControlTextGenerationSaveTarget[] + variablePreviews?: Partial> 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({ /> { const separator = commandTemplate.endsWith('\n') || commandTemplate.length === 0 ? '' : ' ' diff --git a/src/renderer/src/components/right-sidebar/create-pull-request-ai-defaults.ts b/src/renderer/src/components/right-sidebar/create-pull-request-ai-defaults.ts new file mode 100644 index 00000000000..b5601a90f5f --- /dev/null +++ b/src/renderer/src/components/right-sidebar/create-pull-request-ai-defaults.ts @@ -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 | null | undefined +}): Required { + 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 + }) +} diff --git a/src/renderer/src/components/right-sidebar/hosted-review-creation-copy.ts b/src/renderer/src/components/right-sidebar/hosted-review-creation-copy.ts new file mode 100644 index 00000000000..fb23d808b1f --- /dev/null +++ b/src/renderer/src/components/right-sidebar/hosted-review-creation-copy.ts @@ -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 +} diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts index d1678f6c5cf..4af9e8ef1ab 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts @@ -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' + ) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts index 660de667d80..dbe667c7263 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts @@ -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 | null + repo?: Pick | null settings: AppState['settings'] submitting: boolean + linkedWorkItemUrl?: string | null prCreationDefaults?: SourceControlAiPrCreationDefaults onBranchChangedByGeneration?: () => Promise generation?: { @@ -93,6 +96,15 @@ export function normalizeCreateReviewBaseSearchResults( return branches } +export function getCreateReviewDiscoveryHostKey( + settings: Pick | null | undefined, + repo: Pick | 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, diff --git a/src/renderer/src/lib/source-control-generation-plan.test.ts b/src/renderer/src/lib/source-control-generation-plan.test.ts index a51e12a1db4..744a6e5187b 100644 --- a/src/renderer/src/lib/source-control-generation-plan.test.ts +++ b/src/renderer/src/lib/source-control-generation-plan.test.ts @@ -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') diff --git a/src/renderer/src/lib/source-control-generation-plan.ts b/src/renderer/src/lib/source-control-generation-plan.ts index 349f4e74d45..7d4e64dbc01 100644 --- a/src/renderer/src/lib/source-control-generation-plan.ts +++ b/src/renderer/src/lib/source-control-generation-plan.ts @@ -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', diff --git a/src/renderer/src/lib/source-control-linked-work-item-url.test.ts b/src/renderer/src/lib/source-control-linked-work-item-url.test.ts new file mode 100644 index 00000000000..74cd88e7f9a --- /dev/null +++ b/src/renderer/src/lib/source-control-linked-work-item-url.test.ts @@ -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 = { id: 'repo-1', path: '/repo' } + +function worktree(overrides: Partial = {}): 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 { + 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 { + 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 { + 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 { + 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') + }) +}) diff --git a/src/renderer/src/lib/source-control-linked-work-item-url.ts b/src/renderer/src/lib/source-control-linked-work-item-url.ts new file mode 100644 index 00000000000..ba89cd5d10f --- /dev/null +++ b/src/renderer/src/lib/source-control-linked-work-item-url.ts @@ -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 = Record> +type WorkItemsCache = Record> + +export type SourceControlLinkedWorkItemUrlInput = { + worktree: Worktree | null | undefined + repo: Pick | null | undefined + branch?: string | null + settings?: GlobalSettings | null + linearIssueCache?: NullableCache + linearStatus?: LinearConnectionStatus | null + githubIssueCache?: NullableCache + githubPrCache?: NullableCache + githubWorkItemsCache?: WorkItemsCache + hostedReviewCache?: NullableCache +} + +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( + cache: NullableCache | 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 | 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 | 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) + ) +} diff --git a/src/renderer/src/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts index 958d82c820f..061164756bc 100644 --- a/src/renderer/src/runtime/runtime-git-client.ts +++ b/src/renderer/src/runtime/runtime-git-client.ts @@ -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 { const target = getActiveRuntimeTarget(context.settings) diff --git a/src/shared/linked-work-item-url.ts b/src/shared/linked-work-item-url.ts new file mode 100644 index 00000000000..bc87bda79a5 --- /dev/null +++ b/src/shared/linked-work-item-url.ts @@ -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.' } + } +} diff --git a/src/shared/pull-request-generation.test.ts b/src/shared/pull-request-generation.test.ts index fdf7173caec..3a27e6d51cd 100644 --- a/src/shared/pull-request-generation.test.ts +++ b/src/shared/pull-request-generation.test.ts @@ -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', () => { diff --git a/src/shared/pull-request-generation.ts b/src/shared/pull-request-generation.ts index 7edc1561cfc..e6fb5cc680b 100644 --- a/src/shared/pull-request-generation.ts +++ b/src/shared/pull-request-generation.ts @@ -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), '', diff --git a/src/shared/source-control-ai-actions.test.ts b/src/shared/source-control-ai-actions.test.ts index d899e6a5804..dbc2ea3a697 100644 --- a/src/shared/source-control-ai-actions.test.ts +++ b/src/shared/source-control-ai-actions.test.ts @@ -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}', { diff --git a/src/shared/source-control-ai-actions.ts b/src/shared/source-control-ai-actions.ts index 38012f0a2dd..52ea25b7ee4 100644 --- a/src/shared/source-control-ai-actions.ts +++ b/src/shared/source-control-ai-actions.ts @@ -68,6 +68,7 @@ export const SOURCE_CONTROL_ACTION_VARIABLES: Record