From 51173e45d45a07fd21f46a5a446c2ce8fb02b7c0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:16:50 -0700 Subject: [PATCH] feat(jira): generate issue titles on the connection host (#28625) Generate Jira issue titles through the selected execution host, protecting cancelled requests and treating Jira content as quoted prompt data. Co-authored-by: Kazuki Hanai --- .../ipc/jira-issue-summary-generation.test.ts | 55 +++ src/main/ipc/jira-issue-summary-generation.ts | 41 +++ .../register-core-handlers.test.ts | 8 + .../register-core-handlers.ts | 2 + .../runtime/orca-runtime-terminal-drivers.ts | 4 + src/main/runtime/rpc/methods/jira.test.ts | 52 +++ src/main/runtime/rpc/methods/jira.ts | 32 +- .../runtime-edge-command-controller.ts | 4 +- src/main/runtime/runtime-jira-commands.ts | 27 ++ .../jira-issue-summary-requests.test.ts | 133 +++++++ .../jira-issue-summary-requests.ts | 111 ++++++ ...jira-issue-summary-text-generation.test.ts | 97 ++++++ .../jira-issue-summary-text-generation.ts | 61 ++++ .../source-control-text-generation-types.ts | 1 + src/preload/api/jira-api.ts | 9 + src/preload/api/jira-bridge.ts | 9 + .../src/components/task-page/TaskPage.tsx | 16 +- .../components/task-page/jira/IssueDialog.tsx | 21 +- .../task-page/jira/IssueTitleField.tsx | 110 ++++++ .../use-task-page-global-effects.ts | 4 +- ...task-page-jira-summary-generation.test.tsx | 324 ++++++++++++++++++ .../use-task-page-jira-summary-generation.ts | 228 ++++++++++++ src/renderer/src/i18n/locales/en.json | 9 + ...ime-jira-summary-generation-client.test.ts | 82 +++++ .../runtime-jira-summary-generation-client.ts | 56 +++ .../src/web/preload-api/web-jira-api.ts | 9 + .../src/web/web-preload-api-jira.test.ts | 17 + .../jira-issue-summary-generation.test.ts | 93 +++++ src/shared/jira-issue-summary-generation.ts | 75 ++++ src/shared/protocol-version.ts | 3 + src/shared/rpc-contract/jira-params.ts | 12 + .../rpc-params-catalog.generated.ts | 4 + 32 files changed, 1679 insertions(+), 30 deletions(-) create mode 100644 src/main/ipc/jira-issue-summary-generation.test.ts create mode 100644 src/main/ipc/jira-issue-summary-generation.ts create mode 100644 src/main/text-generation/jira-issue-summary-requests.test.ts create mode 100644 src/main/text-generation/jira-issue-summary-requests.ts create mode 100644 src/main/text-generation/jira-issue-summary-text-generation.test.ts create mode 100644 src/main/text-generation/jira-issue-summary-text-generation.ts create mode 100644 src/renderer/src/components/task-page/jira/IssueTitleField.tsx create mode 100644 src/renderer/src/components/use-task-page-jira-summary-generation.test.tsx create mode 100644 src/renderer/src/components/use-task-page-jira-summary-generation.ts create mode 100644 src/renderer/src/runtime/runtime-jira-summary-generation-client.test.ts create mode 100644 src/renderer/src/runtime/runtime-jira-summary-generation-client.ts create mode 100644 src/shared/jira-issue-summary-generation.test.ts create mode 100644 src/shared/jira-issue-summary-generation.ts diff --git a/src/main/ipc/jira-issue-summary-generation.test.ts b/src/main/ipc/jira-issue-summary-generation.test.ts new file mode 100644 index 00000000000..601bf3127b1 --- /dev/null +++ b/src/main/ipc/jira-issue-summary-generation.test.ts @@ -0,0 +1,55 @@ +import { EventEmitter } from 'node:events' +import { beforeEach, describe, expect, it, vi } from 'vitest' +const mocks = vi.hoisted(() => ({ handle: vi.fn(), generate: vi.fn(), cancel: vi.fn() })) +vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) +vi.mock('../text-generation/jira-issue-summary-requests', () => ({ + generateJiraIssueSummaryRequest: mocks.generate, + cancelJiraIssueSummaryRequest: mocks.cancel +})) +import { registerJiraIssueSummaryGenerationHandlers } from './jira-issue-summary-generation' +function handler(channel: string) { + const callback = mocks.handle.mock.calls.find(([name]) => name === channel)?.[1] + if (typeof callback !== 'function') { + throw new Error(`Missing handler: ${channel}`) + } + return callback +} +const args = { description: 'Draft', siteId: 'site-1', requestId: 'req-1', draftId: 'draft-1' } +beforeEach(() => { + vi.clearAllMocks() + registerJiraIssueSummaryGenerationHandlers({ getSettings: vi.fn() }) +}) +describe('Jira generation IPC ownership', () => { + it('binds generation and cancel to the sending window', async () => { + const sender = Object.assign(new EventEmitter(), { id: 7 }) + mocks.generate.mockResolvedValue({ success: true, summary: 'Title' }) + await handler('jira:generateIssueSummary')({ sender }, args) + expect(mocks.generate).toHaveBeenCalledWith(expect.anything(), 'window:7', args) + handler('jira:cancelGenerateIssueSummary')({ sender: { id: 8 } }, args) + expect(mocks.cancel).toHaveBeenCalledWith( + 'window:8', + expect.objectContaining({ requestId: 'req-1' }) + ) + expect(sender.listenerCount('destroyed')).toBe(0) + }) + it('cancels the exact draft request when its window is destroyed', async () => { + const sender = Object.assign(new EventEmitter(), { id: 7 }) + let finish!: () => void + mocks.generate.mockReturnValue( + new Promise((resolve) => { + finish = resolve + }) + ) + const pending = handler('jira:generateIssueSummary')({ sender }, args) + sender.emit('destroyed') + expect(mocks.cancel).toHaveBeenCalledWith('window:7', args) + finish() + await pending + }) + it('rejects an unscoped generation request', async () => { + await expect( + handler('jira:generateIssueSummary')({ sender: new EventEmitter() }, { description: 'Draft' }) + ).rejects.toThrow() + expect(mocks.generate).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/jira-issue-summary-generation.ts b/src/main/ipc/jira-issue-summary-generation.ts new file mode 100644 index 00000000000..e6ff36b2f77 --- /dev/null +++ b/src/main/ipc/jira-issue-summary-generation.ts @@ -0,0 +1,41 @@ +import { ipcMain } from 'electron' +import { + GenerateIssueSummary, + CancelGenerateIssueSummary +} from '../../shared/rpc-contract/jira-params' +import type { Store } from '../persistence' +import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' +import { + cancelJiraIssueSummaryRequest, + generateJiraIssueSummaryRequest +} from '../text-generation/jira-issue-summary-requests' + +export function registerJiraIssueSummaryGenerationHandlers( + store: Pick, + commitMessageAgentEnv?: CommitMessageAgentEnvironmentResolvers +): void { + ipcMain.handle('jira:generateIssueSummary', async (event, args: unknown) => { + const request = GenerateIssueSummary.parse(args) + const owner = `window:${event.sender.id}` + const cancel = (): void => cancelJiraIssueSummaryRequest(owner, request) + event.sender.once('destroyed', cancel) + try { + return await generateJiraIssueSummaryRequest( + { + getSettings: () => store.getSettings(), + getAgentEnvironment: () => commitMessageAgentEnv + }, + owner, + request + ) + } finally { + event.sender.removeListener('destroyed', cancel) + } + }) + ipcMain.handle('jira:cancelGenerateIssueSummary', (event, args: unknown) => { + cancelJiraIssueSummaryRequest( + `window:${event.sender.id}`, + CancelGenerateIssueSummary.parse(args) + ) + }) +} diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts index bec4937b740..f0af5ba47b5 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts @@ -51,6 +51,7 @@ const { registerAppHandlersMock, registerLinearHandlersMock, registerJiraHandlersMock, + registerJiraIssueSummaryGenerationHandlersMock, registerBitbucketHandlersMock, registerGitLabHandlersMock, registerHostedReviewHandlersMock, @@ -120,6 +121,7 @@ const { registerAppHandlersMock: vi.fn(), registerLinearHandlersMock: vi.fn(), registerJiraHandlersMock: vi.fn(), + registerJiraIssueSummaryGenerationHandlersMock: vi.fn(), registerBitbucketHandlersMock: vi.fn(), registerGitLabHandlersMock: vi.fn(), registerHostedReviewHandlersMock: vi.fn(), @@ -395,6 +397,10 @@ vi.mock('../jira', () => ({ registerJiraHandlers: registerJiraHandlersMock })) +vi.mock('../jira-issue-summary-generation', () => ({ + registerJiraIssueSummaryGenerationHandlers: registerJiraIssueSummaryGenerationHandlersMock +})) + vi.mock('../bitbucket', () => ({ registerBitbucketHandlers: registerBitbucketHandlersMock })) @@ -477,6 +483,7 @@ describe('registerCoreHandlers', () => { registerAppHandlersMock.mockReset() registerLinearHandlersMock.mockReset() registerJiraHandlersMock.mockReset() + registerJiraIssueSummaryGenerationHandlersMock.mockReset() registerBitbucketHandlersMock.mockReset() registerGitLabHandlersMock.mockReset() registerHostedReviewHandlersMock.mockReset() @@ -580,6 +587,7 @@ describe('registerCoreHandlers', () => { expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats) expect(registerLinearHandlersMock).toHaveBeenCalled() expect(registerJiraHandlersMock).toHaveBeenCalled() + expect(registerJiraIssueSummaryGenerationHandlersMock).toHaveBeenCalledWith(store, undefined) expect(registerBitbucketHandlersMock).toHaveBeenCalled() expect(registerGitLabHandlersMock).toHaveBeenCalledWith(store) expect(registerHostedReviewHandlersMock).toHaveBeenCalledWith(store, stats) diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index 48234504d43..a125d9f98fb 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -14,6 +14,7 @@ import { registerGitLabHandlers } from '../gitlab' import { registerHostedReviewHandlers } from '../hosted-review' import { registerLinearHandlers } from '../linear' import { registerJiraHandlers } from '../jira' +import { registerJiraIssueSummaryGenerationHandlers } from '../jira-issue-summary-generation' import { registerBitbucketHandlers } from '../bitbucket' import { registerFeedbackHandlers } from '../feedback' import { registerCrashReportingHandlers } from '../crash-reporting' @@ -186,6 +187,7 @@ export function registerCoreHandlers( registerHostedReviewHandlers(store, stats) registerLinearHandlers() registerJiraHandlers() + registerJiraIssueSummaryGenerationHandlers(store, commitMessageAgentEnv) registerBitbucketHandlers() registerFeedbackHandlers() if (crashReports) { diff --git a/src/main/runtime/orca-runtime-terminal-drivers.ts b/src/main/runtime/orca-runtime-terminal-drivers.ts index aae40a24d22..14f29fcc82c 100644 --- a/src/main/runtime/orca-runtime-terminal-drivers.ts +++ b/src/main/runtime/orca-runtime-terminal-drivers.ts @@ -55,6 +55,10 @@ export abstract class OrcaRuntimeWithTerminalDrivers extends OrcaRuntimeWithFitO }) protected readonly edgeCommands = new RuntimeEdgeCommandController({ + jiraHost: { + getSettings: () => this.requireStore().getSettings(), + getAgentEnvironment: () => this.accounts.getCommitMessageAgentEnvironment() + }, browserHost: { getAgentBrowserBridge: () => this.agentBrowserBridge, resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector), diff --git a/src/main/runtime/rpc/methods/jira.test.ts b/src/main/runtime/rpc/methods/jira.test.ts index 79cd3354daa..4c3fae446f3 100644 --- a/src/main/runtime/rpc/methods/jira.test.ts +++ b/src/main/runtime/rpc/methods/jira.test.ts @@ -280,3 +280,55 @@ describe('jira RPC methods', () => { expect(runtime.jiraGetProjectStatusOrder).toHaveBeenCalledWith('ALP', 'site-1') }) }) + +describe('Jira title generation RPC', () => { + it('binds generation and stop to the authenticated device across one-shot sockets', async () => { + const generate = vi.fn().mockResolvedValue({ success: true, summary: 'Title' }) + const cancel = vi.fn().mockReturnValue({ ok: true }) + const stub = { + getRuntimeId: () => 'test-runtime', + jiraGenerateIssueSummary: generate, + jiraCancelGenerateIssueSummary: cancel + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these methods only use the three supplied runtime operations. + const runtime = stub as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: JIRA_METHODS }) + const args = { siteId: 'site-1', requestId: 'req-1', draftId: 'draft-1', description: 'Draft' } + const abort = new AbortController() + await dispatcher.dispatch(makeRequest('jira.generateIssueSummary', args), { + connectionId: 'connection-1', + pairedDeviceId: 'device-1', + signal: abort.signal + }) + await dispatcher.dispatch(makeRequest('jira.cancelGenerateIssueSummary', args), { + connectionId: 'connection-2', + pairedDeviceId: 'device-1' + }) + expect(generate).toHaveBeenCalledWith(args, 'device:device-1', abort.signal) + await dispatcher.dispatch(makeRequest('jira.cancelGenerateIssueSummary', args), { + connectionId: 'connection-3', + pairedDeviceId: 'device-2' + }) + expect(cancel).toHaveBeenLastCalledWith( + { siteId: 'site-1', requestId: 'req-1', draftId: 'draft-1' }, + 'device:device-2' + ) + expect(cancel).toHaveBeenCalledWith( + { siteId: 'site-1', requestId: 'req-1', draftId: 'draft-1' }, + 'device:device-1' + ) + }) + + it('rejects an unscoped request before generation starts', async () => { + const generate = vi.fn() + const stub = { getRuntimeId: () => 'test-runtime', jiraGenerateIssueSummary: generate } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: schema rejection never accesses other runtime operations. + const runtime = stub as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: JIRA_METHODS }) + const reply = await dispatcher.dispatch( + makeRequest('jira.generateIssueSummary', { description: 'Draft' }) + ) + expect(reply.ok).toBe(false) + expect(generate).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/jira.ts b/src/main/runtime/rpc/methods/jira.ts index 8fb6270acb2..6460e1518d4 100644 --- a/src/main/runtime/rpc/methods/jira.ts +++ b/src/main/runtime/rpc/methods/jira.ts @@ -2,9 +2,11 @@ import { JIRA_PAYLOAD_CHUNK_CHARS, JIRA_PAYLOAD_MAX_CHARS } from '../../../../shared/jira-payload-stream' -import { defineMethod, defineStreamingMethod } from '../core' +import { defineMethod, defineStreamingMethod, type RpcContext } from '../core' import { AssignableUsers, + GenerateIssueSummary, + CancelGenerateIssueSummary, Connect, CreateIssue, IssueComment, @@ -34,7 +36,35 @@ function emitJiraPayload(value: unknown, emit: (result: unknown) => void): void emit({ type: 'end' }) } +function generationOwner(context: Pick): string { + // One-shot RPCs use different sockets for Generate and Stop. + if (context.pairedDeviceId) { + return `device:${context.pairedDeviceId}` + } + if (context.caller?.kind === 'paired-device') { + return `device:${context.caller.deviceId}` + } + if (context.caller?.kind === 'local-cli') { + return 'local-cli' + } + throw new Error('A title generation request needs an authenticated connection.') +} + export const JIRA_METHODS = [ + defineMethod({ + name: 'jira.generateIssueSummary', + permission: 'workspace', + params: GenerateIssueSummary, + handler: (params, context) => + context.runtime.jiraGenerateIssueSummary(params, generationOwner(context), context.signal) + }), + defineMethod({ + name: 'jira.cancelGenerateIssueSummary', + permission: 'workspace', + params: CancelGenerateIssueSummary, + handler: (params, context) => + context.runtime.jiraCancelGenerateIssueSummary(params, generationOwner(context)) + }), defineMethod({ name: 'jira.connect', permission: 'accounts-admin', diff --git a/src/main/runtime/runtime-edge-command-controller.ts b/src/main/runtime/runtime-edge-command-controller.ts index 5baa3ddf3a6..9dc9b63a3ac 100644 --- a/src/main/runtime/runtime-edge-command-controller.ts +++ b/src/main/runtime/runtime-edge-command-controller.ts @@ -137,7 +137,7 @@ function bindNamedMethods( } export class RuntimeEdgeCommandController { - private readonly jira = new RuntimeJiraCommands() + private readonly jira: RuntimeJiraCommands private readonly browser: RuntimeBrowserCommands private readonly screencasts: RuntimeBrowserScreencastController private readonly emulator: RuntimeEmulatorCommands @@ -145,10 +145,12 @@ export class RuntimeEdgeCommandController { constructor(args: { browserHost: RuntimeBrowserCommandHost + jiraHost: ConstructorParameters[0] screencast: Omit emulatorHost: EmulatorHost getBrowserCommands?: () => RuntimeBrowserCommands }) { + this.jira = new RuntimeJiraCommands(args.jiraHost) this.browser = createRuntimeBrowserCommands(args.browserHost) this.screencasts = new RuntimeBrowserScreencastController({ ...args.screencast, diff --git a/src/main/runtime/runtime-jira-commands.ts b/src/main/runtime/runtime-jira-commands.ts index 64716f92614..ac4353007f9 100644 --- a/src/main/runtime/runtime-jira-commands.ts +++ b/src/main/runtime/runtime-jira-commands.ts @@ -1,3 +1,12 @@ +import type { + JiraIssueSummaryGenerationRequest, + JiraIssueSummaryGenerationCancellation +} from '../../shared/jira-issue-summary-generation' +import { + generateJiraIssueSummaryRequest, + cancelJiraIssueSummaryRequest, + type JiraIssueSummaryGenerationHost +} from '../text-generation/jira-issue-summary-requests' import type { JiraConnectArgs, JiraCreateIssueArgs, @@ -26,6 +35,24 @@ import { } from '../jira/issues' export class RuntimeJiraCommands { + constructor(private readonly host: JiraIssueSummaryGenerationHost) {} + + jiraGenerateIssueSummary( + args: JiraIssueSummaryGenerationRequest, + owner: string, + signal?: AbortSignal + ) { + return generateJiraIssueSummaryRequest(this.host, owner, args, signal) + } + + jiraCancelGenerateIssueSummary( + args: JiraIssueSummaryGenerationCancellation, + owner: string + ): { ok: true } { + cancelJiraIssueSummaryRequest(owner, args) + return { ok: true } + } + jiraConnect(args: JiraConnectArgs): ReturnType { return connect(args) } diff --git a/src/main/text-generation/jira-issue-summary-requests.test.ts b/src/main/text-generation/jira-issue-summary-requests.test.ts new file mode 100644 index 00000000000..13acd2f0721 --- /dev/null +++ b/src/main/text-generation/jira-issue-summary-requests.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +const mocks = vi.hoisted(() => ({ + prepare: vi.fn(), + generate: vi.fn(), + cancel: vi.fn(), + resolve: vi.fn(), + status: vi.fn() +})) +vi.mock('../jira/client', () => ({ getStatus: mocks.status })) +vi.mock('./commit-message-agent-environment', () => ({ + prepareLocalCommitMessageAgentEnv: mocks.prepare +})) +vi.mock('./commit-message-text-generation', () => ({ resolveTextGenerationParams: mocks.resolve })) +vi.mock('./jira-issue-summary-text-generation', () => ({ + generateJiraIssueSummaryFromContext: mocks.generate, + cancelGenerateJiraIssueSummaryLocal: mocks.cancel +})) +import { + generateJiraIssueSummaryRequest, + cancelJiraIssueSummaryRequest +} from './jira-issue-summary-requests' +import { getDefaultSettings } from '../../shared/constants' +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} +const args = { description: 'Draft', siteId: 'site-1', requestId: 'req-1', draftId: 'draft-1' } +const settings = getDefaultSettings('/fixture') +const host = { + getSettings: () => settings, + getAgentEnvironment: () => ({ prepareForCodexLaunch: () => 'host-codex-home' }) +} +beforeEach(() => { + vi.clearAllMocks() + mocks.resolve.mockReturnValue({ + ok: true, + params: { agentId: 'custom', model: '', customAgentCommand: 'agent' } + }) + mocks.prepare.mockResolvedValue({ ok: true, env: { CODEX_HOME: 'host-codex-home' } }) + mocks.generate.mockResolvedValue({ success: true, summary: 'Generated title' }) + mocks.status.mockReturnValue({ sites: [{ id: 'site-1' }, { id: 'site-2' }] }) +}) +describe('Jira title host ownership', () => { + it('uses the owning host settings and account environment', async () => { + expect(await generateJiraIssueSummaryRequest(host, 'owner-1', args)).toMatchObject({ + success: true + }) + expect(mocks.resolve).toHaveBeenCalledWith(settings, 'local', 'branchName', null) + expect(mocks.prepare).toHaveBeenCalledWith( + 'custom', + expect.objectContaining({ prepareForCodexLaunch: expect.any(Function) }) + ) + expect(mocks.generate).toHaveBeenCalledWith( + args, + expect.anything(), + expect.objectContaining({ kind: 'local', env: { CODEX_HOME: 'host-codex-home' } }) + ) + }) + it('does not launch a replaced request after its environment finishes', async () => { + const slow = deferred<{ ok: true }>() + mocks.prepare.mockReturnValueOnce(slow.promise) + const old = generateJiraIssueSummaryRequest(host, 'owner-1', args) + await generateJiraIssueSummaryRequest(host, 'owner-1', { + ...args, + requestId: 'req-2', + description: 'New' + }) + slow.resolve({ ok: true }) + expect(await old).toMatchObject({ canceled: true }) + expect(mocks.generate).toHaveBeenCalledTimes(1) + expect(mocks.generate.mock.calls[0][0].description).toBe('New') + }) + it.each([ + ['owner-2', args], + ['owner-1', { ...args, draftId: 'draft-2' }], + ['owner-1', { ...args, siteId: 'site-2' }] + ])('rejects competing generation from another owner, draft or account', async (owner, other) => { + const pending = deferred<{ success: true; summary: string }>() + mocks.generate.mockReturnValue(pending.promise) + const first = generateJiraIssueSummaryRequest(host, 'owner-1', args) + await Promise.resolve() + mocks.cancel.mockClear() + cancelJiraIssueSummaryRequest(owner, other) + expect(await generateJiraIssueSummaryRequest(host, owner, other)).toMatchObject({ + success: false + }) + expect(mocks.cancel).not.toHaveBeenCalled() + pending.resolve({ success: true, summary: 'First' }) + await first + }) + it('ignores an old cancel after a newer request starts', async () => { + const pending = deferred<{ ok: true }>() + mocks.prepare.mockReturnValueOnce(pending.promise) + const newer = { ...args, requestId: 'req-2' } + const result = generateJiraIssueSummaryRequest(host, 'owner-1', newer) + cancelJiraIssueSummaryRequest('owner-1', args) + pending.resolve({ ok: true }) + expect(await result).toMatchObject({ success: true }) + }) + it.each(['cancel', 'disconnect'])( + 'prevents spawning after %s during environment preparation', + async (action) => { + const pending = deferred<{ ok: true }>() + mocks.prepare.mockReturnValue(pending.promise) + const abort = new AbortController() + const result = generateJiraIssueSummaryRequest(host, 'owner-1', args, abort.signal) + if (action === 'cancel') { + cancelJiraIssueSummaryRequest('owner-1', args) + } else { + abort.abort() + } + pending.resolve({ ok: true }) + expect(await result).toMatchObject({ canceled: true }) + expect(mocks.generate).not.toHaveBeenCalled() + } + ) + it('rejects a Jira account absent on the execution host', async () => { + expect( + await generateJiraIssueSummaryRequest(host, 'owner-1', { ...args, siteId: 'unknown' }) + ).toMatchObject({ success: false }) + expect(mocks.prepare).not.toHaveBeenCalled() + }) + it('rechecks a disconnected Jira account before spawning', async () => { + mocks.status.mockReturnValueOnce({ sites: [{ id: 'site-1' }] }).mockReturnValue({ sites: [] }) + expect(await generateJiraIssueSummaryRequest(host, 'owner-1', args)).toMatchObject({ + success: false + }) + expect(mocks.generate).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/text-generation/jira-issue-summary-requests.ts b/src/main/text-generation/jira-issue-summary-requests.ts new file mode 100644 index 00000000000..43b7d30b17e --- /dev/null +++ b/src/main/text-generation/jira-issue-summary-requests.ts @@ -0,0 +1,111 @@ +import os from 'node:os' +import { LOCAL_COMMIT_MESSAGE_HOST_KEY } from '../../shared/commit-message-host-key' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { + JiraIssueSummaryGenerationRequest, + JiraIssueSummaryGenerationCancellation, + JiraIssueSummaryGenerationResult +} from '../../shared/jira-issue-summary-generation' +import { getStatus } from '../jira/client' +import { + prepareLocalCommitMessageAgentEnv, + type CommitMessageAgentEnvironmentResolvers +} from './commit-message-agent-environment' +import { resolveTextGenerationParams } from './commit-message-text-generation' +import { + cancelGenerateJiraIssueSummaryLocal, + generateJiraIssueSummaryFromContext +} from './jira-issue-summary-text-generation' + +export type JiraIssueSummaryGenerationHost = { + getSettings: () => GlobalSettings + getAgentEnvironment: () => CommitMessageAgentEnvironmentResolvers | undefined +} + +type ActiveRequest = JiraIssueSummaryGenerationCancellation & { owner: string } +// IPC and RPC share the same process lane, including while preparing account credentials. +let activeRequest: ActiveRequest | undefined +const cwd = os.homedir() + +export function cancelJiraIssueSummaryRequest( + owner: string, + args: JiraIssueSummaryGenerationCancellation +): void { + if ( + activeRequest?.owner === owner && + activeRequest.siteId === args.siteId && + activeRequest.draftId === args.draftId && + activeRequest.requestId === args.requestId + ) { + activeRequest = undefined + cancelGenerateJiraIssueSummaryLocal(cwd) + } +} + +export async function generateJiraIssueSummaryRequest( + host: JiraIssueSummaryGenerationHost, + owner: string, + args: JiraIssueSummaryGenerationRequest, + signal?: AbortSignal +): Promise { + if (!args.description.trim()) { + return { success: false, error: 'Description is required.' } + } + if (!getStatus().sites?.some((site) => site.id === args.siteId)) { + return { success: false, error: 'Reconnect the selected Jira account on this host.' } + } + if (signal?.aborted) { + return { success: false, error: 'Generation canceled.', canceled: true } + } + if (activeRequest) { + if ( + activeRequest.owner !== owner || + activeRequest.siteId !== args.siteId || + activeRequest.draftId !== args.draftId + ) { + return { success: false, error: 'A Jira title is already being generated in another draft.' } + } + cancelJiraIssueSummaryRequest(owner, activeRequest) + } + const request = { owner, siteId: args.siteId, draftId: args.draftId, requestId: args.requestId } + activeRequest = request + const cancel = (): void => cancelJiraIssueSummaryRequest(owner, request) + signal?.addEventListener('abort', cancel, { once: true }) + try { + const resolved = resolveTextGenerationParams( + host.getSettings(), + LOCAL_COMMIT_MESSAGE_HOST_KEY, + 'branchName', + null + ) + if (!resolved.ok) { + return { success: false, error: resolved.error } + } + const localEnv = await prepareLocalCommitMessageAgentEnv( + resolved.params.agentId, + host.getAgentEnvironment() + ) + if (activeRequest !== request) { + return { success: false, error: 'Generation canceled.', canceled: true } + } + if (!localEnv.ok) { + return { success: false, error: localEnv.error } + } + if (!getStatus().sites?.some((site) => site.id === args.siteId)) { + return { success: false, error: 'Reconnect the selected Jira account on this host.' } + } + const result = await generateJiraIssueSummaryFromContext(args, resolved.params, { + kind: 'local', + cwd, + ...(localEnv.env ? { env: localEnv.env } : {}) + }) + return activeRequest === request + ? result + : { success: false, error: 'Generation canceled.', canceled: true } + } finally { + signal?.removeEventListener('abort', cancel) + if (activeRequest === request) { + activeRequest = undefined + } + } +} diff --git a/src/main/text-generation/jira-issue-summary-text-generation.test.ts b/src/main/text-generation/jira-issue-summary-text-generation.test.ts new file mode 100644 index 00000000000..83cc6fdf12e --- /dev/null +++ b/src/main/text-generation/jira-issue-summary-text-generation.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import type { CommitMessagePlan } from '../../shared/commit-message-plan' +import { generateJiraIssueSummaryFromContext } from './jira-issue-summary-text-generation' + +function remoteTarget(execute: (plan: CommitMessagePlan) => Promise) { + return { + kind: 'remote' as const, + cwd: '/home/user', + missingBinaryLocation: 'remote PATH', + execute: async (plan: CommitMessagePlan) => ({ + stdout: await execute(plan), + stderr: '', + exitCode: 0, + timedOut: false + }) + } +} + +describe('generateJiraIssueSummaryFromContext', () => { + it('sanitizes agent output into a single-line summary', async () => { + const result = await generateJiraIssueSummaryFromContext( + { description: 'Login crashes when retried offline.' }, + { agentId: 'custom', model: '', customAgentCommand: 'agent' }, + remoteTarget(async () => '"Fix offline login retry crash"\nextra commentary') + ) + + expect(result).toEqual({ + success: true, + summary: 'Fix offline login retry crash', + agentLabel: 'agent' + }) + }) + + it('sends its own Jira prompt and ignores source-control templates and instructions', async () => { + let prompt = '' + await generateJiraIssueSummaryFromContext( + { + description: 'Crash on save', + projectName: 'Mobile App', + issueTypeName: 'Bug' + }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'agent', + customPrompt: 'Prefer kebab-case names.', + commandInputTemplate: 'Prefer kebab-case names.\n\n{basePrompt}' + }, + remoteTarget(async (plan) => { + prompt = plan.stdinPayload ?? '' + return 'Fix crash on save' + }) + ) + + expect(prompt).toContain('Generate a concise Jira issue summary') + expect(prompt).toContain('Project: Mobile App') + expect(prompt).toContain('Issue type: Bug') + expect(prompt).toContain('Crash on save') + expect(prompt).not.toContain('kebab-case') + }) + + it('fails when the output sanitizes to an empty summary', async () => { + const result = await generateJiraIssueSummaryFromContext( + { description: 'Crash on save' }, + { agentId: 'custom', model: '', customAgentCommand: 'agent' }, + remoteTarget(async () => '""\n') + ) + + expect(result).toEqual({ + success: false, + error: 'Generated summary was empty after sanitization.' + }) + }) + + it('propagates agent failures', async () => { + const result = await generateJiraIssueSummaryFromContext( + { description: 'Crash on save' }, + { agentId: 'custom', model: '', customAgentCommand: 'agent' }, + { + kind: 'remote', + cwd: '/home/user', + missingBinaryLocation: 'remote PATH', + execute: async () => ({ + stdout: '', + stderr: 'No API key found.', + exitCode: 1, + timedOut: false + }) + } + ) + + expect(result).toMatchObject({ + success: false, + error: 'agent CLI command failed with code 1: No API key found.' + }) + }) +}) diff --git a/src/main/text-generation/jira-issue-summary-text-generation.ts b/src/main/text-generation/jira-issue-summary-text-generation.ts new file mode 100644 index 00000000000..27a0978e2c8 --- /dev/null +++ b/src/main/text-generation/jira-issue-summary-text-generation.ts @@ -0,0 +1,61 @@ +import { planCommitMessageGeneration } from '../../shared/commit-message-plan' +import { + buildJiraIssueSummaryPrompt, + sanitizeGeneratedJiraIssueSummary, + type JiraIssueSummaryGenerationContext, + type JiraIssueSummaryGenerationResult +} from '../../shared/jira-issue-summary-generation' +import type { ResolvedSourceControlAiGenerationParams } from '../../shared/source-control-ai' +import { spawnSourceControlAgent } from './source-control-agent-launch' +import { cancelLocalGeneration } from './source-control-generation-lanes' +import { runLocalPlanForAgent } from './source-control-local-generation' +import { runRemoteSourceControlPlan } from './source-control-remote-generation' +import { commandBackslashMode } from './source-control-text-generation-requests' +import type { + CommitMessageGenerationTarget, + SpawnSourceControlAgent +} from './source-control-text-generation-types' + +// Source-control templates and instructions do not apply to Jira issue titles. +export async function generateJiraIssueSummaryFromContext( + context: JiraIssueSummaryGenerationContext, + params: ResolvedSourceControlAiGenerationParams, + target: CommitMessageGenerationTarget, + spawnAgent: SpawnSourceControlAgent = spawnSourceControlAgent +): Promise { + const prompt = buildJiraIssueSummaryPrompt(context) + const planned = planCommitMessageGeneration( + { ...params, backslash: commandBackslashMode(target) }, + prompt + ) + if (!planned.ok) { + return { success: false, error: planned.error } + } + const result = + target.kind === 'remote' + ? await runRemoteSourceControlPlan({ + plan: planned.plan, + target, + emptyResultName: 'summary', + operation: 'jira-issue-summary' + }) + : await runLocalPlanForAgent({ + agentId: params.agentId, + plan: planned.plan, + target, + emptyResultName: 'summary', + operation: 'jira-issue-summary', + spawnAgent + }) + if (!result.success) { + return { success: false, error: result.error, canceled: result.canceled } + } + const summary = sanitizeGeneratedJiraIssueSummary(result.rawOutput) + return summary + ? { success: true, summary, agentLabel: result.agentLabel } + : { success: false, error: 'Generated summary was empty after sanitization.' } +} + +export function cancelGenerateJiraIssueSummaryLocal(cwd: string): void { + cancelLocalGeneration('jira-issue-summary', cwd) +} diff --git a/src/main/text-generation/source-control-text-generation-types.ts b/src/main/text-generation/source-control-text-generation-types.ts index 42dc3b0d34a..6c93c15f901 100644 --- a/src/main/text-generation/source-control-text-generation-types.ts +++ b/src/main/text-generation/source-control-text-generation-types.ts @@ -43,6 +43,7 @@ export type TextGenerationOperation = | 'commit-message' | 'pull-request-fields' | 'branch-name' + | 'jira-issue-summary' | 'conversation-name' export type CommitMessageGenerationTarget = diff --git a/src/preload/api/jira-api.ts b/src/preload/api/jira-api.ts index 7498fc819a2..813ef436b99 100644 --- a/src/preload/api/jira-api.ts +++ b/src/preload/api/jira-api.ts @@ -1,3 +1,8 @@ +import type { + JiraIssueSummaryGenerationRequest, + JiraIssueSummaryGenerationCancellation, + JiraIssueSummaryGenerationResult +} from '../../shared/jira-issue-summary-generation' import type { JiraComment, JiraConnectionStatus, @@ -52,6 +57,10 @@ export type JiraApi = { createIssue: ( args: JiraCreateIssueArgs ) => Promise<{ ok: true; id: string; key: string; url: string } | { ok: false; error: string }> + generateIssueSummary: ( + args: JiraIssueSummaryGenerationRequest + ) => Promise + cancelGenerateIssueSummary: (args: JiraIssueSummaryGenerationCancellation) => Promise updateIssue: (args: { key: string updates: JiraIssueUpdate diff --git a/src/preload/api/jira-bridge.ts b/src/preload/api/jira-bridge.ts index 0aaf03521ff..71d45817e23 100644 --- a/src/preload/api/jira-bridge.ts +++ b/src/preload/api/jira-bridge.ts @@ -1,3 +1,7 @@ +import type { + JiraIssueSummaryGenerationRequest, + JiraIssueSummaryGenerationCancellation +} from '../../shared/jira-issue-summary-generation' import { ipcRenderer } from 'electron' import type { JiraProjectStatusOrder } from '../../shared/jira-types' import type { PreloadApi } from '../api-types' @@ -50,6 +54,11 @@ export const jiraApi = { }): Promise<{ ok: true; id: string; key: string; url: string } | { ok: false; error: string }> => ipcRenderer.invoke('jira:createIssue', args), + generateIssueSummary: (args: JiraIssueSummaryGenerationRequest) => + ipcRenderer.invoke('jira:generateIssueSummary', args), + cancelGenerateIssueSummary: (args: JiraIssueSummaryGenerationCancellation): Promise => + ipcRenderer.invoke('jira:cancelGenerateIssueSummary', args), + updateIssue: (args: { key: string updates: unknown diff --git a/src/renderer/src/components/task-page/TaskPage.tsx b/src/renderer/src/components/task-page/TaskPage.tsx index c80e7aa8f38..9798f9f82a7 100644 --- a/src/renderer/src/components/task-page/TaskPage.tsx +++ b/src/renderer/src/components/task-page/TaskPage.tsx @@ -32,6 +32,7 @@ import { useTaskPageGitHubIssueCreation } from '../use-task-page-github-issue-cr import { useTaskPageLinearProjectCreation } from '../use-task-page-linear-project-creation' import { useTaskPageLinearIssueCreation } from '../use-task-page-linear-issue-creation' import { useTaskPageJiraIssueCreation } from '../use-task-page-jira-issue-creation' +import { useTaskPageJiraSummaryGeneration } from '../use-task-page-jira-summary-generation' import { useTaskPageGlobalEffects } from '../use-task-page-global-effects' import { useTaskPageLinearListEffects } from '../use-task-page-linear-list-effects' import { useTaskPageLinearInOrcaEffects } from '../use-task-page-linear-in-orca-effects' @@ -74,11 +75,12 @@ export default function TaskPage(): React.JSX.Element { const stage31 = useTaskPageLinearProjectCreation(stage30) const stage32 = useTaskPageLinearIssueCreation(stage31) const stage33 = useTaskPageJiraIssueCreation(stage32) - const stage34 = useTaskPageGlobalEffects(stage33) - const stage35 = useTaskPageLinearListEffects(stage34) - const stage36 = useTaskPageLinearInOrcaEffects(stage35) - const stage37 = useTaskPageLinearCollectionEffects(stage36) - const stage38 = useTaskPageJiraListEffects(stage37) - const stage39 = useTaskPageComposerActions(stage38) - return + const stage34 = useTaskPageJiraSummaryGeneration(stage33) + const stage35 = useTaskPageGlobalEffects(stage34) + const stage36 = useTaskPageLinearListEffects(stage35) + const stage37 = useTaskPageLinearInOrcaEffects(stage36) + const stage38 = useTaskPageLinearCollectionEffects(stage37) + const stage39 = useTaskPageJiraListEffects(stage38) + const stage40 = useTaskPageComposerActions(stage39) + return } diff --git a/src/renderer/src/components/task-page/jira/IssueDialog.tsx b/src/renderer/src/components/task-page/jira/IssueDialog.tsx index f39beb86078..ff3c492857e 100644 --- a/src/renderer/src/components/task-page/jira/IssueDialog.tsx +++ b/src/renderer/src/components/task-page/jira/IssueDialog.tsx @@ -13,6 +13,7 @@ import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover import { Button } from '@/components/ui/button' import { getJiraProjectPickerDisplayLabel as getJiraProjectDisplayLabel } from '@/components/jira-project-picker-filter' import { ChevronDown, Check, LoaderCircle } from 'lucide-react' +import { TaskPageJiraIssueTitleField } from './IssueTitleField' import { Command, CommandInput, @@ -42,7 +43,6 @@ export function TaskPageJiraIssueDialog({ newJiraIssueOpen, setNewJiraIssueOpen, newJiraIssueTitle, - setNewJiraIssueTitle, newJiraIssueBody, setNewJiraIssueBody, newJiraIssueProjectComboboxOpen, @@ -230,24 +230,7 @@ export function TaskPageJiraIssueDialog({ -
- - setNewJiraIssueTitle(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && !e.nativeEvent.isComposing) { - e.preventDefault() - void handleCreateNewJiraIssue() - } - }} - placeholder={translate('auto.components.TaskPage.578f730c16', 'Short summary')} - disabled={newJiraIssueSubmitting} - /> -
+