mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
* 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>
638 lines
23 KiB
TypeScript
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([])
|
|
})
|