From 86a2fe9684e480b85f77986c61c56c277fdfee02 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:25:42 -0800 Subject: [PATCH] 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 --- .../typecheck-project-coverage.test.mjs | 1 + config/tsconfig.tc.web.json | 1 + config/tsconfig.test.pane-launch.json | 1 + config/tsconfig.web.json | 1 + package.json | 1 + pnpm-lock.yaml | 3 + .../issue-context/query-selections.test.ts | 92 ++++++++ src/main/linear/issue-context/raw.ts | 34 ++- .../linear/linear-description-images.test.ts | 29 ++- .../linear-issue-detail-deadline.test.ts | 10 +- src/main/linear/linear-issue-detail.ts | 79 +++++++ src/main/linear/linear-issue-lookups.ts | 11 +- src/main/linear/linear-sdk.ts | 3 +- .../task-page-linear-detail-query.test.tsx | 208 ++++++++++++++++++ ...ourceResultList.linear-read-error.test.tsx | 27 ++- ...rt-workspace-linear-read-test-fixtures.tsx | 11 +- 16 files changed, 472 insertions(+), 40 deletions(-) create mode 100644 src/main/linear/issue-context/query-selections.test.ts create mode 100644 src/main/linear/linear-issue-detail.ts create mode 100644 src/renderer/src/components/task-page/linear/task-page-linear-detail-query.test.tsx diff --git a/config/scripts/typecheck-project-coverage.test.mjs b/config/scripts/typecheck-project-coverage.test.mjs index b0e9e88eeef..f13979e9cc1 100644 --- a/config/scripts/typecheck-project-coverage.test.mjs +++ b/config/scripts/typecheck-project-coverage.test.mjs @@ -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', diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index bc7273724b4..4c6322b225b 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -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", diff --git a/config/tsconfig.test.pane-launch.json b/config/tsconfig.test.pane-launch.json index 8f3ae1b3b57..0a216a06d15 100644 --- a/config/tsconfig.test.pane-launch.json +++ b/config/tsconfig.test.pane-launch.json @@ -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", diff --git a/config/tsconfig.web.json b/config/tsconfig.web.json index 2168e5639ef..afe03ff049f 100644 --- a/config/tsconfig.web.json +++ b/config/tsconfig.web.json @@ -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", diff --git a/package.json b/package.json index fc7fb0c69d0..d64c980871d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c95cc8207ae..ca7f75ba32a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/main/linear/issue-context/query-selections.test.ts b/src/main/linear/issue-context/query-selections.test.ts new file mode 100644 index 00000000000..d7f99b082b1 --- /dev/null +++ b/src/main/linear/issue-context/query-selections.test.ts @@ -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) + } +) diff --git a/src/main/linear/issue-context/raw.ts b/src/main/linear/issue-context/raw.ts index c4fac5d19f9..42c7ecea658 100644 --- a/src/main/linear/issue-context/raw.ts +++ b/src/main/linear/issue-context/raw.ts @@ -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 } + } } } ` diff --git a/src/main/linear/linear-description-images.test.ts b/src/main/linear/linear-description-images.test.ts index 538266ecf5c..f1539d70b86 100644 --- a/src/main/linear/linear-description-images.test.ts +++ b/src/main/linear/linear-description-images.test.ts @@ -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' }) }) diff --git a/src/main/linear/linear-issue-detail-deadline.test.ts b/src/main/linear/linear-issue-detail-deadline.test.ts index 36e3e87a6d1..ade394f15f3 100644 --- a/src/main/linear/linear-issue-detail-deadline.test.ts +++ b/src/main/linear/linear-issue-detail-deadline.test.ts @@ -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)' }) diff --git a/src/main/linear/linear-issue-detail.ts b/src/main/linear/linear-issue-detail.ts new file mode 100644 index 00000000000..8b5137754a7 --- /dev/null +++ b/src/main/linear/linear-issue-detail.ts @@ -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 } | 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 { + 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 + } +} diff --git a/src/main/linear/linear-issue-lookups.ts b/src/main/linear/linear-issue-lookups.ts index 5edf59a1bfd..081f762043b 100644 --- a/src/main/linear/linear-issue-lookups.ts +++ b/src/main/linear/linear-issue-lookups.ts @@ -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, diff --git a/src/main/linear/linear-sdk.ts b/src/main/linear/linear-sdk.ts index 2f3576b325f..47b4169b358 100644 --- a/src/main/linear/linear-sdk.ts +++ b/src/main/linear/linear-sdk.ts @@ -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. diff --git a/src/renderer/src/components/task-page/linear/task-page-linear-detail-query.test.tsx b/src/renderer/src/components/task-page/linear/task-page-linear-detail-query.test.tsx new file mode 100644 index 00000000000..4089ec82534 --- /dev/null +++ b/src/renderer/src/components/task-page/linear/task-page-linear-detail-query.test.tsx @@ -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 }[] + +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() + drawer.rerender() + 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() + drawer.rerender() + 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('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()) + } +) diff --git a/tests/e2e/SmartWorkspaceSourceResultList.linear-read-error.test.tsx b/tests/e2e/SmartWorkspaceSourceResultList.linear-read-error.test.tsx index bd539041fd3..2f9c6595539 100644 --- a/tests/e2e/SmartWorkspaceSourceResultList.linear-read-error.test.tsx +++ b/tests/e2e/SmartWorkspaceSourceResultList.linear-read-error.test.tsx @@ -468,17 +468,19 @@ it.each(['success', 'fallback', 'legacy', 'release'] as const)( const fresh = Promise.withResolvers() const control = Promise.withResolvers() 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)) diff --git a/tests/e2e/smart-workspace-linear-read-test-fixtures.tsx b/tests/e2e/smart-workspace-linear-read-test-fixtures.tsx index 35b1aaf7683..b9d85f084cd 100644 --- a/tests/e2e/smart-workspace-linear-read-test-fixtures.tsx +++ b/tests/e2e/smart-workspace-linear-read-test-fixtures.tsx @@ -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) } })