diff --git a/src/main/ipc/jira.ts b/src/main/ipc/jira.ts
index e4a0e7ff895..44335aae86a 100644
--- a/src/main/ipc/jira.ts
+++ b/src/main/ipc/jira.ts
@@ -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()
diff --git a/src/main/jira/client.test.ts b/src/main/jira/client.test.ts
index 41cb70c5beb..0c0ee11511c 100644
--- a/src/main/jira/client.test.ts
+++ b/src/main/jira/client.test.ts
@@ -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(
diff --git a/src/main/jira/client.ts b/src/main/jira/client.ts
index cce8ee85b67..4c182ee2ad5 100644
--- a/src/main/jira/client.ts
+++ b/src/main/jira/client.ts
@@ -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, fallbackEmail: string): JiraViewer {
const avatarUrls = data.avatarUrls as Record | 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 {
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,
+ 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,
+ (await jiraRequest(client, `${apiBasePath(client.site)}/myself`)) as Record,
client.site.email
)
return { ok: true, viewer }
diff --git a/src/main/jira/issues.test.ts b/src/main/jira/issues.test.ts
index 1cf9ab31212..4b20b0c4a2f 100644
--- a/src/main/jira/issues.test.ts
+++ b/src/main/jira/issues.test.ts
@@ -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'))
diff --git a/src/main/jira/issues.ts b/src/main/jira/issues.ts
index 5a19b9897b3..2ce24a87420 100644
--- a/src/main/jira/issues.ts
+++ b/src/main/jira/issues.ts
@@ -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 {
- const result = await jiraRequest(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(entry, searchPath, {
method: 'POST',
body: JSON.stringify({
jql,
@@ -429,7 +441,7 @@ export async function getIssue(
try {
const issue = await jiraRequest(
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(
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 {
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(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>(
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(entry, '/rest/api/3/priority')
+ const response = await jiraRequest(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(
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),
diff --git a/src/main/runtime/rpc/methods/jira.ts b/src/main/runtime/rpc/methods/jira.ts
index 35d1d067ca1..a18eb2d02d3 100644
--- a/src/main/runtime/rpc/methods/jira.ts
+++ b/src/main/runtime/rpc/methods/jira.ts
@@ -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({
diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts
index 695e037379a..2ce823028c5 100644
--- a/src/preload/api-types.ts
+++ b/src/preload/api-types.ts
@@ -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
selectSite: (args: { siteId: JiraSiteSelection }) => Promise
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 64276047cea..912e1852cfa 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -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),
diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx
index 0ade42f2811..af47d26736d 100644
--- a/src/renderer/src/components/TaskPage.tsx
+++ b/src/renderer/src/components/TaskPage.tsx
@@ -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
>({})
- 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(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 => {
- 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 {
)}
-
)
}
diff --git a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts
index 79ea48d8929..ad7a1197323 100644
--- a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts
+++ b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts
@@ -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)
})
diff --git a/src/renderer/src/components/jira-connect-dialog.tsx b/src/renderer/src/components/jira-connect-dialog.tsx
index eff9f0cde24..0de9093185c 100644
--- a/src/renderer/src/components/jira-connect-dialog.tsx
+++ b/src/renderer/src/components/jira-connect-dialog.tsx
@@ -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('cloud')
+ const [serverAuthMethod, setServerAuthMethod] = useState('pat')
const [siteUrl, setSiteUrl] = useState('')
const [email, setEmail] = useState('')
const [apiToken, setApiToken] = useState('')
const [connectState, setConnectState] = useState('idle')
const [connectError, setConnectError] = useState(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')}
- {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.'
+ )}