Files
orca/config/scripts/github-opened-issue-repository.test.ts
T
Brennan Benson b029fb61ab Keep GitHub Tasks source changes consistent (#37387)
Keep the GitHub Tasks source picker, rows, totals, searches and issue creation tied to the selected project, host and source. Ignore outdated replies and wait for source preference saves before creating an issue. Preserve support for older paired servers.

Tracked in #8726. Tracked in STA-1815.
2026-11-03 13:26:23 -08:00

683 lines
24 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 { create } from 'zustand'
import type { AppState } from '../../src/renderer/src/store/types'
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('@/store', () => ({
useAppStore: Object.assign(
(selector: (state: ReturnType<typeof metadataStore.getState>) => unknown) =>
metadataStore(selector),
{ getState: () => fixture.store?.getState() ?? metadataStore.getState() }
)
}))
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/remotes/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-dialog-work-item'
import { getTaskPageRepoSourceContext } from '../../src/renderer/src/lib/task-repo-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/useGitHubRepoMetadata'
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'
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' }
}
const metadataStore = create<
Pick<AppState, 'repos' | 'patchWorkItem' | 'patchProjectRowContent' | 'recordFeatureInteraction'>
>()(() => ({
repos: [registeredRepo],
patchWorkItem: vi.fn(),
patchProjectRowContent: vi.fn(),
recordFeatureInteraction: vi.fn()
}))
function renderEditSection(props: Parameters<typeof GHEditSection>[0]): void {
renderToStaticMarkup(createElement(GHEditSection, props))
}
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
expectedGhAccount?: Parameters<typeof listLabels>[5]
}
beforeEach(() => {
fixture.store = null
fixture.preference = 'upstream'
metadataStore.setState({ repos: [registeredRepo] })
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,
args.expectedGhAccount
),
listAssignableUsers: (args: MetadataArgs) =>
listAssignableUsers(
args.repoPath,
fixture.preference,
null,
{},
args.ownerRepo,
args.expectedGhAccount
)
}
}
})
})
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) {
function Metadata() {
useRepoLabels(registeredRepo.path, registeredRepo.id, { ownerRepo })
useRepoAssignees(registeredRepo.path, registeredRepo.id, { ownerRepo })
return null
}
renderToStaticMarkup(createElement(Metadata))
}
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 () => {
function Metadata() {
useRepoLabels(registeredRepo.path, registeredRepo.id)
useRepoAssignees(registeredRepo.path, registeredRepo.id)
return null
}
renderToStaticMarkup(createElement(Metadata))
for (const request of fixture.loads) {
await request.load()
}
const scope = JSON.stringify([
'local',
null,
registeredRepo.id,
registeredRepo.path,
registeredRepo.id,
registeredRepo.path,
null,
null,
null,
null,
null,
null,
null
])
expect(fixture.loads.map((load) => load.key)).toEqual([
`${registeredRepo.id}::${scope}`,
`${registeredRepo.id}::${scope}`
])
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([])
})