feat: cross-repo issues view in new workspace page (#942)

* feat: cross-repo issues view with multi-repo selection

- Add multi-repo selection via RepoMultiCombobox with persisted defaultRepoSelection setting (null = sticky-all)
- Stamp repoId on GitHubWorkItem at the renderer fetch boundary; merge items from all selected repos with per-repo failure tracking
- Extract task-query helpers (tokenize/strip/parseTaskQuery) to src/shared and add unit tests
- Refactor NewWorkspacePage row to <div role=button> to allow nested interactive elements without invalid-HTML hydration errors
- fix(repo-combobox): toggle all-repos selection on repeat click

* fix(tasks): route Use CTA to item's own repo in cross-repo view

handleUseWorkItem previously referenced a removed 'repoId' state; use item.repoId so launching from a merged cross-repo list targets the correct repo.

* test(e2e): add tasks page smoke test
This commit is contained in:
Jinjing
2026-04-22 10:58:18 -07:00
committed by GitHub
parent 46423cede7
commit 0bbf2d7a8f
20 changed files with 931 additions and 268 deletions
+18 -121
View File
@@ -8,6 +8,8 @@ import type {
GitHubViewer,
GitHubWorkItem
} from '../../shared/types'
import { parseTaskQuery, type ParsedTaskQuery } from '../../shared/task-query'
import { sortWorkItemsByUpdatedAt } from '../../shared/work-items'
import { getPRConflictSummary } from './conflict-summary'
import { execFileAsync, ghExecFileAsync, acquire, release, getOwnerRepo } from './gh-utils'
export { _resetOwnerRepoCache } from './gh-utils'
@@ -89,7 +91,12 @@ export async function getAuthenticatedViewer(): Promise<GitHubViewer | null> {
}
}
function mapIssueWorkItem(item: Record<string, unknown>): GitHubWorkItem {
// 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'>
function mapIssueWorkItem(item: Record<string, unknown>): MainWorkItem {
return {
id: `issue:${String(item.number)}`,
type: 'issue',
@@ -116,7 +123,7 @@ function mapIssueWorkItem(item: Record<string, unknown>): GitHubWorkItem {
}
}
function mapPullRequestWorkItem(item: Record<string, unknown>): GitHubWorkItem {
function mapPullRequestWorkItem(item: Record<string, unknown>): MainWorkItem {
return {
id: `pr:${String(item.number)}`,
type: 'pr',
@@ -158,115 +165,6 @@ function mapPullRequestWorkItem(item: Record<string, unknown>): GitHubWorkItem {
}
}
function sortWorkItemsByUpdatedAt(items: GitHubWorkItem[]): GitHubWorkItem[] {
return [...items].sort((left, right) => {
return new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime()
})
}
type ParsedTaskQuery = {
scope: 'all' | 'issue' | 'pr'
state: 'open' | 'closed' | 'all' | 'merged' | null
assignee: string | null
author: string | null
reviewRequested: string | null
reviewedBy: string | null
labels: string[]
freeText: string
}
function tokenizeSearchQuery(rawQuery: string): string[] {
const tokens: string[] = []
const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g
let match: RegExpExecArray | null
while ((match = pattern.exec(rawQuery)) !== null) {
tokens.push(match[1] ?? match[2] ?? match[3] ?? '')
}
return tokens
}
function parseTaskQuery(rawQuery: string): ParsedTaskQuery {
const query: ParsedTaskQuery = {
scope: 'all',
state: null,
assignee: null,
author: null,
reviewRequested: null,
reviewedBy: null,
labels: [],
freeText: ''
}
const freeTextTokens: string[] = []
for (const token of tokenizeSearchQuery(rawQuery.trim())) {
const normalized = token.toLowerCase()
if (normalized === 'is:issue') {
if (query.scope === 'pr') {
continue
}
query.scope = 'issue'
continue
}
if (normalized === 'is:pr') {
query.scope = query.scope === 'issue' ? 'all' : 'pr'
continue
}
if (normalized === 'is:open') {
query.state = 'open'
continue
}
if (normalized === 'is:closed') {
query.state = 'closed'
continue
}
if (normalized === 'is:merged') {
query.state = 'merged'
continue
}
if (normalized === 'is:draft') {
query.scope = 'pr'
query.state = 'open'
continue
}
const [rawKey, ...rest] = token.split(':')
const value = rest.join(':').trim()
const key = rawKey.toLowerCase()
if (!value) {
freeTextTokens.push(token)
continue
}
if (key === 'assignee') {
query.assignee = value
continue
}
if (key === 'author') {
query.author = value
continue
}
if (key === 'review-requested') {
query.scope = 'pr'
query.reviewRequested = value
continue
}
if (key === 'reviewed-by') {
query.scope = 'pr'
query.reviewedBy = value
continue
}
if (key === 'label') {
query.labels.push(value)
continue
}
freeTextTokens.push(token)
}
query.freeText = freeTextTokens.join(' ').trim()
return query
}
function buildWorkItemListArgs(args: {
kind: 'issue' | 'pr'
ownerRepo: { owner: string; repo: string } | null
@@ -338,7 +236,7 @@ async function listRecentWorkItems(
repoPath: string,
ownerRepo: { owner: string; repo: string } | null,
limit: number
): Promise<GitHubWorkItem[]> {
): Promise<MainWorkItem[]> {
if (ownerRepo) {
const [issuesResult, prsResult] = await Promise.all([
ghExecFileAsync(
@@ -419,8 +317,8 @@ async function listQueriedWorkItems(
ownerRepo: { owner: string; repo: string } | null,
query: ParsedTaskQuery,
limit: number
): Promise<GitHubWorkItem[]> {
const fetchers: Promise<GitHubWorkItem[]>[] = []
): Promise<MainWorkItem[]> {
const fetchers: Promise<MainWorkItem[]>[] = []
const issueScope = query.scope !== 'pr'
const prScope = query.scope !== 'issue'
@@ -460,19 +358,21 @@ export async function listWorkItems(
repoPath: string,
limit = 24,
query?: string
): Promise<GitHubWorkItem[]> {
): Promise<MainWorkItem[]> {
const ownerRepo = await getOwnerRepo(repoPath)
const trimmedQuery = query?.trim() ?? ''
await acquire()
try {
// Why: errors propagate to IPC so the renderer's cross-repo aggregator can
// count this repo as failed and surface the partial-failure banner. A
// catch-all here would make an auth/network failure indistinguishable from
// an empty result and silently under-report per-repo failures.
if (!trimmedQuery) {
return await listRecentWorkItems(repoPath, ownerRepo, limit)
}
const parsedQuery = parseTaskQuery(trimmedQuery)
return await listQueriedWorkItems(repoPath, ownerRepo, parsedQuery, limit)
} catch {
return []
} finally {
release()
}
@@ -484,10 +384,7 @@ export async function getRepoSlug(
return getOwnerRepo(repoPath)
}
export async function getWorkItem(
repoPath: string,
number: number
): Promise<GitHubWorkItem | null> {
export async function getWorkItem(repoPath: string, number: number): Promise<MainWorkItem | null> {
await acquire()
try {
const ownerRepo = await getOwnerRepo(repoPath)
+1 -1
View File
@@ -230,7 +230,7 @@ export async function getWorkItemDetails(
// Why: getWorkItem already handles acquire/release. We call it first (outside
// our semaphore) so the known-cheap lookup doesn't compete with the richer
// detail fetches that follow.
const item: GitHubWorkItem | null = await getWorkItem(repoPath, number)
const item: Omit<GitHubWorkItem, 'repoId'> | null = await getWorkItem(repoPath, number)
if (!item) {
return null
}