Files
orca/config/scripts/github-opened-issue-repository.test.ts
T
Brennan BensonandMax Hsu 8e326f2629 Keep GitHub task assignees and rows current after dialog edits (#34245)
* fix(tasks): publish confirmed dialog assignee changes to the list

Record successful per-login changes in the existing assignee snapshot and
patch the Tasks cache. Qualify snapshot writers, composition and quiet
release by canonical repository so fork and host twins stay isolated.

Co-authored-by: Max Hsu <maxmilian@gmail.com>

* test(tasks): bound confirmed assignee publication work

* Add explicit task refresh through registered details

* Refresh registered tasks with finite request ownership

* Preserve addressed task display and ordinary edit boundaries

* Derive task membership from owned rows and batch cache updates

* Preserve task list intent across account changes and query resets

* Preserve GitHub task content through final delivery

* Preserve accepted task content across cold query changes

* Batch finite task delivery publications and index ordered list folds

* Capture immutable Tasks publication inputs and translate Refresh feedback

* Preserve live scalar settlement beside captured publication inputs

* test: make task refresh contracts deterministic for static checks

* test: derive lifetime token annotations from production origins

* test: initialize current repo catalog defaults for refresh fixtures

* Fix refresh import dependencies for static checks

* Fix GitHub task refresh adapters and verification inputs

* Exercise omitted mutation observation in overlap contract

* fix(tasks): refresh live list bases and align delivery contracts

* test(tasks): retain actual pagination setter inputs

* fix(tasks): express page array mutability with checked overloads

* fix(tasks): preserve complete models through refresh stages

* fix(tasks): align origin and query row input contracts

* test: declare portable task refresh mock contracts

* test: allow zero-argument task refresh mutation calls

* Keep GitHub task copies fresh after edits and refresh

* Keep task rows visible and simplify the shared task record

Rows are never filtered for a stale connection or account binding; only a
current record for the row's own repo overlays its facts, derived at read
from repoId/repoHostId. Records cover issues only, conflicts compare only
state, title, label names and assignee logins, and truncated Project lists
never count as conflicts. Project tables stay raw in the store and are
projected where they are read.

Ordinary local dialog opens keep cached, lenient reads; only Refresh asks
for the fresh, strict read. Refresh waits on the record's own pending edits
instead of a second write registry, reports a failed user Refresh once
through the existing toast, and explains in its tooltip when the server
needs an update. Background verification never shows a failure.

A successful dialog assign keeps the selection until a newer reading lands,
main's soft-hide for Tasks-row edits is unchanged, and pins/eviction are
gone because only user edits and Refreshes create records.

* Let saved descriptions win over Refresh and roll back disposed edits

Refresh is now the only fresh, strict read: it writes the same details
entry ordinary loads use, and later reopens and post-edit refetches go
back to the cached, lenient path. An issue's saved description supersedes
any read already in flight, so Refresh rereads after the save instead of
restoring the older body. A failed Refresh with nothing loaded yet leaves
only the body's existing load error.

A record disposed while its edit is in flight still settles the copies its
edit patched, so a failed edit rolls back in Tasks and the dialog as before.
A record whose own connection fence went stale is dropped when next
looked up. Rows without a record pass through acceptSearch unchanged.

* Settle disposed edits through their owner and connect Project edits

A late edit whose record was disposed mid-flight now finishes through the
same base/pending reducer: copies receive only that operation's own change
(one label or login added or removed, a state set), a replacement record
reconciles with its own item read, and settle never returns null so dialog
and Tasks rollback always run. Untracked edits publish the same semantic
change at begin and undo it on failure. Dialog callers stop re-patching
Tasks rows; an unsupported server's live record keeps new edits joined
until its last one settles.

Refresh clicked during the first details load waits for that load, then
runs the strict read; a failed click always shows its one toast and never
installs an inline load error.

Project-row issue edits now publish to Tasks copies with no prior record,
seeded from a complete Tasks row for the matched repo binding, or
established by an item read after the edit lands.

* Give a task record its base only from an accepted provider reading

Starting an edit or a Refresh no longer turns the caller's cached row into
the record's full value. Until an owned canonical read is accepted, a record
holds only its pending changes and the coalesced successful ones, and every
copy (Tasks rows, pickers, the board) shows its own facts plus only those
changes. A successful edit applies just its change to the stored copies and
keeps it until one coalesced read through the existing read lane
establishes the base; a failed edit with nothing else owed retires the
owner, and a failed first Refresh leaves nothing behind. Search deliveries
and board fetches wake a read still owed.

Edits are now semantic change values (set title or state, add or remove a
named label or login) instead of closures, so they coalesce and apply to any
copy. Project edits keep their producer but no longer seed from a cached
row, and the separate establishing read is gone. A Project save with no
title, label or assignee change (a body-only save) skips the record
entirely.

* Keep pending task edits out of stored copies and re-admit queued reads

Accepted records now follow the rule records without a reading already
followed: stored Tasks copies hold only accepted readings and confirmed
changes, and pending changes reach users only through read-time projection.
A search during a pending edit stores the accepted facts, never the edit; a
failed or disposed edit just drops its token instead of computing an undo
over current copies, so a newer reading can't be overwritten by an old
edit's rollback. The cross-copy undo helper is removed.

Without a tracked owner (no binding, or a server without the item route),
other copies change only when an edit succeeds, and a board edit on a
server known to lack the item route stays board-only. Dialog state and
label edits roll back to what the dialog showed.

A canonical read queued behind another one re-enters admission after each
wait, so a Refresh clicked after a discarded one still reads, and a read
whose binding or server support went away meanwhile ends without leaving
an owner.

* Store only readings, keep task data across reconnects, park failed reads

Stored Tasks copies (the fresh-cache fetch return, details settle) hold only
accepted readings and confirmed edits; Cmd-J applies its open-only filter to
what it shows, pending edits included. Records, details entries and
optimistic comments are keyed by account binding and issue; a connection
change re-captures the binding and fences each read and send without
deleting what the dialog shows. A server's missing route marks only the
connection that answered. An unusable canonical read keeps what is known
and waits for the next provider delivery, which retries one owed read per
binding, the longest-waiting first. The dialog's label chips follow the
displayed item, and the duplicate target fence and write-only details
generation are removed.

* Park only reads that were owed, so a failed Refresh never delays a later one

A failed Refresh on a record with nothing owed no longer marks it as a parked
retry; a later obligation on that issue reads on the next delivery instead of
waiting behind every parked record. The refresh hook stops returning its
unused target.

* Keep the ordinary-open details case after main removed its suite

Ports the one case that pins an ordinary open with an issue repository to the
cached, lenient read (a failed timeline read still returns details) into the
main details suite.

* Keep pull-request lookups off the task records and repair CI tests

Pull requests never have a task record, so their lookups no longer load the
store or report a search reading; terminal PR-link confirmation reads as on
main. Tests follow the explicit canonical details flag, the current dialog edit
signature (records hold a dialog close over a lagging search), confirmed-only
untracked publication, and a fixed board timestamp.

* Keep main's close hold where no task record can own it, and split the record cache

A dialog close or reopen that no task record can own (no binding, or a server
without the item route) keeps main's confirmed-state hold, so a lagging search
cannot show the issue open again; a record that takes over retires it. Tracked
edits still go through the record only.

The record cache moves, unchanged, into model, owners and reads layers under
the Project-facing cache, so no module needs a max-lines exception.

* Hold an untracked dialog close only after it is confirmed

A dialog close or reopen no record can own changes no stored copy while the
request runs, so a refresh during a failed request leaves every copy as it was
and nothing needs undoing. At success the edit is admitted again: a record that
can now own it (after a reconnect or upgrade) settles it, otherwise main's
confirmed-state hold is asserted and the change is published once.

* Name the task binding incarnation token type

The binding incarnation, compared by reference, gets its own named type instead
of the broad object type; no behavior change.

* Import Cmd-J task search results from one statement

* Type changed code without assertions and keep the assignee callback stable

Tests build typed fixtures and hoisted mocks instead of casting; the landing
refresh takes only the model parts it reads. Lookups and the comments parse
annotate their values instead of asserting them. The edit section's empty
assignee list is one shared value, so its toggle callback keeps its identity.

* Stamp the opened row onto main's details item before it reaches typed record reads

* Pin the parked-read and board-read budgets at 10 and 100 records

* Size the dialog's Refresh task button like Copy link and Open on GitHub

---------

Co-authored-by: Max Hsu <maxmilian@gmail.com>
2026-11-01 19:34:54 -08:00

638 lines
23 KiB
TypeScript

import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import type * as ReactModule from 'react'
import type * as GhUtils from '../../src/main/github/gh-utils'
import type * as IssueMetadata from '../../src/renderer/src/hooks/useIssueMetadata'
import type { GitHubWorkItem } from '../../src/shared/github/work-item-types'
import type { GitHubOwnerRepo } from '../../src/shared/github/pull-request-types'
import type { TaskSourceContext } from '../../src/shared/task-source-context'
import type { Repo } from '../../src/shared/repo-types'
const fixture = vi.hoisted(() => {
const state: {
loads: { key: string | null; load: () => Promise<unknown[]> }[]
requests: { args: string[]; host?: string }[]
gh: ReturnType<typeof vi.fn>
apiUpdate: ReturnType<typeof vi.fn>
preference: 'origin' | 'upstream'
store: ReturnType<typeof createTestStore> | null
} = {
loads: [],
requests: [],
gh: vi.fn(),
apiUpdate: vi.fn(),
preference: 'upstream',
store: null
}
return state
})
vi.mock('react', async (original) => ({
...(await original<typeof ReactModule>()),
useState: (initial: unknown) => [typeof initial === 'function' ? initial() : initial, vi.fn()],
useMemo: <T>(value: () => T) => value(),
useCallback: <T>(value: T) => value,
useRef: <T>(initial: T) => ({ current: initial }),
useEffect: vi.fn()
}))
vi.mock('zustand/react/shallow', () => ({ useShallow: <T>(value: T) => value }))
vi.mock('@/store', () => ({
useAppStore: Object.assign(
(selector: (state: unknown) => unknown) =>
selector({ patchWorkItem: vi.fn(), patchProjectRowContent: vi.fn() }),
// Edits reach Tasks copies through the store the case created.
{ getState: () => fixture.store?.getState() ?? { recordFeatureInteraction: vi.fn() } }
)
}))
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
vi.mock('@/components/github/github-duplicate-issue-candidates', () => ({
useGitHubDuplicateIssueCandidates: () => []
}))
vi.mock('@/components/github/github-work-item-comment-mutations', () => ({
notifyWorkItemDetailsMutation: vi.fn()
}))
vi.mock('@/hooks/useIssueMetadata', async (original) => ({
...(await original<typeof IssueMetadata>()),
useImmediateMutation: () => ({ isPending: () => false, run: vi.fn() })
}))
vi.mock('@/hooks/useMetadataListRequest', () => ({
useMetadataListRequest: <T>(args: { cacheKey: string | null; load: () => Promise<T[]> }) => {
fixture.loads.push({ key: args.cacheKey, load: args.load })
return { data: [], loading: false, error: null }
}
}))
vi.mock('../../src/main/github/gh-utils', async (original) => ({
...(await original<typeof GhUtils>()),
ghExecFileAsync: fixture.gh,
acquire: vi.fn(),
release: vi.fn(),
getOwnerRepoForRemote: async (_path: string, remote: string) => ({
owner: remote === 'upstream' ? 'upstream-owner' : 'fork-owner',
repo: 'widgets'
})
}))
vi.mock('../../src/main/git/remote-name-listing', () => ({
shouldProbeGitRemote: async () => true
}))
import { GHEditSection } from '../../src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section'
import {
runGHEditLabelToggle,
runGHEditStateChange
} from '../../src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-mutations'
import { findTaskPageDialogWorkItem } from '../../src/renderer/src/components/task-page/task-page-cache-selectors'
import { getTaskPageRepoSourceContext } from '../../src/renderer/src/components/task-page/task-page-source-context'
import { workItemsCacheKey } from '../../src/renderer/src/store/github/cache-identity'
import { createTestStore } from '../../src/renderer/src/store/slices/github-slice-test-harness'
import {
materializeTaskPageItemList,
reapplyPendingTaskPageGitHubMutationsToCache
} from '../../src/renderer/src/components/task-page/github/task-page-github-work-item-mutation-pages'
import { setRuntimeEnvironmentConnectionGenerationForTests } from '../../src/renderer/src/store/slices/runtime-status-connection-generation'
import { getLastConfirmedClientValue } from '../../src/renderer/src/components/task-page/github/task-page-github-confirmed-client-values'
import { RuntimeRpcCallError } from '../../src/renderer/src/runtime/runtime-rpc-result'
import { getTaskSourceCacheScope } from '../../src/shared/task-source-context'
import { listLabels, listAssignableUsers } from '../../src/main/github/issue-field-options'
import { useRepoLabels, useRepoAssignees } from '../../src/renderer/src/hooks/useIssueMetadata'
import { updateIssue } from '../../src/main/github/issue-update'
import {
resetTaskPageGitHubMutationRegistryForTests,
setTaskPageGitHubMutationQueryKey
} from '../../src/renderer/src/components/task-page/github/task-page-github-work-item-mutation-registry'
function renderEditSection(props: Parameters<typeof GHEditSection>[0]): void {
renderToStaticMarkup(createElement(GHEditSection, props))
}
const registeredRepo: Repo = {
id: 'repo-1',
path: join(tmpdir(), 'orca-opened-issue-repository-fixture'),
displayName: 'widgets',
badgeColor: 'primary',
addedAt: 1,
upstream: { owner: 'upstream-owner', repo: 'widgets', host: 'github.com' }
}
function sourceFor(preference: 'origin' | 'upstream'): TaskSourceContext {
const source = getTaskPageRepoSourceContext(
{ ...registeredRepo, issueSourcePreference: preference },
'github'
)
if (!source) {
throw new Error('Registered fixture must produce a source context')
}
return source
}
const sourceContext = sourceFor('origin')
const fork: GitHubWorkItem = {
id: 'issue:5',
type: 'issue',
number: 5,
title: 'FORK title',
state: 'open',
url: 'https://github.com/fork-owner/widgets/issues/5',
labels: [],
updatedAt: '',
author: null,
repoId: 'repo-1'
}
const upstream: GitHubWorkItem = {
...fork,
title: 'UPSTREAM title',
url: 'https://github.com/upstream-owner/widgets/issues/5'
}
const issueRepo = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' }
const unsupported = () =>
new RuntimeRpcCallError({
id: 'old-server',
ok: false,
error: { code: 'method_not_found', message: 'unsupported' }
})
type MetadataArgs = { repoPath: string; ownerRepo?: GitHubOwnerRepo }
beforeEach(() => {
fixture.store = null
fixture.preference = 'upstream'
fixture.loads = []
fixture.requests = []
resetTaskPageGitHubMutationRegistryForTests()
setTaskPageGitHubMutationQueryKey('current-upstream-list')
fixture.gh.mockReset()
fixture.gh.mockImplementation(async (args: string[], options: { host?: string }) => {
fixture.requests.push({ args, host: options.host })
return { stdout: '', stderr: '' }
})
fixture.apiUpdate = vi.fn((args: Parameters<typeof window.api.gh.updateIssue>[0]) =>
updateIssue(
args.repoPath,
args.number,
args.updates,
null,
{},
fixture.preference,
args.ownerRepo
)
)
vi.stubGlobal('window', {
api: {
gh: {
updateIssue: fixture.apiUpdate,
listLabels: (args: MetadataArgs) =>
listLabels(args.repoPath, fixture.preference, null, {}, args.ownerRepo),
listAssignableUsers: (args: MetadataArgs) =>
listAssignableUsers(args.repoPath, fixture.preference, null, {}, args.ownerRepo)
}
}
})
})
afterEach(() => {
resetTaskPageGitHubMutationRegistryForTests()
vi.unstubAllGlobals()
})
it.each([
{ openedItem: fork, listItem: upstream, preference: 'upstream' },
{ openedItem: upstream, listItem: fork, preference: 'origin' },
{ openedItem: fork, listItem: fork, preference: 'origin' }
] as const)(
'scopes $openedItem.title labels under $preference to its canonical list row',
async ({ openedItem, listItem, preference }) => {
fixture.preference = preference
expect(sourceFor('upstream')).toEqual(sourceContext)
const store = createTestStore()
store.setState({ repos: [], recordFeatureInteraction: vi.fn() })
fixture.store = store
const key = workItemsCacheKey(
registeredRepo.id,
36,
'',
getTaskSourceCacheScope(sourceFor('upstream'))
)
store.setState({
workItemsCache: { [key]: { data: [listItem], fetchedAt: Date.now() } }
})
const opened =
findTaskPageDialogWorkItem(store.getState().workItemsCache, {
id: openedItem.id,
repoId: openedItem.repoId,
url: openedItem.url
}) ?? openedItem
expect(opened.url).toBe(openedItem.url)
const target = { ...issueRepo, owner: openedItem === fork ? 'fork-owner' : 'upstream-owner' }
let mutation: Promise<unknown> = Promise.resolve()
runGHEditLabelToggle({
item: opened,
itemNumber: opened.number,
itemRepoId: opened.repoId,
repoPath: registeredRepo.path,
sourceContext,
projectOrigin: undefined,
issueRepo: target,
label: 'fork-only-label',
localLabels: [],
run: async (_key, options) => {
options.onOptimistic?.()
mutation = options.mutate()
await mutation
options.onSuccess?.()
return true
},
onLabelsChange: vi.fn(),
patchProjectRowIfNeeded: vi.fn(),
onMutated: vi.fn()
})
await mutation
expect(fixture.requests[0].args).toContain(`${target.owner}/widgets`)
expect(fixture.apiUpdate).toHaveBeenCalledWith(expect.objectContaining({ ownerRepo: target }))
expect(store.getState().workItemsCache[key]?.data?.[0].labels).toEqual(
listItem.url === openedItem.url ? ['fork-only-label'] : []
)
}
)
it.each([
{ openedItem: fork, owner: 'fork-owner', preference: 'origin' },
{ openedItem: fork, owner: 'fork-owner', preference: 'upstream' },
{ openedItem: upstream, owner: 'upstream-owner', preference: 'origin' },
{ openedItem: upstream, owner: 'upstream-owner', preference: 'upstream' }
] as const)(
'loads $owner picker candidates while preference=$preference',
async ({ preference, openedItem, owner }) => {
fixture.preference = preference
renderEditSection({
item: openedItem,
repoPath: registeredRepo.path,
repoId: fork.repoId,
sourceContext,
projectOrigin: undefined,
localState: 'open',
localLabels: [],
assignees: [],
onStateChange: vi.fn(),
onLabelsChange: vi.fn(),
onMutated: vi.fn(),
onUse: vi.fn()
})
for (const request of fixture.loads.filter((load) => load.key !== null)) {
await request.load()
}
expect(
fixture.requests.map((request) => request.args.find((arg) => arg.startsWith('repos/')))
).toEqual([`repos/${owner}/widgets/labels`, `repos/${owner}/widgets/assignees?per_page=100`])
}
)
it.each(
[true, false].flatMap((tracked) => [
{ openedItem: fork, listItem: fork, tracked },
{ openedItem: fork, listItem: upstream, tracked },
{ openedItem: upstream, listItem: fork, tracked },
{ openedItem: upstream, listItem: upstream, tracked }
])
)(
'a $openedItem.title close only controls its own row while search lags (list=$listItem.title, tracked=$tracked)',
async ({ openedItem, listItem, tracked }) => {
const target = { ...issueRepo, owner: openedItem === fork ? 'fork-owner' : 'upstream-owner' }
fixture.store = createTestStore()
fixture.store.setState({ repos: [registeredRepo], recordFeatureInteraction: vi.fn() })
const cache = fixture.store.getState().githubTaskCache
const binding = cache.capture(registeredRepo, sourceContext)!
if (!tracked) {
// An old server without the item route: no task record can own the edit, so main's hold applies.
await cache.readCanonical(openedItem, binding, () => Promise.reject(unsupported()))
expect(cache.canTrack(binding)).toBe(false)
}
let pending = Promise.resolve()
runGHEditStateChange({
newState: 'closed',
localState: 'open',
item: openedItem,
itemNumber: fork.number,
itemRepoId: fork.repoId,
repoPath: registeredRepo.path,
sourceContext,
projectOrigin: undefined,
issueRepo: target,
run: (_key, options) => {
pending = (async () => {
options.onOptimistic?.()
await options.mutate()
options.onSuccess?.()
})()
return pending
},
onStateChange: vi.fn(),
patchProjectRowIfNeeded: vi.fn(),
onMutated: vi.fn()
})
await pending
expect(fixture.apiUpdate).toHaveBeenCalledWith(expect.objectContaining({ ownerRepo: target }))
// A record owns a tracked close; only an untracked one keeps main's registry hold.
const hold = getLastConfirmedClientValue(
getTaskSourceCacheScope(sourceContext),
fork.repoId,
fork.id,
'state'
)
expect(hold).toBe(tracked ? undefined : 'closed')
// The lagging search row goes through the fetch, the Tasks page list and its projection.
const [shown] = materializeTaskPageItemList({
networkItems: [...cache.acceptSearch([listItem], binding)],
previousItems: [listItem],
queryKey: 'current-upstream-list'
})
expect(cache.projectItem(shown).state).toBe(listItem.url === openedItem.url ? 'closed' : 'open')
}
)
it.each(
[true, false].flatMap(
(tracked) =>
[
{ tracked, from: 'open', to: 'closed' },
{ tracked, from: 'closed', to: 'open' }
] as const
)
)(
'a failed $to edit leaves stored copies as they were through a refresh (tracked=$tracked)',
async ({ tracked, from, to }) => {
const store = createTestStore()
store.setState({ repos: [registeredRepo], recordFeatureInteraction: vi.fn() })
fixture.store = store
const cache = store.getState().githubTaskCache
const binding = cache.capture(registeredRepo, sourceContext)!
if (!tracked) {
await cache.readCanonical(fork, binding, () => Promise.reject(unsupported()))
}
const row = { ...fork, state: from }
const key = workItemsCacheKey(registeredRepo.id, 36, '', getTaskSourceCacheScope(sourceContext))
store.setState({ workItemsCache: { [key]: { data: [row], fetchedAt: 1 } } })
fixture.apiUpdate.mockRejectedValueOnce(new Error('denied'))
let pending = Promise.resolve()
let pages = [row]
runGHEditStateChange({
newState: to,
localState: from,
item: row,
itemNumber: fork.number,
itemRepoId: fork.repoId,
repoPath: registeredRepo.path,
sourceContext,
projectOrigin: undefined,
issueRepo,
run: (_key, options) => {
pending = (async () => {
options.onOptimistic?.()
// A Tasks refresh lands while the edit is in flight, with search still lagging.
reapplyPendingTaskPageGitHubMutationsToCache({
items: store.getState().workItemsCache[key]?.data ?? [],
patchWorkItem: store.getState().patchWorkItem,
sourceContextByRepoId: new Map([[registeredRepo.id, sourceContext]])
})
pages = materializeTaskPageItemList({
networkItems: [row],
previousItems: pages,
queryKey: 'current-upstream-list'
})
await options.mutate().catch(() => options.onRevert?.())
})()
return pending
},
onStateChange: vi.fn(),
patchProjectRowIfNeeded: vi.fn(),
onMutated: vi.fn()
})
await pending
const raw = store.getState().workItemsCache[key]!.data![0]
const shown = [raw, pages[0]].map((copy) => cache.projectItem(copy).state)
expect([raw.state, pages[0].state, ...shown]).toEqual([from, from, from, from])
expect(
getLastConfirmedClientValue(
getTaskSourceCacheScope(sourceContext),
fork.repoId,
fork.id,
'state'
)
).toBeUndefined()
}
)
it('lets a record own a close that began on an unsupported server and succeeded after a reconnect', async () => {
setRuntimeEnvironmentConnectionGenerationForTests('p34245-takeover', 1)
const remote = { ...registeredRepo, executionHostId: 'runtime:p34245-takeover' }
const remoteSource = getTaskPageRepoSourceContext(remote, 'github')!
const store = createTestStore()
store.setState({
repos: [remote],
runtimeStatusByEnvironmentId: new Map(),
recordFeatureInteraction: vi.fn()
})
fixture.store = store
const cache = store.getState().githubTaskCache
await cache.readCanonical(fork, cache.capture(remote, remoteSource)!, () =>
Promise.reject(unsupported())
)
expect(cache.canTrack(cache.capture(remote, remoteSource)!)).toBe(false)
runGHEditStateChange({
newState: 'closed',
localState: 'open',
item: fork,
itemNumber: fork.number,
itemRepoId: fork.repoId,
repoPath: remote.path,
sourceContext: remoteSource,
projectOrigin: undefined,
issueRepo,
// The write is in flight while the upgraded server reconnects, then it succeeds.
run: async (_key, options) => {
options.onOptimistic?.()
setRuntimeEnvironmentConnectionGenerationForTests('p34245-takeover', 2)
store.setState({ runtimeStatusByEnvironmentId: new Map() })
options.onSuccess?.()
},
onStateChange: vi.fn(),
patchProjectRowIfNeeded: vi.fn(),
onMutated: vi.fn()
})
const scope = getTaskSourceCacheScope(remoteSource)
expect(getLastConfirmedClientValue(scope, fork.repoId, fork.id, 'state')).toBeUndefined()
const binding = cache.capture(remote, remoteSource)!
expect(cache.isPending(fork, binding) || cache.readRevision(fork, binding) > 0).toBe(true)
expect(cache.projectItem(cache.acceptSearch([fork], binding)[0]).state).toBe('closed')
})
it('sends a duplicate close with its reason even when the issue already reads closed', async () => {
fixture.store = createTestStore()
fixture.store.setState({ repos: [], recordFeatureInteraction: vi.fn() })
let pending: Promise<void> = Promise.resolve()
runGHEditStateChange({
newState: 'closed',
closeAction: { stateReason: 'duplicate', duplicateOf: 42 },
localState: 'closed',
item: { ...fork, state: 'closed' },
itemNumber: fork.number,
itemRepoId: fork.repoId,
repoPath: registeredRepo.path,
sourceContext,
projectOrigin: undefined,
issueRepo,
run: (_key, options) => {
pending = options.mutate().then(() => options.onSuccess?.())
return pending
},
onStateChange: vi.fn(),
patchProjectRowIfNeeded: vi.fn(),
onMutated: vi.fn()
})
await pending
expect(fixture.apiUpdate).toHaveBeenCalledWith(
expect.objectContaining({
updates: expect.objectContaining({ stateReason: 'duplicate', duplicateOf: 42 })
})
)
// The hold covers only the confirmed state, never the duplicate metadata.
expect(
getLastConfirmedClientValue(
getTaskSourceCacheScope(sourceContext),
fork.repoId,
fork.id,
'state'
)
).toBe('closed')
})
it('keeps ordinary metadata caches distinct by canonical repository and host', () => {
const identities = [
issueRepo,
{ ...issueRepo, owner: 'upstream-owner' },
{ ...issueRepo, host: 'ghe.example:8443' }
]
for (const ownerRepo of identities) {
useRepoLabels(registeredRepo.path, registeredRepo.id, { ownerRepo })
useRepoAssignees(registeredRepo.path, registeredRepo.id, { ownerRepo })
}
expect(new Set(fixture.loads.filter((_, i) => i % 2 === 0).map((load) => load.key)).size).toBe(3)
expect(new Set(fixture.loads.filter((_, i) => i % 2 === 1).map((load) => load.key)).size).toBe(3)
})
it('keeps metadata requests without an explicit target compatible', async () => {
useRepoLabels(registeredRepo.path, registeredRepo.id)
useRepoAssignees(registeredRepo.path, registeredRepo.id)
for (const request of fixture.loads) {
await request.load()
}
expect(fixture.loads.map((load) => load.key)).toEqual([registeredRepo.id, registeredRepo.id])
expect(
fixture.requests.map((request) => request.args.find((arg) => arg.startsWith('repos/')))
).toEqual([
'repos/upstream-owner/widgets/labels',
'repos/upstream-owner/widgets/assignees?per_page=100'
])
})
it('keeps Project row metadata on the existing slug route', async () => {
const labels = vi.fn().mockResolvedValue({ ok: true, labels: [] })
const users = vi.fn().mockResolvedValue({ ok: true, users: [] })
vi.stubGlobal('window', {
api: { gh: { listLabelsBySlug: labels, listAssignableUsersBySlug: users } }
})
renderEditSection({
item: fork,
repoPath: registeredRepo.path,
repoId: fork.repoId,
sourceContext,
projectOrigin: {
owner: 'project-owner',
repo: 'outside',
host: 'ghe.example',
number: fork.number,
type: 'issue',
projectId: 'project-1',
projectItemId: 'row-1',
cacheKey: 'project-key'
},
localState: 'open',
localLabels: [],
assignees: [],
onStateChange: vi.fn(),
onLabelsChange: vi.fn(),
onMutated: vi.fn(),
onUse: vi.fn()
})
for (const request of fixture.loads.filter((load) => load.key !== null)) {
await request.load()
}
expect(labels).toHaveBeenCalledWith({
owner: 'project-owner',
repo: 'outside',
host: 'ghe.example'
})
expect(users).toHaveBeenCalledWith({
owner: 'project-owner',
repo: 'outside',
host: 'ghe.example'
})
expect(fixture.requests).toEqual([])
})
it('rolls back a rejected fork label without changing the upstream row', async () => {
const store = createTestStore()
store.setState({ repos: [], recordFeatureInteraction: vi.fn() })
fixture.store = store
const key = workItemsCacheKey(registeredRepo.id, 36, '', getTaskSourceCacheScope(sourceContext))
store.setState({ workItemsCache: { [key]: { data: [upstream, fork], fetchedAt: 1 } } })
fixture.gh.mockRejectedValueOnce(new Error('Fixture rejects label'))
let pending = Promise.resolve(false)
const observed: string[][][] = []
runGHEditLabelToggle({
item: fork,
itemNumber: fork.number,
itemRepoId: fork.repoId,
repoPath: registeredRepo.path,
sourceContext,
projectOrigin: undefined,
issueRepo,
label: 'fork-only-label',
localLabels: [],
run: (_key, options) => {
pending = (async () => {
options.onOptimistic?.()
observed.push(store.getState().workItemsCache[key]?.data?.map((row) => row.labels) ?? [])
try {
await options.mutate()
return true
} catch {
options.onRevert?.()
observed.push(store.getState().workItemsCache[key]?.data?.map((row) => row.labels) ?? [])
return false
}
})()
return pending
},
onLabelsChange: vi.fn(),
patchProjectRowIfNeeded: vi.fn(),
onMutated: vi.fn()
})
expect(await pending).toBe(false)
// Without a task record, Tasks copies take only a confirmed change; the dialog keeps its own optimism.
expect(observed).toEqual([
[[], []],
[[], []]
])
})
it('does not fall back to upstream metadata for an invalid explicit repository', async () => {
const invalid = { owner: '../escape', repo: 'widgets', host: 'github.com' }
await expect(listLabels(registeredRepo.path, 'upstream', null, {}, invalid)).resolves.toEqual([])
await expect(
listAssignableUsers(registeredRepo.path, 'upstream', null, {}, invalid)
).resolves.toEqual([])
expect(fixture.requests).toEqual([])
})