Improve gh issue table (#9068)
* Refactor GitHub work-item mutations onto a shared optimistic coordinator - Extract PR/issue status, assignee, reviewer, and merge/auto-merge mutations out of TaskPage cell components into a registry-backed begin/confirm/rollback pipeline (task-page-github-work-item-mutation-*), so soft-hide, sticky filter-membership, and quiet revalidation behave consistently across all mutation types instead of each cell re-implementing optimistic update/rollback/toast logic. - Add quiet revalidation (no filter skeleton, no page blanking) and soft-hide handling so a row that exits the active filter (e.g. closing an issue under `is:open`) stays hidden without a jarring list reflow. - Restyle the GitHub task table: opaque sticky ID/Title cells, distinct header fill, accent hover, and tighter row/toolbar chrome to fix background bleed and muddy contrast in the scrolled table. * Fix quiet-revalidate cancellation and sticky-hide scoping in TaskPage - Replace per-render `cancelled` flag with a ref that only flips on true unmount, so a nonce-triggered re-render no longer strands the shared quietState's trailing/backoff bookkeeping mid-flight. - Fix backoff index to use max lag attempts instead of lagging-key count, matching processTaskPageQuietRevalidateSettle so several single-lag items can't jump the delay tier. - Scope sticky-hide retention in materializeTaskPageItemList to the originating query key, preventing non-membership confirms (e.g. auto-merge) from lingering as stale rows across refetches. * Fix is:draft filter to soft-hide non-draft PRs Previously state was forced to 'open' for is:draft queries, so a PR that stopped being a draft still passed the state check and stayed visible. Add an explicit draft check to soft-hide it. * Improve GitHub work-item mutations with scoped quiet revalidation Prevent race conditions and stale data by tracking quiet run ownership, validating scope changes with generations, and blocking overlapping mutations with pre-flight checks. Extract quiet state management into a dedicated module with improved authority clearing and network retry logic.
This commit is contained in:
parent
6c2d168f74
commit
d021e1b711
File diff suppressed because it is too large
Load Diff
|
|
@ -53,20 +53,22 @@ describe('feature interaction writer boundaries', () => {
|
|||
it('records GitHub provider-depth for inline item mutation success paths', () => {
|
||||
const source = componentSource('TaskPage.tsx')
|
||||
const githubWriter = "recordFeatureInteraction('github-tasks')"
|
||||
const mutationSections = [
|
||||
sourceBetween(source, 'function GHAssigneesCell', 'const triggerContent ='),
|
||||
sourceBetween(source, 'function PRReviewCell', 'const requestReviewer ='),
|
||||
sourceBetween(source, 'function PRMergeCell', 'const handleAutoMerge'),
|
||||
// Why: table cells route success telemetry through the optimistic mutation
|
||||
// hook so provider-depth recording stays on one confirm path.
|
||||
const hookSource = readFileSync(
|
||||
join(COMPONENT_ROOT, '../hooks/useTaskPageGitHubWorkItemMutation.ts'),
|
||||
'utf8'
|
||||
)
|
||||
expect(
|
||||
sourceBetween(hookSource, "if (confirmed === 'confirmed')", 'return confirmed')
|
||||
).toContain(githubWriter)
|
||||
expect(
|
||||
sourceBetween(
|
||||
source,
|
||||
'const handleOpenOrUseGitHubWorkItem',
|
||||
'const openComposerForGitLabItem'
|
||||
)
|
||||
]
|
||||
|
||||
for (const section of mutationSections) {
|
||||
expect(section).toContain(githubWriter)
|
||||
}
|
||||
).toContain(githubWriter)
|
||||
})
|
||||
|
||||
it('threads GitHub task source context through inline task mutations', () => {
|
||||
|
|
|
|||
|
|
@ -177,8 +177,9 @@ export default function PRFilterDropdowns({
|
|||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 gap-1.5 rounded-md border-border/50 px-2 text-xs font-normal',
|
||||
'bg-transparent hover:bg-muted/50',
|
||||
// Why: solid fill + full foreground text so the control stays
|
||||
// readable on both page canvas and the muted toolbar band.
|
||||
'h-8 gap-1.5 rounded-md border-border/60 bg-background px-2.5 text-xs font-medium text-foreground shadow-xs hover:bg-muted/60',
|
||||
activeCount > 0 && 'border-border'
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
import { taskPageGitHubFamilyDirtyKey } from './task-page-github-work-item-mutation-keys'
|
||||
import {
|
||||
clearConfirmedAuthorityForItem,
|
||||
deleteConfirmedListSnapshot,
|
||||
deleteLastConfirmedClientValue,
|
||||
deleteStickyHideEntry,
|
||||
getConfirmedListSnapshot,
|
||||
getLastConfirmedClientValue,
|
||||
getStickyHideEntry,
|
||||
getTaskPageGitHubConfirmedAuthorityItemKeys,
|
||||
hasPendingTaskPageGitHubOpsForItem,
|
||||
listPendingTaskPageGitHubOpsForItem,
|
||||
notifyTaskPageGitHubMutationRegistry,
|
||||
resolveItemSourceScope,
|
||||
updateSoftHiddenItemKey
|
||||
} from './task-page-github-work-item-mutation-registry'
|
||||
import { getQuietRevalidateState } from './task-page-github-work-item-quiet-state'
|
||||
import { MAX_LAG_TRAILS } from './task-page-github-work-item-quiet-adopt'
|
||||
|
||||
const AUTHORITY_FAMILIES = ['state', 'autoMerge', 'assignees', 'reviewRequests'] as const
|
||||
|
||||
export function clearTaskPageGitHubAuthorityAbsentFromLoadedItems(
|
||||
loadedItemKeys: ReadonlySet<string>
|
||||
): void {
|
||||
let changed = false
|
||||
for (const itemKey of getTaskPageGitHubConfirmedAuthorityItemKeys()) {
|
||||
if (loadedItemKeys.has(itemKey)) {
|
||||
continue
|
||||
}
|
||||
const separator = itemKey.indexOf('\0')
|
||||
const repoId = itemKey.slice(0, separator)
|
||||
const itemId = itemKey.slice(separator + 1)
|
||||
if (hasPendingTaskPageGitHubOpsForItem(repoId, itemId)) {
|
||||
continue
|
||||
}
|
||||
clearConfirmedAuthorityForItem(repoId, itemId)
|
||||
deleteStickyHideEntry(itemKey)
|
||||
updateSoftHiddenItemKey(itemKey, false)
|
||||
changed = true
|
||||
}
|
||||
if (changed) {
|
||||
notifyTaskPageGitHubMutationRegistry()
|
||||
}
|
||||
}
|
||||
|
||||
export function getTaskPageGitHubRevalidatableAuthorityItemKeys(
|
||||
queryKey: string
|
||||
): ReadonlySet<string> {
|
||||
const keys = new Set<string>()
|
||||
const quiet = getQuietRevalidateState(queryKey)
|
||||
for (const itemKey of getTaskPageGitHubConfirmedAuthorityItemKeys()) {
|
||||
const separator = itemKey.indexOf('\0')
|
||||
if (separator < 0) {
|
||||
continue
|
||||
}
|
||||
const repoId = itemKey.slice(0, separator)
|
||||
const itemId = itemKey.slice(separator + 1)
|
||||
const sourceScope = resolveItemSourceScope(repoId, itemId)
|
||||
const activeFamilies = AUTHORITY_FAMILIES.filter((family) =>
|
||||
family === 'assignees' || family === 'reviewRequests'
|
||||
? getConfirmedListSnapshot(sourceScope, repoId, itemId, family) !== undefined
|
||||
: getLastConfirmedClientValue(sourceScope, repoId, itemId, family) !== undefined
|
||||
)
|
||||
if (
|
||||
activeFamilies.some(
|
||||
(family) =>
|
||||
(quiet?.lagSkipAttempts.get(taskPageGitHubFamilyDirtyKey(itemKey, family)) ?? 0) <
|
||||
MAX_LAG_TRAILS
|
||||
)
|
||||
) {
|
||||
keys.add(itemKey)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
export function clearTaskPageGitHubAuthorityThroughGeneration(
|
||||
queryKey: string,
|
||||
generation: number
|
||||
): void {
|
||||
const quiet = getQuietRevalidateState(queryKey)
|
||||
for (const itemKey of getTaskPageGitHubConfirmedAuthorityItemKeys()) {
|
||||
const separator = itemKey.indexOf('\0')
|
||||
const repoId = itemKey.slice(0, separator)
|
||||
const itemId = itemKey.slice(separator + 1)
|
||||
const sourceScope = resolveItemSourceScope(repoId, itemId)
|
||||
for (const family of AUTHORITY_FAMILIES) {
|
||||
const dirtyAt = quiet?.familyDirtyAt.get(taskPageGitHubFamilyDirtyKey(itemKey, family)) ?? 0
|
||||
if (dirtyAt > generation) {
|
||||
continue
|
||||
}
|
||||
if (family === 'assignees' || family === 'reviewRequests') {
|
||||
deleteConfirmedListSnapshot(sourceScope, repoId, itemId, family)
|
||||
} else {
|
||||
deleteLastConfirmedClientValue(sourceScope, repoId, itemId, family)
|
||||
}
|
||||
}
|
||||
const hasMembershipAuthority =
|
||||
getLastConfirmedClientValue(sourceScope, repoId, itemId, 'state') !== undefined ||
|
||||
getConfirmedListSnapshot(sourceScope, repoId, itemId, 'assignees') !== undefined ||
|
||||
getConfirmedListSnapshot(sourceScope, repoId, itemId, 'reviewRequests') !== undefined
|
||||
const hasPendingMembership = listPendingTaskPageGitHubOpsForItem(repoId, itemId).some(
|
||||
(op) => op.listOp !== undefined || op.key.opKey === 'state' || op.key.opKey === 'merge'
|
||||
)
|
||||
if (!hasMembershipAuthority && !hasPendingMembership) {
|
||||
if (getStickyHideEntry(itemKey)?.queryKey === queryKey) {
|
||||
deleteStickyHideEntry(itemKey)
|
||||
}
|
||||
if (!hasPendingTaskPageGitHubOpsForItem(repoId, itemId)) {
|
||||
updateSoftHiddenItemKey(itemKey, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
notifyTaskPageGitHubMutationRegistry()
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { ParsedTaskQuery } from '../../../shared/task-query'
|
||||
import {
|
||||
recomputeTaskPageGitHubItemSoftHide,
|
||||
shouldSoftHideTaskPageGitHubWorkItem
|
||||
} from './task-page-github-work-item-filter-membership'
|
||||
|
||||
const baseQuery = (overrides: Partial<ParsedTaskQuery> = {}): ParsedTaskQuery => ({
|
||||
scope: 'all',
|
||||
state: null,
|
||||
draft: false,
|
||||
assignee: null,
|
||||
author: null,
|
||||
reviewRequested: null,
|
||||
reviewedBy: null,
|
||||
labels: [],
|
||||
freeText: '',
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('shouldSoftHideTaskPageGitHubWorkItem', () => {
|
||||
it('hides closed or merged under is:open', () => {
|
||||
expect(
|
||||
shouldSoftHideTaskPageGitHubWorkItem({
|
||||
item: { state: 'closed', assignees: [], reviewRequests: [] },
|
||||
query: baseQuery({ state: 'open' }),
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldSoftHideTaskPageGitHubWorkItem({
|
||||
item: { state: 'merged', assignees: [], reviewRequests: [] },
|
||||
query: baseQuery({ state: 'open' }),
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not state-soft-hide when state is null', () => {
|
||||
expect(
|
||||
shouldSoftHideTaskPageGitHubWorkItem({
|
||||
item: { state: 'closed', assignees: [], reviewRequests: [] },
|
||||
query: baseQuery({ state: null }),
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('AND: hides on close or unassign under is:open assignee:@me', () => {
|
||||
const query = baseQuery({ state: 'open', assignee: '@me' })
|
||||
expect(
|
||||
shouldSoftHideTaskPageGitHubWorkItem({
|
||||
item: {
|
||||
state: 'closed',
|
||||
assignees: [{ login: 'me', name: null, avatarUrl: '' }],
|
||||
reviewRequests: []
|
||||
},
|
||||
query,
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldSoftHideTaskPageGitHubWorkItem({
|
||||
item: { state: 'open', assignees: [], reviewRequests: [] },
|
||||
query,
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('skips @me when skipMeQualifiers or viewerLogin null', () => {
|
||||
const query = baseQuery({ assignee: '@me' })
|
||||
expect(
|
||||
shouldSoftHideTaskPageGitHubWorkItem({
|
||||
item: { state: 'open', assignees: [], reviewRequests: [] },
|
||||
query,
|
||||
viewerLogin: null,
|
||||
skipMeQualifiers: false
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldSoftHideTaskPageGitHubWorkItem({
|
||||
item: { state: 'open', assignees: [], reviewRequests: [] },
|
||||
query,
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: true
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('hides when concrete assignee login missing', () => {
|
||||
expect(
|
||||
shouldSoftHideTaskPageGitHubWorkItem({
|
||||
item: {
|
||||
state: 'open',
|
||||
assignees: [{ login: 'bob', name: null, avatarUrl: '' }],
|
||||
reviewRequests: []
|
||||
},
|
||||
query: baseQuery({ assignee: 'alice' }),
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('hides non-draft items under is:draft', () => {
|
||||
const query = baseQuery({ scope: 'pr', state: 'open', draft: true })
|
||||
expect(
|
||||
shouldSoftHideTaskPageGitHubWorkItem({
|
||||
item: { state: 'open', assignees: [], reviewRequests: [] },
|
||||
query,
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldSoftHideTaskPageGitHubWorkItem({
|
||||
item: { state: 'draft', assignees: [], reviewRequests: [] },
|
||||
query,
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('hides when review-requested @me is missing', () => {
|
||||
expect(
|
||||
shouldSoftHideTaskPageGitHubWorkItem({
|
||||
item: { state: 'open', assignees: [], reviewRequests: [] },
|
||||
query: baseQuery({ reviewRequested: '@me' }),
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('recomputeTaskPageGitHubItemSoftHide', () => {
|
||||
it('includes sticky hide for matching queryKey', () => {
|
||||
const itemKey = 'repo\0item'
|
||||
const result = recomputeTaskPageGitHubItemSoftHide({
|
||||
item: {
|
||||
state: 'open',
|
||||
assignees: [{ login: 'me', name: null, avatarUrl: '' }],
|
||||
reviewRequests: []
|
||||
},
|
||||
query: baseQuery({ state: 'open' }),
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false,
|
||||
queryKey: 'q1',
|
||||
sticky: new Map([
|
||||
[
|
||||
itemKey,
|
||||
{ itemKey, sourceScope: null, queryKey: 'q1', reason: 'filter_membership' as const }
|
||||
]
|
||||
]),
|
||||
itemKey
|
||||
})
|
||||
expect(result.hide).toBe(true)
|
||||
expect(result.sticky).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import type { ParsedTaskQuery } from '../../../shared/task-query'
|
||||
import type { GitHubAssignableUser, GitHubWorkItem } from '../../../shared/types'
|
||||
import type { StickyHideEntry } from './task-page-github-work-item-mutation-registry'
|
||||
|
||||
function includesLogin(users: readonly GitHubAssignableUser[] | undefined, login: string): boolean {
|
||||
const target = login.toLowerCase()
|
||||
return (users ?? []).some((user) => user.login.toLowerCase() === target)
|
||||
}
|
||||
|
||||
function resolveMeLogin(viewerLogin: string | null): string | null {
|
||||
const trimmed = viewerLogin?.trim()
|
||||
return trimmed ? trimmed.toLowerCase() : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a registry-merged work item should be soft-hidden for the active query.
|
||||
* Evaluate every applicable membership signal; hide if any fails (AND of constraints).
|
||||
*/
|
||||
export function shouldSoftHideTaskPageGitHubWorkItem(args: {
|
||||
item: Pick<GitHubWorkItem, 'state' | 'assignees' | 'reviewRequests'>
|
||||
query: ParsedTaskQuery
|
||||
viewerLogin: string | null
|
||||
/**
|
||||
* Per-item: true when this item’s sourceContext resolves to environment/SSH.
|
||||
* Never a page-level flag (multi-repo can mix local + remote).
|
||||
*/
|
||||
skipMeQualifiers: boolean
|
||||
}): boolean {
|
||||
const { item, query, skipMeQualifiers } = args
|
||||
const viewer = resolveMeLogin(args.viewerLogin)
|
||||
|
||||
// State membership
|
||||
if (query.state === 'open') {
|
||||
if (item.state === 'closed' || item.state === 'merged') {
|
||||
return true
|
||||
}
|
||||
} else if (query.state === 'closed') {
|
||||
// Why: closed-only lists treat merged as out-of-membership for soft-hide.
|
||||
if (item.state === 'open' || item.state === 'merged' || item.state === 'draft') {
|
||||
return true
|
||||
}
|
||||
} else if (query.state === 'merged') {
|
||||
if (item.state !== 'merged') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// state === null | 'all' → no state-based soft-hide
|
||||
|
||||
// Why: is:draft forces state to 'open', so a draft that turns non-draft still
|
||||
// passes the state check; the draft qualifier soft-hides it explicitly.
|
||||
if (query.draft && item.state !== 'draft') {
|
||||
return true
|
||||
}
|
||||
|
||||
// Assignee membership
|
||||
if (query.assignee) {
|
||||
const assignee = query.assignee.trim()
|
||||
if (assignee.toLowerCase() === '@me') {
|
||||
if (!skipMeQualifiers && viewer && !includesLogin(item.assignees, viewer)) {
|
||||
return true
|
||||
}
|
||||
} else if (!includesLogin(item.assignees, assignee.replace(/^@/, ''))) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Review-requested membership
|
||||
if (query.reviewRequested) {
|
||||
const requested = query.reviewRequested.trim()
|
||||
if (requested.toLowerCase() === '@me') {
|
||||
if (!skipMeQualifiers && viewer && !includesLogin(item.reviewRequests, viewer)) {
|
||||
return true
|
||||
}
|
||||
} else if (!includesLogin(item.reviewRequests, requested.replace(/^@/, ''))) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// author @me / labels / free text: do not soft-hide from those alone
|
||||
return false
|
||||
}
|
||||
|
||||
export function recomputeTaskPageGitHubItemSoftHide(args: {
|
||||
item: Pick<GitHubWorkItem, 'state' | 'assignees' | 'reviewRequests'>
|
||||
query: ParsedTaskQuery
|
||||
viewerLogin: string | null
|
||||
skipMeQualifiers: boolean
|
||||
queryKey: string
|
||||
sticky: ReadonlyMap<string, StickyHideEntry>
|
||||
itemKey: string
|
||||
}): { hide: boolean; sticky: boolean } {
|
||||
const membershipHide = shouldSoftHideTaskPageGitHubWorkItem({
|
||||
item: args.item,
|
||||
query: args.query,
|
||||
viewerLogin: args.viewerLogin,
|
||||
skipMeQualifiers: args.skipMeQualifiers
|
||||
})
|
||||
const stickyEntry = args.sticky.get(args.itemKey)
|
||||
const stickyActive = Boolean(stickyEntry && stickyEntry.queryKey === args.queryKey)
|
||||
// Why: sticky keeps successful membership exits hidden after pending clears;
|
||||
// pending membership hide still applies before confirm.
|
||||
return {
|
||||
hide: membershipHide || stickyActive,
|
||||
sticky: stickyActive
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
import type { ParsedTaskQuery } from '../../../shared/task-query'
|
||||
import type { GitHubAssignableUser, GitHubWorkItem } from '../../../shared/types'
|
||||
import {
|
||||
recomputeTaskPageGitHubItemSoftHide,
|
||||
shouldSoftHideTaskPageGitHubWorkItem
|
||||
} from './task-page-github-work-item-filter-membership'
|
||||
import { applyTaskPageGitHubListOps } from './task-page-github-work-item-mutation-patches'
|
||||
import {
|
||||
deleteStickyHideEntry,
|
||||
getAllStickyHideEntries,
|
||||
getConfirmedListSnapshot,
|
||||
getLastConfirmedClientValue,
|
||||
getTaskPageGitHubSoftHiddenItemKeys,
|
||||
hasConfirmedAuthorityForItem,
|
||||
listPendingTaskPageGitHubOpsForItem,
|
||||
resolveItemSourceScope,
|
||||
notifyTaskPageGitHubMutationRegistry,
|
||||
setSoftHiddenItemKeys,
|
||||
setStickyHideEntry,
|
||||
taskPageGitHubItemKey,
|
||||
updateSoftHiddenItemKey,
|
||||
type PendingListOp,
|
||||
type PendingOp,
|
||||
type TaskPageGitHubListFamily
|
||||
} from './task-page-github-work-item-mutation-registry'
|
||||
|
||||
export function freezeTaskPageGitHubUsers(
|
||||
users: readonly GitHubAssignableUser[]
|
||||
): GitHubAssignableUser[] {
|
||||
return users.map((user) => ({
|
||||
login: user.login,
|
||||
name: user.name,
|
||||
avatarUrl: user.avatarUrl
|
||||
}))
|
||||
}
|
||||
|
||||
export function pendingListOpsForFamily(
|
||||
ops: readonly PendingOp[],
|
||||
family: TaskPageGitHubListFamily
|
||||
): PendingListOp[] {
|
||||
// Why: same login may appear in multiple pending ops after rapid toggles;
|
||||
// keep only the latest op per login so composition matches last intent.
|
||||
return [...ops]
|
||||
.filter((pending) => pending.listOp?.family === family)
|
||||
.sort((a, b) => a.startedAt - b.startedAt)
|
||||
.reduce<PendingListOp[]>((acc, pending) => {
|
||||
if (!pending.listOp) {
|
||||
return acc
|
||||
}
|
||||
if (pending.listOp.logins.length > 1) {
|
||||
acc.push(pending.listOp)
|
||||
return acc
|
||||
}
|
||||
const login = pending.listOp.logins[0]
|
||||
const lastIndex = acc.findIndex((op) => op.logins.length === 1 && op.logins[0] === login)
|
||||
if (lastIndex >= 0) {
|
||||
acc[lastIndex] = pending.listOp
|
||||
} else {
|
||||
acc.push(pending.listOp)
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
|
||||
export function stripFamilyPendingFromList(
|
||||
item: GitHubWorkItem,
|
||||
family: TaskPageGitHubListFamily,
|
||||
ops: readonly PendingOp[]
|
||||
): GitHubAssignableUser[] {
|
||||
const current = freezeTaskPageGitHubUsers(
|
||||
family === 'assignees' ? (item.assignees ?? []) : (item.reviewRequests ?? [])
|
||||
)
|
||||
let list = current
|
||||
const familyOps = pendingListOpsForFamily(ops, family)
|
||||
// Why: strip by reversing adds/removes so lazy snapshot ignores in-flight intent.
|
||||
for (let i = familyOps.length - 1; i >= 0; i--) {
|
||||
const op = familyOps[i]
|
||||
for (let j = 0; j < op.logins.length; j++) {
|
||||
const login = op.logins[j]
|
||||
if (op.kind === 'add') {
|
||||
list = list.filter((user) => user.login.toLowerCase() !== login)
|
||||
} else if (!list.some((user) => user.login.toLowerCase() === login)) {
|
||||
const restored = op.users?.[j] ?? { login, name: null, avatarUrl: '' }
|
||||
list.push(restored)
|
||||
}
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
export function getRegistryMergedTaskPageGitHubWorkItem(
|
||||
item: GitHubWorkItem,
|
||||
sourceScope: string | null
|
||||
): GitHubWorkItem {
|
||||
const ops = listPendingTaskPageGitHubOpsForItem(item.repoId, item.id, sourceScope)
|
||||
let merged: GitHubWorkItem = { ...item }
|
||||
|
||||
// Why: after confirm, pending is cleared but search may still lag — hold the
|
||||
// last confirmed whole-field values until a matching adopt or newer pending.
|
||||
const lastState = getLastConfirmedClientValue(sourceScope, item.repoId, item.id, 'state')
|
||||
if (typeof lastState === 'string') {
|
||||
merged = { ...merged, state: lastState as GitHubWorkItem['state'] }
|
||||
}
|
||||
const lastAutoMerge = getLastConfirmedClientValue(sourceScope, item.repoId, item.id, 'autoMerge')
|
||||
if (typeof lastAutoMerge === 'boolean') {
|
||||
merged = { ...merged, autoMergeEnabled: lastAutoMerge }
|
||||
}
|
||||
|
||||
const wholeByOpKey = new Map<string, PendingOp>()
|
||||
for (const op of ops) {
|
||||
if (op.listOp) {
|
||||
continue
|
||||
}
|
||||
wholeByOpKey.set(op.key.opKey, op)
|
||||
}
|
||||
for (const op of wholeByOpKey.values()) {
|
||||
merged = { ...merged, ...op.next }
|
||||
}
|
||||
|
||||
for (const family of ['assignees', 'reviewRequests'] as const) {
|
||||
const familyOps = pendingListOpsForFamily(ops, family)
|
||||
let snapshot = getConfirmedListSnapshot(sourceScope, item.repoId, item.id, family)
|
||||
if (!snapshot && familyOps.length === 0) {
|
||||
continue
|
||||
}
|
||||
if (!snapshot) {
|
||||
snapshot = stripFamilyPendingFromList(item, family, ops)
|
||||
}
|
||||
const composed = applyTaskPageGitHubListOps(snapshot, familyOps)
|
||||
merged =
|
||||
family === 'assignees'
|
||||
? { ...merged, assignees: composed }
|
||||
: { ...merged, reviewRequests: composed }
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
export function recomputeSoftHideForItem(args: {
|
||||
item: GitHubWorkItem
|
||||
sourceScope: string | null
|
||||
query: ParsedTaskQuery
|
||||
queryKey: string
|
||||
viewerLogin: string | null
|
||||
skipMeQualifiers: boolean
|
||||
/** When true (confirm/rollback), set or clear sticky per K22. */
|
||||
updateSticky: boolean
|
||||
}): boolean {
|
||||
const merged = getRegistryMergedTaskPageGitHubWorkItem(args.item, args.sourceScope)
|
||||
const itemKey = taskPageGitHubItemKey(args.item.repoId, args.item.id)
|
||||
const membershipHide = shouldSoftHideTaskPageGitHubWorkItem({
|
||||
item: merged,
|
||||
query: args.query,
|
||||
viewerLogin: args.viewerLogin,
|
||||
skipMeQualifiers: args.skipMeQualifiers
|
||||
})
|
||||
|
||||
if (args.updateSticky) {
|
||||
if (membershipHide) {
|
||||
setStickyHideEntry({
|
||||
itemKey,
|
||||
sourceScope: args.sourceScope,
|
||||
queryKey: args.queryKey,
|
||||
reason: 'filter_membership'
|
||||
})
|
||||
} else {
|
||||
deleteStickyHideEntry(itemKey)
|
||||
}
|
||||
}
|
||||
|
||||
const result = recomputeTaskPageGitHubItemSoftHide({
|
||||
item: merged,
|
||||
query: args.query,
|
||||
viewerLogin: args.viewerLogin,
|
||||
skipMeQualifiers: args.skipMeQualifiers,
|
||||
queryKey: args.queryKey,
|
||||
sticky: getAllStickyHideEntries(),
|
||||
itemKey
|
||||
})
|
||||
updateSoftHiddenItemKey(itemKey, result.hide)
|
||||
return result.hide
|
||||
}
|
||||
|
||||
export function rebuildSoftHiddenKeysFromPendingAndSticky(args: {
|
||||
query: ParsedTaskQuery
|
||||
queryKey: string
|
||||
viewerLogin: string | null
|
||||
items: readonly GitHubWorkItem[]
|
||||
skipMeByItemKey?: ReadonlyMap<string, boolean>
|
||||
}): void {
|
||||
const next = new Set<string>()
|
||||
for (const [itemKey, entry] of getAllStickyHideEntries()) {
|
||||
if (entry.queryKey === args.queryKey) {
|
||||
next.add(itemKey)
|
||||
}
|
||||
}
|
||||
for (const item of args.items) {
|
||||
const ops = listPendingTaskPageGitHubOpsForItem(item.repoId, item.id)
|
||||
const itemKey = taskPageGitHubItemKey(item.repoId, item.id)
|
||||
if (
|
||||
ops.length === 0 &&
|
||||
!next.has(itemKey) &&
|
||||
!hasConfirmedAuthorityForItem(item.repoId, item.id)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const sourceScope = ops[0]?.key.sourceScope ?? resolveItemSourceScope(item.repoId, item.id)
|
||||
const skipMe = args.skipMeByItemKey?.get(itemKey) ?? ops[0]?.skipMeQualifiers ?? false
|
||||
const merged = getRegistryMergedTaskPageGitHubWorkItem(item, sourceScope)
|
||||
if (
|
||||
shouldSoftHideTaskPageGitHubWorkItem({
|
||||
item: merged,
|
||||
query: args.query,
|
||||
viewerLogin: args.viewerLogin,
|
||||
skipMeQualifiers: skipMe
|
||||
})
|
||||
) {
|
||||
next.add(itemKey)
|
||||
}
|
||||
}
|
||||
const current = getTaskPageGitHubSoftHiddenItemKeys()
|
||||
if (next.size === current.size && [...next].every((itemKey) => current.has(itemKey))) {
|
||||
return
|
||||
}
|
||||
setSoftHiddenItemKeys(next)
|
||||
notifyTaskPageGitHubMutationRegistry()
|
||||
}
|
||||
|
||||
export function familiesFromPendingOp(op: PendingOp): string[] {
|
||||
if (op.listOp) {
|
||||
return [op.listOp.family]
|
||||
}
|
||||
if (op.key.opKey === 'merge') {
|
||||
return ['state', 'merge', 'autoMerge']
|
||||
}
|
||||
if (op.key.opKey === 'autoMerge') {
|
||||
return ['autoMerge']
|
||||
}
|
||||
if (op.key.opKey === 'state') {
|
||||
return ['state']
|
||||
}
|
||||
return [op.key.opKey]
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import type {
|
||||
TaskPageGitHubListFamily,
|
||||
TaskPageGitHubMutationKey
|
||||
} from './task-page-github-work-item-registry-types'
|
||||
|
||||
export function taskPageGitHubItemKey(repoId: string, itemId: string): string {
|
||||
return `${repoId}\0${itemId}`
|
||||
}
|
||||
export function serializeTaskPageGitHubMutationKey(key: TaskPageGitHubMutationKey): string {
|
||||
return `${key.sourceScope ?? ''}\0${key.repoId}\0${key.itemId}\0${key.opKey}`
|
||||
}
|
||||
export function taskPageGitHubSnapshotKey(
|
||||
sourceScope: string | null,
|
||||
repoId: string,
|
||||
itemId: string,
|
||||
family: TaskPageGitHubListFamily
|
||||
): string {
|
||||
return `${sourceScope ?? ''}\0${repoId}\0${itemId}\0${family}`
|
||||
}
|
||||
export function taskPageGitHubLastConfirmedKey(
|
||||
sourceScope: string | null,
|
||||
repoId: string,
|
||||
itemId: string,
|
||||
family: string
|
||||
): string {
|
||||
return `${sourceScope ?? ''}\0${repoId}\0${itemId}\0${family}`
|
||||
}
|
||||
export function taskPageGitHubFamilyDirtyKey(itemKey: string, family: string): string {
|
||||
return `${itemKey}\0${family}`
|
||||
}
|
||||
export function taskPageGitHubListOpKey(
|
||||
family: TaskPageGitHubListFamily,
|
||||
logins: readonly string[]
|
||||
): string {
|
||||
const normalized = logins.map((login) => login.toLowerCase()).sort()
|
||||
if (normalized.length === 1) {
|
||||
return `${family}:${normalized[0]}`
|
||||
}
|
||||
return `${family}:batch:${normalized.join(',')}`
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
import type { ParsedTaskQuery } from '../../../shared/task-query'
|
||||
import type { GitHubWorkItem } from '../../../shared/types'
|
||||
import type { TaskSourceContext } from '../../../shared/task-source-context'
|
||||
import { applyTaskPageGitHubListOps } from './task-page-github-work-item-mutation-patches'
|
||||
import {
|
||||
familiesFromPendingOp,
|
||||
freezeTaskPageGitHubUsers,
|
||||
getRegistryMergedTaskPageGitHubWorkItem,
|
||||
recomputeSoftHideForItem
|
||||
} from './task-page-github-work-item-mutation-composition'
|
||||
import {
|
||||
deletePendingTaskPageGitHubOp,
|
||||
getConfirmedListSnapshot,
|
||||
getPendingTaskPageGitHubOp,
|
||||
getTaskPageGitHubMutationQueryKey,
|
||||
listPendingTaskPageGitHubOpsForItem,
|
||||
notifyTaskPageGitHubMutationRegistry,
|
||||
setConfirmedListSnapshot,
|
||||
setLastConfirmedClientValue,
|
||||
markTaskPageGitHubFamiliesDirty,
|
||||
isTaskPageGitHubMutationQueryKeyCurrent,
|
||||
taskPageGitHubItemKey,
|
||||
type TaskPageGitHubMutationKey
|
||||
} from './task-page-github-work-item-mutation-registry'
|
||||
import type { TaskPageGitHubPatchWorkItem } from './task-page-github-work-item-mutation-types'
|
||||
function applyServerEntityIfPresent(
|
||||
key: TaskPageGitHubMutationKey,
|
||||
opts: {
|
||||
serverEntity?: Partial<GitHubWorkItem>
|
||||
patchWorkItem?: TaskPageGitHubPatchWorkItem
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}
|
||||
): void {
|
||||
if (!opts.serverEntity || !opts.patchWorkItem) {
|
||||
return
|
||||
}
|
||||
const entityPatch: Partial<GitHubWorkItem> = {}
|
||||
if (opts.serverEntity.state !== undefined) {
|
||||
entityPatch.state = opts.serverEntity.state
|
||||
setLastConfirmedClientValue(
|
||||
key.sourceScope,
|
||||
key.repoId,
|
||||
key.itemId,
|
||||
'state',
|
||||
opts.serverEntity.state
|
||||
)
|
||||
}
|
||||
if (opts.serverEntity.autoMergeEnabled !== undefined) {
|
||||
entityPatch.autoMergeEnabled = opts.serverEntity.autoMergeEnabled
|
||||
setLastConfirmedClientValue(
|
||||
key.sourceScope,
|
||||
key.repoId,
|
||||
key.itemId,
|
||||
'autoMerge',
|
||||
opts.serverEntity.autoMergeEnabled
|
||||
)
|
||||
}
|
||||
if (opts.serverEntity.assignees) {
|
||||
const users = freezeTaskPageGitHubUsers(opts.serverEntity.assignees)
|
||||
setConfirmedListSnapshot(key.sourceScope, key.repoId, key.itemId, 'assignees', users)
|
||||
entityPatch.assignees = users
|
||||
}
|
||||
if (opts.serverEntity.reviewRequests) {
|
||||
const users = freezeTaskPageGitHubUsers(opts.serverEntity.reviewRequests)
|
||||
setConfirmedListSnapshot(key.sourceScope, key.repoId, key.itemId, 'reviewRequests', users)
|
||||
entityPatch.reviewRequests = users
|
||||
}
|
||||
if (Object.keys(entityPatch).length > 0) {
|
||||
opts.patchWorkItem(key.itemId, entityPatch, key.repoId, {
|
||||
sourceContext: opts.sourceContext
|
||||
})
|
||||
}
|
||||
}
|
||||
export function confirmTaskPageGitHubWorkItemMutation(
|
||||
key: TaskPageGitHubMutationKey,
|
||||
generation: number,
|
||||
opts: {
|
||||
query: ParsedTaskQuery
|
||||
queryKey: string
|
||||
viewerLogin: string | null
|
||||
item: GitHubWorkItem
|
||||
serverEntity?: Partial<GitHubWorkItem>
|
||||
patchWorkItem?: TaskPageGitHubPatchWorkItem
|
||||
sourceContext?: TaskSourceContext | null
|
||||
scheduleQuiet?: boolean
|
||||
}
|
||||
): 'confirmed' | 'stale' {
|
||||
const pending = getPendingTaskPageGitHubOp(key)
|
||||
if (!pending || pending.generation !== generation) {
|
||||
return 'stale'
|
||||
}
|
||||
// K20: capture before delete.
|
||||
const { skipMeQualifiers, listOp, next } = pending
|
||||
if (listOp) {
|
||||
const snapshot =
|
||||
getConfirmedListSnapshot(key.sourceScope, key.repoId, key.itemId, listOp.family) ??
|
||||
freezeTaskPageGitHubUsers(
|
||||
listOp.family === 'assignees'
|
||||
? (opts.item.assignees ?? [])
|
||||
: (opts.item.reviewRequests ?? [])
|
||||
)
|
||||
// K10: apply confirmed op into snapshot immediately. List authority lives in
|
||||
// confirmedSnapshots; lastConfirmedClientValue is scalar-only (state/autoMerge).
|
||||
const applied = applyTaskPageGitHubListOps(snapshot, [listOp])
|
||||
setConfirmedListSnapshot(key.sourceScope, key.repoId, key.itemId, listOp.family, applied)
|
||||
} else {
|
||||
if (next.state !== undefined) {
|
||||
setLastConfirmedClientValue(key.sourceScope, key.repoId, key.itemId, 'state', next.state)
|
||||
}
|
||||
if (next.autoMergeEnabled !== undefined) {
|
||||
setLastConfirmedClientValue(
|
||||
key.sourceScope,
|
||||
key.repoId,
|
||||
key.itemId,
|
||||
'autoMerge',
|
||||
next.autoMergeEnabled
|
||||
)
|
||||
}
|
||||
}
|
||||
deletePendingTaskPageGitHubOp(key)
|
||||
applyServerEntityIfPresent(key, opts)
|
||||
const remaining = listPendingTaskPageGitHubOpsForItem(key.repoId, key.itemId, key.sourceScope)
|
||||
const merged = getRegistryMergedTaskPageGitHubWorkItem(opts.item, key.sourceScope)
|
||||
if (opts.patchWorkItem && remaining.some((op) => op.listOp)) {
|
||||
opts.patchWorkItem(
|
||||
key.itemId,
|
||||
{ assignees: merged.assignees, reviewRequests: merged.reviewRequests },
|
||||
key.repoId,
|
||||
{ sourceContext: opts.sourceContext }
|
||||
)
|
||||
}
|
||||
if (isTaskPageGitHubMutationQueryKeyCurrent(opts.queryKey)) {
|
||||
recomputeSoftHideForItem({
|
||||
item: { ...opts.item, ...merged },
|
||||
sourceScope: key.sourceScope,
|
||||
query: opts.query,
|
||||
queryKey: opts.queryKey,
|
||||
viewerLogin: opts.viewerLogin,
|
||||
skipMeQualifiers,
|
||||
updateSticky: true
|
||||
})
|
||||
}
|
||||
const itemKey = taskPageGitHubItemKey(key.repoId, key.itemId)
|
||||
markTaskPageGitHubFamiliesDirty(
|
||||
getTaskPageGitHubMutationQueryKey() ?? opts.queryKey,
|
||||
itemKey,
|
||||
familiesFromPendingOp(pending)
|
||||
)
|
||||
notifyTaskPageGitHubMutationRegistry()
|
||||
return 'confirmed'
|
||||
}
|
||||
export function rollbackTaskPageGitHubWorkItemMutation(args: {
|
||||
key: TaskPageGitHubMutationKey
|
||||
generation: number
|
||||
patchWorkItem: TaskPageGitHubPatchWorkItem
|
||||
sourceContext?: TaskSourceContext | null
|
||||
query: ParsedTaskQuery
|
||||
queryKey: string
|
||||
viewerLogin: string | null
|
||||
item: GitHubWorkItem
|
||||
}): 'rolled_back' | 'stale' {
|
||||
const pending = getPendingTaskPageGitHubOp(args.key)
|
||||
if (!pending || pending.generation !== args.generation) {
|
||||
return 'stale'
|
||||
}
|
||||
const { skipMeQualifiers, listOp } = pending
|
||||
deletePendingTaskPageGitHubOp(args.key)
|
||||
const merged = getRegistryMergedTaskPageGitHubWorkItem(args.item, args.key.sourceScope)
|
||||
if (listOp) {
|
||||
args.patchWorkItem(
|
||||
args.key.itemId,
|
||||
listOp.family === 'assignees'
|
||||
? { assignees: merged.assignees }
|
||||
: { reviewRequests: merged.reviewRequests },
|
||||
args.key.repoId,
|
||||
{ sourceContext: args.sourceContext }
|
||||
)
|
||||
} else {
|
||||
const recomposed = getRegistryMergedTaskPageGitHubWorkItem(
|
||||
{ ...args.item, ...pending.previous },
|
||||
args.key.sourceScope
|
||||
)
|
||||
args.patchWorkItem(
|
||||
args.key.itemId,
|
||||
{
|
||||
state: recomposed.state,
|
||||
autoMergeEnabled: recomposed.autoMergeEnabled
|
||||
},
|
||||
args.key.repoId,
|
||||
{ sourceContext: args.sourceContext }
|
||||
)
|
||||
}
|
||||
const after = getRegistryMergedTaskPageGitHubWorkItem(args.item, args.key.sourceScope)
|
||||
if (isTaskPageGitHubMutationQueryKeyCurrent(args.queryKey)) {
|
||||
recomputeSoftHideForItem({
|
||||
item: { ...args.item, ...after },
|
||||
sourceScope: args.key.sourceScope,
|
||||
query: args.query,
|
||||
queryKey: args.queryKey,
|
||||
viewerLogin: args.viewerLogin,
|
||||
skipMeQualifiers,
|
||||
updateSticky: true
|
||||
})
|
||||
}
|
||||
notifyTaskPageGitHubMutationRegistry()
|
||||
return 'rolled_back'
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import type { GitHubWorkItem } from '../../../shared/types'
|
||||
import type { TaskSourceContext } from '../../../shared/task-source-context'
|
||||
import { getRegistryMergedTaskPageGitHubWorkItem } from './task-page-github-work-item-mutation-composition'
|
||||
import {
|
||||
getStickyHideEntry,
|
||||
hasConfirmedAuthorityForItem,
|
||||
hasPendingTaskPageGitHubOpsForItem,
|
||||
resolveItemSourceScope,
|
||||
taskPageGitHubItemKey
|
||||
} from './task-page-github-work-item-mutation-registry'
|
||||
import type { TaskPageGitHubPatchWorkItem } from './task-page-github-work-item-mutation-types'
|
||||
|
||||
export function patchTaskPageGitHubWorkItemPages(
|
||||
pages: readonly (GitHubWorkItem[] | null)[],
|
||||
itemKey: { id: string; repoId: string },
|
||||
patch: Partial<GitHubWorkItem>,
|
||||
shouldPatch?: (item: GitHubWorkItem) => boolean
|
||||
): (GitHubWorkItem[] | null)[] {
|
||||
let changed = false
|
||||
const nextPages = pages.map((page) => {
|
||||
if (!page) {
|
||||
return null
|
||||
}
|
||||
let pageChanged = false
|
||||
const nextPage = page.map((item) => {
|
||||
if (
|
||||
item.id !== itemKey.id ||
|
||||
item.repoId !== itemKey.repoId ||
|
||||
(shouldPatch && !shouldPatch(item))
|
||||
) {
|
||||
return item
|
||||
}
|
||||
changed = true
|
||||
pageChanged = true
|
||||
return { ...item, ...patch }
|
||||
})
|
||||
return pageChanged ? nextPage : page
|
||||
})
|
||||
return changed ? nextPages : (pages as (GitHubWorkItem[] | null)[])
|
||||
}
|
||||
|
||||
/** Match each item to pending/confirmed authority by repoId + itemId + remembered sourceScope. */
|
||||
export function applyPendingTaskPageGitHubMutationsToItems(
|
||||
items: readonly GitHubWorkItem[]
|
||||
): GitHubWorkItem[] {
|
||||
return items.map((item) => {
|
||||
const sourceScope = resolveItemSourceScope(item.repoId, item.id)
|
||||
return getRegistryMergedTaskPageGitHubWorkItem(item, sourceScope)
|
||||
})
|
||||
}
|
||||
|
||||
export function reapplyPendingTaskPageGitHubMutationsToCache(args: {
|
||||
items: readonly GitHubWorkItem[]
|
||||
patchWorkItem: TaskPageGitHubPatchWorkItem
|
||||
sourceContextByRepoId?: ReadonlyMap<string, TaskSourceContext | null | undefined>
|
||||
}): void {
|
||||
for (const item of args.items) {
|
||||
const sourceScope = resolveItemSourceScope(item.repoId, item.id)
|
||||
const hasAuthority =
|
||||
hasPendingTaskPageGitHubOpsForItem(item.repoId, item.id) ||
|
||||
hasConfirmedAuthorityForItem(item.repoId, item.id)
|
||||
if (!hasAuthority) {
|
||||
continue
|
||||
}
|
||||
const merged = getRegistryMergedTaskPageGitHubWorkItem(item, sourceScope)
|
||||
args.patchWorkItem(
|
||||
item.id,
|
||||
{
|
||||
state: merged.state,
|
||||
assignees: merged.assignees,
|
||||
reviewRequests: merged.reviewRequests,
|
||||
autoMergeEnabled: merged.autoMergeEnabled
|
||||
},
|
||||
item.repoId,
|
||||
{ sourceContext: args.sourceContextByRepoId?.get(item.repoId) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Full-replace flat list: overlay + retain pending/confirmed-omitted rows (K18). */
|
||||
export function materializeTaskPageItemList(args: {
|
||||
networkItems: readonly GitHubWorkItem[]
|
||||
previousItems: readonly GitHubWorkItem[]
|
||||
queryKey: string
|
||||
}): GitHubWorkItem[] {
|
||||
const overlaid = applyPendingTaskPageGitHubMutationsToItems(args.networkItems)
|
||||
const byKey = new Map(overlaid.map((item) => [taskPageGitHubItemKey(item.repoId, item.id), item]))
|
||||
for (const item of args.previousItems) {
|
||||
const k = taskPageGitHubItemKey(item.repoId, item.id)
|
||||
if (byKey.has(k)) {
|
||||
continue
|
||||
}
|
||||
// Why: retain in-flight pending rows for rollback visibility; also retain
|
||||
// confirmed rows soft-hidden by a sticky hide scoped to THIS query while
|
||||
// search lag omits them. Requiring the query-scoped sticky avoids retaining
|
||||
// non-membership confirms (e.g. auto-merge) as stale ghosts across refetch.
|
||||
const hasPending = hasPendingTaskPageGitHubOpsForItem(item.repoId, item.id)
|
||||
const sticky = getStickyHideEntry(k)
|
||||
const hasConfirmedStickyHide =
|
||||
hasConfirmedAuthorityForItem(item.repoId, item.id) && sticky?.queryKey === args.queryKey
|
||||
if (!hasPending && !hasConfirmedStickyHide) {
|
||||
continue
|
||||
}
|
||||
const scope = resolveItemSourceScope(item.repoId, item.id)
|
||||
byKey.set(k, getRegistryMergedTaskPageGitHubWorkItem(item, scope))
|
||||
}
|
||||
return [...byKey.values()].sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
)
|
||||
}
|
||||
|
||||
export function reconcileTaskPagePagesAfterQuietRefresh(args: {
|
||||
pages: readonly (GitHubWorkItem[] | null)[]
|
||||
queryKey: string
|
||||
authorityPage: number
|
||||
authorityItems: readonly GitHubWorkItem[]
|
||||
membershipChanged: boolean
|
||||
visiblePage?: number
|
||||
visibleItems?: readonly GitHubWorkItem[]
|
||||
}): (GitHubWorkItem[] | null)[] {
|
||||
const next = [...args.pages]
|
||||
const lastPage = args.visiblePage ?? args.authorityPage
|
||||
while (next.length <= lastPage) {
|
||||
next.push(null)
|
||||
}
|
||||
next[args.authorityPage] = materializeTaskPageItemList({
|
||||
networkItems: args.authorityItems,
|
||||
previousItems: args.pages[args.authorityPage] ?? [],
|
||||
queryKey: args.queryKey
|
||||
})
|
||||
if (args.visiblePage !== undefined && args.visibleItems !== undefined) {
|
||||
if (args.membershipChanged) {
|
||||
for (let page = args.authorityPage + 1; page < args.visiblePage; page++) {
|
||||
next[page] = null
|
||||
}
|
||||
}
|
||||
next[args.visiblePage] = materializeTaskPageItemList({
|
||||
networkItems: args.visibleItems,
|
||||
previousItems: args.pages[args.visiblePage] ?? [],
|
||||
queryKey: args.queryKey
|
||||
})
|
||||
}
|
||||
if (args.membershipChanged) {
|
||||
next.length = lastPage + 1
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/** In-place overlay per page; preserves multi-page structure; no retain. */
|
||||
export function overlayPendingOnTaskPagePages(
|
||||
pages: readonly GitHubWorkItem[][]
|
||||
): GitHubWorkItem[][] {
|
||||
return pages.map((page) => applyPendingTaskPageGitHubMutationsToItems(page))
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { GitHubWorkItem } from '../../../shared/types'
|
||||
import {
|
||||
applyTaskPageGitHubListOps,
|
||||
buildTaskPageGitHubWorkItemMutationPatch,
|
||||
loginSetOfUsers,
|
||||
loginSetsEqual
|
||||
} from './task-page-github-work-item-mutation-patches'
|
||||
|
||||
function baseItem(overrides: Partial<GitHubWorkItem> = {}): GitHubWorkItem {
|
||||
return {
|
||||
id: 'issue:1',
|
||||
type: 'issue',
|
||||
number: 1,
|
||||
title: 't',
|
||||
state: 'open',
|
||||
url: 'https://github.com/o/r/issues/1',
|
||||
labels: [],
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
author: 'alice',
|
||||
repoId: 'repo-1',
|
||||
assignees: [],
|
||||
reviewRequests: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('applyTaskPageGitHubListOps', () => {
|
||||
it('adds and removes logins', () => {
|
||||
const snapshot = [{ login: 'alice', name: null, avatarUrl: '' }]
|
||||
const afterAdd = applyTaskPageGitHubListOps(snapshot, [
|
||||
{
|
||||
family: 'assignees',
|
||||
kind: 'add',
|
||||
logins: ['bob'],
|
||||
users: [{ login: 'bob', name: 'Bob', avatarUrl: 'x' }]
|
||||
}
|
||||
])
|
||||
expect(afterAdd.map((u) => u.login)).toEqual(['alice', 'bob'])
|
||||
const afterRemove = applyTaskPageGitHubListOps(afterAdd, [
|
||||
{ family: 'assignees', kind: 'remove', logins: ['alice'] }
|
||||
])
|
||||
expect(afterRemove.map((u) => u.login)).toEqual(['bob'])
|
||||
})
|
||||
|
||||
it('does not mutate input snapshot arrays', () => {
|
||||
const snapshot = [{ login: 'alice', name: null, avatarUrl: '' }]
|
||||
const frozen = [...snapshot]
|
||||
applyTaskPageGitHubListOps(snapshot, [{ family: 'assignees', kind: 'add', logins: ['bob'] }])
|
||||
expect(snapshot).toEqual(frozen)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildTaskPageGitHubWorkItemMutationPatch', () => {
|
||||
it('builds merge patch with state merged and autoMerge cleared', () => {
|
||||
const item = baseItem({
|
||||
type: 'pr',
|
||||
state: 'open',
|
||||
autoMergeEnabled: true
|
||||
})
|
||||
const patch = buildTaskPageGitHubWorkItemMutationPatch(item, { type: 'merge' })
|
||||
expect(patch.kind).toBe('whole')
|
||||
if (patch.kind !== 'whole') {
|
||||
return
|
||||
}
|
||||
expect(patch.next).toEqual({ state: 'merged', autoMergeEnabled: false })
|
||||
expect(patch.previous).toEqual({ state: 'open', autoMergeEnabled: true })
|
||||
})
|
||||
|
||||
it('builds autoMerge patch', () => {
|
||||
const patch = buildTaskPageGitHubWorkItemMutationPatch(baseItem({ type: 'pr' }), {
|
||||
type: 'setAutoMerge',
|
||||
enabled: true
|
||||
})
|
||||
expect(patch.next).toEqual({ autoMergeEnabled: true })
|
||||
})
|
||||
|
||||
it('builds per-login assignee toggle', () => {
|
||||
const patch = buildTaskPageGitHubWorkItemMutationPatch(baseItem(), {
|
||||
type: 'toggleAssignee',
|
||||
user: { login: 'Alice', name: 'A', avatarUrl: '' }
|
||||
})
|
||||
expect(patch.kind).toBe('list')
|
||||
if (patch.kind !== 'list') {
|
||||
return
|
||||
}
|
||||
expect(patch.opKey).toBe('assignees:alice')
|
||||
expect(patch.listOp.kind).toBe('add')
|
||||
expect(patch.next.assignees?.map((u) => u.login.toLowerCase())).toEqual(['alice'])
|
||||
})
|
||||
|
||||
it('builds multi-login reviewer batch opKey', () => {
|
||||
const patch = buildTaskPageGitHubWorkItemMutationPatch(baseItem({ type: 'pr' }), {
|
||||
type: 'addReviewers',
|
||||
logins: ['bob', 'carol'],
|
||||
candidates: [
|
||||
{ login: 'bob', name: null, avatarUrl: '' },
|
||||
{ login: 'carol', name: null, avatarUrl: '' }
|
||||
]
|
||||
})
|
||||
expect(patch.kind).toBe('list')
|
||||
if (patch.kind !== 'list') {
|
||||
return
|
||||
}
|
||||
expect(patch.opKey).toBe('reviewRequests:batch:bob,carol')
|
||||
expect(patch.listOp.logins).toEqual(['bob', 'carol'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('loginSetsEqual', () => {
|
||||
it('compares case-insensitive login sets', () => {
|
||||
expect(
|
||||
loginSetsEqual(
|
||||
loginSetOfUsers([{ login: 'Alice', name: null, avatarUrl: '' }]),
|
||||
loginSetOfUsers([{ login: 'alice', name: null, avatarUrl: '' }])
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
import type { GitHubAssignableUser, GitHubWorkItem } from '../../../shared/types'
|
||||
import type { TaskPageGitHubCloseAction } from './task-page-github-status-actions'
|
||||
import {
|
||||
taskPageGitHubListOpKey,
|
||||
type PendingListOp,
|
||||
type TaskPageGitHubListFamily
|
||||
} from './task-page-github-work-item-mutation-registry'
|
||||
|
||||
export type TaskPageGitHubMutationIntent =
|
||||
| { type: 'setState'; state: 'open' | 'closed'; closeAction?: TaskPageGitHubCloseAction }
|
||||
| { type: 'toggleAssignee'; user: GitHubAssignableUser }
|
||||
| { type: 'addReviewers'; logins: string[]; candidates: GitHubAssignableUser[] }
|
||||
| { type: 'removeReviewers'; logins: string[] }
|
||||
| { type: 'merge' }
|
||||
| { type: 'setAutoMerge'; enabled: boolean }
|
||||
|
||||
export type TaskPageGitHubWholeFieldPatch = {
|
||||
kind: 'whole'
|
||||
opKey: string
|
||||
previous: Partial<GitHubWorkItem>
|
||||
next: Partial<GitHubWorkItem>
|
||||
/** Families touched for quiet dirty-bit / lastConfirmed. */
|
||||
families: string[]
|
||||
}
|
||||
|
||||
export type TaskPageGitHubListFieldPatch = {
|
||||
kind: 'list'
|
||||
opKey: string
|
||||
family: TaskPageGitHubListFamily
|
||||
listOp: PendingListOp
|
||||
previous: Partial<GitHubWorkItem>
|
||||
next: Partial<GitHubWorkItem>
|
||||
families: string[]
|
||||
}
|
||||
|
||||
export type TaskPageGitHubBuiltPatch = TaskPageGitHubWholeFieldPatch | TaskPageGitHubListFieldPatch
|
||||
|
||||
function freezeUsers(users: readonly GitHubAssignableUser[]): GitHubAssignableUser[] {
|
||||
return users.map((user) => ({
|
||||
login: user.login,
|
||||
name: user.name,
|
||||
avatarUrl: user.avatarUrl
|
||||
}))
|
||||
}
|
||||
|
||||
function normalizeLogin(login: string): string {
|
||||
return login.trim().replace(/^@/, '').toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply pending list ops onto a confirmed snapshot.
|
||||
* Same login keeps only the latest op (callers should pre-collapse if needed).
|
||||
*/
|
||||
export function applyTaskPageGitHubListOps(
|
||||
snapshot: readonly GitHubAssignableUser[],
|
||||
ops: readonly PendingListOp[]
|
||||
): GitHubAssignableUser[] {
|
||||
let list = freezeUsers(snapshot)
|
||||
for (const op of ops) {
|
||||
for (let i = 0; i < op.logins.length; i++) {
|
||||
const login = op.logins[i]
|
||||
if (op.kind === 'add') {
|
||||
if (!list.some((user) => user.login.toLowerCase() === login)) {
|
||||
const candidate = op.users?.[i]
|
||||
list.push(
|
||||
candidate
|
||||
? { login: candidate.login, name: candidate.name, avatarUrl: candidate.avatarUrl }
|
||||
: { login, name: null, avatarUrl: '' }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
list = list.filter((user) => user.login.toLowerCase() !== login)
|
||||
}
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
export function loginSetOfUsers(users: readonly GitHubAssignableUser[] | undefined): Set<string> {
|
||||
const set = new Set<string>()
|
||||
for (const user of users ?? []) {
|
||||
if (user.login) {
|
||||
set.add(user.login.toLowerCase())
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
export function loginSetsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
|
||||
if (a.size !== b.size) {
|
||||
return false
|
||||
}
|
||||
for (const login of a) {
|
||||
if (!b.has(login)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function findUser(
|
||||
candidates: readonly GitHubAssignableUser[],
|
||||
loginLower: string
|
||||
): GitHubAssignableUser | undefined {
|
||||
return candidates.find((user) => user.login.toLowerCase() === loginLower)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure intent → frozen previous/next patches against a registry-merged base item.
|
||||
*/
|
||||
export function buildTaskPageGitHubWorkItemMutationPatch(
|
||||
baseItem: GitHubWorkItem,
|
||||
intent: TaskPageGitHubMutationIntent
|
||||
): TaskPageGitHubBuiltPatch {
|
||||
switch (intent.type) {
|
||||
case 'setState': {
|
||||
return {
|
||||
kind: 'whole',
|
||||
opKey: 'state',
|
||||
previous: { state: baseItem.state },
|
||||
next: { state: intent.state },
|
||||
families: ['state']
|
||||
}
|
||||
}
|
||||
case 'merge': {
|
||||
return {
|
||||
kind: 'whole',
|
||||
opKey: 'merge',
|
||||
previous: {
|
||||
state: baseItem.state,
|
||||
autoMergeEnabled: baseItem.autoMergeEnabled
|
||||
},
|
||||
next: {
|
||||
state: 'merged',
|
||||
autoMergeEnabled: false
|
||||
},
|
||||
families: ['state', 'merge', 'autoMerge']
|
||||
}
|
||||
}
|
||||
case 'setAutoMerge': {
|
||||
return {
|
||||
kind: 'whole',
|
||||
opKey: 'autoMerge',
|
||||
previous: { autoMergeEnabled: baseItem.autoMergeEnabled },
|
||||
next: { autoMergeEnabled: intent.enabled },
|
||||
families: ['autoMerge']
|
||||
}
|
||||
}
|
||||
case 'toggleAssignee': {
|
||||
const login = normalizeLogin(intent.user.login)
|
||||
const current = freezeUsers(baseItem.assignees ?? [])
|
||||
const isOn = current.some((user) => user.login.toLowerCase() === login)
|
||||
const listOp: PendingListOp = isOn
|
||||
? { family: 'assignees', kind: 'remove', logins: [login] }
|
||||
: {
|
||||
family: 'assignees',
|
||||
kind: 'add',
|
||||
logins: [login],
|
||||
users: [
|
||||
{
|
||||
login: intent.user.login,
|
||||
name: intent.user.name,
|
||||
avatarUrl: intent.user.avatarUrl
|
||||
}
|
||||
]
|
||||
}
|
||||
const nextAssignees = applyTaskPageGitHubListOps(current, [listOp])
|
||||
return {
|
||||
kind: 'list',
|
||||
opKey: taskPageGitHubListOpKey('assignees', [login]),
|
||||
family: 'assignees',
|
||||
listOp,
|
||||
previous: { assignees: current },
|
||||
next: { assignees: nextAssignees },
|
||||
families: ['assignees']
|
||||
}
|
||||
}
|
||||
case 'addReviewers': {
|
||||
const logins = intent.logins.map(normalizeLogin).filter(Boolean)
|
||||
const unique = [...new Set(logins)]
|
||||
const users = unique.map((login) => {
|
||||
const fromCandidates = findUser(intent.candidates, login)
|
||||
const fromCurrent = findUser(baseItem.reviewRequests ?? [], login)
|
||||
return (
|
||||
fromCandidates ??
|
||||
fromCurrent ?? {
|
||||
login,
|
||||
name: null,
|
||||
avatarUrl: ''
|
||||
}
|
||||
)
|
||||
})
|
||||
const listOp: PendingListOp = {
|
||||
family: 'reviewRequests',
|
||||
kind: 'add',
|
||||
logins: unique,
|
||||
users: freezeUsers(users)
|
||||
}
|
||||
const current = freezeUsers(baseItem.reviewRequests ?? [])
|
||||
return {
|
||||
kind: 'list',
|
||||
opKey: taskPageGitHubListOpKey('reviewRequests', unique),
|
||||
family: 'reviewRequests',
|
||||
listOp,
|
||||
previous: { reviewRequests: current },
|
||||
next: { reviewRequests: applyTaskPageGitHubListOps(current, [listOp]) },
|
||||
families: ['reviewRequests']
|
||||
}
|
||||
}
|
||||
case 'removeReviewers': {
|
||||
const logins = intent.logins.map(normalizeLogin).filter(Boolean)
|
||||
const unique = [...new Set(logins)]
|
||||
const listOp: PendingListOp = {
|
||||
family: 'reviewRequests',
|
||||
kind: 'remove',
|
||||
logins: unique
|
||||
}
|
||||
const current = freezeUsers(baseItem.reviewRequests ?? [])
|
||||
return {
|
||||
kind: 'list',
|
||||
opKey: taskPageGitHubListOpKey('reviewRequests', unique),
|
||||
family: 'reviewRequests',
|
||||
listOp,
|
||||
previous: { reviewRequests: current },
|
||||
next: { reviewRequests: applyTaskPageGitHubListOps(current, [listOp]) },
|
||||
families: ['reviewRequests']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,328 @@
|
|||
import type { GitHubAssignableUser } from '../../../shared/types'
|
||||
import type {
|
||||
PendingOp,
|
||||
StickyHideEntry,
|
||||
TaskPageGitHubListFamily,
|
||||
TaskPageGitHubMutationKey
|
||||
} from './task-page-github-work-item-registry-types'
|
||||
export type {
|
||||
PendingListOp,
|
||||
PendingOp,
|
||||
StickyHideEntry,
|
||||
TaskPageGitHubListFamily,
|
||||
TaskPageGitHubMutationKey
|
||||
} from './task-page-github-work-item-registry-types'
|
||||
import {
|
||||
serializeTaskPageGitHubMutationKey,
|
||||
taskPageGitHubItemKey,
|
||||
taskPageGitHubLastConfirmedKey,
|
||||
taskPageGitHubSnapshotKey
|
||||
} from './task-page-github-work-item-mutation-keys'
|
||||
import { clearTaskPageGitHubQuietStates } from './task-page-github-work-item-quiet-state'
|
||||
export {
|
||||
getOrCreateQuietRevalidateState,
|
||||
markTaskPageGitHubFamiliesDirty,
|
||||
type QuietRevalidateState
|
||||
} from './task-page-github-work-item-quiet-state'
|
||||
export {
|
||||
serializeTaskPageGitHubMutationKey,
|
||||
taskPageGitHubFamilyDirtyKey,
|
||||
taskPageGitHubItemKey,
|
||||
taskPageGitHubLastConfirmedKey,
|
||||
taskPageGitHubListOpKey,
|
||||
taskPageGitHubSnapshotKey
|
||||
} from './task-page-github-work-item-mutation-keys'
|
||||
type Listener = () => void
|
||||
const listeners = new Set<Listener>()
|
||||
const pendingByKey = new Map<string, PendingOp>()
|
||||
const generations = new Map<string, number>()
|
||||
const confirmedSnapshots = new Map<string, GitHubAssignableUser[]>()
|
||||
const lastConfirmedClientValues = new Map<string, unknown>()
|
||||
/**
|
||||
* Why: after confirm, pending ops are gone but lastConfirmed/snapshots stay keyed
|
||||
* by sourceScope. Overlay must still resolve the same scope or authority is lost.
|
||||
*/
|
||||
const itemSourceScopeByItemKey = new Map<string, string | null>()
|
||||
const stickyHideByItemKey = new Map<string, StickyHideEntry>()
|
||||
const softHiddenItemKeys = new Set<string>()
|
||||
let mutationQueryKey: string | null = null
|
||||
export function subscribeTaskPageGitHubMutationRegistry(listener: Listener): () => void {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
export function notifyTaskPageGitHubMutationRegistry(): void {
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
export function getTaskPageGitHubSoftHiddenItemKeys(): ReadonlySet<string> {
|
||||
return softHiddenItemKeys
|
||||
}
|
||||
export function getTaskPageGitHubConfirmedAuthorityItemKeys(): ReadonlySet<string> {
|
||||
const keys = new Set<string>()
|
||||
for (const itemKey of itemSourceScopeByItemKey.keys()) {
|
||||
const separator = itemKey.indexOf('\0')
|
||||
if (
|
||||
separator >= 0 &&
|
||||
hasConfirmedAuthorityForItem(itemKey.slice(0, separator), itemKey.slice(separator + 1))
|
||||
) {
|
||||
keys.add(itemKey)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
/**
|
||||
* Drop confirmed-client authority (not in-flight pending). Used when the user
|
||||
* hard-refreshes so search can adopt for non-pending families (design tier 3).
|
||||
*/
|
||||
export function clearTaskPageGitHubConfirmedAuthority(): void {
|
||||
confirmedSnapshots.clear()
|
||||
lastConfirmedClientValues.clear()
|
||||
itemSourceScopeByItemKey.clear()
|
||||
}
|
||||
/**
|
||||
* Sticky hides + confirmed authority are query-scoped. Changing query/repo set
|
||||
* clears them so a new filter does not inherit membership exits / lastConfirmed
|
||||
* from a previous search.
|
||||
*/
|
||||
export function setTaskPageGitHubMutationQueryKey(queryKey: string): void {
|
||||
if (mutationQueryKey === queryKey) {
|
||||
return
|
||||
}
|
||||
mutationQueryKey = queryKey
|
||||
stickyHideByItemKey.clear()
|
||||
softHiddenItemKeys.clear()
|
||||
// Why: lastConfirmed is for lag hold within one query; a new search should
|
||||
// not permanently override rows with the previous filter's mutations.
|
||||
clearTaskPageGitHubConfirmedAuthority()
|
||||
// Why: bound growth — quiet lag/dirty state and generation counters accumulate
|
||||
// per queryKey/opKey over a session. A new query starts fresh, so drop the old
|
||||
// quiet states and any generation counters with no in-flight pending op (keys
|
||||
// with a live op must keep their counter so staleness detection stays valid).
|
||||
clearTaskPageGitHubQuietStates()
|
||||
for (const serialized of generations.keys()) {
|
||||
if (!pendingByKey.has(serialized)) {
|
||||
generations.delete(serialized)
|
||||
}
|
||||
}
|
||||
notifyTaskPageGitHubMutationRegistry()
|
||||
}
|
||||
export function isTaskPageGitHubMutationQueryKeyCurrent(queryKey: string): boolean {
|
||||
return mutationQueryKey === queryKey
|
||||
}
|
||||
export function getTaskPageGitHubMutationQueryKey(): string | null {
|
||||
return mutationQueryKey
|
||||
}
|
||||
export function getPendingTaskPageGitHubOp(key: TaskPageGitHubMutationKey): PendingOp | undefined {
|
||||
return pendingByKey.get(serializeTaskPageGitHubMutationKey(key))
|
||||
}
|
||||
export function nextTaskPageGitHubMutationGeneration(key: TaskPageGitHubMutationKey): number {
|
||||
const serialized = serializeTaskPageGitHubMutationKey(key)
|
||||
const next = (generations.get(serialized) ?? 0) + 1
|
||||
generations.set(serialized, next)
|
||||
return next
|
||||
}
|
||||
export function setPendingTaskPageGitHubOp(op: PendingOp): void {
|
||||
const serialized = serializeTaskPageGitHubMutationKey(op.key)
|
||||
// Why: whole-field supersede abandons older pending without rollback; list
|
||||
// ops share the same map entry only when opKey matches (per-login / batch).
|
||||
pendingByKey.set(serialized, op)
|
||||
rememberItemSourceScope(op.key.repoId, op.key.itemId, op.key.sourceScope)
|
||||
}
|
||||
|
||||
export function rememberItemSourceScope(
|
||||
repoId: string,
|
||||
itemId: string,
|
||||
sourceScope: string | null
|
||||
): void {
|
||||
itemSourceScopeByItemKey.set(taskPageGitHubItemKey(repoId, itemId), sourceScope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the sourceScope used for lastConfirmed/snapshot lookups for an item.
|
||||
* Prefer live pending, then the remembered scope from the last begin/confirm.
|
||||
*/
|
||||
export function resolveItemSourceScope(repoId: string, itemId: string): string | null {
|
||||
const fromPending = getSourceScopeFromPendingOps(repoId, itemId)
|
||||
if (fromPending !== undefined) {
|
||||
return fromPending
|
||||
}
|
||||
const remembered = itemSourceScopeByItemKey.get(taskPageGitHubItemKey(repoId, itemId))
|
||||
return remembered !== undefined ? remembered : null
|
||||
}
|
||||
|
||||
export function hasConfirmedAuthorityForItem(repoId: string, itemId: string): boolean {
|
||||
const sourceScope = resolveItemSourceScope(repoId, itemId)
|
||||
return (
|
||||
getConfirmedListSnapshot(sourceScope, repoId, itemId, 'assignees') !== undefined ||
|
||||
getConfirmedListSnapshot(sourceScope, repoId, itemId, 'reviewRequests') !== undefined ||
|
||||
getLastConfirmedClientValue(sourceScope, repoId, itemId, 'state') !== undefined ||
|
||||
getLastConfirmedClientValue(sourceScope, repoId, itemId, 'autoMerge') !== undefined
|
||||
)
|
||||
}
|
||||
export function deletePendingTaskPageGitHubOp(
|
||||
key: TaskPageGitHubMutationKey
|
||||
): PendingOp | undefined {
|
||||
const serialized = serializeTaskPageGitHubMutationKey(key)
|
||||
const existing = pendingByKey.get(serialized)
|
||||
if (existing) {
|
||||
pendingByKey.delete(serialized)
|
||||
}
|
||||
return existing
|
||||
}
|
||||
export function listPendingTaskPageGitHubOpsForItem(
|
||||
repoId: string,
|
||||
itemId: string,
|
||||
sourceScope?: string | null
|
||||
): PendingOp[] {
|
||||
const ops: PendingOp[] = []
|
||||
for (const op of pendingByKey.values()) {
|
||||
if (op.key.repoId !== repoId || op.key.itemId !== itemId) {
|
||||
continue
|
||||
}
|
||||
if (sourceScope !== undefined && op.key.sourceScope !== sourceScope) {
|
||||
continue
|
||||
}
|
||||
ops.push(op)
|
||||
}
|
||||
return ops.sort((a, b) => a.startedAt - b.startedAt)
|
||||
}
|
||||
export function hasPendingTaskPageGitHubOpsForItem(repoId: string, itemId: string): boolean {
|
||||
for (const op of pendingByKey.values()) {
|
||||
if (op.key.repoId === repoId && op.key.itemId === itemId) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
export function getSourceScopeFromPendingOps(
|
||||
repoId: string,
|
||||
itemId: string
|
||||
): string | null | undefined {
|
||||
for (const op of pendingByKey.values()) {
|
||||
if (op.key.repoId === repoId && op.key.itemId === itemId) {
|
||||
return op.key.sourceScope
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
export function getConfirmedListSnapshot(
|
||||
sourceScope: string | null,
|
||||
repoId: string,
|
||||
itemId: string,
|
||||
family: TaskPageGitHubListFamily
|
||||
): GitHubAssignableUser[] | undefined {
|
||||
return confirmedSnapshots.get(taskPageGitHubSnapshotKey(sourceScope, repoId, itemId, family))
|
||||
}
|
||||
export function setConfirmedListSnapshot(
|
||||
sourceScope: string | null,
|
||||
repoId: string,
|
||||
itemId: string,
|
||||
family: TaskPageGitHubListFamily,
|
||||
users: readonly GitHubAssignableUser[]
|
||||
): void {
|
||||
rememberItemSourceScope(repoId, itemId, sourceScope)
|
||||
confirmedSnapshots.set(taskPageGitHubSnapshotKey(sourceScope, repoId, itemId, family), [...users])
|
||||
}
|
||||
export function deleteConfirmedListSnapshot(
|
||||
sourceScope: string | null,
|
||||
repoId: string,
|
||||
itemId: string,
|
||||
family: TaskPageGitHubListFamily
|
||||
): void {
|
||||
confirmedSnapshots.delete(taskPageGitHubSnapshotKey(sourceScope, repoId, itemId, family))
|
||||
}
|
||||
export function getLastConfirmedClientValue(
|
||||
sourceScope: string | null,
|
||||
repoId: string,
|
||||
itemId: string,
|
||||
family: string
|
||||
): unknown {
|
||||
return lastConfirmedClientValues.get(
|
||||
taskPageGitHubLastConfirmedKey(sourceScope, repoId, itemId, family)
|
||||
)
|
||||
}
|
||||
export function setLastConfirmedClientValue(
|
||||
sourceScope: string | null,
|
||||
repoId: string,
|
||||
itemId: string,
|
||||
family: string,
|
||||
value: unknown
|
||||
): void {
|
||||
rememberItemSourceScope(repoId, itemId, sourceScope)
|
||||
lastConfirmedClientValues.set(
|
||||
taskPageGitHubLastConfirmedKey(sourceScope, repoId, itemId, family),
|
||||
value
|
||||
)
|
||||
}
|
||||
export function deleteLastConfirmedClientValue(
|
||||
sourceScope: string | null,
|
||||
repoId: string,
|
||||
itemId: string,
|
||||
family: string
|
||||
): void {
|
||||
lastConfirmedClientValues.delete(
|
||||
taskPageGitHubLastConfirmedKey(sourceScope, repoId, itemId, family)
|
||||
)
|
||||
}
|
||||
export function clearConfirmedAuthorityForItem(repoId: string, itemId: string): void {
|
||||
const sourceScope = resolveItemSourceScope(repoId, itemId)
|
||||
deleteConfirmedListSnapshot(sourceScope, repoId, itemId, 'assignees')
|
||||
deleteConfirmedListSnapshot(sourceScope, repoId, itemId, 'reviewRequests')
|
||||
deleteLastConfirmedClientValue(sourceScope, repoId, itemId, 'state')
|
||||
deleteLastConfirmedClientValue(sourceScope, repoId, itemId, 'autoMerge')
|
||||
}
|
||||
export function getStickyHideEntry(itemKey: string): StickyHideEntry | undefined {
|
||||
return stickyHideByItemKey.get(itemKey)
|
||||
}
|
||||
export function setStickyHideEntry(entry: StickyHideEntry): void {
|
||||
stickyHideByItemKey.set(entry.itemKey, entry)
|
||||
}
|
||||
export function deleteStickyHideEntry(itemKey: string): void {
|
||||
stickyHideByItemKey.delete(itemKey)
|
||||
}
|
||||
export function getAllStickyHideEntries(): ReadonlyMap<string, StickyHideEntry> {
|
||||
return stickyHideByItemKey
|
||||
}
|
||||
export function setSoftHiddenItemKeys(keys: Iterable<string>): void {
|
||||
softHiddenItemKeys.clear()
|
||||
for (const key of keys) {
|
||||
softHiddenItemKeys.add(key)
|
||||
}
|
||||
}
|
||||
export function updateSoftHiddenItemKey(itemKey: string, hide: boolean): void {
|
||||
if (hide) {
|
||||
softHiddenItemKeys.add(itemKey)
|
||||
} else {
|
||||
softHiddenItemKeys.delete(itemKey)
|
||||
}
|
||||
}
|
||||
export function gcStickyHidesAbsentFromPages(
|
||||
pageItemKeys: ReadonlySet<string>,
|
||||
queryKey: string
|
||||
): void {
|
||||
for (const [itemKey, entry] of stickyHideByItemKey) {
|
||||
if (entry.queryKey !== queryKey) {
|
||||
continue
|
||||
}
|
||||
if (!pageItemKeys.has(itemKey)) {
|
||||
stickyHideByItemKey.delete(itemKey)
|
||||
softHiddenItemKeys.delete(itemKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Test-only: wipe module state between unit cases. */
|
||||
export function resetTaskPageGitHubMutationRegistryForTests(): void {
|
||||
pendingByKey.clear()
|
||||
generations.clear()
|
||||
confirmedSnapshots.clear()
|
||||
lastConfirmedClientValues.clear()
|
||||
itemSourceScopeByItemKey.clear()
|
||||
stickyHideByItemKey.clear()
|
||||
softHiddenItemKeys.clear()
|
||||
clearTaskPageGitHubQuietStates()
|
||||
mutationQueryKey = null
|
||||
listeners.clear()
|
||||
}
|
||||
|
|
@ -0,0 +1,373 @@
|
|||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { ParsedTaskQuery } from '../../../shared/task-query'
|
||||
import type { GitHubWorkItem } from '../../../shared/types'
|
||||
import {
|
||||
adoptQuietSearchFieldsForItem,
|
||||
advanceTaskPageQuietRevalidateScope,
|
||||
applyPendingTaskPageGitHubMutationsToItems,
|
||||
beginTaskPageGitHubWorkItemMutation,
|
||||
canStartTaskPageGitHubWorkItemMutation,
|
||||
clearTaskPageGitHubAuthorityAbsentFromLoadedItems,
|
||||
clearTaskPageGitHubAuthorityThroughGeneration,
|
||||
confirmTaskPageGitHubWorkItemMutation,
|
||||
getTaskPageQuietRevalidateBackoffAttempt,
|
||||
getTaskPageGitHubRevalidatableAuthorityItemKeys,
|
||||
isTaskPageQuietRevalidateRunCurrent,
|
||||
isTaskPageQuietRevalidateScopeCurrent,
|
||||
MAX_LAG_TRAILS,
|
||||
patchTaskPageGitHubWorkItemPages,
|
||||
reconcileTaskPagePagesAfterQuietRefresh,
|
||||
rebuildSoftHiddenFromItemsForTests
|
||||
} from './task-page-github-work-item-mutations'
|
||||
import {
|
||||
getLastConfirmedClientValue,
|
||||
getOrCreateQuietRevalidateState,
|
||||
getTaskPageGitHubSoftHiddenItemKeys,
|
||||
resetTaskPageGitHubMutationRegistryForTests,
|
||||
setTaskPageGitHubMutationQueryKey,
|
||||
subscribeTaskPageGitHubMutationRegistry,
|
||||
taskPageGitHubFamilyDirtyKey,
|
||||
taskPageGitHubItemKey
|
||||
} from './task-page-github-work-item-mutation-registry'
|
||||
import {
|
||||
beginTaskPageQuietRevalidateRun,
|
||||
finishTaskPageQuietRevalidateRun
|
||||
} from './task-page-github-work-item-quiet-state'
|
||||
|
||||
function query(): ParsedTaskQuery {
|
||||
return {
|
||||
scope: 'all',
|
||||
state: 'open',
|
||||
draft: false,
|
||||
assignee: null,
|
||||
author: null,
|
||||
reviewRequested: null,
|
||||
reviewedBy: null,
|
||||
labels: [],
|
||||
freeText: ''
|
||||
}
|
||||
}
|
||||
|
||||
function item(overrides: Partial<GitHubWorkItem> = {}): GitHubWorkItem {
|
||||
return {
|
||||
id: 'issue:1',
|
||||
type: 'issue',
|
||||
number: 1,
|
||||
title: 't',
|
||||
state: 'open',
|
||||
url: 'https://github.com/o/r/issues/1',
|
||||
labels: [],
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
author: 'author',
|
||||
repoId: 'repo-1',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => resetTaskPageGitHubMutationRegistryForTests())
|
||||
|
||||
describe('TaskPage GitHub mutation regressions', () => {
|
||||
it('invalidates quiet responses after a query changes away and back', () => {
|
||||
const initial = { queryKey: 'q1', generation: 0 }
|
||||
const returned = advanceTaskPageQuietRevalidateScope(
|
||||
advanceTaskPageQuietRevalidateScope(initial, 'q2'),
|
||||
'q1'
|
||||
)
|
||||
expect(isTaskPageQuietRevalidateScopeCurrent(returned, 'q1', initial.generation)).toBe(false)
|
||||
expect(isTaskPageQuietRevalidateScopeCurrent(returned, 'q1', returned.generation)).toBe(true)
|
||||
})
|
||||
|
||||
it('invalidates an older quiet response after a hard refresh dispatches', () => {
|
||||
expect(
|
||||
isTaskPageQuietRevalidateRunCurrent({ queryKey: 'q', generation: 0 }, 'q', 0, 0, 1)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('lets a remounted TaskPage take over an orphaned quiet run', () => {
|
||||
const quiet = getOrCreateQuietRevalidateState('q')
|
||||
const oldOwner = {}
|
||||
const newOwner = {}
|
||||
const oldRun = beginTaskPageQuietRevalidateRun(quiet, oldOwner)
|
||||
const newRun = beginTaskPageQuietRevalidateRun(quiet, newOwner)
|
||||
if (oldRun === null || newRun === null) {
|
||||
throw new Error('Expected both quiet owners to start a run.')
|
||||
}
|
||||
expect(finishTaskPageQuietRevalidateRun(quiet, oldOwner, oldRun)).toBe(false)
|
||||
expect(quiet.inFlight).toBe(true)
|
||||
expect(finishTaskPageQuietRevalidateRun(quiet, newOwner, newRun)).toBe(true)
|
||||
expect(quiet.inFlight).toBe(false)
|
||||
})
|
||||
|
||||
it('does not let an exhausted lag key block a newer item retry', () => {
|
||||
expect(getTaskPageQuietRevalidateBackoffAttempt([MAX_LAG_TRAILS, 1])).toBe(1)
|
||||
})
|
||||
|
||||
it('resets exhausted family lag when a new confirmation arrives', () => {
|
||||
const base = item()
|
||||
const confirmState = (state: 'open' | 'closed'): void => {
|
||||
const began = beginTaskPageGitHubWorkItemMutation({
|
||||
item: base,
|
||||
intent: { type: 'setState', state },
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
patchWorkItem: () => {}
|
||||
})
|
||||
confirmTaskPageGitHubWorkItemMutation(began.key, began.generation, {
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
item: base
|
||||
})
|
||||
}
|
||||
confirmState('closed')
|
||||
const familyKey = taskPageGitHubFamilyDirtyKey(
|
||||
taskPageGitHubItemKey(base.repoId, base.id),
|
||||
'state'
|
||||
)
|
||||
getOrCreateQuietRevalidateState('q').lagSkipAttempts.set(familyKey, MAX_LAG_TRAILS)
|
||||
expect(getTaskPageGitHubRevalidatableAuthorityItemKeys('q')).not.toContain(
|
||||
taskPageGitHubItemKey(base.repoId, base.id)
|
||||
)
|
||||
confirmState('open')
|
||||
expect(getTaskPageGitHubRevalidatableAuthorityItemKeys('q')).toContain(
|
||||
taskPageGitHubItemKey(base.repoId, base.id)
|
||||
)
|
||||
})
|
||||
|
||||
it('blocks a second same-key write while the first is unresolved', () => {
|
||||
const base = item()
|
||||
const input = { item: base, intent: { type: 'setState', state: 'closed' } as const }
|
||||
expect(canStartTaskPageGitHubWorkItemMutation(input)).toBe(true)
|
||||
beginTaskPageGitHubWorkItemMutation({
|
||||
...input,
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
patchWorkItem: () => {}
|
||||
})
|
||||
expect(canStartTaskPageGitHubWorkItemMutation(input)).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks overlapping list and whole-field writes while allowing disjoint list writes', () => {
|
||||
const base = item({ reviewRequests: [] })
|
||||
beginTaskPageGitHubWorkItemMutation({
|
||||
item: base,
|
||||
intent: {
|
||||
type: 'addReviewers',
|
||||
logins: ['alice', 'bob'],
|
||||
candidates: []
|
||||
},
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
patchWorkItem: () => {}
|
||||
})
|
||||
expect(
|
||||
canStartTaskPageGitHubWorkItemMutation({
|
||||
item: base,
|
||||
intent: { type: 'removeReviewers', logins: ['alice'] }
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
canStartTaskPageGitHubWorkItemMutation({
|
||||
item: base,
|
||||
intent: { type: 'removeReviewers', logins: ['carol'] }
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
const autoMerge = beginTaskPageGitHubWorkItemMutation({
|
||||
item: base,
|
||||
intent: { type: 'setAutoMerge', enabled: true },
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
patchWorkItem: () => {}
|
||||
})
|
||||
expect(autoMerge.opKey).toBe('autoMerge')
|
||||
expect(canStartTaskPageGitHubWorkItemMutation({ item: base, intent: { type: 'merge' } })).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('does not notify subscribers when a soft-hide rebuild is unchanged', () => {
|
||||
const base = item()
|
||||
beginTaskPageGitHubWorkItemMutation({
|
||||
item: base,
|
||||
intent: { type: 'setState', state: 'closed' },
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
patchWorkItem: () => {}
|
||||
})
|
||||
let notifications = 0
|
||||
const unsubscribe = subscribeTaskPageGitHubMutationRegistry(() => {
|
||||
notifications += 1
|
||||
})
|
||||
rebuildSoftHiddenFromItemsForTests({
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
items: [base]
|
||||
})
|
||||
unsubscribe()
|
||||
expect(notifications).toBe(0)
|
||||
})
|
||||
|
||||
it('preserves the visible page when an earlier authority page changes membership', () => {
|
||||
const pages = [[item({ id: 'issue:1' })], [item({ id: 'issue:2' })], [item({ id: 'issue:3' })]]
|
||||
const next = reconcileTaskPagePagesAfterQuietRefresh({
|
||||
pages,
|
||||
queryKey: 'q',
|
||||
authorityPage: 0,
|
||||
authorityItems: [],
|
||||
membershipChanged: true,
|
||||
visiblePage: 2,
|
||||
visibleItems: [item({ id: 'issue:4' })]
|
||||
})
|
||||
expect(next).toHaveLength(3)
|
||||
expect(next[1]).toBeNull()
|
||||
expect(next[2]?.[0].id).toBe('issue:4')
|
||||
})
|
||||
|
||||
it('does not apply an old query soft-hide after mutation completion', () => {
|
||||
const patchWorkItem = (): void => {}
|
||||
const base = item({ state: 'open' })
|
||||
const began = beginTaskPageGitHubWorkItemMutation({
|
||||
item: base,
|
||||
intent: { type: 'setState', state: 'closed' },
|
||||
query: query(),
|
||||
queryKey: 'open-query',
|
||||
viewerLogin: 'me',
|
||||
patchWorkItem
|
||||
})
|
||||
setTaskPageGitHubMutationQueryKey('current-open-query')
|
||||
confirmTaskPageGitHubWorkItemMutation(began.key, began.generation, {
|
||||
query: query(),
|
||||
queryKey: 'open-query',
|
||||
viewerLogin: 'me',
|
||||
item: base,
|
||||
patchWorkItem
|
||||
})
|
||||
expect(getTaskPageGitHubSoftHiddenItemKeys()).not.toContain(
|
||||
taskPageGitHubItemKey(base.repoId, base.id)
|
||||
)
|
||||
expect(getOrCreateQuietRevalidateState('current-open-query').dirtyGeneration).toBe(1)
|
||||
rebuildSoftHiddenFromItemsForTests({
|
||||
query: query(),
|
||||
queryKey: 'current-open-query',
|
||||
viewerLogin: 'me',
|
||||
items: [base]
|
||||
})
|
||||
expect(getTaskPageGitHubSoftHiddenItemKeys()).toContain(
|
||||
taskPageGitHubItemKey(base.repoId, base.id)
|
||||
)
|
||||
})
|
||||
|
||||
it('hard refresh clears only authority that predates its request', () => {
|
||||
const patchWorkItem = (): void => {}
|
||||
const base = item({ state: 'open', autoMergeEnabled: false })
|
||||
const stateMutation = beginTaskPageGitHubWorkItemMutation({
|
||||
item: base,
|
||||
intent: { type: 'setState', state: 'closed' },
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
patchWorkItem
|
||||
})
|
||||
confirmTaskPageGitHubWorkItemMutation(stateMutation.key, stateMutation.generation, {
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
item: base,
|
||||
patchWorkItem
|
||||
})
|
||||
const fetchGeneration = getOrCreateQuietRevalidateState('q').dirtyGeneration
|
||||
const autoMergeMutation = beginTaskPageGitHubWorkItemMutation({
|
||||
item: { ...base, state: 'closed' },
|
||||
intent: { type: 'setAutoMerge', enabled: true },
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
patchWorkItem
|
||||
})
|
||||
confirmTaskPageGitHubWorkItemMutation(autoMergeMutation.key, autoMergeMutation.generation, {
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
item: { ...base, state: 'closed' },
|
||||
patchWorkItem
|
||||
})
|
||||
|
||||
clearTaskPageGitHubAuthorityThroughGeneration('q', fetchGeneration)
|
||||
|
||||
expect(getLastConfirmedClientValue(null, base.repoId, base.id, 'state')).toBeUndefined()
|
||||
expect(getLastConfirmedClientValue(null, base.repoId, base.id, 'autoMerge')).toBe(true)
|
||||
expect(getTaskPageGitHubSoftHiddenItemKeys()).not.toContain(
|
||||
taskPageGitHubItemKey(base.repoId, base.id)
|
||||
)
|
||||
})
|
||||
|
||||
it('releases confirmed authority for rows no longer present on loaded pages', () => {
|
||||
const base = item({ autoMergeEnabled: false })
|
||||
const began = beginTaskPageGitHubWorkItemMutation({
|
||||
item: base,
|
||||
intent: { type: 'setAutoMerge', enabled: true },
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
patchWorkItem: () => {}
|
||||
})
|
||||
confirmTaskPageGitHubWorkItemMutation(began.key, began.generation, {
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
item: base
|
||||
})
|
||||
clearTaskPageGitHubAuthorityAbsentFromLoadedItems(new Set())
|
||||
expect(getTaskPageGitHubRevalidatableAuthorityItemKeys('q')).not.toContain(
|
||||
taskPageGitHubItemKey(base.repoId, base.id)
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves unavailable list metadata on untouched rows', () => {
|
||||
const [overlaid] = applyPendingTaskPageGitHubMutationsToItems([
|
||||
item({ assignees: undefined, reviewRequests: undefined })
|
||||
])
|
||||
expect(overlaid.assignees).toBeUndefined()
|
||||
expect(overlaid.reviewRequests).toBeUndefined()
|
||||
})
|
||||
|
||||
it('patches a later provider page without rebuilding untouched pages', () => {
|
||||
const firstPage = [item({ id: 'issue:1' })]
|
||||
const pages = patchTaskPageGitHubWorkItemPages(
|
||||
[firstPage, [item({ id: 'issue:2' })]],
|
||||
{ id: 'issue:2', repoId: 'repo-1' },
|
||||
{ state: 'closed' }
|
||||
)
|
||||
expect(pages[0]).toBe(firstPage)
|
||||
expect(pages[1]?.[0].state).toBe('closed')
|
||||
})
|
||||
|
||||
it('does not claim authority over untouched search fields', () => {
|
||||
const patches: Partial<GitHubWorkItem>[] = []
|
||||
const patchWorkItem = (_id: string, patch: Partial<GitHubWorkItem>): void => {
|
||||
patches.push(patch)
|
||||
}
|
||||
const open = item({ state: 'open', assignees: undefined, reviewRequests: undefined })
|
||||
const closed = item({ state: 'closed', assignees: undefined, reviewRequests: undefined })
|
||||
for (const serverItem of [open, closed]) {
|
||||
adoptQuietSearchFieldsForItem({
|
||||
item: serverItem,
|
||||
serverItem,
|
||||
sourceScope: null,
|
||||
queryKey: 'q',
|
||||
fetchStartedAtGeneration: 0,
|
||||
patchWorkItem
|
||||
})
|
||||
}
|
||||
expect(getLastConfirmedClientValue(null, open.repoId, open.id, 'state')).toBeUndefined()
|
||||
expect(patches.findLast((patch) => patch.state !== undefined)?.state).toBe('closed')
|
||||
expect(patches.at(-1)?.assignees).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import type { ParsedTaskQuery } from '../../../shared/task-query'
|
||||
import type { GitHubWorkItem } from '../../../shared/types'
|
||||
import type { TaskSourceContext } from '../../../shared/task-source-context'
|
||||
import type { TaskPageGitHubMutationIntent } from './task-page-github-work-item-mutation-patches'
|
||||
import type { TaskPageGitHubMutationKey } from './task-page-github-work-item-mutation-registry'
|
||||
|
||||
export type TaskPageGitHubPatchWorkItem = (
|
||||
itemId: string,
|
||||
patch: Partial<GitHubWorkItem>,
|
||||
repoId?: string,
|
||||
options?: { sourceContext?: TaskSourceContext | null }
|
||||
) => void
|
||||
|
||||
export type BeginTaskPageGitHubWorkItemMutationArgs = {
|
||||
item: GitHubWorkItem
|
||||
intent: TaskPageGitHubMutationIntent
|
||||
sourceContext?: TaskSourceContext | null
|
||||
query: ParsedTaskQuery
|
||||
queryKey: string
|
||||
viewerLogin: string | null
|
||||
/** Derived inside begin from item sourceContext if omitted. */
|
||||
skipMeQualifiers?: boolean
|
||||
patchWorkItem: TaskPageGitHubPatchWorkItem
|
||||
}
|
||||
|
||||
export type BeginTaskPageGitHubWorkItemMutationResult = {
|
||||
generation: number
|
||||
opKey: string
|
||||
itemKey: string
|
||||
families: string[]
|
||||
key: TaskPageGitHubMutationKey
|
||||
}
|
||||
|
|
@ -0,0 +1,836 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ParsedTaskQuery } from '../../../shared/task-query'
|
||||
import type { GitHubWorkItem } from '../../../shared/types'
|
||||
import {
|
||||
adoptQuietSearchFieldsForItem,
|
||||
applyPendingTaskPageGitHubMutationsToItems,
|
||||
beginTaskPageGitHubWorkItemMutation,
|
||||
confirmTaskPageGitHubWorkItemMutation,
|
||||
getRegistryMergedTaskPageGitHubWorkItem,
|
||||
materializeTaskPageItemList,
|
||||
overlayPendingOnTaskPagePages,
|
||||
rollbackTaskPageGitHubWorkItemMutation,
|
||||
settleQuietSearchRevalidate,
|
||||
reapplyPendingTaskPageGitHubMutationsToCache,
|
||||
clearTaskPageGitHubConfirmedAuthority,
|
||||
getTaskPageGitHubStickyHideForTests
|
||||
} from './task-page-github-work-item-mutations'
|
||||
import {
|
||||
getConfirmedListSnapshot,
|
||||
getLastConfirmedClientValue,
|
||||
getOrCreateQuietRevalidateState,
|
||||
getTaskPageGitHubSoftHiddenItemKeys,
|
||||
resetTaskPageGitHubMutationRegistryForTests,
|
||||
taskPageGitHubItemKey
|
||||
} from './task-page-github-work-item-mutation-registry'
|
||||
|
||||
function query(overrides: Partial<ParsedTaskQuery> = {}): ParsedTaskQuery {
|
||||
return {
|
||||
scope: 'all',
|
||||
state: 'open',
|
||||
draft: false,
|
||||
assignee: null,
|
||||
author: null,
|
||||
reviewRequested: null,
|
||||
reviewedBy: null,
|
||||
labels: [],
|
||||
freeText: '',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function item(overrides: Partial<GitHubWorkItem> = {}): GitHubWorkItem {
|
||||
return {
|
||||
id: 'issue:1',
|
||||
type: 'issue',
|
||||
number: 1,
|
||||
title: 't',
|
||||
state: 'open',
|
||||
url: 'https://github.com/o/r/issues/1',
|
||||
labels: [],
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
author: 'author',
|
||||
repoId: 'repo-1',
|
||||
assignees: [],
|
||||
reviewRequests: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function createPatchRecorder() {
|
||||
const patches: { id: string; patch: Partial<GitHubWorkItem>; repoId?: string }[] = []
|
||||
const patchWorkItem = (id: string, patch: Partial<GitHubWorkItem>, repoId?: string): void => {
|
||||
patches.push({ id, patch, repoId })
|
||||
}
|
||||
return { patches, patchWorkItem }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetTaskPageGitHubMutationRegistryForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetTaskPageGitHubMutationRegistryForTests()
|
||||
})
|
||||
|
||||
describe('TaskPage GitHub work item mutations', () => {
|
||||
it('alice confirm while bob pending → composed [alice, bob]', () => {
|
||||
const { patchWorkItem } = createPatchRecorder()
|
||||
const base = item()
|
||||
const alice = beginTaskPageGitHubWorkItemMutation({
|
||||
item: base,
|
||||
intent: {
|
||||
type: 'toggleAssignee',
|
||||
user: { login: 'alice', name: null, avatarUrl: '' }
|
||||
},
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false,
|
||||
patchWorkItem
|
||||
})
|
||||
const bob = beginTaskPageGitHubWorkItemMutation({
|
||||
item: base,
|
||||
intent: {
|
||||
type: 'toggleAssignee',
|
||||
user: { login: 'bob', name: null, avatarUrl: '' }
|
||||
},
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false,
|
||||
patchWorkItem
|
||||
})
|
||||
|
||||
confirmTaskPageGitHubWorkItemMutation(alice.key, alice.generation, {
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
item: base,
|
||||
patchWorkItem,
|
||||
scheduleQuiet: false
|
||||
})
|
||||
|
||||
const merged = getRegistryMergedTaskPageGitHubWorkItem(base, null)
|
||||
expect(merged.assignees?.map((u) => u.login.toLowerCase()).sort()).toEqual(['alice', 'bob'])
|
||||
expect(
|
||||
getConfirmedListSnapshot(null, 'repo-1', 'issue:1', 'assignees')?.map((u) =>
|
||||
u.login.toLowerCase()
|
||||
)
|
||||
).toEqual(['alice'])
|
||||
|
||||
// bob still pending
|
||||
void bob
|
||||
})
|
||||
|
||||
it('multi-login batch confirm applies all logins; rollback of batch removes all', () => {
|
||||
const { patchWorkItem } = createPatchRecorder()
|
||||
const base = item({ type: 'pr', id: 'pr:1' })
|
||||
const began = beginTaskPageGitHubWorkItemMutation({
|
||||
item: base,
|
||||
intent: {
|
||||
type: 'addReviewers',
|
||||
logins: ['bob', 'carol'],
|
||||
candidates: [
|
||||
{ login: 'bob', name: null, avatarUrl: '' },
|
||||
{ login: 'carol', name: null, avatarUrl: '' }
|
||||
]
|
||||
},
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false,
|
||||
patchWorkItem
|
||||
})
|
||||
expect(
|
||||
getRegistryMergedTaskPageGitHubWorkItem(base, null).reviewRequests?.map((u) =>
|
||||
u.login.toLowerCase()
|
||||
)
|
||||
).toEqual(['bob', 'carol'])
|
||||
|
||||
confirmTaskPageGitHubWorkItemMutation(began.key, began.generation, {
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
item: base,
|
||||
patchWorkItem,
|
||||
scheduleQuiet: false
|
||||
})
|
||||
expect(
|
||||
getConfirmedListSnapshot(null, 'repo-1', 'pr:1', 'reviewRequests')?.map((u) =>
|
||||
u.login.toLowerCase()
|
||||
)
|
||||
).toEqual(['bob', 'carol'])
|
||||
|
||||
// Separate batch rollback path
|
||||
resetTaskPageGitHubMutationRegistryForTests()
|
||||
const began2 = beginTaskPageGitHubWorkItemMutation({
|
||||
item: base,
|
||||
intent: {
|
||||
type: 'addReviewers',
|
||||
logins: ['bob', 'carol'],
|
||||
candidates: [
|
||||
{ login: 'bob', name: null, avatarUrl: '' },
|
||||
{ login: 'carol', name: null, avatarUrl: '' }
|
||||
]
|
||||
},
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false,
|
||||
patchWorkItem
|
||||
})
|
||||
rollbackTaskPageGitHubWorkItemMutation({
|
||||
key: began2.key,
|
||||
generation: began2.generation,
|
||||
patchWorkItem,
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
item: base
|
||||
})
|
||||
expect(
|
||||
getRegistryMergedTaskPageGitHubWorkItem(base, null).reviewRequests?.map((u) => u.login) ?? []
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('matching server list releases authority; next begin keeps server metadata', () => {
|
||||
const { patches, patchWorkItem } = createPatchRecorder()
|
||||
// Seed confirmed snapshot [alice, carol] via sequential confirms.
|
||||
const empty = item({ assignees: [] })
|
||||
const addAlice = beginTaskPageGitHubWorkItemMutation({
|
||||
item: empty,
|
||||
intent: {
|
||||
type: 'toggleAssignee',
|
||||
user: { login: 'alice', name: null, avatarUrl: '' }
|
||||
},
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false,
|
||||
patchWorkItem
|
||||
})
|
||||
confirmTaskPageGitHubWorkItemMutation(addAlice.key, addAlice.generation, {
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
item: empty,
|
||||
patchWorkItem,
|
||||
scheduleQuiet: false
|
||||
})
|
||||
const withAlice = item({
|
||||
assignees: [{ login: 'alice', name: null, avatarUrl: '' }]
|
||||
})
|
||||
const addCarol = beginTaskPageGitHubWorkItemMutation({
|
||||
item: withAlice,
|
||||
intent: {
|
||||
type: 'toggleAssignee',
|
||||
user: { login: 'carol', name: null, avatarUrl: '' }
|
||||
},
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false,
|
||||
patchWorkItem
|
||||
})
|
||||
confirmTaskPageGitHubWorkItemMutation(addCarol.key, addCarol.generation, {
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
item: withAlice,
|
||||
patchWorkItem,
|
||||
scheduleQuiet: false
|
||||
})
|
||||
|
||||
const server = item({
|
||||
assignees: [
|
||||
{ login: 'alice', name: 'Alice', avatarUrl: 'a2' },
|
||||
{ login: 'carol', name: 'Carol', avatarUrl: 'c2' }
|
||||
]
|
||||
})
|
||||
const quiet = getOrCreateQuietRevalidateState('q')
|
||||
quiet.fetchStartedAtGeneration = quiet.dirtyGeneration
|
||||
adoptQuietSearchFieldsForItem({
|
||||
item: server,
|
||||
serverItem: server,
|
||||
sourceScope: null,
|
||||
queryKey: 'q',
|
||||
fetchStartedAtGeneration: quiet.fetchStartedAtGeneration,
|
||||
patchWorkItem
|
||||
})
|
||||
|
||||
const snap = getConfirmedListSnapshot(null, 'repo-1', 'issue:1', 'assignees')
|
||||
expect(snap).toBeUndefined()
|
||||
expect(
|
||||
patches
|
||||
.findLast((entry) => entry.patch.assignees !== undefined)
|
||||
?.patch.assignees?.find((u) => u.login === 'carol')?.avatarUrl
|
||||
).toBe('c2')
|
||||
|
||||
beginTaskPageGitHubWorkItemMutation({
|
||||
item: server,
|
||||
intent: {
|
||||
type: 'toggleAssignee',
|
||||
user: { login: 'bob', name: null, avatarUrl: '' }
|
||||
},
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false,
|
||||
patchWorkItem
|
||||
})
|
||||
const merged = getRegistryMergedTaskPageGitHubWorkItem(server, null)
|
||||
expect(merged.assignees?.map((u) => u.login.toLowerCase()).sort()).toEqual([
|
||||
'alice',
|
||||
'bob',
|
||||
'carol'
|
||||
])
|
||||
})
|
||||
|
||||
it('K21: thin search after confirmed add does not force-accept', () => {
|
||||
const { patchWorkItem } = createPatchRecorder()
|
||||
const base = item({
|
||||
assignees: [{ login: 'alice', name: null, avatarUrl: '' }]
|
||||
})
|
||||
// confirm alice already in snapshot path via begin+confirm bob on top of alice snapshot
|
||||
const addBob = beginTaskPageGitHubWorkItemMutation({
|
||||
item: base,
|
||||
intent: {
|
||||
type: 'toggleAssignee',
|
||||
user: { login: 'bob', name: null, avatarUrl: '' }
|
||||
},
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false,
|
||||
patchWorkItem
|
||||
})
|
||||
confirmTaskPageGitHubWorkItemMutation(addBob.key, addBob.generation, {
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
item: base,
|
||||
patchWorkItem,
|
||||
scheduleQuiet: false
|
||||
})
|
||||
|
||||
const quiet = getOrCreateQuietRevalidateState('q')
|
||||
quiet.fetchStartedAtGeneration = quiet.dirtyGeneration
|
||||
const thin = item({
|
||||
assignees: [{ login: 'alice', name: null, avatarUrl: '' }]
|
||||
})
|
||||
const result = adoptQuietSearchFieldsForItem({
|
||||
item: thin,
|
||||
serverItem: thin,
|
||||
sourceScope: null,
|
||||
queryKey: 'q',
|
||||
fetchStartedAtGeneration: quiet.fetchStartedAtGeneration,
|
||||
patchWorkItem
|
||||
})
|
||||
expect(result.needTrailing).toBe(true)
|
||||
expect(
|
||||
getConfirmedListSnapshot(null, 'repo-1', 'issue:1', 'assignees')?.map((u) =>
|
||||
u.login.toLowerCase()
|
||||
)
|
||||
).toEqual(['alice', 'bob'])
|
||||
})
|
||||
|
||||
it('K21: fat search after confirmed remove does not force-accept', () => {
|
||||
const { patchWorkItem } = createPatchRecorder()
|
||||
const base = item({
|
||||
assignees: [
|
||||
{ login: 'alice', name: null, avatarUrl: '' },
|
||||
{ login: 'bob', name: null, avatarUrl: '' }
|
||||
]
|
||||
})
|
||||
const removeBob = beginTaskPageGitHubWorkItemMutation({
|
||||
item: base,
|
||||
intent: {
|
||||
type: 'toggleAssignee',
|
||||
user: { login: 'bob', name: null, avatarUrl: '' }
|
||||
},
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false,
|
||||
patchWorkItem
|
||||
})
|
||||
confirmTaskPageGitHubWorkItemMutation(removeBob.key, removeBob.generation, {
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
item: base,
|
||||
patchWorkItem,
|
||||
scheduleQuiet: false
|
||||
})
|
||||
expect(
|
||||
getConfirmedListSnapshot(null, 'repo-1', 'issue:1', 'assignees')?.map((u) =>
|
||||
u.login.toLowerCase()
|
||||
)
|
||||
).toEqual(['alice'])
|
||||
|
||||
const quiet = getOrCreateQuietRevalidateState('q')
|
||||
quiet.fetchStartedAtGeneration = quiet.dirtyGeneration
|
||||
const fat = item({
|
||||
assignees: [
|
||||
{ login: 'alice', name: null, avatarUrl: '' },
|
||||
{ login: 'bob', name: null, avatarUrl: '' }
|
||||
]
|
||||
})
|
||||
adoptQuietSearchFieldsForItem({
|
||||
item: fat,
|
||||
serverItem: fat,
|
||||
sourceScope: null,
|
||||
queryKey: 'q',
|
||||
fetchStartedAtGeneration: quiet.fetchStartedAtGeneration,
|
||||
patchWorkItem
|
||||
})
|
||||
expect(
|
||||
getConfirmedListSnapshot(null, 'repo-1', 'issue:1', 'assignees')?.map((u) =>
|
||||
u.login.toLowerCase()
|
||||
)
|
||||
).toEqual(['alice'])
|
||||
})
|
||||
|
||||
it('K21: lagging open after close confirm keeps closed + sticky', () => {
|
||||
const { patchWorkItem } = createPatchRecorder()
|
||||
const base = item({ state: 'open' })
|
||||
const began = beginTaskPageGitHubWorkItemMutation({
|
||||
item: base,
|
||||
intent: { type: 'setState', state: 'closed' },
|
||||
query: query({ state: 'open' }),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false,
|
||||
patchWorkItem
|
||||
})
|
||||
confirmTaskPageGitHubWorkItemMutation(began.key, began.generation, {
|
||||
query: query({ state: 'open' }),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
item: base,
|
||||
patchWorkItem,
|
||||
scheduleQuiet: false
|
||||
})
|
||||
expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state')).toBe('closed')
|
||||
const itemKey = taskPageGitHubItemKey('repo-1', 'issue:1')
|
||||
expect(getTaskPageGitHubStickyHideForTests(itemKey)).toBeTruthy()
|
||||
expect(getTaskPageGitHubSoftHiddenItemKeys().has(itemKey)).toBe(true)
|
||||
|
||||
const quiet = getOrCreateQuietRevalidateState('q')
|
||||
quiet.fetchStartedAtGeneration = quiet.dirtyGeneration
|
||||
// Simulate budget exceeded — still no force-accept
|
||||
quiet.lastConfirmAt = Date.now() - 200_000
|
||||
for (let i = 0; i < 6; i++) {
|
||||
adoptQuietSearchFieldsForItem({
|
||||
item: item({ state: 'open' }),
|
||||
serverItem: item({ state: 'open' }),
|
||||
sourceScope: null,
|
||||
queryKey: 'q',
|
||||
fetchStartedAtGeneration: quiet.fetchStartedAtGeneration,
|
||||
patchWorkItem
|
||||
})
|
||||
}
|
||||
expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state')).toBe('closed')
|
||||
expect(getTaskPageGitHubStickyHideForTests(itemKey)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('K22: confirm close then open under Open clears sticky', () => {
|
||||
const { patchWorkItem } = createPatchRecorder()
|
||||
const base = item({ state: 'open' })
|
||||
const close = beginTaskPageGitHubWorkItemMutation({
|
||||
item: base,
|
||||
intent: { type: 'setState', state: 'closed' },
|
||||
query: query({ state: 'open' }),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false,
|
||||
patchWorkItem
|
||||
})
|
||||
confirmTaskPageGitHubWorkItemMutation(close.key, close.generation, {
|
||||
query: query({ state: 'open' }),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
item: base,
|
||||
patchWorkItem,
|
||||
scheduleQuiet: false
|
||||
})
|
||||
const itemKey = taskPageGitHubItemKey('repo-1', 'issue:1')
|
||||
expect(getTaskPageGitHubStickyHideForTests(itemKey)).toBeTruthy()
|
||||
|
||||
const reopen = beginTaskPageGitHubWorkItemMutation({
|
||||
item: { ...base, state: 'closed' },
|
||||
intent: { type: 'setState', state: 'open' },
|
||||
query: query({ state: 'open' }),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false,
|
||||
patchWorkItem
|
||||
})
|
||||
confirmTaskPageGitHubWorkItemMutation(reopen.key, reopen.generation, {
|
||||
query: query({ state: 'open' }),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
item: { ...base, state: 'closed' },
|
||||
patchWorkItem,
|
||||
scheduleQuiet: false
|
||||
})
|
||||
expect(getTaskPageGitHubStickyHideForTests(itemKey)).toBeUndefined()
|
||||
expect(getTaskPageGitHubSoftHiddenItemKeys().has(itemKey)).toBe(false)
|
||||
})
|
||||
|
||||
it('whole-field supersede: stale rollback/confirm no-ops', () => {
|
||||
const { patchWorkItem } = createPatchRecorder()
|
||||
const base = item()
|
||||
const first = beginTaskPageGitHubWorkItemMutation({
|
||||
item: base,
|
||||
intent: { type: 'setState', state: 'closed' },
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false,
|
||||
patchWorkItem
|
||||
})
|
||||
const second = beginTaskPageGitHubWorkItemMutation({
|
||||
item: base,
|
||||
intent: { type: 'setState', state: 'open' },
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false,
|
||||
patchWorkItem
|
||||
})
|
||||
expect(
|
||||
confirmTaskPageGitHubWorkItemMutation(first.key, first.generation, {
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
item: base,
|
||||
scheduleQuiet: false
|
||||
})
|
||||
).toBe('stale')
|
||||
expect(
|
||||
rollbackTaskPageGitHubWorkItemMutation({
|
||||
key: first.key,
|
||||
generation: first.generation,
|
||||
patchWorkItem,
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
item: base
|
||||
})
|
||||
).toBe('stale')
|
||||
expect(
|
||||
confirmTaskPageGitHubWorkItemMutation(second.key, second.generation, {
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
item: base,
|
||||
scheduleQuiet: false
|
||||
})
|
||||
).toBe('confirmed')
|
||||
})
|
||||
|
||||
it('per-login assignee: A fail after B begin leaves B only', () => {
|
||||
const { patchWorkItem } = createPatchRecorder()
|
||||
const base = item()
|
||||
const a = beginTaskPageGitHubWorkItemMutation({
|
||||
item: base,
|
||||
intent: {
|
||||
type: 'toggleAssignee',
|
||||
user: { login: 'alice', name: null, avatarUrl: '' }
|
||||
},
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false,
|
||||
patchWorkItem
|
||||
})
|
||||
beginTaskPageGitHubWorkItemMutation({
|
||||
item: base,
|
||||
intent: {
|
||||
type: 'toggleAssignee',
|
||||
user: { login: 'bob', name: null, avatarUrl: '' }
|
||||
},
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false,
|
||||
patchWorkItem
|
||||
})
|
||||
rollbackTaskPageGitHubWorkItemMutation({
|
||||
key: a.key,
|
||||
generation: a.generation,
|
||||
patchWorkItem,
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
item: base
|
||||
})
|
||||
const merged = getRegistryMergedTaskPageGitHubWorkItem(base, null)
|
||||
expect(merged.assignees?.map((u) => u.login.toLowerCase())).toEqual(['bob'])
|
||||
})
|
||||
|
||||
it('materialize retains pending-omitted row; overlay preserves multi-page', () => {
|
||||
const { patchWorkItem } = createPatchRecorder()
|
||||
const base = item({ state: 'open' })
|
||||
beginTaskPageGitHubWorkItemMutation({
|
||||
item: base,
|
||||
intent: { type: 'setState', state: 'closed' },
|
||||
query: query({ state: 'open' }),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false,
|
||||
patchWorkItem
|
||||
})
|
||||
const list = materializeTaskPageItemList({
|
||||
networkItems: [],
|
||||
previousItems: [base],
|
||||
queryKey: 'q'
|
||||
})
|
||||
expect(list).toHaveLength(1)
|
||||
expect(list[0].state).toBe('closed')
|
||||
|
||||
const pages = overlayPendingOnTaskPagePages([
|
||||
[item({ id: 'issue:1', state: 'open' })],
|
||||
[item({ id: 'issue:2', repoId: 'repo-1', state: 'open' })]
|
||||
])
|
||||
expect(pages).toHaveLength(2)
|
||||
expect(pages[0][0].state).toBe('closed')
|
||||
expect(pages[1]).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('multi-repo: pending on repo A and B both survive applyPending', () => {
|
||||
const { patchWorkItem } = createPatchRecorder()
|
||||
const a = item({ id: 'issue:1', repoId: 'repo-a', state: 'open' })
|
||||
const b = item({ id: 'issue:1', repoId: 'repo-b', state: 'open' })
|
||||
beginTaskPageGitHubWorkItemMutation({
|
||||
item: a,
|
||||
intent: { type: 'setState', state: 'closed' },
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false,
|
||||
patchWorkItem
|
||||
})
|
||||
beginTaskPageGitHubWorkItemMutation({
|
||||
item: b,
|
||||
intent: { type: 'setState', state: 'closed' },
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false,
|
||||
patchWorkItem
|
||||
})
|
||||
const applied = applyPendingTaskPageGitHubMutationsToItems([
|
||||
{ ...a, state: 'open' },
|
||||
{ ...b, state: 'open' }
|
||||
])
|
||||
expect(applied.map((i) => i.state)).toEqual(['closed', 'closed'])
|
||||
expect(applied.map((i) => i.repoId)).toEqual(['repo-a', 'repo-b'])
|
||||
})
|
||||
|
||||
it('dirty-bit: confirm during quiet R1 does not adopt thinner list', () => {
|
||||
const { patchWorkItem } = createPatchRecorder()
|
||||
const base = item({
|
||||
assignees: [{ login: 'alice', name: null, avatarUrl: '' }]
|
||||
})
|
||||
const bob = beginTaskPageGitHubWorkItemMutation({
|
||||
item: base,
|
||||
intent: {
|
||||
type: 'toggleAssignee',
|
||||
user: { login: 'bob', name: null, avatarUrl: '' }
|
||||
},
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false,
|
||||
patchWorkItem
|
||||
})
|
||||
confirmTaskPageGitHubWorkItemMutation(bob.key, bob.generation, {
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
item: base,
|
||||
patchWorkItem,
|
||||
scheduleQuiet: false
|
||||
})
|
||||
// Quiet R1 started at generation before carol confirm
|
||||
const quiet = getOrCreateQuietRevalidateState('q')
|
||||
const g0 = quiet.dirtyGeneration
|
||||
quiet.fetchStartedAtGeneration = g0
|
||||
|
||||
const carol = beginTaskPageGitHubWorkItemMutation({
|
||||
item: base,
|
||||
intent: {
|
||||
type: 'toggleAssignee',
|
||||
user: { login: 'carol', name: null, avatarUrl: '' }
|
||||
},
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false,
|
||||
patchWorkItem
|
||||
})
|
||||
confirmTaskPageGitHubWorkItemMutation(carol.key, carol.generation, {
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
item: base,
|
||||
patchWorkItem,
|
||||
scheduleQuiet: false
|
||||
})
|
||||
expect(quiet.dirtyGeneration).toBeGreaterThan(g0)
|
||||
|
||||
const thin = item({
|
||||
assignees: [{ login: 'alice', name: null, avatarUrl: '' }]
|
||||
})
|
||||
const settle = settleQuietSearchRevalidate({
|
||||
queryKey: 'q',
|
||||
networkItems: [thin],
|
||||
fetchStartedAtGeneration: g0,
|
||||
patchWorkItem
|
||||
})
|
||||
expect(settle.needTrailing).toBe(true)
|
||||
// Snapshot should still reflect confirmed bob (carol may be pending-cleared with snapshot)
|
||||
const snap = getConfirmedListSnapshot(null, 'repo-1', 'issue:1', 'assignees')
|
||||
expect(snap?.map((u) => u.login.toLowerCase()).sort()).toEqual(['alice', 'bob', 'carol'])
|
||||
})
|
||||
|
||||
it('stale confirm does not clobber lastConfirmed', () => {
|
||||
vi.useFakeTimers()
|
||||
const { patchWorkItem } = createPatchRecorder()
|
||||
const base = item()
|
||||
const first = beginTaskPageGitHubWorkItemMutation({
|
||||
item: base,
|
||||
intent: { type: 'setState', state: 'closed' },
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false,
|
||||
patchWorkItem
|
||||
})
|
||||
const second = beginTaskPageGitHubWorkItemMutation({
|
||||
item: base,
|
||||
intent: { type: 'setState', state: 'open' },
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false,
|
||||
patchWorkItem
|
||||
})
|
||||
confirmTaskPageGitHubWorkItemMutation(second.key, second.generation, {
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
item: base,
|
||||
scheduleQuiet: false
|
||||
})
|
||||
confirmTaskPageGitHubWorkItemMutation(first.key, first.generation, {
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
item: base,
|
||||
scheduleQuiet: false
|
||||
})
|
||||
expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state')).toBe('open')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe('post-confirm authority with non-null sourceScope', () => {
|
||||
const sourceContext = {
|
||||
kind: 'task-source' as const,
|
||||
provider: 'github' as const,
|
||||
hostId: 'local' as const,
|
||||
projectId: 'proj',
|
||||
projectHostSetupId: 'setup',
|
||||
repoId: 'repo-1'
|
||||
}
|
||||
|
||||
it('holds closed after confirm when overlay sees open network item', () => {
|
||||
const { patchWorkItem } = createPatchRecorder()
|
||||
const base = item({ state: 'open' })
|
||||
const began = beginTaskPageGitHubWorkItemMutation({
|
||||
item: base,
|
||||
intent: { type: 'setState', state: 'closed' },
|
||||
sourceContext,
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false,
|
||||
patchWorkItem
|
||||
})
|
||||
expect(began.key.sourceScope).not.toBeNull()
|
||||
confirmTaskPageGitHubWorkItemMutation(began.key, began.generation, {
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
item: { ...base, state: 'closed' },
|
||||
patchWorkItem,
|
||||
sourceContext
|
||||
})
|
||||
const overlaid = applyPendingTaskPageGitHubMutationsToItems([item({ state: 'open' })])
|
||||
expect(overlaid[0].state).toBe('closed')
|
||||
})
|
||||
|
||||
it('reapply after confirm holds closed when network returns open', () => {
|
||||
const store = new Map<string, GitHubWorkItem>()
|
||||
const base = item({ state: 'open' })
|
||||
store.set(base.id, base)
|
||||
const patchWorkItem = (id: string, patch: Partial<GitHubWorkItem>): void => {
|
||||
const current = store.get(id) ?? base
|
||||
store.set(id, { ...current, ...patch })
|
||||
}
|
||||
const began = beginTaskPageGitHubWorkItemMutation({
|
||||
item: base,
|
||||
intent: { type: 'setState', state: 'closed' },
|
||||
sourceContext,
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false,
|
||||
patchWorkItem
|
||||
})
|
||||
confirmTaskPageGitHubWorkItemMutation(began.key, began.generation, {
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
item: { ...base, state: 'closed' },
|
||||
patchWorkItem,
|
||||
sourceContext
|
||||
})
|
||||
store.set(base.id, item({ state: 'open' }))
|
||||
reapplyPendingTaskPageGitHubMutationsToCache({
|
||||
items: [item({ state: 'open' })],
|
||||
patchWorkItem
|
||||
})
|
||||
expect(store.get(base.id)?.state).toBe('closed')
|
||||
})
|
||||
|
||||
it('user-style clearConfirmedAuthority allows network open after hard refresh', () => {
|
||||
const { patchWorkItem } = createPatchRecorder()
|
||||
const base = item({ state: 'open' })
|
||||
const began = beginTaskPageGitHubWorkItemMutation({
|
||||
item: base,
|
||||
intent: { type: 'setState', state: 'closed' },
|
||||
sourceContext,
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
skipMeQualifiers: false,
|
||||
patchWorkItem
|
||||
})
|
||||
confirmTaskPageGitHubWorkItemMutation(began.key, began.generation, {
|
||||
query: query(),
|
||||
queryKey: 'q',
|
||||
viewerLogin: 'me',
|
||||
item: { ...base, state: 'closed' },
|
||||
patchWorkItem,
|
||||
sourceContext
|
||||
})
|
||||
clearTaskPageGitHubConfirmedAuthority()
|
||||
const overlaid = applyPendingTaskPageGitHubMutationsToItems([item({ state: 'open' })])
|
||||
expect(overlaid[0].state).toBe('open')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
import type { GitHubWorkItem } from '../../../shared/types'
|
||||
import {
|
||||
getTaskSourceCacheScope,
|
||||
type TaskSourceContext
|
||||
} from '../../../shared/task-source-context'
|
||||
import {
|
||||
buildTaskPageGitHubWorkItemMutationPatch,
|
||||
type TaskPageGitHubMutationIntent
|
||||
} from './task-page-github-work-item-mutation-patches'
|
||||
import {
|
||||
familiesFromPendingOp,
|
||||
getRegistryMergedTaskPageGitHubWorkItem,
|
||||
recomputeSoftHideForItem,
|
||||
rebuildSoftHiddenKeysFromPendingAndSticky,
|
||||
stripFamilyPendingFromList
|
||||
} from './task-page-github-work-item-mutation-composition'
|
||||
import {
|
||||
getConfirmedListSnapshot,
|
||||
getPendingTaskPageGitHubOp,
|
||||
getStickyHideEntry,
|
||||
listPendingTaskPageGitHubOpsForItem,
|
||||
nextTaskPageGitHubMutationGeneration,
|
||||
notifyTaskPageGitHubMutationRegistry,
|
||||
setConfirmedListSnapshot,
|
||||
setPendingTaskPageGitHubOp,
|
||||
setTaskPageGitHubMutationQueryKey,
|
||||
taskPageGitHubItemKey,
|
||||
type PendingOp,
|
||||
type TaskPageGitHubMutationKey
|
||||
} from './task-page-github-work-item-mutation-registry'
|
||||
import type {
|
||||
BeginTaskPageGitHubWorkItemMutationArgs,
|
||||
BeginTaskPageGitHubWorkItemMutationResult
|
||||
} from './task-page-github-work-item-mutation-types'
|
||||
import type { ParsedTaskQuery } from '../../../shared/task-query'
|
||||
|
||||
export type {
|
||||
BeginTaskPageGitHubWorkItemMutationArgs,
|
||||
BeginTaskPageGitHubWorkItemMutationResult,
|
||||
TaskPageGitHubPatchWorkItem
|
||||
} from './task-page-github-work-item-mutation-types'
|
||||
|
||||
export {
|
||||
applyPendingTaskPageGitHubMutationsToItems,
|
||||
materializeTaskPageItemList,
|
||||
overlayPendingOnTaskPagePages,
|
||||
patchTaskPageGitHubWorkItemPages,
|
||||
reapplyPendingTaskPageGitHubMutationsToCache,
|
||||
reconcileTaskPagePagesAfterQuietRefresh
|
||||
} from './task-page-github-work-item-mutation-pages'
|
||||
|
||||
export {
|
||||
adoptQuietSearchFieldsForItem,
|
||||
advanceTaskPageQuietRevalidateScope,
|
||||
getTaskPageQuietRevalidateBackoffAttempt,
|
||||
isTaskPageQuietRevalidateRunCurrent,
|
||||
isTaskPageQuietRevalidateScopeCurrent,
|
||||
processTaskPageQuietRevalidateSettle,
|
||||
settleQuietSearchRevalidate,
|
||||
LAG_BACKOFF_MS,
|
||||
LAG_WALL_BUDGET_MS,
|
||||
MAX_LAG_TRAILS
|
||||
} from './task-page-github-work-item-quiet-revalidate'
|
||||
|
||||
export type { TaskPageQuietRevalidateScope } from './task-page-github-work-item-quiet-revalidate'
|
||||
|
||||
export {
|
||||
clearTaskPageGitHubAuthorityAbsentFromLoadedItems,
|
||||
clearTaskPageGitHubAuthorityThroughGeneration,
|
||||
getTaskPageGitHubRevalidatableAuthorityItemKeys
|
||||
} from './task-page-github-work-item-authority-refresh'
|
||||
|
||||
export {
|
||||
clearTaskPageGitHubConfirmedAuthority,
|
||||
resolveItemSourceScope
|
||||
} from './task-page-github-work-item-mutation-registry'
|
||||
|
||||
export {
|
||||
confirmTaskPageGitHubWorkItemMutation,
|
||||
rollbackTaskPageGitHubWorkItemMutation
|
||||
} from './task-page-github-work-item-mutation-lifecycle'
|
||||
|
||||
export { getRegistryMergedTaskPageGitHubWorkItem } from './task-page-github-work-item-mutation-composition'
|
||||
export { rebuildSoftHiddenKeysFromPendingAndSticky } from './task-page-github-work-item-mutation-composition'
|
||||
|
||||
export { setTaskPageGitHubMutationQueryKey, taskPageGitHubItemKey }
|
||||
|
||||
function resolveSourceScope(sourceContext?: TaskSourceContext | null): string | null {
|
||||
if (sourceContext?.provider === 'github') {
|
||||
return getTaskSourceCacheScope(sourceContext)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveTaskPageGitHubMutation(args: {
|
||||
item: GitHubWorkItem
|
||||
intent: TaskPageGitHubMutationIntent
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}) {
|
||||
const sourceScope = resolveSourceScope(args.sourceContext)
|
||||
const base = getRegistryMergedTaskPageGitHubWorkItem(args.item, sourceScope)
|
||||
return {
|
||||
sourceScope,
|
||||
built: buildTaskPageGitHubWorkItemMutationPatch(base, args.intent)
|
||||
}
|
||||
}
|
||||
|
||||
export function canStartTaskPageGitHubWorkItemMutation(args: {
|
||||
item: GitHubWorkItem
|
||||
intent: TaskPageGitHubMutationIntent
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}): boolean {
|
||||
const { sourceScope, built } = resolveTaskPageGitHubMutation(args)
|
||||
const key = {
|
||||
sourceScope,
|
||||
repoId: args.item.repoId,
|
||||
itemId: args.item.id,
|
||||
opKey: built.opKey
|
||||
}
|
||||
if (isTaskPageGitHubMutationPendingKey(key)) {
|
||||
return false
|
||||
}
|
||||
const pending = listPendingTaskPageGitHubOpsForItem(args.item.repoId, args.item.id, sourceScope)
|
||||
if (built.kind === 'list') {
|
||||
const affectedLogins = new Set(built.listOp.logins)
|
||||
return !pending.some(
|
||||
(op) =>
|
||||
op.listOp?.family === built.family &&
|
||||
op.listOp.logins.some((login) => affectedLogins.has(login))
|
||||
)
|
||||
}
|
||||
return !pending.some(
|
||||
(op) =>
|
||||
!op.listOp && familiesFromPendingOp(op).some((family) => built.families.includes(family))
|
||||
)
|
||||
}
|
||||
|
||||
export function beginTaskPageGitHubWorkItemMutation(
|
||||
args: BeginTaskPageGitHubWorkItemMutationArgs
|
||||
): BeginTaskPageGitHubWorkItemMutationResult {
|
||||
setTaskPageGitHubMutationQueryKey(args.queryKey)
|
||||
const { sourceScope, built } = resolveTaskPageGitHubMutation(args)
|
||||
const skipMeQualifiers = args.skipMeQualifiers ?? false
|
||||
const key = {
|
||||
sourceScope,
|
||||
repoId: args.item.repoId,
|
||||
itemId: args.item.id,
|
||||
opKey: built.opKey
|
||||
}
|
||||
const generation = nextTaskPageGitHubMutationGeneration(key)
|
||||
|
||||
if (built.kind === 'list') {
|
||||
const existing = getConfirmedListSnapshot(
|
||||
sourceScope,
|
||||
args.item.repoId,
|
||||
args.item.id,
|
||||
built.family
|
||||
)
|
||||
if (!existing) {
|
||||
const ops = listPendingTaskPageGitHubOpsForItem(args.item.repoId, args.item.id, sourceScope)
|
||||
const snapshot = stripFamilyPendingFromList(args.item, built.family, ops)
|
||||
setConfirmedListSnapshot(sourceScope, args.item.repoId, args.item.id, built.family, snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
const op: PendingOp = {
|
||||
generation,
|
||||
key,
|
||||
previous: built.previous,
|
||||
next: built.next,
|
||||
listOp: built.kind === 'list' ? built.listOp : undefined,
|
||||
skipMeQualifiers,
|
||||
startedAt: Date.now()
|
||||
}
|
||||
setPendingTaskPageGitHubOp(op)
|
||||
|
||||
const merged = getRegistryMergedTaskPageGitHubWorkItem(args.item, sourceScope)
|
||||
const composedFields: Partial<GitHubWorkItem> =
|
||||
built.kind === 'list'
|
||||
? built.family === 'assignees'
|
||||
? { assignees: merged.assignees }
|
||||
: { reviewRequests: merged.reviewRequests }
|
||||
: built.next
|
||||
|
||||
args.patchWorkItem(args.item.id, composedFields, args.item.repoId, {
|
||||
sourceContext: args.sourceContext
|
||||
})
|
||||
|
||||
recomputeSoftHideForItem({
|
||||
item: { ...args.item, ...merged },
|
||||
sourceScope,
|
||||
query: args.query,
|
||||
queryKey: args.queryKey,
|
||||
viewerLogin: args.viewerLogin,
|
||||
skipMeQualifiers,
|
||||
updateSticky: false
|
||||
})
|
||||
notifyTaskPageGitHubMutationRegistry()
|
||||
|
||||
return {
|
||||
generation,
|
||||
opKey: built.opKey,
|
||||
itemKey: taskPageGitHubItemKey(args.item.repoId, args.item.id),
|
||||
families: built.families,
|
||||
key
|
||||
}
|
||||
}
|
||||
|
||||
export function isTaskPageGitHubMutationPendingKey(key: TaskPageGitHubMutationKey): boolean {
|
||||
return getPendingTaskPageGitHubOp(key) !== undefined
|
||||
}
|
||||
|
||||
export function getTaskPageGitHubStickyHideForTests(itemKey: string) {
|
||||
return getStickyHideEntry(itemKey)
|
||||
}
|
||||
|
||||
export function rebuildSoftHiddenFromItemsForTests(args: {
|
||||
query: ParsedTaskQuery
|
||||
queryKey: string
|
||||
viewerLogin: string | null
|
||||
items: readonly GitHubWorkItem[]
|
||||
}): void {
|
||||
rebuildSoftHiddenKeysFromPendingAndSticky(args)
|
||||
}
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
import type { GitHubWorkItem } from '../../../shared/types'
|
||||
import type { TaskSourceContext } from '../../../shared/task-source-context'
|
||||
import { loginSetOfUsers, loginSetsEqual } from './task-page-github-work-item-mutation-patches'
|
||||
import {
|
||||
familiesFromPendingOp,
|
||||
freezeTaskPageGitHubUsers
|
||||
} from './task-page-github-work-item-mutation-composition'
|
||||
import {
|
||||
getConfirmedListSnapshot,
|
||||
getLastConfirmedClientValue,
|
||||
getOrCreateQuietRevalidateState,
|
||||
listPendingTaskPageGitHubOpsForItem,
|
||||
deleteConfirmedListSnapshot,
|
||||
deleteLastConfirmedClientValue,
|
||||
taskPageGitHubFamilyDirtyKey,
|
||||
taskPageGitHubItemKey
|
||||
} from './task-page-github-work-item-mutation-registry'
|
||||
import type { TaskPageGitHubPatchWorkItem } from './task-page-github-work-item-mutation-types'
|
||||
|
||||
export const MAX_LAG_TRAILS = 5
|
||||
export const LAG_WALL_BUDGET_MS = 90_000
|
||||
export const LAG_BACKOFF_MS = [500, 1000, 2000, 4000, 8000] as const
|
||||
|
||||
function hasPendingForFamily(
|
||||
sourceScope: string | null,
|
||||
repoId: string,
|
||||
itemId: string,
|
||||
family: string
|
||||
): boolean {
|
||||
for (const op of listPendingTaskPageGitHubOpsForItem(repoId, itemId, sourceScope)) {
|
||||
if (op.listOp?.family === family) {
|
||||
return true
|
||||
}
|
||||
if (!op.listOp && familiesFromPendingOp(op).includes(family)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-regressive field-wise adopt from search (K21). Never force-accept.
|
||||
*/
|
||||
export function adoptQuietSearchFieldsForItem(args: {
|
||||
item: GitHubWorkItem
|
||||
serverItem: GitHubWorkItem
|
||||
sourceScope: string | null
|
||||
queryKey: string
|
||||
fetchStartedAtGeneration: number
|
||||
patchWorkItem: TaskPageGitHubPatchWorkItem
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}): { needTrailing: boolean } {
|
||||
const state = getOrCreateQuietRevalidateState(args.queryKey)
|
||||
const itemKey = taskPageGitHubItemKey(args.item.repoId, args.item.id)
|
||||
let needTrailing = false
|
||||
const G0 = args.fetchStartedAtGeneration
|
||||
const tryFamily = (
|
||||
family: string,
|
||||
adopt: () => void,
|
||||
matches: () => boolean,
|
||||
hasClientAuthority: () => boolean,
|
||||
releaseClientAuthority: () => void
|
||||
): void => {
|
||||
if (hasPendingForFamily(args.sourceScope, args.item.repoId, args.item.id, family)) {
|
||||
return
|
||||
}
|
||||
const dirtyAt = state.familyDirtyAt.get(taskPageGitHubFamilyDirtyKey(itemKey, family)) ?? 0
|
||||
if (dirtyAt > G0) {
|
||||
needTrailing = true
|
||||
return
|
||||
}
|
||||
const hasAuthority = hasClientAuthority()
|
||||
if (hasAuthority && !matches()) {
|
||||
const lagKey = taskPageGitHubFamilyDirtyKey(itemKey, family)
|
||||
const attempts = (state.lagSkipAttempts.get(lagKey) ?? 0) + 1
|
||||
state.lagSkipAttempts.set(lagKey, attempts)
|
||||
const wallExceeded = Date.now() - state.lastConfirmAt > LAG_WALL_BUDGET_MS
|
||||
if (attempts < MAX_LAG_TRAILS && !wallExceeded) {
|
||||
needTrailing = true
|
||||
}
|
||||
// K21: never force-accept lagging search.
|
||||
return
|
||||
}
|
||||
adopt()
|
||||
if (hasAuthority) {
|
||||
releaseClientAuthority()
|
||||
}
|
||||
state.lagSkipAttempts.delete(taskPageGitHubFamilyDirtyKey(itemKey, family))
|
||||
}
|
||||
tryFamily(
|
||||
'state',
|
||||
() => {
|
||||
args.patchWorkItem(args.item.id, { state: args.serverItem.state }, args.item.repoId, {
|
||||
sourceContext: args.sourceContext
|
||||
})
|
||||
},
|
||||
() => {
|
||||
const last = getLastConfirmedClientValue(
|
||||
args.sourceScope,
|
||||
args.item.repoId,
|
||||
args.item.id,
|
||||
'state'
|
||||
)
|
||||
return last === undefined || args.serverItem.state === last
|
||||
},
|
||||
() =>
|
||||
getLastConfirmedClientValue(args.sourceScope, args.item.repoId, args.item.id, 'state') !==
|
||||
undefined,
|
||||
() => deleteLastConfirmedClientValue(args.sourceScope, args.item.repoId, args.item.id, 'state')
|
||||
)
|
||||
tryFamily(
|
||||
'autoMerge',
|
||||
() => {
|
||||
args.patchWorkItem(
|
||||
args.item.id,
|
||||
{ autoMergeEnabled: args.serverItem.autoMergeEnabled },
|
||||
args.item.repoId,
|
||||
{ sourceContext: args.sourceContext }
|
||||
)
|
||||
},
|
||||
() => {
|
||||
const last = getLastConfirmedClientValue(
|
||||
args.sourceScope,
|
||||
args.item.repoId,
|
||||
args.item.id,
|
||||
'autoMerge'
|
||||
)
|
||||
return last === undefined || args.serverItem.autoMergeEnabled === last
|
||||
},
|
||||
() =>
|
||||
getLastConfirmedClientValue(args.sourceScope, args.item.repoId, args.item.id, 'autoMerge') !==
|
||||
undefined,
|
||||
() =>
|
||||
deleteLastConfirmedClientValue(args.sourceScope, args.item.repoId, args.item.id, 'autoMerge')
|
||||
)
|
||||
for (const family of ['assignees', 'reviewRequests'] as const) {
|
||||
const serverListValue =
|
||||
family === 'assignees' ? args.serverItem.assignees : args.serverItem.reviewRequests
|
||||
if (serverListValue === undefined) {
|
||||
continue
|
||||
}
|
||||
tryFamily(
|
||||
family,
|
||||
() => {
|
||||
const serverList = freezeTaskPageGitHubUsers(serverListValue)
|
||||
args.patchWorkItem(
|
||||
args.item.id,
|
||||
family === 'assignees' ? { assignees: serverList } : { reviewRequests: serverList },
|
||||
args.item.repoId,
|
||||
{ sourceContext: args.sourceContext }
|
||||
)
|
||||
},
|
||||
() => {
|
||||
const snapshot = getConfirmedListSnapshot(
|
||||
args.sourceScope,
|
||||
args.item.repoId,
|
||||
args.item.id,
|
||||
family
|
||||
)
|
||||
if (!snapshot) {
|
||||
return true
|
||||
}
|
||||
return loginSetsEqual(loginSetOfUsers(snapshot), loginSetOfUsers(serverListValue))
|
||||
},
|
||||
() =>
|
||||
getConfirmedListSnapshot(args.sourceScope, args.item.repoId, args.item.id, family) !==
|
||||
undefined,
|
||||
() => deleteConfirmedListSnapshot(args.sourceScope, args.item.repoId, args.item.id, family)
|
||||
)
|
||||
}
|
||||
return { needTrailing }
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
import type { GitHubWorkItem } from '../../../shared/types'
|
||||
import type { TaskSourceContext } from '../../../shared/task-source-context'
|
||||
import {
|
||||
adoptQuietSearchFieldsForItem,
|
||||
LAG_BACKOFF_MS,
|
||||
LAG_WALL_BUDGET_MS,
|
||||
MAX_LAG_TRAILS
|
||||
} from './task-page-github-work-item-quiet-adopt'
|
||||
import {
|
||||
clearConfirmedAuthorityForItem,
|
||||
gcStickyHidesAbsentFromPages,
|
||||
getAllStickyHideEntries,
|
||||
getOrCreateQuietRevalidateState,
|
||||
hasConfirmedAuthorityForItem,
|
||||
hasPendingTaskPageGitHubOpsForItem,
|
||||
notifyTaskPageGitHubMutationRegistry,
|
||||
resolveItemSourceScope,
|
||||
taskPageGitHubItemKey
|
||||
} from './task-page-github-work-item-mutation-registry'
|
||||
import type { TaskPageGitHubPatchWorkItem } from './task-page-github-work-item-mutation-types'
|
||||
|
||||
export { adoptQuietSearchFieldsForItem, LAG_BACKOFF_MS, LAG_WALL_BUDGET_MS, MAX_LAG_TRAILS }
|
||||
|
||||
export type TaskPageQuietRevalidateScope = { queryKey: string; generation: number }
|
||||
|
||||
export function advanceTaskPageQuietRevalidateScope(
|
||||
scope: TaskPageQuietRevalidateScope,
|
||||
queryKey: string
|
||||
): TaskPageQuietRevalidateScope {
|
||||
return scope.queryKey === queryKey ? scope : { queryKey, generation: scope.generation + 1 }
|
||||
}
|
||||
|
||||
export function isTaskPageQuietRevalidateScopeCurrent(
|
||||
scope: TaskPageQuietRevalidateScope,
|
||||
queryKey: string,
|
||||
generation: number
|
||||
): boolean {
|
||||
return scope.queryKey === queryKey && scope.generation === generation
|
||||
}
|
||||
|
||||
export function isTaskPageQuietRevalidateRunCurrent(
|
||||
scope: TaskPageQuietRevalidateScope,
|
||||
queryKey: string,
|
||||
generation: number,
|
||||
capturedRefreshEpoch: number,
|
||||
currentRefreshEpoch: number
|
||||
): boolean {
|
||||
return (
|
||||
isTaskPageQuietRevalidateScopeCurrent(scope, queryKey, generation) &&
|
||||
capturedRefreshEpoch === currentRefreshEpoch
|
||||
)
|
||||
}
|
||||
|
||||
export function getTaskPageQuietRevalidateBackoffAttempt(attempts: Iterable<number>): number {
|
||||
const eligible = [...attempts].filter((attempt) => attempt < MAX_LAG_TRAILS)
|
||||
return eligible.length === 0 ? 0 : Math.max(...eligible)
|
||||
}
|
||||
|
||||
export function settleQuietSearchRevalidate(args: {
|
||||
queryKey: string
|
||||
networkItems: readonly GitHubWorkItem[]
|
||||
fetchStartedAtGeneration: number
|
||||
patchWorkItem: TaskPageGitHubPatchWorkItem
|
||||
resolveSourceScope?: (item: GitHubWorkItem) => string | null
|
||||
sourceContextByRepoId?: ReadonlyMap<string, TaskSourceContext | null | undefined>
|
||||
revalidatedItemKeys?: ReadonlySet<string>
|
||||
}): { needTrailing: boolean } {
|
||||
let needTrailing = false
|
||||
for (const serverItem of args.networkItems) {
|
||||
const sourceScope =
|
||||
args.resolveSourceScope?.(serverItem) ??
|
||||
resolveItemSourceScope(serverItem.repoId, serverItem.id)
|
||||
const result = adoptQuietSearchFieldsForItem({
|
||||
item: serverItem,
|
||||
serverItem,
|
||||
sourceScope,
|
||||
queryKey: args.queryKey,
|
||||
fetchStartedAtGeneration: args.fetchStartedAtGeneration,
|
||||
patchWorkItem: args.patchWorkItem,
|
||||
sourceContext: args.sourceContextByRepoId?.get(serverItem.repoId)
|
||||
})
|
||||
if (result.needTrailing) {
|
||||
needTrailing = true
|
||||
}
|
||||
}
|
||||
// Why: do not GC sticky solely because search omitted a row under lag — that
|
||||
// would unhide a successful close under Open. Keep sticky for omitted rows
|
||||
// that still have pending or confirmed authority.
|
||||
const pageKeys = new Set(
|
||||
args.networkItems.map((item) => taskPageGitHubItemKey(item.repoId, item.id))
|
||||
)
|
||||
for (const [itemKey, entry] of getAllStickyHideEntries()) {
|
||||
if (
|
||||
entry.queryKey !== args.queryKey ||
|
||||
pageKeys.has(itemKey) ||
|
||||
!args.revalidatedItemKeys?.has(itemKey)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const separator = itemKey.indexOf('\0')
|
||||
if (
|
||||
separator >= 0 &&
|
||||
!hasPendingTaskPageGitHubOpsForItem(itemKey.slice(0, separator), itemKey.slice(separator + 1))
|
||||
) {
|
||||
clearConfirmedAuthorityForItem(itemKey.slice(0, separator), itemKey.slice(separator + 1))
|
||||
}
|
||||
}
|
||||
const safeGcKeys = new Set(pageKeys)
|
||||
for (const [itemKey] of getAllStickyHideEntries()) {
|
||||
if (pageKeys.has(itemKey)) {
|
||||
continue
|
||||
}
|
||||
const sep = itemKey.indexOf('\0')
|
||||
if (sep < 0) {
|
||||
continue
|
||||
}
|
||||
const repoId = itemKey.slice(0, sep)
|
||||
const itemId = itemKey.slice(sep + 1)
|
||||
if (
|
||||
hasPendingTaskPageGitHubOpsForItem(repoId, itemId) ||
|
||||
hasConfirmedAuthorityForItem(repoId, itemId)
|
||||
) {
|
||||
safeGcKeys.add(itemKey)
|
||||
}
|
||||
}
|
||||
gcStickyHidesAbsentFromPages(safeGcKeys, args.queryKey)
|
||||
const quiet = getOrCreateQuietRevalidateState(args.queryKey)
|
||||
// Why: lag counters are aggregated with Math.max across the query, so an orphan
|
||||
// stuck at MAX (item lagged then left the result set) would disable lag-retry
|
||||
// for every row. Drop counters for items fully gone (no page/pending/authority).
|
||||
for (const lagKey of quiet.lagSkipAttempts.keys()) {
|
||||
const itemKey = lagKey.slice(0, lagKey.lastIndexOf('\0'))
|
||||
if (safeGcKeys.has(itemKey)) {
|
||||
continue
|
||||
}
|
||||
const sep = itemKey.indexOf('\0')
|
||||
if (sep < 0) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
!hasPendingTaskPageGitHubOpsForItem(itemKey.slice(0, sep), itemKey.slice(sep + 1)) &&
|
||||
!hasConfirmedAuthorityForItem(itemKey.slice(0, sep), itemKey.slice(sep + 1))
|
||||
) {
|
||||
quiet.lagSkipAttempts.delete(lagKey)
|
||||
}
|
||||
}
|
||||
if (quiet.dirtyGeneration > args.fetchStartedAtGeneration) {
|
||||
needTrailing = true
|
||||
}
|
||||
notifyTaskPageGitHubMutationRegistry()
|
||||
return { needTrailing }
|
||||
}
|
||||
export function processTaskPageQuietRevalidateSettle(args: {
|
||||
queryKey: string
|
||||
networkItems: readonly GitHubWorkItem[]
|
||||
patchWorkItem: TaskPageGitHubPatchWorkItem
|
||||
resolveSourceScope?: (item: GitHubWorkItem) => string | null
|
||||
sourceContextByRepoId?: ReadonlyMap<string, TaskSourceContext | null | undefined>
|
||||
revalidatedItemKeys?: ReadonlySet<string>
|
||||
}): { needTrailing: boolean } {
|
||||
const state = getOrCreateQuietRevalidateState(args.queryKey)
|
||||
return settleQuietSearchRevalidate({
|
||||
queryKey: args.queryKey,
|
||||
networkItems: args.networkItems,
|
||||
fetchStartedAtGeneration: state.fetchStartedAtGeneration,
|
||||
patchWorkItem: args.patchWorkItem,
|
||||
resolveSourceScope: args.resolveSourceScope,
|
||||
sourceContextByRepoId: args.sourceContextByRepoId,
|
||||
revalidatedItemKeys: args.revalidatedItemKeys
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import { taskPageGitHubFamilyDirtyKey } from './task-page-github-work-item-mutation-keys'
|
||||
|
||||
export type QuietRevalidateState = {
|
||||
inFlight: boolean
|
||||
trailingQueued: boolean
|
||||
dirtyGeneration: number
|
||||
fetchStartedAtGeneration: number
|
||||
familyDirtyAt: Map<string, number>
|
||||
lagSkipAttempts: Map<string, number>
|
||||
networkFailureAttempts: number
|
||||
lastConfirmAt: number
|
||||
runGeneration: number
|
||||
runOwner: object | null
|
||||
}
|
||||
|
||||
const quietByQueryKey = new Map<string, QuietRevalidateState>()
|
||||
|
||||
export function getOrCreateQuietRevalidateState(queryKey: string): QuietRevalidateState {
|
||||
let state = quietByQueryKey.get(queryKey)
|
||||
if (!state) {
|
||||
state = {
|
||||
inFlight: false,
|
||||
trailingQueued: false,
|
||||
dirtyGeneration: 0,
|
||||
fetchStartedAtGeneration: 0,
|
||||
familyDirtyAt: new Map(),
|
||||
lagSkipAttempts: new Map(),
|
||||
networkFailureAttempts: 0,
|
||||
lastConfirmAt: 0,
|
||||
runGeneration: 0,
|
||||
runOwner: null
|
||||
}
|
||||
quietByQueryKey.set(queryKey, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
export function beginTaskPageQuietRevalidateRun(
|
||||
state: QuietRevalidateState,
|
||||
owner: object
|
||||
): number | null {
|
||||
if (state.inFlight && state.runOwner === owner) {
|
||||
state.trailingQueued = true
|
||||
return null
|
||||
}
|
||||
state.inFlight = true
|
||||
state.trailingQueued = false
|
||||
state.runGeneration += 1
|
||||
state.runOwner = owner
|
||||
return state.runGeneration
|
||||
}
|
||||
|
||||
export function finishTaskPageQuietRevalidateRun(
|
||||
state: QuietRevalidateState,
|
||||
owner: object,
|
||||
generation: number
|
||||
): boolean {
|
||||
if (state.runOwner !== owner || state.runGeneration !== generation) {
|
||||
return false
|
||||
}
|
||||
state.inFlight = false
|
||||
state.runOwner = null
|
||||
return true
|
||||
}
|
||||
|
||||
export function getQuietRevalidateState(queryKey: string): QuietRevalidateState | undefined {
|
||||
return quietByQueryKey.get(queryKey)
|
||||
}
|
||||
|
||||
export function markTaskPageGitHubFamiliesDirty(
|
||||
queryKey: string,
|
||||
itemKey: string,
|
||||
families: readonly string[]
|
||||
): void {
|
||||
const quiet = getOrCreateQuietRevalidateState(queryKey)
|
||||
quiet.dirtyGeneration += 1
|
||||
quiet.lastConfirmAt = Date.now()
|
||||
quiet.networkFailureAttempts = 0
|
||||
for (const family of families) {
|
||||
const familyKey = taskPageGitHubFamilyDirtyKey(itemKey, family)
|
||||
quiet.familyDirtyAt.set(familyKey, quiet.dirtyGeneration)
|
||||
quiet.lagSkipAttempts.delete(familyKey)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearTaskPageGitHubQuietStates(): void {
|
||||
quietByQueryKey.clear()
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import type { GitHubAssignableUser, GitHubWorkItem } from '../../../shared/types'
|
||||
|
||||
export type TaskPageGitHubListFamily = 'assignees' | 'reviewRequests'
|
||||
export type TaskPageGitHubMutationKey = {
|
||||
sourceScope: string | null
|
||||
repoId: string
|
||||
itemId: string
|
||||
opKey: string
|
||||
}
|
||||
export type PendingListOp = {
|
||||
family: TaskPageGitHubListFamily
|
||||
kind: 'add' | 'remove'
|
||||
logins: string[]
|
||||
users?: GitHubAssignableUser[]
|
||||
}
|
||||
export type PendingOp = {
|
||||
generation: number
|
||||
key: TaskPageGitHubMutationKey
|
||||
previous: Partial<GitHubWorkItem>
|
||||
next: Partial<GitHubWorkItem>
|
||||
listOp?: PendingListOp
|
||||
skipMeQualifiers: boolean
|
||||
startedAt: number
|
||||
}
|
||||
export type StickyHideEntry = {
|
||||
itemKey: string
|
||||
sourceScope: string | null
|
||||
queryKey: string
|
||||
reason: 'filter_membership'
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { TaskSourceContext } from '../../../shared/task-source-context'
|
||||
import { shouldSkipLocalViewerQualifiers } from './useTaskPageGitHubWorkItemMutation'
|
||||
|
||||
function sourceContext(hostId: TaskSourceContext['hostId']): TaskSourceContext {
|
||||
return {
|
||||
kind: 'task-source',
|
||||
provider: 'github',
|
||||
projectId: 'project-1',
|
||||
hostId
|
||||
}
|
||||
}
|
||||
|
||||
describe('TaskPage GitHub work-item mutation host handling', () => {
|
||||
it('uses the local viewer only for local task sources', () => {
|
||||
expect(shouldSkipLocalViewerQualifiers(sourceContext('local'))).toBe(false)
|
||||
expect(shouldSkipLocalViewerQualifiers(sourceContext('ssh:builder'))).toBe(true)
|
||||
expect(shouldSkipLocalViewerQualifiers(sourceContext('runtime:environment-1'))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not reuse the github.com viewer for a local enterprise host', () => {
|
||||
expect(
|
||||
shouldSkipLocalViewerQualifiers({
|
||||
...sourceContext('local'),
|
||||
providerIdentity: {
|
||||
provider: 'github',
|
||||
owner: 'acme',
|
||||
repo: 'project',
|
||||
host: 'github.acme.test'
|
||||
}
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { parseExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { ParsedTaskQuery } from '../../../shared/task-query'
|
||||
import type { GitHubWorkItem } from '../../../shared/types'
|
||||
import type { TaskSourceContext } from '../../../shared/task-source-context'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
beginTaskPageGitHubWorkItemMutation,
|
||||
canStartTaskPageGitHubWorkItemMutation,
|
||||
confirmTaskPageGitHubWorkItemMutation,
|
||||
rollbackTaskPageGitHubWorkItemMutation
|
||||
} from '@/components/task-page-github-work-item-mutations'
|
||||
import type { TaskPageGitHubMutationIntent } from '@/components/task-page-github-work-item-mutation-patches'
|
||||
import type { TaskPageGitHubPatchWorkItem } from '@/components/task-page-github-work-item-mutation-types'
|
||||
import {
|
||||
getTaskPageGitHubSoftHiddenItemKeys,
|
||||
subscribeTaskPageGitHubMutationRegistry,
|
||||
setTaskPageGitHubMutationQueryKey
|
||||
} from '@/components/task-page-github-work-item-mutation-registry'
|
||||
import { useMountedRef } from './useMountedRef'
|
||||
|
||||
export type UseTaskPageGitHubWorkItemMutationArgs = {
|
||||
queryKey: string
|
||||
query: ParsedTaskQuery
|
||||
/** Local gh viewer login only; may be null. */
|
||||
viewerLogin: string | null
|
||||
patchWorkItem: TaskPageGitHubPatchWorkItem
|
||||
}
|
||||
|
||||
export function shouldSkipLocalViewerQualifiers(sourceContext?: TaskSourceContext | null): boolean {
|
||||
if (!sourceContext) {
|
||||
return false
|
||||
}
|
||||
// Why: local gh viewer must not evaluate @me soft-hide for SSH/environment rows.
|
||||
const hostKind = parseExecutionHostId(sourceContext.hostId)?.kind
|
||||
if (hostKind === 'ssh' || hostKind === 'runtime') {
|
||||
return true
|
||||
}
|
||||
const providerHost =
|
||||
sourceContext.providerIdentity?.provider === 'github'
|
||||
? sourceContext.providerIdentity.host?.toLowerCase()
|
||||
: undefined
|
||||
return Boolean(providerHost && providerHost !== 'github.com')
|
||||
}
|
||||
|
||||
export function useTaskPageGitHubWorkItemMutation(args: UseTaskPageGitHubWorkItemMutationArgs): {
|
||||
run: (input: {
|
||||
item: GitHubWorkItem
|
||||
intent: TaskPageGitHubMutationIntent
|
||||
sourceContext?: TaskSourceContext | null
|
||||
mutate: () => Promise<{ ok?: boolean; error?: string | { message?: string } } | void>
|
||||
successToast?: string
|
||||
errorToast: string
|
||||
serverEntityFromResult?: (result: unknown) => Partial<GitHubWorkItem> | undefined
|
||||
}) => Promise<'confirmed' | 'rolled_back' | 'stale'>
|
||||
isIntentPending: (input: {
|
||||
item: GitHubWorkItem
|
||||
intent: TaskPageGitHubMutationIntent
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}) => boolean
|
||||
softHiddenItemKeys: ReadonlySet<string>
|
||||
} {
|
||||
const patchWorkItem = args.patchWorkItem
|
||||
const mountedRef = useMountedRef()
|
||||
const activeQueryRef = useRef({
|
||||
query: args.query,
|
||||
queryKey: args.queryKey,
|
||||
viewerLogin: args.viewerLogin
|
||||
})
|
||||
activeQueryRef.current = {
|
||||
query: args.query,
|
||||
queryKey: args.queryKey,
|
||||
viewerLogin: args.viewerLogin
|
||||
}
|
||||
const [softHiddenItemKeys, setSoftHiddenItemKeys] = useState<ReadonlySet<string>>(
|
||||
() => new Set(getTaskPageGitHubSoftHiddenItemKeys())
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setTaskPageGitHubMutationQueryKey(args.queryKey)
|
||||
}, [args.queryKey])
|
||||
|
||||
useEffect(() => {
|
||||
setSoftHiddenItemKeys(new Set(getTaskPageGitHubSoftHiddenItemKeys()))
|
||||
return subscribeTaskPageGitHubMutationRegistry(() => {
|
||||
setSoftHiddenItemKeys(new Set(getTaskPageGitHubSoftHiddenItemKeys()))
|
||||
})
|
||||
}, [])
|
||||
|
||||
const isIntentPending = useCallback(
|
||||
(input: {
|
||||
item: GitHubWorkItem
|
||||
intent: TaskPageGitHubMutationIntent
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}) => !canStartTaskPageGitHubWorkItemMutation(input),
|
||||
[]
|
||||
)
|
||||
|
||||
const { query, queryKey, viewerLogin } = args
|
||||
|
||||
const run = useCallback(
|
||||
async (input: {
|
||||
item: GitHubWorkItem
|
||||
intent: TaskPageGitHubMutationIntent
|
||||
sourceContext?: TaskSourceContext | null
|
||||
mutate: () => Promise<{ ok?: boolean; error?: string | { message?: string } } | void>
|
||||
successToast?: string
|
||||
errorToast: string
|
||||
serverEntityFromResult?: (result: unknown) => Partial<GitHubWorkItem> | undefined
|
||||
}): Promise<'confirmed' | 'rolled_back' | 'stale'> => {
|
||||
if (!canStartTaskPageGitHubWorkItemMutation(input)) {
|
||||
return 'stale'
|
||||
}
|
||||
const skipMeQualifiers = shouldSkipLocalViewerQualifiers(input.sourceContext)
|
||||
const began = beginTaskPageGitHubWorkItemMutation({
|
||||
item: input.item,
|
||||
intent: input.intent,
|
||||
sourceContext: input.sourceContext,
|
||||
query,
|
||||
queryKey,
|
||||
viewerLogin,
|
||||
skipMeQualifiers,
|
||||
patchWorkItem
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await input.mutate()
|
||||
const activeQuery = activeQueryRef.current
|
||||
const typed = result as { ok?: boolean; error?: string | { message?: string } } | void
|
||||
if (typed && typeof typed === 'object' && typed.ok === false) {
|
||||
const rolled = rollbackTaskPageGitHubWorkItemMutation({
|
||||
key: began.key,
|
||||
generation: began.generation,
|
||||
patchWorkItem,
|
||||
sourceContext: input.sourceContext,
|
||||
query: activeQuery.query,
|
||||
queryKey: activeQuery.queryKey,
|
||||
viewerLogin: activeQuery.viewerLogin,
|
||||
item: input.item
|
||||
})
|
||||
if (rolled === 'rolled_back' && mountedRef.current) {
|
||||
const message =
|
||||
typeof typed.error === 'string'
|
||||
? typed.error
|
||||
: (typed.error?.message ?? input.errorToast)
|
||||
toast.error(message)
|
||||
}
|
||||
return rolled
|
||||
}
|
||||
|
||||
const serverEntity = input.serverEntityFromResult?.(result)
|
||||
const confirmed = confirmTaskPageGitHubWorkItemMutation(began.key, began.generation, {
|
||||
query: activeQuery.query,
|
||||
queryKey: activeQuery.queryKey,
|
||||
viewerLogin: activeQuery.viewerLogin,
|
||||
item: input.item,
|
||||
serverEntity,
|
||||
patchWorkItem,
|
||||
sourceContext: input.sourceContext,
|
||||
// Quiet revalidate: mark dirty; TaskPage runner is registered separately.
|
||||
scheduleQuiet: false
|
||||
})
|
||||
if (confirmed === 'confirmed') {
|
||||
if (input.successToast && mountedRef.current) {
|
||||
toast.success(input.successToast)
|
||||
}
|
||||
useAppStore.getState().recordFeatureInteraction('github-tasks')
|
||||
}
|
||||
return confirmed
|
||||
} catch (err) {
|
||||
const activeQuery = activeQueryRef.current
|
||||
const rolled = rollbackTaskPageGitHubWorkItemMutation({
|
||||
key: began.key,
|
||||
generation: began.generation,
|
||||
patchWorkItem,
|
||||
sourceContext: input.sourceContext,
|
||||
query: activeQuery.query,
|
||||
queryKey: activeQuery.queryKey,
|
||||
viewerLogin: activeQuery.viewerLogin,
|
||||
item: input.item
|
||||
})
|
||||
if (rolled === 'rolled_back' && mountedRef.current) {
|
||||
toast.error(err instanceof Error ? err.message : input.errorToast)
|
||||
}
|
||||
return rolled
|
||||
}
|
||||
},
|
||||
[query, queryKey, viewerLogin, mountedRef, patchWorkItem]
|
||||
)
|
||||
|
||||
return { run, isIntentPending, softHiddenItemKeys }
|
||||
}
|
||||
|
|
@ -5655,6 +5655,26 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('rejects provider-side partial results when completeness is required', async () => {
|
||||
const store = createTestStore()
|
||||
mockApi.gh.listWorkItems.mockResolvedValueOnce({
|
||||
items: [],
|
||||
sources: {
|
||||
issues: { owner: 'up', repo: 'r' },
|
||||
prs: { owner: 'fork', repo: 'r' },
|
||||
originCandidate: { owner: 'fork', repo: 'r' },
|
||||
upstreamCandidate: { owner: 'up', repo: 'r' }
|
||||
},
|
||||
errors: { issues: { type: 'permission_denied', message: 'no access' } }
|
||||
})
|
||||
|
||||
await expect(
|
||||
store
|
||||
.getState()
|
||||
.fetchWorkItems('repo-id', '/repo', 24, '', { force: true, requireComplete: true })
|
||||
).rejects.toThrow('partial result')
|
||||
})
|
||||
|
||||
it('force-retry invalidates a still-failing in-flight request instead of deduping onto it', async () => {
|
||||
// Why: parent design doc §2 acceptance criterion 4 — the [Retry] button
|
||||
// must re-invoke the fetch with force=true and clear the banner on
|
||||
|
|
@ -6454,6 +6474,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
|
|||
}
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('HTTP 503: Service Unavailable'))
|
||||
.mockRejectedValueOnce(new Error('HTTP 503: Service Unavailable'))
|
||||
|
||||
try {
|
||||
const repos = [{ repoId: 'github-repo', path: '/server/github-repo' }]
|
||||
|
|
@ -6466,7 +6487,16 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
|
|||
expect(result.items).toEqual([{ ...item, repoId: 'github-repo' }])
|
||||
expect(result.failedCount).toBe(0)
|
||||
expect(result.githubUnavailable).toBe(true)
|
||||
expect(mockApi.gh.listWorkItems).toHaveBeenCalledTimes(2)
|
||||
expect(result.requestFailureCount).toBe(1)
|
||||
|
||||
const completeOnly = await store.getState().fetchWorkItemsAcrossRepos(repos, 24, 100, '', {
|
||||
force: true,
|
||||
requireComplete: true,
|
||||
allowStaleFallback: false
|
||||
})
|
||||
expect(completeOnly.items).toEqual([])
|
||||
expect(completeOnly.failedCount).toBe(1)
|
||||
expect(mockApi.gh.listWorkItems).toHaveBeenCalledTimes(3)
|
||||
} finally {
|
||||
consoleWarn.mockRestore()
|
||||
}
|
||||
|
|
@ -6604,6 +6634,73 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('reports skipped SSH repos when a complete first page is required', async () => {
|
||||
const store = createTestStore()
|
||||
mockApi.gh.listWorkItems.mockRejectedValueOnce(
|
||||
new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE)
|
||||
)
|
||||
|
||||
const result = await store
|
||||
.getState()
|
||||
.fetchWorkItemsAcrossRepos([{ repoId: 'ssh-repo', path: '/server/ssh-repo' }], 24, 100, '', {
|
||||
requireComplete: true
|
||||
})
|
||||
|
||||
expect(result.failedCount).toBe(1)
|
||||
expect(result.requestFailureCount).toBe(1)
|
||||
})
|
||||
|
||||
it('reports skipped SSH repos when a complete later page is required', async () => {
|
||||
const store = createTestStore()
|
||||
mockApi.gh.listWorkItems.mockRejectedValueOnce(
|
||||
new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE)
|
||||
)
|
||||
|
||||
const result = await store
|
||||
.getState()
|
||||
.fetchWorkItemsNextPage([{ repoId: 'ssh-repo', path: '/server/ssh-repo' }], 24, 100, '', 1, {
|
||||
requireComplete: true
|
||||
})
|
||||
|
||||
expect(result.failedCount).toBe(1)
|
||||
})
|
||||
|
||||
it('rejects provider-side partial data from a complete later-page result', async () => {
|
||||
const store = createTestStore()
|
||||
mockApi.gh.listWorkItems.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
id: 'issue:1',
|
||||
type: 'issue',
|
||||
number: 1,
|
||||
title: 'Partial',
|
||||
state: 'open',
|
||||
url: 'https://github.com/o/r/issues/1',
|
||||
labels: [],
|
||||
updatedAt: '2026-05-22T00:00:00Z',
|
||||
author: 'author'
|
||||
}
|
||||
],
|
||||
sources: {
|
||||
issues: { owner: 'o', repo: 'r' },
|
||||
prs: { owner: 'o', repo: 'r' },
|
||||
originCandidate: { owner: 'o', repo: 'r' },
|
||||
upstreamCandidate: null
|
||||
},
|
||||
errors: { issues: { type: 'permission_denied', message: 'no access' } }
|
||||
})
|
||||
|
||||
const result = await store
|
||||
.getState()
|
||||
.fetchWorkItemsNextPage([{ repoId: 'repo-1', path: '/repo' }], 24, 100, '', 2, {
|
||||
requireComplete: true
|
||||
})
|
||||
|
||||
expect(result.items).toEqual([])
|
||||
expect(result.failedCount).toBe(1)
|
||||
expect(result.errorTypes).toEqual(['permission_denied'])
|
||||
})
|
||||
|
||||
it('routes work-item next-page fetches through the active runtime environment', async () => {
|
||||
const item = {
|
||||
type: 'pr',
|
||||
|
|
@ -6639,7 +6736,8 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
|
|||
24,
|
||||
100,
|
||||
'is:open',
|
||||
1
|
||||
1,
|
||||
{ noCache: true }
|
||||
)
|
||||
|
||||
expect(mockApi.gh.listWorkItems).not.toHaveBeenCalled()
|
||||
|
|
@ -6650,7 +6748,8 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
|
|||
repo: 'runtime-repo-id',
|
||||
limit: 24,
|
||||
query: 'is:open',
|
||||
page: 1
|
||||
page: 1,
|
||||
noCache: true
|
||||
},
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
|
|
|
|||
|
|
@ -621,6 +621,8 @@ export type CacheEntry<T> = {
|
|||
type FetchOptions = {
|
||||
force?: boolean
|
||||
noCache?: boolean
|
||||
requireComplete?: boolean
|
||||
allowStaleFallback?: boolean
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}
|
||||
|
||||
|
|
@ -681,6 +683,7 @@ type InflightWorkItems = {
|
|||
promise: Promise<GitHubWorkItem[]>
|
||||
force: boolean
|
||||
noCache: boolean
|
||||
requireComplete: boolean
|
||||
}
|
||||
const inflightWorkItemsRequests = new Map<string, InflightWorkItems>()
|
||||
const prRequestGenerations = new Map<string, number>()
|
||||
|
|
@ -1958,7 +1961,12 @@ export type GitHubSlice = {
|
|||
displayLimit: number,
|
||||
query: string,
|
||||
options?: FetchOptions
|
||||
) => Promise<{ items: GitHubWorkItem[]; failedCount: number; githubUnavailable: boolean }>
|
||||
) => Promise<{
|
||||
items: GitHubWorkItem[]
|
||||
failedCount: number
|
||||
githubUnavailable: boolean
|
||||
requestFailureCount?: number
|
||||
}>
|
||||
/** Fetch one numbered provider page. Pagination pages remain renderer-local. */
|
||||
fetchWorkItemsNextPage: (
|
||||
repos: {
|
||||
|
|
@ -1970,7 +1978,8 @@ export type GitHubSlice = {
|
|||
perRepoLimit: number,
|
||||
displayLimit: number,
|
||||
query: string,
|
||||
page: number
|
||||
page: number,
|
||||
options?: Pick<FetchOptions, 'noCache' | 'requireComplete'>
|
||||
) => Promise<{
|
||||
items: GitHubWorkItem[]
|
||||
failedCount: number
|
||||
|
|
@ -2651,7 +2660,11 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
|||
const existing = inflightWorkItemsRequests.get(inflightKey)
|
||||
if (existing) {
|
||||
// Why: a forcing/noCache caller must not dedupe to a weaker in-flight fetch (noCache is stricter — it must bypass gh api's cache too).
|
||||
if ((options?.force && !existing.force) || (options?.noCache && !existing.noCache)) {
|
||||
if (
|
||||
(options?.force && !existing.force) ||
|
||||
(options?.noCache && !existing.noCache) ||
|
||||
(options?.requireComplete && !existing.requireComplete)
|
||||
) {
|
||||
await existing.promise.catch(() => {})
|
||||
} else {
|
||||
return existing.promise
|
||||
|
|
@ -2668,6 +2681,9 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
|||
})
|
||||
// Why: stamp repoId at the fetch boundary so downstream consumers can rely on it — main doesn't know Orca's Repo.id.
|
||||
const items: GitHubWorkItem[] = envelope.items.map((item) => ({ ...item, repoId }))
|
||||
if (options?.requireComplete && (envelope.errors?.issues || envelope.errors?.prs)) {
|
||||
throw new Error('GitHub work-item fetch returned a partial result.')
|
||||
}
|
||||
// Why: only surface issues-side errors here; PR-side failures predate the issue-source split (#1076) and are out of scope for this banner (design doc §2).
|
||||
const issuesError = envelope.errors?.issues
|
||||
// Why: errors.issues without sources.issues has no slug for the banner, so it's dropped from the cache; log it so this rare case is visible in devtools.
|
||||
|
|
@ -2720,7 +2736,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
|||
inflightWorkItemsRequests.set(inflightKey, {
|
||||
promise: request,
|
||||
force: Boolean(options?.force),
|
||||
noCache: Boolean(options?.noCache)
|
||||
noCache: Boolean(options?.noCache),
|
||||
requireComplete: Boolean(options?.requireComplete)
|
||||
})
|
||||
return request
|
||||
},
|
||||
|
|
@ -2745,6 +2762,10 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
|||
// Why: fall back to any cache entry (stale or not) before declaring this repo failed; only count as failed when it has nothing to contribute.
|
||||
// Why: use perRepoLimit (not displayLimit) so the cache key matches what fetchWorkItems wrote.
|
||||
if (isGitHubWorkItemsSshRemoteRequiredError(err)) {
|
||||
if (options?.requireComplete) {
|
||||
requestFailureCount += 1
|
||||
failedCount += 1
|
||||
}
|
||||
skippedSourceCount += 1
|
||||
return [] as GitHubWorkItem[]
|
||||
}
|
||||
|
|
@ -2762,7 +2783,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
|||
)
|
||||
: getWorkItemsCacheKeyForOwner(get(), r.repoId, perRepoLimit, query, r.path)
|
||||
const cached = get().workItemsCache[key]?.data
|
||||
if (cached) {
|
||||
if (cached && options?.allowStaleFallback !== false) {
|
||||
console.warn(`[workItems] ${r.repoId} failed, serving cached:`, err)
|
||||
return cached
|
||||
}
|
||||
|
|
@ -2778,10 +2799,15 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
|||
requestFailureCount > 0 &&
|
||||
requestFailureCount === repos.length - skippedSourceCount &&
|
||||
unavailableFailureCount === requestFailureCount
|
||||
return { items: merged, failedCount, githubUnavailable }
|
||||
return {
|
||||
items: merged,
|
||||
failedCount,
|
||||
githubUnavailable,
|
||||
...(requestFailureCount > 0 ? { requestFailureCount } : {})
|
||||
}
|
||||
},
|
||||
|
||||
fetchWorkItemsNextPage: async (repos, perRepoLimit, displayLimit, query, page) => {
|
||||
fetchWorkItemsNextPage: async (repos, perRepoLimit, displayLimit, query, page, options) => {
|
||||
if (isGitHubWorkItemsQueryTooLarge(query)) {
|
||||
return { items: [], failedCount: 0, errorTypes: [] }
|
||||
}
|
||||
|
|
@ -2808,7 +2834,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
|||
const envelope = await listGitHubWorkItemsForRepo(requestContext, {
|
||||
limit: perRepoLimit,
|
||||
query: query || undefined,
|
||||
page
|
||||
page,
|
||||
...(options?.noCache ? { noCache: true } : {})
|
||||
})
|
||||
// Why: page-N failures aren't in the per-repo banner (keyed on the initial fetch); log them so pagination failures are observable instead of silently truncating (richer surface deferred, design doc §6).
|
||||
if (envelope.errors?.issues) {
|
||||
|
|
@ -2836,9 +2863,16 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
|||
envelope.errors.prs
|
||||
)
|
||||
}
|
||||
if (options?.requireComplete && (envelope.errors?.issues || envelope.errors?.prs)) {
|
||||
failedCount += 1
|
||||
return [] as GitHubWorkItem[]
|
||||
}
|
||||
return envelope.items.map((item): GitHubWorkItem => ({ ...item, repoId: r.repoId }))
|
||||
} catch (err) {
|
||||
if (isGitHubWorkItemsSshRemoteRequiredError(err)) {
|
||||
if (options?.requireComplete) {
|
||||
failedCount += 1
|
||||
}
|
||||
return [] as GitHubWorkItem[]
|
||||
}
|
||||
console.warn(`[workItems] next page ${r.repoId} failed:`, err)
|
||||
|
|
|
|||
Loading…
Reference in New Issue