mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
* New workspace page with agent catalog, composer modal, and terminal integration * fix lint * better blank state * fix: resolve typecheck errors in new-workspace flow - widen activateAndRevealWorktree's issueCommand to accept direct command shape used by NewWorkspacePage, not just the main-process runner-script variant - use a ref object in pty-connection tests to dodge TS narrowing the captured callback to never across the mock closure boundary * fix: bound worktree name auto-suffix loop to prevent OOM in tests The auto-suffix loop in createLocalWorktree had no termination cap. When tests (or a misconfigured env) mocked getBranchConflictKind / getPRForBranch to always return a collision, the loop ran forever and the vitest worker crashed with "Ineffective mark-compacts near heap limit". Cap the search at 100 suffixes, and if all fail, fall back to the original specific error messages (branch already exists / PR already owns the name) instead of a generic failure. Update the two tests that asserted the old throw-on-first-conflict behavior to verify the new auto-suffix path end-to-end.
260 lines
8.1 KiB
TypeScript
260 lines
8.1 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const {
|
|
execFileAsyncMock,
|
|
ghExecFileAsyncMock,
|
|
getOwnerRepoMock,
|
|
gitExecFileAsyncMock,
|
|
acquireMock,
|
|
releaseMock
|
|
} = vi.hoisted(() => ({
|
|
execFileAsyncMock: vi.fn(),
|
|
ghExecFileAsyncMock: vi.fn(),
|
|
getOwnerRepoMock: vi.fn(),
|
|
gitExecFileAsyncMock: vi.fn(),
|
|
acquireMock: vi.fn(),
|
|
releaseMock: vi.fn()
|
|
}))
|
|
|
|
vi.mock('./gh-utils', () => ({
|
|
execFileAsync: execFileAsyncMock,
|
|
ghExecFileAsync: ghExecFileAsyncMock,
|
|
getOwnerRepo: getOwnerRepoMock,
|
|
acquire: acquireMock,
|
|
release: releaseMock,
|
|
_resetOwnerRepoCache: vi.fn()
|
|
}))
|
|
|
|
vi.mock('../git/runner', () => ({
|
|
gitExecFileAsync: gitExecFileAsyncMock
|
|
}))
|
|
|
|
import { getPRForBranch, _resetOwnerRepoCache } from './client'
|
|
|
|
describe('getPRForBranch', () => {
|
|
beforeEach(() => {
|
|
execFileAsyncMock.mockReset()
|
|
ghExecFileAsyncMock.mockReset()
|
|
getOwnerRepoMock.mockReset()
|
|
gitExecFileAsyncMock.mockReset()
|
|
acquireMock.mockReset()
|
|
releaseMock.mockReset()
|
|
acquireMock.mockResolvedValue(undefined)
|
|
_resetOwnerRepoCache()
|
|
})
|
|
|
|
it('queries GitHub by head branch when the remote is on github.com', async () => {
|
|
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
|
ghExecFileAsyncMock.mockResolvedValueOnce({
|
|
stdout: JSON.stringify([
|
|
{
|
|
number: 42,
|
|
title: 'Fix PR discovery',
|
|
state: 'OPEN',
|
|
url: 'https://github.com/acme/widgets/pull/42',
|
|
statusCheckRollup: [],
|
|
updatedAt: '2026-03-28T00:00:00Z',
|
|
isDraft: false,
|
|
mergeable: 'MERGEABLE',
|
|
baseRefName: 'main',
|
|
headRefName: 'feature/test',
|
|
baseRefOid: 'base-oid',
|
|
headRefOid: 'head-oid'
|
|
}
|
|
])
|
|
})
|
|
|
|
const pr = await getPRForBranch('/repo-root', 'refs/heads/feature/test')
|
|
|
|
expect(getOwnerRepoMock).toHaveBeenCalledWith('/repo-root')
|
|
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
|
[
|
|
'pr',
|
|
'list',
|
|
'--repo',
|
|
'acme/widgets',
|
|
'--head',
|
|
'feature/test',
|
|
'--state',
|
|
'all',
|
|
'--limit',
|
|
'1',
|
|
'--json',
|
|
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
|
|
],
|
|
{ cwd: '/repo-root' }
|
|
)
|
|
expect(pr?.number).toBe(42)
|
|
expect(pr?.state).toBe('open')
|
|
expect(pr?.mergeable).toBe('MERGEABLE')
|
|
})
|
|
|
|
it('falls back to gh pr view when the remote cannot be resolved to GitHub', async () => {
|
|
getOwnerRepoMock.mockResolvedValueOnce(null)
|
|
ghExecFileAsyncMock.mockResolvedValueOnce({
|
|
stdout: JSON.stringify({
|
|
number: 7,
|
|
title: 'Fallback lookup',
|
|
state: 'OPEN',
|
|
url: 'https://example.com/pr/7',
|
|
statusCheckRollup: [],
|
|
updatedAt: '2026-03-28T00:00:00Z',
|
|
isDraft: true,
|
|
mergeable: 'CONFLICTING',
|
|
baseRefName: 'main',
|
|
headRefName: 'feature/test',
|
|
baseRefOid: 'base-oid',
|
|
headRefOid: 'head-oid'
|
|
})
|
|
})
|
|
|
|
const pr = await getPRForBranch('/non-github-repo', 'feature/test')
|
|
|
|
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
|
[
|
|
'pr',
|
|
'view',
|
|
'feature/test',
|
|
'--json',
|
|
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
|
|
],
|
|
{ cwd: '/non-github-repo' }
|
|
)
|
|
expect(pr?.number).toBe(7)
|
|
expect(pr?.state).toBe('draft')
|
|
expect(pr?.mergeable).toBe('CONFLICTING')
|
|
})
|
|
|
|
it('derives a read-only conflict summary for conflicting PRs when the base ref exists locally', async () => {
|
|
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
|
ghExecFileAsyncMock.mockResolvedValueOnce({
|
|
stdout: JSON.stringify([
|
|
{
|
|
number: 42,
|
|
title: 'Fix PR discovery',
|
|
state: 'OPEN',
|
|
url: 'https://github.com/acme/widgets/pull/42',
|
|
statusCheckRollup: [],
|
|
updatedAt: '2026-03-28T00:00:00Z',
|
|
isDraft: false,
|
|
mergeable: 'CONFLICTING',
|
|
baseRefName: 'main',
|
|
headRefName: 'feature/test',
|
|
baseRefOid: 'base-oid',
|
|
headRefOid: 'head-oid'
|
|
}
|
|
])
|
|
})
|
|
gitExecFileAsyncMock
|
|
.mockResolvedValueOnce({ stdout: '' })
|
|
.mockResolvedValueOnce({ stdout: 'latest-base-oid\n' })
|
|
.mockResolvedValueOnce({ stdout: 'merge-base-oid\n' })
|
|
.mockResolvedValueOnce({ stdout: '3\n' })
|
|
.mockResolvedValueOnce({ stdout: 'result-tree-oid\u0000src/a.ts\u0000src/b.ts\u0000' })
|
|
|
|
const pr = await getPRForBranch('/repo-root', 'feature/test')
|
|
|
|
expect(pr?.conflictSummary).toEqual({
|
|
baseRef: 'main',
|
|
baseCommit: 'latest-',
|
|
commitsBehind: 3,
|
|
files: ['src/a.ts', 'src/b.ts']
|
|
})
|
|
})
|
|
|
|
it('keeps conflicted file paths when git merge-tree exits 1 with stdout', async () => {
|
|
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
|
ghExecFileAsyncMock.mockResolvedValueOnce({
|
|
stdout: JSON.stringify([
|
|
{
|
|
number: 42,
|
|
title: 'Fix PR discovery',
|
|
state: 'OPEN',
|
|
url: 'https://github.com/acme/widgets/pull/42',
|
|
statusCheckRollup: [],
|
|
updatedAt: '2026-03-28T00:00:00Z',
|
|
isDraft: false,
|
|
mergeable: 'CONFLICTING',
|
|
baseRefName: 'main',
|
|
headRefName: 'feature/test',
|
|
baseRefOid: 'base-oid',
|
|
headRefOid: 'head-oid'
|
|
}
|
|
])
|
|
})
|
|
gitExecFileAsyncMock
|
|
.mockResolvedValueOnce({ stdout: '' })
|
|
.mockResolvedValueOnce({ stdout: 'latest-base-oid\n' })
|
|
.mockResolvedValueOnce({ stdout: 'merge-base-oid\n' })
|
|
.mockResolvedValueOnce({ stdout: '2\n' })
|
|
.mockRejectedValueOnce({
|
|
stdout: 'result-tree-oid\u0000src/conflict.ts\u0000'
|
|
})
|
|
|
|
const pr = await getPRForBranch('/repo-root', 'feature/test')
|
|
|
|
expect(pr?.conflictSummary?.files).toEqual(['src/conflict.ts'])
|
|
})
|
|
|
|
it('falls back to GitHub baseRefOid when fetching or resolving the base ref fails', async () => {
|
|
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
|
ghExecFileAsyncMock.mockResolvedValueOnce({
|
|
stdout: JSON.stringify([
|
|
{
|
|
number: 42,
|
|
title: 'Fix PR discovery',
|
|
state: 'OPEN',
|
|
url: 'https://github.com/acme/widgets/pull/42',
|
|
statusCheckRollup: [],
|
|
updatedAt: '2026-03-28T00:00:00Z',
|
|
isDraft: false,
|
|
mergeable: 'CONFLICTING',
|
|
baseRefName: 'main',
|
|
headRefName: 'feature/test',
|
|
baseRefOid: 'base-oid',
|
|
headRefOid: 'head-oid'
|
|
}
|
|
])
|
|
})
|
|
gitExecFileAsyncMock
|
|
.mockRejectedValueOnce(new Error('fetch failed'))
|
|
.mockRejectedValueOnce(new Error('missing refs/remotes/origin/main'))
|
|
.mockRejectedValueOnce(new Error('missing origin/main'))
|
|
.mockResolvedValueOnce({ stdout: 'merge-base-oid\n' })
|
|
.mockResolvedValueOnce({ stdout: '1\n' })
|
|
.mockResolvedValueOnce({ stdout: 'result-tree-oid\u0000src/fallback.ts\u0000' })
|
|
|
|
const pr = await getPRForBranch('/repo-root', 'feature/test')
|
|
|
|
expect(pr?.conflictSummary).toEqual({
|
|
baseRef: 'main',
|
|
baseCommit: 'base-oi',
|
|
commitsBehind: 1,
|
|
files: ['src/fallback.ts']
|
|
})
|
|
})
|
|
|
|
it('returns null for empty branch (e.g. during rebase with detached HEAD)', async () => {
|
|
const pr = await getPRForBranch('/repo-root', '')
|
|
expect(pr).toBeNull()
|
|
// Should not call gh at all
|
|
expect(execFileAsyncMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns null for refs/heads/ only branch (detached after strip)', async () => {
|
|
const pr = await getPRForBranch('/repo-root', 'refs/heads/')
|
|
expect(pr).toBeNull()
|
|
expect(execFileAsyncMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns null when pr list returns an empty array', async () => {
|
|
execFileAsyncMock
|
|
.mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' })
|
|
.mockResolvedValueOnce({ stdout: '[]' })
|
|
|
|
const pr = await getPRForBranch('/repo-root', 'no-pr-branch')
|
|
|
|
expect(pr).toBeNull()
|
|
})
|
|
})
|