mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 01:57:57 +00:00
* fix(mobile): gate PR merge and linking on desktop's shared rules Merge state moves to src/shared so the phone stops offering Merge on drafts, conflicts and blocked PRs, and stops letting branch PRs override explicit links. * fix(mobile): keep stack merges and stale-link recovery in the PR panel Mobile now applies desktop's stack override and drops a linked PR the branch has moved past, and only offers Ready for review when a relaying desktop also forwards it. * fix(mobile): persist stale PR link clears and add a merge recheck The phone now writes desktop's link clear instead of rereading the stale link, and a blocked Merge can be rechecked since nothing on mobile polls PR state. * fix(mobile): guard stale PR link clears like desktop Re-reads the worktree before clearing so a link chosen after the lookup survives, and localizes the merge-state strings the lift exposed to the coverage gate. * test(mobile): drop identity test for worktree link params * refactor(mobile): share one stack-merge decision and trim PR panel wiring * fix(mobile): default worktree bare/archived flags for the stale-link guard * fix(mobile): label the PR merge button from its default merge method
246 lines
7.6 KiB
TypeScript
246 lines
7.6 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
||
import { PrActionsEngine, type PrActionMutations } from './pr-actions-engine'
|
||
import type { GitHubPrMutationOutcome } from './github-pr-mutations'
|
||
|
||
function deferred<T>() {
|
||
let resolve!: (value: T) => void
|
||
const promise = new Promise<T>((r) => {
|
||
resolve = r
|
||
})
|
||
return { promise, resolve }
|
||
}
|
||
|
||
function makeEngine(mutations: Partial<PrActionMutations>) {
|
||
const ok = async (): Promise<GitHubPrMutationOutcome> => ({ ok: true })
|
||
return new PrActionsEngine({
|
||
mutations: {
|
||
mergePR: ok,
|
||
setPRAutoMerge: ok,
|
||
updatePRState: ok,
|
||
markReadyForReview: ok,
|
||
requestReviewers: ok,
|
||
removeReviewers: ok,
|
||
rerunChecks: ok,
|
||
...mutations
|
||
},
|
||
prNumber: 1,
|
||
refetch: () => {},
|
||
onChange: () => {}
|
||
})
|
||
}
|
||
|
||
describe('PrActionsEngine — scoped busy clear (overlapping actions)', () => {
|
||
it('a late-resolving action does not clear a newer action busy state', async () => {
|
||
const slow = deferred<GitHubPrMutationOutcome>()
|
||
const newer = deferred<GitHubPrMutationOutcome>()
|
||
const engine = makeEngine({
|
||
mergePR: () => slow.promise,
|
||
updatePRState: () => newer.promise
|
||
})
|
||
|
||
const mergePromise = engine.merge()
|
||
expect(engine.isBusy({ kind: 'merge' })).toBe(true)
|
||
|
||
const statePromise = engine.updateState('closed')
|
||
expect(engine.isBusy({ kind: 'state' })).toBe(true)
|
||
|
||
// Late merge completion must not clear a newer action’s busy state.
|
||
slow.resolve({ ok: true })
|
||
await mergePromise
|
||
expect(engine.isBusy({ kind: 'state' })).toBe(true)
|
||
|
||
newer.resolve({ ok: true })
|
||
await statePromise
|
||
expect(engine.busy).toBeNull()
|
||
})
|
||
|
||
it('clears its own busy when it is still the owner', async () => {
|
||
const engine = makeEngine({})
|
||
await engine.merge()
|
||
expect(engine.busy).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('PrActionsEngine — transport-rejection-normalized outcomes settle cleanly', () => {
|
||
it('routes a { ok:false } outcome to error and clears busy', async () => {
|
||
const onChange = vi.fn()
|
||
const engine = new PrActionsEngine({
|
||
mutations: {
|
||
mergePR: async () => ({ ok: false, error: 'socket hung up' }),
|
||
setPRAutoMerge: async () => ({ ok: true }),
|
||
updatePRState: async () => ({ ok: true }),
|
||
markReadyForReview: async () => ({ ok: true }),
|
||
requestReviewers: async () => ({ ok: true }),
|
||
removeReviewers: async () => ({ ok: true }),
|
||
rerunChecks: async () => ({ ok: true })
|
||
},
|
||
prNumber: 1,
|
||
refetch: () => {},
|
||
onChange
|
||
})
|
||
await engine.merge()
|
||
expect(engine.error).toBe('socket hung up')
|
||
expect(engine.busy).toBeNull()
|
||
})
|
||
|
||
it('surfaces a refetch throw as error without rejecting the action promise', async () => {
|
||
const engine = new PrActionsEngine({
|
||
mutations: {
|
||
mergePR: async () => ({ ok: true }),
|
||
setPRAutoMerge: async () => ({ ok: true }),
|
||
updatePRState: async () => ({ ok: true }),
|
||
markReadyForReview: async () => ({ ok: true }),
|
||
requestReviewers: async () => ({ ok: true }),
|
||
removeReviewers: async () => ({ ok: true }),
|
||
rerunChecks: async () => ({ ok: true })
|
||
},
|
||
prNumber: 1,
|
||
refetch: async () => {
|
||
throw new Error('refresh failed')
|
||
},
|
||
onChange: () => {}
|
||
})
|
||
await expect(engine.merge()).resolves.toBeUndefined()
|
||
expect(engine.error).toBe('refresh failed')
|
||
expect(engine.busy).toBeNull()
|
||
})
|
||
|
||
it('does not notify on no-op setError(null) at action start', async () => {
|
||
const onChange = vi.fn()
|
||
const engine = new PrActionsEngine({
|
||
mutations: {
|
||
mergePR: async () => ({ ok: true }),
|
||
setPRAutoMerge: async () => ({ ok: true }),
|
||
updatePRState: async () => ({ ok: true }),
|
||
markReadyForReview: async () => ({ ok: true }),
|
||
requestReviewers: async () => ({ ok: true }),
|
||
removeReviewers: async () => ({ ok: true }),
|
||
rerunChecks: async () => ({ ok: true })
|
||
},
|
||
prNumber: 1,
|
||
refetch: () => {},
|
||
onChange
|
||
})
|
||
// Already-clear error state must not add a redundant action-start notification.
|
||
onChange.mockClear()
|
||
const p = engine.merge()
|
||
expect(onChange).toHaveBeenCalledTimes(1)
|
||
await p
|
||
})
|
||
})
|
||
|
||
describe('PrActionsEngine — PR identity changes', () => {
|
||
it('clears optimistic state when the engine points at a different PR', async () => {
|
||
const slow = deferred<GitHubPrMutationOutcome>()
|
||
const mutations: PrActionMutations = {
|
||
mergePR: async () => ({ ok: true }),
|
||
setPRAutoMerge: async () => slow.promise,
|
||
updatePRState: async () => ({ ok: true }),
|
||
markReadyForReview: async () => ({ ok: true }),
|
||
requestReviewers: async () => ({ ok: true }),
|
||
removeReviewers: async () => ({ ok: true }),
|
||
rerunChecks: async () => ({ ok: true })
|
||
}
|
||
const refetch = vi.fn()
|
||
const onChange = vi.fn()
|
||
const engine = new PrActionsEngine({
|
||
mutations,
|
||
prNumber: 1,
|
||
refetch,
|
||
onChange
|
||
})
|
||
|
||
const action = engine.setAutoMerge(true)
|
||
expect(engine.resolveAutoMerge(false)).toBe(true)
|
||
|
||
engine.updateConfig({
|
||
mutations,
|
||
prNumber: 2,
|
||
refetch,
|
||
onChange
|
||
})
|
||
expect(engine.resolveAutoMerge(false)).toBe(false)
|
||
expect(engine.busy).toBeNull()
|
||
|
||
slow.resolve({ ok: true })
|
||
await action
|
||
expect(refetch).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('clears reviewer optimism when switching PR identity', async () => {
|
||
const slow = deferred<GitHubPrMutationOutcome>()
|
||
const mutations: PrActionMutations = {
|
||
mergePR: async () => ({ ok: true }),
|
||
setPRAutoMerge: async () => ({ ok: true }),
|
||
updatePRState: async () => ({ ok: true }),
|
||
markReadyForReview: async () => ({ ok: true }),
|
||
requestReviewers: async () => slow.promise,
|
||
removeReviewers: async () => ({ ok: true }),
|
||
rerunChecks: async () => ({ ok: true })
|
||
}
|
||
const refetch = vi.fn()
|
||
const onChange = vi.fn()
|
||
const engine = new PrActionsEngine({
|
||
mutations,
|
||
prNumber: 1,
|
||
refetch,
|
||
onChange
|
||
})
|
||
|
||
const action = engine.requestReviewer('alice')
|
||
expect(engine.resolveReviewerRequested('alice', false)).toBe(true)
|
||
|
||
engine.updateConfig({
|
||
mutations,
|
||
prNumber: 2,
|
||
refetch,
|
||
onChange
|
||
})
|
||
expect(engine.resolveReviewerRequested('alice', false)).toBe(false)
|
||
|
||
slow.resolve({ ok: true })
|
||
await action
|
||
expect(refetch).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('clears in-flight state when only the GitHub host changes', async () => {
|
||
const slow = deferred<GitHubPrMutationOutcome>()
|
||
const mutations: PrActionMutations = {
|
||
mergePR: async () => ({ ok: true }),
|
||
setPRAutoMerge: async () => slow.promise,
|
||
updatePRState: async () => ({ ok: true }),
|
||
markReadyForReview: async () => ({ ok: true }),
|
||
requestReviewers: async () => ({ ok: true }),
|
||
removeReviewers: async () => ({ ok: true }),
|
||
rerunChecks: async () => ({ ok: true })
|
||
}
|
||
const refetch = vi.fn()
|
||
const onChange = vi.fn()
|
||
const baseConfig = {
|
||
mutations,
|
||
prNumber: 1,
|
||
refetch,
|
||
onChange
|
||
}
|
||
const engine = new PrActionsEngine({
|
||
...baseConfig,
|
||
prRepo: { owner: 'acme', repo: 'widgets' }
|
||
})
|
||
|
||
const action = engine.setAutoMerge(true)
|
||
expect(engine.resolveAutoMerge(false)).toBe(true)
|
||
expect(engine.isBusy({ kind: 'autoMerge' })).toBe(true)
|
||
|
||
engine.updateConfig({
|
||
...baseConfig,
|
||
prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' }
|
||
})
|
||
expect(engine.resolveAutoMerge(false)).toBe(false)
|
||
expect(engine.busy).toBeNull()
|
||
|
||
slow.resolve({ ok: true })
|
||
await action
|
||
expect(refetch).not.toHaveBeenCalled()
|
||
})
|
||
})
|