feat(github): filter Tasks PRs by state and by who, like GitLab MRs (#38903)

* feat(github): filter Tasks PRs by state and by who, like GitLab MRs

GitHub PRs in Tasks get the same two chip groups as GitLab MRs: state (Open, Merged, Closed, All) and who (Everyone, Mine, Reviewing). The chips read and write the search query, so labels and typed text survive a click and nothing new is saved. A combination equal to one of the old presets is applied as that preset, so saved views, prefetch and the phone see the same strings as before.

Tracked in STA-14084.

* fix(github): keep right-click on Open saving the open-PRs default view

* fix(github): wrap the PR state and who chips as two groups
This commit is contained in:
Brennan Benson
2026-11-06 02:28:12 -08:00
committed by GitHub
parent e0e8ecf592
commit e8b2b9618c
18 changed files with 347 additions and 95 deletions
@@ -1,5 +1,9 @@
import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions'
import { getGitHubTaskKindPresets } from '@/components/task-page/task-page-localized-options'
import {
getGitHubPRStateChips,
getGitHubPRWhoChips,
getIssueTaskQueryPresets
} from '@/components/task-page/task-page-localized-options'
import { cn } from '@/lib/utils'
import PRFilterDropdowns from '@/components/github/PRFilterDropdowns'
import { Search, X, Plus, LoaderCircle, RefreshCw } from 'lucide-react'
@@ -17,6 +21,49 @@ import IssueSourceSelector, { issueSourceChipClass } from '@/components/github/I
import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel'
import { repoMatchesHostIdentity } from '@/store/slices/repo-host-identity'
import { deriveGitHubTaskPreset } from './task-page-preset-highlight'
import {
deriveGitHubPRStateChip,
deriveGitHubPRWhoChip,
gitHubPRWhoChange,
gitHubPRWhoPreset
} from './task-page-pr-chips'
function PresetChip({
label,
active,
onSelect,
onSetDefault
}: {
label: string
active: boolean
onSelect: () => void
/** Right-click saves this chip as the default Tasks view. */
onSetDefault?: () => void
}): React.JSX.Element {
return (
<button
type="button"
aria-pressed={active}
onClick={onSelect}
onContextMenu={
onSetDefault
? (event) => {
event.preventDefault()
onSetDefault()
}
: undefined
}
className={cn(
'rounded-md border px-2.5 py-1 text-xs font-medium transition',
active
? 'border-border/50 bg-foreground/90 text-background shadow-xs'
: 'border-border/60 bg-background text-foreground shadow-xs hover:bg-muted/60'
)}
>
{label}
</button>
)
}
export function TaskPageGitHubFilters({
model
}: {
@@ -55,6 +102,8 @@ export function TaskPageGitHubFilters({
githubTasksBusy
} = model
const highlightedPreset = deriveGitHubTaskPreset(activeGithubTaskKind, appliedTaskQuery)
const prStateChip = deriveGitHubPRStateChip(appliedTaskQuery)
const prWhoChip = deriveGitHubPRWhoChip(appliedTaskQuery)
// Why: filter metadata for the primary slug is read from the host that owns the repo it came from.
const primaryGithubFilterSource = perRepoSourceState.find((state) =>
activeGithubTaskKind === 'prs' ? state.sources?.prs : state.sources?.issues
@@ -73,14 +122,56 @@ export function TaskPageGitHubFilters({
className="flex min-w-0 flex-col gap-2.5 rounded-md rounded-b-none border border-border/50 bg-muted/35 px-3 py-2.5"
data-contextual-tour-target="tasks-search-presets"
>
<div className="flex flex-wrap gap-1.5">
{getGitHubTaskKindPresets(activeGithubTaskKind).map((option) => {
const active = highlightedPreset === option.id
return (
<button
{activeGithubTaskKind === 'prs' ? (
// Why: each group wraps as one unit so the divider always separates state from who.
<div className="flex flex-wrap items-center gap-1.5">
<div className="flex flex-wrap gap-1.5">
{getGitHubPRStateChips().map(({ id, label }) => (
<PresetChip
key={id}
label={label}
active={prStateChip === id}
onSelect={() => {
// Why: re-applying a lit chip would only rewrite the query and refetch the same rows.
if (prStateChip !== id) {
applyPRFilterChange({ state: id })
}
}}
// Why: keeps main's right-click on Open, which saved the open-PRs preset.
onSetDefault={id === 'open' ? () => handleSetDefaultTaskPreset('prs') : undefined}
/>
))}
</div>
<span aria-hidden className="mx-0.5 h-4 w-px bg-border/60" />
<div className="flex flex-wrap gap-1.5">
{getGitHubPRWhoChips().map(({ id, label }) => (
<PresetChip
key={id}
label={label}
active={prWhoChip === id}
onSelect={() => {
if (prWhoChip !== id) {
applyPRFilterChange(gitHubPRWhoChange(id))
}
}}
// Why: the default view is a saved preset, and only the open-state combinations are presets.
onSetDefault={
prStateChip === 'open'
? () => handleSetDefaultTaskPreset(gitHubPRWhoPreset(id))
: undefined
}
/>
))}
</div>
</div>
) : (
<div className="flex flex-wrap gap-1.5">
{getIssueTaskQueryPresets().map((option) => (
<PresetChip
key={option.id}
type="button"
onClick={() => {
label={option.label}
active={highlightedPreset === option.id}
onSelect={() => {
const query = option.query
setTaskSearchInput(query)
setAppliedTaskSearch(query)
@@ -91,22 +182,11 @@ export function TaskPageGitHubFilters({
})
setTaskRefreshNonce((current) => current + 1)
}}
onContextMenu={(event) => {
event.preventDefault()
handleSetDefaultTaskPreset(option.id)
}}
className={cn(
'rounded-md border px-2.5 py-1 text-xs font-medium transition',
active
? 'border-border/50 bg-foreground/90 text-background shadow-xs'
: 'border-border/60 bg-background text-foreground shadow-xs hover:bg-muted/60'
)}
>
{option.label}
</button>
)
})}
</div>
onSetDefault={() => handleSetDefaultTaskPreset(option.id)}
/>
))}
</div>
)}
<div className="flex min-w-0 flex-wrap items-center gap-2">
<PRFilterDropdowns
parsed={appliedTaskQuery}
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { parseTaskQuery } from '../../../../../shared/task-query'
import { getTaskPresetQuery } from '../../../../../shared/task-preset-query'
import {
deriveGitHubPRStateChip,
deriveGitHubPRWhoChip,
gitHubPRWhoChange,
matchGitHubPRPreset,
patchGitHubTaskQuery
} from './task-page-pr-chips'
function chips(query: string) {
const parsed = parseTaskQuery(query)
return [deriveGitHubPRStateChip(parsed), deriveGitHubPRWhoChip(parsed)]
}
describe('GitHub PR state × who chips', () => {
it('lights the open row for each saved preset and nothing for someone else', () => {
expect(chips(getTaskPresetQuery('prs'))).toEqual(['open', 'everyone'])
expect(chips(getTaskPresetQuery('my-prs'))).toEqual(['open', 'mine'])
expect(chips(getTaskPresetQuery('review'))).toEqual(['open', 'reviewing'])
expect(chips('is:pr is:merged author:alice')).toEqual(['merged', null])
expect(chips('is:pr assignee:bob')).toEqual(['all', 'everyone'])
})
it('applies a preset-equal combination as that preset and keeps any other filter custom', () => {
const mine = patchGitHubTaskQuery(
patchGitHubTaskQuery('is:pr is:merged', { state: 'open' }),
gitHubPRWhoChange('mine')
)
expect(matchGitHubPRPreset(mine)).toBe('my-prs')
const labelled = patchGitHubTaskQuery('is:pr is:open label:bug fix', gitHubPRWhoChange('mine'))
expect(labelled).toBe('is:pr is:open author:@me label:bug fix')
expect(matchGitHubPRPreset(labelled)).toBeNull()
expect(patchGitHubTaskQuery('is:pr is:open is:draft', { state: 'merged' })).toBe(
'is:pr is:merged'
)
})
})
@@ -0,0 +1,98 @@
import type { PRFilterChange } from '@/components/github/PRFilterSections'
import { parseTaskQuery, serializeTaskQuery, withQualifier } from '../../../../../shared/task-query'
import type { ParsedTaskQuery } from '../../../../../shared/task-query'
import { getTaskPresetQuery } from '../../../../../shared/task-preset-query'
import type { TaskViewPresetId } from '../../../../../shared/ui-chrome-types'
export type GitHubPRStateChip = 'open' | 'merged' | 'closed' | 'all'
export type GitHubPRWhoChip = 'everyone' | 'mine' | 'reviewing'
// Why: saved PR presets are the open-state rows of the state × who grid.
const PR_PRESET_BY_WHO: Record<GitHubPRWhoChip, TaskViewPresetId> = {
everyone: 'prs',
mine: 'my-prs',
reviewing: 'review'
}
/** Patch the query's qualifiers, keeping every other filter and free text. */
export function patchGitHubTaskQuery(query: string, change: PRFilterChange): string {
let next = query
if ('author' in change) {
next = withQualifier(next, 'author', change.author ?? null)
}
if ('assignee' in change) {
next = withQualifier(next, 'assignee', change.assignee ?? null)
}
if ('labels' in change) {
next = withQualifier(next, 'labels', change.labels ?? [])
}
if ('state' in change && change.state) {
next = withQualifier(next, 'state', change.state)
if (change.state !== 'open') {
next = withQualifier(next, 'draft', null)
}
}
if ('draft' in change) {
next = withQualifier(next, 'draft', change.draft ? 'true' : 'false')
}
if ('reviewer' in change) {
// Why: the two reviewer qualifiers are mutually exclusive — clear the other whenever one is set so the chip matches the query.
const reviewer = change.reviewer ?? null
if (reviewer === null) {
next = withQualifier(next, 'reviewRequested', null)
next = withQualifier(next, 'reviewedBy', null)
} else if (reviewer.kind === 'requested') {
next = withQualifier(next, 'reviewedBy', null)
next = withQualifier(next, 'reviewRequested', reviewer.login)
} else {
next = withQualifier(next, 'reviewRequested', null)
next = withQualifier(next, 'reviewedBy', reviewer.login)
}
}
return next
}
/** The saved PR preset this exact query is, so restore, prefetch keys and the phone see today's preset strings. */
export function matchGitHubPRPreset(query: string): TaskViewPresetId | null {
const normalized = serializeTaskQuery(parseTaskQuery(query))
for (const preset of Object.values(PR_PRESET_BY_WHO)) {
if (serializeTaskQuery(parseTaskQuery(getTaskPresetQuery(preset))) === normalized) {
return preset
}
}
return null
}
export function deriveGitHubPRStateChip(query: ParsedTaskQuery): GitHubPRStateChip {
// Why: a PR search with no state qualifier lists every state.
return query.state === null ? 'all' : query.state
}
/** Who means author or reviewer, independent of assignee, labels and text; other people light no chip. */
export function deriveGitHubPRWhoChip(query: ParsedTaskQuery): GitHubPRWhoChip | null {
const isMe = (login: string | null): boolean => login?.toLowerCase() === '@me'
if (query.reviewedBy !== null) {
return null
}
if (query.author === null) {
if (query.reviewRequested === null) {
return 'everyone'
}
return isMe(query.reviewRequested) ? 'reviewing' : null
}
return query.reviewRequested === null && isMe(query.author) ? 'mine' : null
}
export function gitHubPRWhoChange(who: GitHubPRWhoChip): PRFilterChange {
if (who === 'mine') {
return { author: '@me', reviewer: null }
}
if (who === 'reviewing') {
return { author: null, reviewer: { kind: 'requested', login: '@me' } }
}
return { author: null, reviewer: null }
}
export function gitHubPRWhoPreset(who: GitHubPRWhoChip): TaskViewPresetId {
return PR_PRESET_BY_WHO[who]
}
@@ -0,0 +1,63 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { GITHUB_TASK_SEARCH_IDLE_MS } from '@/components/use-github-task-search-commit'
import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture'
import {
Harness,
installSourceDisplayTestLifecycle,
repo,
view
} from './use-task-page-source-display.test-fixture'
vi.mock('@/components/github/PRFilterDropdowns', () => ({ default: () => null }))
installSourceDisplayTestLifecycle()
afterEach(() => {
vi.useRealTimers()
})
function chip(name: string) {
return screen.getByRole('button', { name })
}
describe('GitHub PR state × who chips', () => {
it('saves Open × Mine as the Mine preset after the persist effect and idle commit', () => {
vi.useFakeTimers()
useAppStore.setState({
repos: [repo],
settings: createGlobalSettingsFixture({ defaultRepoSelection: [repo.id] }),
taskPageData: { taskSource: 'github' }
})
render(<Harness />)
act(() => {
view.setTaskSearchInput('is:pr is:merged')
view.setAppliedTaskSearch('is:pr is:merged')
view.setTaskResumeApplied(true)
})
expect(chip('Merged')).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(chip('Open'))
fireEvent.click(chip('Mine'))
act(() => {
vi.advanceTimersByTime(GITHUB_TASK_SEARCH_IDLE_MS + 1)
})
expect(useAppStore.getState().taskResumeState).toMatchObject({
githubItemsPreset: 'my-prs',
githubItemsQuery: 'author:@me is:pr is:open'
})
expect(view.taskSearchInput).toBe('author:@me is:pr is:open')
expect(chip('Open')).toHaveAttribute('aria-pressed', 'true')
expect(chip('Mine')).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(chip('Merged'))
expect(useAppStore.getState().taskResumeState).toMatchObject({
githubItemsPreset: null,
githubItemsQuery: 'is:pr is:merged author:@me'
})
})
})
@@ -16,6 +16,7 @@ import {
} from '../../../../shared/linear/issue-view-resume-state'
import { getTaskPresetQuery } from '../../../../shared/task-preset-query'
import type { GitLabMRInvolvement } from '../../../../shared/gitlab-types'
import type { GitHubPRStateChip, GitHubPRWhoChip } from './github/task-page-pr-chips'
import type { TaskProvider } from '../../../../shared/task-providers'
import type { TaskViewPresetId } from '../../../../shared/ui-chrome-types'
@@ -86,7 +87,7 @@ export const getGitLabIssueFilters = createLocalizedCatalog(
]
)
const getIssueTaskQueryPresets = createLocalizedCatalog((): TaskQueryPreset[] => [
export const getIssueTaskQueryPresets = createLocalizedCatalog((): TaskQueryPreset[] => [
{
id: 'issues',
label: translate('auto.components.TaskPage.606a85c774', 'Open'),
@@ -99,27 +100,29 @@ const getIssueTaskQueryPresets = createLocalizedCatalog((): TaskQueryPreset[] =>
}
])
const getPRTaskQueryPresets = createLocalizedCatalog((): TaskQueryPreset[] => [
{
id: 'prs',
label: translate('auto.components.TaskPage.606a85c774', 'Open'),
query: getTaskPresetQuery('prs')
},
{
id: 'my-prs',
label: translate('auto.components.TaskPage.7698af5263', 'Mine'),
query: getTaskPresetQuery('my-prs')
},
{
id: 'review',
label: translate('auto.components.TaskPage.524f095d55', 'Needs review'),
query: getTaskPresetQuery('review')
}
])
export const getGitHubPRStateChips = createLocalizedCatalog(
(): { id: GitHubPRStateChip; label: string }[] => [
{ id: 'open', label: translate('auto.components.TaskPage.606a85c774', 'Open') },
{ id: 'merged', label: translate('auto.components.TaskPage.37a82eaaf8', 'Merged') },
{ id: 'closed', label: translate('auto.components.TaskPage.d09bf34db7', 'Closed') },
{ id: 'all', label: translate('auto.components.TaskPage.c2268a9982', 'All') }
]
)
export function getGitHubTaskKindPresets(kind: GitHubTaskKind): TaskQueryPreset[] {
return kind === 'prs' ? getPRTaskQueryPresets() : getIssueTaskQueryPresets()
}
// Same who choices and labels as GitLab MRs.
export const getGitHubPRWhoChips = createLocalizedCatalog(
(): { id: GitHubPRWhoChip; label: string }[] => [
{
id: 'everyone',
label: translate('auto.components.TaskPage.gitlabInvolvementEveryone', 'Everyone')
},
{ id: 'mine', label: translate('auto.components.TaskPage.7698af5263', 'Mine') },
{
id: 'reviewing',
label: translate('auto.components.TaskPage.gitlabInvolvementReviewing', 'Reviewing')
}
]
)
export const getSourceOptions = createLocalizedCatalog((): SourceOption[] => [
{
@@ -5,7 +5,7 @@ import {
scopeGitHubTaskSearch,
getDefaultPresetForGitHubTaskKind
} from '../../../../shared/task-github-search'
import { withQualifier } from '../../../../shared/task-query'
import { matchGitHubPRPreset, patchGitHubTaskQuery } from './github/task-page-pr-chips'
import type { TaskViewPresetId } from '../../../../shared/ui-chrome-types'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
@@ -36,46 +36,20 @@ export function useTaskPageSearchActions(model: TaskPageGitHubQuietRefreshModel)
} = model
const applyPRFilterChange = useCallback(
(change: PRFilterChange): void => {
let next = scopeGitHubTaskSearch(taskSearchInput, activeGithubTaskKind)
// Round-trip through parseTaskQuery so dropdown patches preserve other filters and free text.
if ('author' in change) {
next = withQualifier(next, 'author', change.author ?? null)
}
if ('assignee' in change) {
next = withQualifier(next, 'assignee', change.assignee ?? null)
}
if ('labels' in change) {
next = withQualifier(next, 'labels', change.labels ?? [])
}
if ('state' in change && change.state) {
next = withQualifier(next, 'state', change.state)
if (change.state !== 'open') {
next = withQualifier(next, 'draft', null)
}
}
if ('draft' in change) {
next = withQualifier(next, 'draft', change.draft ? 'true' : 'false')
}
if ('reviewer' in change) {
// Why: the two reviewer qualifiers are mutually exclusive — clear the other whenever one is set so the chip matches the query.
const reviewer = change.reviewer ?? null
if (reviewer === null) {
next = withQualifier(next, 'reviewRequested', null)
next = withQualifier(next, 'reviewedBy', null)
} else if (reviewer.kind === 'requested') {
next = withQualifier(next, 'reviewedBy', null)
next = withQualifier(next, 'reviewRequested', reviewer.login)
} else {
next = withQualifier(next, 'reviewRequested', null)
next = withQualifier(next, 'reviewedBy', reviewer.login)
}
}
setTaskSearchInput(next)
setAppliedTaskSearch(next)
setActiveTaskPreset(null)
const next = patchGitHubTaskQuery(
scopeGitHubTaskSearch(taskSearchInput, activeGithubTaskKind),
change
)
// Why: a query equal to a saved PR preset is applied as that preset, or restore, prefetch keys and the phone see an unknown string.
const preset = activeGithubTaskKind === 'prs' ? matchGitHubPRPreset(next) : null
const query = preset ? getTaskPresetQuery(preset) : next
setTaskSearchInput(query)
setAppliedTaskSearch(query)
setActiveTaskPreset(preset)
setTaskResumeState({
githubItemsPreset: null,
githubItemsQuery: next
githubItemsPreset: preset,
githubItemsQuery: query
})
// Why: a filter change replaces every row's meaning; show the load skeleton so stale rows don't read as if the filter did nothing.
setTasksFiltering(true)
@@ -23,6 +23,7 @@ import { useTaskPageGitHubDetail } from './github/use-task-page-github-detail'
import { useTaskPageGitHubCacheReconciliation } from './github/use-task-page-github-cache-reconciliation'
import { useTaskPageSourceDisplay } from './use-task-page-source-display'
import { TaskPageGitHubFilters } from './github/Filters'
import { useTaskPageSearchActions } from './use-task-page-search-actions'
import { TaskPageGitHubList } from './github/List'
import { useTaskPageGitHubListProjection } from './github/use-task-page-github-list-projection'
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
@@ -127,8 +128,13 @@ export function Harness({
useLayoutEffect(() => {
view = model
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the filter actions read the store bindings, search state and kind mounted here; modal and dialog fields only gate the find shortcut.
const searchInput = { ...model } as unknown as Parameters<typeof useTaskPageSearchActions>[0]
const searchActions = useTaskPageSearchActions(searchInput)
const filtersFields = {
...model,
applyPRFilterChange: searchActions.applyPRFilterChange,
handleSetDefaultTaskPreset: searchActions.handleSetDefaultTaskPreset,
setIssueSourcePreference: runRequests
? useAppStore.getState().setIssueSourcePreference
: setIssueSourcePreference,
-1
View File
@@ -3036,7 +3036,6 @@
"9cd11ba218": "Jira",
"11a828abf8": "GitLab",
"acef77f7ca": "GitHub",
"524f095d55": "Review erforderlich",
"7698af5263": "Eigene",
"94f0339621": "Mir zugewiesen",
"c2268a9982": "Alle",
-1
View File
@@ -3259,7 +3259,6 @@
"9cd11ba218": "Jira",
"11a828abf8": "GitLab",
"acef77f7ca": "GitHub",
"524f095d55": "Needs review",
"7698af5263": "Mine",
"94f0339621": "Assigned to me",
"c2268a9982": "All",
-1
View File
@@ -2557,7 +2557,6 @@
"9cd11ba218": "Jira",
"11a828abf8": "GitLab",
"acef77f7ca": "GitHub",
"524f095d55": "Necesita revisión",
"7698af5263": "Mío",
"94f0339621": "Asignado a mí",
"c2268a9982": "Todo",
-1
View File
@@ -2781,7 +2781,6 @@
"9cd11ba218": "Jira",
"11a828abf8": "GitLab",
"acef77f7ca": "GitHub",
"524f095d55": "En attente de relecture",
"7698af5263": "À moi",
"94f0339621": "Assignés à moi",
"c2268a9982": "Tous",
-1
View File
@@ -2519,7 +2519,6 @@
"9cd11ba218": "Jira",
"11a828abf8": "GitLab",
"acef77f7ca": "GitHub",
"524f095d55": "レビュー待ち",
"7698af5263": "私の",
"94f0339621": "私に割り当てられた Issue",
"c2268a9982": "全て",
-1
View File
@@ -2519,7 +2519,6 @@
"9cd11ba218": "Jira",
"11a828abf8": "GitLab",
"acef77f7ca": "GitHub",
"524f095d55": "리뷰 필요",
"7698af5263": "내 항목",
"94f0339621": "나에게 할당됨",
"c2268a9982": "모두",
-1
View File
@@ -14906,7 +14906,6 @@
"9cd11ba218": "Jira",
"11a828abf8": "GitLab",
"acef77f7ca": "GitHub",
"524f095d55": "Precisa de revisão",
"7698af5263": "Meus",
"94f0339621": "Atribuído a mim",
"c2268a9982": "Todos",
-1
View File
@@ -3116,7 +3116,6 @@
"9cd11ba218": "Jira",
"11a828abf8": "GitLab",
"acef77f7ca": "GitHub",
"524f095d55": "Нужна проверка",
"7698af5263": "Мои",
"94f0339621": "Назначенные мне",
"c2268a9982": "Все",
-1
View File
@@ -3014,7 +3014,6 @@
"9cd11ba218": "Jira",
"11a828abf8": "GitLab",
"acef77f7ca": "GitHub",
"524f095d55": "İnceleme gerekiyor",
"7698af5263": "Benimkiler",
"94f0339621": "Bana atananlar",
"c2268a9982": "Tümü",
@@ -2973,7 +2973,6 @@
"9cd11ba218": "Jira",
"11a828abf8": "GitLab",
"acef77f7ca": "GitHub",
"524f095d55": "待審查",
"7698af5263": "我的",
"94f0339621": "指派給我",
"c2268a9982": "全部",
-1
View File
@@ -2519,7 +2519,6 @@
"9cd11ba218": "Jira",
"11a828abf8": "GitLab",
"acef77f7ca": "GitHub",
"524f095d55": "待评审",
"7698af5263": "我的",
"94f0339621": "分配给我",
"c2268a9982": "全部",