mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Add Jira task provider support (#2238)
This commit is contained in:
@@ -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: '',
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
]
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 &
|
||||
|
||||
@@ -265,6 +265,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
|
||||
defaultTaskViewPreset: 'all',
|
||||
defaultTaskSource: 'github',
|
||||
visibleTaskProviders: [...TASK_PROVIDERS],
|
||||
visibleTaskProvidersDefaultedForJira: true,
|
||||
defaultRepoSelection: null,
|
||||
defaultLinearTeamSelection: null,
|
||||
opencodeSessionCookie: '',
|
||||
|
||||
@@ -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 }
|
||||
@@ -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'])
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user