fix(web): Jira reaches the server (#27136)

The web client had no Jira namespace, so every Jira call fell through to
the silent fallback and returned undefined. Forward each Jira method to the
server's jira.* RPCs, stream issue details, and warn once per namespace when
a call lands on the fallback.

Builds on #16803 by @JasonJarvan.

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
This commit is contained in:
OrcaWin
2026-10-09 21:37:03 -07:00
committed by GitHub
co-authored by m4air
parent 9a6b5d8753
commit 7178c7ed47
6 changed files with 239 additions and 0 deletions
@@ -0,0 +1,31 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createFallbackProxy } from './web-fallback-api'
describe('web fallback proxy', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('warns once per unimplemented namespace and still returns the default result', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
type Api = { first: () => Promise<unknown>; second: () => Promise<unknown> }
const missing: Api = createFallbackProxy(['missingNamespace'])
const other: Api = createFallbackProxy(['otherMissingNamespace'])
await expect(missing.first()).resolves.toBeUndefined()
await missing.second()
await other.first()
expect(warn).toHaveBeenCalledTimes(2)
expect(warn.mock.calls[0]?.[0]).toContain('window.api.missingNamespace.first')
expect(warn.mock.calls[1]?.[0]).toContain('window.api.otherMissingNamespace.first')
})
it('does not warn when a namespace routes calls through an override', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const routed: { call: () => unknown } = createFallbackProxy(['routedNamespace'], () => 'ok')
expect(routed.call()).toBe('ok')
expect(warn).not.toHaveBeenCalled()
})
})
@@ -21,6 +21,7 @@ export function createFallbackProxy(
applyOverride?: (path: string[], args: unknown[]) => unknown
): never {
const fn = () => undefined
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the proxy answers every property and call, so it stands in for any API member.
return new Proxy(fn, {
get(_target, property) {
if (property === 'then') {
@@ -32,11 +33,26 @@ export function createFallbackProxy(
if (applyOverride) {
return applyOverride(path, args)
}
warnFallbackNamespaceOnce(path)
return getFallbackResult(path, args)
}
}) as never
}
const warnedFallbackNamespaces = new Set<string>()
// Why: a missing web namespace otherwise fails silently with a default result (see #16802).
function warnFallbackNamespaceOnce(path: string[]): void {
const namespace = path[0] ?? ''
if (warnedFallbackNamespaces.has(namespace)) {
return
}
warnedFallbackNamespaces.add(namespace)
console.warn(
`[web] window.api.${path.join('.')} has no web implementation; returning a default result.`
)
}
export function getFallbackResult(path: string[], args: unknown[]): unknown {
const name = path.at(-1) ?? ''
if (name.startsWith('on')) {
@@ -0,0 +1,66 @@
import type { PreloadApi } from '../../../../preload/api-types'
import { readRuntimeJiraPayload } from '../../runtime/runtime-jira-payload-stream'
import { RuntimeRpcCallError } from '../../runtime/runtime-rpc-client'
import { callRuntimeResult } from './web-runtime-calls'
import { requireActiveEnvironment } from './web-runtime-session'
const JIRA_NETWORK_TIMEOUT_MS = 30_000
const JIRA_SETTINGS_TIMEOUT_MS = 15_000
function jiraCall<TResult>(method: string, params?: unknown, timeoutMs = JIRA_NETWORK_TIMEOUT_MS) {
return callRuntimeResult<TResult>(`jira.${method}`, params, timeoutMs)
}
// Why: issue bodies with inline images exceed the 1 MiB socket frame, so stream them in chunks.
async function readJiraPayload<TResult>(
streamMethod: string,
fallbackMethod: string,
args: unknown
): Promise<TResult> {
const target = { kind: 'environment', environmentId: requireActiveEnvironment().id } as const
try {
return await readRuntimeJiraPayload<TResult>(target, `jira.${streamMethod}`, args)
} catch (error) {
if (!(error instanceof RuntimeRpcCallError) || error.code !== 'method_not_found') {
throw error
}
// Older servers lack payload streaming but return text-only details one-shot.
return jiraCall<TResult>(fallbackMethod, args)
}
}
export function createWebJiraApi(): PreloadApi['jira'] {
return {
connect: (args) => jiraCall('connect', args),
disconnect: async (args) => {
await jiraCall('disconnect', args, JIRA_SETTINGS_TIMEOUT_MS)
},
selectSite: (args) => jiraCall('selectSite', args, JIRA_SETTINGS_TIMEOUT_MS),
status: () => jiraCall('status', undefined, JIRA_SETTINGS_TIMEOUT_MS),
readStatus: () => jiraCall('readStatus', undefined, JIRA_SETTINGS_TIMEOUT_MS),
testConnection: (args) => jiraCall('testConnection', args),
// Why: requestId keys desktop-only IPC cancellation; the server has no cancel method.
searchIssues: ({ requestId: _requestId, ...args }) => jiraCall('searchIssues', args),
cancelSearchIssues: () => Promise.resolve(),
listIssues: (args) => jiraCall('listIssues', args),
getIssue: (args) => readJiraPayload('getIssueStream', 'getIssue', args),
lookupIssueSummary: ({ requestId: _requestId, ...args }) =>
jiraCall('lookupIssueSummary', args),
cancelIssueSummary: () => Promise.resolve(),
createIssue: (args) => jiraCall('createIssue', args),
updateIssue: (args) => jiraCall('updateIssue', args),
addIssueComment: (args) => jiraCall('addIssueComment', args),
issueComments: (args) => readJiraPayload('issueCommentsStream', 'issueComments', args),
listProjects: (args) => jiraCall('listProjects', args),
listIssueTypes: (args) => jiraCall('listIssueTypes', args),
listCreateFields: (args) => jiraCall('listCreateFields', args),
listPriorities: (args) => jiraCall('listPriorities', args),
listAssignableUsers: (args) => jiraCall('listAssignableUsers', args),
// Why: servers expose no project-scoped user search; match the remote-environment fallback.
listAssignableUsersForProject: ({ query, siteId }) =>
jiraCall('searchUsers', { query, siteId }),
searchUsers: (args) => jiraCall('searchUsers', args),
listTransitions: (args) => jiraCall('listTransitions', args),
getProjectStatusOrder: (args) => jiraCall('getProjectStatusOrder', args)
}
}
@@ -46,6 +46,7 @@ describe('web preload API composition', () => {
'gl',
'hostedReview',
'linear',
'jira',
'hooks',
'stats',
'memory',
@@ -0,0 +1,123 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import {
installBrowserGlobals,
writeStoredRuntimeEnvironment
} from './web-preload-api-test-harness'
type RuntimeCall = { method: string; params: unknown }
function mockRuntimeClient(resultFor: (method: string) => unknown): RuntimeCall[] {
const runtimeCalls: RuntimeCall[] = []
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call(method: string, params?: unknown): Promise<RuntimeRpcResponse<unknown>> {
runtimeCalls.push({ method, params })
return Promise.resolve({
id: `call-${runtimeCalls.length}`,
ok: true,
result: resultFor(method),
_meta: { runtimeId: 'runtime-1' }
})
}
subscribe(
method: string,
params: unknown,
callbacks: { onResponse: (response: RuntimeRpcResponse<unknown>) => void }
): Promise<{ unsubscribe: () => void }> {
runtimeCalls.push({ method, params })
const payload = JSON.stringify(resultFor(method))
for (const result of [{ type: 'chunk', content: payload }, { type: 'end' }]) {
callbacks.onResponse({ id: 'stream-1', ok: true, result, _meta: { runtimeId: 'r-1' } })
}
return Promise.resolve({ unsubscribe: () => {} })
}
close(): void {}
}
}))
return runtimeCalls
}
async function installApi() {
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage)
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
// Why: return the globals, not the api proxy, which reads as a thenable.
return globals
}
describe('web Jira preload API', () => {
beforeEach(() => {
vi.resetModules()
})
afterEach(() => {
vi.unstubAllGlobals()
vi.doUnmock('./web-runtime-client')
})
it('routes Jira connector calls through the paired runtime', async () => {
const runtimeCalls = mockRuntimeClient((method) =>
method === 'jira.connect'
? { ok: true, viewer: { accountId: 'viewer-1', displayName: 'Test User' } }
: { connected: false, viewer: null }
)
const {
window: { api }
} = await installApi()
const credentials = {
siteUrl: 'https://jira.example.com',
email: 'user@example.com',
apiToken: 'token'
}
await expect(api.jira.connect(credentials)).resolves.toMatchObject({ ok: true })
await expect(api.jira.status()).resolves.toEqual({ connected: false, viewer: null })
expect(runtimeCalls).toEqual([
{ method: 'jira.connect', params: credentials },
{ method: 'jira.status', params: undefined }
])
})
it('maps desktop-only Jira methods onto the server methods that exist', async () => {
const runtimeCalls = mockRuntimeClient(() => [])
const {
window: { api }
} = await installApi()
await api.jira.searchIssues({ jql: 'project = ORCA', siteId: 'site-1', requestId: 'r1' })
await api.jira.lookupIssueSummary({ key: 'ORCA-1', siteId: 'site-1', requestId: 'r2' })
await api.jira.listAssignableUsersForProject({
projectIdOrKey: 'ORCA',
query: 'ann',
siteId: 'site-1'
})
await expect(api.jira.cancelSearchIssues({ requestId: 'r1' })).resolves.toBeUndefined()
await expect(api.jira.cancelIssueSummary({ requestId: 'r2' })).resolves.toBeUndefined()
expect(runtimeCalls).toEqual([
{ method: 'jira.searchIssues', params: { jql: 'project = ORCA', siteId: 'site-1' } },
{ method: 'jira.lookupIssueSummary', params: { key: 'ORCA-1', siteId: 'site-1' } },
{ method: 'jira.searchUsers', params: { query: 'ann', siteId: 'site-1' } }
])
})
it('streams issue details so inline images are not cut off by the socket frame cap', async () => {
const issue = { key: 'ORCA-1', title: 'Large issue' }
const runtimeCalls = mockRuntimeClient((method) =>
method === 'jira.getIssueStream' ? issue : null
)
const {
window: { api }
} = await installApi()
await expect(api.jira.getIssue({ key: 'ORCA-1', siteId: 'site-1' })).resolves.toEqual(issue)
expect(runtimeCalls).toEqual([
{ method: 'jira.getIssueStream', params: { key: 'ORCA-1', siteId: 'site-1' } }
])
})
})
+2
View File
@@ -40,6 +40,7 @@ import { createWebPlatformApi } from './preload-api/web-platform-api'
import { createRateLimitsApi } from './preload-api/web-rate-limits-api'
import { createReposApi } from './preload-api/web-repositories-api'
import { createHooksApi, createRuntimeNamespaceApi } from './preload-api/web-review-api'
import { createWebJiraApi } from './preload-api/web-jira-api'
import { callRuntimeResult } from './preload-api/web-runtime-calls'
import { createWebRuntimeApi } from './preload-api/web-runtime-api'
import { createRuntimeEnvironmentsApi } from './preload-api/web-runtime-environments-api'
@@ -91,6 +92,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
gl: createGitLabApi(),
hostedReview: createRuntimeNamespaceApi('hostedReview'),
linear: createRuntimeNamespaceApi('linear'),
jira: createWebJiraApi(),
hooks: createHooksApi(),
stats: {
getSummary: async () =>