diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 72913219690..f4349930751 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -110,7 +110,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings skipDeleteAutomationConfirm: false, defaultTaskViewPreset: 'all', defaultTaskSource: 'github', - visibleTaskProviders: ['github', 'gitlab', 'linear'], + visibleTaskProviders: ['github', 'gitlab', 'linear', 'jira'], + visibleTaskProvidersDefaultedForJira: true, defaultRepoSelection: null, defaultLinearTeamSelection: null, opencodeSessionCookie: '', diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 53c366eb360..bf915a98919 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -114,7 +114,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings skipDeleteAutomationConfirm: false, defaultTaskViewPreset: 'all', defaultTaskSource: 'github', - visibleTaskProviders: ['github', 'gitlab', 'linear'], + visibleTaskProviders: ['github', 'gitlab', 'linear', 'jira'], + visibleTaskProvidersDefaultedForJira: true, defaultRepoSelection: null, defaultLinearTeamSelection: null, opencodeSessionCookie: '', diff --git a/src/main/ipc/jira.ts b/src/main/ipc/jira.ts new file mode 100644 index 00000000000..e8ed17f9bfe --- /dev/null +++ b/src/main/ipc/jira.ts @@ -0,0 +1,262 @@ +import { ipcMain } from 'electron' +import { connect, disconnect, getStatus, selectSite, testConnection } from '../jira/client' +import { _resetPreflightCache } from './preflight' +import { + addIssueComment, + createIssue, + getIssue, + getIssueComments, + listAssignableUsers, + listCreateFields, + listIssueTypes, + listIssues, + listPriorities, + listProjects, + listTransitions, + searchIssues, + updateIssue +} from '../jira/issues' +import type { + JiraConnectArgs, + JiraCreateIssueArgs, + JiraIssueFilter, + JiraIssueUpdate, + JiraSiteSelection +} from '../../shared/types' + +const VALID_FILTERS = new Set(['assigned', 'reported', 'all', 'done']) + +function normalizeSiteId(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +function normalizeSiteSelection(value: unknown): JiraSiteSelection | undefined { + const siteId = normalizeSiteId(value) + return siteId as JiraSiteSelection | undefined +} + +function clampLimit(value: unknown, fallback = 30): number { + const limit = typeof value === 'number' && Number.isFinite(value) ? value : fallback + return Math.min(Math.max(1, limit), 100) +} + +function normalizeStringArray(value: unknown): string[] | undefined { + if (value === undefined) { + return undefined + } + return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : undefined +} + +function normalizeIssueUpdate(value: unknown): JiraIssueUpdate | null { + if (!value || typeof value !== 'object') { + return null + } + const input = value as JiraIssueUpdate + if (input.title !== undefined && typeof input.title !== 'string') { + return null + } + if (input.labels !== undefined && normalizeStringArray(input.labels) === undefined) { + return null + } + if ( + input.assigneeAccountId !== undefined && + input.assigneeAccountId !== null && + typeof input.assigneeAccountId !== 'string' + ) { + return null + } + if ( + input.priorityId !== undefined && + input.priorityId !== null && + typeof input.priorityId !== 'string' + ) { + return null + } + if (input.transitionId !== undefined && typeof input.transitionId !== 'string') { + return null + } + return input +} + +export function registerJiraHandlers(): void { + ipcMain.handle('jira:connect', async (_event, args: JiraConnectArgs) => { + if ( + typeof args?.siteUrl !== 'string' || + typeof args?.email !== 'string' || + typeof args?.apiToken !== 'string' + ) { + return { ok: false, error: 'Site URL, email, and API token are required.' } + } + const result = await connect({ + siteUrl: args.siteUrl, + email: args.email, + apiToken: args.apiToken + }) + if (result.ok) { + _resetPreflightCache() + } + return result + }) + + ipcMain.handle('jira:disconnect', async (_event, args?: { siteId?: string }) => { + disconnect(normalizeSiteId(args?.siteId)) + _resetPreflightCache() + }) + + ipcMain.handle('jira:selectSite', async (_event, args: { siteId: JiraSiteSelection }) => { + const siteId = normalizeSiteSelection(args?.siteId) + if (!siteId) { + return getStatus() + } + return selectSite(siteId) + }) + + ipcMain.handle('jira:status', async () => { + return getStatus() + }) + + ipcMain.handle('jira:testConnection', async (_event, args?: { siteId?: string }) => { + return testConnection(normalizeSiteId(args?.siteId)) + }) + + ipcMain.handle( + 'jira:searchIssues', + async (_event, args: { jql: string; limit?: number; siteId?: JiraSiteSelection }) => { + if (typeof args?.jql !== 'string') { + return [] + } + return searchIssues(args.jql, clampLimit(args.limit), normalizeSiteSelection(args.siteId)) + } + ) + + ipcMain.handle( + 'jira:listIssues', + async ( + _event, + args?: { filter?: JiraIssueFilter; limit?: number; siteId?: JiraSiteSelection } + ) => { + const filter = VALID_FILTERS.has(args?.filter as JiraIssueFilter) + ? (args!.filter as JiraIssueFilter) + : undefined + return listIssues(filter, clampLimit(args?.limit), normalizeSiteSelection(args?.siteId)) + } + ) + + ipcMain.handle('jira:getIssue', async (_event, args: { key: string; siteId?: string }) => { + if (typeof args?.key !== 'string' || !args.key.trim()) { + return null + } + return getIssue(args.key.trim(), normalizeSiteId(args.siteId)) + }) + + ipcMain.handle('jira:createIssue', async (_event, args: JiraCreateIssueArgs) => { + if (typeof args?.projectId !== 'string' || !args.projectId.trim()) { + return { ok: false, error: 'Project is required.' } + } + if (typeof args?.issueTypeId !== 'string' || !args.issueTypeId.trim()) { + return { ok: false, error: 'Issue type is required.' } + } + if (typeof args?.title !== 'string' || !args.title.trim()) { + return { ok: false, error: 'Title is required.' } + } + return createIssue({ + siteId: normalizeSiteId(args.siteId), + projectId: args.projectId.trim(), + issueTypeId: args.issueTypeId.trim(), + title: args.title.trim(), + description: args.description?.trim() || undefined, + customFields: + args.customFields && typeof args.customFields === 'object' ? args.customFields : undefined + }) + }) + + ipcMain.handle( + 'jira:updateIssue', + async (_event, args: { key: string; updates: JiraIssueUpdate; siteId?: string }) => { + if (typeof args?.key !== 'string' || !args.key.trim()) { + return { ok: false, error: 'Issue key is required.' } + } + const updates = normalizeIssueUpdate(args.updates) + if (!updates) { + return { ok: false, error: 'Updates object is required.' } + } + return updateIssue(args.key.trim(), updates, normalizeSiteId(args.siteId)) + } + ) + + ipcMain.handle( + 'jira:addIssueComment', + async (_event, args: { key: string; body: string; siteId?: string }) => { + if (typeof args?.key !== 'string' || !args.key.trim()) { + return { ok: false, error: 'Issue key is required.' } + } + if (typeof args?.body !== 'string' || !args.body.trim()) { + return { ok: false, error: 'Comment body is required.' } + } + return addIssueComment(args.key.trim(), args.body.trim(), normalizeSiteId(args.siteId)) + } + ) + + ipcMain.handle('jira:issueComments', async (_event, args: { key: string; siteId?: string }) => { + if (typeof args?.key !== 'string' || !args.key.trim()) { + return [] + } + return getIssueComments(args.key.trim(), normalizeSiteId(args.siteId)) + }) + + ipcMain.handle('jira:listProjects', async (_event, args?: { siteId?: JiraSiteSelection }) => { + return listProjects(normalizeSiteSelection(args?.siteId)) + }) + + ipcMain.handle( + 'jira:listIssueTypes', + async (_event, args: { projectIdOrKey: string; siteId?: string }) => { + if (typeof args?.projectIdOrKey !== 'string' || !args.projectIdOrKey.trim()) { + return [] + } + return listIssueTypes(args.projectIdOrKey.trim(), normalizeSiteId(args.siteId)) + } + ) + + ipcMain.handle( + 'jira:listCreateFields', + async (_event, args: { projectIdOrKey: string; issueTypeId: string; siteId?: string }) => { + if (typeof args?.projectIdOrKey !== 'string' || !args.projectIdOrKey.trim()) { + return [] + } + if (typeof args?.issueTypeId !== 'string' || !args.issueTypeId.trim()) { + return [] + } + return listCreateFields( + args.projectIdOrKey.trim(), + args.issueTypeId.trim(), + normalizeSiteId(args.siteId) + ) + } + ) + + ipcMain.handle('jira:listPriorities', async (_event, args?: { siteId?: string }) => { + return listPriorities(normalizeSiteId(args?.siteId)) + }) + + ipcMain.handle( + 'jira:listAssignableUsers', + async (_event, args: { key: string; query?: string; siteId?: string }) => { + if (typeof args?.key !== 'string' || !args.key.trim()) { + return [] + } + return listAssignableUsers( + args.key.trim(), + typeof args.query === 'string' ? args.query : undefined, + normalizeSiteId(args.siteId) + ) + } + ) + + ipcMain.handle('jira:listTransitions', async (_event, args: { key: string; siteId?: string }) => { + if (typeof args?.key !== 'string' || !args.key.trim()) { + return [] + } + return listTransitions(args.key.trim(), normalizeSiteId(args.siteId)) + }) +} diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index ee7a90f07f1..ea45caf35b8 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -40,6 +40,7 @@ const { registerFilesystemWatcherHandlersMock, registerAppHandlersMock, registerLinearHandlersMock, + registerJiraHandlersMock, registerGitLabHandlersMock, registerHostedReviewHandlersMock, registerExportHandlersMock, @@ -86,6 +87,7 @@ const { registerFilesystemWatcherHandlersMock: vi.fn(), registerAppHandlersMock: vi.fn(), registerLinearHandlersMock: vi.fn(), + registerJiraHandlersMock: vi.fn(), registerGitLabHandlersMock: vi.fn(), registerHostedReviewHandlersMock: vi.fn(), registerExportHandlersMock: vi.fn(), @@ -262,6 +264,10 @@ vi.mock('./linear', () => ({ registerLinearHandlers: registerLinearHandlersMock })) +vi.mock('./jira', () => ({ + registerJiraHandlers: registerJiraHandlersMock +})) + vi.mock('./gitlab', () => ({ registerGitLabHandlers: registerGitLabHandlersMock })) @@ -311,6 +317,7 @@ describe('registerCoreHandlers', () => { registerFilesystemWatcherHandlersMock.mockReset() registerAppHandlersMock.mockReset() registerLinearHandlersMock.mockReset() + registerJiraHandlersMock.mockReset() registerGitLabHandlersMock.mockReset() registerHostedReviewHandlersMock.mockReset() registerExportHandlersMock.mockReset() @@ -363,6 +370,7 @@ describe('registerCoreHandlers', () => { expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits) expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats) expect(registerLinearHandlersMock).toHaveBeenCalled() + expect(registerJiraHandlersMock).toHaveBeenCalled() expect(registerGitLabHandlersMock).toHaveBeenCalledWith(store) expect(registerHostedReviewHandlersMock).toHaveBeenCalledWith(store, stats) expect(registerFeedbackHandlersMock).toHaveBeenCalled() diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 3aeaa9b4332..262f53ceeaf 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -14,6 +14,7 @@ import { registerGitHubHandlers } from './github' import { registerGitLabHandlers } from './gitlab' import { registerHostedReviewHandlers } from './hosted-review' import { registerLinearHandlers } from './linear' +import { registerJiraHandlers } from './jira' import { registerFeedbackHandlers } from './feedback' import { registerCrashReportingHandlers } from './crash-reporting' import { registerExportHandlers } from './export' @@ -111,6 +112,7 @@ export function registerCoreHandlers( registerGitLabHandlers(store) registerHostedReviewHandlers(store, stats) registerLinearHandlers() + registerJiraHandlers() registerFeedbackHandlers() if (crashReports) { registerCrashReportingHandlers(crashReports) diff --git a/src/main/jira/client.ts b/src/main/jira/client.ts new file mode 100644 index 00000000000..cc7c5645993 --- /dev/null +++ b/src/main/jira/client.ts @@ -0,0 +1,490 @@ +/* eslint-disable max-lines -- Why: Jira credential storage and authenticated +request plumbing share one boundary so encrypted token lifecycle and +multi-site selection cannot drift between task operations. */ +import { createHash } from 'crypto' +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs' +import { homedir } from 'os' +import { join } from 'path' +import { safeStorage } from 'electron' +import type { + JiraConnectArgs, + JiraConnectionStatus, + JiraSite, + JiraSiteSelection, + JiraViewer +} from '../../shared/types' + +const MAX_CONCURRENT = 4 +let running = 0 +const queue: (() => void)[] = [] + +export function acquire(): Promise { + if (running < MAX_CONCURRENT) { + running += 1 + return Promise.resolve() + } + return new Promise((resolve) => + queue.push(() => { + running += 1 + resolve() + }) + ) +} + +export function release(): void { + running -= 1 + const next = queue.shift() + if (next) { + next() + } +} + +type JiraSiteFile = { + version: 1 + activeSiteId: string | null + selectedSiteId: JiraSiteSelection | null + sites: JiraSite[] +} + +export type JiraClientForSite = { + site: JiraSite + authorization: string +} + +export class JiraApiError extends Error { + status: number | null + + constructor(message: string, status: number | null = null) { + super(message) + this.status = status + } +} + +let cachedSiteFile: JiraSiteFile | null = null +let siteFileLoaded = false +const cachedTokens = new Map() + +function getOrcaDir(): string { + return join(homedir(), '.orca') +} + +function getSiteFilePath(): string { + return join(getOrcaDir(), 'jira-sites.json') +} + +function getTokenDir(): string { + return join(getOrcaDir(), 'jira-tokens') +} + +function getTokenPath(siteId: string): string { + return join(getTokenDir(), `${Buffer.from(siteId).toString('base64url')}.enc`) +} + +function ensureOrcaDir(): void { + const dir = getOrcaDir() + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } +} + +function ensureTokenDir(): void { + const dir = getTokenDir() + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } +} + +function emptySiteFile(): JiraSiteFile { + return { + version: 1, + activeSiteId: null, + selectedSiteId: null, + sites: [] + } +} + +function hasStoredToken(siteId: string): boolean { + return cachedTokens.has(siteId) || existsSync(getTokenPath(siteId)) +} + +function normalizeSite(input: unknown): JiraSite | null { + if (!input || typeof input !== 'object') { + return null + } + const record = input as Record + if ( + typeof record.id !== 'string' || + typeof record.siteUrl !== 'string' || + typeof record.email !== 'string' || + typeof record.displayName !== 'string' || + typeof record.accountId !== 'string' + ) { + return null + } + return { + id: record.id, + siteUrl: record.siteUrl, + email: record.email, + displayName: record.displayName, + accountId: record.accountId + } +} + +function readSiteFileFromDisk(): JiraSiteFile { + const path = getSiteFilePath() + if (!existsSync(path)) { + return emptySiteFile() + } + try { + const parsed = JSON.parse(readFileSync(path, { encoding: 'utf-8' })) as Partial + const sites = Array.isArray(parsed.sites) + ? parsed.sites + .map((site) => normalizeSite(site)) + .filter((site): site is JiraSite => site !== null) + .filter((site) => hasStoredToken(site.id)) + : [] + const activeSiteId = + typeof parsed.activeSiteId === 'string' && + sites.some((site) => site.id === parsed.activeSiteId) + ? parsed.activeSiteId + : (sites[0]?.id ?? null) + const selectedSiteId = + parsed.selectedSiteId === 'all' || + (typeof parsed.selectedSiteId === 'string' && + sites.some((site) => site.id === parsed.selectedSiteId)) + ? parsed.selectedSiteId + : activeSiteId + return { version: 1, activeSiteId, selectedSiteId, sites } + } catch { + return emptySiteFile() + } +} + +function getSiteFile(): JiraSiteFile { + if (!siteFileLoaded || !cachedSiteFile) { + cachedSiteFile = readSiteFileFromDisk() + siteFileLoaded = true + } + return cachedSiteFile +} + +function writeSiteFile(file: JiraSiteFile): void { + ensureOrcaDir() + const sites = file.sites.filter((site) => hasStoredToken(site.id)) + const activeSiteId = + file.activeSiteId && sites.some((site) => site.id === file.activeSiteId) + ? file.activeSiteId + : (sites[0]?.id ?? null) + const selectedSiteId = + file.selectedSiteId === 'all' + ? 'all' + : file.selectedSiteId && sites.some((site) => site.id === file.selectedSiteId) + ? file.selectedSiteId + : activeSiteId + + cachedSiteFile = { + version: 1, + activeSiteId, + selectedSiteId, + sites + } + siteFileLoaded = true + writeFileSync(getSiteFilePath(), JSON.stringify(cachedSiteFile, null, 2), { + encoding: 'utf-8', + mode: 0o600 + }) +} + +function writeEncryptedToken(path: string, apiToken: string): void { + if (safeStorage.isEncryptionAvailable()) { + writeFileSync(path, safeStorage.encryptString(apiToken), { mode: 0o600 }) + return + } + console.warn('[jira] safeStorage encryption unavailable — storing token in plaintext') + writeFileSync(path, apiToken, { encoding: 'utf-8', mode: 0o600 }) +} + +function readToken(siteId: string): string | null { + const cached = cachedTokens.get(siteId) + if (cached) { + return cached + } + const path = getTokenPath(siteId) + if (!existsSync(path)) { + return null + } + try { + const raw = readFileSync(path) + const token = safeStorage.isEncryptionAvailable() + ? safeStorage.decryptString(raw) + : raw.toString('utf-8') + cachedTokens.set(siteId, token) + return token + } catch { + return null + } +} + +function saveToken(siteId: string, apiToken: string): void { + ensureOrcaDir() + ensureTokenDir() + writeEncryptedToken(getTokenPath(siteId), apiToken) + cachedTokens.set(siteId, apiToken) +} + +function deleteToken(siteId: string): void { + cachedTokens.delete(siteId) + try { + unlinkSync(getTokenPath(siteId)) + } catch { + // Token may not exist — safe to ignore. + } +} + +export function normalizeJiraSiteUrl(siteUrl: string): string { + const trimmed = siteUrl.trim() + const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}` + const url = new URL(withProtocol) + url.pathname = url.pathname.replace(/\/+$/, '') + url.search = '' + url.hash = '' + return url.toString().replace(/\/$/, '') +} + +function getSiteId(siteUrl: string, email: string): string { + return createHash('sha256') + .update(`${siteUrl}\n${email.toLowerCase()}`) + .digest('base64url') + .slice(0, 24) +} + +function toViewer(data: Record, fallbackEmail: string): JiraViewer { + const avatarUrls = data.avatarUrls as Record | undefined + return { + accountId: typeof data.accountId === 'string' ? data.accountId : '', + displayName: typeof data.displayName === 'string' ? data.displayName : fallbackEmail, + email: typeof data.emailAddress === 'string' ? data.emailAddress : fallbackEmail, + avatarUrl: + typeof avatarUrls?.['48x48'] === 'string' + ? avatarUrls['48x48'] + : typeof avatarUrls?.['32x32'] === 'string' + ? avatarUrls['32x32'] + : undefined + } +} + +function siteToViewer(site: JiraSite | null): JiraViewer | null { + if (!site) { + return null + } + return { + accountId: site.accountId, + displayName: site.displayName, + email: site.email + } +} + +function authHeader(email: string, apiToken: string): string { + return `Basic ${Buffer.from(`${email}:${apiToken}`).toString('base64')}` +} + +async function requestWithCredentials( + siteUrl: string, + email: string, + apiToken: string, + path: string, + init?: RequestInit +): Promise { + const headers = new Headers(init?.headers) + headers.set('Accept', 'application/json') + headers.set('Content-Type', 'application/json') + headers.set('Authorization', authHeader(email, apiToken)) + const response = await fetch(`${siteUrl}${path}`, { + ...init, + headers + }) + if (!response.ok) { + throw new JiraApiError(await readJiraError(response), response.status) + } + if (response.status === 204) { + return null + } + return response.json() +} + +async function readJiraError(response: Response): Promise { + try { + const data = (await response.json()) as { + errorMessages?: string[] + errors?: Record + message?: string + } + const messages = [ + ...(Array.isArray(data.errorMessages) ? data.errorMessages : []), + ...Object.values(data.errors ?? {}), + ...(data.message ? [data.message] : []) + ].filter(Boolean) + if (messages.length > 0) { + return messages.join('; ') + } + } catch { + // Fall through to status text. + } + return response.statusText || `Jira request failed (${response.status})` +} + +export async function jiraRequest( + client: JiraClientForSite, + path: string, + init?: RequestInit +): Promise { + const headers = new Headers(init?.headers) + headers.set('Accept', 'application/json') + headers.set('Content-Type', 'application/json') + headers.set('Authorization', client.authorization) + const response = await fetch(`${client.site.siteUrl}${path}`, { + ...init, + headers + }) + if (!response.ok) { + throw new JiraApiError(await readJiraError(response), response.status) + } + if (response.status === 204) { + return null as T + } + return (await response.json()) as T +} + +export function getClients(selection?: JiraSiteSelection | null): JiraClientForSite[] { + const file = getSiteFile() + const selected = selection ?? file.selectedSiteId ?? file.activeSiteId + const sites = + selected === 'all' + ? file.sites + : file.sites.filter((site) => site.id === (selected ?? file.activeSiteId)) + + return sites.flatMap((site) => { + const token = readToken(site.id) + return token ? [{ site, authorization: authHeader(site.email, token) }] : [] + }) +} + +export function getStatus(): JiraConnectionStatus { + const file = getSiteFile() + const sites = file.sites.filter((site) => hasStoredToken(site.id)) + const activeSite = sites.find((site) => site.id === file.activeSiteId) ?? sites[0] ?? null + return { + connected: sites.length > 0, + viewer: siteToViewer(activeSite), + sites, + activeSiteId: activeSite?.id ?? null, + selectedSiteId: file.selectedSiteId ?? activeSite?.id ?? null + } +} + +export async function connect( + args: JiraConnectArgs +): Promise<{ ok: true; viewer: JiraViewer } | { ok: false; error: string }> { + let siteUrl: string + try { + siteUrl = normalizeJiraSiteUrl(args.siteUrl) + } catch { + return { ok: false, error: 'Enter a valid Jira site URL.' } + } + + const email = args.email.trim() + const apiToken = args.apiToken.trim() + if (!email || !apiToken) { + return { ok: false, error: 'Email and API token are required.' } + } + + await acquire() + try { + const viewer = toViewer( + (await requestWithCredentials(siteUrl, email, apiToken, '/rest/api/3/myself')) as Record< + string, + unknown + >, + email + ) + const id = getSiteId(siteUrl, email) + const site: JiraSite = { + id, + siteUrl, + email, + displayName: viewer.displayName, + accountId: viewer.accountId + } + saveToken(id, apiToken) + const file = getSiteFile() + writeSiteFile({ + version: 1, + activeSiteId: id, + selectedSiteId: id, + sites: [site, ...file.sites.filter((entry) => entry.id !== id)] + }) + return { ok: true, viewer } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : 'Connection failed.' } + } finally { + release() + } +} + +export function disconnect(siteId?: string): void { + const file = getSiteFile() + const ids = siteId ? [siteId] : file.sites.map((site) => site.id) + for (const id of ids) { + deleteToken(id) + } + writeSiteFile({ + version: 1, + activeSiteId: file.activeSiteId, + selectedSiteId: file.selectedSiteId, + sites: file.sites.filter((site) => !ids.includes(site.id)) + }) +} + +export function selectSite(siteId: JiraSiteSelection): JiraConnectionStatus { + const file = getSiteFile() + if (siteId !== 'all' && !file.sites.some((site) => site.id === siteId)) { + return getStatus() + } + writeSiteFile({ + ...file, + activeSiteId: siteId === 'all' ? file.activeSiteId : siteId, + selectedSiteId: siteId + }) + return getStatus() +} + +export async function testConnection( + siteId?: string +): Promise<{ ok: true; viewer: JiraViewer } | { ok: false; error: string }> { + const client = getClients(siteId)[0] + if (!client) { + return { ok: false, error: 'Not connected to Jira.' } + } + await acquire() + try { + const viewer = toViewer( + (await jiraRequest(client, '/rest/api/3/myself')) as Record, + client.site.email + ) + return { ok: true, viewer } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : 'Connection failed.' } + } finally { + release() + } +} + +export function clearToken(siteId: string): void { + deleteToken(siteId) + const file = getSiteFile() + writeSiteFile({ ...file, sites: file.sites.filter((site) => site.id !== siteId) }) +} + +export function isAuthError(error: unknown): boolean { + return error instanceof JiraApiError && (error.status === 401 || error.status === 403) +} diff --git a/src/main/jira/issues.test.ts b/src/main/jira/issues.test.ts new file mode 100644 index 00000000000..c5fa06b8110 --- /dev/null +++ b/src/main/jira/issues.test.ts @@ -0,0 +1,210 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { JiraClientForSite } from './client' + +const { clearTokenMock, getClientsMock, jiraRequestMock } = vi.hoisted(() => ({ + clearTokenMock: vi.fn(), + getClientsMock: vi.fn(), + jiraRequestMock: vi.fn() +})) + +vi.mock('./client', () => ({ + acquire: vi.fn().mockResolvedValue(undefined), + release: vi.fn(), + clearToken: (...args: unknown[]) => clearTokenMock(...args), + getClients: (...args: unknown[]) => getClientsMock(...args), + isAuthError: vi.fn().mockReturnValue(false), + jiraRequest: (...args: unknown[]) => jiraRequestMock(...args) +})) + +function makeEntry(): JiraClientForSite { + return { + site: { + id: 'site-1', + siteUrl: 'https://example.atlassian.net', + email: 'ada@example.com', + displayName: 'Example Jira', + accountId: 'account-1' + }, + authorization: 'Basic token' + } +} + +describe('Jira issue operations', () => { + beforeEach(() => { + vi.clearAllMocks() + getClientsMock.mockReturnValue([makeEntry()]) + }) + + it('paginates Jira project search results before sorting them', async () => { + jiraRequestMock + .mockResolvedValueOnce({ + startAt: 0, + maxResults: 2, + total: 3, + values: [ + { id: '2', key: 'BRV', name: 'Bravo' }, + { id: '3', key: 'CHR', name: 'Charlie' } + ] + }) + .mockResolvedValueOnce({ + startAt: 2, + maxResults: 2, + total: 3, + values: [{ id: '1', key: 'ALP', name: 'Alpha' }] + }) + + const { listProjects } = await import('./issues') + + await expect(listProjects('site-1')).resolves.toMatchObject([ + { id: '1', key: 'ALP', name: 'Alpha', siteId: 'site-1' }, + { id: '2', key: 'BRV', name: 'Bravo', siteId: 'site-1' }, + { id: '3', key: 'CHR', name: 'Charlie', siteId: 'site-1' } + ]) + + expect(jiraRequestMock).toHaveBeenCalledTimes(2) + expect(String(jiraRequestMock.mock.calls[0][1])).toContain('startAt=0') + expect(String(jiraRequestMock.mock.calls[1][1])).toContain('startAt=2') + }) + + it('maps create-metadata issue types from the Jira issueTypes page key', async () => { + jiraRequestMock.mockResolvedValueOnce({ + startAt: 0, + maxResults: 100, + total: 1, + issueTypes: [ + { + id: '10001', + name: 'Bug', + description: 'Something is broken', + iconUrl: 'https://example.atlassian.net/bug.svg', + subtask: false + } + ] + }) + + const { listIssueTypes } = await import('./issues') + + await expect(listIssueTypes('10000', 'site-1')).resolves.toEqual([ + { + id: '10001', + name: 'Bug', + description: 'Something is broken', + iconUrl: 'https://example.atlassian.net/bug.svg', + subtask: false + } + ]) + + expect(String(jiraRequestMock.mock.calls[0][1])).toContain( + '/rest/api/3/issue/createmeta/10000/issuetypes?' + ) + }) + + it('maps required Jira create fields from create field metadata', async () => { + jiraRequestMock.mockResolvedValueOnce({ + startAt: 0, + maxResults: 100, + total: 1, + values: [ + { + fieldId: 'customfield_10010', + name: 'Severity', + required: true, + schema: { + type: 'option', + custom: 'com.atlassian.jira.plugin.system.customfieldtypes:select' + }, + allowedValues: [{ id: 'option-1', value: 'High' }] + } + ] + }) + + const { listCreateFields } = await import('./issues') + + await expect(listCreateFields('10000', '10001', 'site-1')).resolves.toEqual([ + { + key: 'customfield_10010', + name: 'Severity', + required: true, + schema: { + type: 'option', + custom: 'com.atlassian.jira.plugin.system.customfieldtypes:select', + items: undefined + }, + allowedValues: [{ id: 'option-1', value: 'High', name: undefined }] + } + ]) + + expect(String(jiraRequestMock.mock.calls[0][1])).toContain( + '/rest/api/3/issue/createmeta/10000/issuetypes/10001?' + ) + }) + + it('includes custom create fields when creating Jira issues', async () => { + jiraRequestMock.mockResolvedValueOnce({ + id: 'issue-1', + key: 'ALP-1', + self: 'https://example.atlassian.net/rest/api/3/issue/issue-1' + }) + + const { createIssue } = await import('./issues') + + await expect( + createIssue({ + siteId: 'site-1', + projectId: '10000', + issueTypeId: '10001', + title: 'Fix Jira create', + customFields: { + customfield_10010: { id: 'option-1' } + } + }) + ).resolves.toEqual({ + ok: true, + id: 'issue-1', + key: 'ALP-1', + url: 'https://example.atlassian.net/browse/ALP-1' + }) + + const requestInit = jiraRequestMock.mock.calls[0][2] as { body: string } + expect(JSON.parse(requestInit.body).fields).toMatchObject({ + project: { id: '10000' }, + issuetype: { id: '10001' }, + summary: 'Fix Jira create', + customfield_10010: { id: 'option-1' } + }) + }) + + it('maps comments from the Jira comments page key', async () => { + jiraRequestMock.mockResolvedValueOnce({ + comments: [ + { + id: 'comment-1', + body: { + type: 'doc', + version: 1, + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Looks reproducible.' }] + } + ] + }, + created: '2026-05-30T12:00:00.000Z', + author: { accountId: 'user-1', displayName: 'Ada' } + } + ] + }) + + const { getIssueComments } = await import('./issues') + + await expect(getIssueComments('ALP-1', 'site-1')).resolves.toEqual([ + { + id: 'comment-1', + body: 'Looks reproducible.', + createdAt: '2026-05-30T12:00:00.000Z', + user: { accountId: 'user-1', displayName: 'Ada', avatarUrl: undefined, email: undefined }, + updatedAt: undefined + } + ]) + }) +}) diff --git a/src/main/jira/issues.ts b/src/main/jira/issues.ts new file mode 100644 index 00000000000..490e488cf59 --- /dev/null +++ b/src/main/jira/issues.ts @@ -0,0 +1,831 @@ +/* eslint-disable max-lines -- Why: Jira task reads and mutations share ADF + mapping, multi-site fan-out, and auth-clearing behavior; keeping the API + boundary together avoids subtle drift between operations. */ +import type { + JiraComment, + JiraCreateField, + JiraCreateFieldAllowedValue, + JiraCreateIssueArgs, + JiraCreateIssueResult, + JiraIssue, + JiraIssueFilter, + JiraIssueType, + JiraIssueUpdate, + JiraMutationResult, + JiraPriority, + JiraProject, + JiraSite, + JiraSiteSelection, + JiraStatus, + JiraTransition, + JiraUser +} from '../../shared/types' +import { + acquire, + clearToken, + getClients, + isAuthError, + jiraRequest, + release, + type JiraClientForSite +} from './client' + +const ISSUE_FIELDS = [ + 'summary', + 'description', + 'project', + 'issuetype', + 'status', + 'assignee', + 'reporter', + 'priority', + 'labels', + 'created', + 'updated' +] + +type JiraRecord = Record + +type JiraSearchResponse = { + issues?: JiraRecord[] +} + +type JiraPagedResponse = { + startAt?: number + maxResults?: number + total?: number + isLast?: boolean + values?: T[] + issueTypes?: T[] + comments?: T[] + fields?: T[] | Record +} + +type JiraPageItemKey = 'values' | 'issueTypes' | 'comments' + +function clampLimit(limit: number | undefined, fallback = 30): number { + return Math.min(Math.max(1, Number.isFinite(limit) ? Number(limit) : fallback), 100) +} + +function shouldThrowAuthError(selection: JiraSiteSelection | null | undefined): boolean { + return selection !== 'all' +} + +function asRecord(value: unknown): JiraRecord { + return value && typeof value === 'object' ? (value as JiraRecord) : {} +} + +function asString(value: unknown, fallback = ''): string { + return typeof value === 'string' ? value : fallback +} + +function asStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : [] +} + +function asFiniteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function getPageItems(response: JiraPagedResponse, key: JiraPageItemKey): T[] { + const keyedItems = response[key] + if (Array.isArray(keyedItems)) { + return keyedItems + } + return response.values ?? [] +} + +function shouldFetchNextPage( + response: JiraPagedResponse, + startAt: number, + items: T[], + requestedMaxResults: number +): boolean { + if (response.isLast === true || items.length === 0) { + return false + } + const total = asFiniteNumber(response.total) + const pageSize = asFiniteNumber(response.maxResults) + if (total !== null) { + return startAt + items.length < total && (pageSize ?? requestedMaxResults) > 0 + } + if (response.isLast === false) { + return (pageSize ?? requestedMaxResults) > 0 + } + return pageSize !== null && items.length >= pageSize +} + +async function fetchPagedRecords( + entry: JiraClientForSite, + key: JiraPageItemKey, + pathForPage: (startAt: number, maxResults: number) => string, + maxResults = 100 +): Promise { + const records: JiraRecord[] = [] + let startAt = 0 + for (let guard = 0; guard < 100; guard += 1) { + const response = await jiraRequest>( + entry, + pathForPage(startAt, maxResults) + ) + const items = getPageItems(response, key) + records.push(...items) + if (!shouldFetchNextPage(response, startAt, items, maxResults)) { + break + } + startAt += asFiniteNumber(response.maxResults) ?? maxResults + } + return records +} + +function avatarUrl(value: unknown): string | undefined { + const avatars = asRecord(value) + return ( + asString(avatars['48x48']) || + asString(avatars['32x32']) || + asString(avatars['24x24']) || + undefined + ) +} + +function mapUser(value: unknown): JiraUser | undefined { + const user = asRecord(value) + const accountId = asString(user.accountId) + if (!accountId) { + return undefined + } + return { + accountId, + displayName: asString(user.displayName, 'Unknown'), + email: typeof user.emailAddress === 'string' ? user.emailAddress : undefined, + avatarUrl: avatarUrl(user.avatarUrls) + } +} + +function mapProject(value: unknown, site?: JiraSite): JiraProject { + const project = asRecord(value) + return { + id: asString(project.id), + key: asString(project.key), + name: asString(project.name, asString(project.key)), + siteId: site?.id, + siteName: site?.displayName + } +} + +function mapIssueType(value: unknown): JiraIssueType { + const issueType = asRecord(value) + return { + id: asString(issueType.id), + name: asString(issueType.name, 'Issue'), + description: asString(issueType.description) || undefined, + iconUrl: asString(issueType.iconUrl) || undefined, + subtask: typeof issueType.subtask === 'boolean' ? issueType.subtask : undefined + } +} + +function mapCreateFieldAllowedValue(value: unknown): JiraCreateFieldAllowedValue { + const option = asRecord(value) + return { + id: asString(option.id) || undefined, + value: asString(option.value) || undefined, + name: asString(option.name) || undefined + } +} + +function mapCreateField(value: unknown, fallbackKey = ''): JiraCreateField | null { + const field = asRecord(value) + const schema = asRecord(field.schema) + const key = + asString(field.key) || + asString(field.fieldId) || + asString(field.id) || + asString(field.fieldKey) || + fallbackKey + if (!key) { + return null + } + const allowedValues = Array.isArray(field.allowedValues) + ? field.allowedValues.map(mapCreateFieldAllowedValue) + : undefined + return { + key, + name: asString(field.name, key), + required: field.required === true, + schema: { + type: asString(schema.type) || undefined, + items: asString(schema.items) || undefined, + custom: asString(schema.custom) || undefined + }, + allowedValues + } +} + +function getCreateFieldRecords(response: JiraPagedResponse): JiraRecord[] { + if (Array.isArray(response.values)) { + return response.values + } + if (Array.isArray(response.fields)) { + return response.fields + } + if (response.fields && typeof response.fields === 'object') { + return Object.entries(response.fields).map(([key, value]) => ({ + key, + ...asRecord(value) + })) + } + return [] +} + +function mapPriority(value: unknown): JiraPriority | undefined { + const priority = asRecord(value) + const id = asString(priority.id) + if (!id) { + return undefined + } + return { + id, + name: asString(priority.name, 'Priority'), + iconUrl: asString(priority.iconUrl) || undefined + } +} + +function mapStatus(value: unknown): JiraStatus { + const status = asRecord(value) + const category = asRecord(status.statusCategory) + return { + id: asString(status.id), + name: asString(status.name, 'Unknown'), + categoryKey: asString(category.key, 'undefined'), + categoryName: asString(category.name, 'No Category'), + colorName: asString(category.colorName) || undefined + } +} + +function textNode(text: string): JiraRecord { + return text ? { type: 'text', text } : { type: 'hardBreak' } +} + +function textToAdf(text: string): JiraRecord { + const lines = text.split(/\r?\n/) + return { + type: 'doc', + version: 1, + content: lines.map((line) => ({ + type: 'paragraph', + content: line ? [textNode(line)] : [] + })) + } +} + +function adfToPlainText(value: unknown): string { + const chunks: string[] = [] + + const walk = (node: unknown): void => { + if (!node) { + return + } + if (typeof node === 'string') { + chunks.push(node) + return + } + if (Array.isArray(node)) { + node.forEach(walk) + return + } + if (typeof node !== 'object') { + return + } + const record = node as JiraRecord + if (typeof record.text === 'string') { + chunks.push(record.text) + } + if (record.type === 'hardBreak') { + chunks.push('\n') + } + walk(record.content) + if ( + record.type === 'paragraph' || + record.type === 'heading' || + record.type === 'listItem' || + record.type === 'bulletList' || + record.type === 'orderedList' + ) { + chunks.push('\n') + } + } + + walk(value) + return chunks + .join('') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim() +} + +function issueUrl(site: JiraSite, key: string): string { + return `${site.siteUrl}/browse/${encodeURIComponent(key)}` +} + +export function mapJiraIssue(site: JiraSite, raw: JiraRecord): JiraIssue { + const fields = asRecord(raw.fields) + const key = asString(raw.key) + return { + id: asString(raw.id, key), + key, + siteId: site.id, + siteName: site.displayName, + title: asString(fields.summary, key || 'Untitled issue'), + description: adfToPlainText(fields.description), + url: issueUrl(site, key), + project: mapProject(fields.project, site), + issueType: mapIssueType(fields.issuetype), + status: mapStatus(fields.status), + labels: asStringArray(fields.labels), + assignee: mapUser(fields.assignee), + reporter: mapUser(fields.reporter), + priority: mapPriority(fields.priority), + createdAt: asString(fields.created, new Date().toISOString()), + updatedAt: asString(fields.updated, new Date().toISOString()) + } +} + +function sortAndLimitIssues(issues: JiraIssue[], limit: number): JiraIssue[] { + return issues + .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) + .slice(0, limit) +} + +function filterToJql(filter: JiraIssueFilter): string { + if (filter === 'assigned') { + return 'assignee = currentUser() AND resolution = Unresolved ORDER BY updated DESC' + } + if (filter === 'reported') { + return 'reporter = currentUser() AND resolution = Unresolved ORDER BY updated DESC' + } + if (filter === 'done') { + return 'assignee = currentUser() AND resolution IS NOT EMPTY ORDER BY updated DESC' + } + return 'resolution = Unresolved ORDER BY updated DESC' +} + +async function searchIssuesForClient( + entry: JiraClientForSite, + jql: string, + limit: number +): Promise { + const result = await jiraRequest(entry, '/rest/api/3/search/jql', { + method: 'POST', + body: JSON.stringify({ + jql, + maxResults: limit, + fields: ISSUE_FIELDS + }) + }) + return (result.issues ?? []).map((issue) => mapJiraIssue(entry.site, issue)) +} + +export async function listIssues( + filter: JiraIssueFilter = 'assigned', + limit = 30, + siteId?: JiraSiteSelection | null +): Promise { + return searchIssues(filterToJql(filter), limit, siteId) +} + +export async function searchIssues( + jql: string, + limit = 30, + siteId?: JiraSiteSelection | null +): Promise { + const entries = getClients(siteId) + if (entries.length === 0 || !jql.trim()) { + return [] + } + const safeLimit = clampLimit(limit) + const results = await Promise.all( + entries.map(async (entry) => { + await acquire() + try { + return await searchIssuesForClient(entry, jql.trim(), safeLimit) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + if (shouldThrowAuthError(siteId)) { + throw error + } + } else { + console.warn('[jira] searchIssues failed:', error) + } + return [] + } finally { + release() + } + }) + ) + return entries.length === 1 + ? results.flat().slice(0, safeLimit) + : sortAndLimitIssues(results.flat(), safeLimit) +} + +export async function getIssue( + key: string, + siteId?: JiraSiteSelection | null +): Promise { + const entries = getClients(siteId) + for (const entry of entries) { + await acquire() + try { + const issue = await jiraRequest( + entry, + `/rest/api/3/issue/${encodeURIComponent(key)}?fields=${encodeURIComponent( + ISSUE_FIELDS.join(',') + )}` + ) + return mapJiraIssue(entry.site, issue) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + if (shouldThrowAuthError(siteId)) { + throw error + } + } else { + console.warn('[jira] getIssue failed:', error) + } + } finally { + release() + } + } + return null +} + +export async function createIssue(args: JiraCreateIssueArgs): Promise { + const entry = getClients(args.siteId)[0] + if (!entry) { + return { ok: false, error: 'Not connected to Jira.' } + } + const title = args.title.trim() + if (!title) { + return { ok: false, error: 'Title is required.' } + } + + await acquire() + try { + const fields: JiraRecord = { + project: { id: args.projectId }, + issuetype: { id: args.issueTypeId }, + summary: title + } + if (args.description?.trim()) { + fields.description = textToAdf(args.description.trim()) + } + for (const [fieldKey, value] of Object.entries(args.customFields ?? {})) { + if (!fieldKey || value === undefined || value === null || value === '') { + continue + } + fields[fieldKey] = value + } + const created = await jiraRequest<{ id: string; key: string; self: string }>( + entry, + '/rest/api/3/issue', + { + method: 'POST', + body: JSON.stringify({ fields }) + } + ) + return { ok: true, id: created.id, key: created.key, url: issueUrl(entry.site, created.key) } + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + return { ok: false, error: error instanceof Error ? error.message : 'Failed to create issue.' } + } finally { + release() + } +} + +export async function updateIssue( + key: string, + updates: JiraIssueUpdate, + siteId?: string | null +): Promise { + const entry = getClients(siteId)[0] + if (!entry) { + return { ok: false, error: 'Not connected to Jira.' } + } + await acquire() + try { + const fields: JiraRecord = {} + if (updates.title !== undefined) { + fields.summary = updates.title + } + if (updates.labels !== undefined) { + fields.labels = updates.labels + } + if (updates.priorityId !== undefined) { + fields.priority = updates.priorityId ? { id: updates.priorityId } : null + } + if (Object.keys(fields).length > 0) { + await jiraRequest(entry, `/rest/api/3/issue/${encodeURIComponent(key)}`, { + method: 'PUT', + body: JSON.stringify({ fields }) + }) + } + if (updates.assigneeAccountId !== undefined) { + await jiraRequest(entry, `/rest/api/3/issue/${encodeURIComponent(key)}/assignee`, { + method: 'PUT', + body: JSON.stringify({ accountId: updates.assigneeAccountId }) + }) + } + if (updates.transitionId) { + await jiraRequest(entry, `/rest/api/3/issue/${encodeURIComponent(key)}/transitions`, { + method: 'POST', + body: JSON.stringify({ transition: { id: updates.transitionId } }) + }) + } + return { ok: true } + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + return { ok: false, error: error instanceof Error ? error.message : 'Failed to update issue.' } + } finally { + release() + } +} + +export async function addIssueComment( + key: string, + body: string, + siteId?: string | null +): Promise<{ ok: true; id: string } | { ok: false; error: string }> { + const entry = getClients(siteId)[0] + if (!entry) { + return { ok: false, error: 'Not connected to Jira.' } + } + await acquire() + try { + const comment = await jiraRequest<{ id: string }>( + entry, + `/rest/api/3/issue/${encodeURIComponent(key)}/comment`, + { + method: 'POST', + body: JSON.stringify({ body: textToAdf(body) }) + } + ) + return { ok: true, id: comment.id } + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + return { ok: false, error: error instanceof Error ? error.message : 'Failed to add comment.' } + } finally { + release() + } +} + +function mapComment(raw: JiraRecord): JiraComment { + return { + id: asString(raw.id), + body: adfToPlainText(raw.body), + createdAt: asString(raw.created, new Date().toISOString()), + updatedAt: asString(raw.updated) || undefined, + user: mapUser(raw.author) + } +} + +export async function getIssueComments( + key: string, + siteId?: string | null +): Promise { + const entry = getClients(siteId)[0] + if (!entry) { + return [] + } + await acquire() + try { + const comments = await fetchPagedRecords(entry, 'comments', (startAt, maxResults) => { + const params = new URLSearchParams({ + maxResults: String(maxResults), + orderBy: 'created', + startAt: String(startAt) + }) + return `/rest/api/3/issue/${encodeURIComponent(key)}/comment?${params.toString()}` + }) + return comments.map(mapComment) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] getIssueComments failed:', error) + return [] + } finally { + release() + } +} + +export async function listProjects(siteId?: JiraSiteSelection | null): Promise { + const entries = getClients(siteId) + if (entries.length === 0) { + return [] + } + const results = await Promise.all( + entries.map(async (entry) => { + await acquire() + try { + const projects = await fetchPagedRecords(entry, 'values', (startAt, maxResults) => { + const params = new URLSearchParams({ + maxResults: String(maxResults), + startAt: String(startAt) + }) + return `/rest/api/3/project/search?${params.toString()}` + }) + return projects.map((project) => mapProject(project, entry.site)) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + if (shouldThrowAuthError(siteId)) { + throw error + } + } else { + console.warn('[jira] listProjects failed:', error) + } + return [] + } finally { + release() + } + }) + ) + return results.flat().sort((a, b) => a.name.localeCompare(b.name)) +} + +export async function listIssueTypes( + projectIdOrKey: string, + siteId?: string | null +): Promise { + const entry = getClients(siteId)[0] + if (!entry) { + return [] + } + await acquire() + try { + const issueTypes = await fetchPagedRecords(entry, 'issueTypes', (startAt, maxResults) => { + const params = new URLSearchParams({ + maxResults: String(maxResults), + startAt: String(startAt) + }) + return `/rest/api/3/issue/createmeta/${encodeURIComponent( + projectIdOrKey + )}/issuetypes?${params.toString()}` + }) + return issueTypes.map(mapIssueType) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] listIssueTypes failed:', error) + return [] + } finally { + release() + } +} + +export async function listCreateFields( + projectIdOrKey: string, + issueTypeId: string, + siteId?: string | null +): Promise { + const entry = getClients(siteId)[0] + if (!entry) { + return [] + } + await acquire() + try { + const fields: JiraCreateField[] = [] + let startAt = 0 + const maxResults = 100 + for (let guard = 0; guard < 100; guard += 1) { + const params = new URLSearchParams({ + maxResults: String(maxResults), + startAt: String(startAt) + }) + const response = await jiraRequest>( + entry, + `/rest/api/3/issue/createmeta/${encodeURIComponent( + projectIdOrKey + )}/issuetypes/${encodeURIComponent(issueTypeId)}?${params.toString()}` + ) + const records = getCreateFieldRecords(response) + fields.push( + ...records + .map((record) => mapCreateField(record)) + .filter((field): field is JiraCreateField => field !== null) + ) + if (!shouldFetchNextPage(response, startAt, records, maxResults)) { + break + } + startAt += asFiniteNumber(response.maxResults) ?? maxResults + } + return fields + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] listCreateFields failed:', error) + return [] + } finally { + release() + } +} + +export async function listPriorities(siteId?: string | null): Promise { + const entry = getClients(siteId)[0] + if (!entry) { + return [] + } + await acquire() + try { + const response = await jiraRequest(entry, '/rest/api/3/priority') + return response.map(mapPriority).filter((priority): priority is JiraPriority => !!priority) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] listPriorities failed:', error) + return [] + } finally { + release() + } +} + +export async function listAssignableUsers( + key: string, + query?: string, + siteId?: string | null +): Promise { + const entry = getClients(siteId)[0] + if (!entry) { + return [] + } + const params = new URLSearchParams({ issueKey: key, maxResults: '50' }) + if (query?.trim()) { + params.set('query', query.trim()) + } + await acquire() + try { + const response = await jiraRequest( + entry, + `/rest/api/3/user/assignable/search?${params.toString()}` + ) + return response.map(mapUser).filter((user): user is JiraUser => !!user) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] listAssignableUsers failed:', error) + return [] + } finally { + release() + } +} + +export async function listTransitions( + key: string, + siteId?: string | null +): Promise { + const entry = getClients(siteId)[0] + if (!entry) { + return [] + } + await acquire() + try { + const response = await jiraRequest<{ transitions?: JiraRecord[] }>( + entry, + `/rest/api/3/issue/${encodeURIComponent(key)}/transitions` + ) + return (response.transitions ?? []).map((transition) => ({ + id: asString(transition.id), + name: asString(transition.name), + to: mapStatus(transition.to) + })) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] listTransitions failed:', error) + return [] + } finally { + release() + } +} diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index c65e8398051..4207b5d14ff 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -264,7 +264,7 @@ describe('Store', () => { expect(settings.terminalUseSeparateLightTheme).toBe(true) expect(settings.rightSidebarOpenByDefault).toBe(true) expect(settings.showTasksButton).toBe(true) - expect(settings.visibleTaskProviders).toEqual(['github', 'gitlab', 'linear']) + expect(settings.visibleTaskProviders).toEqual(['github', 'gitlab', 'linear', 'jira']) expect(settings.openInApplications).toEqual([]) expect(settings.experimentalActivity).toBe(false) expect(settings.experimentalActivityDefaultedOffForAllUsers).toBe(true) @@ -860,7 +860,7 @@ describe('Store', () => { expect(store.getSettings().showGitIgnoredFiles).toBe(true) expect(store.getSettings().showTasksButton).toBe(true) expect(store.getSettings().combinedDiffFileTreeVisibleByDefault).toBe(false) - expect(store.getSettings().visibleTaskProviders).toEqual(['github', 'gitlab', 'linear']) + expect(store.getSettings().visibleTaskProviders).toEqual(['github', 'gitlab', 'linear', 'jira']) expect(store.getSettings().experimentalActivity).toBe(false) expect(store.getSettings().experimentalActivityDefaultedOffForAllUsers).toBe(true) expect(store.getSettings().experimentalTerminalAttention).toBe(false) @@ -1017,6 +1017,24 @@ describe('Store', () => { workspaceSession: {} }) + const store = await createStore() + expect(store.getSettings().visibleTaskProviders).toEqual(['gitlab', 'jira']) + }) + + it('preserves a deliberate Jira provider opt-out after migration', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { + visibleTaskProviders: ['gitlab'], + visibleTaskProvidersDefaultedForJira: true + }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() expect(store.getSettings().visibleTaskProviders).toEqual(['gitlab']) }) @@ -1049,7 +1067,7 @@ describe('Store', () => { const store = await createStore() expect(store.getSettings().defaultTaskSource).toBe('github') - expect(store.getSettings().visibleTaskProviders).toEqual(['github', 'linear']) + expect(store.getSettings().visibleTaskProviders).toEqual(['github', 'linear', 'jira']) }) it('normalizes invalid task provider defaults on load', async () => { @@ -1057,7 +1075,7 @@ describe('Store', () => { schemaVersion: 1, repos: [], worktreeMeta: {}, - settings: { visibleTaskProviders: ['gitlab'], defaultTaskSource: 'jira' as never }, + settings: { visibleTaskProviders: ['gitlab'], defaultTaskSource: 'bitbucket' as never }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} @@ -1065,7 +1083,7 @@ describe('Store', () => { const store = await createStore() expect(store.getSettings().defaultTaskSource).toBe('gitlab') - expect(store.getSettings().visibleTaskProviders).toEqual(['gitlab']) + expect(store.getSettings().visibleTaskProviders).toEqual(['gitlab', 'jira']) }) it('normalizes persisted open-in applications on load', async () => { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 4bfff092048..adf8f4f9632 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -1642,10 +1642,21 @@ export class Store { const migratedExperimentalActivity = experimentalActivityDefaultedOffForAllUsers ? (parsed.settings?.experimentalActivity ?? false) : false - const taskProviderSettings = normalizeTaskProviderSettings({ + const rawTaskProviderSettings = normalizeTaskProviderSettings({ visibleTaskProviders: parsed.settings?.visibleTaskProviders, defaultTaskSource: parsed.settings?.defaultTaskSource }) + const visibleTaskProvidersDefaultedForJira = + parsed.settings?.visibleTaskProvidersDefaultedForJira === true + const migratedVisibleTaskProviders = visibleTaskProvidersDefaultedForJira + ? rawTaskProviderSettings.visibleTaskProviders + : rawTaskProviderSettings.visibleTaskProviders.includes('jira') + ? rawTaskProviderSettings.visibleTaskProviders + : [...rawTaskProviderSettings.visibleTaskProviders, 'jira' as const] + const taskProviderSettings = normalizeTaskProviderSettings({ + visibleTaskProviders: migratedVisibleTaskProviders, + defaultTaskSource: rawTaskProviderSettings.defaultTaskSource + }) const primarySelectionDefaultedForLinux = parsed.settings?.primarySelectionMiddleClickPasteDefaultedForLinux === true const primarySelectionDefaultedForTerminalDefaults = @@ -1662,6 +1673,9 @@ export class Store { if (migratePrimarySelectionPlatformDefault || stampPrimarySelectionTerminalDefaults) { this.loadNeedsSave = true } + if (!visibleTaskProvidersDefaultedForJira) { + this.loadNeedsSave = true + } result = { ...defaults, ...parsed, @@ -1700,6 +1714,7 @@ export class Store { ), defaultTaskSource: taskProviderSettings.defaultTaskSource, visibleTaskProviders: taskProviderSettings.visibleTaskProviders, + visibleTaskProvidersDefaultedForJira: true, terminalShortcutPolicy: normalizeTerminalShortcutPolicy( parsed.settings?.terminalShortcutPolicy ), @@ -2831,6 +2846,9 @@ export class Store { }) sanitizedUpdates.defaultTaskSource = taskProviderSettings.defaultTaskSource sanitizedUpdates.visibleTaskProviders = taskProviderSettings.visibleTaskProviders + if ('visibleTaskProviders' in updates) { + sanitizedUpdates.visibleTaskProvidersDefaultedForJira = true + } } if ('openInApplications' in updates) { sanitizedUpdates.openInApplications = normalizeOpenInApplications(updates.openInApplications) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 711ccabdf38..9365c6600d9 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -54,6 +54,11 @@ import type { WorktreeRemoteBranchConflictEvent, WorktreeStartupLaunch, LinearCustomViewModel, + JiraConnectArgs, + JiraCreateIssueArgs, + JiraIssueFilter, + JiraIssueUpdate, + JiraSiteSelection, LinearIssueUpdate, LinearWorkspaceSelection, NestedRepoScanResult, @@ -285,6 +290,28 @@ import { getTeamStates as getLinearTeamStates, listTeams as listLinearTeams } from '../linear/teams' +import { + connect as connectJira, + disconnect as disconnectJira, + getStatus as getJiraStatus, + selectSite as selectJiraSite, + testConnection as testJiraConnection +} from '../jira/client' +import { + addIssueComment as addJiraIssueComment, + createIssue as createJiraIssue, + getIssue as getJiraIssue, + getIssueComments as getJiraIssueComments, + listAssignableUsers as listJiraAssignableUsers, + listCreateFields as listJiraCreateFields, + listIssueTypes as listJiraIssueTypes, + listIssues as listJiraIssues, + listPriorities as listJiraPriorities, + listProjects as listJiraProjects, + listTransitions as listJiraTransitions, + searchIssues as searchJiraIssues, + updateIssue as updateJiraIssue +} from '../jira/issues' import { clearProjectItemFieldValue, getProjectViewTable, @@ -12541,6 +12568,108 @@ export class OrcaRuntimeService { return getLinearTeamMembers(teamId, workspaceId) } + // ── Jira integration ── + + jiraConnect(args: JiraConnectArgs): ReturnType { + return connectJira(args) + } + + jiraDisconnect(siteId?: string): { ok: true } { + disconnectJira(siteId) + return { ok: true } + } + + jiraSelectSite(siteId: JiraSiteSelection): ReturnType { + return selectJiraSite(siteId) + } + + jiraStatus(): ReturnType { + return getJiraStatus() + } + + jiraTestConnection(siteId?: string): ReturnType { + return testJiraConnection(siteId) + } + + jiraSearchIssues( + jql: string, + limit = 30, + siteId?: JiraSiteSelection + ): ReturnType { + return searchJiraIssues(jql, Math.min(Math.max(1, limit), 100), siteId) + } + + jiraListIssues( + filter?: JiraIssueFilter, + limit = 30, + siteId?: JiraSiteSelection + ): ReturnType { + return listJiraIssues(filter, Math.min(Math.max(1, limit), 100), siteId) + } + + jiraCreateIssue(args: JiraCreateIssueArgs): ReturnType { + return createJiraIssue(args) + } + + jiraGetIssue(key: string, siteId?: string): ReturnType { + return getJiraIssue(key, siteId) + } + + jiraUpdateIssue( + key: string, + updates: JiraIssueUpdate, + siteId?: string + ): ReturnType { + return updateJiraIssue(key, updates, siteId) + } + + jiraAddIssueComment( + key: string, + body: string, + siteId?: string + ): ReturnType { + return addJiraIssueComment(key, body, siteId) + } + + jiraIssueComments(key: string, siteId?: string): ReturnType { + return getJiraIssueComments(key, siteId) + } + + jiraListProjects(siteId?: JiraSiteSelection): ReturnType { + return listJiraProjects(siteId) + } + + jiraListIssueTypes( + projectIdOrKey: string, + siteId?: string + ): ReturnType { + return listJiraIssueTypes(projectIdOrKey, siteId) + } + + jiraListCreateFields( + projectIdOrKey: string, + issueTypeId: string, + siteId?: string + ): ReturnType { + return listJiraCreateFields(projectIdOrKey, issueTypeId, siteId) + } + + jiraListPriorities(siteId?: string): ReturnType { + return listJiraPriorities(siteId) + } + + jiraListAssignableUsers( + key: string, + query?: string, + siteId?: string + ): ReturnType { + return listJiraAssignableUsers(key, query, siteId) + } + + jiraListTransitions(key: string, siteId?: string): ReturnType { + return listJiraTransitions(key, siteId) + } + // ── Browser automation ── private readonly browserCommands = new RuntimeBrowserCommands({ diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index d971a959e12..10132edd886 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -21,6 +21,7 @@ import { GITHUB_METHODS } from './github' import { GITLAB_METHODS } from './gitlab' import { HOSTED_REVIEW_METHODS } from './hosted-review' import { LINEAR_METHODS } from './linear' +import { JIRA_METHODS } from './jira' import { SSH_METHODS } from './ssh' import { SPEECH_METHODS } from './speech' import { CLIENT_UI_METHODS } from './client-ui' @@ -54,6 +55,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...GITLAB_METHODS, ...HOSTED_REVIEW_METHODS, ...LINEAR_METHODS, + ...JIRA_METHODS, ...SSH_METHODS, ...SPEECH_METHODS, ...WORKSPACE_PORT_METHODS, diff --git a/src/main/runtime/rpc/methods/jira.test.ts b/src/main/runtime/rpc/methods/jira.test.ts new file mode 100644 index 00000000000..8df96326c0a --- /dev/null +++ b/src/main/runtime/rpc/methods/jira.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { JIRA_METHODS } from './jira' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('jira RPC methods', () => { + it('routes Jira account methods to the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + jiraStatus: vi.fn().mockResolvedValue({ connected: true, viewer: null }), + jiraTestConnection: vi.fn().mockResolvedValue({ ok: true, viewer: { displayName: 'Ada' } }), + jiraConnect: vi.fn().mockResolvedValue({ ok: true, viewer: { displayName: 'Ada' } }), + jiraSelectSite: vi.fn().mockResolvedValue({ connected: true, viewer: null }), + jiraDisconnect: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: JIRA_METHODS }) + + await dispatcher.dispatch(makeRequest('jira.status')) + await dispatcher.dispatch(makeRequest('jira.testConnection')) + await dispatcher.dispatch( + makeRequest('jira.connect', { + siteUrl: 'https://example.atlassian.net', + email: 'ada@example.com', + apiToken: 'token' + }) + ) + await dispatcher.dispatch(makeRequest('jira.selectSite', { siteId: 'site-1' })) + await dispatcher.dispatch(makeRequest('jira.disconnect')) + + expect(runtime.jiraStatus).toHaveBeenCalled() + expect(runtime.jiraTestConnection).toHaveBeenCalled() + expect(runtime.jiraConnect).toHaveBeenCalledWith({ + siteUrl: 'https://example.atlassian.net', + email: 'ada@example.com', + apiToken: 'token' + }) + expect(runtime.jiraSelectSite).toHaveBeenCalledWith('site-1') + expect(runtime.jiraDisconnect).toHaveBeenCalled() + }) + + it('routes Jira issue queries and mutations to the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + jiraSearchIssues: vi.fn().mockResolvedValue([{ key: 'ABC-1' }]), + jiraListIssues: vi.fn().mockResolvedValue([{ key: 'ABC-2' }]), + jiraGetIssue: vi.fn().mockResolvedValue({ key: 'ABC-3' }), + jiraCreateIssue: vi.fn().mockResolvedValue({ ok: true, key: 'ABC-4' }), + jiraUpdateIssue: vi.fn().mockResolvedValue({ ok: true }), + jiraAddIssueComment: vi.fn().mockResolvedValue({ ok: true, id: 'comment-1' }), + jiraIssueComments: vi.fn().mockResolvedValue([{ id: 'comment-2' }]) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: JIRA_METHODS }) + + await dispatcher.dispatch( + makeRequest('jira.searchIssues', { jql: 'project = ABC', limit: 30, siteId: 'all' }) + ) + await dispatcher.dispatch( + makeRequest('jira.listIssues', { filter: 'assigned', limit: 20, siteId: 'site-1' }) + ) + await dispatcher.dispatch(makeRequest('jira.getIssue', { key: 'ABC-3', siteId: 'site-1' })) + await dispatcher.dispatch( + makeRequest('jira.createIssue', { + siteId: 'site-1', + projectId: 'project-1', + issueTypeId: 'type-1', + title: 'Fix bug', + description: 'Details', + customFields: { customfield_10010: { id: 'option-1' } } + }) + ) + await dispatcher.dispatch( + makeRequest('jira.updateIssue', { + key: 'ABC-3', + siteId: 'site-1', + updates: { + title: 'Fixed title', + assigneeAccountId: null, + priorityId: '2', + labels: ['bug'], + transitionId: '31' + } + }) + ) + await dispatcher.dispatch( + makeRequest('jira.addIssueComment', { key: 'ABC-3', body: 'Looks good', siteId: 'site-1' }) + ) + await dispatcher.dispatch(makeRequest('jira.issueComments', { key: 'ABC-3', siteId: 'site-1' })) + + expect(runtime.jiraSearchIssues).toHaveBeenCalledWith('project = ABC', 30, 'all') + expect(runtime.jiraListIssues).toHaveBeenCalledWith('assigned', 20, 'site-1') + expect(runtime.jiraGetIssue).toHaveBeenCalledWith('ABC-3', 'site-1') + expect(runtime.jiraCreateIssue).toHaveBeenCalledWith({ + siteId: 'site-1', + projectId: 'project-1', + issueTypeId: 'type-1', + title: 'Fix bug', + description: 'Details', + customFields: { customfield_10010: { id: 'option-1' } } + }) + expect(runtime.jiraUpdateIssue).toHaveBeenCalledWith( + 'ABC-3', + { + title: 'Fixed title', + assigneeAccountId: null, + priorityId: '2', + labels: ['bug'], + transitionId: '31' + }, + 'site-1' + ) + expect(runtime.jiraAddIssueComment).toHaveBeenCalledWith('ABC-3', 'Looks good', 'site-1') + expect(runtime.jiraIssueComments).toHaveBeenCalledWith('ABC-3', 'site-1') + }) + + it('routes Jira metadata requests to the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + jiraListProjects: vi.fn().mockResolvedValue([{ id: 'project-1' }]), + jiraListIssueTypes: vi.fn().mockResolvedValue([{ id: 'type-1' }]), + jiraListCreateFields: vi.fn().mockResolvedValue([{ key: 'customfield_10010' }]), + jiraListPriorities: vi.fn().mockResolvedValue([{ id: 'priority-1' }]), + jiraListAssignableUsers: vi.fn().mockResolvedValue([{ accountId: 'user-1' }]), + jiraListTransitions: vi.fn().mockResolvedValue([{ id: 'transition-1' }]) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: JIRA_METHODS }) + + await dispatcher.dispatch(makeRequest('jira.listProjects', { siteId: 'all' })) + await dispatcher.dispatch( + makeRequest('jira.listIssueTypes', { projectIdOrKey: 'project-1', siteId: 'site-1' }) + ) + await dispatcher.dispatch( + makeRequest('jira.listCreateFields', { + projectIdOrKey: 'project-1', + issueTypeId: 'type-1', + siteId: 'site-1' + }) + ) + await dispatcher.dispatch(makeRequest('jira.listPriorities', { siteId: 'site-1' })) + await dispatcher.dispatch( + makeRequest('jira.listAssignableUsers', { + key: 'ABC-3', + query: 'Ada', + siteId: 'site-1' + }) + ) + await dispatcher.dispatch( + makeRequest('jira.listTransitions', { key: 'ABC-3', siteId: 'site-1' }) + ) + + expect(runtime.jiraListProjects).toHaveBeenCalledWith('all') + expect(runtime.jiraListIssueTypes).toHaveBeenCalledWith('project-1', 'site-1') + expect(runtime.jiraListCreateFields).toHaveBeenCalledWith('project-1', 'type-1', 'site-1') + expect(runtime.jiraListPriorities).toHaveBeenCalledWith('site-1') + expect(runtime.jiraListAssignableUsers).toHaveBeenCalledWith('ABC-3', 'Ada', 'site-1') + expect(runtime.jiraListTransitions).toHaveBeenCalledWith('ABC-3', 'site-1') + }) +}) diff --git a/src/main/runtime/rpc/methods/jira.ts b/src/main/runtime/rpc/methods/jira.ts new file mode 100644 index 00000000000..9a4c614d9ee --- /dev/null +++ b/src/main/runtime/rpc/methods/jira.ts @@ -0,0 +1,208 @@ +import { z } from 'zod' +import { defineMethod, type RpcMethod } from '../core' +import { + OptionalFiniteNumber, + OptionalPlainString, + OptionalString, + requiredString +} from '../schemas' + +const VALID_FILTERS = ['assigned', 'reported', 'all', 'done'] as const + +const SiteSelection = z + .object({ + siteId: OptionalString + }) + .optional() + +const Connect = z.object({ + siteUrl: requiredString('Site URL is required'), + email: requiredString('Email is required'), + apiToken: requiredString('API token is required') +}) + +const SelectSite = z.object({ + siteId: requiredString('Site ID is required') +}) + +const SearchIssues = z.object({ + jql: requiredString('Missing JQL'), + limit: OptionalFiniteNumber, + siteId: OptionalString +}) + +const ListIssues = z + .object({ + filter: z.enum(VALID_FILTERS).optional(), + limit: OptionalFiniteNumber, + siteId: OptionalString + }) + .optional() + +const IssueKey = z.object({ + key: requiredString('Issue key is required'), + siteId: OptionalString +}) + +const CreateIssue = z.object({ + siteId: OptionalString, + projectId: requiredString('Project is required'), + issueTypeId: requiredString('Issue type is required'), + title: requiredString('Title is required'), + description: OptionalPlainString, + customFields: z.record(z.string(), z.unknown()).optional() +}) + +const IssueUpdate = z.object({ + key: requiredString('Issue key is required'), + siteId: OptionalString, + updates: z.object({ + title: OptionalString, + labels: z.array(z.string()).optional(), + assigneeAccountId: z.union([z.string(), z.null()]).optional(), + priorityId: z.union([z.string(), z.null()]).optional(), + transitionId: OptionalString + }) +}) + +const IssueComment = z.object({ + key: requiredString('Issue key is required'), + body: requiredString('Comment body is required'), + siteId: OptionalString +}) + +const ProjectIssueTypes = z.object({ + projectIdOrKey: requiredString('Project is required'), + siteId: OptionalString +}) + +const ProjectIssueTypeFields = z.object({ + projectIdOrKey: requiredString('Project is required'), + issueTypeId: requiredString('Issue type is required'), + siteId: OptionalString +}) + +const AssignableUsers = z.object({ + key: requiredString('Issue key is required'), + query: OptionalPlainString, + siteId: OptionalString +}) + +export const JIRA_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'jira.connect', + params: Connect, + handler: async (params, { runtime }) => + runtime.jiraConnect({ + siteUrl: params.siteUrl.trim(), + email: params.email.trim(), + apiToken: params.apiToken.trim() + }) + }), + defineMethod({ + name: 'jira.disconnect', + params: SiteSelection, + handler: async (params, { runtime }) => runtime.jiraDisconnect(params?.siteId) + }), + defineMethod({ + name: 'jira.selectSite', + params: SelectSite, + handler: async (params, { runtime }) => runtime.jiraSelectSite(params.siteId.trim()) + }), + defineMethod({ + name: 'jira.status', + params: null, + handler: async (_params, { runtime }) => runtime.jiraStatus() + }), + defineMethod({ + name: 'jira.testConnection', + params: SiteSelection, + handler: async (params, { runtime }) => runtime.jiraTestConnection(params?.siteId) + }), + defineMethod({ + name: 'jira.searchIssues', + params: SearchIssues, + handler: async (params, { runtime }) => + runtime.jiraSearchIssues(params.jql, params.limit, params.siteId) + }), + defineMethod({ + name: 'jira.listIssues', + params: ListIssues, + handler: async (params, { runtime }) => + runtime.jiraListIssues(params?.filter, params?.limit, params?.siteId) + }), + defineMethod({ + name: 'jira.getIssue', + params: IssueKey, + handler: async (params, { runtime }) => runtime.jiraGetIssue(params.key.trim(), params.siteId) + }), + defineMethod({ + name: 'jira.createIssue', + params: CreateIssue, + handler: async (params, { runtime }) => + runtime.jiraCreateIssue({ + siteId: params.siteId, + projectId: params.projectId.trim(), + issueTypeId: params.issueTypeId.trim(), + title: params.title.trim(), + description: params.description?.trim() || undefined, + customFields: params.customFields + }) + }), + defineMethod({ + name: 'jira.updateIssue', + params: IssueUpdate, + handler: async (params, { runtime }) => + runtime.jiraUpdateIssue(params.key.trim(), params.updates, params.siteId) + }), + defineMethod({ + name: 'jira.addIssueComment', + params: IssueComment, + handler: async (params, { runtime }) => + runtime.jiraAddIssueComment(params.key.trim(), params.body.trim(), params.siteId) + }), + defineMethod({ + name: 'jira.issueComments', + params: IssueKey, + handler: async (params, { runtime }) => + runtime.jiraIssueComments(params.key.trim(), params.siteId) + }), + defineMethod({ + name: 'jira.listProjects', + params: SiteSelection, + handler: async (params, { runtime }) => runtime.jiraListProjects(params?.siteId) + }), + defineMethod({ + name: 'jira.listIssueTypes', + params: ProjectIssueTypes, + handler: async (params, { runtime }) => + runtime.jiraListIssueTypes(params.projectIdOrKey.trim(), params.siteId) + }), + defineMethod({ + name: 'jira.listCreateFields', + params: ProjectIssueTypeFields, + handler: async (params, { runtime }) => + runtime.jiraListCreateFields( + params.projectIdOrKey.trim(), + params.issueTypeId.trim(), + params.siteId + ) + }), + defineMethod({ + name: 'jira.listPriorities', + params: SiteSelection, + handler: async (params, { runtime }) => runtime.jiraListPriorities(params?.siteId) + }), + defineMethod({ + name: 'jira.listAssignableUsers', + params: AssignableUsers, + handler: async (params, { runtime }) => + runtime.jiraListAssignableUsers(params.key.trim(), params.query, params.siteId) + }), + defineMethod({ + name: 'jira.listTransitions', + params: IssueKey, + handler: async (params, { runtime }) => + runtime.jiraListTransitions(params.key.trim(), params.siteId) + }) +] diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index f53cc88a08b..7d6601aa604 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -69,6 +69,20 @@ import type { MRListState, ListWorkItemsResult, IssueInfo, + JiraComment, + JiraConnectionStatus, + JiraCreateField, + JiraCreateIssueArgs, + JiraIssue, + JiraIssueFilter, + JiraIssueType, + JiraIssueUpdate, + JiraPriority, + JiraProject, + JiraSiteSelection, + JiraTransition, + JiraUser, + JiraViewer, LinearViewer, LinearCollectionResult, LinearConnectionStatus, @@ -1392,6 +1406,58 @@ export type PreloadApi = { teamLabels: (args: { teamId: string; workspaceId?: string }) => Promise teamMembers: (args: { teamId: string; workspaceId?: string }) => Promise } + jira: { + connect: (args: { + siteUrl: string + email: string + apiToken: string + }) => Promise<{ ok: true; viewer: JiraViewer } | { ok: false; error: string }> + disconnect: (args?: { siteId?: string }) => Promise + selectSite: (args: { siteId: JiraSiteSelection }) => Promise + status: () => Promise + testConnection: (args?: { + siteId?: string + }) => Promise<{ ok: true; viewer: JiraViewer } | { ok: false; error: string }> + searchIssues: (args: { + jql: string + limit?: number + siteId?: JiraSiteSelection + }) => Promise + listIssues: (args?: { + filter?: JiraIssueFilter + limit?: number + siteId?: JiraSiteSelection + }) => Promise + getIssue: (args: { key: string; siteId?: string }) => Promise + createIssue: ( + args: JiraCreateIssueArgs + ) => Promise<{ ok: true; id: string; key: string; url: string } | { ok: false; error: string }> + updateIssue: (args: { + key: string + updates: JiraIssueUpdate + siteId?: string + }) => Promise<{ ok: true } | { ok: false; error: string }> + addIssueComment: (args: { + key: string + body: string + siteId?: string + }) => Promise<{ ok: true; id: string } | { ok: false; error: string }> + issueComments: (args: { key: string; siteId?: string }) => Promise + listProjects: (args?: { siteId?: JiraSiteSelection }) => Promise + listIssueTypes: (args: { projectIdOrKey: string; siteId?: string }) => Promise + listCreateFields: (args: { + projectIdOrKey: string + issueTypeId: string + siteId?: string + }) => Promise + listPriorities: (args?: { siteId?: string }) => Promise + listAssignableUsers: (args: { + key: string + query?: string + siteId?: string + }) => Promise + listTransitions: (args: { key: string; siteId?: string }) => Promise + } starNag: { onShow: (callback: () => void) => () => void dismiss: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 3ca0f338366..94e292f1399 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1290,6 +1290,92 @@ const api = { ipcRenderer.invoke('linear:teamMembers', args) }, + jira: { + connect: (args: { + siteUrl: string + email: string + apiToken: string + }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => + ipcRenderer.invoke('jira:connect', args), + + disconnect: (args?: { siteId?: string }): Promise => + ipcRenderer.invoke('jira:disconnect', args), + + selectSite: (args: { siteId: string }): Promise => + ipcRenderer.invoke('jira:selectSite', args), + + status: (): Promise => ipcRenderer.invoke('jira:status'), + + testConnection: (args?: { + siteId?: string + }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => + ipcRenderer.invoke('jira:testConnection', args), + + searchIssues: (args: { jql: string; limit?: number; siteId?: string }): Promise => + ipcRenderer.invoke('jira:searchIssues', args), + + listIssues: (args?: { + filter?: 'assigned' | 'reported' | 'all' | 'done' + limit?: number + siteId?: string + }): Promise => ipcRenderer.invoke('jira:listIssues', args), + + getIssue: (args: { key: string; siteId?: string }): Promise => + ipcRenderer.invoke('jira:getIssue', args), + + createIssue: (args: { + siteId?: string + projectId: string + issueTypeId: string + title: string + description?: string + customFields?: Record + }): Promise< + { ok: true; id: string; key: string; url: string } | { ok: false; error: string } + > => ipcRenderer.invoke('jira:createIssue', args), + + updateIssue: (args: { + key: string + updates: unknown + siteId?: string + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('jira:updateIssue', args), + + addIssueComment: (args: { + key: string + body: string + siteId?: string + }): Promise<{ ok: true; id: string } | { ok: false; error: string }> => + ipcRenderer.invoke('jira:addIssueComment', args), + + issueComments: (args: { key: string; siteId?: string }): Promise => + ipcRenderer.invoke('jira:issueComments', args), + + listProjects: (args?: { siteId?: string }): Promise => + ipcRenderer.invoke('jira:listProjects', args), + + listIssueTypes: (args: { projectIdOrKey: string; siteId?: string }): Promise => + ipcRenderer.invoke('jira:listIssueTypes', args), + + listCreateFields: (args: { + projectIdOrKey: string + issueTypeId: string + siteId?: string + }): Promise => ipcRenderer.invoke('jira:listCreateFields', args), + + listPriorities: (args?: { siteId?: string }): Promise => + ipcRenderer.invoke('jira:listPriorities', args), + + listAssignableUsers: (args: { + key: string + query?: string + siteId?: string + }): Promise => ipcRenderer.invoke('jira:listAssignableUsers', args), + + listTransitions: (args: { key: string; siteId?: string }): Promise => + ipcRenderer.invoke('jira:listTransitions', args) + }, + starNag: { onShow: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent): void => callback() diff --git a/src/renderer/src/components/JiraIssueWorkspace.tsx b/src/renderer/src/components/JiraIssueWorkspace.tsx new file mode 100644 index 00000000000..2bd3c10edfa --- /dev/null +++ b/src/renderer/src/components/JiraIssueWorkspace.tsx @@ -0,0 +1,750 @@ +/* eslint-disable max-lines -- Why: the Jira drawer co-locates preview, + metadata edits, and comments so the task page has one full issue surface. */ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + ArrowRight, + Clipboard, + ExternalLink, + GitBranch, + LoaderCircle, + RefreshCw, + Save, + Send, + X +} from 'lucide-react' +import { toast } from 'sonner' +import { VisuallyHidden } from 'radix-ui' + +import CommentMarkdown from '@/components/sidebar/CommentMarkdown' +import { JiraIcon } from '@/components/icons/JiraIcon' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { createBrowserUuid } from '@/lib/browser-uuid' +import { useAppStore } from '@/store' +import { + jiraAddIssueComment, + jiraGetIssue, + jiraIssueComments, + jiraListAssignableUsers, + jiraListPriorities, + jiraListTransitions, + jiraUpdateIssue +} from '@/runtime/runtime-jira-client' +import type { + JiraComment, + JiraIssue, + JiraPriority, + JiraTransition, + JiraUser +} from '../../../shared/types' + +type JiraIssueWorkspaceProps = { + issue: JiraIssue | null + onUse: (issue: JiraIssue) => void + onClose: () => void +} + +const relativeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) + +function formatRelativeTime(input: string): string { + const date = new Date(input) + if (Number.isNaN(date.getTime())) { + return 'recently' + } + const diffMinutes = Math.round((date.getTime() - Date.now()) / 60_000) + if (Math.abs(diffMinutes) < 60) { + return relativeFormatter.format(diffMinutes, 'minute') + } + const diffHours = Math.round(diffMinutes / 60) + if (Math.abs(diffHours) < 24) { + return relativeFormatter.format(diffHours, 'hour') + } + return relativeFormatter.format(Math.round(diffHours / 24), 'day') +} + +function buildJiraBranchName(issue: JiraIssue): string { + const slug = issue.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 52) + return `${issue.key.toLowerCase()}${slug ? `-${slug}` : ''}` +} + +function buildJiraPrompt(issue: JiraIssue): string { + return `Complete Jira issue ${issue.key}: ${issue.title}\n\n${issue.url}` +} + +function jiraStatusClass(categoryKey: string): string { + if (categoryKey === 'done') { + return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200' + } + if (categoryKey === 'indeterminate') { + return 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-200' + } + return 'border-border/50 bg-muted/40 text-muted-foreground' +} + +async function copyTextToClipboard(text: string, label: string): Promise { + try { + await window.api.ui.writeClipboardText(text) + toast.success(`${label} copied`) + } catch { + toast.error(`Failed to copy ${label.toLowerCase()}`) + } +} + +export default function JiraIssueWorkspace({ + issue, + onUse, + onClose +}: JiraIssueWorkspaceProps): React.JSX.Element { + const settings = useAppStore((s) => s.settings) + const patchJiraIssue = useAppStore((s) => s.patchJiraIssue) + const [fullIssue, setFullIssue] = useState(null) + const [issueLoading, setIssueLoading] = useState(false) + const [comments, setComments] = useState([]) + const [commentsLoading, setCommentsLoading] = useState(false) + const [commentsError, setCommentsError] = useState(null) + const [transitions, setTransitions] = useState([]) + const [priorities, setPriorities] = useState([]) + const [users, setUsers] = useState([]) + const [pendingField, setPendingField] = useState(null) + const [titleDraft, setTitleDraft] = useState('') + const [labelsDraft, setLabelsDraft] = useState('') + const [commentDraft, setCommentDraft] = useState('') + const [commentSubmitting, setCommentSubmitting] = useState(false) + const requestIdRef = useRef(0) + const optimisticCommentsRef = useRef([]) + + const displayed = fullIssue ?? issue + const siteId = displayed?.siteId ?? undefined + + const loadComments = useCallback( + async (targetIssue: JiraIssue, requestId: number): Promise => { + setCommentsLoading(true) + setCommentsError(null) + try { + let fetched = await jiraIssueComments(settings, targetIssue.key, targetIssue.siteId) + if (requestId !== requestIdRef.current) { + return + } + const optimistic = optimisticCommentsRef.current + if (optimistic.length > 0) { + const fetchedIds = new Set(fetched.map((comment) => comment.id)) + fetched = [...fetched, ...optimistic.filter((comment) => !fetchedIds.has(comment.id))] + } + setComments(fetched) + } catch (error) { + if (requestId === requestIdRef.current) { + setCommentsError(error instanceof Error ? error.message : 'Failed to load comments.') + } + } finally { + if (requestId === requestIdRef.current) { + setCommentsLoading(false) + } + } + }, + [settings] + ) + + useEffect(() => { + if (!issue) { + setFullIssue(null) + setIssueLoading(false) + setComments([]) + setCommentsError(null) + setTransitions([]) + setPriorities([]) + setUsers([]) + setCommentDraft('') + optimisticCommentsRef.current = [] + return + } + + requestIdRef.current += 1 + const requestId = requestIdRef.current + optimisticCommentsRef.current = [] + setFullIssue(issue) + setTitleDraft(issue.title) + setLabelsDraft(issue.labels.join(', ')) + setComments([]) + setCommentsError(null) + setIssueLoading(true) + + void jiraGetIssue(settings, issue.key, issue.siteId) + .then((result) => { + if (requestId !== requestIdRef.current) { + return + } + if (result) { + setFullIssue(result) + setTitleDraft(result.title) + setLabelsDraft(result.labels.join(', ')) + } + }) + .catch(() => {}) + .finally(() => { + if (requestId === requestIdRef.current) { + setIssueLoading(false) + } + }) + + void Promise.all([ + jiraListTransitions(settings, issue.key, issue.siteId), + jiraListPriorities(settings, issue.siteId), + jiraListAssignableUsers(settings, issue.key, undefined, issue.siteId) + ]) + .then(([nextTransitions, nextPriorities, nextUsers]) => { + if (requestId !== requestIdRef.current) { + return + } + setTransitions(nextTransitions) + setPriorities(nextPriorities) + setUsers(nextUsers) + }) + .catch(() => {}) + + void loadComments(issue, requestId) + }, [issue, loadComments, settings]) + + const refreshIssue = useCallback(async (): Promise => { + if (!displayed) { + return + } + try { + const latest = await jiraGetIssue(settings, displayed.key, displayed.siteId) + if (latest) { + setFullIssue(latest) + patchJiraIssue(latest.key, latest) + } + } catch { + // Keep the visible issue snapshot if refresh fails. + } + }, [displayed, patchJiraIssue, settings]) + + const mutateIssue = useCallback( + async ( + field: string, + updates: Parameters[2], + optimistic?: Partial + ): Promise => { + if (!displayed || pendingField) { + return + } + setPendingField(field) + const previous = displayed + try { + if (optimistic) { + setFullIssue({ ...displayed, ...optimistic }) + patchJiraIssue(displayed.key, optimistic) + } + const result = await jiraUpdateIssue(settings, displayed.key, updates, siteId) + if (!result.ok) { + throw new Error(result.error) + } + await refreshIssue() + } catch (error) { + setFullIssue(previous) + patchJiraIssue(previous.key, previous) + toast.error(error instanceof Error ? error.message : 'Failed to update Jira issue.') + } finally { + setPendingField(null) + } + }, + [displayed, patchJiraIssue, pendingField, refreshIssue, settings, siteId] + ) + + const handleSaveTitle = useCallback(() => { + if (!displayed) { + return + } + const title = titleDraft.trim() + if (!title || title === displayed.title) { + setTitleDraft(displayed.title) + return + } + void mutateIssue('title', { title }, { title }) + }, [displayed, mutateIssue, titleDraft]) + + const handleSaveLabels = useCallback(() => { + if (!displayed) { + return + } + const labels = labelsDraft + .split(',') + .map((label) => label.trim()) + .filter(Boolean) + void mutateIssue('labels', { labels }, { labels }) + }, [displayed, labelsDraft, mutateIssue]) + + const handleSubmitComment = useCallback(async (): Promise => { + if (!displayed || commentSubmitting) { + return + } + const body = commentDraft.trim() + if (!body) { + return + } + setCommentSubmitting(true) + try { + const result = await jiraAddIssueComment(settings, displayed.key, body, displayed.siteId) + if (!result.ok) { + throw new Error(result.error) + } + const comment: JiraComment = { + id: result.id || createBrowserUuid(), + body, + createdAt: new Date().toISOString(), + user: { accountId: 'local', displayName: 'You' } + } + optimisticCommentsRef.current.push(comment) + setComments((prev) => [...prev, comment]) + setCommentDraft('') + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to add comment.') + } finally { + setCommentSubmitting(false) + } + }, [commentDraft, commentSubmitting, displayed, settings]) + + const actionItems = useMemo(() => { + if (!displayed) { + return [] + } + return [ + { + label: 'Open in Jira', + icon: ExternalLink, + action: () => window.api.shell.openUrl(displayed.url) + }, + { + label: 'Copy URL', + icon: Clipboard, + action: () => void copyTextToClipboard(displayed.url, 'URL') + }, + { + label: 'Copy key', + icon: Clipboard, + action: () => void copyTextToClipboard(displayed.key, 'Key') + }, + { + label: 'Copy suggested branch name', + icon: GitBranch, + action: () => void copyTextToClipboard(buildJiraBranchName(displayed), 'Branch name') + }, + { + label: 'Copy prompt', + icon: Clipboard, + action: () => void copyTextToClipboard(buildJiraPrompt(displayed), 'Prompt') + } + ] + }, [displayed]) + + return ( + !open && onClose()}> + event.preventDefault()} + > + + {displayed?.title ?? 'Jira issue'} + + + + Preview, edit, and start work from the selected issue. + + + + {displayed ? ( +
+
+
+
+
+ {displayed.key} + {displayed.siteName ? {displayed.siteName} : null} + {displayed.project.key} + {formatRelativeTime(displayed.updatedAt)} + {issueLoading ? : null} +
+

+ {displayed.title} +

+
+ + + + + + + Close + + +
+
+ +
+ + + + + + {transitions.map((transition) => ( + + ))} + + + + + + + + + + {priorities.map((priority) => ( + + ))} + + + + + + + + + + {users.map((user) => ( + + ))} + + +
+ +
+
+
+
+ +
+ setTitleDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && !event.nativeEvent.isComposing) { + event.preventDefault() + handleSaveTitle() + } + }} + className="h-8 text-xs" + /> + +
+ +
+ setLabelsDraft(event.target.value)} + placeholder="backend, bug" + className="h-8 text-xs" + /> + +
+
+
+ +
+
+ + + {displayed.issueType.name} + + + {displayed.project.key} · {displayed.assignee?.displayName ?? 'Unassigned'} + +
+ {displayed.description?.trim() ? ( + + ) : ( +

No description provided.

+ )} +
+ +
+
+
+ Comments + {comments.length > 0 ? ( + {comments.length} + ) : null} +
+ {commentsError ? ( + + ) : null} +
+ {commentsError ? ( +
+ {commentsError} +
+ ) : commentsLoading && comments.length === 0 ? ( +
+ +
+ ) : comments.length === 0 ? ( +

No comments yet.

+ ) : ( +
+ {comments.map((comment) => ( +
+
+ {comment.user?.avatarUrl ? ( + + ) : null} + + {comment.user?.displayName ?? 'Unknown'} + + + {formatRelativeTime(comment.createdAt)} + +
+
+ +
+
+ ))} +
+ )} +
+
+ + +
+ +
+
+