mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
feat(jira): support self-hosted Jira Server/DC with PAT + username/password (#8976)
Adds self-hosted Jira Server/Data Center support (personal access token or classic username + password) alongside Atlassian Cloud, fully addressing the older-instance ask in #6676. Takeover of #7724 (@wquintal's original PAT implementation), brought current with main and hardened via a multi-agent adversarial review. Fixes #6676. Co-authored-by: William Quintal <williamquintal95@gmail.com>
This commit is contained in:
@@ -91,7 +91,8 @@ export function registerJiraHandlers(): void {
|
||||
const result = await connect({
|
||||
siteUrl: args.siteUrl,
|
||||
email: args.email,
|
||||
apiToken: args.apiToken
|
||||
apiToken: args.apiToken,
|
||||
authType: args.authType === 'server' ? 'server' : 'cloud'
|
||||
})
|
||||
if (result.ok) {
|
||||
_resetPreflightCache()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type * as Os from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -402,6 +402,227 @@ describe('Jira client credential storage', () => {
|
||||
expect(jira.isAuthError(new jira.JiraApiError('Forbidden', 403))).toBe(false)
|
||||
})
|
||||
|
||||
it('connects to self-hosted Jira with a Bearer PAT against REST v2', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
name: 'wquintal',
|
||||
key: 'JIRAUSER10101',
|
||||
displayName: 'William',
|
||||
emailAddress: 'william@example.com'
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
)
|
||||
const jira = await loadClientModule()
|
||||
|
||||
await expect(
|
||||
jira.connect({
|
||||
siteUrl: 'jira.example.com',
|
||||
email: '',
|
||||
apiToken: 'pat-token',
|
||||
authType: 'server'
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
// Server /myself has no accountId; the username stands in for it.
|
||||
viewer: { displayName: 'William', accountId: 'wquintal' }
|
||||
})
|
||||
|
||||
expect(netFetchMock).toHaveBeenCalledWith(
|
||||
'https://jira.example.com/rest/api/2/myself',
|
||||
expect.objectContaining({ headers: expect.any(Headers) })
|
||||
)
|
||||
const headers = netFetchMock.mock.calls[0]?.[1]?.headers as Headers
|
||||
expect(headers.get('Authorization')).toBe('Bearer pat-token')
|
||||
})
|
||||
|
||||
it('connects to self-hosted Jira with Basic username/password against REST v2', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({ name: 'jdoe', key: 'JIRAUSER20202', displayName: 'Jane Doe' }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
)
|
||||
const jira = await loadClientModule()
|
||||
|
||||
await expect(
|
||||
jira.connect({
|
||||
siteUrl: 'jira.example.com',
|
||||
// A username present means classic Basic auth (older Server/DC that
|
||||
// predate PATs); the token slot carries the account password.
|
||||
email: 'jdoe',
|
||||
apiToken: 'account-password',
|
||||
authType: 'server'
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
viewer: { displayName: 'Jane Doe', accountId: 'jdoe' }
|
||||
})
|
||||
|
||||
expect(netFetchMock).toHaveBeenCalledWith(
|
||||
'https://jira.example.com/rest/api/2/myself',
|
||||
expect.objectContaining({ headers: expect.any(Headers) })
|
||||
)
|
||||
const headers = netFetchMock.mock.calls[0]?.[1]?.headers as Headers
|
||||
expect(headers.get('Authorization')).toBe(
|
||||
`Basic ${Buffer.from('jdoe:account-password').toString('base64')}`
|
||||
)
|
||||
})
|
||||
|
||||
it('uses Basic auth for stored self-hosted sites that carry a username', async () => {
|
||||
const siteId = 'site-server-basic'
|
||||
const orcaDir = join(tempHome, '.orca')
|
||||
mkdirSync(join(orcaDir, 'jira-tokens'), { recursive: true })
|
||||
writeFileSync(
|
||||
join(orcaDir, 'jira-sites.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
activeSiteId: siteId,
|
||||
selectedSiteId: siteId,
|
||||
sites: [
|
||||
{
|
||||
id: siteId,
|
||||
siteUrl: 'https://jira.example.com',
|
||||
email: 'jdoe',
|
||||
displayName: 'Jane Doe',
|
||||
accountId: 'jdoe',
|
||||
authType: 'server'
|
||||
}
|
||||
]
|
||||
}),
|
||||
{ encoding: 'utf-8' }
|
||||
)
|
||||
writeFileSync(tokenPathForSite(siteId), 'account-password')
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ name: 'jdoe', displayName: 'Jane Doe' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
)
|
||||
const jira = await loadClientModule()
|
||||
|
||||
await expect(jira.testConnection(siteId)).resolves.toMatchObject({ ok: true })
|
||||
|
||||
const headers = netFetchMock.mock.calls[0]?.[1]?.headers as Headers
|
||||
expect(headers.get('Authorization')).toBe(
|
||||
`Basic ${Buffer.from('jdoe:account-password').toString('base64')}`
|
||||
)
|
||||
})
|
||||
|
||||
it('requires a token but not an email for self-hosted connections', async () => {
|
||||
const jira = await loadClientModule()
|
||||
|
||||
await expect(
|
||||
jira.connect({
|
||||
siteUrl: 'jira.example.com',
|
||||
email: '',
|
||||
apiToken: '',
|
||||
authType: 'server'
|
||||
})
|
||||
).resolves.toEqual({ ok: false, error: 'Personal access token is required.' })
|
||||
expect(netFetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires a password when a username is present on self-hosted', async () => {
|
||||
const jira = await loadClientModule()
|
||||
|
||||
await expect(
|
||||
jira.connect({
|
||||
siteUrl: 'jira.example.com',
|
||||
email: 'jdoe',
|
||||
apiToken: '',
|
||||
authType: 'server'
|
||||
})
|
||||
).resolves.toEqual({ ok: false, error: 'Password is required.' })
|
||||
expect(netFetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps distinct self-hosted PAT accounts on one host as separate sites', async () => {
|
||||
netFetchMock
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ name: 'alice', displayName: 'Alice' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ name: 'bot', displayName: 'Bot' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
)
|
||||
const jira = await loadClientModule()
|
||||
|
||||
await jira.connect({
|
||||
siteUrl: 'jira.example.com',
|
||||
email: '',
|
||||
apiToken: 'alice-pat',
|
||||
authType: 'server'
|
||||
})
|
||||
await jira.connect({
|
||||
siteUrl: 'jira.example.com',
|
||||
email: '',
|
||||
apiToken: 'bot-pat',
|
||||
authType: 'server'
|
||||
})
|
||||
|
||||
// Two PATs (both with empty email) to the same host must not collide onto
|
||||
// one id and silently overwrite each other — the viewer identity keys them.
|
||||
const stored = JSON.parse(
|
||||
readFileSync(join(tempHome, '.orca', 'jira-sites.json'), 'utf-8')
|
||||
) as {
|
||||
sites: { accountId: string }[]
|
||||
}
|
||||
expect(stored.sites).toHaveLength(2)
|
||||
expect(stored.sites.map((site) => site.accountId).sort()).toEqual(['alice', 'bot'])
|
||||
})
|
||||
|
||||
it('uses Bearer auth and REST v2 for stored self-hosted sites', async () => {
|
||||
const siteId = 'site-server'
|
||||
const orcaDir = join(tempHome, '.orca')
|
||||
mkdirSync(join(orcaDir, 'jira-tokens'), { recursive: true })
|
||||
writeFileSync(
|
||||
join(orcaDir, 'jira-sites.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
activeSiteId: siteId,
|
||||
selectedSiteId: siteId,
|
||||
sites: [
|
||||
{
|
||||
id: siteId,
|
||||
siteUrl: 'https://jira.example.com',
|
||||
email: '',
|
||||
displayName: 'William',
|
||||
accountId: 'wquintal',
|
||||
authType: 'server'
|
||||
}
|
||||
]
|
||||
}),
|
||||
{ encoding: 'utf-8' }
|
||||
)
|
||||
writeFileSync(tokenPathForSite(siteId), 'pat-token')
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ name: 'wquintal', displayName: 'William' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
)
|
||||
const jira = await loadClientModule()
|
||||
|
||||
await expect(jira.testConnection(siteId)).resolves.toMatchObject({
|
||||
ok: true,
|
||||
viewer: { displayName: 'William' }
|
||||
})
|
||||
|
||||
expect(netFetchMock).toHaveBeenCalledWith(
|
||||
'https://jira.example.com/rest/api/2/myself',
|
||||
expect.objectContaining({ headers: expect.any(Headers) })
|
||||
)
|
||||
const headers = netFetchMock.mock.calls[0]?.[1]?.headers as Headers
|
||||
expect(headers.get('Authorization')).toBe('Bearer pat-token')
|
||||
})
|
||||
|
||||
it('bridges proxy environment settings before Jira connect requests', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
|
||||
+62
-15
@@ -14,6 +14,7 @@ import {
|
||||
import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings'
|
||||
import { withSpan } from '../observability/tracer'
|
||||
import type {
|
||||
JiraAuthType,
|
||||
JiraConnectArgs,
|
||||
JiraConnectionStatus,
|
||||
JiraSite,
|
||||
@@ -65,6 +66,13 @@ export type JiraClientForSite = {
|
||||
authorization: string
|
||||
}
|
||||
|
||||
// Self-hosted Jira Server/Data Center only exposes REST v2; Cloud endpoints
|
||||
// in this codebase are written against v3. Callers build paths with this
|
||||
// prefix so one code path serves both deployments.
|
||||
export function apiBasePath(site: JiraSite): string {
|
||||
return site.authType === 'server' ? '/rest/api/2' : '/rest/api/3'
|
||||
}
|
||||
|
||||
export class JiraApiError extends Error {
|
||||
status: number | null
|
||||
|
||||
@@ -143,7 +151,9 @@ function normalizeSite(input: unknown): JiraSite | null {
|
||||
siteUrl: record.siteUrl,
|
||||
email: record.email,
|
||||
displayName: record.displayName,
|
||||
accountId: record.accountId
|
||||
accountId: record.accountId,
|
||||
// Sites saved before self-hosted support have no authType; they are Cloud.
|
||||
authType: record.authType === 'server' ? 'server' : 'cloud'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,8 +294,17 @@ function getSiteId(siteUrl: string, email: string): string {
|
||||
|
||||
function toViewer(data: Record<string, unknown>, fallbackEmail: string): JiraViewer {
|
||||
const avatarUrls = data.avatarUrls as Record<string, unknown> | undefined
|
||||
// Server/DC /myself has no accountId; its stable identifiers are name/key.
|
||||
const accountId =
|
||||
typeof data.accountId === 'string'
|
||||
? data.accountId
|
||||
: typeof data.name === 'string'
|
||||
? data.name
|
||||
: typeof data.key === 'string'
|
||||
? data.key
|
||||
: ''
|
||||
return {
|
||||
accountId: typeof data.accountId === 'string' ? data.accountId : '',
|
||||
accountId,
|
||||
displayName: typeof data.displayName === 'string' ? data.displayName : fallbackEmail,
|
||||
email: typeof data.emailAddress === 'string' ? data.emailAddress : fallbackEmail,
|
||||
avatarUrl:
|
||||
@@ -308,7 +327,14 @@ function siteToViewer(site: JiraSite | null): JiraViewer | null {
|
||||
}
|
||||
}
|
||||
|
||||
function authHeader(email: string, apiToken: string): string {
|
||||
function authHeader(email: string, apiToken: string, authType?: JiraAuthType): string {
|
||||
// Self-hosted with no username = a personal access token (Bearer); Basic auth
|
||||
// with a PAT in the password slot is what produces the 401s users report.
|
||||
// Self-hosted WITH a username is classic username+password Basic auth, which
|
||||
// older Server/DC instances (predating PATs) require. Cloud is always Basic.
|
||||
if (authType === 'server' && !email) {
|
||||
return `Bearer ${apiToken}`
|
||||
}
|
||||
return `Basic ${Buffer.from(`${email}:${apiToken}`).toString('base64')}`
|
||||
}
|
||||
|
||||
@@ -366,13 +392,14 @@ async function requestWithCredentials(
|
||||
email: string,
|
||||
apiToken: string,
|
||||
path: string,
|
||||
init?: RequestInit
|
||||
init?: RequestInit,
|
||||
authType?: JiraAuthType
|
||||
): Promise<unknown> {
|
||||
const headers = new Headers(init?.headers)
|
||||
headers.set('Accept', 'application/json')
|
||||
headers.set('Content-Type', 'application/json')
|
||||
headers.set('User-Agent', JIRA_API_USER_AGENT)
|
||||
headers.set('Authorization', authHeader(email, apiToken))
|
||||
headers.set('Authorization', authHeader(email, apiToken, authType))
|
||||
const response = await jiraFetch(`${siteUrl}${path}`, {
|
||||
...init,
|
||||
headers
|
||||
@@ -453,7 +480,7 @@ export function getClients(selection?: JiraSiteSelection | null): JiraClientForS
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return token ? [{ site, authorization: authHeader(site.email, token) }] : []
|
||||
return token ? [{ site, authorization: authHeader(site.email, token, site.authType) }] : []
|
||||
})
|
||||
}
|
||||
|
||||
@@ -484,28 +511,48 @@ export async function connect(
|
||||
return { ok: false, error: 'Enter a valid Jira site URL.' }
|
||||
}
|
||||
|
||||
const authType: JiraAuthType = args.authType === 'server' ? 'server' : 'cloud'
|
||||
const email = args.email.trim()
|
||||
const apiToken = args.apiToken.trim()
|
||||
if (!email || !apiToken) {
|
||||
if (authType === 'server') {
|
||||
if (!apiToken) {
|
||||
// A username present means classic Basic auth (password); its absence
|
||||
// means the credential is a personal access token sent as Bearer.
|
||||
return {
|
||||
ok: false,
|
||||
error: email ? 'Password is required.' : 'Personal access token is required.'
|
||||
}
|
||||
}
|
||||
} else if (!email || !apiToken) {
|
||||
return { ok: false, error: 'Email and API token are required.' }
|
||||
}
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
const myselfPath = authType === 'server' ? '/rest/api/2/myself' : '/rest/api/3/myself'
|
||||
const viewer = toViewer(
|
||||
(await requestWithCredentials(siteUrl, email, apiToken, '/rest/api/3/myself')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
email
|
||||
(await requestWithCredentials(
|
||||
siteUrl,
|
||||
email,
|
||||
apiToken,
|
||||
myselfPath,
|
||||
undefined,
|
||||
authType
|
||||
)) as Record<string, unknown>,
|
||||
email || siteUrl
|
||||
)
|
||||
const id = getSiteId(siteUrl, email)
|
||||
// PAT sites have no email, so keying on it alone would collide every PAT
|
||||
// connection to the same host into one id (silently overwriting a prior
|
||||
// account + token). Fall back to the verified viewer identity so distinct
|
||||
// accounts stay distinct. Cloud/Basic keep keying on their non-empty email.
|
||||
const id = getSiteId(siteUrl, email || viewer.accountId)
|
||||
const site: JiraSite = {
|
||||
id,
|
||||
siteUrl,
|
||||
email,
|
||||
displayName: viewer.displayName,
|
||||
accountId: viewer.accountId
|
||||
accountId: viewer.accountId,
|
||||
authType
|
||||
}
|
||||
saveToken(id, apiToken)
|
||||
const file = getSiteFile()
|
||||
@@ -565,7 +612,7 @@ export async function testConnection(
|
||||
await acquire()
|
||||
try {
|
||||
const viewer = toViewer(
|
||||
(await jiraRequest(client, '/rest/api/3/myself')) as Record<string, unknown>,
|
||||
(await jiraRequest(client, `${apiBasePath(client.site)}/myself`)) as Record<string, unknown>,
|
||||
client.site.email
|
||||
)
|
||||
return { ok: true, viewer }
|
||||
|
||||
@@ -12,6 +12,8 @@ const { clearTokenMock, getClientsMock, isAuthErrorMock, jiraRequestMock } = vi.
|
||||
vi.mock('./client', () => ({
|
||||
acquire: vi.fn().mockResolvedValue(undefined),
|
||||
release: vi.fn(),
|
||||
apiBasePath: (site: { authType?: string }) =>
|
||||
site.authType === 'server' ? '/rest/api/2' : '/rest/api/3',
|
||||
clearToken: (...args: unknown[]) => clearTokenMock(...args),
|
||||
getClients: (...args: unknown[]) => getClientsMock(...args),
|
||||
isAuthError: (...args: unknown[]) => isAuthErrorMock(...args),
|
||||
@@ -31,6 +33,20 @@ function makeEntry(id = 'site-1'): JiraClientForSite {
|
||||
}
|
||||
}
|
||||
|
||||
function makeServerEntry(id = 'server-1'): JiraClientForSite {
|
||||
return {
|
||||
site: {
|
||||
id,
|
||||
siteUrl: 'https://jira.example.com',
|
||||
email: '',
|
||||
displayName: 'Self-hosted Jira',
|
||||
accountId: 'wquintal',
|
||||
authType: 'server'
|
||||
},
|
||||
authorization: 'Bearer pat-token'
|
||||
}
|
||||
}
|
||||
|
||||
describe('Jira issue operations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -60,6 +76,67 @@ describe('Jira issue operations', () => {
|
||||
).rejects.toThrow(error.message)
|
||||
})
|
||||
|
||||
it('uses classic /search and REST v2 for self-hosted sites', async () => {
|
||||
getClientsMock.mockReturnValue([makeServerEntry()])
|
||||
jiraRequestMock.mockResolvedValueOnce({ issues: [] })
|
||||
const { searchIssues } = await import('./issues')
|
||||
|
||||
await searchIssues('project = ALP', 20, 'server-1')
|
||||
|
||||
expect(jiraRequestMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ site: expect.objectContaining({ authType: 'server' }) }),
|
||||
'/rest/api/2/search',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
)
|
||||
})
|
||||
|
||||
it('sends plain-text bodies and v2 paths for self-hosted issue creation', async () => {
|
||||
getClientsMock.mockReturnValue([makeServerEntry()])
|
||||
jiraRequestMock.mockResolvedValueOnce({ id: '1', key: 'ALP-1', self: '' })
|
||||
const { createIssue } = await import('./issues')
|
||||
|
||||
await createIssue({
|
||||
siteId: 'server-1',
|
||||
projectId: '10000',
|
||||
issueTypeId: '10001',
|
||||
title: 'Fix auth',
|
||||
description: 'Body text'
|
||||
})
|
||||
|
||||
const [, path, init] = jiraRequestMock.mock.calls[0]
|
||||
expect(path).toBe('/rest/api/2/issue')
|
||||
const body = JSON.parse((init as { body: string }).body) as {
|
||||
fields: { description: unknown }
|
||||
}
|
||||
// REST v2 rejects ADF documents; the description must stay a plain string.
|
||||
expect(body.fields.description).toBe('Body text')
|
||||
})
|
||||
|
||||
it('assigns by username on self-hosted sites', async () => {
|
||||
getClientsMock.mockReturnValue([makeServerEntry()])
|
||||
jiraRequestMock.mockResolvedValue(null)
|
||||
const { updateIssue } = await import('./issues')
|
||||
|
||||
await updateIssue('ALP-1', { assigneeAccountId: 'wquintal' }, 'server-1')
|
||||
|
||||
expect(jiraRequestMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'/rest/api/2/issue/ALP-1/assignee',
|
||||
expect.objectContaining({ body: JSON.stringify({ name: 'wquintal' }) })
|
||||
)
|
||||
})
|
||||
|
||||
it('lists self-hosted projects from the unpaged /project resource', async () => {
|
||||
getClientsMock.mockReturnValue([makeServerEntry()])
|
||||
jiraRequestMock.mockResolvedValueOnce([{ id: '1', key: 'ALP', name: 'Alpha' }])
|
||||
const { listProjects } = await import('./issues')
|
||||
|
||||
const projects = await listProjects('server-1')
|
||||
|
||||
expect(jiraRequestMock).toHaveBeenCalledWith(expect.anything(), '/rest/api/2/project')
|
||||
expect(projects).toMatchObject([{ key: 'ALP', name: 'Alpha' }])
|
||||
})
|
||||
|
||||
it('rejects single-site search failures so the UI can surface them', async () => {
|
||||
getClientsMock.mockReturnValue([makeEntry('site-1')])
|
||||
jiraRequestMock.mockRejectedValueOnce(new Error('Forbidden'))
|
||||
|
||||
+52
-25
@@ -23,6 +23,7 @@ import type {
|
||||
} from '../../shared/types'
|
||||
import {
|
||||
acquire,
|
||||
apiBasePath,
|
||||
clearToken,
|
||||
getClients,
|
||||
isAuthError,
|
||||
@@ -191,7 +192,8 @@ function avatarUrl(value: unknown): string | undefined {
|
||||
|
||||
function mapUser(value: unknown): JiraUser | undefined {
|
||||
const user = asRecord(value)
|
||||
const accountId = asString(user.accountId)
|
||||
// Server/DC users have no accountId; name (login) and key are its stable ids.
|
||||
const accountId = asString(user.accountId) || asString(user.name) || asString(user.key)
|
||||
if (!accountId) {
|
||||
return undefined
|
||||
}
|
||||
@@ -307,6 +309,11 @@ function issueUrl(site: JiraSite, key: string): string {
|
||||
return `${site.siteUrl}/browse/${encodeURIComponent(key)}`
|
||||
}
|
||||
|
||||
// REST v2 (Server/DC) bodies are plain text; v3 (Cloud) requires ADF documents.
|
||||
function toBodyText(site: JiraSite, text: string): unknown {
|
||||
return site.authType === 'server' ? text : textToAdf(text)
|
||||
}
|
||||
|
||||
export function mapJiraIssue(site: JiraSite, raw: JiraRecord): JiraIssue {
|
||||
const fields = asRecord(raw.fields)
|
||||
const key = asString(raw.key)
|
||||
@@ -354,7 +361,12 @@ async function searchIssuesForClient(
|
||||
jql: string,
|
||||
limit: number
|
||||
): Promise<JiraIssue[]> {
|
||||
const result = await jiraRequest<JiraSearchResponse>(entry, '/rest/api/3/search/jql', {
|
||||
// Server/DC only has the classic /search resource; /search/jql is Cloud-only.
|
||||
const searchPath =
|
||||
entry.site.authType === 'server'
|
||||
? `${apiBasePath(entry.site)}/search`
|
||||
: '/rest/api/3/search/jql'
|
||||
const result = await jiraRequest<JiraSearchResponse>(entry, searchPath, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
jql,
|
||||
@@ -429,7 +441,7 @@ export async function getIssue(
|
||||
try {
|
||||
const issue = await jiraRequest<JiraRecord>(
|
||||
entry,
|
||||
`/rest/api/3/issue/${encodeURIComponent(key)}?fields=${encodeURIComponent(
|
||||
`${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}?fields=${encodeURIComponent(
|
||||
ISSUE_FIELDS.join(',')
|
||||
)}`
|
||||
)
|
||||
@@ -468,7 +480,7 @@ export async function createIssue(args: JiraCreateIssueArgs): Promise<JiraCreate
|
||||
summary: title
|
||||
}
|
||||
if (args.description?.trim()) {
|
||||
fields.description = textToAdf(args.description.trim())
|
||||
fields.description = toBodyText(entry.site, args.description.trim())
|
||||
}
|
||||
for (const [fieldKey, value] of Object.entries(args.customFields ?? {})) {
|
||||
if (!fieldKey || value === undefined || value === null || value === '') {
|
||||
@@ -478,7 +490,7 @@ export async function createIssue(args: JiraCreateIssueArgs): Promise<JiraCreate
|
||||
}
|
||||
const created = await jiraRequest<{ id: string; key: string; self: string }>(
|
||||
entry,
|
||||
'/rest/api/3/issue',
|
||||
`${apiBasePath(entry.site)}/issue`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ fields })
|
||||
@@ -517,20 +529,27 @@ export async function updateIssue(
|
||||
if (updates.priorityId !== undefined) {
|
||||
fields.priority = updates.priorityId ? { id: updates.priorityId } : null
|
||||
}
|
||||
const issueBase = `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}`
|
||||
if (Object.keys(fields).length > 0) {
|
||||
await jiraRequest(entry, `/rest/api/3/issue/${encodeURIComponent(key)}`, {
|
||||
await jiraRequest(entry, issueBase, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ fields })
|
||||
})
|
||||
}
|
||||
if (updates.assigneeAccountId !== undefined) {
|
||||
await jiraRequest(entry, `/rest/api/3/issue/${encodeURIComponent(key)}/assignee`, {
|
||||
// Server/DC identifies assignees by username (`name`), not accountId;
|
||||
// mapUser stores the Server username in the accountId slot.
|
||||
const assigneeBody =
|
||||
entry.site.authType === 'server'
|
||||
? { name: updates.assigneeAccountId }
|
||||
: { accountId: updates.assigneeAccountId }
|
||||
await jiraRequest(entry, `${issueBase}/assignee`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ accountId: updates.assigneeAccountId })
|
||||
body: JSON.stringify(assigneeBody)
|
||||
})
|
||||
}
|
||||
if (updates.transitionId) {
|
||||
await jiraRequest(entry, `/rest/api/3/issue/${encodeURIComponent(key)}/transitions`, {
|
||||
await jiraRequest(entry, `${issueBase}/transitions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ transition: { id: updates.transitionId } })
|
||||
})
|
||||
@@ -560,10 +579,10 @@ export async function addIssueComment(
|
||||
try {
|
||||
const comment = await jiraRequest<{ id: string }>(
|
||||
entry,
|
||||
`/rest/api/3/issue/${encodeURIComponent(key)}/comment`,
|
||||
`${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/comment`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ body: textToAdf(body) })
|
||||
body: JSON.stringify({ body: toBodyText(entry.site, body) })
|
||||
}
|
||||
)
|
||||
return { ok: true, id: comment.id }
|
||||
@@ -604,7 +623,7 @@ export async function getIssueComments(
|
||||
orderBy: 'created',
|
||||
startAt: String(startAt)
|
||||
})
|
||||
return `/rest/api/3/issue/${encodeURIComponent(key)}/comment?${params.toString()}`
|
||||
return `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/comment?${params.toString()}`
|
||||
})
|
||||
return comments.map(mapComment)
|
||||
} catch (error) {
|
||||
@@ -628,13 +647,18 @@ export async function listProjects(siteId?: JiraSiteSelection | null): Promise<J
|
||||
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()}`
|
||||
})
|
||||
// Server/DC has no /project/search resource; /project returns the
|
||||
// full list as a plain (unpaged) array.
|
||||
const projects =
|
||||
entry.site.authType === 'server'
|
||||
? await jiraRequest<JiraRecord[]>(entry, `${apiBasePath(entry.site)}/project`)
|
||||
: 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)) {
|
||||
@@ -669,7 +693,8 @@ export async function listIssueTypes(
|
||||
maxResults: String(maxResults),
|
||||
startAt: String(startAt)
|
||||
})
|
||||
return `/rest/api/3/issue/createmeta/${encodeURIComponent(
|
||||
// Per-project createmeta paths exist on Server/DC from Jira 8.4 onward.
|
||||
return `${apiBasePath(entry.site)}/issue/createmeta/${encodeURIComponent(
|
||||
projectIdOrKey
|
||||
)}/issuetypes?${params.toString()}`
|
||||
})
|
||||
@@ -707,7 +732,7 @@ export async function listCreateFields(
|
||||
})
|
||||
const response = await jiraRequest<JiraPagedResponse<JiraRecord>>(
|
||||
entry,
|
||||
`/rest/api/3/issue/createmeta/${encodeURIComponent(
|
||||
`${apiBasePath(entry.site)}/issue/createmeta/${encodeURIComponent(
|
||||
projectIdOrKey
|
||||
)}/issuetypes/${encodeURIComponent(issueTypeId)}?${params.toString()}`
|
||||
)
|
||||
@@ -742,7 +767,7 @@ export async function listPriorities(siteId?: string | null): Promise<JiraPriori
|
||||
}
|
||||
await acquire()
|
||||
try {
|
||||
const response = await jiraRequest<JiraRecord[]>(entry, '/rest/api/3/priority')
|
||||
const response = await jiraRequest<JiraRecord[]>(entry, `${apiBasePath(entry.site)}/priority`)
|
||||
return response.map(mapPriority).filter((priority): priority is JiraPriority => !!priority)
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
@@ -765,15 +790,17 @@ export async function listAssignableUsers(
|
||||
if (!entry) {
|
||||
return []
|
||||
}
|
||||
const isServer = entry.site.authType === 'server'
|
||||
const params = new URLSearchParams({ issueKey: key, maxResults: '50' })
|
||||
if (query?.trim()) {
|
||||
params.set('query', query.trim())
|
||||
// Server/DC filters assignable users by `username`; `query` is Cloud-only.
|
||||
params.set(isServer ? 'username' : 'query', query.trim())
|
||||
}
|
||||
await acquire()
|
||||
try {
|
||||
const response = await jiraRequest<JiraRecord[]>(
|
||||
entry,
|
||||
`/rest/api/3/user/assignable/search?${params.toString()}`
|
||||
`${apiBasePath(entry.site)}/user/assignable/search?${params.toString()}`
|
||||
)
|
||||
return response.map(mapUser).filter((user): user is JiraUser => !!user)
|
||||
} catch (error) {
|
||||
@@ -800,7 +827,7 @@ export async function listTransitions(
|
||||
try {
|
||||
const response = await jiraRequest<{ transitions?: JiraRecord[] }>(
|
||||
entry,
|
||||
`/rest/api/3/issue/${encodeURIComponent(key)}/transitions`
|
||||
`${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/transitions`
|
||||
)
|
||||
return (response.transitions ?? []).map((transition) => ({
|
||||
id: asString(transition.id),
|
||||
|
||||
@@ -17,8 +17,10 @@ const SiteSelection = z
|
||||
|
||||
const Connect = z.object({
|
||||
siteUrl: requiredString('Site URL is required'),
|
||||
email: requiredString('Email is required'),
|
||||
apiToken: requiredString('API token is required')
|
||||
// Self-hosted PAT auth needs no email; connect() enforces it for Cloud.
|
||||
email: OptionalPlainString,
|
||||
apiToken: requiredString('API token is required'),
|
||||
authType: z.enum(['cloud', 'server']).optional()
|
||||
})
|
||||
|
||||
const SelectSite = z.object({
|
||||
@@ -100,8 +102,9 @@ export const JIRA_METHODS: RpcMethod[] = [
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.jiraConnect({
|
||||
siteUrl: params.siteUrl.trim(),
|
||||
email: params.email.trim(),
|
||||
apiToken: params.apiToken.trim()
|
||||
email: params.email?.trim() ?? '',
|
||||
apiToken: params.apiToken.trim(),
|
||||
authType: params.authType
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
|
||||
@@ -2007,6 +2007,7 @@ export type PreloadApi = {
|
||||
siteUrl: string
|
||||
email: string
|
||||
apiToken: string
|
||||
authType?: 'cloud' | 'server'
|
||||
}) => Promise<{ ok: true; viewer: JiraViewer } | { ok: false; error: string }>
|
||||
disconnect: (args?: { siteId?: string }) => Promise<void>
|
||||
selectSite: (args: { siteId: JiraSiteSelection }) => Promise<JiraConnectionStatus>
|
||||
|
||||
@@ -1744,6 +1744,7 @@ const api = {
|
||||
siteUrl: string
|
||||
email: string
|
||||
apiToken: string
|
||||
authType?: 'cloud' | 'server'
|
||||
}): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> =>
|
||||
ipcRenderer.invoke('jira:connect', args),
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
GitPullRequestDraft,
|
||||
List,
|
||||
LoaderCircle,
|
||||
Lock,
|
||||
Minus,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -90,6 +89,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import TaskProjectSourceCombobox from '@/components/task-project-source-combobox'
|
||||
import { JiraConnectDialog } from '@/components/jira-connect-dialog'
|
||||
import { LinearApiKeyDialog } from '@/components/linear-api-key-dialog'
|
||||
import { LinearScopeSelector } from '@/components/linear-scope-selector'
|
||||
import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel'
|
||||
@@ -3118,7 +3118,6 @@ export default function TaskPage(): React.JSX.Element {
|
||||
const jiraStatus = useAppStore((s) => s.jiraStatus)
|
||||
const jiraStatusChecked = useAppStore((s) => s.jiraStatusChecked)
|
||||
const jiraStatusContextKey = useAppStore((s) => s.jiraStatusContextKey)
|
||||
const connectJira = useAppStore((s) => s.connectJira)
|
||||
const selectJiraSite = useAppStore((s) => s.selectJiraSite)
|
||||
const searchJiraIssues = useAppStore((s) => s.searchJiraIssues)
|
||||
const listJiraIssues = useAppStore((s) => s.listJiraIssues)
|
||||
@@ -5849,6 +5848,7 @@ export default function TaskPage(): React.JSX.Element {
|
||||
}, [newLinearStates.data, newLinearIssueStateId])
|
||||
|
||||
const [linearConnectOpen, setLinearConnectOpen] = useState(false)
|
||||
const [jiraConnectOpen, setJiraConnectOpen] = useState(false)
|
||||
useContextualTour(
|
||||
'tasks',
|
||||
!dialogWorkItem &&
|
||||
@@ -5858,6 +5858,7 @@ export default function TaskPage(): React.JSX.Element {
|
||||
!newLinearProjectOpen &&
|
||||
!newLinearIssueOpen &&
|
||||
!linearConnectOpen &&
|
||||
!jiraConnectOpen &&
|
||||
activeModal === 'none',
|
||||
'tasks_open'
|
||||
)
|
||||
@@ -5897,12 +5898,6 @@ export default function TaskPage(): React.JSX.Element {
|
||||
const [newJiraIssueCustomFieldValues, setNewJiraIssueCustomFieldValues] = useState<
|
||||
Record<string, string>
|
||||
>({})
|
||||
const [jiraConnectOpen, setJiraConnectOpen] = useState(false)
|
||||
const [jiraSiteUrlDraft, setJiraSiteUrlDraft] = useState('')
|
||||
const [jiraEmailDraft, setJiraEmailDraft] = useState('')
|
||||
const [jiraApiTokenDraft, setJiraApiTokenDraft] = useState('')
|
||||
const [jiraConnectState, setJiraConnectState] = useState<'idle' | 'connecting' | 'error'>('idle')
|
||||
const [jiraConnectError, setJiraConnectError] = useState<string | null>(null)
|
||||
const includeJiraSiteNameInProjectLabel = selectedJiraSiteId === 'all'
|
||||
const previousProviderRuntimeContextKeyRef = useRef(providerRuntimeContextKey)
|
||||
|
||||
@@ -8114,33 +8109,6 @@ export default function TaskPage(): React.JSX.Element {
|
||||
[openComposerForJiraItem]
|
||||
)
|
||||
|
||||
const handleJiraConnect = useCallback(async (): Promise<void> => {
|
||||
const siteUrl = jiraSiteUrlDraft.trim()
|
||||
const email = jiraEmailDraft.trim()
|
||||
const apiToken = jiraApiTokenDraft.trim()
|
||||
if (!siteUrl || !email || !apiToken) {
|
||||
return
|
||||
}
|
||||
setJiraConnectState('connecting')
|
||||
setJiraConnectError(null)
|
||||
try {
|
||||
const result = await connectJira({ siteUrl, email, apiToken })
|
||||
if (result.ok) {
|
||||
setJiraSiteUrlDraft('')
|
||||
setJiraEmailDraft('')
|
||||
setJiraApiTokenDraft('')
|
||||
setJiraConnectState('idle')
|
||||
setJiraConnectOpen(false)
|
||||
} else {
|
||||
setJiraConnectState('error')
|
||||
setJiraConnectError(result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
setJiraConnectState('error')
|
||||
setJiraConnectError(error instanceof Error ? error.message : 'Connection failed')
|
||||
}
|
||||
}, [connectJira, jiraApiTokenDraft, jiraEmailDraft, jiraSiteUrlDraft])
|
||||
|
||||
const taskPageListChromeHidden = shouldHideTaskPageListChrome({
|
||||
taskSource,
|
||||
hasGitHubDetail: Boolean(dialogWorkItem),
|
||||
@@ -10085,16 +10053,7 @@ export default function TaskPage(): React.JSX.Element {
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setJiraSiteUrlDraft('')
|
||||
setJiraEmailDraft('')
|
||||
setJiraApiTokenDraft('')
|
||||
setJiraConnectState('idle')
|
||||
setJiraConnectError(null)
|
||||
setJiraConnectOpen(true)
|
||||
}}
|
||||
>
|
||||
<Button onClick={() => setJiraConnectOpen(true)}>
|
||||
{translate('auto.components.TaskPage.83bce6be5c', 'Connect Jira')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => hideTaskSource('jira', 'Jira')}>
|
||||
@@ -12700,137 +12659,7 @@ export default function TaskPage(): React.JSX.Element {
|
||||
onConnected={handleLinearAccessConnected}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={jiraConnectOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (jiraConnectState !== 'connecting') {
|
||||
setJiraConnectOpen(open)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="sm:max-w-md"
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
jiraSiteUrlDraft.trim() &&
|
||||
jiraEmailDraft.trim() &&
|
||||
jiraApiTokenDraft.trim() &&
|
||||
jiraConnectState !== 'connecting'
|
||||
) {
|
||||
e.preventDefault()
|
||||
void handleJiraConnect()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="gap-3">
|
||||
<DialogTitle className="leading-tight">
|
||||
{translate('auto.components.TaskPage.60f806ce99', 'Connect Jira site')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{translate(
|
||||
'auto.components.TaskPage.33fc2bcb30',
|
||||
'Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={translate(
|
||||
'auto.components.TaskPage.163df31e0e',
|
||||
'https://example.atlassian.net'
|
||||
)}
|
||||
value={jiraSiteUrlDraft}
|
||||
onChange={(e) => {
|
||||
setJiraSiteUrlDraft(e.target.value)
|
||||
if (jiraConnectState === 'error') {
|
||||
setJiraConnectState('idle')
|
||||
setJiraConnectError(null)
|
||||
}
|
||||
}}
|
||||
disabled={jiraConnectState === 'connecting'}
|
||||
/>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder={translate('auto.components.TaskPage.68df347677', 'you@example.com')}
|
||||
value={jiraEmailDraft}
|
||||
onChange={(e) => {
|
||||
setJiraEmailDraft(e.target.value)
|
||||
if (jiraConnectState === 'error') {
|
||||
setJiraConnectState('idle')
|
||||
setJiraConnectError(null)
|
||||
}
|
||||
}}
|
||||
disabled={jiraConnectState === 'connecting'}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={translate('auto.components.TaskPage.b95623e93f', 'Atlassian API token')}
|
||||
value={jiraApiTokenDraft}
|
||||
onChange={(e) => {
|
||||
setJiraApiTokenDraft(e.target.value)
|
||||
if (jiraConnectState === 'error') {
|
||||
setJiraConnectState('idle')
|
||||
setJiraConnectError(null)
|
||||
}
|
||||
}}
|
||||
disabled={jiraConnectState === 'connecting'}
|
||||
/>
|
||||
{jiraConnectState === 'error' && jiraConnectError && (
|
||||
<p className="text-xs text-destructive">{jiraConnectError}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate('auto.components.TaskPage.59c14d34a2', 'Create a token in')}{' '}
|
||||
<button
|
||||
className="text-primary underline-offset-2 hover:underline"
|
||||
onClick={() =>
|
||||
window.api.shell.openUrl(
|
||||
'https://id.atlassian.com/manage-profile/security/api-tokens'
|
||||
)
|
||||
}
|
||||
>
|
||||
{translate('auto.components.TaskPage.246c2b3dd3', 'Atlassian account settings')}
|
||||
</button>
|
||||
.
|
||||
</p>
|
||||
<p className="flex items-center gap-1.5 text-[11px] text-muted-foreground/70">
|
||||
<Lock className="size-3 shrink-0" />
|
||||
{translate(
|
||||
'auto.components.TaskPage.2abe22ef76',
|
||||
'Your token is encrypted via the OS keychain and stored locally.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setJiraConnectOpen(false)}
|
||||
disabled={jiraConnectState === 'connecting'}
|
||||
>
|
||||
{translate('auto.components.TaskPage.ff69a30681', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleJiraConnect()}
|
||||
disabled={
|
||||
!jiraSiteUrlDraft.trim() ||
|
||||
!jiraEmailDraft.trim() ||
|
||||
!jiraApiTokenDraft.trim() ||
|
||||
jiraConnectState === 'connecting'
|
||||
}
|
||||
>
|
||||
{jiraConnectState === 'connecting' ? (
|
||||
<>
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
{translate('auto.components.TaskPage.513cddfa7a', 'Verifying…')}
|
||||
</>
|
||||
) : (
|
||||
translate('auto.components.TaskPage.887efe9140', 'Connect')
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<JiraConnectDialog open={jiraConnectOpen} onOpenChange={setJiraConnectOpen} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -205,8 +205,11 @@ describe('feature interaction writer boundaries', () => {
|
||||
const taskPageSource = componentSource('TaskPage.tsx')
|
||||
const jiraWriter = "recordFeatureInteraction('jira-tasks')"
|
||||
|
||||
// End boundary is the declaration after the handler: the Jira connect flow
|
||||
// now lives in the shared JiraConnectDialog, so handleJiraConnect (the prior
|
||||
// marker) no longer exists in TaskPage.
|
||||
expect(
|
||||
sourceBetween(taskPageSource, 'const handleUseJiraItem', 'const handleJiraConnect')
|
||||
sourceBetween(taskPageSource, 'const handleUseJiraItem', 'const taskPageListChromeHidden')
|
||||
).toContain(jiraWriter)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { useId, useLayoutEffect, useState } from 'react'
|
||||
import { LoaderCircle, Lock } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { hasRemoteProviderRuntime } from '@/lib/provider-runtime-context'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -26,6 +27,10 @@ type JiraConnectDialogProps = {
|
||||
}
|
||||
|
||||
type ConnectState = 'idle' | 'connecting' | 'error'
|
||||
type JiraInstanceType = 'cloud' | 'server'
|
||||
// Self-hosted Jira accepts either a personal access token (Bearer) or classic
|
||||
// username + password (Basic); older Server/DC instances predate PATs.
|
||||
type ServerAuthMethod = 'pat' | 'basic'
|
||||
|
||||
// Why: mirrors the inline Jira connect dialog in TaskPage so the onboarding
|
||||
// "Connect integrations" step can reuse the same site URL + email + API token
|
||||
@@ -45,15 +50,39 @@ export function JiraConnectDialog({
|
||||
const tokenId = useId()
|
||||
const errorId = useId()
|
||||
|
||||
const [instanceType, setInstanceType] = useState<JiraInstanceType>('cloud')
|
||||
const [serverAuthMethod, setServerAuthMethod] = useState<ServerAuthMethod>('pat')
|
||||
const [siteUrl, setSiteUrl] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [apiToken, setApiToken] = useState('')
|
||||
const [connectState, setConnectState] = useState<ConnectState>('idle')
|
||||
const [connectError, setConnectError] = useState<string | null>(null)
|
||||
|
||||
// Start every open with a clean slate so a previously-typed secret, stale
|
||||
// instance/auth-method selection, or old error can't linger across reopens.
|
||||
// Runs before paint so a stale credential never renders for a frame.
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
setInstanceType('cloud')
|
||||
setServerAuthMethod('pat')
|
||||
setSiteUrl('')
|
||||
setEmail('')
|
||||
setApiToken('')
|
||||
setConnectState('idle')
|
||||
setConnectError(null)
|
||||
}, [open])
|
||||
|
||||
const isServer = instanceType === 'server'
|
||||
// `needsIdentity` folds "Cloud Atlassian email" and "self-hosted Basic
|
||||
// username" — the identity slot that keys/labels the stored site. PAT auth
|
||||
// uses no identity, so the email field is hidden and left empty.
|
||||
const isServerBasic = isServer && serverAuthMethod === 'basic'
|
||||
const needsIdentity = !isServer || isServerBasic
|
||||
const canSubmit =
|
||||
Boolean(siteUrl.trim()) &&
|
||||
Boolean(email.trim()) &&
|
||||
(!needsIdentity || Boolean(email.trim())) &&
|
||||
Boolean(apiToken.trim()) &&
|
||||
connectState !== 'connecting'
|
||||
const credentialStorageCopy = hasRemoteProviderRuntime(settings)
|
||||
@@ -67,6 +96,16 @@ export function JiraConnectDialog({
|
||||
}
|
||||
}
|
||||
|
||||
// A Cloud email, a Server username, a PAT, and an account password are
|
||||
// different secrets; drop the credential fields when the deployment or auth
|
||||
// method changes so one can't be submitted as another (e.g. a password
|
||||
// silently riding along as a Bearer PAT).
|
||||
const clearCredentialsOnModeSwitch = (): void => {
|
||||
setEmail('')
|
||||
setApiToken('')
|
||||
clearErrorOnEdit()
|
||||
}
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean): void => {
|
||||
if (connectState !== 'connecting') {
|
||||
onOpenChange(nextOpen)
|
||||
@@ -77,7 +116,12 @@ export function JiraConnectDialog({
|
||||
const trimmedSite = siteUrl.trim()
|
||||
const trimmedEmail = email.trim()
|
||||
const trimmedToken = apiToken.trim()
|
||||
if (!trimmedSite || !trimmedEmail || !trimmedToken || connectState === 'connecting') {
|
||||
if (
|
||||
!trimmedSite ||
|
||||
(needsIdentity && !trimmedEmail) ||
|
||||
!trimmedToken ||
|
||||
connectState === 'connecting'
|
||||
) {
|
||||
return
|
||||
}
|
||||
setConnectState('connecting')
|
||||
@@ -85,8 +129,11 @@ export function JiraConnectDialog({
|
||||
try {
|
||||
const result = await connectJira({
|
||||
siteUrl: trimmedSite,
|
||||
email: trimmedEmail,
|
||||
apiToken: trimmedToken
|
||||
// Cloud sends the Atlassian email; self-hosted Basic sends the username;
|
||||
// PAT sends nothing, so a stale email can't key/label the stored site.
|
||||
email: needsIdentity ? trimmedEmail : '',
|
||||
apiToken: trimmedToken,
|
||||
authType: instanceType
|
||||
})
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
@@ -95,6 +142,8 @@ export function JiraConnectDialog({
|
||||
setSiteUrl('')
|
||||
setEmail('')
|
||||
setApiToken('')
|
||||
setInstanceType('cloud')
|
||||
setServerAuthMethod('pat')
|
||||
setConnectState('idle')
|
||||
onOpenChange(false)
|
||||
onConnected?.()
|
||||
@@ -121,31 +170,110 @@ export function JiraConnectDialog({
|
||||
{translate('auto.components.jira.connect.dialog.8388bdea2b', 'Connect Jira site')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{translate(
|
||||
'auto.components.jira.connect.dialog.d785c42b8b',
|
||||
'Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.'
|
||||
)}
|
||||
{!isServer
|
||||
? translate(
|
||||
'auto.components.jira.connect.dialog.d785c42b8b',
|
||||
'Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.'
|
||||
)
|
||||
: isServerBasic
|
||||
? translate(
|
||||
'auto.components.jira.connect.dialog.1d947a07ab',
|
||||
'Use a self-hosted Jira base URL, username, and password to browse issues.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.jira.connect.dialog.2e2b69e48e',
|
||||
'Use a self-hosted Jira base URL and a personal access token to browse issues.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
noValidate
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void handleConnect()
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
variant="outline"
|
||||
value={instanceType}
|
||||
disabled={connectState === 'connecting'}
|
||||
onValueChange={(value) => {
|
||||
if (!value || connectState === 'connecting') {
|
||||
return
|
||||
}
|
||||
setInstanceType(value as JiraInstanceType)
|
||||
clearCredentialsOnModeSwitch()
|
||||
}}
|
||||
aria-label={translate(
|
||||
'auto.components.jira.connect.dialog.b67e919bd5',
|
||||
'Jira instance type'
|
||||
)}
|
||||
>
|
||||
<ToggleGroupItem value="cloud" className="h-8 px-3 text-xs">
|
||||
{translate('auto.components.jira.connect.dialog.17787d6e4b', 'Atlassian Cloud')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="server" className="h-8 px-3 text-xs">
|
||||
{translate('auto.components.jira.connect.dialog.bc7a831773', 'Self-hosted')}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
{isServer ? (
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
variant="outline"
|
||||
value={serverAuthMethod}
|
||||
disabled={connectState === 'connecting'}
|
||||
onValueChange={(value) => {
|
||||
if (!value || connectState === 'connecting') {
|
||||
return
|
||||
}
|
||||
setServerAuthMethod(value as ServerAuthMethod)
|
||||
clearCredentialsOnModeSwitch()
|
||||
}}
|
||||
aria-label={translate(
|
||||
'auto.components.jira.connect.dialog.f49708c369',
|
||||
'Jira authentication method'
|
||||
)}
|
||||
>
|
||||
<ToggleGroupItem value="pat" className="h-8 px-3 text-xs">
|
||||
{translate(
|
||||
'auto.components.jira.connect.dialog.730d973bae',
|
||||
'Personal access token'
|
||||
)}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="basic" className="h-8 px-3 text-xs">
|
||||
{translate(
|
||||
'auto.components.jira.connect.dialog.84a810dd0e',
|
||||
'Username & password'
|
||||
)}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={siteUrlId} className="text-xs">
|
||||
{translate('auto.components.jira.connect.dialog.e176f9d0c5', 'Jira Cloud site URL')}
|
||||
{isServer
|
||||
? translate('auto.components.jira.connect.dialog.3489e186d6', 'Jira site URL')
|
||||
: translate(
|
||||
'auto.components.jira.connect.dialog.e176f9d0c5',
|
||||
'Jira Cloud site URL'
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id={siteUrlId}
|
||||
autoFocus
|
||||
placeholder={translate(
|
||||
'auto.components.jira.connect.dialog.70fcd360c4',
|
||||
'https://example.atlassian.net'
|
||||
)}
|
||||
placeholder={
|
||||
isServer
|
||||
? translate(
|
||||
'auto.components.jira.connect.dialog.cbc27fa599',
|
||||
'https://jira.example.com'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.jira.connect.dialog.70fcd360c4',
|
||||
'https://example.atlassian.net'
|
||||
)
|
||||
}
|
||||
value={siteUrl}
|
||||
onChange={(event) => {
|
||||
setSiteUrl(event.target.value)
|
||||
@@ -154,36 +282,66 @@ export function JiraConnectDialog({
|
||||
disabled={connectState === 'connecting'}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={emailId} className="text-xs">
|
||||
{translate('auto.components.jira.connect.dialog.2849ddb295', 'Atlassian email')}
|
||||
</Label>
|
||||
<Input
|
||||
id={emailId}
|
||||
type="email"
|
||||
placeholder={translate(
|
||||
'auto.components.jira.connect.dialog.e91b9a4073',
|
||||
'you@example.com'
|
||||
)}
|
||||
value={email}
|
||||
onChange={(event) => {
|
||||
setEmail(event.target.value)
|
||||
clearErrorOnEdit()
|
||||
}}
|
||||
disabled={connectState === 'connecting'}
|
||||
/>
|
||||
</div>
|
||||
{needsIdentity ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={emailId} className="text-xs">
|
||||
{isServerBasic
|
||||
? translate('auto.components.jira.connect.dialog.8d1223fa5c', 'Username')
|
||||
: translate(
|
||||
'auto.components.jira.connect.dialog.2849ddb295',
|
||||
'Atlassian email'
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id={emailId}
|
||||
type={isServerBasic ? 'text' : 'email'}
|
||||
placeholder={
|
||||
isServerBasic
|
||||
? translate('auto.components.jira.connect.dialog.be9eba0a1b', 'username')
|
||||
: translate(
|
||||
'auto.components.jira.connect.dialog.e91b9a4073',
|
||||
'you@example.com'
|
||||
)
|
||||
}
|
||||
value={email}
|
||||
onChange={(event) => {
|
||||
setEmail(event.target.value)
|
||||
clearErrorOnEdit()
|
||||
}}
|
||||
disabled={connectState === 'connecting'}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={tokenId} className="text-xs">
|
||||
{translate('auto.components.jira.connect.dialog.3d81bf3ab3', 'API token')}
|
||||
{isServerBasic
|
||||
? translate('auto.components.jira.connect.dialog.70035652d7', 'Password')
|
||||
: isServer
|
||||
? translate(
|
||||
'auto.components.jira.connect.dialog.730d973bae',
|
||||
'Personal access token'
|
||||
)
|
||||
: translate('auto.components.jira.connect.dialog.3d81bf3ab3', 'API token')}
|
||||
</Label>
|
||||
<Input
|
||||
id={tokenId}
|
||||
type="password"
|
||||
placeholder={translate(
|
||||
'auto.components.jira.connect.dialog.7b3967c12f',
|
||||
'Atlassian API token'
|
||||
)}
|
||||
placeholder={
|
||||
isServerBasic
|
||||
? translate(
|
||||
'auto.components.jira.connect.dialog.c50abbf340',
|
||||
'Jira account password'
|
||||
)
|
||||
: isServer
|
||||
? translate(
|
||||
'auto.components.jira.connect.dialog.8b9c7b9e7b',
|
||||
'Jira personal access token'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.jira.connect.dialog.7b3967c12f',
|
||||
'Atlassian API token'
|
||||
)
|
||||
}
|
||||
value={apiToken}
|
||||
onChange={(event) => {
|
||||
setApiToken(event.target.value)
|
||||
@@ -199,24 +357,40 @@ export function JiraConnectDialog({
|
||||
{connectError}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate('auto.components.jira.connect.dialog.8090504a3e', 'Create a token in')}{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary underline-offset-2 hover:underline"
|
||||
onClick={() =>
|
||||
window.api.shell.openUrl(
|
||||
'https://id.atlassian.com/manage-profile/security/api-tokens'
|
||||
)
|
||||
}
|
||||
>
|
||||
{isServerBasic ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.jira.connect.dialog.fdd26d81cc',
|
||||
'Atlassian account settings'
|
||||
'auto.components.jira.connect.dialog.d8737db691',
|
||||
'Use your Jira Server or Data Center account username and password.'
|
||||
)}
|
||||
</button>
|
||||
.
|
||||
</p>
|
||||
</p>
|
||||
) : isServer ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.jira.connect.dialog.ccfb086d3e',
|
||||
'Create a personal access token in your Jira profile under Personal Access Tokens.'
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate('auto.components.jira.connect.dialog.8090504a3e', 'Create a token in')}{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary underline-offset-2 hover:underline"
|
||||
onClick={() =>
|
||||
window.api.shell.openUrl(
|
||||
'https://id.atlassian.com/manage-profile/security/api-tokens'
|
||||
)
|
||||
}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.jira.connect.dialog.fdd26d81cc',
|
||||
'Atlassian account settings'
|
||||
)}
|
||||
</button>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
<p className="flex items-center gap-1.5 text-[11px] text-muted-foreground/70">
|
||||
<Lock className="size-3 shrink-0" />
|
||||
{credentialStorageCopy}
|
||||
|
||||
@@ -38,8 +38,14 @@ export function JiraIntegrationCard(): React.JSX.Element {
|
||||
const siteCount = sites.length || (connected ? 1 : 0)
|
||||
const accountScope = getProviderAccountScope(settings)
|
||||
const credentialCopy = hasRemoteProviderRuntime(settings)
|
||||
? 'Connect a Jira Cloud site with your Atlassian email and an API token. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.'
|
||||
: 'Connect a Jira Cloud site with your Atlassian email and an API token. Credentials are stored locally and encrypted when local runtime storage supports it.'
|
||||
? translate(
|
||||
'auto.components.settings.task.tracker.integration.cards.2d60ec7921',
|
||||
'Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.task.tracker.integration.cards.977e360b71',
|
||||
'Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it.'
|
||||
)
|
||||
const subordinateRowClass = useIntegrationSubordinateRowClass('flex items-center gap-3')
|
||||
const accountScopeRowClass = useIntegrationSubordinateRowClass('text-xs')
|
||||
|
||||
|
||||
@@ -8678,7 +8678,9 @@
|
||||
"fe9231215b": "Checking Linear access before showing setup actions.",
|
||||
"e1f5e6424c": "{{value0}} workspace{{value1}} connected",
|
||||
"disconnect_all": "Disconnect",
|
||||
"account_scope_prefix": "Account scope"
|
||||
"account_scope_prefix": "Account scope",
|
||||
"2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.",
|
||||
"977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12692,7 +12694,24 @@
|
||||
"70fcd360c4": "https://example.atlassian.net",
|
||||
"e176f9d0c5": "Jira Cloud site URL",
|
||||
"d785c42b8b": "Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.",
|
||||
"8388bdea2b": "Connect Jira site"
|
||||
"8388bdea2b": "Connect Jira site",
|
||||
"2e2b69e48e": "Use a self-hosted Jira base URL and a personal access token to browse issues.",
|
||||
"b67e919bd5": "Jira instance type",
|
||||
"17787d6e4b": "Atlassian Cloud",
|
||||
"bc7a831773": "Self-hosted",
|
||||
"3489e186d6": "Jira site URL",
|
||||
"cbc27fa599": "https://jira.example.com",
|
||||
"730d973bae": "Personal access token",
|
||||
"8b9c7b9e7b": "Jira personal access token",
|
||||
"ccfb086d3e": "Create a personal access token in your Jira profile under Personal Access Tokens.",
|
||||
"1d947a07ab": "Use a self-hosted Jira base URL, username, and password to browse issues.",
|
||||
"f49708c369": "Jira authentication method",
|
||||
"84a810dd0e": "Username & password",
|
||||
"8d1223fa5c": "Username",
|
||||
"be9eba0a1b": "username",
|
||||
"70035652d7": "Password",
|
||||
"c50abbf340": "Jira account password",
|
||||
"d8737db691": "Use your Jira Server or Data Center account username and password."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8670,7 +8670,9 @@
|
||||
"fe9231215b": "Comprobando el acceso a Linear antes de mostrar las acciones de configuración.",
|
||||
"e1f5e6424c": "Conexión completada para {{value0}} workspace{{value1}}",
|
||||
"disconnect_all": "Desconectar todo",
|
||||
"account_scope_prefix": "Alcance de la cuenta"
|
||||
"account_scope_prefix": "Alcance de la cuenta",
|
||||
"2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.",
|
||||
"977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12692,7 +12694,24 @@
|
||||
"70fcd360c4": "https://example.atlassian.net",
|
||||
"e176f9d0c5": "URL del sitio de Jira Cloud",
|
||||
"d785c42b8b": "Usa una URL de Jira Cloud, email de Atlassian y token API para explorar issues.",
|
||||
"8388bdea2b": "Conectar sitio de Jira"
|
||||
"8388bdea2b": "Conectar sitio de Jira",
|
||||
"2e2b69e48e": "Use a self-hosted Jira base URL and a personal access token to browse issues.",
|
||||
"b67e919bd5": "Jira instance type",
|
||||
"17787d6e4b": "Atlassian Cloud",
|
||||
"bc7a831773": "Self-hosted",
|
||||
"3489e186d6": "Jira site URL",
|
||||
"cbc27fa599": "https://jira.example.com",
|
||||
"730d973bae": "Personal access token",
|
||||
"8b9c7b9e7b": "Jira personal access token",
|
||||
"ccfb086d3e": "Create a personal access token in your Jira profile under Personal Access Tokens.",
|
||||
"1d947a07ab": "Use a self-hosted Jira base URL, username, and password to browse issues.",
|
||||
"f49708c369": "Jira authentication method",
|
||||
"84a810dd0e": "Username & password",
|
||||
"8d1223fa5c": "Username",
|
||||
"be9eba0a1b": "username",
|
||||
"70035652d7": "Password",
|
||||
"c50abbf340": "Jira account password",
|
||||
"d8737db691": "Use your Jira Server or Data Center account username and password."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8670,7 +8670,9 @@
|
||||
"fe9231215b": "Checking Linear access before showing setup actions.",
|
||||
"e1f5e6424c": "{{value0}} workspace{{value1}} connected",
|
||||
"disconnect_all": "切断",
|
||||
"account_scope_prefix": "Account scope"
|
||||
"account_scope_prefix": "Account scope",
|
||||
"2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.",
|
||||
"977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12692,7 +12694,24 @@
|
||||
"70fcd360c4": "https://example.atlassian.net",
|
||||
"e176f9d0c5": "Jira Cloud site URL",
|
||||
"d785c42b8b": "Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.",
|
||||
"8388bdea2b": "Connect Jira site"
|
||||
"8388bdea2b": "Connect Jira site",
|
||||
"2e2b69e48e": "Use a self-hosted Jira base URL and a personal access token to browse issues.",
|
||||
"b67e919bd5": "Jira instance type",
|
||||
"17787d6e4b": "Atlassian Cloud",
|
||||
"bc7a831773": "Self-hosted",
|
||||
"3489e186d6": "Jira site URL",
|
||||
"cbc27fa599": "https://jira.example.com",
|
||||
"730d973bae": "Personal access token",
|
||||
"8b9c7b9e7b": "Jira personal access token",
|
||||
"ccfb086d3e": "Create a personal access token in your Jira profile under Personal Access Tokens.",
|
||||
"1d947a07ab": "Use a self-hosted Jira base URL, username, and password to browse issues.",
|
||||
"f49708c369": "Jira authentication method",
|
||||
"84a810dd0e": "Username & password",
|
||||
"8d1223fa5c": "Username",
|
||||
"be9eba0a1b": "username",
|
||||
"70035652d7": "Password",
|
||||
"c50abbf340": "Jira account password",
|
||||
"d8737db691": "Use your Jira Server or Data Center account username and password."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8670,7 +8670,9 @@
|
||||
"fe9231215b": "설정 작업을 표시하기 전에 Linear 액세스를 확인하는 중입니다.",
|
||||
"e1f5e6424c": "{{value0}}개 워크스페이스{{value1}} 연결됨",
|
||||
"disconnect_all": "연결 해제",
|
||||
"account_scope_prefix": "계정 범위"
|
||||
"account_scope_prefix": "계정 범위",
|
||||
"2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.",
|
||||
"977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12692,7 +12694,24 @@
|
||||
"70fcd360c4": "https://example.atlassian.net",
|
||||
"e176f9d0c5": "Jira Cloud 사이트 URL",
|
||||
"d785c42b8b": "Jira Cloud 사이트 URL, Atlassian 이메일, API 토큰을 사용해 이슈를 탐색합니다.",
|
||||
"8388bdea2b": "Jira 사이트 연결"
|
||||
"8388bdea2b": "Jira 사이트 연결",
|
||||
"2e2b69e48e": "Use a self-hosted Jira base URL and a personal access token to browse issues.",
|
||||
"b67e919bd5": "Jira instance type",
|
||||
"17787d6e4b": "Atlassian Cloud",
|
||||
"bc7a831773": "Self-hosted",
|
||||
"3489e186d6": "Jira site URL",
|
||||
"cbc27fa599": "https://jira.example.com",
|
||||
"730d973bae": "Personal access token",
|
||||
"8b9c7b9e7b": "Jira personal access token",
|
||||
"ccfb086d3e": "Create a personal access token in your Jira profile under Personal Access Tokens.",
|
||||
"1d947a07ab": "Use a self-hosted Jira base URL, username, and password to browse issues.",
|
||||
"f49708c369": "Jira authentication method",
|
||||
"84a810dd0e": "Username & password",
|
||||
"8d1223fa5c": "Username",
|
||||
"be9eba0a1b": "username",
|
||||
"70035652d7": "Password",
|
||||
"c50abbf340": "Jira account password",
|
||||
"d8737db691": "Use your Jira Server or Data Center account username and password."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8670,7 +8670,9 @@
|
||||
"fe9231215b": "在显示设置操作前正在检查 Linear 访问权限。",
|
||||
"e1f5e6424c": "{{value0}} 个工作区{{value1}} 已连接",
|
||||
"disconnect_all": "断开连接",
|
||||
"account_scope_prefix": "账户范围"
|
||||
"account_scope_prefix": "账户范围",
|
||||
"2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.",
|
||||
"977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12692,7 +12694,24 @@
|
||||
"70fcd360c4": "https://example.atlassian.net",
|
||||
"e176f9d0c5": "Jira Cloud 站点 URL",
|
||||
"d785c42b8b": "使用 Jira Cloud 站点 URL、Atlassian 邮箱和 API token 来浏览议题。",
|
||||
"8388bdea2b": "连接 Jira 站点"
|
||||
"8388bdea2b": "连接 Jira 站点",
|
||||
"2e2b69e48e": "Use a self-hosted Jira base URL and a personal access token to browse issues.",
|
||||
"b67e919bd5": "Jira instance type",
|
||||
"17787d6e4b": "Atlassian Cloud",
|
||||
"bc7a831773": "Self-hosted",
|
||||
"3489e186d6": "Jira site URL",
|
||||
"cbc27fa599": "https://jira.example.com",
|
||||
"730d973bae": "Personal access token",
|
||||
"8b9c7b9e7b": "Jira personal access token",
|
||||
"ccfb086d3e": "Create a personal access token in your Jira profile under Personal Access Tokens.",
|
||||
"1d947a07ab": "Use a self-hosted Jira base URL, username, and password to browse issues.",
|
||||
"f49708c369": "Jira authentication method",
|
||||
"84a810dd0e": "Username & password",
|
||||
"8d1223fa5c": "Username",
|
||||
"be9eba0a1b": "username",
|
||||
"70035652d7": "Password",
|
||||
"c50abbf340": "Jira account password",
|
||||
"d8737db691": "Use your Jira Server or Data Center account username and password."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
GlobalSettings,
|
||||
JiraAuthType,
|
||||
JiraComment,
|
||||
JiraConnectionStatus,
|
||||
JiraCreateField,
|
||||
@@ -57,7 +58,7 @@ export async function jiraStatus(settings: RuntimeJiraSettings): Promise<JiraCon
|
||||
|
||||
export async function jiraConnect(
|
||||
settings: RuntimeJiraSettings,
|
||||
args: { siteUrl: string; email: string; apiToken: string }
|
||||
args: { siteUrl: string; email: string; apiToken: string; authType?: JiraAuthType }
|
||||
): Promise<JiraConnectResult> {
|
||||
const target = getJiraRuntimeTarget(settings)
|
||||
return target.kind === 'environment'
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
import type {
|
||||
JiraAuthType,
|
||||
JiraConnectionStatus,
|
||||
JiraIssue,
|
||||
JiraIssueFilter,
|
||||
@@ -163,6 +164,7 @@ export type JiraSlice = {
|
||||
siteUrl: string
|
||||
email: string
|
||||
apiToken: string
|
||||
authType?: JiraAuthType
|
||||
}) => Promise<{ ok: true; viewer: JiraViewer } | { ok: false; error: string }>
|
||||
testJiraConnection: (
|
||||
siteId?: string | null
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
// 'cloud' = Atlassian Cloud (email + API token, Basic auth, REST v3).
|
||||
// 'server' = self-hosted Jira Server/Data Center (personal access token,
|
||||
// Bearer auth, REST v2). Older stored sites omit the field and mean 'cloud'.
|
||||
export type JiraAuthType = 'cloud' | 'server'
|
||||
|
||||
export type JiraSite = {
|
||||
id: string
|
||||
siteUrl: string
|
||||
email: string
|
||||
displayName: string
|
||||
accountId: string
|
||||
authType?: JiraAuthType
|
||||
}
|
||||
|
||||
export type JiraViewer = {
|
||||
@@ -130,8 +136,11 @@ export type JiraIssueFilter = 'assigned' | 'reported' | 'all' | 'done'
|
||||
|
||||
export type JiraConnectArgs = {
|
||||
siteUrl: string
|
||||
// Ignored for 'server' auth: self-hosted PATs authenticate via Bearer
|
||||
// header alone, so the email field may be empty.
|
||||
email: string
|
||||
apiToken: string
|
||||
authType?: JiraAuthType
|
||||
}
|
||||
|
||||
export type JiraCreateIssueArgs = {
|
||||
|
||||
@@ -1863,6 +1863,7 @@ export type {
|
||||
} from './gitlab-types'
|
||||
|
||||
export type {
|
||||
JiraAuthType,
|
||||
JiraComment,
|
||||
JiraConnectArgs,
|
||||
JiraConnectionStatus,
|
||||
|
||||
Reference in New Issue
Block a user