mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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' } }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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 () =>
|
||||
|
||||
Reference in New Issue
Block a user