Add Jira task provider support (#2238)

This commit is contained in:
Neil
2026-06-01 19:04:04 -07:00
committed by GitHub
parent 4766797b45
commit d0904c90fb
45 changed files with 5788 additions and 48 deletions
@@ -110,7 +110,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
skipDeleteAutomationConfirm: false,
defaultTaskViewPreset: 'all',
defaultTaskSource: 'github',
visibleTaskProviders: ['github', 'gitlab', 'linear'],
visibleTaskProviders: ['github', 'gitlab', 'linear', 'jira'],
visibleTaskProvidersDefaultedForJira: true,
defaultRepoSelection: null,
defaultLinearTeamSelection: null,
opencodeSessionCookie: '',
+2 -1
View File
@@ -114,7 +114,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
skipDeleteAutomationConfirm: false,
defaultTaskViewPreset: 'all',
defaultTaskSource: 'github',
visibleTaskProviders: ['github', 'gitlab', 'linear'],
visibleTaskProviders: ['github', 'gitlab', 'linear', 'jira'],
visibleTaskProvidersDefaultedForJira: true,
defaultRepoSelection: null,
defaultLinearTeamSelection: null,
opencodeSessionCookie: '',
+262
View File
@@ -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<JiraIssueFilter>(['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))
})
}
@@ -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()
+2
View File
@@ -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)
+490
View File
@@ -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<void> {
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<string, string>()
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<string, unknown>
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<JiraSiteFile>
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<string, unknown>, fallbackEmail: string): JiraViewer {
const avatarUrls = data.avatarUrls as Record<string, unknown> | 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<unknown> {
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<string> {
try {
const data = (await response.json()) as {
errorMessages?: string[]
errors?: Record<string, string>
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<T>(
client: JiraClientForSite,
path: string,
init?: RequestInit
): Promise<T> {
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<string, unknown>,
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)
}
+210
View File
@@ -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
}
])
})
})
+831
View File
@@ -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<string, unknown>
type JiraSearchResponse = {
issues?: JiraRecord[]
}
type JiraPagedResponse<T> = {
startAt?: number
maxResults?: number
total?: number
isLast?: boolean
values?: T[]
issueTypes?: T[]
comments?: T[]
fields?: T[] | Record<string, T>
}
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<T>(response: JiraPagedResponse<T>, key: JiraPageItemKey): T[] {
const keyedItems = response[key]
if (Array.isArray(keyedItems)) {
return keyedItems
}
return response.values ?? []
}
function shouldFetchNextPage<T>(
response: JiraPagedResponse<T>,
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<JiraRecord[]> {
const records: JiraRecord[] = []
let startAt = 0
for (let guard = 0; guard < 100; guard += 1) {
const response = await jiraRequest<JiraPagedResponse<JiraRecord>>(
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>): 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<JiraIssue[]> {
const result = await jiraRequest<JiraSearchResponse>(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<JiraIssue[]> {
return searchIssues(filterToJql(filter), limit, siteId)
}
export async function searchIssues(
jql: string,
limit = 30,
siteId?: JiraSiteSelection | null
): Promise<JiraIssue[]> {
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<JiraIssue | null> {
const entries = getClients(siteId)
for (const entry of entries) {
await acquire()
try {
const issue = await jiraRequest<JiraRecord>(
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<JiraCreateIssueResult> {
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<JiraMutationResult> {
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<JiraComment[]> {
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<JiraProject[]> {
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<JiraIssueType[]> {
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<JiraCreateField[]> {
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<JiraPagedResponse<JiraRecord>>(
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<JiraPriority[]> {
const entry = getClients(siteId)[0]
if (!entry) {
return []
}
await acquire()
try {
const response = await jiraRequest<JiraRecord[]>(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<JiraUser[]> {
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<JiraRecord[]>(
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<JiraTransition[]> {
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()
}
}
+23 -5
View File
@@ -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 () => {
+19 -1
View File
@@ -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)
+129
View File
@@ -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<typeof connectJira> {
return connectJira(args)
}
jiraDisconnect(siteId?: string): { ok: true } {
disconnectJira(siteId)
return { ok: true }
}
jiraSelectSite(siteId: JiraSiteSelection): ReturnType<typeof getJiraStatus> {
return selectJiraSite(siteId)
}
jiraStatus(): ReturnType<typeof getJiraStatus> {
return getJiraStatus()
}
jiraTestConnection(siteId?: string): ReturnType<typeof testJiraConnection> {
return testJiraConnection(siteId)
}
jiraSearchIssues(
jql: string,
limit = 30,
siteId?: JiraSiteSelection
): ReturnType<typeof searchJiraIssues> {
return searchJiraIssues(jql, Math.min(Math.max(1, limit), 100), siteId)
}
jiraListIssues(
filter?: JiraIssueFilter,
limit = 30,
siteId?: JiraSiteSelection
): ReturnType<typeof listJiraIssues> {
return listJiraIssues(filter, Math.min(Math.max(1, limit), 100), siteId)
}
jiraCreateIssue(args: JiraCreateIssueArgs): ReturnType<typeof createJiraIssue> {
return createJiraIssue(args)
}
jiraGetIssue(key: string, siteId?: string): ReturnType<typeof getJiraIssue> {
return getJiraIssue(key, siteId)
}
jiraUpdateIssue(
key: string,
updates: JiraIssueUpdate,
siteId?: string
): ReturnType<typeof updateJiraIssue> {
return updateJiraIssue(key, updates, siteId)
}
jiraAddIssueComment(
key: string,
body: string,
siteId?: string
): ReturnType<typeof addJiraIssueComment> {
return addJiraIssueComment(key, body, siteId)
}
jiraIssueComments(key: string, siteId?: string): ReturnType<typeof getJiraIssueComments> {
return getJiraIssueComments(key, siteId)
}
jiraListProjects(siteId?: JiraSiteSelection): ReturnType<typeof listJiraProjects> {
return listJiraProjects(siteId)
}
jiraListIssueTypes(
projectIdOrKey: string,
siteId?: string
): ReturnType<typeof listJiraIssueTypes> {
return listJiraIssueTypes(projectIdOrKey, siteId)
}
jiraListCreateFields(
projectIdOrKey: string,
issueTypeId: string,
siteId?: string
): ReturnType<typeof listJiraCreateFields> {
return listJiraCreateFields(projectIdOrKey, issueTypeId, siteId)
}
jiraListPriorities(siteId?: string): ReturnType<typeof listJiraPriorities> {
return listJiraPriorities(siteId)
}
jiraListAssignableUsers(
key: string,
query?: string,
siteId?: string
): ReturnType<typeof listJiraAssignableUsers> {
return listJiraAssignableUsers(key, query, siteId)
}
jiraListTransitions(key: string, siteId?: string): ReturnType<typeof listJiraTransitions> {
return listJiraTransitions(key, siteId)
}
// ── Browser automation ──
private readonly browserCommands = new RuntimeBrowserCommands({
+2
View File
@@ -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,
+162
View File
@@ -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')
})
})
+208
View File
@@ -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)
})
]
+66
View File
@@ -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<LinearLabel[]>
teamMembers: (args: { teamId: string; workspaceId?: string }) => Promise<LinearMember[]>
}
jira: {
connect: (args: {
siteUrl: string
email: string
apiToken: string
}) => Promise<{ ok: true; viewer: JiraViewer } | { ok: false; error: string }>
disconnect: (args?: { siteId?: string }) => Promise<void>
selectSite: (args: { siteId: JiraSiteSelection }) => Promise<JiraConnectionStatus>
status: () => Promise<JiraConnectionStatus>
testConnection: (args?: {
siteId?: string
}) => Promise<{ ok: true; viewer: JiraViewer } | { ok: false; error: string }>
searchIssues: (args: {
jql: string
limit?: number
siteId?: JiraSiteSelection
}) => Promise<JiraIssue[]>
listIssues: (args?: {
filter?: JiraIssueFilter
limit?: number
siteId?: JiraSiteSelection
}) => Promise<JiraIssue[]>
getIssue: (args: { key: string; siteId?: string }) => Promise<JiraIssue | null>
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<JiraComment[]>
listProjects: (args?: { siteId?: JiraSiteSelection }) => Promise<JiraProject[]>
listIssueTypes: (args: { projectIdOrKey: string; siteId?: string }) => Promise<JiraIssueType[]>
listCreateFields: (args: {
projectIdOrKey: string
issueTypeId: string
siteId?: string
}) => Promise<JiraCreateField[]>
listPriorities: (args?: { siteId?: string }) => Promise<JiraPriority[]>
listAssignableUsers: (args: {
key: string
query?: string
siteId?: string
}) => Promise<JiraUser[]>
listTransitions: (args: { key: string; siteId?: string }) => Promise<JiraTransition[]>
}
starNag: {
onShow: (callback: () => void) => () => void
dismiss: () => Promise<void>
+86
View File
@@ -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<void> =>
ipcRenderer.invoke('jira:disconnect', args),
selectSite: (args: { siteId: string }): Promise<unknown> =>
ipcRenderer.invoke('jira:selectSite', args),
status: (): Promise<unknown> => 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<unknown[]> =>
ipcRenderer.invoke('jira:searchIssues', args),
listIssues: (args?: {
filter?: 'assigned' | 'reported' | 'all' | 'done'
limit?: number
siteId?: string
}): Promise<unknown[]> => ipcRenderer.invoke('jira:listIssues', args),
getIssue: (args: { key: string; siteId?: string }): Promise<unknown> =>
ipcRenderer.invoke('jira:getIssue', args),
createIssue: (args: {
siteId?: string
projectId: string
issueTypeId: string
title: string
description?: string
customFields?: Record<string, unknown>
}): 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<unknown[]> =>
ipcRenderer.invoke('jira:issueComments', args),
listProjects: (args?: { siteId?: string }): Promise<unknown[]> =>
ipcRenderer.invoke('jira:listProjects', args),
listIssueTypes: (args: { projectIdOrKey: string; siteId?: string }): Promise<unknown[]> =>
ipcRenderer.invoke('jira:listIssueTypes', args),
listCreateFields: (args: {
projectIdOrKey: string
issueTypeId: string
siteId?: string
}): Promise<unknown[]> => ipcRenderer.invoke('jira:listCreateFields', args),
listPriorities: (args?: { siteId?: string }): Promise<unknown[]> =>
ipcRenderer.invoke('jira:listPriorities', args),
listAssignableUsers: (args: {
key: string
query?: string
siteId?: string
}): Promise<unknown[]> => ipcRenderer.invoke('jira:listAssignableUsers', args),
listTransitions: (args: { key: string; siteId?: string }): Promise<unknown[]> =>
ipcRenderer.invoke('jira:listTransitions', args)
},
starNag: {
onShow: (callback: () => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent): void => callback()
@@ -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<void> {
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<JiraIssue | null>(null)
const [issueLoading, setIssueLoading] = useState(false)
const [comments, setComments] = useState<JiraComment[]>([])
const [commentsLoading, setCommentsLoading] = useState(false)
const [commentsError, setCommentsError] = useState<string | null>(null)
const [transitions, setTransitions] = useState<JiraTransition[]>([])
const [priorities, setPriorities] = useState<JiraPriority[]>([])
const [users, setUsers] = useState<JiraUser[]>([])
const [pendingField, setPendingField] = useState<string | null>(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<JiraComment[]>([])
const displayed = fullIssue ?? issue
const siteId = displayed?.siteId ?? undefined
const loadComments = useCallback(
async (targetIssue: JiraIssue, requestId: number): Promise<void> => {
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<void> => {
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<typeof jiraUpdateIssue>[2],
optimistic?: Partial<JiraIssue>
): Promise<void> => {
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<void> => {
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 (
<Sheet open={issue !== null} onOpenChange={(open) => !open && onClose()}>
<SheetContent
side="right"
showCloseButton={false}
className="w-[min(92vw,780px)] p-0 sm:max-w-[780px]"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<VisuallyHidden.Root asChild>
<SheetTitle>{displayed?.title ?? 'Jira issue'}</SheetTitle>
</VisuallyHidden.Root>
<VisuallyHidden.Root asChild>
<SheetDescription>
Preview, edit, and start work from the selected issue.
</SheetDescription>
</VisuallyHidden.Root>
{displayed ? (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
<div className="flex-none border-b border-border/50 bg-muted/30 px-4 py-3">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
<span className="font-mono">{displayed.key}</span>
{displayed.siteName ? <span>{displayed.siteName}</span> : null}
<span>{displayed.project.key}</span>
<span>{formatRelativeTime(displayed.updatedAt)}</span>
{issueLoading ? <LoaderCircle className="size-3 animate-spin" /> : null}
</div>
<h2 className="mt-1 text-[20px] font-semibold leading-tight text-foreground">
{displayed.title}
</h2>
</div>
<Button
onClick={() => onUse(displayed)}
className="hidden shrink-0 gap-2 sm:inline-flex"
size="sm"
>
Start workspace
<ArrowRight className="size-4" />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="shrink-0"
onClick={onClose}
aria-label="Close Jira issue preview"
>
<X className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Close
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border/60 px-4 py-2.5">
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={pendingField === 'transition' || transitions.length === 0}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-medium transition hover:opacity-80 disabled:opacity-50',
jiraStatusClass(displayed.status.categoryKey)
)}
>
{displayed.status.name}
{pendingField === 'transition' ? (
<LoaderCircle className="size-3 animate-spin" />
) : null}
</button>
</PopoverTrigger>
<PopoverContent
className="popover-scroll-content scrollbar-sleek w-52 p-1"
align="start"
>
{transitions.map((transition) => (
<button
key={transition.id}
type="button"
onClick={() =>
void mutateIssue(
'transition',
{ transitionId: transition.id },
{ status: transition.to }
)
}
className="flex w-full items-center rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
>
{transition.name}
</button>
))}
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={pendingField === 'priority'}
className="rounded-md px-1.5 py-0.5 text-[11px] text-muted-foreground transition hover:bg-muted/40 disabled:opacity-50"
>
{displayed.priority?.name ?? 'No priority'}
{pendingField === 'priority' ? (
<LoaderCircle className="ml-1 inline size-3 animate-spin" />
) : null}
</button>
</PopoverTrigger>
<PopoverContent
className="popover-scroll-content scrollbar-sleek w-48 p-1"
align="start"
>
<button
type="button"
onClick={() =>
void mutateIssue('priority', { priorityId: null }, { priority: undefined })
}
className="flex w-full items-center rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
>
No priority
</button>
{priorities.map((priority) => (
<button
key={priority.id}
type="button"
onClick={() =>
void mutateIssue('priority', { priorityId: priority.id }, { priority })
}
className="flex w-full items-center rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
>
{priority.name}
</button>
))}
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={pendingField === 'assignee'}
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] text-muted-foreground transition hover:bg-muted/40 disabled:opacity-50"
>
{displayed.assignee?.displayName ?? '+ Assignee'}
{pendingField === 'assignee' ? (
<LoaderCircle className="size-3 animate-spin" />
) : null}
</button>
</PopoverTrigger>
<PopoverContent
className="popover-scroll-content scrollbar-sleek w-56 p-1"
align="start"
>
<button
type="button"
onClick={() =>
void mutateIssue(
'assignee',
{ assigneeAccountId: null },
{ assignee: undefined }
)
}
className="flex w-full items-center rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
>
Unassigned
</button>
{users.map((user) => (
<button
key={user.accountId}
type="button"
onClick={() =>
void mutateIssue(
'assignee',
{ assigneeAccountId: user.accountId },
{ assignee: user }
)
}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
>
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="" className="size-5 rounded-full" />
) : null}
<span className="truncate">{user.displayName}</span>
</button>
))}
</PopoverContent>
</Popover>
</div>
<div className="grid min-h-0 flex-1 grid-cols-1 xl:grid-cols-[minmax(0,1fr)_228px]">
<div className="min-h-0 overflow-y-auto scrollbar-sleek">
<section className="border-b border-border/40 px-4 py-4">
<div className="grid gap-2">
<label className="text-[11px] font-medium text-muted-foreground">Title</label>
<div className="flex gap-2">
<Input
value={titleDraft}
onChange={(event) => setTitleDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
event.preventDefault()
handleSaveTitle()
}
}}
className="h-8 text-xs"
/>
<Button
size="sm"
variant="outline"
onClick={handleSaveTitle}
disabled={pendingField === 'title'}
>
{pendingField === 'title' ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Save className="size-4" />
)}
</Button>
</div>
<label className="mt-2 text-[11px] font-medium text-muted-foreground">
Labels
</label>
<div className="flex gap-2">
<Input
value={labelsDraft}
onChange={(event) => setLabelsDraft(event.target.value)}
placeholder="backend, bug"
className="h-8 text-xs"
/>
<Button
size="sm"
variant="outline"
onClick={handleSaveLabels}
disabled={pendingField === 'labels'}
>
{pendingField === 'labels' ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Save className="size-4" />
)}
</Button>
</div>
</div>
</section>
<section className="border-b border-border/40 px-4 py-4">
<div className="mb-2 flex items-center gap-2">
<JiraIcon className="size-3 text-muted-foreground" />
<span className="text-xs font-medium text-foreground">
{displayed.issueType.name}
</span>
<span className="text-xs text-muted-foreground">
{displayed.project.key} · {displayed.assignee?.displayName ?? 'Unassigned'}
</span>
</div>
{displayed.description?.trim() ? (
<CommentMarkdown
content={displayed.description}
className="text-[14px] leading-relaxed"
/>
) : (
<p className="text-sm italic text-muted-foreground">No description provided.</p>
)}
</section>
<section className="px-4 py-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-foreground">Comments</span>
{comments.length > 0 ? (
<span className="text-[12px] text-muted-foreground">{comments.length}</span>
) : null}
</div>
{commentsError ? (
<Button
variant="outline"
size="xs"
onClick={() => void loadComments(displayed, requestIdRef.current)}
disabled={commentsLoading}
className="gap-1"
>
{commentsLoading ? (
<LoaderCircle className="size-3 animate-spin" />
) : (
<RefreshCw className="size-3" />
)}
Retry
</Button>
) : null}
</div>
{commentsError ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{commentsError}
</div>
) : commentsLoading && comments.length === 0 ? (
<div className="flex items-center justify-center py-8">
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
</div>
) : comments.length === 0 ? (
<p className="text-sm text-muted-foreground">No comments yet.</p>
) : (
<div className="flex flex-col gap-3">
{comments.map((comment) => (
<div
key={comment.id}
className="rounded-md border border-border/50 bg-muted/20"
>
<div className="flex min-w-0 items-center gap-2 border-b border-border/40 px-3 py-2">
{comment.user?.avatarUrl ? (
<img
src={comment.user.avatarUrl}
alt=""
className="size-5 shrink-0 rounded-full"
/>
) : null}
<span className="truncate text-[13px] font-semibold text-foreground">
{comment.user?.displayName ?? 'Unknown'}
</span>
<span className="shrink-0 text-[12px] text-muted-foreground">
{formatRelativeTime(comment.createdAt)}
</span>
</div>
<div className="px-3 py-2">
<CommentMarkdown
content={comment.body}
className="text-[13px] leading-relaxed"
/>
</div>
</div>
))}
</div>
)}
</section>
</div>
<aside className="border-t border-border/50 bg-muted/20 px-3 py-3 xl:border-l xl:border-t-0">
<Button
onClick={() => onUse(displayed)}
className="mb-3 w-full justify-center gap-2 sm:hidden"
>
Start workspace
<ArrowRight className="size-4" />
</Button>
<div className="grid gap-1">
{actionItems.map((item) => {
const Icon = item.icon
return (
<Tooltip key={item.label}>
<TooltipTrigger asChild>
<button
type="button"
onClick={item.action}
className="flex min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-muted-foreground transition hover:bg-accent hover:text-accent-foreground"
>
<Icon className="size-3.5 shrink-0" />
<span className="truncate">{item.label}</span>
</button>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={6}>
{item.label}
</TooltipContent>
</Tooltip>
)
})}
</div>
</aside>
</div>
<div className="flex-none border-t border-border/50 bg-background px-3 py-3">
<div className="flex gap-2">
<textarea
value={commentDraft}
onChange={(event) => setCommentDraft(event.target.value)}
placeholder="Add a Jira comment..."
rows={2}
disabled={commentSubmitting}
className="min-h-10 flex-1 resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
<Button
onClick={() => void handleSubmitComment()}
disabled={!commentDraft.trim() || commentSubmitting}
className="self-end gap-2"
>
{commentSubmitting ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
Comment
</Button>
</div>
</div>
</div>
) : null}
</SheetContent>
</Sheet>
)
}
@@ -558,7 +558,7 @@ export default function NewWorkspaceComposerCard({
)}
>
{smartNameSelection ? (
// Why: when a source (PR/issue/Linear/branch) is picked the
// Why: when a source (PR/issue/Linear/Jira/branch) is picked the
// smart field shows a pill instead of an editable name, so
// surface the auto-derived workspace name here under Advanced
// where it can be reviewed/overridden. When the user typed an
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
export function JiraIcon({ className }: { className?: string }): React.JSX.Element {
return (
<svg viewBox="0 0 24 24" aria-hidden className={className} fill="currentColor">
<path d="M11.54 2.3 3.2 10.64a2.44 2.44 0 0 0 0 3.45l5.9 5.91 3.03-3.03-4.19-4.18a.58.58 0 0 1 0-.82l5.75-5.74-2.15-3.93Z" />
<path d="m14.9 4 3.9 3.91a2.44 2.44 0 0 1 0 3.45L10.46 19.7l-2.15-3.93 5.75-5.74a.58.58 0 0 0 0-.82L11.54 6.7 14.9 4Z" />
</svg>
)
}
@@ -42,6 +42,7 @@ import { lookupSmartGitHubSubmitItem } from '@/lib/smart-github-submit'
import { parseGitLabIssueOrMRLink } from '@/lib/gitlab-links'
import { cn } from '@/lib/utils'
import { LinearIcon } from '@/components/icons/LinearIcon'
import { JiraIcon } from '@/components/icons/JiraIcon'
import { searchRuntimeRepoBaseRefDetails } from '@/runtime/runtime-repo-client'
import {
buildSmartWorkspaceSourceRows,
@@ -95,7 +96,7 @@ type SmartWorkspaceNameFieldProps = {
}
export type SmartWorkspaceNameSelection = {
kind: 'github-pr' | 'github-issue' | 'gitlab-mr' | 'gitlab-issue' | 'branch' | 'linear'
kind: 'github-pr' | 'github-issue' | 'gitlab-mr' | 'gitlab-issue' | 'branch' | 'linear' | 'jira'
label: string
url?: string
}
@@ -1244,6 +1245,9 @@ function SelectionIcon({ kind }: { kind: SmartWorkspaceNameSelection['kind'] }):
if (kind === 'branch') {
return <GitBranch className="size-3.5 shrink-0 text-muted-foreground" />
}
if (kind === 'jira') {
return <JiraIcon className="size-3.5 shrink-0 text-muted-foreground" />
}
return <LinearIcon className="size-3.5 shrink-0 text-muted-foreground" />
}
@@ -6,6 +6,7 @@ import {
resolveVisibleTaskProvider
} from '../../../../shared/task-providers'
import { cn } from '@/lib/utils'
import { JiraIcon } from '@/components/icons/JiraIcon'
import { LinearIcon } from '@/components/icons/LinearIcon'
import { Label } from '../ui/label'
import { SearchableSetting } from './SearchableSetting'
@@ -39,6 +40,12 @@ const TASK_PROVIDER_OPTIONS: readonly {
label: 'Linear',
description: 'Show Linear in the Tasks source picker and sidebar shortcuts.',
Icon: ({ className }) => <LinearIcon className={className} />
},
{
id: 'jira',
label: 'Jira',
description: 'Show Jira in the Tasks source picker and sidebar shortcuts.',
Icon: ({ className }) => <JiraIcon className={className} />
}
]
@@ -79,6 +86,8 @@ export function TasksPane({ settings, updateSettings }: TasksPaneProps): React.J
'github',
'gitlab',
'linear',
'jira',
'atlassian',
'display',
'hide'
]}
@@ -4,6 +4,17 @@ export const TASKS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Task Providers',
description: 'Choose which task providers appear in the Tasks page and sidebar shortcuts.',
keywords: ['tasks', 'provider', 'source', 'github', 'gitlab', 'linear', 'display', 'hide']
keywords: [
'tasks',
'provider',
'source',
'github',
'gitlab',
'linear',
'jira',
'atlassian',
'display',
'hide'
]
}
]
@@ -7,6 +7,7 @@ import { isGitRepoKind } from '../../../../shared/repo-kind'
import type { GlobalSettings } from '../../../../shared/types'
import { getTaskPresetQuery, PER_REPO_FETCH_LIMIT } from '@/lib/new-workspace'
import { LinearIcon } from '@/components/icons/LinearIcon'
import { JiraIcon } from '@/components/icons/JiraIcon'
import {
normalizeVisibleTaskProviders,
restoreAvailableDefaultTaskProvider,
@@ -219,6 +220,23 @@ const SidebarNav = React.memo(function SidebarNav() {
<LinearIcon className="size-3.5" />
</span>
) : null}
{visibleTaskProviders.includes('jira') ? (
<span
role="button"
tabIndex={-1}
onClick={(e) => {
e.stopPropagation()
if (!canBrowseTasks) {
return
}
openTaskPage({ taskSource: 'jira' })
}}
className="rounded p-0.5 text-muted-foreground/70 transition-colors hover:text-foreground"
aria-label="Open Jira tasks"
>
<JiraIcon className="size-3.5" />
</span>
) : null}
</span>
</button>
) : null}
@@ -0,0 +1,30 @@
import type { CacheEntry } from '@/store/slices/github'
import type { JiraIssue } from '../../../shared/types'
type JiraIssueCache = Record<string, CacheEntry<JiraIssue>>
type JiraSearchCache = Record<string, CacheEntry<JiraIssue[]>>
export function findTaskPageJiraIssue(
jiraIssueCache: JiraIssueCache,
jiraSearchCache: JiraSearchCache,
jiraIssueKey: string | null
): JiraIssue | null {
if (!jiraIssueKey) {
return null
}
for (const entry of Object.values(jiraIssueCache)) {
if (entry?.data?.key === jiraIssueKey) {
return entry.data
}
}
for (const entry of Object.values(jiraSearchCache)) {
const found = entry?.data?.find((issue) => issue.key === jiraIssueKey)
if (found) {
return found
}
}
return null
}
+24 -9
View File
@@ -41,6 +41,7 @@ import {
buildAgentPromptWithContext,
ensureAgentStartupInTerminal,
getAttachmentLabel,
getLinkedWorkItemProvider,
getLinkedWorkItemSuggestedName,
getSetupConfig,
getWorkspaceSeedName,
@@ -368,8 +369,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}
if (
initialLinkedWorkItem?.type === 'issue' &&
!initialLinkedWorkItem.linearIdentifier &&
!isGitLabIssueUrl(initialLinkedWorkItem.url)
getLinkedWorkItemProvider(initialLinkedWorkItem) === 'github'
) {
return String(initialLinkedWorkItem.number)
}
@@ -1116,6 +1116,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}
setLinkedWorkItem({
type: item.type,
provider: 'github',
number: item.number,
title: item.title,
url: item.url
@@ -1191,6 +1192,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}
setLinkedWorkItem({
type: item.type,
provider: 'gitlab',
number: item.number,
title: item.title,
url: item.url
@@ -1761,16 +1763,23 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const smartNameSelection = useMemo<SmartWorkspaceNameSelection | null>(() => {
if (linkedWorkItem) {
const isLinear = linkedWorkItem.number === 0 && !linkedWorkItem.url.includes('github.com')
const provider = getLinkedWorkItemProvider(linkedWorkItem)
const isLinear = provider === 'linear'
const kind: SmartWorkspaceNameSelection['kind'] = isLinear
? 'linear'
: linkedWorkItem.type === 'pr'
? 'github-pr'
: 'github-issue'
: provider === 'jira'
? 'jira'
: provider === 'gitlab'
? linkedWorkItem.type === 'mr'
? 'gitlab-mr'
: 'gitlab-issue'
: linkedWorkItem.type === 'pr'
? 'github-pr'
: 'github-issue'
return {
kind,
label:
isLinear || linkedWorkItem.number === 0
isLinear || provider === 'jira' || linkedWorkItem.number === 0
? linkedWorkItem.title
: `#${linkedWorkItem.number} ${linkedWorkItem.title}`,
url: linkedWorkItem.url
@@ -1885,7 +1894,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
: await ensureHooksConfirmed(useAppStore.getState(), repoId, 'issueCommand')
}
const linkedLinearIssue = submitLinkedWorkItem?.linearIdentifier
const linkedLinearIssue =
submitLinkedWorkItem && getLinkedWorkItemProvider(submitLinkedWorkItem) === 'linear'
? submitLinkedWorkItem.linearIdentifier
: undefined
const effectiveBranchNameOverride = resolveComposerBranchNameOverrideForCreate({
branchNameOverride,
branchAutoName: branchAutoNameRef.current,
@@ -2109,7 +2121,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
? 'skip'
: ((submitResolvedSetupDecision ?? 'inherit') as SetupDecision)
const linkedLinearIssue = submitLinkedWorkItem?.linearIdentifier
const linkedLinearIssue =
submitLinkedWorkItem && getLinkedWorkItemProvider(submitLinkedWorkItem) === 'linear'
? submitLinkedWorkItem.linearIdentifier
: undefined
const effectiveBranchNameOverride = resolveComposerBranchNameOverrideForCreate({
branchNameOverride,
branchAutoName: branchAutoNameRef.current,
@@ -14,6 +14,7 @@ export function buildLinearIssueLinkedWorkItem(
): LinkedWorkItemSummary {
return {
type: 'issue',
provider: 'linear',
// Why: Linear issue identifiers are strings; keep numeric issue metadata
// empty while preserving the real source through `linearIdentifier`.
number: 0,
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { getLinkedWorkItemProvider } from './new-workspace'
describe('getLinkedWorkItemProvider', () => {
it.each([
[
'explicit provider metadata',
{
type: 'issue',
provider: 'jira',
number: 0,
title: 'ORCA-123 Fix Jira',
url: 'https://example.atlassian.net/browse/ORCA-123',
jiraIdentifier: 'ORCA-123'
},
'jira'
],
[
'Jira issue URL with no numeric issue id',
{
type: 'issue',
number: 0,
title: 'ORCA-123 Fix Jira',
url: 'https://example.atlassian.net/browse/ORCA-123'
},
'jira'
],
[
'legacy Linear linked issue',
{
type: 'issue',
number: 0,
title: 'Fix Linear',
url: 'https://linear.app/team/issue/ENG-123/fix-linear',
linearIdentifier: 'ENG-123'
},
'linear'
]
] as const)('detects %s', (_label, item, provider) => {
expect(getLinkedWorkItemProvider(item)).toBe(provider)
})
})
@@ -0,0 +1,46 @@
import type { LinkedWorkItemSummary } from './new-workspace'
export function isGitLabIssueUrl(url: string): boolean {
// Why: self-hosted GitLab issue URLs may not contain "gitlab".
try {
return new URL(url).pathname.includes('/-/issues/')
} catch {
return /\/-\/issues\//i.test(url)
}
}
function isJiraIssueUrl(url: string): boolean {
try {
const parsed = new URL(url)
return (
/\.atlassian\.net$/i.test(parsed.hostname) ||
/\/browse\/[A-Z][A-Z0-9]+-\d+/i.test(parsed.pathname)
)
} catch {
return false
}
}
export function getLinkedWorkItemProvider(
item: LinkedWorkItemSummary
): NonNullable<LinkedWorkItemSummary['provider']> {
if (item.provider) {
return item.provider
}
if (item.linearIdentifier) {
return 'linear'
}
if (item.jiraIdentifier || isJiraIssueUrl(item.url)) {
return 'jira'
}
if (item.type === 'mr') {
return 'gitlab'
}
if (isGitLabIssueUrl(item.url)) {
return 'gitlab'
}
if (item.number === 0 && !item.url.includes('github.com')) {
return 'linear'
}
return 'github'
}
+4 -17
View File
@@ -39,39 +39,26 @@ export function getTaskPresetQuery(presetId: TaskViewPresetId | null): string {
}
}
export const IS_MAC = navigator.userAgent.includes('Mac')
export const CLIENT_PLATFORM: NodeJS.Platform = navigator.userAgent.includes('Windows')
? 'win32'
: IS_MAC
: navigator.userAgent.includes('Mac')
? 'darwin'
: 'linux'
export type { LinkedWorkItemContext } from '@/lib/linked-work-item-context'
export { getLinkedWorkItemProvider, isGitLabIssueUrl } from './linked-work-item-provider'
export type LinkedWorkItemSummary = {
/** 'mr' is the GitLab analogue of 'pr'. The shape is otherwise
* identical so the linked-work-item badge in the composer renders
* uniformly across providers. */
type: 'issue' | 'pr' | 'mr'
provider?: 'github' | 'gitlab' | 'linear' | 'jira'
number: number
title: string
url: string
/** Linear identifier (for example ENG-123) when this linked item came from
* Linear rather than GitHub. */
linearIdentifier?: string
jiraIdentifier?: string
linkedContext?: LinkedWorkItemContext
}
export function isGitLabIssueUrl(url: string): boolean {
// Why: self-hosted GitLab issue URLs may not contain "gitlab"; the
// provider-stable signal is the `/-/issues/` path segment.
try {
return new URL(url).pathname.includes('/-/issues/')
} catch {
return /\/-\/issues\//i.test(url)
}
}
// Why: when a repo has no `orca.yaml` issueCommand and no per-user override,
// we still want the composer to send a useful default prompt whenever the user
// attaches a linked work item without typing anything else. "Complete <url>"
@@ -0,0 +1,256 @@
import type {
GlobalSettings,
JiraComment,
JiraConnectionStatus,
JiraCreateField,
JiraCreateIssueArgs,
JiraCreateIssueResult,
JiraIssue,
JiraIssueFilter,
JiraIssueType,
JiraIssueUpdate,
JiraMutationResult,
JiraPriority,
JiraProject,
JiraSiteSelection,
JiraTransition,
JiraUser,
JiraViewer
} from '../../../shared/types'
import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client'
export type RuntimeJiraSettings =
| Pick<GlobalSettings, 'activeRuntimeEnvironmentId'>
| null
| undefined
export type JiraConnectResult = { ok: true; viewer: JiraViewer } | { ok: false; error: string }
export type JiraCommentResult = { ok: true; id: string } | { ok: false; error: string }
export async function jiraStatus(settings: RuntimeJiraSettings): Promise<JiraConnectionStatus> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<JiraConnectionStatus>(target, 'jira.status', undefined, { timeoutMs: 15_000 })
: window.api.jira.status()
}
export async function jiraConnect(
settings: RuntimeJiraSettings,
args: { siteUrl: string; email: string; apiToken: string }
): Promise<JiraConnectResult> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<JiraConnectResult>(target, 'jira.connect', args, { timeoutMs: 30_000 })
: window.api.jira.connect(args)
}
export async function jiraDisconnect(
settings: RuntimeJiraSettings,
siteId?: string | null
): Promise<void> {
const target = getActiveRuntimeTarget(settings)
if (target.kind === 'environment') {
await callRuntimeRpc<{ ok: true }>(target, 'jira.disconnect', siteId ? { siteId } : undefined, {
timeoutMs: 15_000
})
return
}
await window.api.jira.disconnect(siteId ? { siteId } : undefined)
}
export async function jiraSelectSite(
settings: RuntimeJiraSettings,
siteId: JiraSiteSelection
): Promise<JiraConnectionStatus> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<JiraConnectionStatus>(
target,
'jira.selectSite',
{ siteId },
{ timeoutMs: 15_000 }
)
: window.api.jira.selectSite({ siteId })
}
export async function jiraTestConnection(
settings: RuntimeJiraSettings,
siteId?: string | null
): Promise<JiraConnectResult> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<JiraConnectResult>(
target,
'jira.testConnection',
siteId ? { siteId } : undefined,
{ timeoutMs: 30_000 }
)
: window.api.jira.testConnection(siteId ? { siteId } : undefined)
}
export async function jiraSearchIssues(
settings: RuntimeJiraSettings,
jql: string,
limit?: number,
siteId?: JiraSiteSelection | null
): Promise<JiraIssue[]> {
const target = getActiveRuntimeTarget(settings)
const args = { jql, limit, siteId: siteId ?? undefined }
return target.kind === 'environment'
? callRuntimeRpc<JiraIssue[]>(target, 'jira.searchIssues', args, { timeoutMs: 30_000 })
: window.api.jira.searchIssues(args)
}
export async function jiraListIssues(
settings: RuntimeJiraSettings,
filter?: JiraIssueFilter,
limit?: number,
siteId?: JiraSiteSelection | null
): Promise<JiraIssue[]> {
const target = getActiveRuntimeTarget(settings)
const args = { filter, limit, siteId: siteId ?? undefined }
return target.kind === 'environment'
? callRuntimeRpc<JiraIssue[]>(target, 'jira.listIssues', args, { timeoutMs: 30_000 })
: window.api.jira.listIssues(args)
}
export async function jiraGetIssue(
settings: RuntimeJiraSettings,
key: string,
siteId?: string | null
): Promise<JiraIssue | null> {
const target = getActiveRuntimeTarget(settings)
const args = { key, siteId: siteId ?? undefined }
return target.kind === 'environment'
? callRuntimeRpc<JiraIssue | null>(target, 'jira.getIssue', args, { timeoutMs: 30_000 })
: window.api.jira.getIssue(args)
}
export async function jiraCreateIssue(
settings: RuntimeJiraSettings,
args: JiraCreateIssueArgs
): Promise<JiraCreateIssueResult> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<JiraCreateIssueResult>(target, 'jira.createIssue', args, { timeoutMs: 30_000 })
: window.api.jira.createIssue(args)
}
export async function jiraUpdateIssue(
settings: RuntimeJiraSettings,
key: string,
updates: JiraIssueUpdate,
siteId?: string | null
): Promise<JiraMutationResult> {
const target = getActiveRuntimeTarget(settings)
const args = { key, updates, siteId: siteId ?? undefined }
return target.kind === 'environment'
? callRuntimeRpc<JiraMutationResult>(target, 'jira.updateIssue', args, { timeoutMs: 30_000 })
: window.api.jira.updateIssue(args)
}
export async function jiraAddIssueComment(
settings: RuntimeJiraSettings,
key: string,
body: string,
siteId?: string | null
): Promise<JiraCommentResult> {
const target = getActiveRuntimeTarget(settings)
const args = { key, body, siteId: siteId ?? undefined }
return target.kind === 'environment'
? callRuntimeRpc<JiraCommentResult>(target, 'jira.addIssueComment', args, {
timeoutMs: 30_000
})
: window.api.jira.addIssueComment(args)
}
export async function jiraIssueComments(
settings: RuntimeJiraSettings,
key: string,
siteId?: string | null
): Promise<JiraComment[]> {
const target = getActiveRuntimeTarget(settings)
const args = { key, siteId: siteId ?? undefined }
return target.kind === 'environment'
? callRuntimeRpc<JiraComment[]>(target, 'jira.issueComments', args, { timeoutMs: 30_000 })
: window.api.jira.issueComments(args)
}
export async function jiraListProjects(
settings: RuntimeJiraSettings,
siteId?: JiraSiteSelection | null
): Promise<JiraProject[]> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<JiraProject[]>(target, 'jira.listProjects', siteId ? { siteId } : undefined, {
timeoutMs: 30_000
})
: window.api.jira.listProjects(siteId ? { siteId } : undefined)
}
export async function jiraListIssueTypes(
settings: RuntimeJiraSettings,
projectIdOrKey: string,
siteId?: string | null
): Promise<JiraIssueType[]> {
const target = getActiveRuntimeTarget(settings)
const args = { projectIdOrKey, siteId: siteId ?? undefined }
return target.kind === 'environment'
? callRuntimeRpc<JiraIssueType[]>(target, 'jira.listIssueTypes', args, { timeoutMs: 30_000 })
: window.api.jira.listIssueTypes(args)
}
export async function jiraListCreateFields(
settings: RuntimeJiraSettings,
projectIdOrKey: string,
issueTypeId: string,
siteId?: string | null
): Promise<JiraCreateField[]> {
const target = getActiveRuntimeTarget(settings)
const args = { projectIdOrKey, issueTypeId, siteId: siteId ?? undefined }
return target.kind === 'environment'
? callRuntimeRpc<JiraCreateField[]>(target, 'jira.listCreateFields', args, {
timeoutMs: 30_000
})
: window.api.jira.listCreateFields(args)
}
export async function jiraListPriorities(
settings: RuntimeJiraSettings,
siteId?: string | null
): Promise<JiraPriority[]> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<JiraPriority[]>(
target,
'jira.listPriorities',
siteId ? { siteId } : undefined,
{ timeoutMs: 30_000 }
)
: window.api.jira.listPriorities(siteId ? { siteId } : undefined)
}
export async function jiraListAssignableUsers(
settings: RuntimeJiraSettings,
key: string,
query?: string,
siteId?: string | null
): Promise<JiraUser[]> {
const target = getActiveRuntimeTarget(settings)
const args = { key, query, siteId: siteId ?? undefined }
return target.kind === 'environment'
? callRuntimeRpc<JiraUser[]>(target, 'jira.listAssignableUsers', args, { timeoutMs: 30_000 })
: window.api.jira.listAssignableUsers(args)
}
export async function jiraListTransitions(
settings: RuntimeJiraSettings,
key: string,
siteId?: string | null
): Promise<JiraTransition[]> {
const target = getActiveRuntimeTarget(settings)
const args = { key, siteId: siteId ?? undefined }
return target.kind === 'environment'
? callRuntimeRpc<JiraTransition[]>(target, 'jira.listTransitions', args, { timeoutMs: 30_000 })
: window.api.jira.listTransitions(args)
}
+2
View File
@@ -12,6 +12,7 @@ import { createGitHubSlice } from './slices/github'
import { createHostedReviewSlice } from './slices/hosted-review'
import { createLinearSlice } from './slices/linear'
import { createPreflightSlice } from './slices/preflight'
import { createJiraSlice } from './slices/jira'
import { createEditorSlice } from './slices/editor'
import { createStatsSlice } from './slices/stats'
import { createMemorySlice } from './slices/memory'
@@ -44,6 +45,7 @@ export const useAppStore = create<AppState>()((...a) => ({
...createHostedReviewSlice(...a),
...createLinearSlice(...a),
...createPreflightSlice(...a),
...createJiraSlice(...a),
...createEditorSlice(...a),
...createStatsSlice(...a),
...createMemorySlice(...a),
@@ -119,6 +119,7 @@ import { createGitHubSlice } from './github'
import { createHostedReviewSlice } from './hosted-review'
import { createLinearSlice } from './linear'
import { createPreflightSlice } from './preflight'
import { createJiraSlice } from './jira'
import { createEditorSlice } from './editor'
import { createStatsSlice } from './stats'
import { createMemorySlice } from './memory'
@@ -150,6 +151,7 @@ function createTestStore() {
...createHostedReviewSlice(...a),
...createLinearSlice(...a),
...createPreflightSlice(...a),
...createJiraSlice(...a),
...createEditorSlice(...a),
...createStatsSlice(...a),
...createMemorySlice(...a),
+301
View File
@@ -0,0 +1,301 @@
/* eslint-disable max-lines -- Why: the Jira slice owns site status, issue
caches, and optimistic patch propagation as one store boundary so active
site changes invalidate every related query coherently. */
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type {
JiraConnectionStatus,
JiraIssue,
JiraIssueFilter,
JiraSiteSelection,
JiraViewer
} from '../../../../shared/types'
import type { CacheEntry } from './github'
import {
jiraConnect,
jiraDisconnect,
jiraGetIssue,
jiraListIssues,
jiraSearchIssues,
jiraSelectSite,
jiraStatus,
jiraTestConnection
} from '@/runtime/runtime-jira-client'
const CACHE_TTL = 60_000
const MAX_CACHE_ENTRIES = 500
function isFresh<T>(entry: CacheEntry<T> | undefined): entry is CacheEntry<T> {
return entry !== undefined && Date.now() - entry.fetchedAt < CACHE_TTL
}
function evictStaleEntries<T>(
cache: Record<string, CacheEntry<T>>,
maxEntries = MAX_CACHE_ENTRIES
): Record<string, CacheEntry<T>> {
const keys = Object.keys(cache)
if (keys.length <= maxEntries) {
return cache
}
const sorted = keys.sort((a, b) => (cache[a]?.fetchedAt ?? 0) - (cache[b]?.fetchedAt ?? 0))
const pruned: Record<string, CacheEntry<T>> = {}
for (const key of sorted.slice(sorted.length - maxEntries)) {
pruned[key] = cache[key]
}
return pruned
}
function looksLikeAuthError(error: unknown): boolean {
const msg = error instanceof Error ? error.message : String(error)
return /authenticat|unauthorized|forbidden|401|403/i.test(msg)
}
const inflightIssueRequests = new Map<string, Promise<JiraIssue | null>>()
const inflightSearchRequests = new Map<string, Promise<JiraIssue[]>>()
const inflightListRequests = new Map<string, Promise<JiraIssue[]>>()
function getSelectedSiteId(status: JiraConnectionStatus): JiraSiteSelection | null {
return status.selectedSiteId ?? status.activeSiteId ?? null
}
function clearJiraInflight(): void {
inflightIssueRequests.clear()
inflightSearchRequests.clear()
inflightListRequests.clear()
}
export type JiraSlice = {
jiraStatus: JiraConnectionStatus
jiraStatusChecked: boolean
jiraIssueCache: Record<string, CacheEntry<JiraIssue>>
jiraSearchCache: Record<string, CacheEntry<JiraIssue[]>>
checkJiraConnection: () => Promise<void>
connectJira: (args: {
siteUrl: string
email: string
apiToken: string
}) => Promise<{ ok: true; viewer: JiraViewer } | { ok: false; error: string }>
testJiraConnection: (
siteId?: string | null
) => Promise<{ ok: true; viewer: JiraViewer } | { ok: false; error: string }>
selectJiraSite: (siteId: JiraSiteSelection) => Promise<void>
disconnectJira: (siteId?: string | null) => Promise<void>
fetchJiraIssue: (key: string, siteId?: string | null) => Promise<JiraIssue | null>
searchJiraIssues: (jql: string, limit?: number) => Promise<JiraIssue[]>
listJiraIssues: (filter?: JiraIssueFilter, limit?: number) => Promise<JiraIssue[]>
patchJiraIssue: (issueKey: string, patch: Partial<JiraIssue>) => void
}
export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, get) => ({
jiraStatus: { connected: false, viewer: null },
jiraStatusChecked: false,
jiraIssueCache: {},
jiraSearchCache: {},
checkJiraConnection: async () => {
try {
const status = await jiraStatus(get().settings)
const prev = get().jiraStatus
if (
prev.connected !== status.connected ||
prev.viewer?.email !== status.viewer?.email ||
getSelectedSiteId(prev) !== getSelectedSiteId(status) ||
(prev.sites?.length ?? 0) !== (status.sites?.length ?? 0)
) {
set({ jiraStatus: status, jiraStatusChecked: true })
} else if (!get().jiraStatusChecked) {
set({ jiraStatusChecked: true })
}
} catch {
if (get().jiraStatus.connected) {
set({ jiraStatus: { connected: false, viewer: null }, jiraStatusChecked: true })
} else if (!get().jiraStatusChecked) {
set({ jiraStatusChecked: true })
}
}
},
connectJira: async (args) => {
try {
const result = await jiraConnect(get().settings, args)
if (result.ok) {
set({ jiraStatus: { connected: true, viewer: result.viewer }, jiraStatusChecked: true })
void get().checkJiraConnection()
}
return result
} catch (error) {
const message = error instanceof Error ? error.message : 'Connection failed'
return { ok: false as const, error: message }
}
},
testJiraConnection: async (siteId) => {
try {
const result = await jiraTestConnection(get().settings, siteId)
const status = await jiraStatus(get().settings)
set({ jiraStatus: status, jiraStatusChecked: true })
return result
} catch (error) {
const message = error instanceof Error ? error.message : 'Test failed'
return { ok: false as const, error: message }
}
},
selectJiraSite: async (siteId) => {
const status = await jiraSelectSite(get().settings, siteId)
clearJiraInflight()
set({
jiraStatus: status,
jiraIssueCache: {},
jiraSearchCache: {},
jiraStatusChecked: true
})
},
disconnectJira: async (siteId) => {
await jiraDisconnect(get().settings, siteId)
clearJiraInflight()
const status = await jiraStatus(get().settings)
set({
jiraStatus: status.connected ? status : { connected: false, viewer: null },
jiraIssueCache: {},
jiraSearchCache: {},
jiraStatusChecked: true
})
},
fetchJiraIssue: async (key, siteId) => {
const issueCacheKey = `${siteId ?? 'selected'}::${key}`
const cached = get().jiraIssueCache[issueCacheKey] ?? get().jiraIssueCache[key]
if (isFresh(cached)) {
return cached.data
}
const inflight = inflightIssueRequests.get(issueCacheKey)
if (inflight) {
return inflight
}
const promise = jiraGetIssue(get().settings, key, siteId)
.then((issue) => {
set((s) => ({
jiraIssueCache: evictStaleEntries({
...s.jiraIssueCache,
[issueCacheKey]: { data: issue, fetchedAt: Date.now() }
})
}))
return issue
})
.catch((error) => {
console.warn('[jira] fetchJiraIssue failed:', error)
if (looksLikeAuthError(error)) {
set({ jiraStatus: { connected: false, viewer: null } })
}
return null
})
.finally(() => {
inflightIssueRequests.delete(issueCacheKey)
})
inflightIssueRequests.set(issueCacheKey, promise)
return promise
},
searchJiraIssues: async (jql, limit = 30) => {
const siteId = getSelectedSiteId(get().jiraStatus)
const cacheKey = `${siteId ?? 'default'}::${jql}::${limit}`
const cached = get().jiraSearchCache[cacheKey]
if (isFresh(cached)) {
return cached.data ?? []
}
const inflight = inflightSearchRequests.get(cacheKey)
if (inflight) {
return inflight
}
const promise = jiraSearchIssues(get().settings, jql, limit, siteId)
.then((issues) => {
set((s) => ({
jiraSearchCache: evictStaleEntries({
...s.jiraSearchCache,
[cacheKey]: { data: issues, fetchedAt: Date.now() }
})
}))
return issues
})
.catch((error) => {
console.warn('[jira] searchJiraIssues failed:', error)
if (looksLikeAuthError(error)) {
set({ jiraStatus: { connected: false, viewer: null } })
}
return []
})
.finally(() => {
inflightSearchRequests.delete(cacheKey)
})
inflightSearchRequests.set(cacheKey, promise)
return promise
},
listJiraIssues: async (filter = 'assigned', limit = 30) => {
const siteId = getSelectedSiteId(get().jiraStatus)
const cacheKey = `${siteId ?? 'default'}::list::${filter}::${limit}`
const cached = get().jiraSearchCache[cacheKey]
if (isFresh(cached)) {
return cached.data ?? []
}
const inflight = inflightListRequests.get(cacheKey)
if (inflight) {
return inflight
}
const promise = jiraListIssues(get().settings, filter, limit, siteId)
.then((issues) => {
set((s) => ({
jiraSearchCache: evictStaleEntries({
...s.jiraSearchCache,
[cacheKey]: { data: issues, fetchedAt: Date.now() }
})
}))
return issues
})
.catch((error) => {
console.warn('[jira] listJiraIssues failed:', error)
if (looksLikeAuthError(error)) {
set({ jiraStatus: { connected: false, viewer: null } })
}
return []
})
.finally(() => {
inflightListRequests.delete(cacheKey)
})
inflightListRequests.set(cacheKey, promise)
return promise
},
patchJiraIssue: (issueKey, patch) => {
set((s) => {
let changed = false
const nextIssueCache = { ...s.jiraIssueCache }
for (const [key, entry] of Object.entries(nextIssueCache)) {
if (entry?.data?.key !== issueKey) {
continue
}
nextIssueCache[key] = { ...entry, data: { ...entry.data, ...patch }, fetchedAt: 0 }
changed = true
}
const nextSearchCache = { ...s.jiraSearchCache }
for (const key of Object.keys(nextSearchCache)) {
const entry = nextSearchCache[key]
if (!entry?.data) {
continue
}
const index = entry.data.findIndex((issue) => issue.key === issueKey)
if (index === -1) {
continue
}
const updatedItems = [...entry.data]
updatedItems[index] = { ...updatedItems[index], ...patch }
nextSearchCache[key] = { ...entry, data: updatedItems }
changed = true
}
return changed ? { jiraIssueCache: nextIssueCache, jiraSearchCache: nextSearchCache } : {}
})
}
})
@@ -205,7 +205,8 @@ describe('createSettingsSlice runtime switching', () => {
showDotfilesByWorktree: { 'repo-env-1::/env-1/repo': false },
gitIgnoredPathsByWorktree: { 'repo-env-1::/env-1/repo': ['dist/'] },
prCache: { '/env-1/repo::main': { data: null, fetchedAt: Date.now() } },
linearIssueCache: { 'LIN-1': { data: { id: 'LIN-1' } as never, fetchedAt: Date.now() } }
linearIssueCache: { 'LIN-1': { data: { id: 'LIN-1' } as never, fetchedAt: Date.now() } },
jiraIssueCache: { 'JIRA-1': { data: { key: 'JIRA-1' } as never, fetchedAt: Date.now() } }
})
await expect(store.getState().switchRuntimeEnvironment('env-2')).resolves.toBe(true)
@@ -261,6 +262,7 @@ describe('createSettingsSlice runtime switching', () => {
expect(store.getState().browserTabsByWorktree).toEqual({})
expect(store.getState().prCache).toEqual({})
expect(store.getState().linearIssueCache).toEqual({})
expect(store.getState().jiraIssueCache).toEqual({})
})
it('does not close host-owned mirrored resources when a paired web client switches servers', async () => {
+5 -1
View File
@@ -130,7 +130,11 @@ function runtimeScopedStateReset(): Partial<AppState> {
linearCustomViewCache: {},
linearCustomViewDetailCache: {},
linearCustomViewIssueCache: {},
linearCustomViewProjectCache: {}
linearCustomViewProjectCache: {},
jiraStatus: { connected: false, viewer: null },
jiraStatusChecked: false,
jiraIssueCache: {},
jiraSearchCache: {}
}
}
@@ -20,6 +20,7 @@ import { createGitHubSlice } from './github'
import { createHostedReviewSlice } from './hosted-review'
import { createLinearSlice } from './linear'
import { createPreflightSlice } from './preflight'
import { createJiraSlice } from './jira'
import { createEditorSlice } from './editor'
import { createStatsSlice } from './stats'
import { createMemorySlice } from './memory'
@@ -59,6 +60,7 @@ export function createTestStore() {
...createHostedReviewSlice(...a),
...createLinearSlice(...a),
...createPreflightSlice(...a),
...createJiraSlice(...a),
...createEditorSlice(...a),
...createStatsSlice(...a),
...createMemorySlice(...a),
+5 -2
View File
@@ -746,7 +746,9 @@ describe('createUISlice hydratePersistedUI', () => {
githubItemsPreset: 'invalid',
githubItemsQuery: 42,
linearPreset: 'completed',
linearQuery: 'label:bug'
linearQuery: 'label:bug',
jiraPreset: 'reported',
jiraQuery: 99
} as unknown as PersistedUIState['taskResumeState']
})
)
@@ -754,7 +756,8 @@ describe('createUISlice hydratePersistedUI', () => {
expect(store.getState().taskResumeState).toEqual({
githubMode: 'project',
linearPreset: 'completed',
linearQuery: 'label:bug'
linearQuery: 'label:bug',
jiraPreset: 'reported'
})
})
+15
View File
@@ -223,6 +223,12 @@ const VALID_LINEAR_MODES = new Set<NonNullable<TaskResumeState['linearMode']>>([
'projects',
'views'
])
const VALID_JIRA_PRESETS = new Set<NonNullable<TaskResumeState['jiraPreset']>>([
'assigned',
'reported',
'all',
'done'
])
function filterTrustedOrcaHooksToValidRepos(
trust: PersistedTrustedOrcaHooks,
@@ -386,6 +392,15 @@ function sanitizeTaskResumeState(value: unknown): TaskResumeState | undefined {
}
}
}
if (
typeof input.jiraPreset === 'string' &&
VALID_JIRA_PRESETS.has(input.jiraPreset as NonNullable<TaskResumeState['jiraPreset']>)
) {
next.jiraPreset = input.jiraPreset as NonNullable<TaskResumeState['jiraPreset']>
}
if (typeof input.jiraQuery === 'string') {
next.jiraQuery = input.jiraQuery
}
return Object.keys(next).length > 0 ? next : undefined
}
+2
View File
@@ -10,6 +10,7 @@ import type { GitHubSlice } from './slices/github'
import type { HostedReviewSlice } from './slices/hosted-review'
import type { LinearSlice } from './slices/linear'
import type { PreflightSlice } from './slices/preflight'
import type { JiraSlice } from './slices/jira'
import type { EditorSlice } from './slices/editor'
import type { StatsSlice } from './slices/stats'
import type { MemorySlice } from './slices/memory'
@@ -39,6 +40,7 @@ export type AppState = RepoSlice &
HostedReviewSlice &
LinearSlice &
PreflightSlice &
JiraSlice &
EditorSlice &
StatsSlice &
MemorySlice &
+1
View File
@@ -265,6 +265,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
defaultTaskViewPreset: 'all',
defaultTaskSource: 'github',
visibleTaskProviders: [...TASK_PROVIDERS],
visibleTaskProvidersDefaultedForJira: true,
defaultRepoSelection: null,
defaultLinearTeamSelection: null,
opencodeSessionCookie: '',
+143
View File
@@ -0,0 +1,143 @@
export type JiraSite = {
id: string
siteUrl: string
email: string
displayName: string
accountId: string
}
export type JiraViewer = {
accountId: string
displayName: string
email: string | null
avatarUrl?: string
}
export type JiraSiteSelection = string | 'all'
export type JiraConnectionStatus = {
connected: boolean
viewer: JiraViewer | null
sites?: JiraSite[]
activeSiteId?: string | null
selectedSiteId?: JiraSiteSelection | null
}
export type JiraProject = {
id: string
key: string
name: string
siteId?: string
siteName?: string
}
export type JiraIssueType = {
id: string
name: string
description?: string
iconUrl?: string
subtask?: boolean
}
export type JiraCreateFieldAllowedValue = {
id?: string
value?: string
name?: string
}
export type JiraCreateField = {
key: string
name: string
required: boolean
schema?: {
type?: string
items?: string
custom?: string
}
allowedValues?: JiraCreateFieldAllowedValue[]
}
export type JiraUser = {
accountId: string
displayName: string
email?: string | null
avatarUrl?: string
}
export type JiraPriority = {
id: string
name: string
iconUrl?: string
}
export type JiraStatus = {
id: string
name: string
categoryKey: string
categoryName: string
colorName?: string
}
export type JiraTransition = {
id: string
name: string
to: JiraStatus
}
export type JiraIssue = {
id: string
key: string
siteId?: string
siteName?: string
title: string
description?: string
url: string
project: JiraProject
issueType: JiraIssueType
status: JiraStatus
labels: string[]
assignee?: JiraUser
reporter?: JiraUser
priority?: JiraPriority
updatedAt: string
createdAt: string
}
export type JiraComment = {
id: string
body: string
createdAt: string
updatedAt?: string
user?: JiraUser
}
export type JiraIssueUpdate = {
title?: string
labels?: string[]
assigneeAccountId?: string | null
priorityId?: string | null
transitionId?: string
}
export type JiraIssueFilter = 'assigned' | 'reported' | 'all' | 'done'
export type JiraConnectArgs = {
siteUrl: string
email: string
apiToken: string
}
export type JiraCreateIssueArgs = {
siteId?: string
projectId: string
issueTypeId: string
title: string
description?: string
customFields?: Record<string, unknown>
}
export type JiraCreateIssueResult =
| { ok: true; id: string; key: string; url: string }
| { ok: false; error: string }
export type JiraMutationResult = { ok: true } | { ok: false; error: string }
+3 -3
View File
@@ -16,7 +16,7 @@ describe('task providers', () => {
})
it('falls back to all providers when none are visible', () => {
expect(normalizeVisibleTaskProviders([])).toEqual(['github', 'gitlab', 'linear'])
expect(normalizeVisibleTaskProviders([])).toEqual(['github', 'gitlab', 'linear', 'jira'])
})
it('restores a valid saved default when provider settings drifted', () => {
@@ -35,7 +35,7 @@ describe('task providers', () => {
expect(
normalizeTaskProviderSettings({
visibleTaskProviders: ['gitlab'],
defaultTaskSource: 'jira'
defaultTaskSource: 'bitbucket'
})
).toEqual({
defaultTaskSource: 'gitlab',
@@ -103,7 +103,7 @@ describe('task providers', () => {
gitlabInstalled: false,
linearConnected: true
},
'jira'
'bitbucket'
)
).toEqual(['github'])
})
+7 -2
View File
@@ -1,6 +1,6 @@
export type TaskProvider = 'github' | 'gitlab' | 'linear'
export type TaskProvider = 'github' | 'gitlab' | 'linear' | 'jira'
export const TASK_PROVIDERS: readonly TaskProvider[] = ['github', 'gitlab', 'linear']
export const TASK_PROVIDERS: readonly TaskProvider[] = ['github', 'gitlab', 'linear', 'jira']
const TASK_PROVIDER_SET = new Set<TaskProvider>(TASK_PROVIDERS)
@@ -100,6 +100,11 @@ function isTaskProviderAvailable(
if (provider === 'gitlab') {
return availability.gitlabInstalled
}
// Why: Jira can be connected from the Tasks surface itself, so hiding it
// when disconnected would remove the entry point for first-time setup.
if (provider === 'jira') {
return true
}
return availability.linearConnected
}
+28
View File
@@ -1372,6 +1372,29 @@ export type {
MRState
} from './gitlab-types'
export type {
JiraComment,
JiraConnectArgs,
JiraConnectionStatus,
JiraCreateField,
JiraCreateFieldAllowedValue,
JiraCreateIssueArgs,
JiraCreateIssueResult,
JiraIssue,
JiraIssueFilter,
JiraIssueType,
JiraIssueUpdate,
JiraMutationResult,
JiraPriority,
JiraProject,
JiraSite,
JiraSiteSelection,
JiraStatus,
JiraTransition,
JiraUser,
JiraViewer
} from './jira-types'
/**
* GitHub API rate-limit buckets surfaced in the TaskPage header so users can
* see remaining budget before they hit the wall. `core` = REST (5000/hr),
@@ -2100,6 +2123,9 @@ export type GlobalSettings = {
* list hides unused providers from Tasks chrome and sidebar shortcuts while
* leaving the chosen default source stable when it is still visible. */
visibleTaskProviders: TaskProvider[]
/** Why: one-shot migration guard so Jira becomes visible for existing
* profiles once, without re-adding it after a later deliberate opt-out. */
visibleTaskProvidersDefaultedForJira: boolean
/** Why: persists the user's repo selection in the cross-repo tasks view.
* `null` means sticky-all every eligible repo is selected, including
* repos added in future sessions, so the "All repos" label stays
@@ -2441,6 +2467,8 @@ export type TaskResumeState = {
workspaceId: LinearConcreteWorkspaceId
model?: LinearCustomViewModel
}
jiraPreset?: 'assigned' | 'reported' | 'all' | 'done'
jiraQuery?: string
}
export type RightSidebarTab = 'explorer' | 'search' | 'source-control' | 'checks' | 'ports'