Load Linear drawer fields in one issue query (#37663)

* Read Linear drawer details in one issue query

* Keep readable Linear details after optional field errors

* Select only Linear fields needed by each issue reader

* Explain the typed Linear partial response boundary

* Typecheck Linear integration in the mixed-process project

* Update Linear integration fixtures for raw issue reads
This commit is contained in:
Brennan Benson
2026-11-02 22:25:42 -08:00
committed by GitHub
parent a6489eb6e9
commit 86a2fe9684
16 changed files with 472 additions and 40 deletions
@@ -39,6 +39,7 @@ describe('typecheck project coverage', () => {
it('assigns cross-process integrations and their producer fixture to the mixed project', () => {
const files = [
'src/renderer/src/components/task-page/linear/task-page-linear-detail-query.test.tsx',
'src/renderer/src/components/terminal-pane/pty-agent-pane-launch.test.ts',
'src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-target-contract.test.tsx',
'src/renderer/src/runtime/observed-provider-accounts.test.tsx',
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "./tsconfig.web.json",
"exclude": [
"../src/renderer/src/components/task-page/linear/task-page-linear-detail-query.test.tsx",
"../src/renderer/src/components/terminal-pane/pty-agent-pane-launch.test.ts",
"../src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-target-contract.test.tsx",
"../src/renderer/src/runtime/observed-provider-accounts.test.tsx",
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "./tsconfig.web.json",
"include": [
"../src/renderer/src/components/task-page/linear/task-page-linear-detail-query.test.tsx",
"../src/renderer/src/components/terminal-pane/pty-agent-pane-launch.test.ts",
"../src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-target-contract.test.tsx",
"../src/renderer/src/runtime/observed-provider-accounts.test.tsx",
+1
View File
@@ -9,6 +9,7 @@
"../src/shared/**/*"
],
"exclude": [
"../src/renderer/src/components/task-page/linear/task-page-linear-detail-query.test.tsx",
"../src/renderer/src/components/terminal-pane/pty-agent-pane-launch.test.ts",
"../src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-target-contract.test.tsx",
"../src/renderer/src/runtime/observed-provider-accounts.test.tsx",
+1
View File
@@ -322,6 +322,7 @@
"es-module-lexer": "1.7.0",
"esbuild": "^0.28.2",
"github-slugger": "2.0.0",
"graphql": "16.14.2",
"happy-dom": "^21.0.4",
"hast-util-from-html-isomorphic": "2.0.0",
"hast-util-to-jsx-runtime": "2.3.6",
+3
View File
@@ -483,6 +483,9 @@ importers:
github-slugger:
specifier: 2.0.0
version: 2.0.0
graphql:
specifier: 16.14.2
version: 16.14.2
happy-dom:
specifier: ^21.0.4
version: 21.0.4
@@ -0,0 +1,92 @@
import { buildSchema, graphql } from 'graphql'
import { expect, it, vi } from 'vitest'
import { CHILDREN_QUERY, ISSUE_QUERY, SEARCH_QUERY } from './raw'
const schema = buildSchema(`
type Query { issue(id: String!): Issue, searchIssues(term: String!, first: Int): Issues }
type Issue {
id: String, identifier: String, title: String, url: String, description: String,
priority: Int, estimate: Float, dueDate: String, branchName: String,
createdAt: String, updatedAt: String, state: Named, team: Named, project: Named,
cycle: Named, assignee: User, labels(first: Int): Labels,
children(first: Int, after: String): Issues
}
type Named { id: String, name: String, type: String, color: String, key: String, url: String }
type User { id: String, displayName: String, avatarUrl: String }
type Label { id: String, name: String, color: String }
type Labels { nodes: [Label], pageInfo: PageInfo }
type Issues { nodes: [Issue], pageInfo: PageInfo }
type PageInfo { hasNextPage: Boolean, endCursor: String }
`)
const issue = {
id: 'issue',
createdAt: '2026-11-02T00:00:00Z',
state: { id: 'state', name: 'Todo' },
team: { id: 'team', color: 'blue' },
project: { id: 'project', url: 'https://linear.app/project' },
cycle: { id: 'cycle', name: 'Sprint' },
labels: { nodes: [{ id: 'label', name: 'Bug', color: 'red' }], pageInfo: { hasNextPage: false } }
}
const agentProjection = {
createdAt: issue.createdAt,
state: { id: 'state' },
team: { color: 'blue' },
cycle: issue.cycle,
labels: { nodes: [{ color: 'red' }], pageInfo: { hasNextPage: false } }
}
it('drawer selection never invokes agent-only resolvers that could hide readable detail', async () => {
const cycle = vi.fn(() => {
throw new Error('Cycle resolver unavailable')
})
const result = await graphql({
schema,
source: ISSUE_QUERY,
variableValues: { id: issue.id, includeDrawerFields: true },
rootValue: { issue: { ...issue, cycle, children: { nodes: [] } } }
})
expect(result.errors).toBeUndefined()
expect(cycle).not.toHaveBeenCalled()
expect(result.data).not.toHaveProperty('issue.createdAt')
expect(result.data).not.toHaveProperty('issue.cycle')
expect(result.data).toMatchObject({
issue: { id: issue.id, project: { url: issue.project.url }, children: { nodes: [] } }
})
})
it.each([
{
name: 'default issue',
query: ISSUE_QUERY,
variables: { id: issue.id },
data: { issue: agentProjection }
},
{
name: 'search',
query: SEARCH_QUERY,
variables: { term: 'issue' },
data: { searchIssues: { nodes: [agentProjection] } }
},
{
name: 'children',
query: CHILDREN_QUERY,
variables: { id: issue.id },
data: { issue: { children: { nodes: [agentProjection] } } }
}
])(
'retains all agent fields for $name without declaring drawer variables',
async ({ query, variables, data }) => {
const result = await graphql({
schema,
source: query,
variableValues: variables,
rootValue: {
issue: { ...issue, children: { nodes: [issue] } },
searchIssues: { nodes: [issue] }
}
})
expect(result.errors).toBeUndefined()
expect(result.data).toMatchObject(data)
}
)
+26 -8
View File
@@ -109,7 +109,7 @@ export type RawRelationsResponse = {
} | null
}
export const ISSUE_FIELDS = `
const ISSUE_CORE_FIELDS = `
id
identifier
title
@@ -119,20 +119,38 @@ export const ISSUE_FIELDS = `
estimate
dueDate
branchName
createdAt
updatedAt
state { id name type color }
team { id name key color }
state { name type color }
team { id name key }
project { id name color }
cycle { id name }
assignee { id displayName avatarUrl }
labels(first: 50) { nodes { id name color } pageInfo { hasNextPage } }
labels(first: 50) { nodes { id name } }
`
const ISSUE_AGENT_FIELDS = `
createdAt
state { id }
team { color }
cycle { id name }
labels(first: 50) { nodes { color } pageInfo { hasNextPage } }
`
export const ISSUE_FIELDS = `
${ISSUE_CORE_FIELDS}
${ISSUE_AGENT_FIELDS}
`
export const ISSUE_QUERY = `
query OrcaAgentLinearIssue($id: String!) {
query OrcaAgentLinearIssue($id: String!, $includeDrawerFields: Boolean = false) {
issue(id: $id) {
${ISSUE_FIELDS}
${ISSUE_CORE_FIELDS}
... @skip(if: $includeDrawerFields) {
${ISSUE_AGENT_FIELDS}
}
project @include(if: $includeDrawerFields) { url }
children(first: 25) @include(if: $includeDrawerFields) {
nodes { id identifier title url }
}
}
}
`
@@ -16,7 +16,7 @@ vi.mock('./client', () => ({ getClients, getPublicFileUrlClient, isAuthError }))
vi.mock('./linear-token-store', () => ({ clearToken: vi.fn() }))
vi.mock('./linear-request-concurrency', () => ({ acquire: vi.fn(), release: vi.fn() }))
vi.mock('./linear-issue-query-support', () => ({
mapIssueForWorkspace: async (_entry: unknown, result: unknown) => result
mapRawIssueForWorkspace: (_entry: unknown, result: unknown) => result
}))
const entry = {
@@ -30,7 +30,7 @@ const entry = {
apiKey: 'private-key',
client: new (loadLinearSdk().LinearClient)({ apiKey: 'private-key' })
}
entry.client.issue = issue
entry.client.client.rawRequest = issue
const source = 'https://uploads.linear.app/w/image'
const signed = `${source}?signature=fresh&expires=123`
@@ -45,7 +45,9 @@ describe('Linear description images', () => {
it('keeps editable description URLs canonical while supplying signed display URLs', async () => {
const description = `Before\n\n![Screenshot](${source})\n\nAfter`
issue.mockResolvedValue({ id: 'issue-1', description })
issue.mockResolvedValue({
data: { issue: { id: 'issue-1', description, updatedAt: '2026-11-02T00:00:00Z' } }
})
rawRequest.mockResolvedValue({
data: { issue: { description: description.replace(source, signed) } }
})
@@ -53,6 +55,7 @@ describe('Linear description images', () => {
await expect(getIssue('issue-1', 'workspace-1')).resolves.toEqual({
id: 'issue-1',
description,
updatedAt: '2026-11-02T00:00:00.000Z',
descriptionImageUrls: { [source]: signed }
})
expect(getPublicFileUrlClient).toHaveBeenCalledWith(entry, expect.any(AbortSignal))
@@ -86,8 +89,14 @@ describe('Linear description images', () => {
it.each([undefined, 'No images', '![Public](https://example.com/image.png)'])(
'skips the extra read for descriptions without private uploads: %s',
async (description) => {
issue.mockResolvedValue({ id: 'issue-1', description })
await expect(getIssue('issue-1')).resolves.toEqual({ id: 'issue-1', description })
issue.mockResolvedValue({
data: { issue: { id: 'issue-1', description, updatedAt: '2026-11-02T00:00:00Z' } }
})
await expect(getIssue('issue-1')).resolves.toMatchObject({
id: 'issue-1',
description,
updatedAt: '2026-11-02T00:00:00.000Z'
})
expect(getPublicFileUrlClient).not.toHaveBeenCalled()
}
)
@@ -108,7 +117,15 @@ describe('Linear description images', () => {
it('keeps the issue readable when the signing read fails', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
issue.mockResolvedValue({ id: 'issue-1', description: `![Screenshot](${source})` })
issue.mockResolvedValue({
data: {
issue: {
id: 'issue-1',
description: `![Screenshot](${source})`,
updatedAt: '2026-11-02T00:00:00Z'
}
}
})
rawRequest.mockRejectedValue(new Error('Network unavailable'))
await expect(getIssue('issue-1')).resolves.toMatchObject({ id: 'issue-1' })
})
@@ -12,7 +12,7 @@ vi.mock('./client', () => ({
isAuthError: () => false
}))
vi.mock('./linear-token-store', () => ({ clearToken: vi.fn() }))
vi.mock('./linear-issue-query-support', () => ({ mapIssueForWorkspace: mocks.map }))
vi.mock('./linear-issue-query-support', () => ({ mapRawIssueForWorkspace: mocks.map }))
const issue: LinearIssue = {
id: 'issue',
@@ -25,7 +25,7 @@ const issue: LinearIssue = {
labels: [],
labelIds: [],
priority: 0,
updatedAt: '2026-10-08'
updatedAt: '2026-10-08T00:00:00.000Z'
}
let server: Server
let apiUrl = ''
@@ -53,7 +53,7 @@ beforeEach(async () => {
if (answer && (!signingOnly || !body.includes('OrcaLinearDescriptionImages'))) {
response.end(
JSON.stringify({
data: { issue: { id: issue.id, sharedAccess: { sharedWithUsers: [] }, reactions: [] } }
data: { issue: { id: issue.id, updatedAt: issue.updatedAt } }
})
)
} else {
@@ -81,7 +81,7 @@ beforeEach(async () => {
mocks.signing.mockImplementation((_entry: unknown, signal: AbortSignal) =>
createLinearClient({ apiKey: 'probe-only', signal })
)
mocks.map.mockResolvedValue(issue)
mocks.map.mockReturnValue(issue)
vi.spyOn(globalThis, 'setTimeout').mockImplementation((callback, delay, ...args) => {
if (delay === 30_000) {
deadlines.push(() => callback(...args))
@@ -164,7 +164,7 @@ it('removes expired queued admission without spending or releasing a running per
it('uses the same deadline for a real SDK image-signing body and rejects a partial detail', async () => {
answer = true
signingOnly = true
mocks.map.mockResolvedValue({
mocks.map.mockReturnValue({
...issue,
description: '![Upload](https://uploads.linear.app/w/image)'
})
+79
View File
@@ -0,0 +1,79 @@
import type { LinearIssue } from '../../shared/linear/issue-types'
import type { LinearClientForWorkspace } from './client'
import { isAuthError } from './client'
import { ISSUE_QUERY } from './issue-context/raw'
import type { LinearIssueNode, LinearRawVariables } from './linear-issue-query-documents'
import { mapRawIssueForWorkspace } from './linear-issue-query-support'
import { loadLinearSdk } from './linear-sdk'
type LinearIssueDetailNode = LinearIssueNode & {
project?: { id: string; name: string; url?: string | null; color?: string | null } | null
children?: { nodes?: NonNullable<LinearIssue['subIssues']> } | null
}
type LinearIssueDetailResponse = { issue?: LinearIssueDetailNode | null }
const OPTIONAL_RELATIONS = {
state: true,
team: true,
assignee: true,
project: true,
labels: true,
children: true
}
function isOptionalRelation(field: string | undefined): field is keyof typeof OPTIONAL_RELATIONS {
return field !== undefined && Object.hasOwn(OPTIONAL_RELATIONS, field)
}
export async function readLinearIssueDetail(
entry: LinearClientForWorkspace,
id: string
): Promise<LinearIssue | null> {
let issue: LinearIssueDetailNode | null | undefined
try {
const result = await entry.client.client.rawRequest<
LinearIssueDetailResponse,
LinearRawVariables
>(ISSUE_QUERY, { id, includeDrawerFields: true })
issue = result.data?.issue
} catch (error) {
if (
!(error instanceof loadLinearSdk().LinearError) ||
isAuthError(error) ||
error.errors?.some(({ type }) => type === 'AuthenticationError') ||
error.status !== 200 ||
!error.errors?.length ||
!error.errors.every(({ path }) => path?.[0] === 'issue' && isOptionalRelation(path[1]))
) {
throw error
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SDK partial data uses this request's selected provider schema.
const data = error.data as LinearIssueDetailResponse | undefined
issue = data?.issue && { ...data.issue }
if (issue) {
// Keep the former SDK mapper's whole-relation failure isolation.
for (const { path } of error.errors) {
const field = path?.[1]
if (isOptionalRelation(field)) {
delete issue[field]
}
}
}
}
if (!issue) {
return null
}
return {
...mapRawIssueForWorkspace(entry, issue),
updatedAt: new Date(issue.updatedAt).toISOString(),
project: issue.project
? {
id: issue.project.id,
name: issue.project.name,
url: issue.project.url ?? undefined,
color: issue.project.color ?? undefined
}
: undefined,
subIssues: issue.children?.nodes
}
}
+5 -6
View File
@@ -8,6 +8,7 @@ import { acquire, release } from './linear-request-concurrency'
import { clearToken } from './linear-token-store'
import { getClients, isAuthError } from './client'
import { getRequiredEntry } from './issue-context/client'
import { readLinearIssueDetail } from './linear-issue-detail'
import { getDescriptionImageUrls } from './linear-description-images'
import {
ATTACHMENT_BY_UUID_QUERY,
@@ -21,7 +22,6 @@ import {
type LinearRawVariables
} from './linear-issue-query-documents'
import {
mapIssueForWorkspace,
mapRawIssueForWorkspace,
shouldThrowAuthError,
sortAndLimitIssues
@@ -67,11 +67,10 @@ async function readIssueWithSignal(
for (const entry of entries) {
await acquire(signal)
try {
const issue = await entry.client.issue(id)
const mapped = await mapIssueForWorkspace(entry, issue, {
includeChildren: true,
includeProject: true
})
const mapped = await readLinearIssueDetail(entry, id)
if (!mapped) {
continue
}
const descriptionImageUrls = await getDescriptionImageUrls(
entry,
mapped.id,
+2 -1
View File
@@ -1,4 +1,4 @@
import type { LinearClient, LinearClientOptions } from '@linear/sdk'
import type { LinearClient, LinearClientOptions, LinearError } from '@linear/sdk'
import { createRequire } from 'node:module'
import { recoverLinearQuery } from './linear-request-recovery'
@@ -6,6 +6,7 @@ import { recoverLinearQuery } from './linear-request-recovery'
export type LinearSdkModule = {
LinearClient: new (options: LinearClientOptions) => LinearClient
AuthenticationLinearError: new (...args: never[]) => Error
LinearError: new (...args: never[]) => LinearError
}
// Lazy synchronous loading keeps SDK parsing off startup; a separate module lets tests mock createRequire access.
@@ -0,0 +1,208 @@
// @vitest-environment happy-dom
import { reads, store, task } from './task-page-linear-refresh-test-fixtures'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { render, renderHook, screen, waitFor } from '@testing-library/react'
import LinearItemDrawer from '../../LinearItemDrawer'
import { useTaskPageLinearInOrcaEffects } from './use-task-page-linear-in-orca-effects'
import { useLinearIssueResult } from '@/hooks/linear-issue-committed-state'
import { getLinearSourceScope } from '@/store/slices/linear/linear-slice-scope'
import { defaultScopeSource } from '@/lib/default-creation-host'
import { getIssue } from '../../../../../main/linear/linear-issue-lookups'
import { createLinearClient, loadLinearSdk } from '../../../../../main/linear/linear-sdk'
import { ISSUE_QUERY } from '../../../../../main/linear/issue-context/raw'
import { linearIssueSchema } from '../../../../../../mobile/src/tasks/task-item-detail-reply-schema'
import type { LinearIssue } from '../../../../../shared/linear/issue-types'
const clients = vi.hoisted(() => ({ get: vi.fn() }))
vi.mock('../../../../../main/linear/client', () => ({
getClients: clients.get,
isAuthError: (error: unknown) => error instanceof loadLinearSdk().AuthenticationLinearError
}))
vi.mock('../../../../../main/linear/linear-token-store', () => ({ clearToken: vi.fn() }))
const child = {
id: 'child',
identifier: 'DAT-125',
title: 'Child task',
url: 'https://linear.app/acme/issue/DAT-125'
}
const project = {
id: 'project',
name: 'Release',
url: 'https://linear.app/acme/project/release',
color: '#888888'
}
const rawIssue = {
...task('Fresh'),
branchName: 'dat-124',
description: 'Full description',
priority: 2,
estimate: 3,
dueDate: '2026-11-10',
updatedAt: '2026-11-02T10:00:00-08:00',
assignee: { id: 'user', displayName: 'Ada', avatarUrl: 'https://example.com/avatar' },
project,
labels: { nodes: [{ id: 'bug', name: 'Bug' }] },
children: { nodes: [child] }
}
// The shared query returns the same first label page the drawer previously requested.
const { labelIds: _labelIds, ...responseIssue } = rawIssue
const expected = {
...task('Fresh'),
branchName: rawIssue.branchName,
description: rawIssue.description,
priority: 2,
estimate: 3,
dueDate: rawIssue.dueDate,
updatedAt: '2026-11-02T18:00:00.000Z',
workspaceName: 'Acme',
assignee: rawIssue.assignee,
project,
labels: ['Bug'],
labelIds: ['bug'],
subIssues: [child]
}
let requests: { query: string; variables: Record<string, unknown> }[]
beforeEach(() => {
requests = []
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => {
requests.push(JSON.parse(String(init?.body)))
return Response.json({ data: { issue: responseIssue } })
})
clients.get.mockImplementation((_workspaceId: string, signal: AbortSignal) => [
{
workspace: { id: 'w', organizationName: 'Acme' },
client: createLinearClient({ apiKey: 'test-only', signal })
}
])
reads.detail.mockImplementation((_source, id: string, workspaceId: string) =>
getIssue(id, workspaceId)
)
})
afterEach(() => vi.restoreAllMocks())
function expectQuery(index: number, id: string) {
expect(requests[index]).toEqual({
query: ISSUE_QUERY,
variables: { id, includeDrawerFields: true }
})
expect(requests[index].query).toContain('children(first: 25)')
expect(clients.get).toHaveBeenLastCalledWith('w', expect.any(AbortSignal))
}
it('forced drawer opens use one issue query and publish full detail for window and phone', async () => {
const s = store()
const drawer = render(<LinearItemDrawer issue={null} onUse={vi.fn()} onClose={vi.fn()} />)
drawer.rerender(<LinearItemDrawer issue={task()} onUse={vi.fn()} onClose={vi.fn()} />)
await waitFor(() => expect(screen.getByTestId('drawer').textContent).toBe('Fresh:2:Bug'))
expect(requests).toHaveLength(1)
expectQuery(0, task().id)
const detail = s.getState().linearIssueCache[`w::${task().id}`].data
expect(detail).toEqual(expected)
expect(linearIssueSchema.parse(detail)).toEqual(expected)
drawer.rerender(<LinearItemDrawer issue={null} onUse={vi.fn()} onClose={vi.fn()} />)
drawer.rerender(<LinearItemDrawer issue={task()} onUse={vi.fn()} onClose={vi.fn()} />)
await waitFor(() => expect(requests).toHaveLength(2))
await waitFor(() => expect(screen.getByTestId('drawer').textContent).toBe('Fresh:2:Bug'))
expectQuery(1, task().id)
})
it('linked-issue refresh uses one issue query and retains identifier and workspace routing', async () => {
const s = store()
const source = defaultScopeSource(null)
const scope = getLinearSourceScope(source)
const lastLinearRequestRef = { current: null }
const linkedRefs = { current: [{ identifier: task().identifier, workspaceId: 'w' }] }
const noop = () => {}
const linked = renderHook(
({ nonce }) => {
const result = useLinearIssueResult<LinearIssue[]>('linked', [], scope)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the linked loader only consumes these real slice actions, result ownership and request fields.
useTaskPageLinearInOrcaEffects({
...s.getState(),
linearConnected: true,
selectedLinearWorkspaceId: 'w',
taskSource: 'linear',
linearTaskSourceContext: source,
taskResumeApplied: true,
linearMode: 'in-orca',
setLinearIssues: result.setData,
linearIssueResult: result,
setLinearIssuesHasMore: noop,
setLinearLoading: result.setLoading,
setLinearError: result.setError,
linearRefreshNonce: nonce,
lastLinearRequestRef,
inOrcaLinkedLinearRefsSignature: 'w::DAT-124',
inOrcaLinkedLinearRefsRef: linkedRefs
} as never)
return result
},
{ initialProps: { nonce: 0 } }
)
await waitFor(() => expect(linked.result.current.data).toEqual([expected]))
expect(requests).toHaveLength(1)
expectQuery(0, 'DAT-124')
linked.rerender({ nonce: 1 })
await waitFor(() => expect(requests).toHaveLength(2))
await waitFor(() => expect(linked.result.current.loading).toBe(false))
expect(linked.result.current.data).toEqual([expected])
expect(linearIssueSchema.parse(linked.result.current.data[0])).toEqual(expected)
expectQuery(1, 'DAT-124')
})
it('keeps readable core detail when an optional relation returns partial GraphQL errors', async () => {
vi.mocked(globalThis.fetch).mockImplementation(async (_input, init) => {
requests.push(JSON.parse(String(init?.body)))
return Response.json({
data: {
issue: { ...responseIssue, assignee: { ...responseIssue.assignee, avatarUrl: null } }
},
errors: [{ message: 'Avatar resolver failed', path: ['issue', 'assignee', 'avatarUrl'] }]
})
})
await expect(getIssue(task().id, 'w')).resolves.toEqual({ ...expected, assignee: undefined })
expect(requests).toHaveLength(1)
expectQuery(0, task().id)
})
it.each([
{ path: ['issue', 'title'], extensions: undefined, auth: false },
{ path: ['issue', 'assignee'], extensions: { type: 'authentication error' }, auth: true }
])(
'does not accept partial data for a core or authentication failure: $path',
async ({ path, extensions, auth }) => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.mocked(globalThis.fetch).mockResolvedValue(
Response.json({
data: { issue: responseIssue },
errors: [{ message: 'Read failed', path, extensions }]
})
)
const read = getIssue(task().id, 'w')
await (auth ? expect(read).rejects.toThrow('Linear') : expect(read).resolves.toBeNull())
}
)
it.each([false, true])(
'rejects mixed authentication errors with auth first=%s',
async (authFirst) => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
const auth = {
message: 'Authentication required',
path: ['issue', 'assignee'],
extensions: { type: 'authentication error' }
}
const optional = { message: 'Project resolver failed', path: ['issue', 'project', 'name'] }
vi.mocked(globalThis.fetch).mockResolvedValue(
Response.json({
data: { issue: { ...responseIssue, project: null, assignee: null } },
errors: authFirst ? [auth, optional] : [optional, auth]
})
)
const read = getIssue(task().id, 'w')
await (authFirst ? expect(read).rejects.toThrow('Linear') : expect(read).resolves.toBeNull())
}
)
@@ -468,17 +468,19 @@ it.each(['success', 'fallback', 'legacy', 'release'] as const)(
const fresh = Promise.withResolvers<LinearIssue | null>()
const control = Promise.withResolvers<LinearIssue | null>()
let aliasLoads = 0
detailTransport.issue.mockReset().mockImplementation(async (_id, workspaceId) => {
const issue =
workspaceId === 'L'
? await (++aliasLoads === 1 ? old.promise : fresh.promise)
: aliasRow('Exact W', 'W')
if (!issue) {
throw new Error('missing issue')
}
// Only SDK I/O is synthetic; actual main concurrency/mapping/detail settlement runs.
return { ...issue, updatedAt: new Date(issue.updatedAt), labelIds: [] }
})
detailTransport.search
.mockReset()
.mockImplementation(async (_query, _variables, workspaceId) => {
const issue =
workspaceId === 'L'
? await (++aliasLoads === 1 ? old.promise : fresh.promise)
: aliasRow('Exact W', 'W')
if (!issue) {
throw new Error('missing issue')
}
// Only provider I/O is synthetic; actual main concurrency/mapping/detail settlement runs.
return { data: { issue: { ...issue, labels: { nodes: [] } } } }
})
detailTransport.rpc.mockReset().mockImplementation((target, method, params) => {
expect(target).toEqual({ kind: 'environment', environmentId: 'env-1' })
expect(method).toBe('linear.getIssue')
@@ -533,7 +535,8 @@ it.each(['success', 'fallback', 'legacy', 'release'] as const)(
workspaceId: 'W'
})
const loads = (workspaceId: string) =>
detailTransport.issue.mock.calls.filter(([, workspace]) => workspace === workspaceId).length
detailTransport.search.mock.calls.filter(([, , workspace]) => workspace === workspaceId)
.length
try {
fireEvent.click(screen.getByRole('button', { name: 'Linear tab' }))
await waitFor(() => expect(loads('L')).toBe(1))
@@ -60,6 +60,7 @@ const detailTransport = vi.hoisted(() => {
workspaces: ['workspace-1'],
accountFixture: false,
credential,
LinearError: class LinearError extends Error {},
AuthError: class AuthenticationLinearError extends Error {}
}
})
@@ -79,7 +80,9 @@ vi.mock('../../src/main/linear/client', async (original) => {
workspace: { id: workspaceId, organizationName: 'Acme' },
client: {
issue: (id: string) => detailTransport.issue(id, workspaceId),
client: { rawRequest: (...args: unknown[]) => detailTransport.search(...args) }
client: {
rawRequest: (...args: unknown[]) => detailTransport.search(...args, workspaceId)
}
}
}
]
@@ -98,7 +101,11 @@ vi.mock('../../src/main/linear/linear-sdk', () => {
}
}
return {
loadLinearSdk: () => ({ AuthenticationLinearError: detailTransport.AuthError, LinearClient }),
loadLinearSdk: () => ({
LinearError: detailTransport.LinearError,
AuthenticationLinearError: detailTransport.AuthError,
LinearClient
}),
createLinearClient: (options: { apiKey: string }) => new LinearClient(options)
}
})