diff --git a/src/main/github/__fixtures__/work-item-search-api.ts b/src/main/github/__fixtures__/work-item-search-api.ts new file mode 100644 index 00000000000..e48a5818cb9 --- /dev/null +++ b/src/main/github/__fixtures__/work-item-search-api.ts @@ -0,0 +1,214 @@ +import { z } from 'zod' +type Captured = { args: string[]; cwd?: string; fixtureCredential?: string } +type Issue = Record +export class WorkItemSearchApi { + calls: Captured[] = [] + restSearches = 0 + graphqlCalls = 0 + graphqlFields = 0 + restDetails = 0 + rejected = 0 + graphqlAvailable = true + searchAvailable = true + rowsPerRepo = 120 + specialNodes: Issue[] | undefined + aliasErrorRepo: string | undefined + expectedSearch: string | undefined + reportedCount: number | undefined + private nextCursor = 0 + private cursors = new Map() + private cache = new Map() + + private rows(query: string): Issue[] { + if (this.expectedSearch && query.replace(/ sort:created-desc$/, '') !== this.expectedSearch) { + throw new Error(`Unexpected fixture search ${query}`) + } + const repo = /repo:([^\s]+)/.exec(query)?.[1] ?? 'unknown/repo' + return ( + this.specialNodes ?? + Array.from({ length: this.rowsPerRepo }, (_, index) => ({ + __typename: 'Issue', + number: 10000 - index, + title: `${repo} issue ${index}`, + state: 'OPEN', + url: `https://github.com/${repo}/issues/${10000 - index}`, + updatedAt: '2026-09-11T00:00:00Z', + author: { + __typename: 'User', + login: 'author', + avatarUrl: 'https://avatars.githubusercontent.com/u/42?u=profile&v=4' + }, + labels: { nodes: [{ name: 'bug' }], pageInfo: { hasNextPage: false } }, + assignees: { nodes: [], pageInfo: { hasNextPage: false } } + })) + ) + } + + async capture( + _binary: string, + args: string[], + options: { cwd?: string; env?: NodeJS.ProcessEnv } + ): Promise<{ stdout: string; stderr: string }> { + const credential = options.env?.GH_TOKEN + this.calls.push({ + args: [...args], + cwd: options.cwd, + fixtureCredential: credential?.startsWith('fixture-') ? credential : undefined + }) + if (args.includes('rate_limit')) { + const bucket = { limit: 5000, remaining: 4500, reset: 3600 } + return { + stdout: JSON.stringify({ + resources: { + core: bucket, + graphql: { ...bucket, remaining: this.graphqlAvailable ? 4500 : 0 }, + search: { + limit: 30, + remaining: this.searchAvailable ? Math.max(0, 30 - this.restSearches) : 0, + reset: 60 + } + } + }), + stderr: '' + } + } + if (args[0] === 'pr') { + return { stdout: '[]', stderr: '' } + } + const endpoint = args.find((arg) => arg.startsWith('search/issues?')) + if (endpoint) { + const cached = args.includes('--cache') + ? this.cache.get(JSON.stringify([options.cwd, args])) + : undefined + if (cached !== undefined) { + return { stdout: cached, stderr: '' } + } + this.restSearches++ + if (!this.searchAvailable || this.restSearches > 30) { + this.rejected++ + throw Object.assign(new Error('HTTP 403: API rate limit exceeded'), { + stderr: 'HTTP 403: API rate limit exceeded' + }) + } + const url = new URL(endpoint, 'https://api.github.com') + const query = url.searchParams.get('q') ?? '' + const rows = this.rows(query) + const limit = Number(url.searchParams.get('per_page') ?? 1) + const page = Number(url.searchParams.get('page') ?? 1) + if (page * limit > 1000) { + throw Object.assign( + new Error('Only the first 1000 search results are available (HTTP 422)'), + { stderr: 'Only the first 1000 search results are available (HTTP 422)' } + ) + } + const stdout = args.includes('.total_count') + ? String(this.reportedCount ?? rows.length) + : JSON.stringify(rows.slice((page - 1) * limit, page * limit).map(this.restIssue)) + if (args.includes('--cache')) { + this.cache.set(JSON.stringify([options.cwd, args]), stdout) + } + return { stdout, stderr: '' } + } + const detail = args.find((arg) => /^repos\/.+\/issues\/\d+$/.test(arg)) + if (detail) { + this.restDetails++ + const row = this.rows('repo:fixture/repo').find( + (row) => row.number === Number(detail.split('/').at(-1)) + ) + return { + stdout: JSON.stringify({ + ...this.restIssue(row!), + labels: Array.from({ length: 125 }, (_, index) => ({ name: `label-${index}` })) + }), + stderr: '' + } + } + if (!args.includes('graphql')) { + throw new Error(`Unexpected fixture request ${args.join(' ')}`) + } + this.graphqlCalls++ + if (!this.graphqlAvailable) { + throw Object.assign(new Error('HTTP 403: API rate limit exceeded'), { + stderr: 'HTTP 403: API rate limit exceeded' + }) + } + const query = args.find((arg) => arg.startsWith('query='))?.slice(6) ?? '' + const fields = [ + ...query.matchAll( + /(r\d+): search\(type: ISSUE, query: ("(?:[^"\\]|\\.)*"), first: (\d+)(?:, after: ("(?:[^"\\]|\\.)*"))?\)/g + ) + ] + if (!fields.length) { + throw new Error(`Unexpected GraphQL fixture query ${query}`) + } + const data: Record = { rateLimit: { cost: 1 } } + const errors: unknown[] = [] + for (let index = 0; index < fields.length; index++) { + const field = fields[index] + this.graphqlFields++ + const search = z.string().parse(JSON.parse(field[2])) + if (this.aliasErrorRepo && search.includes(`repo:${this.aliasErrorRepo} `)) { + data[field[1]] = null + errors.push({ message: 'fixture repository search unavailable', path: [field[1]] }) + continue + } + const first = Number(field[3]) + const cursor = field[4] ? z.string().parse(JSON.parse(field[4])) : undefined + const saved = cursor ? this.cursors.get(cursor) : undefined + if (cursor && (!saved || saved.query !== search)) { + throw new Error('Unknown or cross-query opaque cursor') + } + const offset = saved?.offset ?? 0 + const rows = this.rows(search) + const page = rows.slice(offset, offset + first) + const next = offset + page.length + const endCursor = `opaque:${++this.nextCursor}:cursor` + this.cursors.set(endCursor, { query: search, offset: next }) + const selection = query.slice(field.index, fields[index + 1]?.index ?? query.length) + data[field[1]] = { + issueCount: this.reportedCount ?? rows.length, + pageInfo: { hasNextPage: next < rows.length, endCursor }, + ...(selection.includes(' nodes {') ? { nodes: page } : {}) + } + } + const stdout = JSON.stringify({ data, ...(errors.length ? { errors } : {}) }) + if (errors.length) { + throw Object.assign(new Error('GraphQL partial failure'), { + stdout, + stderr: 'GraphQL partial failure' + }) + } + return { stdout, stderr: '' } + } + + private restIssue(row: Issue): Issue { + const actor = (value: unknown) => { + const user = z + .object({ + __typename: z.string().optional(), + login: z.string(), + avatarUrl: z.string().optional() + }) + .nullable() + .parse(value) + return user + ? { + login: user.login + (user.__typename === 'Bot' ? '[bot]' : ''), + avatar_url: user.avatarUrl?.replace(/\?u=[^&]+&/, '?') + } + : null + } + return { + ...row, + state: String(row.state).toLowerCase(), + html_url: row.url, + updated_at: row.updatedAt, + user: actor(row.author), + labels: z.object({ nodes: z.array(z.unknown()) }).parse(row.labels).nodes, + assignees: z + .object({ nodes: z.array(z.unknown()) }) + .parse(row.assignees) + .nodes.map(actor) + } + } +} diff --git a/src/main/github/__fixtures__/work-item-search-metadata.json b/src/main/github/__fixtures__/work-item-search-metadata.json new file mode 100644 index 00000000000..d8fa785f853 --- /dev/null +++ b/src/main/github/__fixtures__/work-item-search-metadata.json @@ -0,0 +1,248 @@ +[ + { + "graphql": { + "number": 19933, + "title": "[Bug]: Browser element annotations cleared and overlay removed when scrolling the page (no reload)", + "state": "OPEN", + "url": "https://github.com/stablyai/orca/issues/19933", + "updatedAt": "2026-09-12T01:57:20Z", + "labels": { + "nodes": [ + { + "name": "bug" + }, + { + "name": "os:linux" + } + ], + "pageInfo": { + "hasNextPage": false + } + }, + "author": { + "__typename": "User", + "login": "peeraponw", + "avatarUrl": "https://avatars.githubusercontent.com/u/13129669?u=bf337820b1f6d507dfac4e48db7cf61e6c2c8b95&v=4" + }, + "assignees": { + "nodes": [ + { + "login": "AmethystLiang", + "avatarUrl": "https://avatars.githubusercontent.com/u/6427696?u=86a210ddf931a6a557a4664cc17a97dc02c9de36&v=4" + } + ], + "pageInfo": { + "hasNextPage": false + } + }, + "__typename": "Issue" + }, + "rest": { + "number": 19933, + "title": "[Bug]: Browser element annotations cleared and overlay removed when scrolling the page (no reload)", + "state": "open", + "html_url": "https://github.com/stablyai/orca/issues/19933", + "updated_at": "2026-09-12T01:57:20Z", + "labels": [ + { + "name": "bug" + }, + { + "name": "os:linux" + } + ], + "user": { + "login": "peeraponw", + "avatar_url": "https://avatars.githubusercontent.com/u/13129669?v=4" + }, + "assignees": [ + { + "login": "AmethystLiang", + "avatar_url": "https://avatars.githubusercontent.com/u/6427696?v=4" + } + ] + } + }, + { + "graphql": { + "number": 19932, + "title": "[Bug] Incorrect status after /usage", + "state": "OPEN", + "url": "https://github.com/stablyai/orca/issues/19932", + "updatedAt": "2026-09-10T21:44:41Z", + "labels": { + "nodes": [ + { + "name": "bug" + } + ], + "pageInfo": { + "hasNextPage": false + } + }, + "author": { + "__typename": "Bot", + "login": "orca-discord-issues", + "avatarUrl": "https://avatars.githubusercontent.com/u/127256420?v=4" + }, + "assignees": { + "nodes": [], + "pageInfo": { + "hasNextPage": false + } + }, + "__typename": "Issue" + }, + "rest": { + "number": 19932, + "title": "[Bug] Incorrect status after /usage", + "state": "open", + "html_url": "https://github.com/stablyai/orca/issues/19932", + "updated_at": "2026-09-10T21:44:41Z", + "labels": [ + { + "name": "bug" + } + ], + "user": { + "login": "orca-discord-issues[bot]", + "avatar_url": "https://avatars.githubusercontent.com/u/127256420?v=4" + }, + "assignees": [] + } + }, + { + "graphql": { + "number": 19926, + "title": "[Bug]: Copying assistant response adds visual-wrap line breaks when pasted into Google Chat (macOS)", + "state": "OPEN", + "url": "https://github.com/stablyai/orca/issues/19926", + "updatedAt": "2026-09-11T17:14:42Z", + "labels": { + "nodes": [], + "pageInfo": { + "hasNextPage": false + } + }, + "author": { + "__typename": "User", + "login": "JanPlessow", + "avatarUrl": "https://avatars.githubusercontent.com/u/202702070?u=6cdc81f81e27630db038e80eea4924c9ded7ddc2&v=4" + }, + "assignees": { + "nodes": [], + "pageInfo": { + "hasNextPage": false + } + }, + "__typename": "Issue" + }, + "rest": { + "number": 19926, + "title": "[Bug]: Copying assistant response adds visual-wrap line breaks when pasted into Google Chat (macOS)", + "state": "open", + "html_url": "https://github.com/stablyai/orca/issues/19926", + "updated_at": "2026-09-11T17:14:42Z", + "labels": [], + "user": { + "login": "JanPlessow", + "avatar_url": "https://avatars.githubusercontent.com/u/202702070?v=4" + }, + "assignees": [] + } + }, + { + "graphql": { + "number": 19919, + "title": "[Bug]: v1.4.199 Windows NSIS installer reports success but never installs Orca.exe (partial install)", + "state": "OPEN", + "url": "https://github.com/stablyai/orca/issues/19919", + "updatedAt": "2026-09-10T20:08:25Z", + "labels": { + "nodes": [], + "pageInfo": { + "hasNextPage": false + } + }, + "author": { + "__typename": "User", + "login": "Subdij", + "avatarUrl": "https://avatars.githubusercontent.com/u/105368200?u=d379763ab273d375d0cfff0215daf325bd59f4d6&v=4" + }, + "assignees": { + "nodes": [], + "pageInfo": { + "hasNextPage": false + } + }, + "__typename": "Issue" + }, + "rest": { + "number": 19919, + "title": "[Bug]: v1.4.199 Windows NSIS installer reports success but never installs Orca.exe (partial install)", + "state": "open", + "html_url": "https://github.com/stablyai/orca/issues/19919", + "updated_at": "2026-09-10T20:08:25Z", + "labels": [], + "user": { + "login": "Subdij", + "avatar_url": "https://avatars.githubusercontent.com/u/105368200?v=4" + }, + "assignees": [] + } + }, + { + "graphql": { + "number": 19918, + "title": "[Bug]: Svelte files do not get parsed or highlighted correctly", + "state": "OPEN", + "url": "https://github.com/stablyai/orca/issues/19918", + "updatedAt": "2026-09-10T20:06:53Z", + "labels": { + "nodes": [ + { + "name": "bug" + }, + { + "name": "os:macos" + } + ], + "pageInfo": { + "hasNextPage": false + } + }, + "author": { + "__typename": "User", + "login": "futuraprime", + "avatarUrl": "https://avatars.githubusercontent.com/u/181752?v=4" + }, + "assignees": { + "nodes": [], + "pageInfo": { + "hasNextPage": false + } + }, + "__typename": "Issue" + }, + "rest": { + "number": 19918, + "title": "[Bug]: Svelte files do not get parsed or highlighted correctly", + "state": "open", + "html_url": "https://github.com/stablyai/orca/issues/19918", + "updated_at": "2026-09-10T20:06:53Z", + "labels": [ + { + "name": "bug" + }, + { + "name": "os:macos" + } + ], + "user": { + "login": "futuraprime", + "avatar_url": "https://avatars.githubusercontent.com/u/181752?v=4" + }, + "assignees": [] + } + } +] diff --git a/src/main/github/client-issue-source.test.ts b/src/main/github/client-issue-source.test.ts index 17820ddd0f1..d9c593be3a0 100644 --- a/src/main/github/client-issue-source.test.ts +++ b/src/main/github/client-issue-source.test.ts @@ -2,6 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' import type * as GhUtils from './gh-utils' +// Keep legacy REST request/failure coverage; API-boundary suites exercise the GraphQL path. +vi.mock('./client/list/work-item-search-page', () => ({ usesGraphqlWorkItemSearch: () => false })) + const { execFileAsyncMock, ghExecFileAsyncMock, diff --git a/src/main/github/client-work-items-query-paging.test.ts b/src/main/github/client-work-items-query-paging.test.ts index 0e8b62bdfcf..6ff7ace0cc9 100644 --- a/src/main/github/client-work-items-query-paging.test.ts +++ b/src/main/github/client-work-items-query-paging.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' +// Keep legacy REST request/failure coverage; API-boundary suites exercise the GraphQL path. +vi.mock('./client/list/work-item-search-page', () => ({ usesGraphqlWorkItemSearch: () => false })) + const { execFileAsyncMock, ghExecFileAsyncMock, diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts index f26ccfc12d9..642b5c2aa4e 100644 --- a/src/main/github/client-work-items.test.ts +++ b/src/main/github/client-work-items.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' +// Keep legacy REST request/failure coverage; API-boundary suites exercise the GraphQL path. +vi.mock('./client/list/work-item-search-page', () => ({ usesGraphqlWorkItemSearch: () => false })) + const { execFileAsyncMock, ghExecFileAsyncMock, diff --git a/src/main/github/client/fetch/work-item-fetch.ts b/src/main/github/client/fetch/work-item-fetch.ts index aa4dd0794a8..f0c227d05e7 100644 --- a/src/main/github/client/fetch/work-item-fetch.ts +++ b/src/main/github/client/fetch/work-item-fetch.ts @@ -22,11 +22,13 @@ export async function fetchIssueWorkItem( ownerRepo: GitHubApiRepository | null, number: number, connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} + localGitOptions: LocalGitExecOptions = {}, + environment?: NodeJS.ProcessEnv ): Promise { const ghOptions = { ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) + ...githubHostExecOptions(ownerRepo), + ...(environment ? { env: environment } : {}) } if (ownerRepo) { const { stdout } = await ghExecFileAsync( diff --git a/src/main/github/client/list/count-work-items.ts b/src/main/github/client/list/count-work-items.ts index e742597685e..570220586f9 100644 --- a/src/main/github/client/list/count-work-items.ts +++ b/src/main/github/client/list/count-work-items.ts @@ -12,7 +12,8 @@ import { } from '../../gh-utils' import { githubHostExecOptions, - resolveIssueGitHubApiRepositorySource + resolveIssueGitHubApiRepositorySource, + type GitHubRepoExecOptions } from '../../github-api-repository' import { getRateLimit, @@ -23,6 +24,7 @@ import { import { sameOwnerRepo } from './../github-exec-scope' import { resolvePrWorkItemSource } from './work-item-list-request' import { buildSearchQueryString, defaultOpenWorkItemQuery } from './work-item-search-query' +import { searchWorkItemCount, usesGraphqlWorkItemSearch } from './work-item-search-page' export async function countWorkItemsForQuery( repoPath: string, ownerRepo: OwnerRepo, @@ -31,10 +33,23 @@ export async function countWorkItemsForQuery( localGitOptions: LocalGitExecOptions = {} ): Promise { const searchQ = buildSearchQueryString(ownerRepo, query) - const ghOptions = { + const ghOptions: GitHubRepoExecOptions = { ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), ...githubHostExecOptions(ownerRepo) } + if (usesGraphqlWorkItemSearch(ownerRepo, ghOptions)) { + ghOptions.env = { ...process.env } + try { + return await searchWorkItemCount(searchQ, ghOptions) + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw error + } + } + } + if (repositoryRateLimitGuard(ownerRepo, 'search', ghOptions).blocked) { + return 0 + } const { stdout } = await ghExecFileAsync( [ 'api', @@ -85,7 +100,10 @@ export async function countWorkItems( if (spendsSharedGitHubComQuota(ownerRepo, ghOptions)) { await getRateLimit() } - if (repositoryRateLimitGuard(ownerRepo, 'search', ghOptions).blocked) { + if ( + !usesGraphqlWorkItemSearch(ownerRepo, ghOptions) && + repositoryRateLimitGuard(ownerRepo, 'search', ghOptions).blocked + ) { return 0 } diff --git a/src/main/github/client/list/list-work-items.ts b/src/main/github/client/list/list-work-items.ts index e28037a0233..10192262f6a 100644 --- a/src/main/github/client/list/list-work-items.ts +++ b/src/main/github/client/list/list-work-items.ts @@ -58,7 +58,8 @@ export async function listWorkItems( limit, requestedPage, connectionId, - localGitOptions + localGitOptions, + noCache ) const errors = diff --git a/src/main/github/client/list/work-item-issue-page.ts b/src/main/github/client/list/work-item-issue-page.ts new file mode 100644 index 00000000000..ac1273887d2 --- /dev/null +++ b/src/main/github/client/list/work-item-issue-page.ts @@ -0,0 +1,138 @@ +import { z } from 'zod' +import type { ParsedTaskQuery } from '../../../../shared/task-query' +import { ghExecFileAsync, type LocalGitExecOptions, type OwnerRepo } from '../../gh-utils' +import { noteRepositoryRateLimitSpend } from '../../rate-limit' +import type { GitHubRepoExecOptions } from '../../github-api-repository' +import { fetchIssueWorkItem } from '../fetch/work-item-fetch' +import { mapIssueWorkItem } from '../map/work-item' +import type { MainWorkItem } from '../map/work-item-field-coercion' +import { buildWorkItemListRequest } from './work-item-list-request' +import { buildSearchQueryString } from './work-item-search-query' +import { searchWorkItemPage, usesGraphqlWorkItemSearch } from './work-item-search-page' + +type Actor = { __typename?: string; login: string; avatarUrl?: string } +type IssueNode = { + __typename: string + number: number + title: string + state: string + url: string + updatedAt: string + author: Actor | null + labels: { nodes: { name: string }[]; pageInfo: { hasNextPage: boolean } } + assignees: { nodes: Actor[]; pageInfo: { hasNextPage: boolean } } +} +const ISSUE_NODE_SELECTION = `__typename ... on Issue { + number title state url updatedAt + author { __typename login avatarUrl } + labels(first: 100) { nodes { name } pageInfo { hasNextPage } } + assignees(first: 100) { nodes { __typename login avatarUrl } pageInfo { hasNextPage } } +}` + +function restActor(actor: Actor | null): Record | null { + if (!actor) { + return null + } + const login = + actor.__typename === 'Bot' && !actor.login.endsWith('[bot]') + ? `${actor.login}[bot]` + : actor.login + let avatar = actor.avatarUrl + if (avatar) { + const url = new URL(avatar) + if (url.hostname === 'avatars.githubusercontent.com') { + url.searchParams.delete('u') + avatar = url.toString() + } + } + return { login, avatar_url: avatar } +} + +export async function listIssueWorkItemPage(args: { + repoPath: string + ownerRepo: OwnerRepo + query: ParsedTaskQuery + limit: number + page: number + options: GitHubRepoExecOptions + connectionId?: string | null + localGitOptions?: LocalGitExecOptions + noCache?: boolean +}): Promise { + const preferGraphql = usesGraphqlWorkItemSearch(args.ownerRepo, args.options) + const options = preferGraphql + ? { ...args.options, env: { ...(args.options.env ?? process.env) } } + : args.options + if (preferGraphql) { + try { + const nodes = await searchWorkItemPage({ + search: buildSearchQueryString(args.ownerRepo, { ...args.query, scope: 'issue' }), + nodeSelection: ISSUE_NODE_SELECTION, + limit: args.limit, + page: args.page, + options, + noCache: args.noCache + }) + const items: MainWorkItem[] = [] + for (const node of nodes) { + if (!node || node.__typename !== 'Issue') { + throw new Error('GitHub issue search response missing issue') + } + if ( + !Number.isSafeInteger(node.number) || + node.number <= 0 || + typeof node.title !== 'string' || + typeof node.url !== 'string' || + typeof node.updatedAt !== 'string' || + !['OPEN', 'CLOSED'].includes(node.state) + ) { + throw new Error('GitHub issue search response missing fields') + } + if (!node.labels?.pageInfo || !node.assignees?.pageInfo) { + throw new Error('GitHub issue search response missing association completeness') + } + if (node.labels.pageInfo.hasNextPage || node.assignees.pageInfo.hasNextPage) { + const complete = await fetchIssueWorkItem( + args.repoPath, + args.ownerRepo, + node.number, + args.connectionId, + args.localGitOptions, + options.env + ) + if (!complete) { + throw new Error('GitHub issue detail response missing issue') + } + items.push(complete) + continue + } + items.push( + mapIssueWorkItem({ + ...node, + state: node.state.toLowerCase(), + user: restActor(node.author), + labels: node.labels.nodes, + assignees: node.assignees.nodes.map(restActor) + }) + ) + } + return items + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw error + } + // REST retains exact search semantics when GraphQL is unavailable for this credential. + } + } + const request = buildWorkItemListRequest({ kind: 'issue', ...args }) + if (args.noCache) { + request.args.splice(1, 2) + } + const { stdout } = await ghExecFileAsync(request.args, options) + noteRepositoryRateLimitSpend(args.ownerRepo, 'search', 1, options) + return z + .array(z.record(z.string(), z.unknown())) + .parse(JSON.parse(stdout)) + .filter((item) => !('pull_request' in item)) + .map(mapIssueWorkItem) +} diff --git a/src/main/github/client/list/work-item-pages.ts b/src/main/github/client/list/work-item-pages.ts index 63d5e7a0098..73871ae1a45 100644 --- a/src/main/github/client/list/work-item-pages.ts +++ b/src/main/github/client/list/work-item-pages.ts @@ -15,12 +15,13 @@ import { githubHostExecOptions } from '../../github-api-repository' import { githubPRStackExecutionScope } from './../github-exec-scope' import { hydrateWorkItemRepositoryMergeMetadata } from './../detect/hydrate-work-item-merge-metadata' import type { MainWorkItem } from './../map/work-item-field-coercion' -import { mapIssueWorkItem, mapPullRequestWorkItem } from './../map/work-item' +import { mapPullRequestWorkItem } from './../map/work-item' import { buildWorkItemListRequest, assertSshRepoHasResolvedGitHubSource, type PartialWorkItemsResult } from './work-item-list-request' +import { listIssueWorkItemPage } from './work-item-issue-page' export async function listRecentWorkItems( repoPath: string, issueOwnerRepo: OwnerRepo | null, @@ -34,15 +35,6 @@ export async function listRecentWorkItems( const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo }) const recentQuery = parseTaskQuery('is:open') - const issueRequest = issueOwnerRepo - ? buildWorkItemListRequest({ - kind: 'issue', - ownerRepo: issueOwnerRepo, - limit, - query: recentQuery, - page - }) - : null const prRequest = prOwnerRepo ? buildWorkItemListRequest({ kind: 'pr', @@ -52,18 +44,22 @@ export async function listRecentWorkItems( page }) : null - if (noCache && issueRequest) { - issueRequest.args.splice(1, 2) - } // Why: unresolved sources must stay empty — an unscoped Search API would return other public repos' issues (#9660). // Why: allSettled so a 403 on the issue side doesn't zero the PR half (partial results + banner). const [issuesSettled, prsSettled] = await Promise.allSettled([ - issueRequest && issueOwnerRepo - ? ghExecFileAsync(issueRequest.args, { - ...ghOptions, - ...githubHostExecOptions(issueOwnerRepo) + issueOwnerRepo + ? listIssueWorkItemPage({ + repoPath, + ownerRepo: issueOwnerRepo, + query: recentQuery, + limit, + page, + options: { ...ghOptions, ...githubHostExecOptions(issueOwnerRepo) }, + connectionId, + localGitOptions, + noCache }) - : Promise.resolve({ stdout: '[]' }), + : Promise.resolve([]), prRequest && prOwnerRepo ? ghExecFileAsync(prRequest.args, { ...ghOptions, @@ -75,15 +71,7 @@ export async function listRecentWorkItems( let issues: MainWorkItem[] = [] let issuesError: ClassifiedError | undefined if (issuesSettled.status === 'fulfilled') { - try { - issues = (JSON.parse(issuesSettled.value.stdout) as Record[]) - // Why: search/issues can still return PRs (pull_request marker) even with is:issue; filter them out. - .filter((item) => !('pull_request' in item)) - .map(mapIssueWorkItem) - } catch (err) { - // Why: a malformed issue payload must not discard the successfully fetched PR half. - issuesError = classifyListIssuesError(err instanceof Error ? err.message : String(err)) - } + issues = issuesSettled.value } else { const stderr = issuesSettled.reason instanceof Error @@ -130,7 +118,8 @@ export async function listQueriedWorkItems( limit: number, page?: number, connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} + localGitOptions: LocalGitExecOptions = {}, + noCache?: boolean ): Promise { const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo }) @@ -154,27 +143,24 @@ export async function listQueriedWorkItems( if (!issueOwnerRepo) { return { items: [] } } - const request = buildWorkItemListRequest({ - kind: 'issue', - ownerRepo: issueOwnerRepo, - limit, - query, - page: page ?? 1 - }) try { - const { stdout } = await ghExecFileAsync(request.args, { - ...ghOptions, - ...githubHostExecOptions(issueOwnerRepo) + const items = await listIssueWorkItemPage({ + repoPath, + ownerRepo: issueOwnerRepo, + query, + limit, + page: page ?? 1, + options: { ...ghOptions, ...githubHostExecOptions(issueOwnerRepo) }, + connectionId, + localGitOptions, + noCache }) - const items = (JSON.parse(stdout) as Record[]) - .filter((item) => !('pull_request' in item)) - .map(mapIssueWorkItem) successfulRequestCount += 1 return { items } } catch (err) { const stderr = err instanceof Error ? err.message : String(err) if (classifyGitHubUnavailable(stderr)) { - availabilityError ??= err + availabilityError = err } else { nonAvailabilityFailureCount += 1 } diff --git a/src/main/github/client/list/work-item-search-batch.ts b/src/main/github/client/list/work-item-search-batch.ts new file mode 100644 index 00000000000..7f2154b328a --- /dev/null +++ b/src/main/github/client/list/work-item-search-batch.ts @@ -0,0 +1,194 @@ +import { z } from 'zod' +import { createHash } from 'node:crypto' +import { BoundedMap } from '../../../../shared/bounded-map' +import { runCoalescedProbe, type CoalescedProbes } from '../../../git/coalesced-probe' +import { createGhRateLimitBlockedError } from '../../../git/gh-rate-limit-breaker' +import { extractExecError, ghExecFileAsync } from '../../gh-utils' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../rate-limit' +import type { GitHubRepoExecOptions } from '../../github-api-repository' + +const envelopeSchema = z.object({ + data: z.record(z.string(), z.unknown()).nullish(), + errors: z + .array( + z.object({ + message: z.string().optional(), + path: z.array(z.union([z.string(), z.number()])).optional() + }) + ) + .optional() +}) +type Envelope = z.infer +type SearchRequest = { + search: string + first: number + after?: string + selection: string + options: GitHubRepoExecOptions + environment?: NodeJS.ProcessEnv + noCache?: boolean +} +type PendingSearch = { + request: SearchRequest + environment: NodeJS.ProcessEnv + resolve: (value: unknown) => void + reject: (error: unknown) => void +} + +export const WORK_ITEM_SEARCH_CACHE_MS = 120_000 +const MAX_BATCH = 10 +// Leave room for Windows argv escaping and the gh executable path. +const MAX_BATCH_QUERY_CHARS = 12_000 +const pending = new Map() +type SearchResponse = { at: number; value: T } +const inFlight: CoalescedProbes> = new Map() +const responses = new BoundedMap({ + maxEntries: 512, + maxBytes: 16 * 1024 * 1024, + sizeOf: (value, key) => Buffer.byteLength(key) + Buffer.byteLength(JSON.stringify(value)) +}) + +export function workItemSearchScope( + options: GitHubRepoExecOptions, + environment: NodeJS.ProcessEnv = options.env ?? process.env +): string { + // gh wrappers and credential selection can depend on cwd and the inherited environment. + return createHash('sha256') + .update( + JSON.stringify([ + options, + process.cwd(), + Object.entries(environment).sort(([a], [b]) => a.localeCompare(b)) + ]) + ) + .digest('hex') +} + +export function requestWorkItemSearch(request: SearchRequest): Promise> { + const environment = { ...(request.environment ?? request.options.env ?? process.env) } + const scope = workItemSearchScope(request.options, environment) + const key = JSON.stringify([ + scope, + request.search, + request.first, + request.after, + request.selection + ]) + const cached = request.noCache ? undefined : responses.get(key) + if (cached && Date.now() - cached.at < WORK_ITEM_SEARCH_CACHE_MS) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The cache key includes the complete selection; callers validate its response shape. + return Promise.resolve(cached as SearchResponse) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Coalescing uses the complete selection key; callers validate the selected response. + return runCoalescedProbe(inFlight, `${key}:${Boolean(request.noCache)}`, async (ownsKey) => { + const value = await new Promise((resolve, reject) => { + const batchKey = `${scope}:${Boolean(request.noCache)}` + let queue = pending.get(batchKey) + if (!queue) { + queue = [] + pending.set(batchKey, queue) + setTimeout(() => flushSearches(batchKey), 0) + } + queue.push({ request, environment, resolve, reject }) + }) + const response = { at: Date.now(), value } + if (!request.noCache && ownsKey()) { + responses.set(key, response) + } + return response + }) as Promise> +} + +function flushSearches(key: string): void { + const queue = pending.get(key) + pending.delete(key) + if (!queue) { + return + } + let batch: PendingSearch[] = [] + let characters = 0 + for (const entry of queue) { + const size = searchSelection(entry.request, batch.length).length + if (batch.length && (batch.length === MAX_BATCH || characters + size > MAX_BATCH_QUERY_CHARS)) { + void executeSearches(batch) + batch = [] + characters = 0 + } + batch.push(entry) + characters += size + } + if (batch.length) { + void executeSearches(batch) + } +} + +function searchSelection(request: SearchRequest, index: number): string { + const after = request.after ? `, after: ${JSON.stringify(request.after)}` : '' + return `r${index}: search(type: ISSUE, query: ${JSON.stringify(request.search)}, first: ${request.first}${after}) { ${request.selection} }` +} + +async function executeSearches(batch: PendingSearch[]): Promise { + const { options } = batch[0].request + try { + const guard = repositoryRateLimitGuard(options, 'graphql', options) + if (guard.blocked) { + throw createGhRateLimitBlockedError('graphql', guard.resetAt * 1000) + } + const selections = batch.map(({ request }, index) => searchSelection(request, index)) + const query = `query { ${selections.join('\n')} rateLimit { cost } }` + // One response cache owns age; a gh cache hit would otherwise renew an older response. + const args = ['api', 'graphql', '-f', `query=${query}`] + let envelope: Envelope + try { + const { stdout } = await ghExecFileAsync(args, { + ...options, + env: batch[0].environment, + idempotent: true + }) + envelope = envelopeSchema.parse(JSON.parse(stdout)) + } catch (error) { + const { stdout } = extractExecError(error) + if (!stdout) { + throw error + } + try { + envelope = envelopeSchema.parse(JSON.parse(stdout)) + } catch { + throw error + } + if (!envelope.data || !envelope.errors?.length) { + throw error + } + } + const rateLimit = envelope.data?.rateLimit + const cost = + rateLimit && typeof rateLimit === 'object' && 'cost' in rateLimit ? rateLimit.cost : undefined + noteRepositoryRateLimitSpend( + options, + 'graphql', + typeof cost === 'number' && Number.isFinite(cost) && cost >= 0 ? cost : batch.length, + options + ) + for (const [index, entry] of batch.entries()) { + const alias = `r${index}` + const errors = envelope.errors?.filter( + (error) => !error.path?.length || error.path[0] === alias + ) + const value = envelope.data?.[alias] + if (errors?.length || value === undefined || value === null) { + entry.reject( + new Error( + errors?.map((error) => error.message).join('; ') || + 'GitHub search response missing data' + ) + ) + } else { + entry.resolve(value) + } + } + } catch (error) { + for (const entry of batch) { + entry.reject(error) + } + } +} diff --git a/src/main/github/client/list/work-item-search-page.ts b/src/main/github/client/list/work-item-search-page.ts new file mode 100644 index 00000000000..e03c16e777b --- /dev/null +++ b/src/main/github/client/list/work-item-search-page.ts @@ -0,0 +1,128 @@ +import { BoundedMap } from '../../../../shared/bounded-map' +import { isDefaultGitHubHost } from '../../../../shared/github/repository-identity-key' +import type { OwnerRepo } from '../../gh-utils' +import type { GitHubRepoExecOptions } from '../../github-api-repository' +import { + requestWorkItemSearch, + workItemSearchScope, + WORK_ITEM_SEARCH_CACHE_MS +} from './work-item-search-batch' + +type PageInfo = { endCursor: string | null; hasNextPage: boolean } +export type SearchConnection = { issueCount: number; pageInfo: PageInfo; nodes: T[] } +const cursors = new BoundedMap({ + maxEntries: 1024, + maxBytes: 1024 * 1024, + sizeOf: (value, key) => Buffer.byteLength(key) + Buffer.byteLength(value.cursor) + 8 +}) + +export function usesGraphqlWorkItemSearch( + ownerRepo: OwnerRepo, + options: GitHubRepoExecOptions +): boolean { + return isDefaultGitHubHost( + ownerRepo.host ?? options.host ?? options.env?.GH_HOST ?? process.env.GH_HOST + ) +} + +export async function searchWorkItemCount( + search: string, + options: GitHubRepoExecOptions +): Promise { + const { value: result } = await requestWorkItemSearch<{ issueCount: number }>({ + search, + first: 1, + selection: 'issueCount', + options + }) + if (!Number.isSafeInteger(result.issueCount) || result.issueCount < 0) { + throw new Error('GitHub search response missing count') + } + return result.issueCount +} + +export async function searchWorkItemPage(args: { + search: string + nodeSelection: string + limit: number + page: number + options: GitHubRepoExecOptions + noCache?: boolean +}): Promise { + const { options, noCache } = args + const environment = { ...(options.env ?? process.env) } + if (!Number.isSafeInteger(args.limit) || args.limit < 1) { + throw new Error('Invalid GitHub search page limit') + } + const limit = Math.min(100, args.limit) + const offset = (args.page - 1) * limit + if (offset + limit > 1000) { + throw new Error('Only the first 1000 search results are available (HTTP 422)') + } + const search = `${args.search} sort:created-desc` + const scope = JSON.stringify([workItemSearchScope(options, environment), search]) + let position = 0 + let after: string | undefined + if (!noCache) { + for (let at = offset; at > 0; at--) { + const cached = cursors.get(`${scope}:${at}`) + if (cached && Date.now() - cached.at < WORK_ITEM_SEARCH_CACHE_MS) { + position = at + after = cached.cursor + break + } + } + } + const remember = (position: number, info: PageInfo, at: number): void => { + if ( + typeof info.hasNextPage !== 'boolean' || + (info.endCursor !== null && typeof info.endCursor !== 'string') + ) { + throw new Error('GitHub search response invalid pagination') + } + if (info.hasNextPage && !info.endCursor) { + throw new Error('GitHub search response missing cursor') + } + if (!noCache && info.endCursor) { + cursors.set(`${scope}:${position}`, { at, cursor: info.endCursor }) + } + } + while (position < offset) { + const first = Math.min(100, offset - position) + const { value: skipped, at } = await requestWorkItemSearch>({ + search, + first, + after, + selection: 'issueCount pageInfo { endCursor hasNextPage }', + options, + environment, + noCache + }) + if (!skipped.pageInfo || !Number.isSafeInteger(skipped.issueCount)) { + throw new Error('GitHub search response missing pagination') + } + if (skipped.issueCount <= offset) { + return [] + } + if (!skipped.pageInfo.hasNextPage) { + throw new Error('GitHub search pagination ended before requested page') + } + position += first + remember(position, skipped.pageInfo, at) + after = skipped.pageInfo.endCursor ?? undefined + } + const { value: result, at } = await requestWorkItemSearch>({ + search, + first: limit, + after, + selection: `issueCount pageInfo { endCursor hasNextPage } nodes { ${args.nodeSelection} }`, + options, + environment, + noCache + }) + if (!Array.isArray(result.nodes) || !result.pageInfo) { + throw new Error('GitHub search response missing page') + } + remember(offset + result.nodes.length, result.pageInfo, at) + return result.nodes +} diff --git a/src/main/github/github-api-repository.ts b/src/main/github/github-api-repository.ts index 7e394789714..7e72ec9da9e 100644 --- a/src/main/github/github-api-repository.ts +++ b/src/main/github/github-api-repository.ts @@ -32,7 +32,10 @@ export { githubRepositoryWebHost } from './github-repository-host' export type GitHubApiRepository = GitHubOwnerRepo -export type GitHubRepoExecOptions = ReturnType & { host?: string } +export type GitHubRepoExecOptions = ReturnType & { + host?: string + env?: NodeJS.ProcessEnv +} export type GitHubRepoExecution = { ownerRepo: GitHubApiRepository | null ghOptions: GitHubRepoExecOptions diff --git a/src/main/github/work-item-search-fallback-environment.test.ts b/src/main/github/work-item-search-fallback-environment.test.ts new file mode 100644 index 00000000000..9f57048d9f6 --- /dev/null +++ b/src/main/github/work-item-search-fallback-environment.test.ts @@ -0,0 +1,86 @@ +import { expect, it, vi } from 'vitest' +import { api, capture } from './work-item-search-test-harness' +import { listWorkItems } from './client/list/list-work-items' +import { countWorkItems } from './client/list/count-work-items' +import metadata from './__fixtures__/work-item-search-metadata.json' + +it('preserves the Search budget floor when the preferred count fails', async () => { + api.restSearches = 29 + api.aliasErrorRepo = 'fixture/repo' + + expect(await countWorkItems('fixture/repo', 'is:issue')).toBe(0) + expect(api.calls.some((call) => call.args.includes('graphql'))).toBe(true) + expect(api.calls.some((call) => call.args.some((arg) => arg.startsWith('search/issues?')))).toBe( + false + ) + expect(api.restSearches).toBe(29) +}) + +it('still counts through GraphQL when the REST Search budget is below its floor', async () => { + api.restSearches = 29 + + expect(await countWorkItems('fixture/repo', 'is:issue')).toBe(120) + expect(api.restSearches).toBe(29) +}) + +it('keeps REST fallback on the credential captured for the failed preferred search', async () => { + vi.stubEnv('GH_TOKEN', 'fixture-original-credential') + api.aliasErrorRepo = 'fixture/repo' + capture.mockImplementation(async (binary, args, options) => { + if (args.includes('graphql')) { + vi.stubEnv('GH_TOKEN', 'fixture-later-credential') + } + return api.capture(binary, args, options) + }) + expect((await listWorkItems('fixture/repo', 24, 'is:issue')).items).toHaveLength(24) + const queries = api.calls.filter( + (call) => + call.args.includes('graphql') || call.args.some((arg) => arg.startsWith('search/issues?')) + ) + expect(queries.map((call) => call.fixtureCredential)).toEqual([ + 'fixture-original-credential', + 'fixture-original-credential' + ]) +}) + +it('keeps count fallback on its captured credential', async () => { + vi.stubEnv('GH_TOKEN', 'fixture-original-credential') + api.aliasErrorRepo = 'fixture/repo' + capture.mockImplementation(async (binary, args, options) => { + if (args.includes('graphql')) { + vi.stubEnv('GH_TOKEN', 'fixture-later-credential') + } + return api.capture(binary, args, options) + }) + expect(await countWorkItems('fixture/repo', 'is:issue')).toBe(120) + const queries = api.calls.filter( + (call) => + call.args.includes('graphql') || call.args.some((arg) => arg.startsWith('search/issues?')) + ) + expect(queries.map((call) => call.fixtureCredential)).toEqual([ + 'fixture-original-credential', + 'fixture-original-credential' + ]) +}) + +it('hydrates oversized associations with the preferred page credential', async () => { + vi.stubEnv('GH_TOKEN', 'fixture-original-credential') + const row = structuredClone(metadata[0].graphql) + api.specialNodes = [{ ...row, labels: { ...row.labels, pageInfo: { hasNextPage: true } } }] + capture.mockImplementation(async (binary, args, options) => { + if (args.includes('graphql')) { + vi.stubEnv('GH_TOKEN', 'fixture-later-credential') + } + return api.capture(binary, args, options) + }) + const result = await listWorkItems('fixture/repo', 24, 'is:issue') + expect(result.items[0].labels).toHaveLength(125) + const queries = api.calls.filter( + (call) => + call.args.includes('graphql') || call.args.some((arg) => /^repos\/.+\/issues\/\d+$/.test(arg)) + ) + expect(queries.map((call) => call.fixtureCredential)).toEqual([ + 'fixture-original-credential', + 'fixture-original-credential' + ]) +}) diff --git a/src/main/github/work-item-search-freshness.test.ts b/src/main/github/work-item-search-freshness.test.ts new file mode 100644 index 00000000000..bbc59bc471e --- /dev/null +++ b/src/main/github/work-item-search-freshness.test.ts @@ -0,0 +1,32 @@ +import { expect, it, vi } from 'vitest' +import { api, capture } from './work-item-search-test-harness' +import { searchWorkItemCount } from './client/list/work-item-search-page' + +it('does not renew a gh-cached response after bounded response-cache eviction', async () => { + const ghCache = new Map< + string, + { expires: number; response: { stdout: string; stderr: string } } + >() + capture.mockImplementation(async (binary, args, options) => { + const key = JSON.stringify([options.cwd, options.env?.GH_TOKEN, args]) + const cached = args.includes('--cache') ? ghCache.get(key) : undefined + if (cached && cached.expires > Date.now()) { + return cached.response + } + const response = await api.capture(binary, args, options) + if (args.includes('--cache')) { + ghCache.set(key, { expires: Date.now() + 120000, response }) + } + return response + }) + const search = 'repo:fixture/first is:issue' + expect(await searchWorkItemCount(search, {})).toBe(120) + for (let index = 0; index < 512; index++) { + await searchWorkItemCount(`repo:fixture/evict-${index} is:issue`, {}) + } + api.reportedCount = 121 + vi.setSystemTime(119000) + await searchWorkItemCount(search, {}) + vi.setSystemTime(120001) + expect(await searchWorkItemCount(search, {})).toBe(121) +}) diff --git a/src/main/github/work-item-search-isolation.test.ts b/src/main/github/work-item-search-isolation.test.ts new file mode 100644 index 00000000000..de11bdacc2e --- /dev/null +++ b/src/main/github/work-item-search-isolation.test.ts @@ -0,0 +1,140 @@ +import { expect, it, vi } from 'vitest' +import { api, sourceContext } from './work-item-search-test-harness' +import { listWorkItems } from './client/list/list-work-items' +import { countWorkItems } from './client/list/count-work-items' +import { searchWorkItemCount, usesGraphqlWorkItemSearch } from './client/list/work-item-search-page' +import { recordGhPrimaryRateLimit, ghRateLimitScopeKey } from '../git/gh-rate-limit-breaker' + +it('coalesces matching searches and batches independent queries in one execution context', async () => { + const queries = Array.from({ length: 25 }, (_, i) => `repo:fixture/repo-${i} is:issue`) + const counts = await Promise.all( + queries.flatMap((search) => [searchWorkItemCount(search, {}), searchWorkItemCount(search, {})]) + ) + expect(counts).toEqual(Array(50).fill(120)) + expect(api.graphqlCalls).toBe(3) + expect(api.graphqlFields).toBe(25) + expect(api.calls.every((call) => call.cwd === undefined)).toBe(true) +}) + +it('isolates native cwd, WSL distro, host, admission context and inherited credentials', async () => { + const search = 'repo:fixture/repo is:issue' + const options = [ + { cwd: 'folder-a' }, + { cwd: 'folder-b' }, + { wslDistro: 'Ubuntu' }, + { wslDistro: 'Debian' }, + { host: 'github.example.com' }, + { admissionTier: 'interactive' as const } + ] + await Promise.all(options.map((option) => searchWorkItemCount(search, option))) + expect(api.graphqlCalls).toBe(options.length) + await searchWorkItemCount(search, { cwd: 'folder-a' }) + expect(api.graphqlCalls).toBe(options.length) + vi.stubEnv('GH_TOKEN', 'fixture-rotated-credential') + await searchWorkItemCount(search, { cwd: 'folder-a' }) + expect(api.graphqlCalls).toBe(options.length + 1) + expect(api.calls.some((call) => call.args.includes('github.example.com'))).toBe(true) +}) + +it('keeps SSH GitHub execution client-side without passing remote cwd', async () => { + const results = await Promise.all( + Array.from({ length: 8 }, (_, i) => + listWorkItems(`/remote/repo-${i}`, 24, 'is:issue', 1, undefined, `ssh-${i}`) + ) + ) + expect(results.every((result) => result.items.length === 24)).toBe(true) + expect(api.graphqlCalls).toBe(2) + expect(api.calls.every((call) => call.cwd === undefined)).toBe(true) +}) + +it('leaves GHES on REST and unresolved/non-GitHub sources empty', async () => { + sourceContext.host = 'github.example.com' + expect((await listWorkItems('fixture/repo', 24, 'is:issue')).items).toHaveLength(24) + expect(await countWorkItems('fixture/repo', 'is:issue')).toBe(120) + expect(api.graphqlCalls).toBe(0) + expect(api.restSearches).toBe(2) + expect( + api.calls + .filter((call) => !call.args.includes('rate_limit')) + .every((call) => call.args.includes('github.example.com')) + ).toBe(true) + expect( + usesGraphqlWorkItemSearch({ owner: 'fixture', repo: 'repo', host: 'gitlab.com' }, {}) + ).toBe(false) + sourceContext.available = false + expect((await listWorkItems('folder/without-git', 24, 'is:issue')).items).toEqual([]) + expect(await countWorkItems('folder/without-git')).toBe(0) + await expect( + listWorkItems('/remote/unresolved', 24, 'is:issue', 1, undefined, 'ssh') + ).rejects.toThrow() + expect(api.restSearches).toBe(2) +}) + +it('preserves successful aliases when one repository needs REST fallback', async () => { + api.aliasErrorRepo = 'fixture/repo-1' + const results = await Promise.all( + Array.from({ length: 4 }, (_, i) => + listWorkItems(`/remote/repo-${i}`, 24, 'is:issue', 1, undefined, 'ssh') + ) + ) + expect(results.every((result) => result.items.length === 24 && !result.errors)).toBe(true) + expect(api.graphqlCalls).toBe(1) + expect(api.restSearches).toBe(1) + expect( + api.calls + .find((call) => call.args.some((arg) => arg.startsWith('search/issues?'))) + ?.args.join(' ') + ).toContain('repo%3Afixture%2Frepo-1') +}) + +it('falls back on GraphQL quota exhaustion and respects independent runner breaker scopes', async () => { + recordGhPrimaryRateLimit('graphql', 3600000, ghRateLimitScopeKey('native', 'github.com')) + expect((await listWorkItems('fixture/repo', 24, 'is:issue')).items).toHaveLength(24) + expect(api.graphqlCalls).toBe(0) + expect(api.restSearches).toBe(1) + expect( + ( + await listWorkItems('fixture/repo', 24, 'is:issue', 1, undefined, undefined, false, { + wslDistro: 'Ubuntu' + }) + ).items + ).toHaveLength(24) + expect(api.graphqlCalls).toBe(1) + expect(api.restSearches).toBe(1) +}) + +it('keeps count/list search usable when REST Search is exhausted and reports both-bucket failures', async () => { + api.searchAvailable = false + expect(await countWorkItems('fixture/repo', 'is:issue')).toBe(120) + expect((await listWorkItems('fixture/repo', 24, 'is:issue')).items).toHaveLength(24) + expect(api.restSearches).toBe(0) + api.graphqlAvailable = false + await expect( + listWorkItems('fixture/repo', 24, 'is:issue', 1, undefined, undefined, true) + ).rejects.toThrow(/rate limit exceeded/) +}) + +it('splits long search predicates within Windows command-line headroom', async () => { + const queries = Array.from( + { length: 4 }, + (_, index) => `repo:fixture/repo-${index} is:issue ${'word '.repeat(1400)}` + ) + expect(await Promise.all(queries.map((query) => searchWorkItemCount(query, {})))).toEqual([ + 120, 120, 120, 120 + ]) + expect(api.graphqlCalls).toBe(4) + expect(api.calls.every((call) => call.args.join(' ').length < 12000)).toBe(true) +}) + +it('executes queued requests with the credential environment captured at enqueue time', async () => { + vi.stubEnv('GH_TOKEN', 'fixture-first-credential') + const first = searchWorkItemCount('repo:fixture/repo is:issue', {}) + vi.stubEnv('GH_TOKEN', 'fixture-second-credential') + const second = searchWorkItemCount('repo:fixture/repo is:issue', {}) + expect(await Promise.all([first, second])).toEqual([120, 120]) + expect(api.graphqlCalls).toBe(2) + expect(api.calls.map((call) => call.fixtureCredential)).toEqual([ + 'fixture-first-credential', + 'fixture-second-credential' + ]) +}) diff --git a/src/main/github/work-item-search-pagination.test.ts b/src/main/github/work-item-search-pagination.test.ts new file mode 100644 index 00000000000..5e07878e5e6 --- /dev/null +++ b/src/main/github/work-item-search-pagination.test.ts @@ -0,0 +1,71 @@ +import { expect, it, vi } from 'vitest' +import { api } from './work-item-search-test-harness' +import { listWorkItems } from './client/list/list-work-items' +import { countWorkItems } from './client/list/count-work-items' + +function issuePage(page: number, noCache = false, limit = 24) { + return listWorkItems( + 'fixture/repo', + limit, + 'is:issue is:open', + page, + undefined, + undefined, + noCache + ) +} + +it('restores a cold numbered page using only API-issued opaque cursors', async () => { + const third = await issuePage(3) + expect(third.items.map((item) => item.number)).toEqual( + Array.from({ length: 24 }, (_, i) => 9952 - i) + ) + expect(api.graphqlCalls).toBe(2) + expect(api.calls[0].args.join(' ')).not.toContain(' nodes {') + expect(api.calls[1].args.join(' ')).toContain('after: "opaque:1:cursor"') + expect((await issuePage(4)).items[0].number).toBe(9928) + expect(api.graphqlCalls).toBe(3) + expect((await issuePage(6)).items).toEqual([]) + expect(api.restSearches).toBe(0) +}) + +it('walks long jumps without node hydration and retains the authoritative 1000-result window', async () => { + api.rowsPerRepo = 1400 + const last = await issuePage(10, false, 100) + expect(last.items).toHaveLength(100) + expect(last.items[0].number).toBe(9100) + expect(api.graphqlCalls).toBe(10) + expect(api.calls.filter((call) => call.args.join(' ').includes(' nodes {'))).toHaveLength(1) + expect(await countWorkItems('fixture/repo', 'is:issue is:open')).toBe(1400) + const outside = await issuePage(11, false, 100) + expect(outside.items).toEqual([]) + expect(outside.errors?.issues).toMatchObject({ + type: 'validation_error', + message: 'Invalid request — Only the first 1000 search results are available (HTTP 422)' + }) + expect(api.restSearches).toBe(1) +}) + +it('bypasses both page and cursor caches on refresh and expires retained entries', async () => { + await issuePage(3) + expect(api.graphqlCalls).toBe(2) + await issuePage(3) + expect(api.graphqlCalls).toBe(2) + await issuePage(3, true) + expect(api.graphqlCalls).toBe(4) + expect(api.calls.slice(-2).every((call) => !call.args.includes('--cache'))).toBe(true) + vi.setSystemTime(120001) + await issuePage(3) + expect(api.graphqlCalls).toBe(6) + expect(api.restSearches).toBe(0) +}) + +it('does not renew cursor freshness when re-reading a cached page', async () => { + await issuePage(1) + vi.setSystemTime(119000) + await issuePage(1) + vi.setSystemTime(120001) + await issuePage(2) + expect(api.graphqlCalls).toBe(3) + expect(api.calls[1].args.join(' ')).not.toContain('after:') +}) diff --git a/src/main/github/work-item-search-semantics.test.ts b/src/main/github/work-item-search-semantics.test.ts new file mode 100644 index 00000000000..e5df22cddd3 --- /dev/null +++ b/src/main/github/work-item-search-semantics.test.ts @@ -0,0 +1,114 @@ +import { expect, it } from 'vitest' +import { api } from './work-item-search-test-harness' +import { listWorkItems } from './client/list/list-work-items' +import { countWorkItems } from './client/list/count-work-items' +import { mapIssueWorkItem } from './client/map/work-item' +import metadata from './__fixtures__/work-item-search-metadata.json' + +it('matches the saved REST projection for users, bots, avatars, assignees and labels', async () => { + api.specialNodes = metadata.map((pair) => pair.graphql) + api.reportedCount = 2748 + const result = await listWorkItems('fixture/repo', 5, 'is:issue') + expect(result.items).toEqual(metadata.map((pair) => mapIssueWorkItem(pair.rest))) + expect(await countWorkItems('fixture/repo', 'is:issue')).toBe(2748) + expect(api.restSearches).toBe(0) +}) + +it('preserves deleted authors and hydrates associations beyond GraphQL connection limits', async () => { + const row = structuredClone(metadata[0].graphql) + api.specialNodes = [ + { ...row, author: null, labels: { ...row.labels, pageInfo: { hasNextPage: true } } } + ] + const result = await listWorkItems('fixture/repo', 24, 'is:issue') + expect(result.items).toHaveLength(1) + expect(result.items[0].author).toBeNull() + expect(result.items[0].labels).toEqual( + Array.from({ length: 125 }, (_, index) => `label-${index}`) + ) + expect(api.restDetails).toBe(1) + expect(api.restSearches).toBe(0) +}) + +it('passes every issue predicate to the server for both results and full counts', async () => { + api.expectedSearch = + 'repo:fixture/repo is:issue is:closed assignee:"some user" author:"some author" label:"needs review" label:bug in:"title,body" "needle phrase"' + api.specialNodes = [ + { ...metadata[0].graphql, number: 7, state: 'CLOSED', title: 'old needle phrase' } + ] + const query = + 'is:issue is:closed assignee:"some user" author:"some author" label:"needs review" label:bug in:"title,body" "needle phrase"' + expect((await listWorkItems('fixture/repo', 24, query)).items).toMatchObject([ + { number: 7, state: 'closed', title: 'old needle phrase' } + ]) + expect(await countWorkItems('fixture/repo', query)).toBe(1) + expect(api.restSearches).toBe(0) +}) + +it.each([ + ['is:issue state:all', 'repo:fixture/repo is:issue'], + ['is:issue is:open label:bug', 'repo:fixture/repo is:issue is:open label:bug'] +])('retains state/scope semantics for %s', async (query, expected) => { + api.expectedSearch = expected + expect((await listWorkItems('fixture/repo', 24, query)).items).toHaveLength(24) + expect(await countWorkItems('fixture/repo', query)).toBe(120) + expect(api.restSearches).toBe(0) +}) + +it.each([ + [ + 'is:draft', + 'is:pr is:open draft:true sort:created-desc', + 'repo:fixture/repo is:pull-request is:open draft:true' + ], + [ + 'is:pr is:closed', + 'is:pr is:closed -is:merged sort:created-desc', + 'repo:fixture/repo is:pull-request is:closed -is:merged' + ], + ['is:merged', 'is:pr is:merged sort:created-desc', 'repo:fixture/repo is:pull-request is:merged'], + [ + 'review-requested:"some user" reviewed-by:someone', + 'is:pr review-requested:"some user" reviewed-by:someone sort:created-desc', + 'repo:fixture/repo is:pull-request review-requested:"some user" reviewed-by:someone' + ] +])('keeps rich PR lists and full count predicates for %s', async (query, prSearch, countSearch) => { + expect((await listWorkItems('fixture/repo', 24, query)).items).toEqual([]) + expect(api.graphqlCalls).toBe(0) + expect(api.restSearches).toBe(0) + expect(api.calls[0].args).toContain(prSearch) + expect(api.calls[0].args).toContain('--json') + api.expectedSearch = countSearch + expect(await countWorkItems('fixture/repo', query)).toBe(120) + expect(api.graphqlCalls).toBe(1) +}) + +it('falls back with the original numbered query when GraphQL is unavailable', async () => { + api.graphqlAvailable = false + api.expectedSearch = 'repo:fixture/repo is:issue is:closed label:"needs review" "exact phrase"' + const result = await listWorkItems( + 'fixture/repo', + 24, + 'is:issue is:closed label:"needs review" "exact phrase"', + 3, + undefined, + undefined, + true + ) + expect(result.items[0].number).toBe(9952) + const call = api.calls.find((call) => call.args.some((arg) => arg.startsWith('search/issues?'))) + expect(call?.args).toEqual([ + 'api', + '--hostname', + 'github.com', + `search/issues?q=${encodeURIComponent(api.expectedSearch)}&sort=created&order=desc&per_page=24&page=3`, + '--jq', + '.items' + ]) +}) + +it('falls back for malformed GraphQL rows instead of presenting a truncated result', async () => { + api.specialNodes = [{ ...metadata[0].graphql, __typename: 'PullRequest' }] + const result = await listWorkItems('fixture/repo', 24, 'is:issue') + expect(result.items).toHaveLength(1) + expect(api.restSearches).toBe(1) +}) diff --git a/src/main/github/work-item-search-test-harness.ts b/src/main/github/work-item-search-test-harness.ts new file mode 100644 index 00000000000..d3783f0afe1 --- /dev/null +++ b/src/main/github/work-item-search-test-harness.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, vi } from 'vitest' +import type { Mock } from 'vitest' +import { randomUUID } from 'node:crypto' +import { basename } from 'node:path' +import type * as GithubApiRepositoryModule from './github-api-repository' +import { WorkItemSearchApi } from './__fixtures__/work-item-search-api' + +const { + capture, + sourceContext +}: { + capture: Mock + sourceContext: { host: string; available: boolean } +} = vi.hoisted(() => ({ + capture: vi.fn(), + sourceContext: { host: 'github.com', available: true } +})) +vi.mock('../git/command-runner/exec-file-capture', () => ({ + execFileCaptureToTermination: capture +})) +vi.mock('../git/command-runner/wsl-command-resolution', () => ({ + resolveCommand: (binary: string, args: string[], cwd?: string, distro?: string) => ({ + binary, + args, + cwd, + wsl: distro ? { distro } : null, + wslMode: null + }), + resolveDefaultWslCli: () => null +})) +vi.mock('../git/runner', async () => ({ + ghExecFileAsync: (await import('../git/command-runner/gh-exec-file')).ghExecFileAsync, + gitExecFileAsync: vi.fn() +})) +vi.mock('./github-api-repository', async (importOriginal) => { + const actual = await importOriginal() + const source = (repoPath: string) => + sourceContext.available + ? { owner: 'fixture', repo: basename(repoPath), host: sourceContext.host } + : null + return { + ...actual, + resolveIssueGitHubApiRepositorySource: async (repoPath: string) => ({ + source: source(repoPath), + fellBack: false + }), + getOriginGitHubApiRepository: async (repoPath: string) => source(repoPath), + getGitHubApiRepositoryForRemote: async () => null + } +}) + +import { _resetRateLimitCache } from './rate-limit' +import { clearGhRateLimitBlock, ghRateLimitScopeKey } from '../git/gh-rate-limit-breaker' +export let api: WorkItemSearchApi +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(0) + vi.stubEnv('GH_HOST', 'github.com') + vi.stubEnv('ORCA_WORK_ITEM_SEARCH_FIXTURE', randomUUID()) + _resetRateLimitCache() + for (const runtime of ['native', 'wsl:ubuntu', 'wsl:debian']) { + for (const host of ['github.com', 'github.example.com']) { + for (const bucket of ['core', 'graphql', 'search'] as const) { + clearGhRateLimitBlock(bucket, ghRateLimitScopeKey(runtime, host)) + } + } + } + sourceContext.host = 'github.com' + sourceContext.available = true + api = new WorkItemSearchApi() + capture.mockReset().mockImplementation(api.capture.bind(api)) +}) +afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() +}) + +export { capture, sourceContext } diff --git a/tests/e2e/github-work-item-search-burst.unit.test.ts b/tests/e2e/github-work-item-search-burst.unit.test.ts new file mode 100644 index 00000000000..bcebb88ec58 --- /dev/null +++ b/tests/e2e/github-work-item-search-burst.unit.test.ts @@ -0,0 +1,87 @@ +import { z } from 'zod' +import { expect, it } from 'vitest' +import { join } from 'node:path' +import { api } from '../../src/main/github/work-item-search-test-harness' +import { listWorkItems } from '../../src/main/github/client/list/list-work-items' +import { countWorkItems } from '../../src/main/github/client/list/count-work-items' +import { + createTestStore, + mockApi +} from '../../src/renderer/src/store/slices/github-slice-test-harness' + +const repos = Array.from({ length: 36 }, (_, index) => ({ + repoId: `repo-${index}`, + path: join('fixture', `repo-${index}`) +})) +function connectedStore() { + mockApi.gh.listWorkItems.mockImplementation((...params: unknown[]) => { + const args = z + .object({ + repoPath: z.string(), + limit: z.number(), + query: z.string().optional(), + page: z.number().optional(), + noCache: z.boolean().optional() + }) + .parse(params[0]) + return listWorkItems( + args.repoPath, + args.limit, + args.query, + args.page, + undefined, + undefined, + args.noCache + ) + }) + mockApi.gh.countWorkItems.mockImplementation((...params: unknown[]) => { + const args = z.object({ repoPath: z.string(), query: z.string().optional() }).parse(params[0]) + return countWorkItems(args.repoPath, args.query) + }) + return createTestStore() +} + +it.each(['counts-first', 'lists-first'] as const)( + 'returns every repo and full totals with %s', + async (order) => { + const store = connectedStore() + const lists = () => + store.getState().fetchWorkItemsAcrossRepos(repos, 24, 1000, '', { force: true }) + const counts = () => store.getState().countWorkItemsAcrossRepos(repos, '', 24) + const pending = + order === 'counts-first' + ? { total: counts(), items: lists() } + : { items: lists(), total: counts() } + const [items, total] = await Promise.all([pending.items, pending.total]) + expect(items).toMatchObject({ failedCount: 0 }) + expect(items.items).toHaveLength(36 * 24) + expect(new Set(items.items.map((item) => item.repoId)).size).toBe(36) + expect(total).toEqual({ totalCount: 36 * 120, totalPages: 5 }) + expect(api.restSearches).toBe(0) + expect(api.rejected).toBe(0) + expect(api.graphqlCalls).toBeLessThanOrEqual(72) + } +) + +it('retains all repos on repeat, bypass-cache refresh and independent page jumps', async () => { + const store = connectedStore() + const fetch = (noCache = false) => + store + .getState() + .fetchWorkItemsAcrossRepos(repos, 24, 1000, 'is:issue is:open', { force: true, noCache }) + expect((await fetch()).items).toHaveLength(36 * 24) + const firstCalls = api.graphqlCalls + expect((await fetch()).items).toHaveLength(36 * 24) + expect(api.graphqlCalls).toBe(firstCalls) + for (let repeat = 0; repeat < 3; repeat++) { + expect((await fetch(true)).items).toHaveLength(36 * 24) + } + expect(api.graphqlCalls).toBe(firstCalls + 3 * 36) + const page3 = await store + .getState() + .fetchWorkItemsNextPage(repos, 24, 1000, 'is:issue is:open', 3) + expect(page3.items).toHaveLength(36 * 24) + expect(page3.items.every((item) => item.number <= 9952 && item.number >= 9929)).toBe(true) + expect(page3.failedCount).toBe(0) + expect(api.restSearches).toBe(0) +})