mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
feat(jira): load attachment images outside issue Markdown (#28631)
Load Jira attachment images separately from issue Markdown so descriptions and comments stay small while screenshots load lazily and open in the existing lightbox. Downloads begin when an image mounts, capture its owning host, require negotiated image support, and use bounded authenticated byte streams and caches. Older clients retain their inline-image handlers. Keep legacy and cancellable downloads in separate in-flight domains so cancelling a new image reader cannot abort a legacy reader; completed bytes share the bounded cache. Preserve the landed assignee, board, transition/comment queue and request lifetimes. Move unchanged project metadata IPC handlers into their domain module and tighten two inherited focus-check ceilings without changing behavior. Validation:410 tests/54 suites; all changed-source quality stages, lint, format, routing and contract checks; exact final node/web types; independent source review;48/48 fresh hidden Electron checks with226 stable source hashes and nine inspected screenshots. Synthetic transports exercise production clients and UI. Current CI static checks, mobile bundle, wire compatibility, Linux/Windows packaging and Windows serve pass; five test shards were pending at review. Extends the existing attachment work credited to Frank (@lifefloating) and Brennan Benson in PR #8938; existing contribution credits and media remain in this PR.
This commit is contained in:
@@ -219,7 +219,7 @@
|
||||
1 src/renderer/src/components/tab-group/workspace-tab-close-commands.ts
|
||||
1 src/renderer/src/components/task-page-jira-status-order.ts
|
||||
2 src/renderer/src/components/task-page-linear-issue-model.tsx
|
||||
10 src/renderer/src/components/task-page/TaskPage.tsx
|
||||
9 src/renderer/src/components/task-page/TaskPage.tsx
|
||||
2 src/renderer/src/components/task-page/jira/IssueAssigneeField.tsx
|
||||
1 src/renderer/src/components/task-page/linear/Filters.tsx
|
||||
1 src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx
|
||||
@@ -254,7 +254,7 @@
|
||||
2 src/renderer/src/components/use-new-linear-issue-projects.ts
|
||||
4 src/renderer/src/components/use-task-page-jira-creation-metadata.ts
|
||||
4 src/renderer/src/components/use-task-page-jira-issue-creation.ts
|
||||
2 src/renderer/src/components/use-task-page-jira-list-effects.ts
|
||||
1 src/renderer/src/components/use-task-page-jira-list-effects.ts
|
||||
4 src/renderer/src/components/use-task-page-linear-board.ts
|
||||
6 src/renderer/src/components/use-task-page-linear-creation-state.ts
|
||||
4 src/renderer/src/components/use-task-page-linear-issue-creation.ts
|
||||
|
||||
@@ -330,10 +330,10 @@ describe('the shell policy this page is tested under', () => {
|
||||
|
||||
it('admits data: and https: for images and for nothing else', () => {
|
||||
expect(cspHeader.split('; ').filter((entry) => entry.includes('data:'))).toEqual([
|
||||
"img-src 'self' data: https:"
|
||||
"img-src 'self' data: blob: https:"
|
||||
])
|
||||
expect(cspHeader.split('; ').filter((entry) => entry.includes('https:'))).toEqual([
|
||||
"img-src 'self' data: https:"
|
||||
"img-src 'self' data: blob: https:"
|
||||
])
|
||||
// `http:` is not a substring of `https:`, so this still refuses a cleartext source.
|
||||
expect(cspHeader).not.toContain('http:')
|
||||
|
||||
@@ -243,13 +243,13 @@ describeRender(
|
||||
it('serves the shipped image policy and neither route violates it', async () => {
|
||||
// The policy the shells send, read from the Kotlin source rather than restated, so this
|
||||
// cannot pass against a header the app does not use.
|
||||
expect(cspHeader).toContain("img-src 'self' data: https:")
|
||||
expect(cspHeader).toContain("img-src 'self' data: blob: https:")
|
||||
const swift = await readFile(
|
||||
join(projectDir, 'mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift'),
|
||||
'utf8'
|
||||
)
|
||||
// The other shell says the same thing, which no served header can show.
|
||||
expect(swift).toContain(`"img-src 'self' data: https:"`)
|
||||
expect(swift).toContain(`"img-src 'self' data: blob: https:"`)
|
||||
|
||||
for (const [route, text] of [
|
||||
[HUB_ROUTE, 'Source Control'],
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ internal val MOBILE_WEB_SHELL_CSP = listOf(
|
||||
//
|
||||
// `http:` stays out: it reaches no image TLS cannot serve, and a cleartext image is readable and
|
||||
// replaceable in flight by anything on the path.
|
||||
"img-src 'self' data: https:",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"font-src 'none'",
|
||||
// The origin is one read-only directory behind the manifest map, so 'self' reaches nothing the
|
||||
// page cannot already read, and the bootstrap page reads ./manifest.json through it. This is the
|
||||
|
||||
+4
-4
@@ -15,7 +15,7 @@ class MobileWebShellCspTest {
|
||||
assertTrue(directives.contains("style-src 'self' 'unsafe-inline'"))
|
||||
// A file preview is a `data:` URI; `https:` is the favicon, project icon and avatar the
|
||||
// page already renders, and the sealed preview frame. See MobileWebShellCsp.
|
||||
assertTrue(directives.contains("img-src 'self' data: https:"))
|
||||
assertTrue(directives.contains("img-src 'self' data: blob: https:"))
|
||||
// The bootstrap page reads ./manifest.json from its own origin, which is one read-only
|
||||
// directory behind the manifest map, so 'self' reaches nothing it cannot already read.
|
||||
assertTrue(directives.contains("connect-src 'self'"))
|
||||
@@ -42,17 +42,17 @@ class MobileWebShellCspTest {
|
||||
// Narrowed rather than absent: `data:` is a fetch source for images and for nothing else, so a
|
||||
// directive that grew one would fail here instead of passing a blanket absence check.
|
||||
assertEquals(
|
||||
listOf("img-src 'self' data: https:"),
|
||||
listOf("img-src 'self' data: blob: https:"),
|
||||
directives.filter { it.contains("data:") }
|
||||
)
|
||||
assertEquals(
|
||||
listOf("media-src blob:"),
|
||||
listOf("img-src 'self' data: blob: https:", "media-src blob:"),
|
||||
directives.filter { it.contains("blob:") }
|
||||
)
|
||||
// Same shape for `https:`: images and nothing else. `http:` is not a substring of `https:`, so
|
||||
// this still refuses a cleartext source anywhere in the header.
|
||||
assertEquals(
|
||||
listOf("img-src 'self' data: https:"),
|
||||
listOf("img-src 'self' data: blob: https:"),
|
||||
directives.filter { it.contains("https:") }
|
||||
)
|
||||
assertFalse(MOBILE_WEB_SHELL_CSP.contains("http:"))
|
||||
|
||||
@@ -33,7 +33,7 @@ enum MobileWebShellCsp {
|
||||
//
|
||||
// `http:` stays out: it reaches no image TLS cannot serve, and a cleartext image is readable
|
||||
// and replaceable in flight by anything on the path.
|
||||
"img-src 'self' data: https:",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"font-src 'none'",
|
||||
// The origin is one read-only directory behind the manifest map, so 'self' reaches nothing the
|
||||
// page cannot already read, and the bootstrap page reads ./manifest.json through it. This is
|
||||
|
||||
@@ -211,7 +211,7 @@ import Foundation
|
||||
precondition(directives.contains("style-src 'self' 'unsafe-inline'"))
|
||||
// A file preview is a `data:` URI; `https:` is the favicon, project icon and avatar the
|
||||
// page already renders, and the sealed preview frame. See MobileWebShellCsp.
|
||||
precondition(directives.contains("img-src 'self' data: https:"))
|
||||
precondition(directives.contains("img-src 'self' data: blob: https:"))
|
||||
precondition(directives.contains("connect-src 'self'"))
|
||||
precondition(directives.contains("worker-src 'none'"))
|
||||
precondition(directives.contains("frame-src 'none'"))
|
||||
@@ -224,11 +224,11 @@ import Foundation
|
||||
precondition(!header.contains("unsafe-eval"))
|
||||
// Narrowed rather than absent: `data:` is a fetch source for images and for nothing else, so a
|
||||
// directive that grew one would fail here instead of passing a blanket absence check.
|
||||
precondition(directives.filter { $0.contains("data:") } == ["img-src 'self' data: https:"])
|
||||
precondition(!header.contains("blob:"))
|
||||
precondition(directives.filter { $0.contains("data:") } == ["img-src 'self' data: blob: https:"])
|
||||
precondition(directives.filter { $0.contains("blob:") } == ["img-src 'self' data: blob: https:", "media-src blob:"])
|
||||
// Same shape for `https:`: images and nothing else. `http:` is not a substring of `https:`, so
|
||||
// this still refuses a cleartext source anywhere in the header.
|
||||
precondition(directives.filter { $0.contains("https:") } == ["img-src 'self' data: https:"])
|
||||
precondition(directives.filter { $0.contains("https:") } == ["img-src 'self' data: blob: https:"])
|
||||
precondition(!header.contains("http:"))
|
||||
precondition(!header.contains("\r") && !header.contains("\n"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
handle: vi.fn(),
|
||||
listProjects: vi.fn(),
|
||||
listIssueTypes: vi.fn(),
|
||||
listCreateFields: vi.fn(),
|
||||
listPriorities: vi.fn()
|
||||
}))
|
||||
vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } }))
|
||||
vi.mock('./preflight', () => ({ _resetPreflightCache: vi.fn() }))
|
||||
vi.mock('../jira/client', () => ({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getStatus: vi.fn(),
|
||||
selectSite: vi.fn(),
|
||||
testConnection: vi.fn()
|
||||
}))
|
||||
vi.mock('../jira/issues', () => ({
|
||||
addIssueComment: vi.fn(),
|
||||
createIssue: vi.fn(),
|
||||
getIssue: vi.fn(),
|
||||
getIssueSummary: vi.fn(),
|
||||
getIssueComments: vi.fn(),
|
||||
getProjectStatusOrder: vi.fn(),
|
||||
listCreateFields: mocks.listCreateFields,
|
||||
listIssueTypes: mocks.listIssueTypes,
|
||||
listIssues: vi.fn(),
|
||||
listPriorities: mocks.listPriorities,
|
||||
listProjects: mocks.listProjects,
|
||||
listTransitions: vi.fn(),
|
||||
searchIssues: vi.fn(),
|
||||
updateIssue: vi.fn(),
|
||||
listAssignableUsers: vi.fn(),
|
||||
listAssignableUsersForProject: vi.fn(),
|
||||
searchUsers: vi.fn()
|
||||
}))
|
||||
import { registerJiraHandlers } from './jira'
|
||||
|
||||
function handler(channel: string) {
|
||||
const callback = mocks.handle.mock.calls.find(([name]) => name === channel)?.[1]
|
||||
if (typeof callback !== 'function') {
|
||||
throw new Error(`Missing handler: ${channel}`)
|
||||
}
|
||||
return callback
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
registerJiraHandlers()
|
||||
})
|
||||
|
||||
describe('Jira project metadata IPC registration', () => {
|
||||
it('registers each metadata channel once through the main Jira registration', () => {
|
||||
for (const channel of [
|
||||
'jira:listProjects',
|
||||
'jira:listIssueTypes',
|
||||
'jira:listCreateFields',
|
||||
'jira:listPriorities'
|
||||
]) {
|
||||
expect(mocks.handle.mock.calls.filter(([name]) => name === channel)).toHaveLength(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('retains all-sites selection and site normalization', async () => {
|
||||
await handler('jira:listProjects')(undefined, { siteId: ' all ' })
|
||||
expect(mocks.listProjects).toHaveBeenLastCalledWith('all')
|
||||
await handler('jira:listProjects')(undefined, { siteId: ' ' })
|
||||
expect(mocks.listProjects).toHaveBeenLastCalledWith(undefined)
|
||||
await handler('jira:listPriorities')(undefined, { siteId: ' site-1 ' })
|
||||
expect(mocks.listPriorities).toHaveBeenCalledWith('site-1')
|
||||
})
|
||||
|
||||
it('preserves invalid project and issue type sentinels', async () => {
|
||||
await expect(
|
||||
handler('jira:listIssueTypes')(undefined, { projectIdOrKey: ' ' })
|
||||
).resolves.toEqual([])
|
||||
await expect(
|
||||
handler('jira:listCreateFields')(undefined, { projectIdOrKey: 'ABC', issueTypeId: 1 })
|
||||
).resolves.toEqual([])
|
||||
expect(mocks.listIssueTypes).not.toHaveBeenCalled()
|
||||
expect(mocks.listCreateFields).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards trimmed project and issue type identifiers to the same site', async () => {
|
||||
await handler('jira:listIssueTypes')(undefined, {
|
||||
projectIdOrKey: ' ABC ',
|
||||
siteId: ' site-1 '
|
||||
})
|
||||
expect(mocks.listIssueTypes).toHaveBeenCalledWith('ABC', 'site-1')
|
||||
await handler('jira:listCreateFields')(undefined, {
|
||||
projectIdOrKey: ' ABC ',
|
||||
issueTypeId: ' 10001 ',
|
||||
siteId: ' site-1 '
|
||||
})
|
||||
expect(mocks.listCreateFields).toHaveBeenCalledWith('ABC', '10001', 'site-1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { listCreateFields, listIssueTypes, listPriorities, listProjects } from '../jira/issues'
|
||||
import type { JiraSiteSelection } from '../../shared/jira-types'
|
||||
|
||||
export function registerJiraProjectMetadataHandlers(
|
||||
normalizeSiteId: (value: unknown) => string | undefined
|
||||
): void {
|
||||
ipcMain.handle('jira:listProjects', async (_event, args?: { siteId?: JiraSiteSelection }) => {
|
||||
return listProjects(normalizeSiteId(args?.siteId))
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'jira:listIssueTypes',
|
||||
async (_event, args: { projectIdOrKey: string; siteId?: string }) => {
|
||||
if (typeof args?.projectIdOrKey !== 'string' || !args.projectIdOrKey.trim()) {
|
||||
return []
|
||||
}
|
||||
return listIssueTypes(args.projectIdOrKey.trim(), normalizeSiteId(args.siteId))
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'jira:listCreateFields',
|
||||
async (_event, args: { projectIdOrKey: string; issueTypeId: string; siteId?: string }) => {
|
||||
if (typeof args?.projectIdOrKey !== 'string' || !args.projectIdOrKey.trim()) {
|
||||
return []
|
||||
}
|
||||
if (typeof args?.issueTypeId !== 'string' || !args.issueTypeId.trim()) {
|
||||
return []
|
||||
}
|
||||
return listCreateFields(
|
||||
args.projectIdOrKey.trim(),
|
||||
args.issueTypeId.trim(),
|
||||
normalizeSiteId(args.siteId)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('jira:listPriorities', async (_event, args?: { siteId?: string }) => {
|
||||
return listPriorities(normalizeSiteId(args?.siteId))
|
||||
})
|
||||
}
|
||||
+24
-43
@@ -1,9 +1,11 @@
|
||||
import { JiraIssueListFiltersParam } from '../../shared/rpc-contract/jira-quick-filter-params'
|
||||
import { ipcMain } from 'electron'
|
||||
import type { JiraMediaFormat } from '../../shared/jira-attachment-image'
|
||||
import { connect, disconnect, getStatus, selectSite, testConnection } from '../jira/client'
|
||||
import { _resetPreflightCache } from './preflight'
|
||||
import { JiraCancellableRequests } from './jira-cancellable-requests'
|
||||
import { registerJiraUserSearchHandlers } from './jira-user-search'
|
||||
import { registerJiraProjectMetadataHandlers } from './jira-project-metadata'
|
||||
import {
|
||||
addIssueComment,
|
||||
createIssue,
|
||||
@@ -11,11 +13,7 @@ import {
|
||||
getIssueSummary,
|
||||
getIssueComments,
|
||||
getProjectStatusOrder,
|
||||
listCreateFields,
|
||||
listIssueTypes,
|
||||
listIssues,
|
||||
listPriorities,
|
||||
listProjects,
|
||||
listTransitions,
|
||||
searchIssues,
|
||||
updateIssue
|
||||
@@ -187,12 +185,20 @@ export function registerJiraHandlers(): void {
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('jira:getIssue', async (_event, args: { key: string; siteId?: string }) => {
|
||||
if (typeof args?.key !== 'string' || !args.key.trim()) {
|
||||
return null
|
||||
ipcMain.handle(
|
||||
'jira:getIssue',
|
||||
async (_event, args: { key: string; siteId?: string; mediaFormat?: JiraMediaFormat }) => {
|
||||
if (typeof args?.key !== 'string' || !args.key.trim()) {
|
||||
return null
|
||||
}
|
||||
return getIssue(
|
||||
args.key.trim(),
|
||||
normalizeSiteId(args.siteId),
|
||||
false,
|
||||
args.mediaFormat === 'reference' ? 'reference' : undefined
|
||||
)
|
||||
}
|
||||
return getIssue(args.key.trim(), normalizeSiteId(args.siteId))
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'jira:lookupIssueSummary',
|
||||
@@ -266,47 +272,22 @@ export function registerJiraHandlers(): void {
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('jira:issueComments', async (_event, args: { key: string; siteId?: string }) => {
|
||||
if (typeof args?.key !== 'string' || !args.key.trim()) {
|
||||
return []
|
||||
}
|
||||
return getIssueComments(args.key.trim(), normalizeSiteId(args.siteId))
|
||||
})
|
||||
|
||||
ipcMain.handle('jira:listProjects', async (_event, args?: { siteId?: JiraSiteSelection }) => {
|
||||
return listProjects(normalizeSiteSelection(args?.siteId))
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'jira:listIssueTypes',
|
||||
async (_event, args: { projectIdOrKey: string; siteId?: string }) => {
|
||||
if (typeof args?.projectIdOrKey !== 'string' || !args.projectIdOrKey.trim()) {
|
||||
'jira:issueComments',
|
||||
async (_event, args: { key: string; siteId?: string; mediaFormat?: JiraMediaFormat }) => {
|
||||
if (typeof args?.key !== 'string' || !args.key.trim()) {
|
||||
return []
|
||||
}
|
||||
return listIssueTypes(args.projectIdOrKey.trim(), normalizeSiteId(args.siteId))
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'jira:listCreateFields',
|
||||
async (_event, args: { projectIdOrKey: string; issueTypeId: string; siteId?: string }) => {
|
||||
if (typeof args?.projectIdOrKey !== 'string' || !args.projectIdOrKey.trim()) {
|
||||
return []
|
||||
}
|
||||
if (typeof args?.issueTypeId !== 'string' || !args.issueTypeId.trim()) {
|
||||
return []
|
||||
}
|
||||
return listCreateFields(
|
||||
args.projectIdOrKey.trim(),
|
||||
args.issueTypeId.trim(),
|
||||
normalizeSiteId(args.siteId)
|
||||
return getIssueComments(
|
||||
args.key.trim(),
|
||||
normalizeSiteId(args.siteId),
|
||||
false,
|
||||
args.mediaFormat === 'reference' ? 'reference' : undefined
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('jira:listPriorities', async (_event, args?: { siteId?: string }) => {
|
||||
return listPriorities(normalizeSiteId(args?.siteId))
|
||||
})
|
||||
registerJiraProjectMetadataHandlers(normalizeSiteId)
|
||||
|
||||
registerJiraUserSearchHandlers()
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { JiraSite } from '../../shared/jira-types'
|
||||
import { apiBasePath } from './authenticated-request'
|
||||
import { isJiraGatewayBaseUrl } from './gateway-base-url'
|
||||
import type { AttachmentMeta } from './attachment-meta'
|
||||
|
||||
export function attachmentContentUrl(site: JiraSite, meta: AttachmentMeta): URL | null {
|
||||
if (!meta.contentUrl && site.authType === 'server') {
|
||||
return null
|
||||
}
|
||||
const contentUrl = meta.contentUrl ? new URL(meta.contentUrl, `${site.siteUrl}/`) : null
|
||||
const scopedSiteAttachment =
|
||||
contentUrl?.origin === new URL(site.siteUrl).origin &&
|
||||
site.authType !== 'server' &&
|
||||
isJiraGatewayBaseUrl(site.apiBaseUrl) &&
|
||||
!contentUrl.pathname.startsWith('/rest/')
|
||||
const url =
|
||||
contentUrl && !scopedSiteAttachment
|
||||
? contentUrl
|
||||
: new URL(
|
||||
`${apiBasePath(site)}/attachment/content/${encodeURIComponent(meta.id)}`,
|
||||
site.siteUrl
|
||||
)
|
||||
if (/\/rest\/api\/(?:2|3)\/attachment\/content\/[^/]+$/i.test(url.pathname)) {
|
||||
url.searchParams.set('redirect', 'false')
|
||||
}
|
||||
return url
|
||||
}
|
||||
@@ -31,6 +31,8 @@ export function selectPreferredAttachmentIds(args: {
|
||||
renderedHtmlIds: string[]
|
||||
attachmentField: unknown
|
||||
mediaAttrs: readonly JiraAdfMediaAttrs[]
|
||||
maxBytes?: number
|
||||
maxImages?: number
|
||||
}): { preferredIds: string[]; fallbackRan: boolean; needCount: number } {
|
||||
const needing = args.mediaAttrs.filter((attrs) => !(attrs.url && /^https?:\/\//i.test(attrs.url)))
|
||||
const needCount = needing.length
|
||||
@@ -44,10 +46,10 @@ export function selectPreferredAttachmentIds(args: {
|
||||
let fallbackRan = false
|
||||
|
||||
if (needCount > preferredIds.length) {
|
||||
const metas = parseImageAttachmentMetas(args.attachmentField)
|
||||
const metas = parseImageAttachmentMetas(args.attachmentField, args.maxBytes)
|
||||
// Why: multiple Jira screenshots often share image.png — assign next unused meta per node.
|
||||
for (const node of needing) {
|
||||
if (preferredIds.length >= MAX_IMAGES) {
|
||||
if (preferredIds.length >= (args.maxImages ?? MAX_IMAGES)) {
|
||||
break
|
||||
}
|
||||
const alt = (node.alt ?? '').trim()
|
||||
@@ -68,7 +70,7 @@ export function selectPreferredAttachmentIds(args: {
|
||||
}
|
||||
|
||||
return {
|
||||
preferredIds: preferredIds.slice(0, MAX_IMAGES),
|
||||
preferredIds: preferredIds.slice(0, args.maxImages ?? MAX_IMAGES),
|
||||
fallbackRan,
|
||||
needCount
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { jiraRequestBinary, type JiraClientForSite } from './authenticated-request'
|
||||
import type { AttachmentMeta } from './attachment-meta'
|
||||
import { attachmentContentUrl } from './attachment-content-url'
|
||||
import {
|
||||
loadAttachmentBytesWithCache,
|
||||
type AttachmentImageBytes,
|
||||
attachmentImagesEpoch,
|
||||
getCachedAttachmentBytes
|
||||
} from './attachment-image-cache'
|
||||
import {
|
||||
JIRA_IMAGE_MAX_BYTES,
|
||||
JIRA_IMAGE_MAX_PENDING_READS,
|
||||
isJiraImageMimeType
|
||||
} from '../../shared/jira-attachment-image'
|
||||
import { withJiraDeadline } from './jira-read-failure'
|
||||
import { PrioritySemaphore } from '../../shared/priority-semaphore'
|
||||
import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
|
||||
|
||||
const downloads = new PrioritySemaphore(3)
|
||||
const interests = new Map<string, { controller: AbortController; readers: number }>()
|
||||
|
||||
export async function readAttachmentImageBytes(
|
||||
client: JiraClientForSite,
|
||||
meta: AttachmentMeta,
|
||||
signal?: AbortSignal
|
||||
): Promise<AttachmentImageBytes | null> {
|
||||
if (signal?.aborted) {
|
||||
throw signal.reason ?? new Error('Jira image request cancelled')
|
||||
}
|
||||
const cached = getCachedAttachmentBytes(client.site.id, meta.id)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const key = `${client.site.id}::${attachmentImagesEpoch(client.site.id)}::${meta.id}`
|
||||
let interest = interests.get(key)
|
||||
if (!interest || interest.controller.signal.aborted) {
|
||||
if (interests.size >= JIRA_IMAGE_MAX_PENDING_READS) {
|
||||
throw new Error('Jira image queue is full.')
|
||||
}
|
||||
interest = { controller: new AbortController(), readers: 0 }
|
||||
interests.set(key, interest)
|
||||
}
|
||||
const owned = interest
|
||||
owned.readers += 1
|
||||
try {
|
||||
return await waitForPromiseWithSignal(
|
||||
loadAttachmentBytesWithCache({
|
||||
siteId: client.site.id,
|
||||
attachmentId: meta.id,
|
||||
signal: owned.controller.signal,
|
||||
load: async () => {
|
||||
const release = await downloads.acquire(0, owned.controller.signal)
|
||||
try {
|
||||
const url = attachmentContentUrl(client.site, meta)
|
||||
if (!url) {
|
||||
return null
|
||||
}
|
||||
const binary = await withJiraDeadline(owned.controller.signal, 30_000, (deadline) =>
|
||||
waitForPromiseWithSignal(
|
||||
jiraRequestBinary(client, url.toString(), {
|
||||
maxBytes: JIRA_IMAGE_MAX_BYTES,
|
||||
signal: deadline
|
||||
}),
|
||||
deadline
|
||||
)
|
||||
)
|
||||
const reported = binary.contentType.split(';')[0]?.trim().toLowerCase()
|
||||
const mimeType =
|
||||
!reported || reported === 'application/octet-stream'
|
||||
? meta.mimeType.toLowerCase()
|
||||
: reported
|
||||
if (!isJiraImageMimeType(mimeType) || binary.data.byteLength === 0) {
|
||||
return null
|
||||
}
|
||||
return { data: new Uint8Array(binary.data), mimeType }
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
}),
|
||||
signal
|
||||
)
|
||||
} finally {
|
||||
owned.readers -= 1
|
||||
if (owned.readers === 0) {
|
||||
owned.controller.abort()
|
||||
if (interests.get(key) === owned) {
|
||||
interests.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
// Why: description and comments fetch attachments independently; comments also
|
||||
// refetch after every post. Cache finished data URLs in main so the second path
|
||||
// does not re-download or re-base64 the same attachment bytes.
|
||||
// Description, comments, and lazy image reads share one bounded cache.
|
||||
|
||||
import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay'
|
||||
|
||||
@@ -8,14 +6,20 @@ const CACHE_TTL_MS = 30 * 60_000
|
||||
const MAX_CACHE_ENTRIES = 96
|
||||
const MAX_CACHE_BYTES = 24 * 1024 * 1024
|
||||
|
||||
export type AttachmentImageBytes = { data: Uint8Array<ArrayBuffer>; mimeType: string }
|
||||
type ImageValue = string | AttachmentImageBytes
|
||||
|
||||
type CacheEntry = {
|
||||
dataUrl: string
|
||||
image: ImageValue
|
||||
byteSize: number
|
||||
storedAt: number
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry>()
|
||||
const inFlight = new Map<string, Promise<string | null>>()
|
||||
const inFlight = new Map<
|
||||
string,
|
||||
{ promise: Promise<ImageValue | null>; signal?: AbortSignal; generation: symbol }
|
||||
>()
|
||||
let expiryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let scheduledExpiryAt = Infinity
|
||||
// Why: mid-flight downloads must not repopulate cache after disconnect/clearToken.
|
||||
@@ -34,7 +38,7 @@ function nextEpoch(): number {
|
||||
return epochTicker
|
||||
}
|
||||
|
||||
function currentEpoch(siteId: string): number {
|
||||
export function attachmentImagesEpoch(siteId: string): number {
|
||||
return Math.max(globalEpoch, siteEpoch.get(siteId) ?? 0)
|
||||
}
|
||||
|
||||
@@ -101,7 +105,7 @@ function evictUntilWithinBounds(): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedAttachmentDataUrl(siteId: string, attachmentId: string): string | null {
|
||||
function getCachedImage(siteId: string, attachmentId: string): ImageValue | null {
|
||||
pruneExpired()
|
||||
const key = cacheKey(siteId, attachmentId)
|
||||
const entry = cache.get(key)
|
||||
@@ -115,19 +119,19 @@ export function getCachedAttachmentDataUrl(siteId: string, attachmentId: string)
|
||||
// Why: delete-then-set refreshes insertion order for LRU-style eviction.
|
||||
cache.delete(key)
|
||||
cache.set(key, entry)
|
||||
return entry.dataUrl
|
||||
return entry.image
|
||||
}
|
||||
|
||||
export function setCachedAttachmentDataUrl(args: {
|
||||
function setCachedImage(args: {
|
||||
siteId: string
|
||||
attachmentId: string
|
||||
dataUrl: string
|
||||
image: ImageValue
|
||||
byteSize: number
|
||||
}): void {
|
||||
pruneExpired()
|
||||
const key = cacheKey(args.siteId, args.attachmentId)
|
||||
const entry: CacheEntry = {
|
||||
dataUrl: args.dataUrl,
|
||||
image: args.image,
|
||||
byteSize: args.byteSize,
|
||||
storedAt: Date.now()
|
||||
}
|
||||
@@ -138,54 +142,127 @@ export function setCachedAttachmentDataUrl(args: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleflight loader: concurrent cold misses for the same attachment share one
|
||||
* download. Failed loads are not cached so a later retry can succeed.
|
||||
* Singleflight loader: concurrent cold misses share one download within their
|
||||
* cancellation domain. Failed loads are not cached so a later retry can succeed.
|
||||
*/
|
||||
export async function loadAttachmentDataUrlWithCache(args: {
|
||||
async function loadImageWithCache(args: {
|
||||
siteId: string
|
||||
attachmentId: string
|
||||
load: () => Promise<{ dataUrl: string; byteSize: number } | null>
|
||||
}): Promise<string | null> {
|
||||
const cached = getCachedAttachmentDataUrl(args.siteId, args.attachmentId)
|
||||
load: () => Promise<{ image: ImageValue; byteSize: number } | null>
|
||||
signal?: AbortSignal
|
||||
}): Promise<ImageValue | null> {
|
||||
const cached = getCachedImage(args.siteId, args.attachmentId)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const key = cacheKey(args.siteId, args.attachmentId)
|
||||
// Legacy waiters must not inherit a cancellable reader's download.
|
||||
const key = `${cacheKey(args.siteId, args.attachmentId)}::${args.signal ? 'cancellable' : 'legacy'}`
|
||||
const existing = inFlight.get(key)
|
||||
if (existing) {
|
||||
return existing
|
||||
if (existing && !existing.signal?.aborted) {
|
||||
return existing.promise
|
||||
}
|
||||
|
||||
const epochAtStart = currentEpoch(args.siteId)
|
||||
const promise = (async (): Promise<string | null> => {
|
||||
const epochAtStart = attachmentImagesEpoch(args.siteId)
|
||||
const generation = Symbol()
|
||||
const promise = (async (): Promise<ImageValue | null> => {
|
||||
try {
|
||||
const loaded = await args.load()
|
||||
if (!loaded) {
|
||||
return null
|
||||
}
|
||||
// Why: return bytes to the waiter but skip cache if site was cleared mid-flight.
|
||||
if (currentEpoch(args.siteId) === epochAtStart) {
|
||||
setCachedAttachmentDataUrl({
|
||||
if (!args.signal?.aborted && attachmentImagesEpoch(args.siteId) === epochAtStart) {
|
||||
setCachedImage({
|
||||
siteId: args.siteId,
|
||||
attachmentId: args.attachmentId,
|
||||
dataUrl: loaded.dataUrl,
|
||||
image: loaded.image,
|
||||
byteSize: loaded.byteSize
|
||||
})
|
||||
}
|
||||
return loaded.dataUrl
|
||||
return loaded.image
|
||||
} finally {
|
||||
// A cleared generation no longer owns the current download's singleflight slot.
|
||||
if (currentEpoch(args.siteId) === epochAtStart) {
|
||||
if (inFlight.get(key)?.generation === generation) {
|
||||
inFlight.delete(key)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
inFlight.set(key, promise)
|
||||
inFlight.set(key, { promise, signal: args.signal, generation })
|
||||
return promise
|
||||
}
|
||||
|
||||
function imageDataUrl(image: ImageValue): string {
|
||||
return typeof image === 'string'
|
||||
? image
|
||||
: `data:${image.mimeType};base64,${Buffer.from(image.data).toString('base64')}`
|
||||
}
|
||||
|
||||
function imageBytes(image: ImageValue): AttachmentImageBytes | null {
|
||||
if (typeof image !== 'string') {
|
||||
return image
|
||||
}
|
||||
const match = /^data:(image\/[^;]+);base64,(.*)$/s.exec(image)
|
||||
if (!match?.[1] || match[2] === undefined) {
|
||||
return null
|
||||
}
|
||||
return { data: Uint8Array.from(Buffer.from(match[2], 'base64')), mimeType: match[1] }
|
||||
}
|
||||
|
||||
export function getCachedAttachmentBytes(
|
||||
siteId: string,
|
||||
attachmentId: string
|
||||
): AttachmentImageBytes | null {
|
||||
const image = getCachedImage(siteId, attachmentId)
|
||||
return image === null ? null : imageBytes(image)
|
||||
}
|
||||
|
||||
export function getCachedAttachmentDataUrl(siteId: string, attachmentId: string): string | null {
|
||||
const image = getCachedImage(siteId, attachmentId)
|
||||
return image === null ? null : imageDataUrl(image)
|
||||
}
|
||||
|
||||
export function setCachedAttachmentDataUrl(args: {
|
||||
siteId: string
|
||||
attachmentId: string
|
||||
dataUrl: string
|
||||
byteSize: number
|
||||
}): void {
|
||||
setCachedImage({ ...args, image: args.dataUrl })
|
||||
}
|
||||
|
||||
export async function loadAttachmentDataUrlWithCache(args: {
|
||||
siteId: string
|
||||
attachmentId: string
|
||||
load: () => Promise<{ dataUrl: string; byteSize: number } | null>
|
||||
}): Promise<string | null> {
|
||||
const image = await loadImageWithCache({
|
||||
...args,
|
||||
load: async () => {
|
||||
const loaded = await args.load()
|
||||
return loaded && { image: loaded.dataUrl, byteSize: loaded.byteSize }
|
||||
}
|
||||
})
|
||||
return image === null ? null : imageDataUrl(image)
|
||||
}
|
||||
|
||||
export async function loadAttachmentBytesWithCache(args: {
|
||||
siteId: string
|
||||
attachmentId: string
|
||||
load: () => Promise<AttachmentImageBytes | null>
|
||||
signal?: AbortSignal
|
||||
}): Promise<AttachmentImageBytes | null> {
|
||||
const image = await loadImageWithCache({
|
||||
...args,
|
||||
load: async () => {
|
||||
const loaded = await args.load()
|
||||
return loaded && { image: loaded, byteSize: loaded.data.byteLength }
|
||||
}
|
||||
})
|
||||
return image === null ? null : imageBytes(image)
|
||||
}
|
||||
|
||||
export function clearAttachmentImagesForSite(siteId?: string): void {
|
||||
if (siteId == null || siteId === '') {
|
||||
cache.clear()
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { JIRA_IMAGE_SCHEME } from '../../shared/jira-attachment-image'
|
||||
import { attachmentImagesEpoch } from './attachment-image-cache'
|
||||
import type { JiraClientForSite } from './authenticated-request'
|
||||
import type { AttachmentMeta } from './attachment-meta'
|
||||
|
||||
const GRANT_TTL_MS = 30 * 60_000
|
||||
const MAX_GRANTS = 2048
|
||||
type ImageGrant = { siteId: string; meta: AttachmentMeta; epoch: number; expiresAt: number }
|
||||
const grants = new Map<string, ImageGrant>()
|
||||
const references = new Map<string, string>()
|
||||
|
||||
function revokeExpired(): void {
|
||||
for (const [reference, grant] of grants) {
|
||||
if (grant.expiresAt <= Date.now() || grant.epoch !== attachmentImagesEpoch(grant.siteId)) {
|
||||
grants.delete(reference)
|
||||
references.delete(`${grant.siteId}::${grant.meta.id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function registerAttachmentImageGrant(
|
||||
client: JiraClientForSite,
|
||||
meta: AttachmentMeta
|
||||
): string {
|
||||
revokeExpired()
|
||||
const identity = `${client.site.id}::${meta.id}`
|
||||
const existing = references.get(identity)
|
||||
if (existing) {
|
||||
const grant = grants.get(existing)
|
||||
if (grant) {
|
||||
grant.meta = meta
|
||||
grant.expiresAt = Date.now() + GRANT_TTL_MS
|
||||
return `${JIRA_IMAGE_SCHEME}://attachment/${existing}`
|
||||
}
|
||||
}
|
||||
while (grants.size >= MAX_GRANTS) {
|
||||
const oldest = grants.keys().next().value
|
||||
if (!oldest) {
|
||||
break
|
||||
}
|
||||
const grant = grants.get(oldest)
|
||||
grants.delete(oldest)
|
||||
if (grant) {
|
||||
references.delete(`${grant.siteId}::${grant.meta.id}`)
|
||||
}
|
||||
}
|
||||
const reference = randomUUID()
|
||||
grants.set(reference, {
|
||||
siteId: client.site.id,
|
||||
meta,
|
||||
epoch: attachmentImagesEpoch(client.site.id),
|
||||
expiresAt: Date.now() + GRANT_TTL_MS
|
||||
})
|
||||
references.set(identity, reference)
|
||||
return `${JIRA_IMAGE_SCHEME}://attachment/${reference}`
|
||||
}
|
||||
|
||||
export function attachmentImageGrant(reference: string): ImageGrant | null {
|
||||
revokeExpired()
|
||||
return grants.get(reference) ?? null
|
||||
}
|
||||
|
||||
export function isAttachmentImageGrantCurrent(reference: string, grant: ImageGrant): boolean {
|
||||
return attachmentImageGrant(reference) === grant
|
||||
}
|
||||
|
||||
export function resetAttachmentImageGrantsForTest(): void {
|
||||
grants.clear()
|
||||
references.clear()
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { protocol } from 'electron'
|
||||
import { JIRA_IMAGE_SCHEME, jiraImageReference } from '../../shared/jira-attachment-image'
|
||||
import { readGrantedAttachmentImage } from './attachment-image-read'
|
||||
|
||||
export const JIRA_IMAGE_CUSTOM_SCHEME: Electron.CustomScheme = {
|
||||
scheme: JIRA_IMAGE_SCHEME,
|
||||
privileges: { standard: true, secure: true, stream: true }
|
||||
}
|
||||
|
||||
export async function handleJiraImageRequest(request: Request): Promise<Response> {
|
||||
const reference = jiraImageReference(request.url)
|
||||
if (!reference) {
|
||||
return new Response(null, { status: 404 })
|
||||
}
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
||||
return new Response(null, { status: 405 })
|
||||
}
|
||||
try {
|
||||
const image = await readGrantedAttachmentImage(reference, request.signal)
|
||||
if (!image) {
|
||||
return new Response(null, { status: 404 })
|
||||
}
|
||||
return new Response(request.method === 'HEAD' ? null : image.data, {
|
||||
headers: {
|
||||
'Content-Type': image.mimeType,
|
||||
'Content-Length': String(image.data.byteLength),
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Content-Type-Options': 'nosniff'
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return new Response(null, { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
export function installJiraImageProtocolHandler(): void {
|
||||
protocol.handle(JIRA_IMAGE_SCHEME, handleJiraImageRequest)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { JiraClientForSite } from './authenticated-request'
|
||||
import { JIRA_IMAGE_MAX_PENDING_READS } from '../../shared/jira-attachment-image'
|
||||
import { readAttachmentImageBytes } from './attachment-image-bytes'
|
||||
import {
|
||||
registerAttachmentImageGrant,
|
||||
attachmentImageGrant,
|
||||
resetAttachmentImageGrantsForTest
|
||||
} from './attachment-image-grants'
|
||||
import { readGrantedAttachmentImage } from './attachment-image-read'
|
||||
import { handleJiraImageRequest } from './attachment-image-protocol'
|
||||
import { clearAttachmentImagesForSite, _resetAttachmentImageCache } from './attachment-image-cache'
|
||||
|
||||
const { binary, clients } = vi.hoisted(() => ({ binary: vi.fn(), clients: vi.fn() }))
|
||||
vi.mock('./authenticated-request', () => ({
|
||||
apiBasePath: () => '/rest/api/3',
|
||||
jiraRequestBinary: binary
|
||||
}))
|
||||
vi.mock('./client', () => ({ getClients: clients }))
|
||||
vi.mock('electron', () => ({ protocol: { handle: vi.fn() } }))
|
||||
const client: JiraClientForSite = {
|
||||
site: { id: 'site', siteUrl: 'https://jira.test', email: '', accountId: '', displayName: '' },
|
||||
authorization: 'private-token'
|
||||
}
|
||||
const meta = { id: '1', filename: 'shot.png', mimeType: 'image/png', size: 3 }
|
||||
const bytes = Uint8Array.from([1, 2, 3])
|
||||
function grant(): string {
|
||||
return registerAttachmentImageGrant(client, meta).split('/').at(-1) ?? ''
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
_resetAttachmentImageCache()
|
||||
resetAttachmentImageGrantsForTest()
|
||||
binary.mockReset().mockResolvedValue({ data: bytes.buffer, contentType: 'image/png' })
|
||||
clients.mockReset().mockReturnValue([client])
|
||||
})
|
||||
afterEach(() => {
|
||||
_resetAttachmentImageCache()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('authenticated Jira image reads', () => {
|
||||
it('uses only a live opaque grant, never a caller URL or stored credential', async () => {
|
||||
const reference = grant()
|
||||
expect(attachmentImageGrant(reference)).not.toHaveProperty('authorization')
|
||||
expect(await readGrantedAttachmentImage('unknown')).toBeNull()
|
||||
expect(binary).not.toHaveBeenCalled()
|
||||
expect(await readGrantedAttachmentImage(reference)).toEqual({
|
||||
data: bytes,
|
||||
mimeType: 'image/png'
|
||||
})
|
||||
expect(binary).toHaveBeenCalledWith(
|
||||
client,
|
||||
'https://jira.test/rest/api/3/attachment/content/1?redirect=false',
|
||||
expect.objectContaining({ maxBytes: 8 * 1024 * 1024, signal: expect.any(AbortSignal) })
|
||||
)
|
||||
clients.mockReturnValue([])
|
||||
expect(await readGrantedAttachmentImage(reference)).toBeNull()
|
||||
})
|
||||
|
||||
it('discards a download if the site disconnects before it finishes', async () => {
|
||||
let finish: ((value: { data: ArrayBuffer; contentType: string }) => void) | undefined
|
||||
binary.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
const reading = readGrantedAttachmentImage(grant())
|
||||
await vi.waitFor(() => expect(binary).toHaveBeenCalledTimes(1))
|
||||
clearAttachmentImagesForSite('site')
|
||||
finish?.({ data: bytes.buffer, contentType: 'image/png' })
|
||||
expect(await reading).toBeNull()
|
||||
})
|
||||
|
||||
it('shares a download and aborts only after the last reader leaves', async () => {
|
||||
let downloadSignal: AbortSignal | undefined
|
||||
binary.mockImplementation((_client, _url, options: { signal: AbortSignal }) => {
|
||||
downloadSignal = options.signal
|
||||
return new Promise((_resolve, reject) =>
|
||||
options.signal.addEventListener('abort', () => reject(new Error('cancelled')), {
|
||||
once: true
|
||||
})
|
||||
)
|
||||
})
|
||||
const first = new AbortController()
|
||||
const second = new AbortController()
|
||||
const a = readAttachmentImageBytes(client, meta, first.signal).catch((error) => error)
|
||||
const b = readAttachmentImageBytes(client, meta, second.signal).catch((error) => error)
|
||||
await vi.waitFor(() => expect(binary).toHaveBeenCalledTimes(1))
|
||||
first.abort()
|
||||
await a
|
||||
expect(downloadSignal?.aborted).toBe(false)
|
||||
second.abort()
|
||||
await b
|
||||
expect(downloadSignal?.aborted).toBe(true)
|
||||
binary.mockResolvedValue({ data: bytes.buffer, contentType: 'image/png' })
|
||||
expect(await readAttachmentImageBytes(client, meta)).toEqual({
|
||||
data: bytes,
|
||||
mimeType: 'image/png'
|
||||
})
|
||||
})
|
||||
|
||||
it('allows three concurrent downloads across different issues, with a bounded queue', async () => {
|
||||
const controller = new AbortController()
|
||||
binary.mockImplementation(
|
||||
(_client, _url, options: { signal: AbortSignal }) =>
|
||||
new Promise((_resolve, reject) =>
|
||||
options.signal.addEventListener('abort', () => reject(new Error('cancelled')), {
|
||||
once: true
|
||||
})
|
||||
)
|
||||
)
|
||||
const reads = Array.from({ length: JIRA_IMAGE_MAX_PENDING_READS }, (_, id) =>
|
||||
readAttachmentImageBytes(client, { ...meta, id: String(id) }, controller.signal).catch(
|
||||
(error) => error
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() => expect(binary).toHaveBeenCalledTimes(3))
|
||||
await expect(readAttachmentImageBytes(client, { ...meta, id: 'overflow' })).rejects.toThrow(
|
||||
'queue is full'
|
||||
)
|
||||
controller.abort()
|
||||
await Promise.all(reads)
|
||||
expect(binary).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('loads the combined 48 description and comment references without rejecting a reader', async () => {
|
||||
const releases: (() => void)[] = []
|
||||
binary.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releases.push(() => resolve({ data: bytes.buffer, contentType: 'image/png' }))
|
||||
})
|
||||
)
|
||||
const reads = Array.from({ length: 48 }, (_, id) =>
|
||||
readAttachmentImageBytes(client, { ...meta, id: String(id) })
|
||||
)
|
||||
await vi.waitFor(() => expect(binary).toHaveBeenCalledTimes(3))
|
||||
for (let batch = 0; batch < 16; batch += 1) {
|
||||
await vi.waitFor(() => expect(releases).toHaveLength(3))
|
||||
releases.splice(0).forEach((release) => release())
|
||||
}
|
||||
expect((await Promise.all(reads)).filter(Boolean)).toHaveLength(48)
|
||||
expect(binary).toHaveBeenCalledTimes(48)
|
||||
})
|
||||
|
||||
it('uses the REST attachment fallback for scoped site links while preserving Server paths', async () => {
|
||||
const scoped = {
|
||||
...client,
|
||||
site: {
|
||||
...client.site,
|
||||
apiBaseUrl: 'https://api.atlassian.com/ex/jira/tenant',
|
||||
authType: 'cloud' as const
|
||||
}
|
||||
}
|
||||
await readAttachmentImageBytes(scoped, { ...meta, contentUrl: '/secure/attachment/1/shot.png' })
|
||||
expect(binary).toHaveBeenCalledWith(
|
||||
scoped,
|
||||
'https://jira.test/rest/api/3/attachment/content/1?redirect=false',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('expires grants and renews them when the issue is read again', () => {
|
||||
vi.useFakeTimers()
|
||||
const reference = grant()
|
||||
vi.advanceTimersByTime(29 * 60_000)
|
||||
expect(grant()).toBe(reference)
|
||||
vi.advanceTimersByTime(29 * 60_000)
|
||||
expect(attachmentImageGrant(reference)).not.toBeNull()
|
||||
vi.advanceTimersByTime(2 * 60_000)
|
||||
expect(attachmentImageGrant(reference)).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves Server content paths and rejects SVG responses', async () => {
|
||||
const server = { ...client, site: { ...client.site, authType: 'server' as const } }
|
||||
expect(await readAttachmentImageBytes(server, meta)).toBeNull()
|
||||
binary.mockResolvedValue({ data: bytes.buffer, contentType: 'image/svg+xml' })
|
||||
expect(
|
||||
await readAttachmentImageBytes(server, {
|
||||
...meta,
|
||||
contentUrl: '/secure/attachment/1/shot.png'
|
||||
})
|
||||
).toBeNull()
|
||||
expect(binary).toHaveBeenCalledWith(
|
||||
server,
|
||||
'https://jira.test/secure/attachment/1/shot.png',
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('local Jira image protocol', () => {
|
||||
it('serves bounded image bytes and headers without exposing authorization', async () => {
|
||||
const url = `orca-jira-image://attachment/${grant()}`
|
||||
const response = await handleJiraImageRequest(new Request(url))
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('content-type')).toBe('image/png')
|
||||
expect(response.headers.get('cache-control')).toBe('no-store')
|
||||
expect(response.headers.get('authorization')).toBeNull()
|
||||
expect(new Uint8Array(await response.arrayBuffer())).toEqual(bytes)
|
||||
const head = await handleJiraImageRequest(new Request(url, { method: 'HEAD' }))
|
||||
expect(head.headers.get('content-length')).toBe('3')
|
||||
expect(await head.text()).toBe('')
|
||||
})
|
||||
it('refuses unknown, malformed, revoked and non-GET image requests', async () => {
|
||||
const url = `orca-jira-image://attachment/${grant()}`
|
||||
expect((await handleJiraImageRequest(new Request(`${url}?url=https://evil.test`))).status).toBe(
|
||||
404
|
||||
)
|
||||
expect((await handleJiraImageRequest(new Request(url, { method: 'POST' }))).status).toBe(405)
|
||||
clearAttachmentImagesForSite('site')
|
||||
expect((await handleJiraImageRequest(new Request(url))).status).toBe(404)
|
||||
expect(binary).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { getClients } from './client'
|
||||
import { attachmentImageGrant, isAttachmentImageGrantCurrent } from './attachment-image-grants'
|
||||
import { readAttachmentImageBytes } from './attachment-image-bytes'
|
||||
import type { AttachmentImageBytes } from './attachment-image-cache'
|
||||
|
||||
export async function readGrantedAttachmentImage(
|
||||
reference: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<AttachmentImageBytes | null> {
|
||||
const grant = attachmentImageGrant(reference)
|
||||
if (!grant) {
|
||||
return null
|
||||
}
|
||||
const client = getClients(grant.siteId).find((entry) => entry.site.id === grant.siteId)
|
||||
if (!client) {
|
||||
return null
|
||||
}
|
||||
const image = await readAttachmentImageBytes(client, grant.meta, signal)
|
||||
if (!isAttachmentImageGrantCurrent(reference, grant) || signal?.aborted) {
|
||||
return null
|
||||
}
|
||||
return image
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { JIRA_IMAGE_MAX_BYTES } from '../../shared/jira-attachment-image'
|
||||
import { loadIssueImageAttachments, createMediaMarkdownResolver } from './attachment-images'
|
||||
import { attachmentImageGrant, resetAttachmentImageGrantsForTest } from './attachment-image-grants'
|
||||
import { clearAttachmentImagesForSite, _resetAttachmentImageCache } from './attachment-image-cache'
|
||||
import type { JiraClientForSite } from './authenticated-request'
|
||||
|
||||
vi.mock('./authenticated-request', () => ({
|
||||
apiBasePath: () => '/rest/api/3',
|
||||
jiraRequestBinary: vi.fn(),
|
||||
JiraApiError: class extends Error {}
|
||||
}))
|
||||
const client: JiraClientForSite = {
|
||||
site: {
|
||||
id: 'site-1',
|
||||
siteUrl: 'https://example.atlassian.net',
|
||||
email: '',
|
||||
accountId: '',
|
||||
displayName: ''
|
||||
},
|
||||
authorization: 'secret'
|
||||
}
|
||||
const metadata = [
|
||||
{
|
||||
id: '1',
|
||||
filename: 'shot.png',
|
||||
mimeType: 'image/png',
|
||||
size: 3 * 1024 * 1024,
|
||||
content: 'https://example.atlassian.net/secure/attachment/1/shot.png'
|
||||
}
|
||||
]
|
||||
|
||||
describe('lazy Jira image references', () => {
|
||||
beforeEach(() => {
|
||||
_resetAttachmentImageCache()
|
||||
resetAttachmentImageGrantsForTest()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
it('keeps image bytes out of Markdown without downloading the attachment', async () => {
|
||||
const { jiraRequestBinary } = await import('./authenticated-request')
|
||||
const images = await loadIssueImageAttachments(client, metadata, ['1'], 'reference')
|
||||
const markdown = createMediaMarkdownResolver(images, ['1'])({ id: 'media-id', alt: 'shot.png' })
|
||||
expect(markdown).toMatch(/^!\[shot.png\]\(orca-jira-image:\/\/attachment\/[0-9a-f-]+\)$/)
|
||||
expect(markdown).not.toContain('data:')
|
||||
expect(jiraRequestBinary).not.toHaveBeenCalled()
|
||||
})
|
||||
it('keeps grants isolated by site and revokes them on disconnect', async () => {
|
||||
const images = await loadIssueImageAttachments(client, metadata, ['1'], 'reference')
|
||||
const reference = images[0]?.dataUrl.split('/').at(-1) ?? ''
|
||||
expect(attachmentImageGrant(reference)?.siteId).toBe('site-1')
|
||||
clearAttachmentImagesForSite('other-site')
|
||||
expect(attachmentImageGrant(reference)).not.toBeNull()
|
||||
clearAttachmentImagesForSite('site-1')
|
||||
expect(attachmentImageGrant(reference)).toBeNull()
|
||||
})
|
||||
it('retains per-image limits while removing the legacy aggregate-byte cap', async () => {
|
||||
const entries = [1, 2, 3].map((id) => ({ ...metadata[0], id: String(id) }))
|
||||
expect(
|
||||
await loadIssueImageAttachments(client, entries, ['1', '2', '3'], 'reference')
|
||||
).toHaveLength(3)
|
||||
const oversized = [{ ...metadata[0], size: JIRA_IMAGE_MAX_BYTES + 1 }]
|
||||
expect(await loadIssueImageAttachments(client, oversized, ['1'], 'reference')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
it('discovers larger images by filename when rendered HTML has no attachment IDs', async () => {
|
||||
const { collectIssueMediaRequest } = await import('./jira-issue-media')
|
||||
const raw = {
|
||||
key: 'JIRA-1',
|
||||
fields: {
|
||||
attachment: metadata,
|
||||
description: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'mediaSingle',
|
||||
content: [{ type: 'media', attrs: { id: 'media-id', alt: 'shot.png' } }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(collectIssueMediaRequest(raw)?.preferredIds).toEqual([])
|
||||
expect(collectIssueMediaRequest(raw, 'reference')?.preferredIds).toEqual(['1'])
|
||||
})
|
||||
|
||||
it('keeps the old per-image cap when an old client reuses a newer byte-cache entry', async () => {
|
||||
const { loadAttachmentBytesWithCache } = await import('./attachment-image-cache')
|
||||
_resetAttachmentImageCache()
|
||||
await loadAttachmentBytesWithCache({
|
||||
siteId: client.site.id,
|
||||
attachmentId: '1',
|
||||
load: async () => ({ data: new Uint8Array(3 * 1024 * 1024), mimeType: 'image/png' })
|
||||
})
|
||||
expect(await loadIssueImageAttachments(client, [{ ...metadata[0], size: 0 }], ['1'])).toEqual([])
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { JiraClientForSite } from './authenticated-request'
|
||||
import { JiraApiError, apiBasePath, jiraRequestBinary } from './authenticated-request'
|
||||
import { attachmentContentUrl } from './attachment-content-url'
|
||||
import { JiraApiError, jiraRequestBinary } from './authenticated-request'
|
||||
import type { JiraAdfMediaAttrs, JiraAdfMediaResolver } from './adf-markdown'
|
||||
import { escapeMarkdownAlt, unresolvedMediaPlaceholder } from './adf-markdown'
|
||||
import { escapeMarkdownLinkDestination } from './adf-media-destination'
|
||||
@@ -13,6 +14,12 @@ import {
|
||||
type AttachmentMeta
|
||||
} from './attachment-meta'
|
||||
import { mapWithConcurrency } from '../../shared/map-with-concurrency'
|
||||
import {
|
||||
JIRA_IMAGE_MAX_BYTES,
|
||||
JIRA_IMAGE_MAX_REFERENCES,
|
||||
type JiraMediaFormat
|
||||
} from '../../shared/jira-attachment-image'
|
||||
import { registerAttachmentImageGrant } from './attachment-image-grants'
|
||||
|
||||
const DOWNLOAD_CONCURRENCY = 3
|
||||
|
||||
@@ -38,16 +45,9 @@ async function downloadImageAttachment(
|
||||
attachmentId: meta.id,
|
||||
load: async () => {
|
||||
try {
|
||||
const contentUrl = meta.contentUrl
|
||||
? new URL(meta.contentUrl, `${client.site.siteUrl}/`)
|
||||
: // Why: Cloud fallback uses apiBasePath so Server sites that lack contentUrl
|
||||
// still hit /rest/api/2 if ever called; Server still requires content metadata.
|
||||
new URL(
|
||||
`${apiBasePath(client.site)}/attachment/content/${encodeURIComponent(meta.id)}`,
|
||||
client.site.siteUrl
|
||||
)
|
||||
if (/\/rest\/api\/(?:2|3)\/attachment\/content\/[^/]+$/i.test(contentUrl.pathname)) {
|
||||
contentUrl.searchParams.set('redirect', 'false')
|
||||
const contentUrl = attachmentContentUrl(client.site, meta)
|
||||
if (!contentUrl) {
|
||||
return null
|
||||
}
|
||||
const binary = await jiraRequestBinary(client, contentUrl.toString())
|
||||
if (binary.data.byteLength === 0 || binary.data.byteLength > MAX_IMAGE_BYTES) {
|
||||
@@ -83,6 +83,9 @@ async function downloadImageAttachment(
|
||||
// Approximate byte size from base64 payload when served from cache.
|
||||
const base64Part = dataUrl.includes(',') ? dataUrl.slice(dataUrl.indexOf(',') + 1) : ''
|
||||
const byteSize = Math.floor((base64Part.length * 3) / 4)
|
||||
if (byteSize === 0 || byteSize > MAX_IMAGE_BYTES) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: meta.id,
|
||||
@@ -96,9 +99,13 @@ async function downloadImageAttachment(
|
||||
export async function loadIssueImageAttachments(
|
||||
client: JiraClientForSite,
|
||||
attachmentField: unknown,
|
||||
preferredIds: string[] = []
|
||||
preferredIds: string[] = [],
|
||||
mediaFormat?: JiraMediaFormat
|
||||
): Promise<JiraImageAttachment[]> {
|
||||
const metas = parseImageAttachmentMetas(attachmentField)
|
||||
const metas = parseImageAttachmentMetas(
|
||||
attachmentField,
|
||||
mediaFormat ? JIRA_IMAGE_MAX_BYTES : MAX_IMAGE_BYTES
|
||||
)
|
||||
if (metas.length === 0 || preferredIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
@@ -115,6 +122,16 @@ export async function loadIssueImageAttachments(
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaFormat === 'reference') {
|
||||
return ordered.slice(0, JIRA_IMAGE_MAX_REFERENCES).map((meta) => ({
|
||||
id: meta.id,
|
||||
filename: meta.filename,
|
||||
mimeType: meta.mimeType,
|
||||
byteSize: meta.size,
|
||||
dataUrl: registerAttachmentImageGrant(client, meta)
|
||||
}))
|
||||
}
|
||||
|
||||
// Why: pre-select by declared size so concurrent downloads do not fetch bodies
|
||||
// that will be dropped by the total budget after completion.
|
||||
const toDownload: AttachmentMeta[] = []
|
||||
@@ -149,7 +166,7 @@ export async function loadIssueImageAttachments(
|
||||
}
|
||||
|
||||
export type MediaResolutionStats = {
|
||||
/** Attachment-needing media nodes that successfully resolved to a data: image. */
|
||||
/** Attachment-needing media nodes that resolved to an image destination. */
|
||||
attachmentResolvedCount: number
|
||||
}
|
||||
|
||||
@@ -202,7 +219,7 @@ export function createMediaMarkdownResolver(
|
||||
if (attrs.id) {
|
||||
const cached = resolvedByMediaId.get(attrs.id)
|
||||
if (cached) {
|
||||
if (stats && !cached.startsWith('*[') && cached.includes('data:')) {
|
||||
if (stats && cached.startsWith('![')) {
|
||||
stats.attachmentResolvedCount += 1
|
||||
}
|
||||
return cached
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { readAttachmentImageBytes } from './attachment-image-bytes'
|
||||
import { loadIssueImageAttachments, type JiraImageAttachment } from './attachment-images'
|
||||
import * as cache from './attachment-image-cache'
|
||||
import type { JiraClientForSite } from './authenticated-request'
|
||||
|
||||
const { binary } = vi.hoisted(() => ({ binary: vi.fn() }))
|
||||
vi.mock('./authenticated-request', () => ({
|
||||
apiBasePath: () => '/rest/api/3',
|
||||
jiraRequestBinary: binary,
|
||||
JiraApiError: class extends Error {}
|
||||
}))
|
||||
|
||||
const client: JiraClientForSite = {
|
||||
site: { id: 'site', siteUrl: 'https://jira.test', email: '', accountId: '', displayName: '' },
|
||||
authorization: 'fixture-token'
|
||||
}
|
||||
const meta = { id: '1', filename: 'shot.png', mimeType: 'image/png', size: 3 }
|
||||
const bytes = Uint8Array.from([1, 2, 3])
|
||||
|
||||
beforeEach(() => {
|
||||
binary.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cache._resetAttachmentImageCache()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it.each(['cancellable-first', 'legacy-first'] as const)(
|
||||
'retains a legacy inline image after new reader cancellation (%s)',
|
||||
async (order) => {
|
||||
const finish: (() => void)[] = []
|
||||
binary.mockImplementation(
|
||||
(_client, _url, options?: { signal?: AbortSignal }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
finish.push(() => resolve({ data: bytes.buffer, contentType: 'image/png' }))
|
||||
options?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => reject(new Error('new reader cancelled')),
|
||||
{
|
||||
once: true
|
||||
}
|
||||
)
|
||||
})
|
||||
)
|
||||
const legacyLoading = vi.spyOn(cache, 'loadAttachmentDataUrlWithCache')
|
||||
const controller = new AbortController()
|
||||
const readLegacy = (): Promise<{ images: JiraImageAttachment[] } | { error: unknown }> =>
|
||||
loadIssueImageAttachments(client, [meta], ['1']).then(
|
||||
(images) => ({ images }),
|
||||
(error) => ({ error })
|
||||
)
|
||||
const readNew = () =>
|
||||
readAttachmentImageBytes(client, meta, controller.signal).catch(() => null)
|
||||
const first = order === 'cancellable-first' ? readNew() : readLegacy()
|
||||
await vi.waitFor(() => expect(binary).toHaveBeenCalledTimes(1))
|
||||
const second = order === 'cancellable-first' ? readLegacy() : readNew()
|
||||
const newReading = order === 'cancellable-first' ? first : second
|
||||
const legacyReading = order === 'cancellable-first' ? second : first
|
||||
await vi.waitFor(() => expect(legacyLoading).toHaveBeenCalledTimes(1))
|
||||
await vi.waitFor(() => expect(binary).toHaveBeenCalledTimes(2))
|
||||
controller.abort()
|
||||
await newReading
|
||||
finish.forEach((resolve) => resolve())
|
||||
expect(await legacyReading).toMatchObject({
|
||||
images: [{ id: '1', dataUrl: 'data:image/png;base64,AQID' }]
|
||||
})
|
||||
expect(await readAttachmentImageBytes(client, meta)).toEqual({
|
||||
data: bytes,
|
||||
mimeType: 'image/png'
|
||||
})
|
||||
expect(binary).toHaveBeenCalledTimes(2)
|
||||
}
|
||||
)
|
||||
@@ -26,7 +26,10 @@ export function isImageMimeType(mimeType: string): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
export function parseImageAttachmentMetas(attachmentField: unknown): AttachmentMeta[] {
|
||||
export function parseImageAttachmentMetas(
|
||||
attachmentField: unknown,
|
||||
maxBytes = MAX_IMAGE_BYTES
|
||||
): AttachmentMeta[] {
|
||||
if (!Array.isArray(attachmentField)) {
|
||||
return []
|
||||
}
|
||||
@@ -40,7 +43,7 @@ export function parseImageAttachmentMetas(attachmentField: unknown): AttachmentM
|
||||
if (!id || !isImageMimeType(mimeType)) {
|
||||
continue
|
||||
}
|
||||
if (size > MAX_IMAGE_BYTES) {
|
||||
if (size > maxBytes) {
|
||||
continue
|
||||
}
|
||||
const contentUrl = asString(record.content)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { jiraRequestBinary, type JiraClientForSite } from './authenticated-request'
|
||||
|
||||
const { fetchImage } = vi.hoisted(() => ({ fetchImage: vi.fn() }))
|
||||
vi.mock('../network/http-client', () => ({
|
||||
getMainHttpClient: () => ({ fetch: fetchImage, proxySession: () => null })
|
||||
}))
|
||||
vi.mock('../network/proxy-settings', () => ({
|
||||
ensureElectronProxyFromEnvironment: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
vi.mock('../observability/tracer', () => ({
|
||||
withSpan: (
|
||||
_name: string,
|
||||
run: (span: { setAttribute: () => void; addEvent: () => void }) => Promise<unknown>
|
||||
) => run({ setAttribute: () => {}, addEvent: () => {} })
|
||||
}))
|
||||
const client: JiraClientForSite = {
|
||||
site: { id: 'site', siteUrl: 'https://jira.test', email: '', accountId: '', displayName: '' },
|
||||
authorization: 'secret'
|
||||
}
|
||||
beforeEach(() => fetchImage.mockReset())
|
||||
|
||||
describe('bounded Jira attachment downloads', () => {
|
||||
it('rejects oversized declared bodies before consuming any bytes', async () => {
|
||||
const cancel = vi.fn()
|
||||
const response = new Response(new ReadableStream({ cancel }), {
|
||||
headers: { 'content-length': '9' }
|
||||
})
|
||||
const arrayBuffer = vi.spyOn(response, 'arrayBuffer')
|
||||
fetchImage.mockResolvedValue(response)
|
||||
await expect(jiraRequestBinary(client, '/image', { maxBytes: 8 })).rejects.toThrow('byte limit')
|
||||
expect(cancel).toHaveBeenCalledOnce()
|
||||
expect(arrayBuffer).not.toHaveBeenCalled()
|
||||
})
|
||||
it('caps chunked bodies even when Content-Length is missing', async () => {
|
||||
let part = 0
|
||||
const cancel = vi.fn()
|
||||
const response = new Response(
|
||||
new ReadableStream({
|
||||
pull(controller) {
|
||||
controller.enqueue(Uint8Array.from([++part, 0, 0]))
|
||||
},
|
||||
cancel
|
||||
})
|
||||
)
|
||||
fetchImage.mockResolvedValue(response)
|
||||
await expect(jiraRequestBinary(client, '/image', { maxBytes: 8 })).rejects.toThrow('byte limit')
|
||||
expect(cancel).toHaveBeenCalledOnce()
|
||||
expect(part).toBeLessThanOrEqual(4)
|
||||
})
|
||||
it('returns bounded bytes and forwards caller cancellation to the HTTP client', async () => {
|
||||
const controller = new AbortController()
|
||||
fetchImage.mockResolvedValue(
|
||||
new Response(Uint8Array.from([1, 2, 3]), { headers: { 'content-type': 'image/png' } })
|
||||
)
|
||||
expect(
|
||||
await jiraRequestBinary(client, '/image', { maxBytes: 8, signal: controller.signal })
|
||||
).toEqual({ data: Uint8Array.from([1, 2, 3]).buffer, contentType: 'image/png' })
|
||||
expect(fetchImage).toHaveBeenCalledWith(
|
||||
'https://jira.test/image',
|
||||
expect.objectContaining({ signal: controller.signal })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import { isJiraGatewayBaseUrl } from './gateway-base-url'
|
||||
import { JiraApiError, toJiraApiError } from './jira-api-error'
|
||||
|
||||
export { JiraApiError } from './jira-api-error'
|
||||
import { readFetchResponseBytesWithinLimit } from '../../shared/fetch-response-body'
|
||||
|
||||
// Why: Atlassian's XSRF filter rejects POST/PUT REST calls that carry a browser
|
||||
// User-Agent, failing them with "XSRF check failed" even under API-token auth.
|
||||
@@ -177,7 +178,8 @@ function resolveBinaryRequestUrl(site: JiraSite, pathOrUrl: string): URL {
|
||||
|
||||
export async function jiraRequestBinary(
|
||||
client: JiraClientForSite,
|
||||
pathOrUrl: string
|
||||
pathOrUrl: string,
|
||||
options: { maxBytes?: number; signal?: AbortSignal } = {}
|
||||
): Promise<{ data: ArrayBuffer; contentType: string }> {
|
||||
const requestUrl = resolveBinaryRequestUrl(client.site, pathOrUrl)
|
||||
const headers = new Headers()
|
||||
@@ -188,14 +190,19 @@ export async function jiraRequestBinary(
|
||||
headers.set('Authorization', client.authorization)
|
||||
const response = await jiraFetch(requestUrl.toString(), {
|
||||
headers,
|
||||
signal: options.signal,
|
||||
...(isJiraGatewayBaseUrl(apiBaseUrl(client.site)) ? { redirect: 'error' as const } : {})
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw await toJiraApiError(response, requestUrl.toString())
|
||||
}
|
||||
const contentType = response.headers.get('content-type') || 'application/octet-stream'
|
||||
const bytes = await readFetchResponseBytesWithinLimit(
|
||||
response,
|
||||
options.maxBytes ?? 2 * 1024 * 1024
|
||||
)
|
||||
return {
|
||||
data: await response.arrayBuffer(),
|
||||
data: Uint8Array.from(bytes).buffer,
|
||||
contentType
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,16 +98,19 @@ describe('scoped Jira Cloud tokens over HTTP', () => {
|
||||
dataUrl: 'data:image/png;base64,AQ==',
|
||||
byteSize: 1
|
||||
})
|
||||
const epoch = cache.attachmentImagesEpoch(client.site.id)
|
||||
authorized = false
|
||||
await expect(
|
||||
jira.connect({ ...CREDENTIAL, apiToken: 'rejected-token' })
|
||||
).resolves.toMatchObject({ ok: false })
|
||||
expect(cache.getCachedAttachmentDataUrl(client.site.id, 'image')).not.toBeNull()
|
||||
expect(cache.attachmentImagesEpoch(client.site.id)).toBe(epoch)
|
||||
authorized = true
|
||||
await expect(
|
||||
jira.connect({ ...CREDENTIAL, apiToken: 'replacement-token' })
|
||||
).resolves.toMatchObject({ ok: true })
|
||||
expect(cache.getCachedAttachmentDataUrl(client.site.id, 'image')).toBeNull()
|
||||
expect(cache.attachmentImagesEpoch(client.site.id)).toBeGreaterThan(epoch)
|
||||
expect(jira.getClients()[0]?.authorization).toBe(
|
||||
`Basic ${Buffer.from('ada@example.com:replacement-token').toString('base64')}`
|
||||
)
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { requireStrictJiraSite } from './jira-read-failure'
|
||||
import type { JiraComment } from '../../shared/jira-types'
|
||||
import {
|
||||
JIRA_IMAGE_MAX_BYTES,
|
||||
JIRA_IMAGE_MAX_REFERENCES,
|
||||
type JiraMediaFormat
|
||||
} from '../../shared/jira-attachment-image'
|
||||
import { acquire, release } from './request-queue'
|
||||
import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request'
|
||||
import { clearToken, getClients, isAuthError } from './client'
|
||||
@@ -39,7 +44,8 @@ function mapComment(raw: JiraRecord, adfOptions?: AdfToMarkdownOptions): JiraCom
|
||||
async function collectCommentMediaRequest(
|
||||
client: JiraClientForSite,
|
||||
key: string,
|
||||
comments: JiraRecord[]
|
||||
comments: JiraRecord[],
|
||||
mediaFormat?: JiraMediaFormat
|
||||
): Promise<MediaRequest | undefined> {
|
||||
const htmlIds: string[] = []
|
||||
const seen = new Set<string>()
|
||||
@@ -80,7 +86,8 @@ async function collectCommentMediaRequest(
|
||||
const selection = selectPreferredAttachmentIds({
|
||||
renderedHtmlIds: htmlIds,
|
||||
attachmentField,
|
||||
mediaAttrs
|
||||
mediaAttrs,
|
||||
...(mediaFormat ? { maxBytes: JIRA_IMAGE_MAX_BYTES, maxImages: JIRA_IMAGE_MAX_REFERENCES } : {})
|
||||
})
|
||||
if (selection.needCount === 0 && selection.preferredIds.length === 0) {
|
||||
return undefined
|
||||
@@ -97,7 +104,8 @@ async function collectCommentMediaRequest(
|
||||
export async function getIssueComments(
|
||||
key: string,
|
||||
siteId?: string | null,
|
||||
strict = false
|
||||
strict = false,
|
||||
mediaFormat?: JiraMediaFormat
|
||||
): Promise<JiraComment[]> {
|
||||
const entries = getClients(siteId)
|
||||
requireStrictJiraSite(entries, siteId, strict)
|
||||
@@ -121,7 +129,7 @@ export async function getIssueComments(
|
||||
})
|
||||
return `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/comment?${params.toString()}`
|
||||
})
|
||||
mediaRequest = await collectCommentMediaRequest(entry, key, comments)
|
||||
mediaRequest = await collectCommentMediaRequest(entry, key, comments, mediaFormat)
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.site.id)
|
||||
@@ -140,7 +148,9 @@ export async function getIssueComments(
|
||||
}
|
||||
|
||||
try {
|
||||
const prepared = mediaRequest ? await prepareMediaResolver(entry, mediaRequest) : undefined
|
||||
const prepared = mediaRequest
|
||||
? await prepareMediaResolver(entry, mediaRequest, mediaFormat)
|
||||
: undefined
|
||||
const mapped = comments.map((comment) => mapComment(comment, prepared?.options))
|
||||
if (prepared) {
|
||||
flushMediaResolutionWarn(entry, prepared)
|
||||
|
||||
@@ -11,6 +11,11 @@ import {
|
||||
} from './attachment-images'
|
||||
import type { JiraClientForSite } from './authenticated-request'
|
||||
import { asRecord, asString, type JiraRecord } from './jira-record-pages'
|
||||
import {
|
||||
JIRA_IMAGE_MAX_BYTES,
|
||||
JIRA_IMAGE_MAX_REFERENCES,
|
||||
type JiraMediaFormat
|
||||
} from '../../shared/jira-attachment-image'
|
||||
|
||||
export type MediaRequest = {
|
||||
attachmentField: unknown
|
||||
@@ -21,7 +26,10 @@ export type MediaRequest = {
|
||||
}
|
||||
|
||||
/** Pooled: HTML/ADF selection only — no binary downloads. */
|
||||
export function collectIssueMediaRequest(raw: JiraRecord): MediaRequest | undefined {
|
||||
export function collectIssueMediaRequest(
|
||||
raw: JiraRecord,
|
||||
mediaFormat?: JiraMediaFormat
|
||||
): MediaRequest | undefined {
|
||||
const fields = asRecord(raw.fields)
|
||||
const renderedFields = asRecord(raw.renderedFields)
|
||||
const htmlIds = extractAttachmentContentIdsFromHtml(
|
||||
@@ -31,7 +39,8 @@ export function collectIssueMediaRequest(raw: JiraRecord): MediaRequest | undefi
|
||||
const selection = selectPreferredAttachmentIds({
|
||||
renderedHtmlIds: htmlIds,
|
||||
attachmentField: fields.attachment,
|
||||
mediaAttrs
|
||||
mediaAttrs,
|
||||
...(mediaFormat ? { maxBytes: JIRA_IMAGE_MAX_BYTES, maxImages: JIRA_IMAGE_MAX_REFERENCES } : {})
|
||||
})
|
||||
if (selection.needCount === 0 && selection.preferredIds.length === 0) {
|
||||
return undefined
|
||||
@@ -54,7 +63,8 @@ export type PreparedMedia = {
|
||||
/** Unpooled: binary downloads + resolver (outside the Jira API semaphore). */
|
||||
export async function prepareMediaResolver(
|
||||
client: JiraClientForSite,
|
||||
request: MediaRequest
|
||||
request: MediaRequest,
|
||||
mediaFormat?: JiraMediaFormat
|
||||
): Promise<PreparedMedia | undefined> {
|
||||
if (request.preferredIds.length === 0) {
|
||||
warnIfMediaResolutionIncomplete({
|
||||
@@ -70,7 +80,8 @@ export async function prepareMediaResolver(
|
||||
const images = await loadIssueImageAttachments(
|
||||
client,
|
||||
request.attachmentField,
|
||||
request.preferredIds
|
||||
request.preferredIds,
|
||||
mediaFormat
|
||||
)
|
||||
if (images.length === 0) {
|
||||
warnIfMediaResolutionIncomplete({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { JiraIssue, JiraSiteSelection } from '../../shared/jira-types'
|
||||
import type { JiraMediaFormat } from '../../shared/jira-attachment-image'
|
||||
import { JiraSummaryLookupError } from '../../shared/jira-summary-lookup'
|
||||
import { acquire, release } from './request-queue'
|
||||
import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request'
|
||||
@@ -30,7 +31,8 @@ const ISSUE_SUMMARY_TIMEOUT_MS = 30_000
|
||||
export async function getIssue(
|
||||
key: string,
|
||||
siteId?: JiraSiteSelection | null,
|
||||
strict = false
|
||||
strict = false,
|
||||
mediaFormat?: JiraMediaFormat
|
||||
): Promise<JiraIssue | null> {
|
||||
const entries = getClients(siteId)
|
||||
requireStrictJiraSite(entries, siteId, strict)
|
||||
@@ -52,7 +54,7 @@ export async function getIssue(
|
||||
`${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}?${params.toString()}`
|
||||
)
|
||||
// Why: keep only JSON under the pool; binary downloads fan out after release.
|
||||
mediaRequest = collectIssueMediaRequest(issue)
|
||||
mediaRequest = collectIssueMediaRequest(issue, mediaFormat)
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.site.id)
|
||||
@@ -77,7 +79,9 @@ export async function getIssue(
|
||||
if (!issue) {
|
||||
continue
|
||||
}
|
||||
const prepared = mediaRequest ? await prepareMediaResolver(entry, mediaRequest) : undefined
|
||||
const prepared = mediaRequest
|
||||
? await prepareMediaResolver(entry, mediaRequest, mediaFormat)
|
||||
: undefined
|
||||
const mapped = mapJiraIssue(entry.site, issue, prepared?.options, agileFields)
|
||||
if (prepared) {
|
||||
flushMediaResolutionWarn(entry, prepared)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import '../unused-default-rpc-methods.test-fixture'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { JIRA_ATTACHMENT_IMAGE_METHODS } from './jira-attachment-image'
|
||||
import {
|
||||
registerAttachmentImageGrant,
|
||||
resetAttachmentImageGrantsForTest
|
||||
} from '../../../jira/attachment-image-grants'
|
||||
import {
|
||||
clearAttachmentImagesForSite,
|
||||
_resetAttachmentImageCache
|
||||
} from '../../../jira/attachment-image-cache'
|
||||
import { decodeJiraImageFrame } from '../../../../shared/jira-image-stream-frame'
|
||||
|
||||
const { read } = vi.hoisted(() => ({ read: vi.fn() }))
|
||||
vi.mock('../../../jira/attachment-image-read', () => ({ readGrantedAttachmentImage: read }))
|
||||
const transferId = '12345678-1234-4234-8234-123456789012'
|
||||
const runtime = { getRuntimeId: () => 'runtime' }
|
||||
const dispatcher = new RpcDispatcher({
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The image handler never calls runtime; this fixture implements the dispatcher's metadata read.
|
||||
runtime: runtime as unknown as OrcaRuntimeService,
|
||||
methods: JIRA_ATTACHMENT_IMAGE_METHODS
|
||||
})
|
||||
function request() {
|
||||
const url = registerAttachmentImageGrant(
|
||||
{
|
||||
site: { id: 'site', siteUrl: 'https://jira.test', email: '', accountId: '', displayName: '' },
|
||||
authorization: 'secret'
|
||||
},
|
||||
{ id: '1', filename: 'image.png', mimeType: 'image/png', size: 1 }
|
||||
)
|
||||
return {
|
||||
id: 'image',
|
||||
authToken: 'token',
|
||||
method: 'jira.attachmentImage',
|
||||
params: { reference: url.split('/').at(-1), transferId }
|
||||
}
|
||||
}
|
||||
beforeEach(() => {
|
||||
_resetAttachmentImageCache()
|
||||
resetAttachmentImageGrantsForTest()
|
||||
read.mockReset().mockResolvedValue({ data: new Uint8Array(130_000), mimeType: 'image/png' })
|
||||
})
|
||||
|
||||
describe('Jira image RPC carrier', () => {
|
||||
it('sends metadata, bounded addressed binary chunks and an end marker', async () => {
|
||||
const replies: string[] = []
|
||||
const frames: Uint8Array<ArrayBufferLike>[] = []
|
||||
await dispatcher.dispatchStreaming(request(), (reply) => replies.push(reply), {
|
||||
sendBinary: (bytes) => {
|
||||
frames.push(bytes)
|
||||
}
|
||||
})
|
||||
expect(replies[0]).toContain('"byteLength":130000')
|
||||
expect(replies.at(-1)).toContain('"type":"end"')
|
||||
expect(frames).toHaveLength(2)
|
||||
expect(decodeJiraImageFrame(frames[0] ?? new Uint8Array(), transferId)?.bytes.byteLength).toBe(
|
||||
64 * 1024
|
||||
)
|
||||
expect(decodeJiraImageFrame(frames[1] ?? new Uint8Array(), transferId)?.sequence).toBe(1)
|
||||
expect(replies.join('')).not.toContain('secret')
|
||||
})
|
||||
it.each(['cancel', 'revoke', 'refuse'] as const)(
|
||||
'stops sending chunks after %s',
|
||||
async (action) => {
|
||||
const replies: string[] = []
|
||||
const controller = new AbortController()
|
||||
const binary = vi.fn(() => {
|
||||
if (action === 'cancel') {
|
||||
controller.abort()
|
||||
}
|
||||
if (action === 'revoke') {
|
||||
clearAttachmentImagesForSite('site')
|
||||
}
|
||||
return action !== 'refuse'
|
||||
})
|
||||
await dispatcher.dispatchStreaming(request(), (reply) => replies.push(reply), {
|
||||
sendBinary: binary,
|
||||
signal: controller.signal
|
||||
})
|
||||
expect(binary).toHaveBeenCalledOnce()
|
||||
expect(replies.join('')).not.toContain('"type":"end"')
|
||||
expect(replies.at(-1)).toContain('"ok":false')
|
||||
}
|
||||
)
|
||||
it('requires a binary transport and rejects unknown grant references', async () => {
|
||||
const replies: string[] = []
|
||||
await dispatcher.dispatchStreaming(request(), (reply) => replies.push(reply))
|
||||
expect(replies.join('')).toContain('binary subscription transport')
|
||||
expect(read).not.toHaveBeenCalled()
|
||||
const unknown = request()
|
||||
unknown.params.reference = '00000000-0000-0000-0000-000000000000'
|
||||
await dispatcher.dispatchStreaming(unknown, (reply) => replies.push(reply), {
|
||||
sendBinary: vi.fn()
|
||||
})
|
||||
expect(replies.join('')).toContain('unavailable')
|
||||
expect(read).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { encodeJiraImageFrame } from '../../../../shared/jira-image-stream-frame'
|
||||
import { JIRA_IMAGE_CHUNK_BYTES } from '../../../../shared/jira-attachment-image'
|
||||
import { yieldToEventLoop } from '../../../../shared/event-loop-yield'
|
||||
import { throwIfSignalAborted } from '../../../../shared/abort-signal-reason'
|
||||
import {
|
||||
attachmentImageGrant,
|
||||
isAttachmentImageGrantCurrent
|
||||
} from '../../../jira/attachment-image-grants'
|
||||
import { readGrantedAttachmentImage } from '../../../jira/attachment-image-read'
|
||||
import { AttachmentImage } from '../../../../shared/rpc-contract/jira-params'
|
||||
import { defineStreamingMethod } from '../core'
|
||||
|
||||
export const JIRA_ATTACHMENT_IMAGE_METHODS = [
|
||||
defineStreamingMethod({
|
||||
name: 'jira.attachmentImage',
|
||||
permission: 'workspace',
|
||||
params: AttachmentImage,
|
||||
handler: async ({ reference, transferId }, { sendBinary, signal }, emit) => {
|
||||
if (!sendBinary) {
|
||||
throw new Error('Jira images require a binary subscription transport.')
|
||||
}
|
||||
const grant = attachmentImageGrant(reference)
|
||||
const image = grant && (await readGrantedAttachmentImage(reference, signal))
|
||||
if (!image || !grant) {
|
||||
throw new Error('Jira image is unavailable. Reload the issue to try again.')
|
||||
}
|
||||
emit({ type: 'image', mimeType: image.mimeType, byteLength: image.data.byteLength })
|
||||
for (let offset = 0; offset < image.data.byteLength; offset += JIRA_IMAGE_CHUNK_BYTES) {
|
||||
throwIfSignalAborted(signal)
|
||||
if (!isAttachmentImageGrantCurrent(reference, grant)) {
|
||||
throw new Error('Jira image access was revoked.')
|
||||
}
|
||||
const accepted = sendBinary(
|
||||
encodeJiraImageFrame(
|
||||
transferId,
|
||||
offset / JIRA_IMAGE_CHUNK_BYTES,
|
||||
image.data.subarray(offset, offset + JIRA_IMAGE_CHUNK_BYTES)
|
||||
)
|
||||
)
|
||||
if (accepted === false || accepted === 'backlogged') {
|
||||
throw new Error('Jira image transport closed.')
|
||||
}
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
throwIfSignalAborted(signal)
|
||||
if (!isAttachmentImageGrantCurrent(reference, grant)) {
|
||||
throw new Error('Jira image access was revoked.')
|
||||
}
|
||||
emit({ type: 'end' })
|
||||
}
|
||||
})
|
||||
]
|
||||
@@ -116,7 +116,7 @@ describe('jira RPC methods', () => {
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
expect(runtime.jiraGetIssue).toHaveBeenCalledWith('ABC-3', 'site-1', undefined)
|
||||
expect(runtime.jiraGetIssue).toHaveBeenCalledWith('ABC-3', 'site-1', undefined, undefined)
|
||||
expect(runtime.jiraLookupIssueSummary).toHaveBeenCalledWith(
|
||||
'ABC-3',
|
||||
'site-1',
|
||||
@@ -142,7 +142,7 @@ describe('jira RPC methods', () => {
|
||||
'site-1'
|
||||
)
|
||||
expect(runtime.jiraAddIssueComment).toHaveBeenCalledWith('ABC-3', 'Looks good', 'site-1')
|
||||
expect(runtime.jiraIssueComments).toHaveBeenCalledWith('ABC-3', 'site-1', undefined)
|
||||
expect(runtime.jiraIssueComments).toHaveBeenCalledWith('ABC-3', 'site-1', undefined, undefined)
|
||||
})
|
||||
|
||||
it('passes verified read requests and atomic label operations through RPC', async () => {
|
||||
@@ -158,10 +158,20 @@ describe('jira RPC methods', () => {
|
||||
methods: JIRA_METHODS
|
||||
})
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('jira.getIssue', { key: 'ABC-1', siteId: 'site-1', strict: true })
|
||||
makeRequest('jira.getIssue', {
|
||||
key: 'ABC-1',
|
||||
siteId: 'site-1',
|
||||
strict: true,
|
||||
mediaFormat: 'reference'
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('jira.issueComments', { key: 'ABC-1', siteId: 'site-1', strict: true })
|
||||
makeRequest('jira.issueComments', {
|
||||
key: 'ABC-1',
|
||||
siteId: 'site-1',
|
||||
strict: true,
|
||||
mediaFormat: 'reference'
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('jira.updateIssue', {
|
||||
@@ -170,8 +180,8 @@ describe('jira RPC methods', () => {
|
||||
updates: { labelOperations: { add: ['bug'] } }
|
||||
})
|
||||
)
|
||||
expect(runtime.jiraGetIssue).toHaveBeenCalledWith('ABC-1', 'site-1', true)
|
||||
expect(runtime.jiraIssueComments).toHaveBeenCalledWith('ABC-1', 'site-1', true)
|
||||
expect(runtime.jiraGetIssue).toHaveBeenCalledWith('ABC-1', 'site-1', true, 'reference')
|
||||
expect(runtime.jiraIssueComments).toHaveBeenCalledWith('ABC-1', 'site-1', true, 'reference')
|
||||
expect(runtime.jiraUpdateIssue).toHaveBeenCalledWith(
|
||||
'ABC-1',
|
||||
{ labelOperations: { add: ['bug'] } },
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
JIRA_PAYLOAD_MAX_CHARS
|
||||
} from '../../../../shared/jira-payload-stream'
|
||||
import { defineMethod, defineStreamingMethod, type RpcContext } from '../core'
|
||||
import { JIRA_ATTACHMENT_IMAGE_METHODS } from './jira-attachment-image'
|
||||
import {
|
||||
AssignableUsers,
|
||||
GenerateIssueSummary,
|
||||
@@ -51,6 +52,7 @@ function generationOwner(context: Pick<RpcContext, 'pairedDeviceId' | 'caller'>)
|
||||
}
|
||||
|
||||
export const JIRA_METHODS = [
|
||||
...JIRA_ATTACHMENT_IMAGE_METHODS,
|
||||
defineMethod({
|
||||
name: 'jira.generateIssueSummary',
|
||||
permission: 'workspace',
|
||||
@@ -132,7 +134,7 @@ export const JIRA_METHODS = [
|
||||
permission: 'workspace',
|
||||
params: IssueKey,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.jiraGetIssue(params.key.trim(), params.siteId, params.strict)
|
||||
runtime.jiraGetIssue(params.key.trim(), params.siteId, params.strict, params.mediaFormat)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'jira.lookupIssueSummary',
|
||||
@@ -151,7 +153,12 @@ export const JIRA_METHODS = [
|
||||
params: IssueKey,
|
||||
handler: async (params, { runtime }, emit) => {
|
||||
emitJiraPayload(
|
||||
await runtime.jiraGetIssue(params.key.trim(), params.siteId, params.strict),
|
||||
await runtime.jiraGetIssue(
|
||||
params.key.trim(),
|
||||
params.siteId,
|
||||
params.strict,
|
||||
params.mediaFormat
|
||||
),
|
||||
emit
|
||||
)
|
||||
}
|
||||
@@ -190,7 +197,7 @@ export const JIRA_METHODS = [
|
||||
permission: 'workspace',
|
||||
params: IssueKey,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.jiraIssueComments(params.key.trim(), params.siteId, params.strict)
|
||||
runtime.jiraIssueComments(params.key.trim(), params.siteId, params.strict, params.mediaFormat)
|
||||
}),
|
||||
defineStreamingMethod({
|
||||
name: 'jira.issueCommentsStream',
|
||||
@@ -198,7 +205,12 @@ export const JIRA_METHODS = [
|
||||
params: IssueKey,
|
||||
handler: async (params, { runtime }, emit) => {
|
||||
emitJiraPayload(
|
||||
await runtime.jiraIssueComments(params.key.trim(), params.siteId, params.strict),
|
||||
await runtime.jiraIssueComments(
|
||||
params.key.trim(),
|
||||
params.siteId,
|
||||
params.strict,
|
||||
params.mediaFormat
|
||||
),
|
||||
emit
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
JiraIssueUpdate,
|
||||
JiraSiteSelection
|
||||
} from '../../shared/jira-types'
|
||||
import type { JiraMediaFormat } from '../../shared/jira-attachment-image'
|
||||
import { connect, disconnect, getStatus, selectSite, testConnection } from '../jira/client'
|
||||
import {
|
||||
addIssueComment,
|
||||
@@ -103,8 +104,13 @@ export class RuntimeJiraCommands {
|
||||
return createIssue(args)
|
||||
}
|
||||
|
||||
jiraGetIssue(key: string, siteId?: string, strict = false): ReturnType<typeof getIssue> {
|
||||
return getIssue(key, siteId, strict)
|
||||
jiraGetIssue(
|
||||
key: string,
|
||||
siteId?: string,
|
||||
strict = false,
|
||||
mediaFormat?: JiraMediaFormat
|
||||
): ReturnType<typeof getIssue> {
|
||||
return getIssue(key, siteId, strict, mediaFormat)
|
||||
}
|
||||
|
||||
jiraLookupIssueSummary(
|
||||
@@ -134,9 +140,10 @@ export class RuntimeJiraCommands {
|
||||
jiraIssueComments(
|
||||
key: string,
|
||||
siteId?: string,
|
||||
strict = false
|
||||
strict = false,
|
||||
mediaFormat?: JiraMediaFormat
|
||||
): ReturnType<typeof getIssueComments> {
|
||||
return getIssueComments(key, siteId, strict)
|
||||
return getIssueComments(key, siteId, strict, mediaFormat)
|
||||
}
|
||||
|
||||
jiraListProjects(siteId?: JiraSiteSelection, strict = false): ReturnType<typeof listProjects> {
|
||||
|
||||
@@ -43,6 +43,7 @@ export const PAIRED_DESKTOP_METHODS = [
|
||||
'accounts.subscribe',
|
||||
'accounts.unsubscribe',
|
||||
// Jira credentials and issue mutations remain desktop-only.
|
||||
'jira.attachmentImage',
|
||||
'jira.getIssue',
|
||||
'jira.issueComments',
|
||||
'jira.listIssues',
|
||||
|
||||
@@ -86,6 +86,7 @@ import { initOpenCodeUsagePath } from '../opencode-usage/store'
|
||||
import { initMuseUsagePath } from '../muse-usage/store'
|
||||
import { registerDocPreviewSchemePrivileges } from '../browser/doc-preview-protocol'
|
||||
import { MEDIA_PREVIEW_CUSTOM_SCHEME } from '../media/media-preview-protocol'
|
||||
import { JIRA_IMAGE_CUSTOM_SCHEME } from '../jira/attachment-image-protocol'
|
||||
import { startCrashpadCapture } from '../crash-reporting/crashpad-capture'
|
||||
import { CrashReportStore } from '../crash-reporting/crash-report-store'
|
||||
import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store'
|
||||
@@ -207,8 +208,7 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b
|
||||
// Why before any spawn: `signalProcessTree` is shared with the CLI and relay, so
|
||||
// it can only reach the main-process guard and breadcrumb store once this is registered.
|
||||
installMainProcessTreeKillGate()
|
||||
const isDev = is.dev
|
||||
configureDevUserDataPath(isDev)
|
||||
configureDevUserDataPath(is.dev)
|
||||
configureOrcaUserDataPathEnv()
|
||||
// Why these four lines are one step (#16761): the two above decide where userData lives, and
|
||||
// everything below may resolve a path. Installing the accessor any later leaves a window where an
|
||||
@@ -253,8 +253,8 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b
|
||||
startMainThreadChurnProbe({ extraStats: () => ({ diffCache: settledDiffCache.stats() }) })
|
||||
// Why: acquire AFTER configureDevUserDataPath — Electron derives lock identity from `userData`, so dev/packaged lock in separate namespaces.
|
||||
// Why dev locks too: two processes on one profile corrupt its stores (PR #1326 / #1312); parallel `pnpm dev` needs ORCA_DEV_USER_DATA_PATH per copy.
|
||||
const bypass = shouldBypassSingleInstanceLock({ isDev, isServeMode: state.isServeMode })
|
||||
const skip = shouldSkipSingleInstanceLock({ isDev, isServeMode: state.isServeMode })
|
||||
const bypass = shouldBypassSingleInstanceLock({ isDev: is.dev, isServeMode: state.isServeMode })
|
||||
const skip = shouldSkipSingleInstanceLock({ isDev: is.dev, isServeMode: state.isServeMode })
|
||||
if (bypass) {
|
||||
// Why: diagnostic escape hatch for macOS builds where Electron reports a false lock loss before any app logs exist.
|
||||
logSingleInstanceLockBypass()
|
||||
@@ -271,7 +271,7 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b
|
||||
// Why: a false-negative lock loss otherwise looks like a silent crash on packaged macOS; `open --stderr` can capture this line.
|
||||
// In dev it is the line `pnpm dev` prints before exiting.
|
||||
logSingleInstanceLockFailure({
|
||||
isDevDesktop: isDev && !state.isServeMode,
|
||||
isDevDesktop: is.dev && !state.isServeMode,
|
||||
userDataPath: app.getPath('userData')
|
||||
})
|
||||
// Why: a graceful quit is deferred pre-ready, so this launch would still walk into Linux display init and SIGSEGV (#11935).
|
||||
@@ -325,7 +325,7 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b
|
||||
setSpeechServiceFactories(electronSpeechServiceFactories)
|
||||
setWorktreeWatcherRemoval(desktopWorktreeWatcherRemoval)
|
||||
// Why: couple to dev-parent only for electron-vite desktop runs; `orca serve`'s parent (CLI shim/background shell) isn't the intended server lifetime.
|
||||
const shouldCoupleToDevParent = isDev && !state.isServeMode
|
||||
const shouldCoupleToDevParent = is.dev && !state.isServeMode
|
||||
installDevParentDisconnectQuit(shouldCoupleToDevParent)
|
||||
installDevParentWatchdog(shouldCoupleToDevParent)
|
||||
installDevParentSignalQuit(shouldCoupleToDevParent)
|
||||
@@ -360,7 +360,7 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b
|
||||
initMuseUsagePath()
|
||||
// Why: Electron freezes the privileged scheme table at ready, so the doc-preview
|
||||
// scheme must be declared here or its webview loses fetch/secure-origin privileges.
|
||||
registerDocPreviewSchemePrivileges([MEDIA_PREVIEW_CUSTOM_SCHEME])
|
||||
registerDocPreviewSchemePrivileges([MEDIA_PREVIEW_CUSTOM_SCHEME, JIRA_IMAGE_CUSTOM_SCHEME])
|
||||
// Why: must precede app.whenReady() so Crashpad is installed before the
|
||||
// first renderer spawns; a CHECK before this point is still exit-code-only.
|
||||
startCrashpadCapture()
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from '../browser/browser-session-proxy'
|
||||
import { installDocPreviewProtocolHandler } from '../browser/doc-preview-protocol'
|
||||
import { installMediaPreviewProtocolHandler } from '../media/media-preview-protocol'
|
||||
import { installJiraImageProtocolHandler } from '../jira/attachment-image-protocol'
|
||||
import { registerDocPreviewGrantHandlers } from '../ipc/doc-preview-grant-ipc'
|
||||
import { initializeBrowserSessionsForApp } from '../browser/browser-session-startup'
|
||||
import { browserSessionRegistry } from '../browser/browser-session-registry'
|
||||
@@ -259,6 +260,7 @@ export async function initializeReadyFoundation(): Promise<void> {
|
||||
setBrowserNetworkProxySettingsResolver(() => state.store!.getSettings())
|
||||
// Why: the preview session is protocol-scoped, so the handler must exist before any preview webview attaches.
|
||||
installMediaPreviewProtocolHandler()
|
||||
installJiraImageProtocolHandler()
|
||||
installDocPreviewProtocolHandler()
|
||||
registerDocPreviewGrantHandlers()
|
||||
// Why: browser sessions serve desktop webviews and runtime profile commands, so init at app startup rather than via a renderer IPC path.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { JiraMediaFormat } from '../../shared/jira-attachment-image'
|
||||
import type {
|
||||
JiraIssueSummaryGenerationRequest,
|
||||
JiraIssueSummaryGenerationCancellation,
|
||||
@@ -49,7 +50,11 @@ export type JiraApi = {
|
||||
siteId?: JiraSiteSelection
|
||||
filters?: JiraIssueListFilters
|
||||
}) => Promise<JiraIssue[]>
|
||||
getIssue: (args: { key: string; siteId?: string }) => Promise<JiraIssue | null>
|
||||
getIssue: (args: {
|
||||
key: string
|
||||
siteId?: string
|
||||
mediaFormat?: JiraMediaFormat
|
||||
}) => Promise<JiraIssue | null>
|
||||
lookupIssueSummary: (args: {
|
||||
key: string
|
||||
siteId: string
|
||||
@@ -73,7 +78,11 @@ export type JiraApi = {
|
||||
body: string
|
||||
siteId?: string
|
||||
}) => Promise<{ ok: true; id: string } | { ok: false; error: string }>
|
||||
issueComments: (args: { key: string; siteId?: string }) => Promise<JiraComment[]>
|
||||
issueComments: (args: {
|
||||
key: string
|
||||
siteId?: string
|
||||
mediaFormat?: JiraMediaFormat
|
||||
}) => Promise<JiraComment[]>
|
||||
listProjects: (args?: { siteId?: JiraSiteSelection }) => Promise<JiraProject[]>
|
||||
listIssueTypes: (args: { projectIdOrKey: string; siteId?: string }) => Promise<JiraIssueType[]>
|
||||
listCreateFields: (args: {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { JiraMediaFormat } from '../../shared/jira-attachment-image'
|
||||
import type {
|
||||
JiraIssueSummaryGenerationRequest,
|
||||
JiraIssueSummaryGenerationCancellation
|
||||
@@ -37,7 +38,8 @@ export const jiraApi = {
|
||||
siteId?: string
|
||||
}) => ipcRenderer.invoke('jira:listIssues', args),
|
||||
|
||||
getIssue: (args: { key: string; siteId?: string }) => ipcRenderer.invoke('jira:getIssue', args),
|
||||
getIssue: (args: { key: string; siteId?: string; mediaFormat?: JiraMediaFormat }) =>
|
||||
ipcRenderer.invoke('jira:getIssue', args),
|
||||
|
||||
lookupIssueSummary: (args: { key: string; siteId: string; requestId?: string }) =>
|
||||
ipcRenderer.invoke('jira:lookupIssueSummary', args),
|
||||
@@ -74,7 +76,7 @@ export const jiraApi = {
|
||||
}): Promise<{ ok: true; id: string } | { ok: false; error: string }> =>
|
||||
ipcRenderer.invoke('jira:addIssueComment', args),
|
||||
|
||||
issueComments: (args: { key: string; siteId?: string }) =>
|
||||
issueComments: (args: { key: string; siteId?: string; mediaFormat?: JiraMediaFormat }) =>
|
||||
ipcRenderer.invoke('jira:issueComments', args),
|
||||
|
||||
listProjects: (args?: { siteId?: string }) => ipcRenderer.invoke('jira:listProjects', args),
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
<html class="native-shell">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="img-src 'self' data: blob: http: https: file: orca-media: orca-preview: orca-jira-image:"
|
||||
/>
|
||||
<title>Orca</title>
|
||||
<!-- CSP is relaxed during development; electron-vite injects a stricter policy for production builds -->
|
||||
</head>
|
||||
|
||||
@@ -385,6 +385,7 @@ export default function JiraIssueWorkspace({
|
||||
|
||||
<JiraIssueWorkspaceContent
|
||||
displayed={displayed}
|
||||
imageTarget={owner}
|
||||
titleDraft={titleDraft}
|
||||
setTitleDraft={setTitleDraft}
|
||||
labelsDraft={labelsDraft}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { JiraAttachmentImage } from './jira-attachment-image'
|
||||
|
||||
const { acquire, release, listeners } = vi.hoisted(() => ({
|
||||
acquire: vi.fn(),
|
||||
release: vi.fn(),
|
||||
listeners: new Set<(environmentId: string | null) => void>()
|
||||
}))
|
||||
vi.mock('@/runtime/runtime-jira-image-cache', () => ({
|
||||
acquireJiraImageUrl: acquire,
|
||||
hasCachedJiraImage: () => false,
|
||||
onJiraImageCacheInvalidated: (listener: (environmentId: string | null) => void) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
}))
|
||||
const target = { kind: 'environment' as const, environmentId: 'first-host' }
|
||||
beforeEach(() => {
|
||||
acquire.mockReset()
|
||||
release.mockReset()
|
||||
listeners.clear()
|
||||
})
|
||||
afterEach(cleanup)
|
||||
|
||||
it('removes the previous image immediately when its mounted owner or reference changes', async () => {
|
||||
acquire
|
||||
.mockResolvedValueOnce({ url: 'blob:first', release })
|
||||
.mockImplementationOnce(() => new Promise(() => {}))
|
||||
const view = render(
|
||||
<JiraAttachmentImage target={target} reference="first" alt="shot" compact={false} />
|
||||
)
|
||||
await waitFor(() => expect(screen.getByRole('img').getAttribute('src')).toBe('blob:first'))
|
||||
view.rerender(
|
||||
<JiraAttachmentImage
|
||||
target={{ kind: 'environment', environmentId: 'second-host' }}
|
||||
reference="second"
|
||||
alt="shot"
|
||||
compact={false}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
expect(release).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('lets a new reference load after the previous reference failed', async () => {
|
||||
acquire
|
||||
.mockRejectedValueOnce(new Error('unavailable'))
|
||||
.mockResolvedValueOnce({ url: 'blob:second', release })
|
||||
const view = render(
|
||||
<JiraAttachmentImage target={target} reference="first" alt="shot" compact={false} />
|
||||
)
|
||||
await waitFor(() => expect(acquire).toHaveBeenCalledOnce())
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
view.rerender(
|
||||
<JiraAttachmentImage target={target} reference="second" alt="shot" compact={false} />
|
||||
)
|
||||
await waitFor(() => expect(screen.getByRole('img').getAttribute('src')).toBe('blob:second'))
|
||||
})
|
||||
|
||||
it('reacquires the same reference after disconnect and reconnect invalidations', async () => {
|
||||
acquire
|
||||
.mockResolvedValueOnce({ url: 'blob:first', release })
|
||||
.mockRejectedValueOnce(new Error('disconnected'))
|
||||
.mockResolvedValueOnce({ url: 'blob:reconnected', release })
|
||||
render(<JiraAttachmentImage target={target} reference="same" alt="shot" compact={false} />)
|
||||
await waitFor(() => expect(screen.getByRole('img').getAttribute('src')).toBe('blob:first'))
|
||||
await act(async () => listeners.forEach((listener) => listener('first-host')))
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
await waitFor(() => expect(acquire).toHaveBeenCalledTimes(2))
|
||||
await act(async () => listeners.forEach((listener) => listener('other-host')))
|
||||
expect(acquire).toHaveBeenCalledTimes(2)
|
||||
await act(async () => listeners.forEach((listener) => listener('first-host')))
|
||||
await waitFor(() => expect(screen.getByRole('img').getAttribute('src')).toBe('blob:reconnected'))
|
||||
expect(acquire).toHaveBeenCalledTimes(3)
|
||||
expect(release).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('fences a pending rejection synchronously when its cache is invalidated', async () => {
|
||||
let requestSignal: AbortSignal | undefined
|
||||
let rejectFirst: ((reason: Error) => void) | undefined
|
||||
acquire
|
||||
.mockImplementationOnce((_target, _reference, signal: AbortSignal) => {
|
||||
requestSignal = signal
|
||||
return new Promise((_resolve, reject) => {
|
||||
rejectFirst = reject
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({ url: 'blob:retried', release })
|
||||
render(<JiraAttachmentImage target={target} reference="same" alt="shot" compact={false} />)
|
||||
await waitFor(() => expect(acquire).toHaveBeenCalledOnce())
|
||||
await act(async () => {
|
||||
listeners.forEach((listener) => listener('first-host'))
|
||||
expect(requestSignal?.aborted).toBe(true)
|
||||
rejectFirst?.(new Error('Cache invalidated'))
|
||||
await Promise.resolve()
|
||||
})
|
||||
await waitFor(() => expect(screen.getByRole('img').getAttribute('src')).toBe('blob:retried'))
|
||||
expect(acquire).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
|
||||
import {
|
||||
acquireJiraImageUrl,
|
||||
hasCachedJiraImage,
|
||||
onJiraImageCacheInvalidated
|
||||
} from '@/runtime/runtime-jira-image-cache'
|
||||
import { jiraImageProtocolUrl } from '@/runtime/runtime-jira-image-client'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { ExpandableMarkdownImage } from './sidebar/MarkdownImageLightbox'
|
||||
|
||||
type JiraAttachmentImageProps = {
|
||||
target: RuntimeClientTarget
|
||||
reference: string
|
||||
alt?: string
|
||||
compact: boolean
|
||||
}
|
||||
|
||||
export function JiraAttachmentImage(props: JiraAttachmentImageProps): React.JSX.Element {
|
||||
const owner = props.target.kind === 'environment' ? props.target.environmentId : 'local'
|
||||
return <ResolvedJiraAttachmentImage key={`${owner}:${props.reference}`} {...props} />
|
||||
}
|
||||
|
||||
function ResolvedJiraAttachmentImage({
|
||||
target,
|
||||
reference,
|
||||
alt,
|
||||
compact
|
||||
}: JiraAttachmentImageProps): React.JSX.Element {
|
||||
const [resolved, setResolved] = useState<string | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const [generation, setGeneration] = useState(0)
|
||||
const activeRequest = useRef<AbortController | null>(null)
|
||||
const localProtocol = target.kind === 'local' && !hasCachedJiraImage(target, reference)
|
||||
const src = localProtocol ? jiraImageProtocolUrl(reference) : resolved
|
||||
useEffect(
|
||||
() =>
|
||||
onJiraImageCacheInvalidated((environmentId) => {
|
||||
if (
|
||||
environmentId === null ||
|
||||
(target.kind === 'environment' && environmentId === target.environmentId)
|
||||
) {
|
||||
activeRequest.current?.abort()
|
||||
setResolved(null)
|
||||
setFailed(false)
|
||||
setGeneration((value) => value + 1)
|
||||
}
|
||||
}),
|
||||
[target]
|
||||
)
|
||||
useEffect(() => {
|
||||
if (localProtocol) {
|
||||
return
|
||||
}
|
||||
const controller = new AbortController()
|
||||
activeRequest.current = controller
|
||||
let release: (() => void) | undefined
|
||||
void acquireJiraImageUrl(target, reference, controller.signal)
|
||||
.then((image) => {
|
||||
if (controller.signal.aborted) {
|
||||
image.release()
|
||||
return
|
||||
}
|
||||
release = image.release
|
||||
setResolved(image.url)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
setFailed(true)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
controller.abort()
|
||||
if (activeRequest.current === controller) {
|
||||
activeRequest.current = null
|
||||
}
|
||||
release?.()
|
||||
}
|
||||
}, [generation, localProtocol, reference, target])
|
||||
if (!src || failed) {
|
||||
return (
|
||||
<span>
|
||||
{alt || translate('auto.components.sidebar.MarkdownImageLightbox.image', 'Image')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ExpandableMarkdownImage
|
||||
key={generation}
|
||||
src={src}
|
||||
onError={() => setFailed(true)}
|
||||
alt={alt}
|
||||
triggerClassName={compact ? 'my-1' : undefined}
|
||||
className={
|
||||
compact
|
||||
? 'max-h-32 max-w-full rounded-sm object-contain outline outline-1 outline-border/70'
|
||||
: 'max-h-96 max-w-full rounded-md object-contain outline outline-1 outline-border/70'
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createContext, useContext, useMemo } from 'react'
|
||||
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
|
||||
import { jiraImageReference } from '../../../shared/jira-attachment-image'
|
||||
import CommentMarkdown from './sidebar/CommentMarkdown'
|
||||
import { selectCommentMarkdownComponents } from './sidebar/comment-markdown-element-renderers'
|
||||
import { JiraAttachmentImage } from './jira-attachment-image'
|
||||
|
||||
const JiraMarkdownContext = createContext<{ target: RuntimeClientTarget; compact: boolean }>({
|
||||
target: { kind: 'local' },
|
||||
compact: false
|
||||
})
|
||||
|
||||
function JiraMarkdownImage(props: React.ComponentProps<'img'>): React.JSX.Element | null {
|
||||
const { target, compact } = useContext(JiraMarkdownContext)
|
||||
const reference = jiraImageReference(props.src)
|
||||
if (reference) {
|
||||
return (
|
||||
<JiraAttachmentImage
|
||||
target={target}
|
||||
reference={reference}
|
||||
alt={props.alt}
|
||||
compact={compact}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const BaseImage = selectCommentMarkdownComponents({
|
||||
variant: compact ? 'compact' : 'document',
|
||||
expandImages: true
|
||||
}).img
|
||||
return BaseImage ? <BaseImage src={props.src} alt={props.alt} /> : null
|
||||
}
|
||||
|
||||
const imageExtension = {
|
||||
remarkPlugins: [],
|
||||
sanitizeAttributes: {},
|
||||
components: { img: JiraMarkdownImage }
|
||||
}
|
||||
|
||||
export function JiraIssueMarkdown({
|
||||
target,
|
||||
content,
|
||||
compact = false,
|
||||
className
|
||||
}: {
|
||||
target: RuntimeClientTarget
|
||||
content: string
|
||||
compact?: boolean
|
||||
className?: string
|
||||
}): React.JSX.Element {
|
||||
const environmentId = target.kind === 'environment' ? target.environmentId : null
|
||||
const value = useMemo<{ target: RuntimeClientTarget; compact: boolean }>(
|
||||
() => ({
|
||||
target: environmentId ? { kind: 'environment', environmentId } : { kind: 'local' },
|
||||
compact
|
||||
}),
|
||||
[environmentId, compact]
|
||||
)
|
||||
return (
|
||||
<JiraMarkdownContext.Provider value={value}>
|
||||
<CommentMarkdown
|
||||
content={content}
|
||||
variant={compact ? 'compact' : 'document'}
|
||||
expandImages
|
||||
extension={imageExtension}
|
||||
className={className}
|
||||
/>
|
||||
</JiraMarkdownContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { ArrowRight, LoaderCircle, RefreshCw, Save, Send } from 'lucide-react'
|
||||
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
|
||||
import { JiraIssueMarkdown } from './jira-issue-markdown'
|
||||
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
|
||||
import { JiraIcon } from '@/components/icons/JiraIcon'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -17,6 +18,7 @@ export type JiraIssueWorkspaceAction = {
|
||||
|
||||
export function JiraIssueWorkspaceContent({
|
||||
displayed,
|
||||
imageTarget,
|
||||
titleDraft,
|
||||
setTitleDraft,
|
||||
labelsDraft,
|
||||
@@ -32,6 +34,7 @@ export function JiraIssueWorkspaceContent({
|
||||
actionItems
|
||||
}: {
|
||||
displayed: JiraIssue
|
||||
imageTarget: RuntimeClientTarget
|
||||
titleDraft: string
|
||||
setTitleDraft: (value: string) => void
|
||||
labelsDraft: string
|
||||
@@ -119,9 +122,9 @@ export function JiraIssueWorkspaceContent({
|
||||
</span>
|
||||
</div>
|
||||
{displayed.description?.trim() ? (
|
||||
<CommentMarkdown
|
||||
<JiraIssueMarkdown
|
||||
target={imageTarget}
|
||||
content={displayed.description}
|
||||
variant="document"
|
||||
className="text-[14px] leading-relaxed"
|
||||
/>
|
||||
) : (
|
||||
@@ -196,9 +199,10 @@ export function JiraIssueWorkspaceContent({
|
||||
<div className="px-3 py-2">
|
||||
{/* Why: Jira comment screenshots need the same preview
|
||||
affordance without changing compact comment typography. */}
|
||||
<CommentMarkdown
|
||||
<JiraIssueMarkdown
|
||||
target={imageTarget}
|
||||
content={comment.body}
|
||||
expandImages
|
||||
compact
|
||||
className="text-[13px] leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, type ComponentProps, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { JiraComment, JiraIssue, JiraTransition } from '../../../shared/jira-types'
|
||||
import type { TaskSourceContext } from '../../../shared/task-source-context'
|
||||
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
|
||||
import type { AppState } from '../store/types'
|
||||
import type {
|
||||
JiraIssueCommentComposer,
|
||||
JiraIssueWorkspaceContent
|
||||
} from './jira-issue-workspace-content'
|
||||
import type { JiraIssueMetadataBar } from './jira-issue-workspace-chrome'
|
||||
import JiraIssueWorkspace from './JiraIssueWorkspace'
|
||||
|
||||
type PendingRead<T> = {
|
||||
target: RuntimeClientTarget
|
||||
key: string
|
||||
resolve: (value: T) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
type ComposerProps = ComponentProps<typeof JiraIssueCommentComposer>
|
||||
type ContentProps = ComponentProps<typeof JiraIssueWorkspaceContent>
|
||||
type MetadataProps = ComponentProps<typeof JiraIssueMetadataBar>
|
||||
type JiraStoreBoundary = Pick<AppState, 'patchJiraIssue'> & {
|
||||
settings: Pick<NonNullable<AppState['settings']>, 'activeRuntimeEnvironmentId'>
|
||||
}
|
||||
|
||||
const boundary = vi.hoisted(
|
||||
(): {
|
||||
issues: PendingRead<JiraIssue | null>[]
|
||||
comments: PendingRead<JiraComment[]>[]
|
||||
transitions: PendingRead<JiraTransition[]>[]
|
||||
composer: ComposerProps | null
|
||||
settings: JiraStoreBoundary['settings']
|
||||
content: ContentProps | null
|
||||
metadata: MetadataProps | null
|
||||
} => ({
|
||||
issues: [],
|
||||
comments: [],
|
||||
transitions: [],
|
||||
composer: null,
|
||||
settings: { activeRuntimeEnvironmentId: 'environment-1' },
|
||||
content: null,
|
||||
metadata: null
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/store', () => {
|
||||
return {
|
||||
useAppStore: (select: (state: JiraStoreBoundary) => unknown) =>
|
||||
select({
|
||||
settings: boundary.settings,
|
||||
patchJiraIssue: () => {}
|
||||
})
|
||||
}
|
||||
})
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
vi.mock('@/runtime/runtime-jira-client', () => {
|
||||
function request<T>(
|
||||
queue: PendingRead<T>[],
|
||||
target: RuntimeClientTarget,
|
||||
key: string
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => queue.push({ target, key, resolve, reject }))
|
||||
}
|
||||
return {
|
||||
jiraGetIssueForTarget: (target: RuntimeClientTarget, key: string) =>
|
||||
request(boundary.issues, target, key),
|
||||
jiraIssueCommentsForTarget: (target: RuntimeClientTarget, key: string) =>
|
||||
request(boundary.comments, target, key),
|
||||
jiraListTransitionsForTarget: (target: RuntimeClientTarget, key: string) =>
|
||||
request(boundary.transitions, target, key),
|
||||
jiraListPrioritiesForTarget: async () => [],
|
||||
jiraListAssignableUsersForTarget: async () => [],
|
||||
jiraUpdateIssueForTarget: async () => ({ ok: true }),
|
||||
jiraAddIssueCommentForTarget: async () => ({ ok: true, id: 'added' })
|
||||
}
|
||||
})
|
||||
vi.mock('@/components/ui/sheet', () => {
|
||||
const Content = ({ children }: { children: ReactNode }) => <div>{children}</div>
|
||||
return {
|
||||
// Keep the owner mounted, including its closed content, so stale state is observable.
|
||||
Sheet: ({ open, children }: { open: boolean; children: ReactNode }) => (
|
||||
<div data-open={open}>{children}</div>
|
||||
),
|
||||
SheetContent: Content,
|
||||
SheetTitle: Content,
|
||||
SheetDescription: Content
|
||||
}
|
||||
})
|
||||
vi.mock('./jira-issue-workspace-chrome', () => ({
|
||||
JiraIssueWorkspaceHeader: ({ issueLoading }: { issueLoading: boolean }) => (
|
||||
<div data-testid="issue-loading">{String(issueLoading)}</div>
|
||||
),
|
||||
JiraIssueMetadataBar: (props: MetadataProps) => (
|
||||
<div
|
||||
data-testid="transitions"
|
||||
ref={(node) => {
|
||||
boundary.metadata = node ? props : null
|
||||
}}
|
||||
>
|
||||
{props.transitions.map((transition) => transition.id).join(',')}
|
||||
</div>
|
||||
)
|
||||
}))
|
||||
vi.mock('./jira-issue-workspace-content', () => ({
|
||||
JiraIssueWorkspaceContent: (props: ComponentProps<typeof JiraIssueWorkspaceContent>) => (
|
||||
<div
|
||||
data-testid="detail"
|
||||
ref={(node) => {
|
||||
boundary.content = node ? props : null
|
||||
}}
|
||||
>
|
||||
<span data-testid="title">{props.displayed.title}</span>
|
||||
<span data-testid="labels">{props.displayed.labels.join(',')}</span>
|
||||
<span data-testid="description">{props.displayed.description}</span>
|
||||
<span data-testid="image-owner">{JSON.stringify(props.imageTarget)}</span>
|
||||
<span data-testid="comments">{props.comments.map((comment) => comment.id).join(',')}</span>
|
||||
<span data-testid="comments-loading">{String(props.commentsLoading)}</span>
|
||||
<span data-testid="comments-error">{props.commentsError}</span>
|
||||
<button type="button" onClick={props.retryComments}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
JiraIssueCommentComposer: (props: ComposerProps) => (
|
||||
<div
|
||||
ref={(node) => {
|
||||
boundary.composer = node ? props : null
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}))
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
||||
const sourceContext: TaskSourceContext = {
|
||||
kind: 'task-source',
|
||||
provider: 'jira',
|
||||
projectId: 'project-1',
|
||||
hostId: 'runtime:environment-1'
|
||||
}
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement
|
||||
|
||||
function issue(key = 'JIR-1', title = key): JiraIssue {
|
||||
return {
|
||||
id: key,
|
||||
key,
|
||||
title,
|
||||
siteId: 'site-1',
|
||||
description: '',
|
||||
url: `https://jira.invalid/browse/${key}`,
|
||||
project: { id: 'project', key: 'PRJ', name: 'Project' },
|
||||
issueType: { id: 'task', name: 'Task' },
|
||||
status: { id: 'open', name: 'Open', categoryKey: 'new', categoryName: 'New' },
|
||||
labels: [],
|
||||
createdAt: '2026-10-02',
|
||||
updatedAt: '2026-10-02'
|
||||
}
|
||||
}
|
||||
|
||||
function comment(id: string): JiraComment {
|
||||
return { id, body: id, createdAt: '2026-10-02' }
|
||||
}
|
||||
|
||||
async function render(
|
||||
selected: JiraIssue | null,
|
||||
context: TaskSourceContext | null = sourceContext
|
||||
): Promise<void> {
|
||||
if (!root) {
|
||||
throw new Error('Missing React root')
|
||||
}
|
||||
const mountedRoot = root
|
||||
await act(async () => {
|
||||
mountedRoot.render(
|
||||
<JiraIssueWorkspace
|
||||
issue={selected}
|
||||
sourceContext={context}
|
||||
onUse={() => {}}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function take<T>(queue: PendingRead<T>[]): PendingRead<T> {
|
||||
const pending = queue.shift()
|
||||
if (!pending) {
|
||||
throw new Error('Missing pending Jira read')
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
function text(id: string): string | null | undefined {
|
||||
return container.querySelector(`[data-testid="${id}"]`)?.textContent
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
boundary.settings = { activeRuntimeEnvironmentId: 'environment-1' }
|
||||
boundary.issues.length = 0
|
||||
boundary.comments.length = 0
|
||||
boundary.transitions.length = 0
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
const mountedRoot = root
|
||||
if (mountedRoot) {
|
||||
await act(async () => mountedRoot.unmount())
|
||||
}
|
||||
root = null
|
||||
await act(async () => {
|
||||
for (const pending of [...boundary.issues, ...boundary.comments, ...boundary.transitions]) {
|
||||
pending.reject(new Error('Test cleanup'))
|
||||
}
|
||||
})
|
||||
boundary.issues.length = 0
|
||||
boundary.comments.length = 0
|
||||
boundary.transitions.length = 0
|
||||
boundary.composer = null
|
||||
boundary.content = null
|
||||
boundary.metadata = null
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
describe('Jira issue workspace image ownership', () => {
|
||||
it('keeps explicit ownership without duplicate reads when the default host changes', async () => {
|
||||
const selected = issue()
|
||||
const owner: RuntimeClientTarget = { kind: 'environment', environmentId: 'environment-1' }
|
||||
await render(selected)
|
||||
boundary.settings = { activeRuntimeEnvironmentId: 'environment-2' }
|
||||
await render(selected)
|
||||
for (const reads of [boundary.issues, boundary.comments, boundary.transitions]) {
|
||||
expect(reads).toHaveLength(1)
|
||||
expect(reads[0]?.target).toEqual(owner)
|
||||
}
|
||||
expect(text('image-owner')).toBe(JSON.stringify(owner))
|
||||
})
|
||||
|
||||
it('fences late detail, comments and transitions when the same issue switches owners', async () => {
|
||||
const selected = issue()
|
||||
await render(selected)
|
||||
const oldIssue = take(boundary.issues)
|
||||
const oldComments = take(boundary.comments)
|
||||
const oldTransitions = take(boundary.transitions)
|
||||
await render(selected, { ...sourceContext, hostId: 'runtime:environment-2' })
|
||||
const nextIssue = take(boundary.issues)
|
||||
const nextComments = take(boundary.comments)
|
||||
const nextTransitions = take(boundary.transitions)
|
||||
expect(nextIssue.target).toEqual({ kind: 'environment', environmentId: 'environment-2' })
|
||||
expect(nextComments.target).toEqual(nextIssue.target)
|
||||
expect(nextTransitions.target).toEqual(nextIssue.target)
|
||||
expect(text('image-owner')).toBe(JSON.stringify(nextIssue.target))
|
||||
await act(async () => {
|
||||
oldIssue.resolve(issue('JIR-1', 'Old owner'))
|
||||
oldComments.resolve([comment('old')])
|
||||
oldTransitions.resolve([{ id: 'old', name: 'Old', to: selected.status }])
|
||||
})
|
||||
expect(text('title')).toBe('JIR-1')
|
||||
expect(text('comments')).toBe('')
|
||||
expect(text('transitions')).toBe('')
|
||||
await act(async () => {
|
||||
nextIssue.resolve(issue('JIR-1', 'New owner'))
|
||||
nextComments.resolve([comment('new')])
|
||||
nextTransitions.resolve([{ id: 'new', name: 'New', to: selected.status }])
|
||||
})
|
||||
expect(text('title')).toBe('New owner')
|
||||
expect(text('comments')).toBe('new')
|
||||
expect(text('transitions')).toBe('new')
|
||||
})
|
||||
|
||||
it('uses one account owner for row-less detail, comments, transitions and images', async () => {
|
||||
await render(issue(), null)
|
||||
const detail = take(boundary.issues)
|
||||
const comments = take(boundary.comments)
|
||||
const transitions = take(boundary.transitions)
|
||||
expect(detail.target).toEqual({ kind: 'environment', environmentId: 'environment-1' })
|
||||
expect(comments.target).toBe(detail.target)
|
||||
expect(transitions.target).toBe(detail.target)
|
||||
expect(text('image-owner')).toBe(JSON.stringify(detail.target))
|
||||
await act(async () => {
|
||||
detail.resolve(null)
|
||||
comments.resolve([])
|
||||
transitions.resolve([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -195,7 +195,7 @@ const commentMarkdownSanitizeSchema = {
|
||||
// Why: native chat opts into file URI links after sanitize; the URL
|
||||
// transform below still strips them for all other markdown surfaces.
|
||||
href: [...(defaultSchema.protocols?.href ?? []), 'file'],
|
||||
src: [...(defaultSchema.protocols?.src ?? []), 'data', 'blob']
|
||||
src: [...(defaultSchema.protocols?.src ?? []), 'data', 'blob', 'orca-jira-image']
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ type ExpandableMarkdownImageProps = {
|
||||
alt?: string
|
||||
className?: string
|
||||
triggerClassName?: string
|
||||
onError?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,7 +27,8 @@ export function ExpandableMarkdownImage({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
triggerClassName
|
||||
triggerClassName,
|
||||
onError
|
||||
}: ExpandableMarkdownImageProps): React.JSX.Element {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const label =
|
||||
@@ -51,7 +53,12 @@ export function ExpandableMarkdownImage({
|
||||
'Expand image'
|
||||
)}
|
||||
>
|
||||
<img src={src} alt={alt ?? ''} className={cn(className, 'pointer-events-none')} />
|
||||
<img
|
||||
src={src}
|
||||
alt={alt ?? ''}
|
||||
className={cn(className, 'pointer-events-none')}
|
||||
onError={onError}
|
||||
/>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import { jiraImageReference } from '../../../../shared/jira-attachment-image'
|
||||
import type { Components } from 'react-markdown'
|
||||
import { NATIVE_CHAT_FILE_HREF_PREFIX } from '../../../../shared/native-chat-href-routing'
|
||||
import {
|
||||
@@ -35,7 +36,9 @@ export function isTrustedCompactImageSrc(src: string | undefined): src is string
|
||||
}
|
||||
const normalized = src.trim().toLowerCase()
|
||||
return (
|
||||
normalized.startsWith('blob:') || /^data:image\/(?:png|jpe?g|gif|webp);base64,/.test(normalized)
|
||||
jiraImageReference(src) !== null ||
|
||||
normalized.startsWith('blob:') ||
|
||||
/^data:image\/(?:png|jpe?g|gif|webp);base64,/.test(normalized)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { observeJiraImageHostStatus } from '@/runtime/runtime-jira-image-cache'
|
||||
import type { RuntimeHostStatusSnapshot } from '../../../../shared/runtime-host-status'
|
||||
import { getTabIdsAwaitingHostHydrationRemount } from '@/lib/parked-terminal-host-hydration'
|
||||
import { emitAutomationsChangedWindowEvent } from '@/lib/automations-changed-window-event'
|
||||
@@ -71,6 +72,7 @@ export function installAppLifetimeIpcEvents(
|
||||
const apply = (snapshot: RuntimeHostStatusSnapshot): void => {
|
||||
useAppStore.getState().applyRuntimeHostStatusSnapshot(snapshot)
|
||||
remoteRuntimeTerminalColorPush.observeStatusSnapshot(snapshot)
|
||||
observeJiraImageHostStatus(snapshot)
|
||||
}
|
||||
let stopped = false
|
||||
unsubs.push(statusApi.onStatusChanged(apply), () => {
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
jiraSearchUsers,
|
||||
jiraListIssues
|
||||
} from './runtime-jira-client'
|
||||
import { JIRA_IMAGE_CAPABILITY } from '../../../shared/jira-attachment-image'
|
||||
import { clearJiraImageCache } from './runtime-jira-image-cache'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client'
|
||||
import { createCompatibleRuntimeStatusResponse } from './runtime-compatibility-test-fixture'
|
||||
import {
|
||||
@@ -75,6 +77,7 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearJiraImageCache()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
describe('current sprint runtime compatibility', () => {
|
||||
@@ -451,7 +454,15 @@ describe('runtime Jira client search bounds', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('streams image-bearing issue and comment payloads from remote runtimes', async () => {
|
||||
it('streams image-bearing issue and comment payloads from older remote runtimes', async () => {
|
||||
const status = createCompatibleRuntimeStatusResponse()
|
||||
if (!status.ok) {
|
||||
throw new Error('Expected successful status')
|
||||
}
|
||||
status.result.capabilities = status.result.capabilities?.filter(
|
||||
(capability) => capability !== JIRA_IMAGE_CAPABILITY
|
||||
)
|
||||
runtimeCall.mockResolvedValue(status)
|
||||
runtimeSubscribe.mockImplementation(
|
||||
async (args: RuntimeSubscribeArgs, callbacks: RuntimeSubscribeCallbacks) => {
|
||||
const payload =
|
||||
@@ -501,7 +512,7 @@ describe('runtime Jira client search bounds', () => {
|
||||
},
|
||||
expect.anything()
|
||||
)
|
||||
expect(runtimeCall).not.toHaveBeenCalled()
|
||||
expect(runtimeCall).toHaveBeenCalledWith(expect.objectContaining({ method: 'status.get' }))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
|
||||
import { searchLocalJiraIssues } from './local-jira-search-cancellation'
|
||||
import { assertRuntimeEnvironmentCapability, callRuntimeRpc } from './runtime-rpc-client'
|
||||
import { clearJiraImageCache } from './runtime-jira-image-cache'
|
||||
import { isRuntimeProviderSearchQueryWithinLimit } from './runtime-provider-search-bounds'
|
||||
import { getJiraRuntimeTarget, type RuntimeJiraSettings } from './runtime-jira-target'
|
||||
import { parseJiraConnectionStatus } from './runtime-jira-connection-status'
|
||||
@@ -85,6 +86,7 @@ export async function jiraDisconnect(
|
||||
settings: RuntimeJiraSettings,
|
||||
siteId?: string | null
|
||||
): Promise<void> {
|
||||
clearJiraImageCache()
|
||||
const target = getJiraRuntimeTarget(settings)
|
||||
if (target.kind === 'environment') {
|
||||
await callRuntimeRpc<{ ok: true }>(target, 'jira.disconnect', siteId ? { siteId } : undefined, {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { JIRA_IMAGE_CAPABILITY } from '../../../shared/jira-attachment-image'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
jiraAddIssueCommentForTarget,
|
||||
@@ -54,8 +55,16 @@ describe('captured Jira detail owners', () => {
|
||||
await jiraListPrioritiesForTarget(owner, 'site-1')
|
||||
await jiraListAssignableUsersForTarget(owner, 'ENG-1', 'Ada', 'site-1')
|
||||
|
||||
expect(localMethods.getIssue).toHaveBeenCalledWith({ key: 'ENG-1', siteId: 'site-1' })
|
||||
expect(localMethods.issueComments).toHaveBeenCalledWith({ key: 'ENG-1', siteId: 'site-1' })
|
||||
expect(localMethods.getIssue).toHaveBeenCalledWith({
|
||||
key: 'ENG-1',
|
||||
siteId: 'site-1',
|
||||
mediaFormat: 'reference'
|
||||
})
|
||||
expect(localMethods.issueComments).toHaveBeenCalledWith({
|
||||
key: 'ENG-1',
|
||||
siteId: 'site-1',
|
||||
mediaFormat: 'reference'
|
||||
})
|
||||
expect(localMethods.updateIssue).toHaveBeenCalledWith({
|
||||
key: 'ENG-1',
|
||||
siteId: 'site-1',
|
||||
@@ -128,9 +137,16 @@ describe('captured Jira detail owners', () => {
|
||||
error: { code: 'method_not_found', message: 'Old host' }
|
||||
})
|
||||
)
|
||||
const status = createCompatibleRuntimeStatusResponse()
|
||||
if (!status.ok) {
|
||||
throw new Error('Expected successful status')
|
||||
}
|
||||
status.result.capabilities = status.result.capabilities?.filter(
|
||||
(capability) => capability !== JIRA_IMAGE_CAPABILITY
|
||||
)
|
||||
runtimeCall.mockImplementation(async (args: { method: string }) =>
|
||||
args.method === 'status.get'
|
||||
? createCompatibleRuntimeStatusResponse()
|
||||
? status
|
||||
: {
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { JIRA_IMAGE_CAPABILITY } from '../../../shared/jira-attachment-image'
|
||||
import { migrateLegacyJiraImages } from './runtime-jira-image-cache'
|
||||
import type {
|
||||
JiraComment,
|
||||
JiraIssue,
|
||||
@@ -7,7 +9,11 @@ import type {
|
||||
JiraTransition
|
||||
} from '../../../shared/jira-types'
|
||||
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
|
||||
import { assertRuntimeEnvironmentCapability, callRuntimeRpc } from './runtime-rpc-client'
|
||||
import {
|
||||
assertRuntimeEnvironmentCapability,
|
||||
callRuntimeRpc,
|
||||
runtimeEnvironmentSupportsCapability
|
||||
} from './runtime-rpc-client'
|
||||
import { JIRA_TRANSITION_FIELDS_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
|
||||
import { RuntimeRpcCallError } from './runtime-rpc-result'
|
||||
import { readRuntimeJiraPayload } from './runtime-jira-payload-stream'
|
||||
@@ -37,10 +43,30 @@ export async function jiraGetIssueForTarget(
|
||||
key: string,
|
||||
siteId?: string | null
|
||||
): Promise<JiraIssue | null> {
|
||||
const args = { key, siteId: siteId ?? undefined }
|
||||
return target.kind === 'environment'
|
||||
? readRemoteJiraPayload<JiraIssue | null>(target, 'jira.getIssueStream', 'jira.getIssue', args)
|
||||
: window.api.jira.getIssue(args)
|
||||
const references =
|
||||
target.kind === 'local' ||
|
||||
(await runtimeEnvironmentSupportsCapability(target.environmentId, JIRA_IMAGE_CAPABILITY))
|
||||
const args = {
|
||||
key,
|
||||
siteId: siteId ?? undefined,
|
||||
...(references ? { mediaFormat: 'reference' as const } : {})
|
||||
}
|
||||
const issue =
|
||||
target.kind === 'environment'
|
||||
? references
|
||||
? await callRuntimeRpc<JiraIssue | null>(target, 'jira.getIssue', args, {
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
: await readRemoteJiraPayload<JiraIssue | null>(
|
||||
target,
|
||||
'jira.getIssueStream',
|
||||
'jira.getIssue',
|
||||
args
|
||||
)
|
||||
: await window.api.jira.getIssue(args)
|
||||
return issue
|
||||
? { ...issue, description: migrateLegacyJiraImages(issue.description ?? '', target) }
|
||||
: null
|
||||
}
|
||||
|
||||
export async function jiraUpdateIssueForTarget(
|
||||
@@ -84,15 +110,31 @@ export async function jiraIssueCommentsForTarget(
|
||||
key: string,
|
||||
siteId?: string | null
|
||||
): Promise<JiraComment[]> {
|
||||
const args = { key, siteId: siteId ?? undefined }
|
||||
return target.kind === 'environment'
|
||||
? readRemoteJiraPayload<JiraComment[]>(
|
||||
target,
|
||||
'jira.issueCommentsStream',
|
||||
'jira.issueComments',
|
||||
args
|
||||
)
|
||||
: window.api.jira.issueComments(args)
|
||||
const references =
|
||||
target.kind === 'local' ||
|
||||
(await runtimeEnvironmentSupportsCapability(target.environmentId, JIRA_IMAGE_CAPABILITY))
|
||||
const args = {
|
||||
key,
|
||||
siteId: siteId ?? undefined,
|
||||
...(references ? { mediaFormat: 'reference' as const } : {})
|
||||
}
|
||||
const comments =
|
||||
target.kind === 'environment'
|
||||
? references
|
||||
? await callRuntimeRpc<JiraComment[]>(target, 'jira.issueComments', args, {
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
: await readRemoteJiraPayload<JiraComment[]>(
|
||||
target,
|
||||
'jira.issueCommentsStream',
|
||||
'jira.issueComments',
|
||||
args
|
||||
)
|
||||
: await window.api.jira.issueComments(args)
|
||||
return comments.map((comment) => ({
|
||||
...comment,
|
||||
body: migrateLegacyJiraImages(comment.body, target)
|
||||
}))
|
||||
}
|
||||
|
||||
export async function jiraListPrioritiesForTarget(
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { jiraGetIssueForTarget, jiraIssueCommentsForTarget } from './runtime-jira-detail-client'
|
||||
import { JIRA_IMAGE_CAPABILITY } from '../../../shared/jira-attachment-image'
|
||||
import { clearJiraImageCache, hasCachedJiraImage } from './runtime-jira-image-cache'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client'
|
||||
import { createCompatibleRuntimeStatusResponse } from './runtime-compatibility-test-fixture'
|
||||
|
||||
type SubscribeArgs = Parameters<typeof window.api.runtimeEnvironments.subscribe>[0]
|
||||
type SubscribeCallbacks = Parameters<typeof window.api.runtimeEnvironments.subscribe>[1]
|
||||
const target = { kind: 'environment' as const, environmentId: 'issue-owner' }
|
||||
const image = ''
|
||||
const localIssue = vi.fn()
|
||||
const localComments = vi.fn()
|
||||
const runtimeCall = vi.fn()
|
||||
const runtimeSubscribe = vi.fn()
|
||||
const close = vi.fn()
|
||||
|
||||
function statusWithoutImages() {
|
||||
const response = createCompatibleRuntimeStatusResponse()
|
||||
if (!response.ok) {
|
||||
throw new Error('Expected a successful compatibility fixture')
|
||||
}
|
||||
response.result.capabilities = response.result.capabilities?.filter(
|
||||
(capability) => capability !== JIRA_IMAGE_CAPABILITY
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
localIssue.mockReset()
|
||||
localComments.mockReset()
|
||||
runtimeCall.mockReset()
|
||||
runtimeSubscribe.mockReset()
|
||||
close.mockReset()
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
jira: { getIssue: localIssue, issueComments: localComments },
|
||||
runtimeEnvironments: { call: runtimeCall, subscribe: runtimeSubscribe }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearJiraImageCache()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('captured Jira detail owners', () => {
|
||||
it('requests image references through local issue and comment IPC', async () => {
|
||||
localIssue.mockResolvedValue({ key: 'JIR-1', description: image })
|
||||
localComments.mockResolvedValue([{ id: 'comment', body: image }])
|
||||
await expect(jiraGetIssueForTarget({ kind: 'local' }, 'JIR-1', 'site')).resolves.toMatchObject({
|
||||
description: image
|
||||
})
|
||||
await expect(jiraIssueCommentsForTarget({ kind: 'local' }, 'JIR-1', 'site')).resolves.toEqual([
|
||||
{ id: 'comment', body: image }
|
||||
])
|
||||
const args = { key: 'JIR-1', siteId: 'site', mediaFormat: 'reference' }
|
||||
expect(localIssue).toHaveBeenCalledWith(args)
|
||||
expect(localComments).toHaveBeenCalledWith(args)
|
||||
expect(runtimeCall).not.toHaveBeenCalled()
|
||||
expect(runtimeSubscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('checks the captured remote owner capability and reads short image references there', async () => {
|
||||
runtimeCall.mockImplementation(async (args: { method: string }) =>
|
||||
args.method === 'status.get'
|
||||
? createCompatibleRuntimeStatusResponse()
|
||||
: {
|
||||
id: 'detail',
|
||||
ok: true,
|
||||
result:
|
||||
args.method === 'jira.getIssue'
|
||||
? { key: 'JIR-1', description: image }
|
||||
: [{ id: 'comment', body: image }],
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
}
|
||||
)
|
||||
await expect(jiraGetIssueForTarget(target, 'JIR-1', 'site')).resolves.toMatchObject({
|
||||
description: image
|
||||
})
|
||||
await expect(jiraIssueCommentsForTarget(target, 'JIR-1', 'site')).resolves.toEqual([
|
||||
{ id: 'comment', body: image }
|
||||
])
|
||||
for (const method of ['status.get', 'jira.getIssue', 'jira.issueComments']) {
|
||||
expect(runtimeCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selector: 'issue-owner',
|
||||
method
|
||||
})
|
||||
)
|
||||
}
|
||||
expect(runtimeCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'jira.getIssue',
|
||||
params: { key: 'JIR-1', siteId: 'site', mediaFormat: 'reference' }
|
||||
})
|
||||
)
|
||||
expect(localIssue).not.toHaveBeenCalled()
|
||||
expect(localComments).not.toHaveBeenCalled()
|
||||
expect(runtimeSubscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps old-host streams and migrated image bytes under their captured owner', async () => {
|
||||
runtimeCall.mockResolvedValue(statusWithoutImages())
|
||||
runtimeSubscribe.mockImplementation(
|
||||
async (args: SubscribeArgs, callbacks: SubscribeCallbacks) => {
|
||||
const body = ''
|
||||
const payload =
|
||||
args.method === 'jira.getIssueStream'
|
||||
? { key: 'JIR-1', description: body }
|
||||
: [{ id: 'comment', body }]
|
||||
for (const result of [
|
||||
{ type: 'chunk', content: JSON.stringify(payload) },
|
||||
{ type: 'end' }
|
||||
]) {
|
||||
callbacks.onResponse({
|
||||
id: 'detail',
|
||||
ok: true,
|
||||
result,
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
}
|
||||
return { unsubscribe: close, sendBinary: vi.fn() }
|
||||
}
|
||||
)
|
||||
const issue = await jiraGetIssueForTarget(target, 'JIR-1', 'site')
|
||||
const comments = await jiraIssueCommentsForTarget(target, 'JIR-1', 'site')
|
||||
for (const body of [issue?.description, comments[0]?.body]) {
|
||||
const reference = body?.match(/orca-jira-image:\/\/attachment\/([^)]+)/)?.[1]
|
||||
if (!reference) {
|
||||
throw new Error('Expected a migrated image reference')
|
||||
}
|
||||
expect(hasCachedJiraImage(target, reference)).toBe(true)
|
||||
expect(
|
||||
hasCachedJiraImage({ kind: 'environment', environmentId: 'other-owner' }, reference)
|
||||
).toBe(false)
|
||||
}
|
||||
for (const method of ['jira.getIssueStream', 'jira.issueCommentsStream']) {
|
||||
expect(runtimeSubscribe).toHaveBeenCalledWith(
|
||||
{
|
||||
selector: 'issue-owner',
|
||||
method,
|
||||
params: { key: 'JIR-1', siteId: 'site' },
|
||||
timeoutMs: 60_000
|
||||
},
|
||||
expect.anything()
|
||||
)
|
||||
}
|
||||
await Promise.resolve()
|
||||
expect(close).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('falls back to the original owner RPC only when an old host lacks the stream method', async () => {
|
||||
runtimeCall.mockImplementation(async (args: { method: string }) =>
|
||||
args.method === 'status.get'
|
||||
? statusWithoutImages()
|
||||
: {
|
||||
id: 'detail',
|
||||
ok: true,
|
||||
result: args.method === 'jira.getIssue' ? null : [],
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
}
|
||||
)
|
||||
runtimeSubscribe.mockImplementation(
|
||||
async (_args: SubscribeArgs, callbacks: SubscribeCallbacks) => {
|
||||
callbacks.onResponse({
|
||||
id: 'detail',
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'method_not_found',
|
||||
message: 'Unknown method'
|
||||
}
|
||||
})
|
||||
return { unsubscribe: close, sendBinary: vi.fn() }
|
||||
}
|
||||
)
|
||||
await expect(jiraGetIssueForTarget(target, 'JIR-1')).resolves.toBeNull()
|
||||
await expect(jiraIssueCommentsForTarget(target, 'JIR-1')).resolves.toEqual([])
|
||||
for (const method of ['jira.getIssue', 'jira.issueComments']) {
|
||||
expect(runtimeCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selector: 'issue-owner',
|
||||
method,
|
||||
params: { key: 'JIR-1', siteId: undefined },
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
)
|
||||
}
|
||||
expect(localIssue).not.toHaveBeenCalled()
|
||||
expect(localComments).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves stream errors instead of retrying failed reads through another path', async () => {
|
||||
runtimeCall.mockResolvedValue(statusWithoutImages())
|
||||
runtimeSubscribe.mockImplementation(
|
||||
async (_args: SubscribeArgs, callbacks: SubscribeCallbacks) => {
|
||||
callbacks.onResponse({
|
||||
id: 'detail',
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'forbidden',
|
||||
message: 'Access denied'
|
||||
}
|
||||
})
|
||||
return { unsubscribe: close, sendBinary: vi.fn() }
|
||||
}
|
||||
)
|
||||
await expect(jiraGetIssueForTarget(target, 'JIR-1')).rejects.toMatchObject({
|
||||
code: 'forbidden'
|
||||
})
|
||||
await expect(jiraIssueCommentsForTarget(target, 'JIR-1')).rejects.toMatchObject({
|
||||
code: 'forbidden'
|
||||
})
|
||||
expect(runtimeCall).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'jira.getIssue' })
|
||||
)
|
||||
expect(runtimeCall).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'jira.issueComments' })
|
||||
)
|
||||
expect(localIssue).not.toHaveBeenCalled()
|
||||
expect(localComments).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,255 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
acquireJiraImageUrl,
|
||||
migrateLegacyJiraImages,
|
||||
hasCachedJiraImage,
|
||||
clearJiraImageCache,
|
||||
observeJiraImageHostStatus,
|
||||
onJiraImageCacheInvalidated
|
||||
} from './runtime-jira-image-cache'
|
||||
import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision'
|
||||
import { jiraImageReference } from '../../../shared/jira-attachment-image'
|
||||
|
||||
const { download } = vi.hoisted(() => ({ download: vi.fn() }))
|
||||
vi.mock('./runtime-jira-image-client', () => ({ readRuntimeJiraImage: download }))
|
||||
const target = { kind: 'environment' as const, environmentId: 'host' }
|
||||
const createUrl = vi.fn()
|
||||
const revoke = vi.fn()
|
||||
const originalUrl = URL
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
'URL',
|
||||
class extends originalUrl {
|
||||
static createObjectURL = createUrl
|
||||
static revokeObjectURL = revoke
|
||||
}
|
||||
)
|
||||
clearJiraImageCache()
|
||||
download.mockReset().mockResolvedValue(new Blob([Uint8Array.of(1)], { type: 'image/png' }))
|
||||
createUrl.mockReset().mockImplementation(() => `blob:${createUrl.mock.calls.length}`)
|
||||
revoke.mockReset()
|
||||
replaceRuntimeEnvironmentRevisions([])
|
||||
replaceRuntimeEnvironmentRevisions([{ id: 'host', createdAt: 1 }])
|
||||
})
|
||||
afterEach(() => {
|
||||
clearJiraImageCache()
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
function acquire(reference = 'image') {
|
||||
return acquireJiraImageUrl(target, reference, new AbortController().signal)
|
||||
}
|
||||
|
||||
describe('bounded Jira Blob image cache', () => {
|
||||
it('shares concurrent downloads, retains active URLs and revokes on clear', async () => {
|
||||
const [a, b] = await Promise.all([acquire(), acquire()])
|
||||
expect(download).toHaveBeenCalledOnce()
|
||||
expect(a.url).toBe(b.url)
|
||||
a.release()
|
||||
expect(revoke).not.toHaveBeenCalled()
|
||||
b.release()
|
||||
clearJiraImageCache()
|
||||
expect(revoke).toHaveBeenCalledWith(a.url)
|
||||
})
|
||||
it('aborts the shared transfer only after both readers cancel', async () => {
|
||||
let requestSignal: AbortSignal | undefined
|
||||
download.mockImplementation((_target, _reference, signal: AbortSignal) => {
|
||||
requestSignal = signal
|
||||
return new Promise((_resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(new Error('cancelled')), { once: true })
|
||||
)
|
||||
})
|
||||
const a = new AbortController()
|
||||
const b = new AbortController()
|
||||
const first = acquireJiraImageUrl(target, 'same', a.signal).catch((error) => error)
|
||||
const second = acquireJiraImageUrl(target, 'same', b.signal).catch((error) => error)
|
||||
await vi.waitFor(() => expect(download).toHaveBeenCalledOnce())
|
||||
a.abort()
|
||||
await first
|
||||
expect(requestSignal?.aborted).toBe(false)
|
||||
b.abort()
|
||||
await second
|
||||
expect(requestSignal?.aborted).toBe(true)
|
||||
})
|
||||
it('admits no more than 24 MiB of active image URLs', async () => {
|
||||
download.mockResolvedValue(new Blob([new Uint8Array(8 * 1024 * 1024)], { type: 'image/png' }))
|
||||
const held = await Promise.all([acquire('1'), acquire('2'), acquire('3')])
|
||||
await expect(acquire('4')).rejects.toThrow('cache is full')
|
||||
expect(createUrl).toHaveBeenCalledTimes(3)
|
||||
held[0]?.release()
|
||||
const next = await acquire('4')
|
||||
expect(revoke).toHaveBeenCalledOnce()
|
||||
next.release()
|
||||
held.forEach((image) => image.release())
|
||||
})
|
||||
it('expires unused URLs and fences a same-ID host replacement', async () => {
|
||||
vi.useFakeTimers()
|
||||
const image = await acquire()
|
||||
image.release()
|
||||
await vi.advanceTimersByTimeAsync(30 * 60_000)
|
||||
expect(hasCachedJiraImage(target, 'image')).toBe(false)
|
||||
expect(revoke).toHaveBeenCalledWith(image.url)
|
||||
const replacement = await acquire()
|
||||
replaceRuntimeEnvironmentRevisions([{ id: 'host', createdAt: 2 }], {
|
||||
retired: ['host'],
|
||||
sameHost: []
|
||||
})
|
||||
expect(hasCachedJiraImage(target, 'image')).toBe(false)
|
||||
expect(revoke).toHaveBeenCalledWith(replacement.url)
|
||||
replacement.release()
|
||||
})
|
||||
it('clears a host cache when the connection is lost or revoked', async () => {
|
||||
const image = await acquire()
|
||||
observeJiraImageHostStatus({
|
||||
environmentId: 'host',
|
||||
pairingRevision: 1,
|
||||
sequence: 2,
|
||||
checkedAt: 0,
|
||||
status: null,
|
||||
verification: 'unavailable',
|
||||
transport: 'disconnected'
|
||||
})
|
||||
expect(hasCachedJiraImage(target, 'image')).toBe(false)
|
||||
expect(revoke).toHaveBeenCalledWith(image.url)
|
||||
image.release()
|
||||
})
|
||||
it('allows the combined description and comment queue while keeping three active downloads', async () => {
|
||||
const releases: (() => void)[] = []
|
||||
download.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releases.push(() => resolve(new Blob([Uint8Array.of(1)], { type: 'image/png' })))
|
||||
})
|
||||
)
|
||||
const reads = Array.from({ length: 48 }, (_, id) => acquire(String(id)))
|
||||
await vi.waitFor(() => expect(download).toHaveBeenCalledTimes(3))
|
||||
for (let batch = 0; batch < 16; batch += 1) {
|
||||
await vi.waitFor(() => expect(releases).toHaveLength(3))
|
||||
releases.splice(0).forEach((release) => release())
|
||||
}
|
||||
const images = await Promise.all(reads)
|
||||
expect(images).toHaveLength(48)
|
||||
expect(download).toHaveBeenCalledTimes(48)
|
||||
images.forEach((image) => image.release())
|
||||
})
|
||||
|
||||
it('notifies mounted readers once when a disconnected host is verified again', () => {
|
||||
const listener = vi.fn()
|
||||
const stop = onJiraImageCacheInvalidated(listener)
|
||||
const status = {
|
||||
environmentId: 'host',
|
||||
pairingRevision: 1,
|
||||
sequence: 2,
|
||||
checkedAt: 0,
|
||||
status: null,
|
||||
verification: 'unavailable' as const,
|
||||
transport: 'disconnected' as const
|
||||
}
|
||||
observeJiraImageHostStatus(status)
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
observeJiraImageHostStatus({ ...status, sequence: 3, transport: 'connecting' })
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
observeJiraImageHostStatus({
|
||||
...status,
|
||||
sequence: 4,
|
||||
verification: 'verified',
|
||||
transport: 'ready'
|
||||
})
|
||||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
observeJiraImageHostStatus({
|
||||
...status,
|
||||
sequence: 4,
|
||||
verification: 'verified',
|
||||
transport: 'ready'
|
||||
})
|
||||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('does not let stale host snapshots consume the reconnect retry', () => {
|
||||
const listener = vi.fn()
|
||||
const stop = onJiraImageCacheInvalidated(listener)
|
||||
const status = {
|
||||
environmentId: 'host',
|
||||
pairingRevision: 1,
|
||||
sequence: 3,
|
||||
checkedAt: 0,
|
||||
status: null,
|
||||
verification: 'unavailable' as const,
|
||||
transport: 'disconnected' as const
|
||||
}
|
||||
observeJiraImageHostStatus(status)
|
||||
observeJiraImageHostStatus({
|
||||
...status,
|
||||
sequence: 2,
|
||||
verification: 'verified',
|
||||
transport: 'ready'
|
||||
})
|
||||
observeJiraImageHostStatus({
|
||||
...status,
|
||||
pairingRevision: 0,
|
||||
sequence: 10,
|
||||
verification: 'verified',
|
||||
transport: 'ready'
|
||||
})
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
observeJiraImageHostStatus({
|
||||
...status,
|
||||
sequence: 4,
|
||||
verification: 'verified',
|
||||
transport: 'ready'
|
||||
})
|
||||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('forgets the old reconnect marker when a host is replaced', () => {
|
||||
observeJiraImageHostStatus({
|
||||
environmentId: 'host',
|
||||
pairingRevision: 1,
|
||||
sequence: 3,
|
||||
checkedAt: 0,
|
||||
status: null,
|
||||
verification: 'unavailable',
|
||||
transport: 'disconnected'
|
||||
})
|
||||
replaceRuntimeEnvironmentRevisions([{ id: 'host', createdAt: 2 }], {
|
||||
retired: ['host'],
|
||||
sameHost: []
|
||||
})
|
||||
const listener = vi.fn()
|
||||
const stop = onJiraImageCacheInvalidated(listener)
|
||||
observeJiraImageHostStatus({
|
||||
environmentId: 'host',
|
||||
pairingRevision: 2,
|
||||
sequence: 1,
|
||||
checkedAt: 0,
|
||||
status: null,
|
||||
verification: 'verified',
|
||||
transport: 'ready'
|
||||
})
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
stop()
|
||||
})
|
||||
|
||||
it('converts old-host image data into short references before retaining Markdown', async () => {
|
||||
const body = migrateLegacyJiraImages(
|
||||
' and literal data:image/png;base64,AQID',
|
||||
target
|
||||
)
|
||||
expect(body).toMatch(
|
||||
/^!\[shot\]\(orca-jira-image:\/\/attachment\/[0-9a-f-]+\) and literal data:/
|
||||
)
|
||||
const url = body.match(/orca-jira-image:\/\/attachment\/[0-9a-f-]+/)?.[0]
|
||||
const reference = jiraImageReference(url)
|
||||
if (!reference) {
|
||||
throw new Error('Expected migrated image reference')
|
||||
}
|
||||
const image = await acquire(reference)
|
||||
expect(image.url).toMatch(/^blob:/)
|
||||
expect(download).not.toHaveBeenCalled()
|
||||
image.release()
|
||||
expect(migrateLegacyJiraImages('', target)).toBe('![bad]()')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,264 @@
|
||||
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
|
||||
import type { RuntimeHostStatusSnapshot } from '../../../shared/runtime-host-status'
|
||||
import {
|
||||
JIRA_IMAGE_MAX_BYTES,
|
||||
JIRA_IMAGE_MAX_REFERENCES,
|
||||
JIRA_IMAGE_MAX_PENDING_READS
|
||||
} from '../../../shared/jira-attachment-image'
|
||||
import { PrioritySemaphore } from '../../../shared/priority-semaphore'
|
||||
import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason'
|
||||
import { readRuntimeJiraImage } from './runtime-jira-image-client'
|
||||
import {
|
||||
getRuntimeEnvironmentRevision,
|
||||
onRuntimeEnvironmentsRetired,
|
||||
onRuntimeEnvironmentRevisionsChanged
|
||||
} from './runtime-environment-revision'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
|
||||
const MAX_CACHE_BYTES = 24 * 1024 * 1024
|
||||
const MAX_CACHE_ENTRIES = 96
|
||||
const CACHE_TTL_MS = 30 * 60_000
|
||||
const downloads = new PrioritySemaphore(3)
|
||||
type ImageEntry = { url: string; bytes: number; users: number; expiresAt: number }
|
||||
const entries = new Map<string, ImageEntry>()
|
||||
const pending = new Map<
|
||||
string,
|
||||
{ promise: Promise<void>; controller: AbortController; users: number }
|
||||
>()
|
||||
const invalidationListeners = new Set<(environmentId: string | null) => void>()
|
||||
const disconnectedHosts = new Set<string>()
|
||||
const lastHostStatusSequences = new Map<string, number>()
|
||||
let expiryTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function imageOwner(target: RuntimeClientTarget): string {
|
||||
return target.kind === 'environment' ? target.environmentId : 'local'
|
||||
}
|
||||
|
||||
function imageKey(target: RuntimeClientTarget, reference: string): string {
|
||||
const revision =
|
||||
target.kind === 'environment' ? getRuntimeEnvironmentRevision(target.environmentId) : 0
|
||||
return `${imageOwner(target)}::${revision ?? 0}::${reference}`
|
||||
}
|
||||
|
||||
function removeEntry(key: string, entry: ImageEntry): void {
|
||||
URL.revokeObjectURL(entry.url)
|
||||
entries.delete(key)
|
||||
}
|
||||
|
||||
function scheduleExpiry(): void {
|
||||
clearTimeout(expiryTimer)
|
||||
const expiresAt = Math.min(
|
||||
...[...entries.values()].filter((entry) => entry.users === 0).map((entry) => entry.expiresAt)
|
||||
)
|
||||
expiryTimer = Number.isFinite(expiresAt)
|
||||
? setTimeout(
|
||||
() => {
|
||||
evictUnused()
|
||||
scheduleExpiry()
|
||||
},
|
||||
Math.max(1, expiresAt - Date.now())
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function evictUnused(requiredBytes = 0): boolean {
|
||||
for (const [key, entry] of entries) {
|
||||
if (entry.users === 0 && entry.expiresAt <= Date.now()) {
|
||||
removeEntry(key, entry)
|
||||
}
|
||||
}
|
||||
let bytes = [...entries.values()].reduce((total, entry) => total + entry.bytes, 0)
|
||||
for (const [key, entry] of entries) {
|
||||
if (bytes + requiredBytes <= MAX_CACHE_BYTES && entries.size < MAX_CACHE_ENTRIES) {
|
||||
break
|
||||
}
|
||||
if (entry.users > 0) {
|
||||
continue
|
||||
}
|
||||
removeEntry(key, entry)
|
||||
bytes -= entry.bytes
|
||||
}
|
||||
return bytes + requiredBytes <= MAX_CACHE_BYTES && entries.size < MAX_CACHE_ENTRIES
|
||||
}
|
||||
|
||||
function saveImage(key: string, blob: Blob): boolean {
|
||||
if (!evictUnused(blob.size)) {
|
||||
return false
|
||||
}
|
||||
entries.set(key, {
|
||||
url: URL.createObjectURL(blob),
|
||||
bytes: blob.size,
|
||||
users: 0,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS
|
||||
})
|
||||
scheduleExpiry()
|
||||
return true
|
||||
}
|
||||
|
||||
export async function acquireJiraImageUrl(
|
||||
target: RuntimeClientTarget,
|
||||
reference: string,
|
||||
signal: AbortSignal
|
||||
): Promise<{ url: string; release: () => void }> {
|
||||
if (signal.aborted) {
|
||||
throw signal.reason
|
||||
}
|
||||
evictUnused()
|
||||
const key = imageKey(target, reference)
|
||||
let entry = entries.get(key)
|
||||
if (!entry) {
|
||||
let loading = pending.get(key)
|
||||
if (!loading || loading.controller.signal.aborted) {
|
||||
if (pending.size >= JIRA_IMAGE_MAX_PENDING_READS) {
|
||||
throw new Error('Jira image queue is full.')
|
||||
}
|
||||
const controller = new AbortController()
|
||||
loading = {
|
||||
controller,
|
||||
users: 0,
|
||||
promise: (async () => {
|
||||
const release = await downloads.acquire(0, controller.signal)
|
||||
try {
|
||||
const blob = await readRuntimeJiraImage(target, reference, controller.signal)
|
||||
if (controller.signal.aborted || key !== imageKey(target, reference)) {
|
||||
return
|
||||
}
|
||||
if (!saveImage(key, blob)) {
|
||||
throw new Error('Jira image cache is full.')
|
||||
}
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
})().finally(() => {
|
||||
if (pending.get(key)?.controller === controller) {
|
||||
pending.delete(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
pending.set(key, loading)
|
||||
}
|
||||
const owned = loading
|
||||
owned.users += 1
|
||||
try {
|
||||
await waitForPromiseWithSignal(owned.promise, signal)
|
||||
} finally {
|
||||
owned.users -= 1
|
||||
if (owned.users === 0 && pending.get(key) === owned) {
|
||||
owned.controller.abort()
|
||||
}
|
||||
}
|
||||
entry = entries.get(key)
|
||||
}
|
||||
if (!entry || signal.aborted) {
|
||||
throw new Error('Jira image is unavailable.')
|
||||
}
|
||||
entries.delete(key)
|
||||
entries.set(key, entry)
|
||||
entry.users += 1
|
||||
scheduleExpiry()
|
||||
const owned = entry
|
||||
let released = false
|
||||
return {
|
||||
url: owned.url,
|
||||
release: () => {
|
||||
if (released) {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
owned.users -= 1
|
||||
evictUnused()
|
||||
scheduleExpiry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function migrateLegacyJiraImages(content: string, target: RuntimeClientTarget): string {
|
||||
let admitted = 0
|
||||
return content.replace(
|
||||
/(!\[(?:\\.|[^\\\]])*\]\()data:(image\/(?:png|jpeg|gif|webp|bmp|avif|tiff|x-icon));base64,([A-Za-z0-9+/=]+)(\))/g,
|
||||
(_url, prefix: string, mimeType: string, encoded: string, suffix: string) => {
|
||||
if (
|
||||
++admitted > JIRA_IMAGE_MAX_REFERENCES ||
|
||||
encoded.length > Math.ceil(JIRA_IMAGE_MAX_BYTES / 3) * 4
|
||||
) {
|
||||
return `${prefix}${suffix}`
|
||||
}
|
||||
try {
|
||||
const bytes = Uint8Array.from(atob(encoded), (char) => char.charCodeAt(0))
|
||||
if (bytes.byteLength === 0 || bytes.byteLength > JIRA_IMAGE_MAX_BYTES) {
|
||||
return `${prefix}${suffix}`
|
||||
}
|
||||
const reference = createBrowserUuid()
|
||||
if (!saveImage(imageKey(target, reference), new Blob([bytes], { type: mimeType }))) {
|
||||
return `${prefix}${suffix}`
|
||||
}
|
||||
return `${prefix}orca-jira-image://attachment/${reference}${suffix}`
|
||||
} catch {
|
||||
return `${prefix}${suffix}`
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function hasCachedJiraImage(target: RuntimeClientTarget, reference: string): boolean {
|
||||
evictUnused()
|
||||
return entries.has(imageKey(target, reference))
|
||||
}
|
||||
|
||||
export function onJiraImageCacheInvalidated(
|
||||
listener: (environmentId: string | null) => void
|
||||
): () => void {
|
||||
invalidationListeners.add(listener)
|
||||
return () => invalidationListeners.delete(listener)
|
||||
}
|
||||
|
||||
export function clearJiraImageCache(environmentId?: string): void {
|
||||
const prefix = environmentId ? `${environmentId}::` : null
|
||||
for (const [key, entry] of entries) {
|
||||
if (!prefix || key.startsWith(prefix)) {
|
||||
removeEntry(key, entry)
|
||||
}
|
||||
}
|
||||
for (const [key, download] of pending) {
|
||||
if (!prefix || key.startsWith(prefix)) {
|
||||
download.controller.abort()
|
||||
pending.delete(key)
|
||||
}
|
||||
}
|
||||
scheduleExpiry()
|
||||
for (const listener of invalidationListeners) {
|
||||
listener(environmentId ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
export function observeJiraImageHostStatus(snapshot: RuntimeHostStatusSnapshot): void {
|
||||
if (
|
||||
getRuntimeEnvironmentRevision(snapshot.environmentId) !== snapshot.pairingRevision ||
|
||||
(lastHostStatusSequences.get(snapshot.environmentId) ?? -1) >= snapshot.sequence
|
||||
) {
|
||||
return
|
||||
}
|
||||
lastHostStatusSequences.set(snapshot.environmentId, snapshot.sequence)
|
||||
if (snapshot.retired || snapshot.verification === 'blocked') {
|
||||
disconnectedHosts.delete(snapshot.environmentId)
|
||||
clearJiraImageCache(snapshot.environmentId)
|
||||
} else if (snapshot.transport === 'disconnected') {
|
||||
disconnectedHosts.add(snapshot.environmentId)
|
||||
clearJiraImageCache(snapshot.environmentId)
|
||||
} else if (
|
||||
snapshot.transport === 'ready' &&
|
||||
snapshot.verification === 'verified' &&
|
||||
disconnectedHosts.delete(snapshot.environmentId)
|
||||
) {
|
||||
clearJiraImageCache(snapshot.environmentId)
|
||||
}
|
||||
}
|
||||
|
||||
function forgetJiraImageHost(environmentId: string): void {
|
||||
disconnectedHosts.delete(environmentId)
|
||||
lastHostStatusSequences.delete(environmentId)
|
||||
clearJiraImageCache(environmentId)
|
||||
}
|
||||
|
||||
onRuntimeEnvironmentsRetired((ids) => ids.forEach(forgetJiraImageHost))
|
||||
onRuntimeEnvironmentRevisionsChanged((ids) => ids.forEach(forgetJiraImageHost))
|
||||
@@ -0,0 +1,153 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { readRuntimeJiraImage } from './runtime-jira-image-client'
|
||||
import { encodeJiraImageFrame } from '../../../shared/jira-image-stream-frame'
|
||||
|
||||
type Callbacks = Parameters<typeof window.api.runtimeEnvironments.subscribe>[1]
|
||||
const subscribe = vi.fn()
|
||||
const streams: { callbacks: Callbacks; transferId: string; close: ReturnType<typeof vi.fn> }[] = []
|
||||
const target = { kind: 'environment' as const, environmentId: 'remote' }
|
||||
const reference = '12345678-1234-4234-8234-123456789012'
|
||||
function stream(index = 0) {
|
||||
const value = streams[index]
|
||||
if (!value) {
|
||||
throw new Error('Missing image stream')
|
||||
}
|
||||
return value
|
||||
}
|
||||
function emit(result: unknown, index = 0): void {
|
||||
stream(index).callbacks.onResponse({
|
||||
id: 'image',
|
||||
ok: true,
|
||||
result,
|
||||
_meta: { runtimeId: 'remote' }
|
||||
})
|
||||
}
|
||||
function send(bytes: Uint8Array, sequence = 0, index = 0): void {
|
||||
stream(index).callbacks.onBinary?.(
|
||||
encodeJiraImageFrame(stream(index).transferId, sequence, bytes)
|
||||
)
|
||||
}
|
||||
function read() {
|
||||
return readRuntimeJiraImage(target, reference, new AbortController().signal)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
streams.length = 0
|
||||
subscribe
|
||||
.mockReset()
|
||||
.mockImplementation(async (args: { params: unknown }, callbacks: Callbacks) => {
|
||||
if (
|
||||
!args.params ||
|
||||
typeof args.params !== 'object' ||
|
||||
!('transferId' in args.params) ||
|
||||
typeof args.params.transferId !== 'string'
|
||||
) {
|
||||
throw new Error('Missing transfer identity')
|
||||
}
|
||||
const close = vi.fn()
|
||||
streams.push({ callbacks, transferId: args.params.transferId, close })
|
||||
return { unsubscribe: close, sendBinary: vi.fn() }
|
||||
})
|
||||
vi.stubGlobal('window', { api: { runtimeEnvironments: { subscribe } } })
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('remote Jira binary images', () => {
|
||||
it('assembles only its own ordered image frames and closes the subscription', async () => {
|
||||
const image = read()
|
||||
emit({ type: 'image', mimeType: 'image/png', byteLength: 3 })
|
||||
stream().callbacks.onBinary?.(Uint8Array.from([0x74, 1, 0, 0]))
|
||||
stream().callbacks.onBinary?.(
|
||||
encodeJiraImageFrame('87654321-4321-4321-8321-210987654321', 0, Uint8Array.of(99))
|
||||
)
|
||||
send(Uint8Array.from([1, 2]))
|
||||
send(Uint8Array.of(3), 1)
|
||||
emit({ type: 'end' })
|
||||
const blob = await image
|
||||
expect(blob.type).toBe('image/png')
|
||||
expect(new Uint8Array(await blob.arrayBuffer())).toEqual(Uint8Array.from([1, 2, 3]))
|
||||
await Promise.resolve()
|
||||
expect(stream().close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ mimeType: 'image/svg+xml', byteLength: 1 },
|
||||
{ mimeType: 'image/png', byteLength: 8 * 1024 * 1024 + 1 },
|
||||
{ mimeType: 'image/png', byteLength: -1 }
|
||||
])('rejects invalid or oversized metadata before retaining bytes: %o', async (header) => {
|
||||
const image = read().catch((error) => error)
|
||||
emit({ type: 'image', ...header })
|
||||
expect(await image).toBeInstanceOf(Error)
|
||||
expect(stream().close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['before metadata', 'out of order', 'oversized', 'incomplete'] as const)(
|
||||
'rejects %s image chunks',
|
||||
async (mode) => {
|
||||
const image = read().catch((error) => error)
|
||||
if (mode !== 'before metadata') {
|
||||
emit({ type: 'image', mimeType: 'image/png', byteLength: 1 })
|
||||
}
|
||||
if (mode === 'incomplete') {
|
||||
emit({ type: 'end' })
|
||||
} else {
|
||||
send(
|
||||
mode === 'oversized' ? Uint8Array.from([1, 2]) : Uint8Array.of(1),
|
||||
mode === 'out of order' ? 1 : 0
|
||||
)
|
||||
}
|
||||
expect(await image).toBeInstanceOf(Error)
|
||||
expect(stream().close).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
|
||||
it('separates simultaneous image transfers on a shared binary carrier', async () => {
|
||||
const a = read()
|
||||
const b = read()
|
||||
emit({ type: 'image', mimeType: 'image/png', byteLength: 1 }, 0)
|
||||
emit({ type: 'image', mimeType: 'image/png', byteLength: 1 }, 1)
|
||||
for (const [index, byte] of [
|
||||
[0, 1],
|
||||
[1, 2]
|
||||
]) {
|
||||
const frame = encodeJiraImageFrame(stream(index).transferId, 0, Uint8Array.of(byte))
|
||||
for (const opened of streams) {
|
||||
opened.callbacks.onBinary?.(frame)
|
||||
}
|
||||
}
|
||||
emit({ type: 'end' }, 0)
|
||||
emit({ type: 'end' }, 1)
|
||||
expect(new Uint8Array(await (await a).arrayBuffer())).toEqual(Uint8Array.of(1))
|
||||
expect(new Uint8Array(await (await b).arrayBuffer())).toEqual(Uint8Array.of(2))
|
||||
})
|
||||
|
||||
it('cancels before the subscribe handle is returned', async () => {
|
||||
let finish: ((value: { unsubscribe: () => void; sendBinary: () => void }) => void) | undefined
|
||||
const close = vi.fn()
|
||||
subscribe.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
const controller = new AbortController()
|
||||
const image = readRuntimeJiraImage(target, reference, controller.signal).catch((error) => error)
|
||||
controller.abort()
|
||||
expect(await image).toBeInstanceOf(Error)
|
||||
finish?.({ unsubscribe: close, sendBinary: vi.fn() })
|
||||
await Promise.resolve()
|
||||
expect(close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('ends stalled image downloads after the transfer deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
const image = read().catch((error) => error)
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
expect(await image).toMatchObject({ message: 'Jira image transfer timed out.' })
|
||||
expect(stream().close).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
JIRA_IMAGE_MAX_BYTES,
|
||||
jiraImageReference,
|
||||
isJiraImageMimeType
|
||||
} from '../../../shared/jira-attachment-image'
|
||||
import { decodeJiraImageFrame } from '../../../shared/jira-image-stream-frame'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
|
||||
import { getRuntimeEnvironmentRevision } from './runtime-environment-revision'
|
||||
import { subscribeRuntimeEnvironment } from './runtime-environment-pairing-refresh'
|
||||
import { RuntimeRpcCallError } from './runtime-rpc-result'
|
||||
|
||||
export async function readRuntimeJiraImage(
|
||||
target: RuntimeClientTarget,
|
||||
reference: string,
|
||||
signal: AbortSignal
|
||||
): Promise<Blob> {
|
||||
if (target.kind !== 'environment') {
|
||||
throw new Error('Local Jira images use the image protocol.')
|
||||
}
|
||||
const transferId = createBrowserUuid()
|
||||
let sequence = 0
|
||||
const revision = getRuntimeEnvironmentRevision(target.environmentId)
|
||||
let close: (() => void) | undefined
|
||||
let closeWhenReady = false
|
||||
const chunks: Uint8Array<ArrayBuffer>[] = []
|
||||
let received = 0
|
||||
let mimeType = ''
|
||||
let expected = -1
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
let settled = false
|
||||
const timeout = setTimeout(() => fail(new Error('Jira image transfer timed out.')), 60_000)
|
||||
const cleanup = (): void => {
|
||||
clearTimeout(timeout)
|
||||
signal.removeEventListener('abort', abort)
|
||||
if (close) {
|
||||
close()
|
||||
} else {
|
||||
closeWhenReady = true
|
||||
}
|
||||
}
|
||||
const fail = (error: unknown): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
chunks.length = 0
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
const abort = (): void => fail(signal.reason ?? new Error('Jira image request cancelled.'))
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
if (signal.aborted) {
|
||||
abort()
|
||||
return
|
||||
}
|
||||
void subscribeRuntimeEnvironment(
|
||||
{
|
||||
selector: target.environmentId,
|
||||
method: 'jira.attachmentImage',
|
||||
params: { reference, transferId },
|
||||
timeoutMs: 60_000,
|
||||
expectedEnvironmentPairingRevision: revision
|
||||
},
|
||||
{
|
||||
onResponse: (response) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
if (!response.ok) {
|
||||
fail(new RuntimeRpcCallError(response))
|
||||
return
|
||||
}
|
||||
const value = response.result
|
||||
if (!value || typeof value !== 'object' || !('type' in value)) {
|
||||
fail(new Error('Invalid Jira image response.'))
|
||||
return
|
||||
}
|
||||
if (
|
||||
value.type === 'image' &&
|
||||
'mimeType' in value &&
|
||||
'byteLength' in value &&
|
||||
typeof value.mimeType === 'string' &&
|
||||
isJiraImageMimeType(value.mimeType) &&
|
||||
typeof value.byteLength === 'number' &&
|
||||
Number.isSafeInteger(value.byteLength) &&
|
||||
value.byteLength > 0 &&
|
||||
value.byteLength <= JIRA_IMAGE_MAX_BYTES &&
|
||||
expected === -1
|
||||
) {
|
||||
mimeType = value.mimeType
|
||||
expected = value.byteLength
|
||||
return
|
||||
}
|
||||
if (value.type === 'end' && received === expected && expected > 0) {
|
||||
settled = true
|
||||
const blob = new Blob(chunks, { type: mimeType })
|
||||
chunks.length = 0
|
||||
cleanup()
|
||||
resolve(blob)
|
||||
return
|
||||
}
|
||||
fail(new Error('Jira image was invalid or incomplete.'))
|
||||
},
|
||||
onBinary: (raw) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
let frame
|
||||
try {
|
||||
frame = decodeJiraImageFrame(raw, transferId)
|
||||
} catch (error) {
|
||||
fail(error)
|
||||
return
|
||||
}
|
||||
if (!frame) {
|
||||
return
|
||||
}
|
||||
if (frame.sequence !== sequence++) {
|
||||
fail(new Error('Jira image chunks arrived out of order.'))
|
||||
return
|
||||
}
|
||||
const chunk = frame.bytes
|
||||
if (expected < 0 || chunk.byteLength === 0) {
|
||||
fail(new Error('Jira image bytes arrived before metadata.'))
|
||||
return
|
||||
}
|
||||
received += chunk.byteLength
|
||||
if (received > JIRA_IMAGE_MAX_BYTES || (expected >= 0 && received > expected)) {
|
||||
fail(new Error('Jira image exceeded its transfer limit.'))
|
||||
return
|
||||
}
|
||||
chunks.push(Uint8Array.from(chunk))
|
||||
},
|
||||
onError: fail,
|
||||
onClose: () => fail(new Error('Jira image transfer closed before completion.'))
|
||||
}
|
||||
)
|
||||
.then((handle) => {
|
||||
close = handle.unsubscribe
|
||||
if (closeWhenReady) {
|
||||
close()
|
||||
}
|
||||
})
|
||||
.catch(fail)
|
||||
})
|
||||
}
|
||||
|
||||
export function jiraImageProtocolUrl(reference: string): string {
|
||||
const url = `orca-jira-image://attachment/${reference}`
|
||||
if (!jiraImageReference(url)) {
|
||||
throw new Error('Invalid Jira image reference.')
|
||||
}
|
||||
return url
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export const JIRA_IMAGE_SCHEME = 'orca-jira-image'
|
||||
export const JIRA_IMAGE_CAPABILITY = 'jira.attachment-images.v1' as const
|
||||
export const JIRA_IMAGE_MAX_BYTES = 8 * 1024 * 1024
|
||||
export const JIRA_IMAGE_MAX_REFERENCES = 24
|
||||
export const JIRA_IMAGE_MAX_PENDING_READS = 96
|
||||
export const JIRA_IMAGE_CHUNK_BYTES = 64 * 1024
|
||||
export type JiraMediaFormat = 'reference'
|
||||
|
||||
export function jiraImageReference(url: string | undefined): string | null {
|
||||
if (!url) {
|
||||
return null
|
||||
}
|
||||
const match =
|
||||
/^orca-jira-image:\/\/attachment\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i.exec(
|
||||
url
|
||||
)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
export function isJiraImageMimeType(value: string): boolean {
|
||||
return /^image\/(?:png|apng|jpeg|jpg|gif|webp|bmp|avif|tiff|x-icon|vnd\.microsoft\.icon)$/.test(
|
||||
value
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
import { JIRA_IMAGE_CAPABILITY } from './jira-attachment-image'
|
||||
|
||||
export const JIRA_ISSUE_SUMMARY_RUNTIME_CAPABILITY = 'jira.issue-summary-generation.v1' as const
|
||||
export const JIRA_USER_FIELDS_RUNTIME_CAPABILITY = 'jira.user-fields.v1' as const
|
||||
export const JIRA_TRANSITION_FIELDS_RUNTIME_CAPABILITY = 'jira.transition-fields.v1' as const
|
||||
export const JIRA_CURRENT_SPRINT_RUNTIME_CAPABILITY = 'jira.current-sprint.v1' as const
|
||||
export const JIRA_USER_FIELDS_UPDATE_REQUIRED_MESSAGE =
|
||||
'Creating Jira issues with user fields requires a newer Orca server. Update the server and try again.'
|
||||
|
||||
export const JIRA_DETAIL_RUNTIME_CAPABILITIES = [
|
||||
JIRA_USER_FIELDS_RUNTIME_CAPABILITY,
|
||||
JIRA_IMAGE_CAPABILITY,
|
||||
JIRA_ISSUE_SUMMARY_RUNTIME_CAPABILITY
|
||||
] as const
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { JIRA_IMAGE_CHUNK_BYTES } from './jira-attachment-image'
|
||||
|
||||
const IMAGE_KIND = 0x6a
|
||||
const IMAGE_VERSION = 1
|
||||
const HEADER_BYTES = 42
|
||||
const encoder = new TextEncoder()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
export function encodeJiraImageFrame(
|
||||
transferId: string,
|
||||
sequence: number,
|
||||
bytes: Uint8Array
|
||||
): Uint8Array<ArrayBuffer> {
|
||||
const frame = new Uint8Array(HEADER_BYTES + bytes.byteLength)
|
||||
frame[0] = IMAGE_KIND
|
||||
frame[1] = IMAGE_VERSION
|
||||
new DataView(frame.buffer).setUint32(2, sequence, true)
|
||||
frame.set(encoder.encode(transferId), 6)
|
||||
frame.set(bytes, HEADER_BYTES)
|
||||
return frame
|
||||
}
|
||||
|
||||
export function decodeJiraImageFrame(
|
||||
bytes: Uint8Array<ArrayBufferLike>,
|
||||
transferId: string
|
||||
): { sequence: number; bytes: Uint8Array<ArrayBufferLike> } | null {
|
||||
if (
|
||||
bytes.byteLength < HEADER_BYTES ||
|
||||
bytes[0] !== IMAGE_KIND ||
|
||||
bytes[1] !== IMAGE_VERSION ||
|
||||
decoder.decode(bytes.subarray(6, HEADER_BYTES)) !== transferId
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const payload = bytes.subarray(HEADER_BYTES)
|
||||
if (payload.byteLength === 0 || payload.byteLength > JIRA_IMAGE_CHUNK_BYTES) {
|
||||
throw new Error('Invalid Jira image chunk size.')
|
||||
}
|
||||
return {
|
||||
sequence: new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(2, true),
|
||||
bytes: payload
|
||||
}
|
||||
}
|
||||
@@ -385,8 +385,7 @@ export const RUNTIME_CAPABILITIES = [
|
||||
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY,
|
||||
FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY,
|
||||
LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY,
|
||||
jiraDetailCapabilities.JIRA_USER_FIELDS_RUNTIME_CAPABILITY,
|
||||
jiraDetailCapabilities.JIRA_ISSUE_SUMMARY_RUNTIME_CAPABILITY,
|
||||
...jiraDetailCapabilities.JIRA_DETAIL_RUNTIME_CAPABILITIES,
|
||||
JIRA_SAVED_FILTER_RUNTIME_CAPABILITY,
|
||||
JIRA_STATUS_FILTER_RUNTIME_CAPABILITY,
|
||||
AI_VAULT_RUNTIME_CAPABILITY,
|
||||
|
||||
@@ -48,7 +48,13 @@ export const ListIssues = z
|
||||
export const IssueKey = z.object({
|
||||
key: requiredString('Issue key is required'),
|
||||
siteId: OptionalString,
|
||||
strict: z.boolean().optional()
|
||||
strict: z.boolean().optional(),
|
||||
mediaFormat: z.literal('reference').optional()
|
||||
})
|
||||
|
||||
export const AttachmentImage = z.object({
|
||||
reference: z.string().uuid(),
|
||||
transferId: z.string().uuid()
|
||||
})
|
||||
|
||||
export const CreateIssue = z.object({
|
||||
|
||||
@@ -316,6 +316,7 @@ import {
|
||||
} from './hosted-review-params'
|
||||
import {
|
||||
AssignableUsers,
|
||||
AttachmentImage,
|
||||
CancelGenerateIssueSummary,
|
||||
Connect,
|
||||
CreateIssue as CreateIssueOfJiraParams,
|
||||
@@ -984,6 +985,7 @@ export const RPC_PARAMS_BY_METHOD = {
|
||||
'hostedReview.forBranch': HostedReviewForBranch,
|
||||
'hostedReview.getCreationEligibility': HostedReviewCreationEligibility,
|
||||
'jira.addIssueComment': IssueCommentOfJiraParams,
|
||||
'jira.attachmentImage': AttachmentImage,
|
||||
'jira.cancelGenerateIssueSummary': CancelGenerateIssueSummary,
|
||||
'jira.connect': Connect,
|
||||
'jira.createIssue': CreateIssueOfJiraParams,
|
||||
|
||||
Reference in New Issue
Block a user