fix(tasks): make mobile and desktop searches agree (#28580)

Use one token-based task search implementation on desktop and mobile. Preserve explicit issue/PR qualifiers, distinguish quoted search text, and follow shared reexports in mounted mobile recordings.

Refs #10950 and #11121.

Co-authored-by: MumuTW <42820974+MumuTW@users.noreply.github.com>
This commit is contained in:
Neil
2026-10-14 03:04:32 -07:00
committed by GitHub
co-authored by MumuTW
parent 7ac2dcc7d4
commit 64538a680c
9 changed files with 170 additions and 85 deletions
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('./mobile-tasks-dependencies', () => ({ colors: {} }))
import {
githubKindFromQuery,
scopeGitHubTaskSearch,
normalizeGitHubPreset,
getTaskPresetQuery
} from './mobile-tasks-item-mapping'
describe('mobile task search parity', () => {
it('lets an explicit issue query override a saved PR preset', () => {
for (const preset of ['prs', 'my-prs', 'review'] as const) {
expect(githubKindFromQuery('is:issue is:open', preset)).toBe('issues')
expect(githubKindFromQuery('is:"issue" is:open', preset)).toBe('issues')
expect(scopeGitHubTaskSearch('is:issue is:open', 'prs')).toBe('is:issue is:open')
}
})
it('preserves the pull-request alias while browsing issues', () => {
expect(scopeGitHubTaskSearch('is:pull-request is:open', 'issues')).toBe(
'is:pull-request is:open'
)
expect(githubKindFromQuery('is:pull-request is:open', 'issues')).toBe('prs')
})
it('opens the PR tab for draft and review searches', () => {
for (const query of ['is:draft', 'reviewed-by:@me', 'review-requested:@me', 'is:merged']) {
expect(githubKindFromQuery(query, 'issues')).toBe('prs')
expect(scopeGitHubTaskSearch(query, 'issues')).toBe(`is:pr ${query}`)
}
})
it('does not read scope qualifiers out of labels or quoted search text', () => {
expect(scopeGitHubTaskSearch('label:is:pr', 'issues')).toBe('is:issue label:is:pr')
expect(scopeGitHubTaskSearch('"is:pull-request"', 'issues')).toBe('is:issue "is:pull-request"')
expect(githubKindFromQuery('"is:pr"', 'issues')).toBe('issues')
expect(scopeGitHubTaskSearch('is:"pr"', 'issues')).toBe('is:"pr"')
})
it('uses the current split-tab fallback for a legacy all preset', () => {
const preset = normalizeGitHubPreset('all')
expect(preset).toBe('issues')
expect(getTaskPresetQuery(preset)).toBe('is:issue is:open')
})
})
+4 -36
View File
@@ -1,3 +1,6 @@
import { getGitHubTaskKind } from '../../../src/shared/task-github-search'
export { scopeGitHubTaskSearch } from '../../../src/shared/task-github-search'
export { getTaskPresetQuery } from '../../../src/shared/task-preset-query'
import {
type RpcSuccess,
type TaskProvider,
@@ -44,22 +47,6 @@ export function formatUpdatedAt(value: string): string {
return `${Math.floor(hours / 24)}d`
}
export function getTaskPresetQuery(preset: GitHubPreset): string {
switch (preset) {
case 'my-issues':
return 'assignee:@me is:issue is:open'
case 'prs':
return 'is:pr is:open'
case 'my-prs':
return 'author:@me is:pr is:open'
case 'review':
return 'review-requested:@me is:pr is:open'
case 'issues':
default:
return 'is:issue is:open'
}
}
export function isTaskProvider(value: unknown): value is TaskProvider {
return value === 'github' || value === 'gitlab' || value === 'linear'
}
@@ -81,15 +68,7 @@ export function normalizeLinearFilter(value: unknown): LinearFilter {
}
export function githubKindFromQuery(query: string, fallbackPreset: GitHubPreset): GitHubTaskKind {
if (/\bis:pr\b/i.test(query)) {
return 'prs'
}
if (/\bis:issue\b/i.test(query)) {
return 'issues'
}
return fallbackPreset === 'prs' || fallbackPreset === 'my-prs' || fallbackPreset === 'review'
? 'prs'
: 'issues'
return getGitHubTaskKind(fallbackPreset, query)
}
export function projectRowType(row: GitHubProjectRow): 'issue' | 'pr' | null {
@@ -164,17 +143,6 @@ export function projectRowStatusLabel(row: GitHubProjectRow): string {
return 'Open'
}
export function scopeGitHubTaskSearch(query: string, kind: GitHubTaskKind): string {
const trimmed = query.trim()
if (!trimmed) {
return getTaskPresetQuery(kind === 'prs' ? 'prs' : 'issues')
}
if (/\bis:(?:issue|pr)\b/i.test(trimmed)) {
return trimmed
}
return `${kind === 'prs' ? 'is:pr' : 'is:issue'} ${trimmed}`
}
export function gitHubStatusLabel(item: GitHubWorkItem): string {
if (item.state === 'merged') {
return 'Merged'
@@ -32,6 +32,21 @@ afterAll(() => {
})
describe('the mounted module loader', () => {
it('follows named reexports reached through the task foundation barrel', () => {
const modules = loaderOver({
'mobile-tasks-legacy-foundation.tsx': `export * from './mapping'`,
'mapping.ts': `export { run as search } from './operation'; export type { Missing } from './missing'`,
'operation.ts': `export const run = (query: string) => 'is:pr ' + query`
})
const foundation = modules.load<{ search: (query: string) => string }>(
'mobile/src/mobile-tasks-legacy-foundation.tsx'
)
expect(foundation.search('is:open')).toBe('is:pr is:open')
expect(() => modules.load('mobile/src/mobile-tasks-legacy-foundation.tsx').Missing).toThrow(
'Unmapped barrel export: Missing'
)
})
it('reuses code without sharing exports, module state, mounting dependencies or effects', () => {
const root = sourceTree({
'state.ts': `
@@ -106,7 +106,19 @@ export function operationModuleLoader(
if (
new RegExp(`export (?:async )?(?:function|const|class) ${String(key)}\\b`).test(
text
)
) ||
ts
.createSourceFile(target, text, ts.ScriptTarget.Latest, true)
.statements.some(
(entry) =>
ts.isExportDeclaration(entry) &&
!entry.isTypeOnly &&
entry.exportClause &&
ts.isNamedExports(entry.exportClause) &&
entry.exportClause.elements.some(
(binding) => binding.name.text === key && !binding.isTypeOnly
)
)
) {
return load(target)[String(key)]
}
@@ -67,6 +67,14 @@ describe('normalizeGitHubTaskPreset', () => {
})
describe('getGitHubTaskKind', () => {
it('lets an explicit issue query override a PR-focused preset', () => {
for (const preset of ['prs', 'my-prs', 'review'] as const) {
expect(getGitHubTaskKind(preset, 'is:issue is:open')).toBe('issues')
expect(getGitHubTaskKind(preset, 'is:"issue"')).toBe('issues')
expect(getGitHubTaskKind(preset, 'label:is:issue')).toBe('prs')
}
})
it('reports prs when the preset is PR-focused', () => {
expect(getGitHubTaskKind('review', '')).toBe('prs')
})
@@ -125,7 +133,7 @@ describe('scopeGitHubTaskSearch', () => {
it('lets a parsed issue scope override the caller kind', () => {
// characterization: quoting hides `is:issue` from the literal-scope regex but
// not from the tokenizer, so the parsed issue scope beats the prs caller kind.
expect(scopeGitHubTaskSearch('is:"issue"', 'prs')).toBe('is:issue is:"issue"')
expect(scopeGitHubTaskSearch('is:"issue"', 'prs')).toBe('is:"issue"')
})
it('re-prefixes a merged-state query with the caller kind', () => {
@@ -1,47 +1,7 @@
import { getTaskPresetQuery } from '../../../shared/task-preset-query'
import { parseTaskQuery } from '../../../shared/task-query'
import type { TaskViewPresetId } from '../../../shared/ui-chrome-types'
import type { GitHubTaskKind } from '@/components/task-page-localized-options'
export function isPRFocusedTaskView(preset: TaskViewPresetId | null, query: string): boolean {
if (preset === 'prs' || preset === 'my-prs' || preset === 'review') {
return true
}
const parsed = parseTaskQuery(query)
return (
parsed.scope === 'pr' ||
parsed.state === 'merged' ||
parsed.draft ||
parsed.reviewRequested !== null ||
parsed.reviewedBy !== null
)
}
export function normalizeGitHubTaskPreset(
preset: TaskViewPresetId | null | undefined
): TaskViewPresetId {
// Why: the split Issues/PRs tabs dropped the mixed "All" view, so legacy saved defaults land on the first tab instead of mixing rows.
return !preset || preset === 'all' ? 'issues' : preset
}
export function getGitHubTaskKind(preset: TaskViewPresetId | null, query: string): GitHubTaskKind {
return isPRFocusedTaskView(preset, query) ? 'prs' : 'issues'
}
export function getDefaultPresetForGitHubTaskKind(kind: GitHubTaskKind): TaskViewPresetId {
return kind === 'prs' ? 'prs' : 'issues'
}
export function scopeGitHubTaskSearch(query: string, kind: GitHubTaskKind): string {
const trimmed = query.trim()
if (!trimmed) {
return getTaskPresetQuery(getDefaultPresetForGitHubTaskKind(kind))
}
if (/\bis:(?:issue|pr|pull-request)\b/i.test(trimmed)) {
return trimmed
}
const parsed = parseTaskQuery(trimmed)
// Why: the issue arm still fires for quoted forms like is:"issue" that the literal regex above misses.
const inferredKind = parsed.scope === 'pr' ? 'prs' : parsed.scope === 'issue' ? 'issues' : kind
return `${inferredKind === 'prs' ? 'is:pr' : 'is:issue'} ${trimmed}`
}
export {
getDefaultPresetForGitHubTaskKind,
getGitHubTaskKind,
isPRFocusedTaskView,
normalizeGitHubTaskPreset,
scopeGitHubTaskSearch
} from '../../../shared/task-github-search'
+49
View File
@@ -0,0 +1,49 @@
import { getTaskPresetQuery } from './task-preset-query'
import { hasExplicitTaskScope, parseTaskQuery } from './task-query'
import type { TaskViewPresetId } from './ui-chrome-types'
export type GitHubTaskKind = 'issues' | 'prs'
export function isPRFocusedTaskView(preset: TaskViewPresetId | null, query: string): boolean {
const parsed = parseTaskQuery(query)
if (parsed.scope === 'issue') {
return false
}
if (preset === 'prs' || preset === 'my-prs' || preset === 'review') {
return true
}
return (
parsed.scope === 'pr' ||
parsed.state === 'merged' ||
parsed.draft ||
parsed.reviewRequested !== null ||
parsed.reviewedBy !== null
)
}
export function normalizeGitHubTaskPreset(
preset: TaskViewPresetId | null | undefined
): TaskViewPresetId {
// Why: the split Issues/PRs tabs dropped the mixed "All" view, so legacy saved defaults land on the first tab instead of mixing rows.
return !preset || preset === 'all' ? 'issues' : preset
}
export function getGitHubTaskKind(preset: TaskViewPresetId | null, query: string): GitHubTaskKind {
return isPRFocusedTaskView(preset, query) ? 'prs' : 'issues'
}
export function getDefaultPresetForGitHubTaskKind(kind: GitHubTaskKind): TaskViewPresetId {
return kind === 'prs' ? 'prs' : 'issues'
}
export function scopeGitHubTaskSearch(query: string, kind: GitHubTaskKind): string {
const trimmed = query.trim()
if (!trimmed) {
return getTaskPresetQuery(getDefaultPresetForGitHubTaskKind(kind))
}
if (hasExplicitTaskScope(trimmed)) {
return trimmed
}
const parsed = parseTaskQuery(trimmed)
const inferredKind = parsed.scope === 'pr' ? 'prs' : parsed.scope === 'issue' ? 'issues' : kind
return `${inferredKind === 'prs' ? 'is:pr' : 'is:issue'} ${trimmed}`
}
+16
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
hasExplicitTaskScope,
parseTaskQuery,
serializeTaskQuery,
stripRepoQualifiers,
@@ -36,6 +37,21 @@ describe('tokenizeSearchQuery', () => {
})
})
describe('hasExplicitTaskScope', () => {
it('recognizes scope qualifiers and their quoted values', () => {
for (const query of ['is:issue', 'IS:PR', 'is:pull-request', 'is:"pr"']) {
expect(hasExplicitTaskScope(query)).toBe(true)
}
})
it('keeps quoted phrases and values of other qualifiers as search text', () => {
for (const query of ['"is:pr"', "'is:issue'", 'label:is:pr', 'title:"is:pull-request"']) {
expect(hasExplicitTaskScope(query)).toBe(false)
expect(parseTaskQuery(query).scope).toBe('all')
}
})
})
describe('parseTaskQuery', () => {
it('returns defaults for an empty query', () => {
const parsed = parseTaskQuery('')
+10
View File
@@ -54,6 +54,12 @@ export function tokenizeSearchQuery(rawQuery: string): string[] {
return tokenizeSearchQueryWithRaw(rawQuery).map((token) => token.value)
}
export function hasExplicitTaskScope(rawQuery: string): boolean {
return tokenizeSearchQueryWithRaw(rawQuery).some(
({ value, raw }) => /^is:/i.test(raw) && /^is:(?:issue|pr|pull-request)$/i.test(value)
)
}
export function parseTaskQuery(rawQuery: string): ParsedTaskQuery {
const query: ParsedTaskQuery = {
scope: 'all',
@@ -71,6 +77,10 @@ export function parseTaskQuery(rawQuery: string): ParsedTaskQuery {
let sawIssueScope = false
let sawPRScope = false
for (const { value: token, raw } of tokenizeSearchQueryWithRaw(rawQuery.trim())) {
if (raw.startsWith('"') || raw.startsWith("'")) {
freeTextTokens.push(raw)
continue
}
const normalized = token.toLowerCase()
if (normalized === 'is:issue') {
sawIssueScope = true