fix(issues): replace cursor-based pagination with page-number Search API (#8680)

* fix(issues): replace cursor-based pagination with page-number Search API

Problem
=======
Issue pagination (#8649) had two bugs:
1. Pages 6-16 were unreachable — clicking page 16 highlighted page 5;
   clicking 6/7 did nothing. The old cursor-based approach
   (updated:<CURSOR) broke with Search API's relevance sorting —
   pages after the first few returned no items even though more
   issues existed.
2. Issue numbers appeared out of order on loaded pages (e.g. #1082
   between #1308 and #1499), because client-side sort used
   updatedAt instead of issue number.

Root Cause
==========
The pagination used two separate GitHub API strategies:
- Initial page 0 load: REST endpoints (repos/:owner/:repo/issues,
  repos/:owner/:repo/pulls) sorted by updatedAt
- Subsequent pages: Search API with cursor (updated:<DATE)

These two sources returned items in different orders, causing items
to go missing or appear on wrong pages across page boundaries.

Solution
========
1. Unified on GitHub Search API for all pages — initial load and
   pagination both use search/issues?q=...&page=N, eliminating the
   REST-vs-Search inconsistency.
2. Changed from cursor-based (update:<DATE) to page-number-based
   pagination (page=N), which the Search API supports natively.
3. Switched client-side sort from updatedAt to issue number
   (sortWorkItemsByNumber), matching GitHub's default Issues view.
4. Parallelized page fetches in handleLoadNextPage — clicking page
   16 now fetches all intermediate pages concurrently (~2s) instead
   of sequentially (~30s).
5. Cleaned up dead legacy gh issue list / gh pr list code path,
   extracted quoteForSearch helper, shortened overlong comments.

Files changed: 11 files, +140/-127 lines

Closes #8649

* chore: remove unrelated merge formatting

---------

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
fsdwen
2026-07-15 18:52:37 -07:00
committed by GitHub
co-authored by Jinjing
parent b0def3b130
commit 78d2b958bf
19 changed files with 529 additions and 485 deletions
+67 -108
View File
@@ -49,6 +49,47 @@ vi.mock('./rate-limit', () => ({
import { countWorkItems, getWorkItem, listWorkItems, _resetOwnerRepoCache } from './client'
const PR_LIST_FIELDS =
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests'
function issueSearchArgs(
ownerRepo: string,
options: { noCache?: boolean; query?: string } = {}
): string[] {
const query = options.query ?? 'is:issue is:open'
return [
'api',
...(options.noCache ? [] : ['--cache', '120s']),
`search/issues?q=${encodeURIComponent(`repo:${ownerRepo} ${query}`)}&sort=created&order=desc&per_page=10&page=1`,
'--jq',
'.items'
]
}
function prListArgs(ownerRepo: string, query = 'is:pr is:open'): string[] {
return [
'pr',
'list',
'--limit',
'10',
'--state',
'all',
'--json',
PR_LIST_FIELDS,
'--repo',
ownerRepo,
'--search',
`${query} sort:created-desc`
]
}
function decodedIssueSearchPath(callIndex: number): string {
const args = ghExecFileAsyncMock.mock.calls[callIndex]?.[0] as string[] | undefined
const apiPath = args?.find((arg) => arg.startsWith('search/issues?'))
expect(apiPath).toBeDefined()
return decodeURIComponent(apiPath ?? '')
}
describe('GitHub issue source split', () => {
beforeEach(() => {
execFileAsyncMock.mockReset()
@@ -115,26 +156,12 @@ describe('GitHub issue source split', () => {
await listWorkItems('/repo-root', 10)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
[
'api',
'--cache',
'120s',
'repos/stablyai/orca/issues?per_page=10&state=open&sort=updated&direction=desc'
],
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
[
'api',
'--cache',
'120s',
'repos/fork/orca/pulls?per_page=10&state=open&sort=updated&direction=desc'
],
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, issueSearchArgs('stablyai/orca'), {
cwd: '/repo-root'
})
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, prListArgs('fork/orca'), {
cwd: '/repo-root'
})
})
it('omits gh api cache args for no-cache recent work-item requests', async () => {
@@ -148,14 +175,12 @@ describe('GitHub issue source split', () => {
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
['api', 'repos/stablyai/orca/issues?per_page=10&state=open&sort=updated&direction=desc'],
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
['api', 'repos/fork/orca/pulls?per_page=10&state=open&sort=updated&direction=desc'],
issueSearchArgs('stablyai/orca', { noCache: true }),
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, prListArgs('fork/orca'), {
cwd: '/repo-root'
})
})
it('lists SSH repo work items with explicit owner/repo and no local cwd', async () => {
@@ -177,26 +202,8 @@ describe('GitHub issue source split', () => {
{}
)
expect(getOwnerRepoMock).toHaveBeenCalledWith('/home/jinwoo/orca', 'openclaw-2', {})
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
[
'api',
'--cache',
'120s',
'repos/stablyai/orca/issues?per_page=10&state=open&sort=updated&direction=desc'
],
{}
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
[
'api',
'--cache',
'120s',
'repos/fork/orca/pulls?per_page=10&state=open&sort=updated&direction=desc'
],
{}
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, issueSearchArgs('stablyai/orca'), {})
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, prListArgs('fork/orca'), {})
})
it('uses upstream for issue-only queries and origin for PR-only queries', async () => {
@@ -206,10 +213,7 @@ describe('GitHub issue source split', () => {
await listWorkItems('/repo-root', 10, 'is:issue')
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
expect.arrayContaining(['--repo', 'stablyai/orca']),
{ cwd: '/repo-root' }
)
expect(decodedIssueSearchPath(0)).toContain('q=repo:stablyai/orca is:issue')
ghExecFileAsyncMock.mockClear()
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
@@ -237,16 +241,9 @@ describe('GitHub issue source split', () => {
await listWorkItems('/repo-root', 10, undefined, undefined, 'upstream')
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
[
'api',
'--cache',
'120s',
'repos/stablyai/orca/pulls?per_page=10&state=open&sort=updated&direction=desc'
],
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, prListArgs('stablyai/orca'), {
cwd: '/repo-root'
})
})
it("uses upstream for queried PRs when preference='upstream'", async () => {
@@ -515,16 +512,9 @@ describe('GitHub issue source split', () => {
const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'auto')
expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'auto', undefined, {})
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
[
'api',
'--cache',
'120s',
'repos/stablyai/orca/issues?per_page=10&state=open&sort=updated&direction=desc'
],
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, issueSearchArgs('stablyai/orca'), {
cwd: '/repo-root'
})
expect(result.issueSourceFellBack).toBeUndefined()
})
@@ -540,16 +530,9 @@ describe('GitHub issue source split', () => {
await listWorkItems('/repo-root', 10, undefined, undefined, 'auto')
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
[
'api',
'--cache',
'120s',
'repos/solo/orca/issues?per_page=10&state=open&sort=updated&direction=desc'
],
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, issueSearchArgs('solo/orca'), {
cwd: '/repo-root'
})
})
it("preference='upstream' + upstream exists → queries upstream", async () => {
@@ -564,13 +547,7 @@ describe('GitHub issue source split', () => {
const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'upstream')
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
expect.arrayContaining([
'repos/stablyai/orca/issues?per_page=10&state=open&sort=updated&direction=desc'
]),
{ cwd: '/repo-root' }
)
expect(decodedIssueSearchPath(0)).toContain('q=repo:stablyai/orca is:issue is:open')
expect(result.issueSourceFellBack).toBeUndefined()
})
@@ -586,13 +563,7 @@ describe('GitHub issue source split', () => {
const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'upstream')
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
expect.arrayContaining([
'repos/solo/orca/issues?per_page=10&state=open&sort=updated&direction=desc'
]),
{ cwd: '/repo-root' }
)
expect(decodedIssueSearchPath(0)).toContain('q=repo:solo/orca is:issue is:open')
expect(result.issueSourceFellBack).toBe(true)
})
@@ -608,13 +579,7 @@ describe('GitHub issue source split', () => {
await listWorkItems('/repo-root', 10, undefined, undefined, 'origin')
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
expect.arrayContaining([
'repos/fork/orca/issues?per_page=10&state=open&sort=updated&direction=desc'
]),
{ cwd: '/repo-root' }
)
expect(decodedIssueSearchPath(0)).toContain('q=repo:fork/orca is:issue is:open')
})
it("preference='origin' + no upstream → queries origin", async () => {
@@ -629,13 +594,7 @@ describe('GitHub issue source split', () => {
await listWorkItems('/repo-root', 10, undefined, undefined, 'origin')
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
expect.arrayContaining([
'repos/solo/orca/issues?per_page=10&state=open&sort=updated&direction=desc'
]),
{ cwd: '/repo-root' }
)
expect(decodedIssueSearchPath(0)).toContain('q=repo:solo/orca is:issue is:open')
})
it('surfaces upstreamCandidate in sources regardless of effective preference', async () => {
+123 -77
View File
@@ -180,18 +180,12 @@ describe('listWorkItems', () => {
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
[
'issue',
'list',
'--limit',
'10',
'--json',
'number,title,state,url,labels,updatedAt,author,assignees',
'--repo',
'acme/widgets',
'--assignee',
'@me',
'--search',
'sort:updated-desc'
'api',
'--cache',
'120s',
`search/issues?q=${encodeURIComponent('repo:acme/widgets is:issue assignee:@me')}&sort=created&order=desc&per_page=10&page=1`,
'--jq',
'.items'
],
{ cwd: '/repo-root' }
)
@@ -202,14 +196,14 @@ describe('listWorkItems', () => {
'list',
'--limit',
'10',
'--state',
'all',
'--json',
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests',
'--repo',
'acme/widgets',
'--assignee',
'@me',
'--search',
'sort:updated-desc'
'is:pr assignee:@me sort:created-desc'
],
{ cwd: '/repo-root' }
)
@@ -218,24 +212,6 @@ describe('listWorkItems', () => {
expect(prListFields).toContain('reviewRequests')
expect(prListFields).not.toContain('mergeStateStatus')
expect(items).toEqual([
{
id: 'issue:12',
type: 'issue',
number: 12,
title: 'Fix bug',
state: 'open',
url: 'https://github.com/acme/widgets/issues/12',
labels: [],
updatedAt: '2026-03-29T00:00:00Z',
author: 'octocat',
assignees: [
{
login: 'test-assignee',
name: 'Test Assignee',
avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4'
}
]
},
{
id: 'pr:42',
type: 'pr',
@@ -257,6 +233,24 @@ describe('listWorkItems', () => {
avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4'
}
]
},
{
id: 'issue:12',
type: 'issue',
number: 12,
title: 'Fix bug',
state: 'open',
url: 'https://github.com/acme/widgets/issues/12',
labels: [],
updatedAt: '2026-03-29T00:00:00Z',
author: 'octocat',
assignees: [
{
login: 'test-assignee',
name: 'Test Assignee',
avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4'
}
]
}
])
})
@@ -404,15 +398,14 @@ describe('listWorkItems', () => {
'list',
'--limit',
'10',
'--state',
'all',
'--json',
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests',
'--repo',
'acme/widgets',
'--state',
'open',
'--draft',
'--search',
'sort:updated-desc'
'is:pr is:open draft:true sort:created-desc'
],
{ cwd: '/repo-root' }
)
@@ -465,14 +458,14 @@ describe('listWorkItems', () => {
'list',
'--limit',
'10',
'--state',
'all',
'--json',
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests',
'--repo',
'acme/widgets',
'--state',
'merged',
'--search',
'sort:updated-desc'
'is:pr is:merged sort:created-desc'
],
{ cwd: '/repo-root' }
)
@@ -536,7 +529,12 @@ describe('listWorkItems', () => {
const { items } = await listWorkItems('/repo-root', 10, 'is:pr is:closed')
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
expect.arrayContaining(['--state', 'closed', '--search', '-is:merged sort:updated-desc']),
expect.arrayContaining([
'--state',
'all',
'--search',
'is:pr is:closed -is:merged sort:created-desc'
]),
{ cwd: '/repo-root' }
)
expect(items).toMatchObject([{ id: 'pr:9', type: 'pr', state: 'closed' }])
@@ -605,7 +603,7 @@ describe('listWorkItems', () => {
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
expect.arrayContaining(['--search', 'review-requested:@me sort:updated-desc']),
expect.arrayContaining(['--search', 'is:pr is:open review-requested:@me sort:created-desc']),
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).not.toHaveBeenCalledWith(
@@ -614,32 +612,86 @@ describe('listWorkItems', () => {
)
})
it('pins list ordering to updated-desc so the updatedAt cursor pages consistently', async () => {
it('uses the requested numbered Search API page for issues', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
await listWorkItems('/repo-root', 10, 'is:issue is:open')
await listWorkItems('/repo-root', 10, 'is:issue is:open', 2)
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
expect.arrayContaining(['--search', 'sort:updated-desc']),
[
'api',
'--cache',
'120s',
`search/issues?q=${encodeURIComponent('repo:acme/widgets is:issue is:open')}&sort=created&order=desc&per_page=10&page=2`,
'--jq',
'.items'
],
{ cwd: '/repo-root' }
)
})
it('combines the inclusive updatedAt cursor with updated-desc ordering on later pages', async () => {
it('fetches and slices stable PR results for the requested numbered page', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify(
[1, 2, 3, 4].map((number) => ({
number,
title: `PR ${number}`,
state: 'OPEN',
url: `https://github.com/acme/widgets/pull/${number}`,
labels: [],
updatedAt: `2026-07-0${number}T00:00:00Z`,
author: { login: 'octocat' },
isDraft: false,
headRefName: `feature/${number}`,
headRefOid: `head-${number}`,
baseRefName: 'main'
}))
)
})
await listWorkItems('/repo-root', 10, 'is:issue is:open', '2026-07-01T00:00:00Z')
const { items } = await listWorkItems('/repo-root', 2, 'is:pr is:open', 2)
// Why: the bound is inclusive (`<=`) so boundary items sharing the cursor's
// exact updatedAt aren't skipped; the renderer dedupes the re-fetched rows.
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
expect.arrayContaining(['--search', 'updated:<=2026-07-01T00:00:00Z sort:updated-desc']),
expect.arrayContaining(['--limit', '4', '--search', 'is:pr is:open sort:created-desc']),
{ cwd: '/repo-root' }
)
expect(items.map((item) => item.number)).toEqual([4, 3])
})
it('filters pull request rows out of issue Search API results', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 2,
title: 'PR-shaped search row',
state: 'open',
html_url: 'https://github.com/acme/widgets/pull/2',
labels: [],
updated_at: '2026-07-02T00:00:00Z',
user: { login: 'octocat' },
pull_request: { url: 'https://api.github.com/repos/acme/widgets/pulls/2' }
},
{
number: 1,
title: 'Issue row',
state: 'open',
html_url: 'https://github.com/acme/widgets/issues/1',
labels: [],
updated_at: '2026-07-01T00:00:00Z',
user: { login: 'octocat' }
}
])
})
const { items } = await listWorkItems('/repo-root', 10, 'is:issue is:open')
expect(items.map((item) => item.id)).toEqual(['issue:1'])
})
it('returns open issues and PRs for the all-open preset query', async () => {
@@ -679,18 +731,12 @@ describe('listWorkItems', () => {
const { items } = await listWorkItems('/repo-root', 10, 'is:open')
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
[
'issue',
'list',
'--limit',
'10',
'--json',
'number,title,state,url,labels,updatedAt,author,assignees',
'--repo',
'acme/widgets',
'--state',
'open',
'--search',
'sort:updated-desc'
'api',
'--cache',
'120s',
`search/issues?q=${encodeURIComponent('repo:acme/widgets is:issue is:open')}&sort=created&order=desc&per_page=10&page=1`,
'--jq',
'.items'
],
{ cwd: '/repo-root' }
)
@@ -700,29 +746,18 @@ describe('listWorkItems', () => {
'list',
'--limit',
'10',
'--state',
'all',
'--json',
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests',
'--repo',
'acme/widgets',
'--state',
'open',
'--search',
'sort:updated-desc'
'is:pr is:open sort:created-desc'
],
{ cwd: '/repo-root' }
)
expect(items).toEqual([
{
id: 'issue:1',
type: 'issue',
number: 1,
title: 'Open issue',
state: 'open',
url: 'https://github.com/acme/widgets/issues/1',
labels: [],
updatedAt: '2026-03-31T00:00:00Z',
author: 'octocat'
},
{
id: 'pr:2',
type: 'pr',
@@ -737,6 +772,17 @@ describe('listWorkItems', () => {
baseRefName: 'main',
headSha: 'head-2',
prRepo: { owner: 'acme', repo: 'widgets' }
},
{
id: 'issue:1',
type: 'issue',
number: 1,
title: 'Open issue',
state: 'open',
url: 'https://github.com/acme/widgets/issues/1',
labels: [],
updatedAt: '2026-03-31T00:00:00Z',
author: 'octocat'
}
])
})
+134 -162
View File
@@ -32,7 +32,7 @@ import { isGitHubWorkItemsQueryTooLarge } from '../../shared/github-work-items-q
import { parseTaskQuery, type ParsedTaskQuery } from '../../shared/task-query'
import {
GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE,
sortWorkItemsByUpdatedAt
sortWorkItemsByNumber
} from '../../shared/work-items'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
@@ -449,17 +449,13 @@ export async function getAuthenticatedViewer(): Promise<GitHubViewer | null> {
}
// Why: main-process maps omit repoId because the IPC handler never receives
// a repo identifier beyond path. The renderer stamps repoId after IPC so
// single-repo and cross-repo items are uniform downstream.
type MainWorkItem = Omit<GitHubWorkItem, 'repoId'>
// a repo identifier beyond path. Exported because runtime consumers receive
// listWorkItems results before the renderer stamps repoId.
export type MainWorkItem = Omit<GitHubWorkItem, 'repoId'>
const WORK_ITEM_ISSUE_LIST_JSON_FIELDS = 'number,title,state,url,labels,updatedAt,author,assignees'
// Why: the Tasks pager slices pages on updatedAt, so every list/search fetch
// must return rows newest-updated-first for the cursor to advance correctly.
// This is the search-qualifier spelling of the same contract the REST list
// paths express as `sort=updated&direction=desc`; keep the two in sync (#8649).
const WORK_ITEM_LIST_SORT_QUALIFIER = 'sort:updated-desc'
// Why: issue numbers follow creation order within a repository. Pinning this
// sort keeps gh's rich PR rows aligned with numbered Search API issue pages.
const WORK_ITEM_NUMBER_SORT_QUALIFIER = 'sort:created-desc'
const WORK_ITEM_PR_LIST_JSON_FIELDS =
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests'
@@ -956,78 +952,98 @@ async function fetchPullRequestWorkItem(
return mapPullRequestWorkItem(JSON.parse(stdout) as Record<string, unknown>)
}
function buildWorkItemListArgs(args: {
type WorkItemListRequest = {
args: string[]
offset: number
}
function normalizeWorkItemPage(page: number | undefined): number {
return typeof page === 'number' && Number.isFinite(page) && page >= 1 ? Math.floor(page) : 1
}
function buildWorkItemListRequest(args: {
kind: 'issue' | 'pr'
ownerRepo: OwnerRepo | null
limit: number
query: ParsedTaskQuery
before?: string
}): string[] {
const { kind, ownerRepo, limit, query, before } = args
const fields = kind === 'issue' ? WORK_ITEM_ISSUE_LIST_JSON_FIELDS : WORK_ITEM_PR_LIST_JSON_FIELDS
const command = kind === 'issue' ? ['issue', 'list'] : ['pr', 'list']
const out = [...command, '--limit', String(limit), '--json', fields]
page: number
}): WorkItemListRequest {
const { kind, ownerRepo, limit, query, page } = args
const searchParts: string[] = []
if (ownerRepo) {
out.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
if (kind === 'issue' && ownerRepo) {
searchParts.push(`repo:${ownerRepo.owner}/${ownerRepo.repo}`)
}
searchParts.push(kind === 'issue' ? 'is:issue' : 'is:pr')
if (query.state === 'open') {
searchParts.push('is:open')
} else if (query.state === 'closed') {
searchParts.push('is:closed')
if (kind === 'pr') {
searchParts.push('-is:merged')
}
} else if (query.state === 'merged') {
searchParts.push('is:merged')
}
if (query.state) {
out.push('--state', query.state)
if (kind === 'pr' && query.draft) {
searchParts.push('draft:true')
}
// Why: GitHub considers merged PRs as "closed". When the user filters for
// closed-only, exclude merged PRs via the search predicate so the displayed
// and counted results match the user's intent.
const excludeMergedFromClosed = kind === 'pr' && query.state === 'closed'
if (query.assignee) {
out.push('--assignee', query.assignee)
searchParts.push(`assignee:${quoteGitHubSearchValue(query.assignee)}`)
}
if (query.author) {
out.push('--author', query.author)
searchParts.push(`author:${quoteGitHubSearchValue(query.author)}`)
}
if (query.labels.length > 0) {
for (const label of query.labels) {
out.push('--label', label)
searchParts.push(`label:${quoteGitHubSearchValue(label)}`)
}
}
// Why: only add --draft when the user explicitly typed `is:draft`. Previously
// this fired for any PR-scoped open query, which made `is:pr is:open` (the
// "PRs" preset) silently filter to drafts-only.
if (kind === 'pr' && query.draft) {
out.push('--draft')
}
const searchParts: string[] = []
if (excludeMergedFromClosed) {
searchParts.push('-is:merged')
}
// Why: cursor-based pagination. GitHub search supports updated:<=DATE to
// fetch items at or older than the cursor. We use the oldest item's updatedAt
// from the previous page as the cursor. The bound is inclusive (`<=`) so items
// sharing the boundary row's exact updatedAt aren't skipped between pages; the
// renderer dedupes the re-fetched boundary rows by id (#8649).
if (before) {
searchParts.push(`updated:<=${before}`)
}
if (kind === 'pr' && query.reviewRequested) {
searchParts.push(`review-requested:${query.reviewRequested}`)
searchParts.push(`review-requested:${quoteGitHubSearchValue(query.reviewRequested)}`)
}
if (kind === 'pr' && query.reviewedBy) {
searchParts.push(`reviewed-by:${query.reviewedBy}`)
searchParts.push(`reviewed-by:${quoteGitHubSearchValue(query.reviewedBy)}`)
}
if (query.freeText) {
searchParts.push(query.freeText)
}
// Why: pagination cursors slice on updatedAt, but `gh issue list` defaults
// to created-desc and `--search` defaults to best-match. `gh issue/pr list`
// has no --sort flag, so the only lever is the search query — which forces us
// to always emit --search. Without pinning the sort, recently-updated old
// items never appear on any page, so the pager advertises pages the fetch
// chain can never reach (#8649).
searchParts.push(WORK_ITEM_LIST_SORT_QUALIFIER)
if (kind === 'issue') {
return {
args: [
'api',
'--cache',
'120s',
`search/issues?q=${encodeURIComponent(searchParts.join(' '))}&sort=created&order=desc&per_page=${limit}&page=${page}`,
'--jq',
'.items'
],
offset: 0
}
}
// Why: search/issues omits the PR fields used by the Tasks management
// columns. Fetch through gh's rich PR list and slice its stable created sort.
searchParts.push(WORK_ITEM_NUMBER_SORT_QUALIFIER)
const out = [
'pr',
'list',
'--limit',
String(Math.min(page * limit, 1000)),
'--state',
'all',
'--json',
WORK_ITEM_PR_LIST_JSON_FIELDS
]
if (ownerRepo) {
out.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
}
out.push('--search', searchParts.join(' '))
return out
return { args: out, offset: (page - 1) * limit }
}
// Why: internal shape shared by listRecentWorkItems / listQueriedWorkItems so
@@ -1105,6 +1121,7 @@ async function listRecentWorkItems(
issueOwnerRepo: OwnerRepo | null,
prOwnerRepo: OwnerRepo | null,
limit: number,
page: number,
connectionId?: string | null,
noCache?: boolean,
localGitOptions: LocalGitExecOptions = {}
@@ -1112,72 +1129,49 @@ async function listRecentWorkItems(
const repoContext = githubRepoContext(repoPath, connectionId, localGitOptions)
const ghOptions = ghRepoExecOptions(repoContext)
const requiresExplicitRepo = Boolean(connectionId)
const restCacheArgs = noCache ? [] : ['--cache', '120s']
assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo })
const recentQuery = parseTaskQuery('is:open')
const issueRequest = buildWorkItemListRequest({
kind: 'issue',
ownerRepo: issueOwnerRepo,
limit,
query: recentQuery,
page
})
const prRequest = buildWorkItemListRequest({
kind: 'pr',
ownerRepo: prOwnerRepo,
limit,
query: recentQuery,
page
})
if (noCache) {
issueRequest.args.splice(1, 2)
}
if (issueOwnerRepo || prOwnerRepo || requiresExplicitRepo) {
// Why: allSettled so a 403 on upstream issues doesn't zero out the origin
// PR half — the UI renders partial results plus a banner for the failing
// side, matching the parent design doc's partial-failure rule (§2).
const [issuesSettled, prsSettled] = await Promise.allSettled([
issueOwnerRepo
? ghExecFileAsync(
[
'api',
...restCacheArgs,
`repos/${issueOwnerRepo.owner}/${issueOwnerRepo.repo}/issues?per_page=${limit}&state=open&sort=updated&direction=desc`
],
ghOptions
)
? ghExecFileAsync(issueRequest.args, ghOptions)
: requiresExplicitRepo
? Promise.resolve({ stdout: '[]' })
: ghCwdResolvedExec(
repoContext,
[
'issue',
'list',
'--limit',
String(limit),
'--state',
'open',
'--json',
WORK_ITEM_ISSUE_LIST_JSON_FIELDS
],
ghOptions
),
: ghCwdResolvedExec(repoContext, issueRequest.args, ghOptions),
prOwnerRepo
? ghExecFileAsync(
[
'api',
...restCacheArgs,
`repos/${prOwnerRepo.owner}/${prOwnerRepo.repo}/pulls?per_page=${limit}&state=open&sort=updated&direction=desc`
],
ghOptions
)
? ghExecFileAsync(prRequest.args, ghOptions)
: requiresExplicitRepo
? Promise.resolve({ stdout: '[]' })
: ghCwdResolvedExec(
repoContext,
[
'pr',
'list',
'--limit',
String(limit),
'--state',
'open',
'--json',
WORK_ITEM_PR_LIST_JSON_FIELDS
],
ghOptions
)
: ghCwdResolvedExec(repoContext, prRequest.args, ghOptions)
])
let issues: MainWorkItem[] = []
let issuesError: ClassifiedError | undefined
if (issuesSettled.status === 'fulfilled') {
issues = (JSON.parse(issuesSettled.value.stdout) as Record<string, unknown>[])
// Why: the GitHub issues REST endpoint also returns pull requests with a
// `pull_request` marker. The new-workspace task picker needs distinct
// issue vs PR buckets, so drop PR-shaped issue rows here before merging.
// Why: the GitHub search/issues endpoint may still return PRs with a
// pull_request marker even when queried with is:issue. Filter them out
// here to keep the issue and PR buckets clean.
.filter((item) => !('pull_request' in item))
.map(mapIssueWorkItem)
} else {
@@ -1190,9 +1184,9 @@ async function listRecentWorkItems(
let prs: MainWorkItem[] = []
if (prsSettled.status === 'fulfilled') {
prs = (JSON.parse(prsSettled.value.stdout) as Record<string, unknown>[]).map((item) =>
mapPullRequestWorkItem(item, prOwnerRepo)
)
prs = (JSON.parse(prsSettled.value.stdout) as Record<string, unknown>[])
.slice(prRequest.offset, prRequest.offset + limit)
.map((item) => mapPullRequestWorkItem(item, prOwnerRepo))
prs = await hydrateWorkItemRepositoryMergeMetadata(prs, prOwnerRepo, ghOptions)
} else {
// Why: PR-side failures must preserve the pre-diff behavior of
@@ -1215,7 +1209,7 @@ async function listRecentWorkItems(
}
return {
items: sortWorkItemsByUpdatedAt([...issues, ...prs]).slice(0, limit),
items: sortWorkItemsByNumber([...issues, ...prs]).slice(0, limit),
issuesError
}
}
@@ -1228,45 +1222,19 @@ async function listRecentWorkItems(
// effectively unusable for the feature — reject-all matches reality. If
// non-GitHub remotes ever grow source metadata, revisit this symmetry.
const [issuesResult, prsResult] = await Promise.all([
ghCwdResolvedExec(
repoContext,
[
'issue',
'list',
'--limit',
String(limit),
'--state',
'open',
'--json',
WORK_ITEM_ISSUE_LIST_JSON_FIELDS
],
ghOptions
),
ghCwdResolvedExec(
repoContext,
[
'pr',
'list',
'--limit',
String(limit),
'--state',
'open',
'--json',
WORK_ITEM_PR_LIST_JSON_FIELDS
],
ghOptions
)
ghCwdResolvedExec(repoContext, issueRequest.args, ghOptions),
ghCwdResolvedExec(repoContext, prRequest.args, ghOptions)
])
const issues = (JSON.parse(issuesResult.stdout) as Record<string, unknown>[]).map(
mapIssueWorkItem
)
const prs = (JSON.parse(prsResult.stdout) as Record<string, unknown>[]).map((item) =>
mapPullRequestWorkItem(item, null)
)
const issues = (JSON.parse(issuesResult.stdout) as Record<string, unknown>[])
.filter((item) => !('pull_request' in item))
.map(mapIssueWorkItem)
const prs = (JSON.parse(prsResult.stdout) as Record<string, unknown>[])
.slice(prRequest.offset, prRequest.offset + limit)
.map((item) => mapPullRequestWorkItem(item, null))
return {
items: sortWorkItemsByUpdatedAt([...issues, ...prs]).slice(0, limit)
items: sortWorkItemsByNumber([...issues, ...prs]).slice(0, limit)
}
}
@@ -1276,7 +1244,7 @@ async function listQueriedWorkItems(
prOwnerRepo: OwnerRepo | null,
query: ParsedTaskQuery,
limit: number,
before?: string,
page?: number,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<PartialWorkItemsResult> {
@@ -1302,19 +1270,21 @@ async function listQueriedWorkItems(
if (requiresExplicitRepo && !issueOwnerRepo) {
return { items: [] }
}
const args = buildWorkItemListArgs({
const request = buildWorkItemListRequest({
kind: 'issue',
ownerRepo: issueOwnerRepo,
limit,
query,
before
page: page ?? 1
})
try {
const { stdout } = issueOwnerRepo
? await ghExecFileAsync(args, ghOptions)
: await ghCwdResolvedExec(repoContext, args, ghOptions)
? await ghExecFileAsync(request.args, ghOptions)
: await ghCwdResolvedExec(repoContext, request.args, ghOptions)
return {
items: (JSON.parse(stdout) as Record<string, unknown>[]).map(mapIssueWorkItem)
items: (JSON.parse(stdout) as Record<string, unknown>[])
.filter((item) => !('pull_request' in item))
.map(mapIssueWorkItem)
}
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
@@ -1329,20 +1299,20 @@ async function listQueriedWorkItems(
if (requiresExplicitRepo && !prOwnerRepo) {
return []
}
const args = buildWorkItemListArgs({
const request = buildWorkItemListRequest({
kind: 'pr',
ownerRepo: prOwnerRepo,
limit,
query,
before
page: page ?? 1
})
try {
const { stdout } = prOwnerRepo
? await ghExecFileAsync(args, ghOptions)
: await ghCwdResolvedExec(repoContext, args, ghOptions)
const mapped = (JSON.parse(stdout) as Record<string, unknown>[]).map((item) =>
mapPullRequestWorkItem(item, prOwnerRepo)
)
? await ghExecFileAsync(request.args, ghOptions)
: await ghCwdResolvedExec(repoContext, request.args, ghOptions)
const mapped = (JSON.parse(stdout) as Record<string, unknown>[])
.slice(request.offset, request.offset + limit)
.map((item) => mapPullRequestWorkItem(item, prOwnerRepo))
const hydrated = await hydrateWorkItemRepositoryMergeMetadata(mapped, prOwnerRepo, ghOptions)
if (query.state === 'closed') {
return hydrated.filter((item) => item.state !== 'merged')
@@ -1356,7 +1326,7 @@ async function listQueriedWorkItems(
const [issueResult, prItems] = await Promise.all([issueFetch, prFetch])
return {
items: sortWorkItemsByUpdatedAt([...issueResult.items, ...prItems]).slice(0, limit),
items: sortWorkItemsByNumber([...issueResult.items, ...prItems]).slice(0, limit),
issuesError: issueResult.issuesError
}
}
@@ -1365,13 +1335,14 @@ export async function listWorkItems(
repoPath: string,
limit = 24,
query?: string,
before?: string,
page?: number,
preference?: IssueSourcePreference,
connectionId?: string | null,
noCache?: boolean,
localGitOptions: LocalGitExecOptions = {}
): Promise<ListWorkItemsResult<MainWorkItem>> {
const trimmedQuery = query?.trim() ?? ''
const requestedPage = normalizeWorkItemPage(page)
if (isGitHubWorkItemsQueryTooLarge(trimmedQuery)) {
return {
items: [],
@@ -1401,6 +1372,7 @@ export async function listWorkItems(
issueOwnerRepo,
prOwnerRepo,
limit,
requestedPage,
connectionId,
noCache,
localGitOptions
@@ -1411,7 +1383,7 @@ export async function listWorkItems(
prOwnerRepo,
parseTaskQuery(trimmedQuery),
limit,
before,
requestedPage,
connectionId,
localGitOptions
)
@@ -1480,7 +1452,7 @@ function buildSearchQueryString(
}
function quoteGitHubSearchValue(value: string): string {
return /\s/.test(value) ? `"${value.replaceAll('"', '\\"')}"` : value
return /[\s"]/.test(value) ? `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"` : value
}
async function countWorkItemsForQuery(