mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 08:01:56 +00:00
@@ -21,6 +21,11 @@ vi.mock('./gh-utils', () => ({
|
||||
getOwnerRepo: getOwnerRepoMock,
|
||||
getIssueOwnerRepo: vi.fn(),
|
||||
getOwnerRepoForRemote: vi.fn(),
|
||||
githubRepoContext: vi.fn((repoPath: string, connectionId?: string | null) => ({
|
||||
repoPath,
|
||||
connectionId: connectionId ?? null
|
||||
})),
|
||||
ghRepoExecOptions: vi.fn((context: { repoPath: string }) => ({ cwd: context.repoPath })),
|
||||
gitExecFileAsync: vi.fn(),
|
||||
extractExecError: extractExecErrorMock,
|
||||
parseGitHubOwnerRepo: vi.fn(),
|
||||
|
||||
@@ -1362,7 +1362,9 @@ async function findOpenPRByHeadBase(args: {
|
||||
ownerRepo: OwnerRepo
|
||||
head: string
|
||||
base: string
|
||||
connectionId?: string | null
|
||||
}): Promise<{ number: number; url: string } | null> {
|
||||
const context = githubRepoContext(args.repoPath, args.connectionId)
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
'pr',
|
||||
@@ -1380,7 +1382,7 @@ async function findOpenPRByHeadBase(args: {
|
||||
'--json',
|
||||
'number,url'
|
||||
],
|
||||
{ cwd: args.repoPath }
|
||||
ghRepoExecOptions(context)
|
||||
)
|
||||
const list = JSON.parse(stdout) as { number?: number; url?: string }[]
|
||||
if (list.length !== 1 || !list[0]?.number || !list[0]?.url) {
|
||||
@@ -1391,7 +1393,8 @@ async function findOpenPRByHeadBase(args: {
|
||||
|
||||
export async function createGitHubPullRequest(
|
||||
repoPath: string,
|
||||
input: CreateHostedReviewInput
|
||||
input: CreateHostedReviewInput,
|
||||
connectionId?: string | null
|
||||
): Promise<CreateHostedReviewResult> {
|
||||
if (input.provider !== 'github') {
|
||||
return {
|
||||
@@ -1401,7 +1404,7 @@ export async function createGitHubPullRequest(
|
||||
}
|
||||
}
|
||||
|
||||
const ownerRepo = await getOwnerRepo(repoPath)
|
||||
const ownerRepo = await getOwnerRepo(repoPath, connectionId)
|
||||
if (!ownerRepo) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -1452,8 +1455,9 @@ export async function createGitHubPullRequest(
|
||||
createArgs.push('--draft')
|
||||
}
|
||||
try {
|
||||
const context = githubRepoContext(repoPath, connectionId)
|
||||
const { stdout } = await ghExecFileAsync(createArgs, {
|
||||
cwd: repoPath,
|
||||
...ghRepoExecOptions(context),
|
||||
timeout: 60_000,
|
||||
idempotent: false
|
||||
})
|
||||
@@ -1462,7 +1466,9 @@ export async function createGitHubPullRequest(
|
||||
return { ok: true, ...created }
|
||||
}
|
||||
const found = head
|
||||
? await findOpenPRByHeadBase({ repoPath, ownerRepo, head, base }).catch(() => null)
|
||||
? await findOpenPRByHeadBase({ repoPath, ownerRepo, head, base, connectionId }).catch(
|
||||
() => null
|
||||
)
|
||||
: null
|
||||
if (found) {
|
||||
return { ok: true, ...found }
|
||||
@@ -1479,9 +1485,13 @@ export async function createGitHubPullRequest(
|
||||
(classified.code === 'already_exists' || classified.code === 'unknown_completion') &&
|
||||
head
|
||||
) {
|
||||
const existing = await findOpenPRByHeadBase({ repoPath, ownerRepo, head, base }).catch(
|
||||
() => null
|
||||
)
|
||||
const existing = await findOpenPRByHeadBase({
|
||||
repoPath,
|
||||
ownerRepo,
|
||||
head,
|
||||
base,
|
||||
connectionId
|
||||
}).catch(() => null)
|
||||
if (existing) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -3006,3 +3016,53 @@ export async function updatePRTitle(
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePRDetails(
|
||||
repoPath: string,
|
||||
prNumber: number,
|
||||
updates: { title?: string; body?: string },
|
||||
connectionId?: string | null,
|
||||
prRepo?: OwnerRepo | null
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId))
|
||||
const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId))
|
||||
if (!ownerRepo) {
|
||||
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
|
||||
}
|
||||
|
||||
const fields: string[] = []
|
||||
if (updates.title !== undefined) {
|
||||
const title = updates.title.trim()
|
||||
if (!title) {
|
||||
return { ok: false, error: 'Title is required' }
|
||||
}
|
||||
fields.push(`title=${title}`)
|
||||
}
|
||||
if (updates.body !== undefined) {
|
||||
fields.push(`body=${updates.body}`)
|
||||
}
|
||||
if (fields.length === 0) {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
'-X',
|
||||
'PATCH',
|
||||
`repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}`,
|
||||
...fields.flatMap((field) => ['--raw-field', field])
|
||||
],
|
||||
ghOptions
|
||||
)
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error'
|
||||
return { ok: false, error: classifyGhError(message).message }
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -839,7 +839,7 @@ export async function getWorkItemDetailsBySlug(
|
||||
// Why: PR files/checks/review-thread tabs depend on a local repo path and
|
||||
// are out of Project-mode slug scope for v1. Omit them here; the dialog
|
||||
// branches on their absence and hides those tabs.
|
||||
...(args.type === 'issue' ? { assignees } : {})
|
||||
assignees
|
||||
}
|
||||
return { ok: true, details }
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ vi.mock('./gl-utils', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
import { getMergeRequest, getMergeRequestForBranch, listMergeRequests } from './client'
|
||||
import { getMergeRequest, getMergeRequestForBranch, listMergeRequests, updateMR } from './client'
|
||||
|
||||
describe('gitlab client — MR operations', () => {
|
||||
beforeEach(() => {
|
||||
@@ -48,6 +48,10 @@ describe('gitlab client — MR operations', () => {
|
||||
releaseMock.mockReset()
|
||||
acquireMock.mockResolvedValue(undefined)
|
||||
getGlabKnownHostsMock.mockResolvedValue(['gitlab.com'])
|
||||
resolveIssueSourceMock.mockResolvedValue({
|
||||
source: { host: 'gitlab.com', path: 'g/p' },
|
||||
fellBack: false
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMergeRequest', () => {
|
||||
@@ -193,15 +197,14 @@ describe('gitlab client — MR operations', () => {
|
||||
describe('listMergeRequests', () => {
|
||||
beforeEach(() => {
|
||||
resolveIssueSourceMock.mockImplementation(async () => ({
|
||||
source: await getProjectRefMock(),
|
||||
source: { host: 'gitlab.com', path: 'g/p' },
|
||||
fellBack: false
|
||||
}))
|
||||
})
|
||||
|
||||
it('returns MRs via glab CLI', async () => {
|
||||
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
it('returns MRs via the GitLab API', async () => {
|
||||
glabApiWithHeadersMock.mockResolvedValueOnce({
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 100,
|
||||
iid: 1,
|
||||
@@ -215,7 +218,8 @@ describe('gitlab client — MR operations', () => {
|
||||
source_project_id: 5,
|
||||
target_project_id: 5
|
||||
}
|
||||
])
|
||||
]),
|
||||
headers: { 'x-total': '1', 'x-total-pages': '1' }
|
||||
})
|
||||
|
||||
const result = await listMergeRequests('/repo', 'opened', 1, 20)
|
||||
@@ -231,58 +235,35 @@ describe('gitlab client — MR operations', () => {
|
||||
isCrossRepository: false,
|
||||
repoId: 'g/p'
|
||||
})
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
expect(glabApiWithHeadersMock).toHaveBeenCalledWith(
|
||||
[
|
||||
'mr',
|
||||
'list',
|
||||
'--output',
|
||||
'json',
|
||||
'--per-page',
|
||||
'20',
|
||||
'--page',
|
||||
'1',
|
||||
'--order',
|
||||
'updated_at',
|
||||
'--sort',
|
||||
'desc',
|
||||
'--repo',
|
||||
'https://gitlab.com/g/p'
|
||||
'projects/g%2Fp/merge_requests?page=1&per_page=20&order_by=updated_at&sort=desc&with_merge_status_recheck=false&state=opened'
|
||||
],
|
||||
{ cwd: '/repo' }
|
||||
)
|
||||
})
|
||||
|
||||
it("passes --all when state='all'", async () => {
|
||||
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
|
||||
it("omits state when state='all'", async () => {
|
||||
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
|
||||
|
||||
await listMergeRequests('/repo', 'all', 1, 20)
|
||||
const callArgs = glabExecFileAsyncMock.mock.calls[0][0] as string[]
|
||||
expect(callArgs).toContain('--all')
|
||||
expect(callArgs).not.toContain('--opened')
|
||||
expect(callArgs).not.toContain('--merged')
|
||||
expect(callArgs).not.toContain('--closed')
|
||||
const callArgs = glabApiWithHeadersMock.mock.calls[0][0] as string[]
|
||||
expect(callArgs[0]).not.toContain('state=')
|
||||
})
|
||||
|
||||
it('passes through Open / Merged / Closed states as flags', async () => {
|
||||
it('passes through Open / Merged / Closed states as API params', async () => {
|
||||
for (const state of ['opened', 'merged', 'closed'] as const) {
|
||||
glabExecFileAsyncMock.mockReset()
|
||||
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
|
||||
glabApiWithHeadersMock.mockReset()
|
||||
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
|
||||
await listMergeRequests('/repo', state, 1, 20)
|
||||
const callArgs = glabExecFileAsyncMock.mock.calls[0][0] as string[]
|
||||
if (state === 'opened') {
|
||||
expect(callArgs).not.toContain('--opened')
|
||||
} else {
|
||||
expect(callArgs).toContain(`--${state}`)
|
||||
}
|
||||
const callArgs = glabApiWithHeadersMock.mock.calls[0][0] as string[]
|
||||
expect(callArgs[0]).toContain(`state=${state}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('flags fork MRs as cross-repository', async () => {
|
||||
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
glabApiWithHeadersMock.mockResolvedValueOnce({
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 200,
|
||||
iid: 2,
|
||||
@@ -293,47 +274,78 @@ describe('gitlab client — MR operations', () => {
|
||||
source_project_id: 11,
|
||||
target_project_id: 5
|
||||
}
|
||||
])
|
||||
]),
|
||||
headers: {}
|
||||
})
|
||||
|
||||
const result = await listMergeRequests('/repo', 'opened', 1, 20)
|
||||
expect(result.items[0].isCrossRepository).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to CLI when project ref is unresolved', async () => {
|
||||
getProjectRefMock.mockResolvedValueOnce(null)
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
id: 100,
|
||||
iid: 1,
|
||||
title: 'fallback mr',
|
||||
state: 'opened',
|
||||
web_url: 'https://gitlab.example.com/fallback/-/merge_requests/1',
|
||||
updated_at: '2026-05-05',
|
||||
source_branch: 'feat',
|
||||
target_branch: 'main',
|
||||
author: { username: 'alice' },
|
||||
source_project_id: 5,
|
||||
target_project_id: 5
|
||||
}
|
||||
])
|
||||
it('returns a not_found envelope when project ref is unresolved', async () => {
|
||||
resolveIssueSourceMock.mockResolvedValueOnce({
|
||||
source: null,
|
||||
fellBack: false
|
||||
})
|
||||
const result = await listMergeRequests('/repo', 'opened')
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0].title).toBe('fallback mr')
|
||||
const callArgs = glabExecFileAsyncMock.mock.calls[0][0] as string[]
|
||||
expect(callArgs).toContain('--order')
|
||||
expect(callArgs).toContain('updated_at')
|
||||
expect(callArgs).not.toContain('--repo')
|
||||
expect(result.items).toEqual([])
|
||||
expect(result.error?.type).toBe('not_found')
|
||||
expect(glabApiWithHeadersMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('classifies CLI errors into the result envelope', async () => {
|
||||
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
|
||||
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden'))
|
||||
it('classifies API errors into the result envelope', async () => {
|
||||
glabApiWithHeadersMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden'))
|
||||
const result = await listMergeRequests('/repo', 'opened')
|
||||
expect(result.error?.type).toBe('permission_denied')
|
||||
expect(result.items).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateMR', () => {
|
||||
beforeEach(() => {
|
||||
resolveIssueSourceMock.mockImplementation(async () => ({
|
||||
source: { host: 'git.internal', path: 'g/p' },
|
||||
fellBack: false
|
||||
}))
|
||||
})
|
||||
|
||||
it('updates title, body, and labels through the selected SSH GitLab host', async () => {
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '{}' })
|
||||
|
||||
await expect(
|
||||
updateMR(
|
||||
'/repo',
|
||||
12,
|
||||
{
|
||||
title: 'Renamed',
|
||||
body: 'Updated body',
|
||||
addLabels: ['bug'],
|
||||
removeLabels: ['stale']
|
||||
},
|
||||
'upstream',
|
||||
'conn-1'
|
||||
)
|
||||
).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
[
|
||||
'api',
|
||||
'--hostname',
|
||||
'git.internal',
|
||||
'-X',
|
||||
'PUT',
|
||||
'projects/g%2Fp/merge_requests/12',
|
||||
'-f',
|
||||
'title=Renamed',
|
||||
'-f',
|
||||
'description=Updated body',
|
||||
'-f',
|
||||
'add_labels=bug',
|
||||
'-f',
|
||||
'remove_labels=stale'
|
||||
],
|
||||
{}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,22 +53,21 @@ describe('gitlab client — combined listWorkItems', () => {
|
||||
})
|
||||
|
||||
it('merges MRs + issues and sorts by updatedAt desc', async () => {
|
||||
glabExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'mr') {
|
||||
return {
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
id: 100,
|
||||
iid: 1,
|
||||
title: 'older mr',
|
||||
state: 'opened',
|
||||
updated_at: '2026-05-05T00:00:00Z',
|
||||
source_project_id: 5,
|
||||
target_project_id: 5
|
||||
}
|
||||
])
|
||||
glabApiWithHeadersMock.mockResolvedValueOnce({
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 100,
|
||||
iid: 1,
|
||||
title: 'older mr',
|
||||
state: 'opened',
|
||||
updated_at: '2026-05-05T00:00:00Z',
|
||||
source_project_id: 5,
|
||||
target_project_id: 5
|
||||
}
|
||||
}
|
||||
]),
|
||||
headers: {}
|
||||
})
|
||||
glabExecFileAsyncMock.mockImplementation(async () => {
|
||||
return {
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
@@ -89,13 +88,13 @@ describe('gitlab client — combined listWorkItems', () => {
|
||||
})
|
||||
|
||||
it("skips the issues fetch when state === 'merged'", async () => {
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
|
||||
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
|
||||
|
||||
await listWorkItems('/repo', 'merged', 1, 20)
|
||||
// Why: the merged-state filter doesn't apply to issues (issues
|
||||
// don't have a merged lifecycle), so the IPC must not even spawn
|
||||
// the issues read. Verifies the listIssues path was not taken.
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes the closed state through to the issues fetch', async () => {
|
||||
@@ -104,11 +103,20 @@ describe('gitlab client — combined listWorkItems', () => {
|
||||
})
|
||||
|
||||
await listWorkItems('/repo', 'closed', 1, 20)
|
||||
const issuesCalls = glabExecFileAsyncMock.mock.calls.filter(
|
||||
(call) => (call[0] as string[])[0] === 'api'
|
||||
)
|
||||
expect(issuesCalls).toHaveLength(1)
|
||||
expect((issuesCalls[0][0] as string[])[1]).toContain('state=closed')
|
||||
const issuesCallPath = glabExecFileAsyncMock.mock.calls[0][0] as string[]
|
||||
expect(issuesCallPath.at(-1)).toContain('state=closed')
|
||||
})
|
||||
|
||||
it('passes search queries through to merge request and issue fetches', async () => {
|
||||
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
|
||||
|
||||
await listWorkItems('/repo', 'opened', 1, 20, undefined, 'ambiguous selector')
|
||||
|
||||
const mergeRequestCallPath = glabApiWithHeadersMock.mock.calls[0][0] as string[]
|
||||
const issuesCallPath = glabExecFileAsyncMock.mock.calls[0][0] as string[]
|
||||
expect(mergeRequestCallPath[0]).toContain('search=ambiguous%20selector')
|
||||
expect(issuesCallPath.at(-1)).toContain('search=ambiguous%20selector')
|
||||
})
|
||||
|
||||
it("omits the state param when 'all'", async () => {
|
||||
@@ -117,11 +125,26 @@ describe('gitlab client — combined listWorkItems', () => {
|
||||
})
|
||||
|
||||
await listWorkItems('/repo', 'all', 1, 20)
|
||||
const issuesCalls = glabExecFileAsyncMock.mock.calls.filter(
|
||||
(call) => (call[0] as string[])[0] === 'api'
|
||||
)
|
||||
expect(issuesCalls).toHaveLength(1)
|
||||
expect((issuesCalls[0][0] as string[])[1]).not.toContain('state=')
|
||||
const issuesCallPath = glabExecFileAsyncMock.mock.calls[0][0] as string[]
|
||||
expect(issuesCallPath.at(-1)).not.toContain('state=')
|
||||
})
|
||||
|
||||
it('routes issue list fetches through the selected SSH GitLab host', async () => {
|
||||
resolveIssueSourceMock.mockResolvedValueOnce({
|
||||
source: { host: 'git.internal', path: 'g/p' },
|
||||
fellBack: false
|
||||
})
|
||||
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
|
||||
|
||||
await listWorkItems('/repo', 'opened', 1, 20, 'upstream', undefined, 'conn-1')
|
||||
|
||||
expect(glabExecFileAsyncMock.mock.calls[0][0]).toEqual([
|
||||
'api',
|
||||
'--hostname',
|
||||
'git.internal',
|
||||
'projects/g%2Fp/issues?per_page=20&order_by=updated_at&sort=desc&state=opened'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns a not_found error envelope when project ref is unresolved', async () => {
|
||||
@@ -134,7 +157,7 @@ describe('gitlab client — combined listWorkItems', () => {
|
||||
})
|
||||
|
||||
it('surfaces the MR error envelope into the combined result', async () => {
|
||||
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden'))
|
||||
glabApiWithHeadersMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden'))
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
|
||||
|
||||
const result = await listWorkItems('/repo', 'opened', 1, 20)
|
||||
@@ -142,7 +165,7 @@ describe('gitlab client — combined listWorkItems', () => {
|
||||
})
|
||||
|
||||
it('still returns issues when MRs error out', async () => {
|
||||
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 500'))
|
||||
glabApiWithHeadersMock.mockRejectedValueOnce(new Error('HTTP 500'))
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{ id: 200, iid: 9, title: 'live issue', state: 'opened', updated_at: '2026-05-08' }
|
||||
|
||||
+268
-125
@@ -16,10 +16,14 @@ import type {
|
||||
import { derivePipelineStatus, mapIssueToWorkItem, mapMRInfo, mapMRToWorkItem } from './mappers'
|
||||
import {
|
||||
acquire,
|
||||
classifyGlabError,
|
||||
classifyListIssuesError,
|
||||
getGlabKnownHosts,
|
||||
getProjectRef,
|
||||
getProjectRefForRemote,
|
||||
glabHostnameArgs,
|
||||
glabRepoExecOptions,
|
||||
glabApiWithHeaders,
|
||||
glabExecFileAsync,
|
||||
release,
|
||||
resolveIssueSource,
|
||||
@@ -33,25 +37,6 @@ function encodedProject(projectPath: string): string {
|
||||
return encodeURIComponent(projectPath)
|
||||
}
|
||||
|
||||
function projectRefToGlabRepo(projectRef: ProjectRef): string {
|
||||
// Why: `glab mr list` otherwise infers from cwd and can ignore an
|
||||
// upstream/origin preference. A full URL also works for self-hosted hosts.
|
||||
return `https://${projectRef.host}/${projectRef.path}`
|
||||
}
|
||||
|
||||
function mrListStateFlags(state: MRListState): string[] {
|
||||
switch (state) {
|
||||
case 'opened':
|
||||
return []
|
||||
case 'merged':
|
||||
return ['--merged']
|
||||
case 'closed':
|
||||
return ['--closed']
|
||||
case 'all':
|
||||
return ['--all']
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authenticated GitLab viewer. Mirrors getAuthenticatedViewer
|
||||
* from the GitHub client — returns null when glab is unavailable, the
|
||||
@@ -80,9 +65,12 @@ export async function getAuthenticatedViewer(): Promise<GitLabViewer | null> {
|
||||
* Resolve a project's full GitLab project ref (host + path). Mirrors
|
||||
* github/getRepoSlug. Returns null for non-GitLab remotes.
|
||||
*/
|
||||
export async function getProjectSlug(repoPath: string): Promise<ProjectRef | null> {
|
||||
export async function getProjectSlug(
|
||||
repoPath: string,
|
||||
connectionId?: string | null
|
||||
): Promise<ProjectRef | null> {
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
return getProjectRef(repoPath, knownHosts)
|
||||
return getProjectRef(repoPath, knownHosts, connectionId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,15 +78,23 @@ export async function getProjectSlug(repoPath: string): Promise<ProjectRef | nul
|
||||
* Returns null when the MR doesn't exist or glab fails — callers
|
||||
* decide whether to surface "not found" UI.
|
||||
*/
|
||||
export async function getMergeRequest(repoPath: string, iid: number): Promise<MRInfo | null> {
|
||||
export async function getMergeRequest(
|
||||
repoPath: string,
|
||||
iid: number,
|
||||
connectionId?: string | null
|
||||
): Promise<MRInfo | null> {
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const projectRef = await getProjectRef(repoPath, knownHosts)
|
||||
const projectRef = await getProjectRef(repoPath, knownHosts, connectionId)
|
||||
await acquire()
|
||||
try {
|
||||
const args = projectRef
|
||||
? ['api', `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`]
|
||||
? [
|
||||
'api',
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
`projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`
|
||||
]
|
||||
: ['mr', 'view', String(iid), '--output', 'json']
|
||||
const { stdout } = await glabExecFileAsync(args, { cwd: repoPath })
|
||||
const { stdout } = await glabExecFileAsync(args, glabRepoExecOptions(repoPath, connectionId))
|
||||
const data = JSON.parse(stdout) as Parameters<typeof mapMRInfo>[0] & {
|
||||
head_pipeline?: { status?: string } | null
|
||||
pipeline?: { status?: string } | null
|
||||
@@ -125,14 +121,15 @@ export async function getMergeRequest(repoPath: string, iid: number): Promise<MR
|
||||
export async function getMergeRequestForBranch(
|
||||
repoPath: string,
|
||||
branch: string,
|
||||
linkedMRIid?: number | null
|
||||
linkedMRIid?: number | null,
|
||||
connectionId?: string | null
|
||||
): Promise<MRInfo | null> {
|
||||
const branchName = branch.replace(/^refs\/heads\//, '')
|
||||
if (!branchName && linkedMRIid == null) {
|
||||
return null
|
||||
}
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const projectRef = await getProjectRef(repoPath, knownHosts)
|
||||
const projectRef = await getProjectRef(repoPath, knownHosts, connectionId)
|
||||
if (!projectRef) {
|
||||
return null
|
||||
}
|
||||
@@ -142,9 +139,10 @@ export async function getMergeRequestForBranch(
|
||||
const { stdout } = await glabExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
`projects/${encodedProject(projectRef.path)}/merge_requests?source_branch=${encodeURIComponent(branchName)}&order_by=updated_at&sort=desc&per_page=1`
|
||||
],
|
||||
{ cwd: repoPath }
|
||||
glabRepoExecOptions(repoPath, connectionId)
|
||||
)
|
||||
const data = JSON.parse(stdout) as (Parameters<typeof mapMRInfo>[0] & {
|
||||
head_pipeline?: { status?: string } | null
|
||||
@@ -162,8 +160,12 @@ export async function getMergeRequestForBranch(
|
||||
// than the MR source branch. Fall back to the durable linked iid so the
|
||||
// core review status still follows the workspace.
|
||||
const { stdout } = await glabExecFileAsync(
|
||||
['api', `projects/${encodedProject(projectRef.path)}/merge_requests/${linkedMRIid}`],
|
||||
{ cwd: repoPath }
|
||||
[
|
||||
'api',
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
`projects/${encodedProject(projectRef.path)}/merge_requests/${linkedMRIid}`
|
||||
],
|
||||
glabRepoExecOptions(repoPath, connectionId)
|
||||
)
|
||||
const raw = JSON.parse(stdout) as Parameters<typeof mapMRInfo>[0] & {
|
||||
head_pipeline?: { status?: string } | null
|
||||
@@ -187,80 +189,60 @@ export async function listMergeRequests(
|
||||
state: MRListState = 'opened',
|
||||
page = 1,
|
||||
perPage = 20,
|
||||
preference?: IssueSourcePreference
|
||||
preference?: IssueSourcePreference,
|
||||
query?: string,
|
||||
connectionId?: string | null
|
||||
): Promise<ListMergeRequestsResult> {
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts)
|
||||
// Why: MRs sit on `origin` in the fork model (the user's fork is where
|
||||
// they push branches and submit MRs). Mirror github's `getOwnerRepo`
|
||||
// call site by going through the upstream/origin preference resolver
|
||||
// so cross-fork workflows reuse the same plumbing.
|
||||
const { source: projectRef } = await resolveIssueSource(
|
||||
repoPath,
|
||||
preference,
|
||||
knownHosts,
|
||||
connectionId
|
||||
)
|
||||
if (!projectRef) {
|
||||
return {
|
||||
items: [],
|
||||
page,
|
||||
perPage,
|
||||
totalCount: 0,
|
||||
totalPages: 0,
|
||||
error: {
|
||||
type: 'not_found',
|
||||
message: 'No GitLab project found for this repository.'
|
||||
}
|
||||
}
|
||||
}
|
||||
// Why: 'all' is exposed as the picker filter but GitLab's API expects
|
||||
// no state param to mean "any state". Drop the param when 'all'.
|
||||
const stateParam = state === 'all' ? '' : `&state=${state}`
|
||||
const searchParam = query?.trim() ? `&search=${encodeURIComponent(query.trim())}` : ''
|
||||
const path =
|
||||
`projects/${encodedProject(projectRef.path)}/merge_requests?` +
|
||||
`page=${page}&per_page=${perPage}&order_by=updated_at&sort=desc&with_merge_status_recheck=false${stateParam}${searchParam}`
|
||||
const repoId = projectRef.path
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
if (projectRef) {
|
||||
// Why: use `glab mr list` (CLI) instead of the REST API directly.
|
||||
// The CLI respects the user's glab auth configuration; `--repo`
|
||||
// keeps upstream/origin preference resolution explicit.
|
||||
const stateFlag = mrListStateFlags(state)
|
||||
const { stdout } = await glabExecFileAsync(
|
||||
[
|
||||
'mr',
|
||||
'list',
|
||||
'--output',
|
||||
'json',
|
||||
'--per-page',
|
||||
String(perPage),
|
||||
'--page',
|
||||
String(page),
|
||||
'--order',
|
||||
'updated_at',
|
||||
'--sort',
|
||||
'desc',
|
||||
'--repo',
|
||||
projectRefToGlabRepo(projectRef),
|
||||
...stateFlag
|
||||
],
|
||||
{ cwd: repoPath }
|
||||
)
|
||||
const data = JSON.parse(stdout) as Parameters<typeof mapMRToWorkItem>[0][]
|
||||
return {
|
||||
items: data.map((d) => mapMRToWorkItem(d, projectRef.path)),
|
||||
page,
|
||||
perPage,
|
||||
// Why: the CLI doesn't return x-total headers, so totals are
|
||||
// approximate. For the Tasks UI this is acceptable — pagination
|
||||
// still works via page+per_page.
|
||||
totalCount: data.length,
|
||||
totalPages: data.length < perPage ? page : page + 1
|
||||
}
|
||||
}
|
||||
// Fallback — let glab infer project from cwd. This path is taken when
|
||||
// the repo's remote host is not in getGlabKnownHosts() (e.g. a fresh
|
||||
// self-hosted instance), but glab itself can still resolve it from the
|
||||
// local git config.
|
||||
const stateFlag = mrListStateFlags(state)
|
||||
const { stdout } = await glabExecFileAsync(
|
||||
[
|
||||
'mr',
|
||||
'list',
|
||||
'--output',
|
||||
'json',
|
||||
'--per-page',
|
||||
String(perPage),
|
||||
'--page',
|
||||
String(page),
|
||||
'--order',
|
||||
'updated_at',
|
||||
'--sort',
|
||||
'desc',
|
||||
...stateFlag
|
||||
],
|
||||
{ cwd: repoPath }
|
||||
const { body, headers } = await glabApiWithHeaders(
|
||||
[...glabHostnameArgs(projectRef, connectionId), path],
|
||||
glabRepoExecOptions(repoPath, connectionId)
|
||||
)
|
||||
const data = JSON.parse(stdout) as Parameters<typeof mapMRToWorkItem>[0][]
|
||||
const data = JSON.parse(body) as Parameters<typeof mapMRToWorkItem>[0][]
|
||||
return {
|
||||
items: data.map((d) => mapMRToWorkItem(d, 'unknown')),
|
||||
items: data.map((d) => mapMRToWorkItem(d, repoId, projectRef)),
|
||||
page,
|
||||
perPage,
|
||||
totalCount: data.length,
|
||||
totalPages: data.length < perPage ? page : page + 1
|
||||
totalCount: parseHeaderInt(headers['x-total'], 0),
|
||||
// Why: when 'all' state is requested or the per_page is large,
|
||||
// GitLab may not include x-total-pages; fall back to ceil(total/perPage).
|
||||
totalPages:
|
||||
parseHeaderInt(headers['x-total-pages'], 0) ||
|
||||
Math.max(1, Math.ceil(parseHeaderInt(headers['x-total'], 0) / perPage))
|
||||
}
|
||||
} catch (err) {
|
||||
const stderr = err instanceof Error ? err.message : String(err)
|
||||
@@ -277,6 +259,14 @@ export async function listMergeRequests(
|
||||
}
|
||||
}
|
||||
|
||||
function parseHeaderInt(value: string | undefined, fallback: number): number {
|
||||
if (!value) {
|
||||
return fallback
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a work item (MR or issue) given an explicit project ref +
|
||||
* iid + type. Mirrors github/getWorkItemByOwnerRepo — used by the
|
||||
@@ -287,20 +277,25 @@ export async function getWorkItemByProjectRef(
|
||||
repoPath: string,
|
||||
projectRef: ProjectRef,
|
||||
iid: number,
|
||||
type: 'issue' | 'mr'
|
||||
type: 'issue' | 'mr',
|
||||
connectionId?: string | null
|
||||
): Promise<GitLabWorkItem | null> {
|
||||
await acquire()
|
||||
try {
|
||||
const resource = type === 'mr' ? 'merge_requests' : 'issues'
|
||||
const { stdout } = await glabExecFileAsync(
|
||||
['api', `projects/${encodedProject(projectRef.path)}/${resource}/${iid}`],
|
||||
{ cwd: repoPath }
|
||||
[
|
||||
'api',
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
`projects/${encodedProject(projectRef.path)}/${resource}/${iid}`
|
||||
],
|
||||
glabRepoExecOptions(repoPath, connectionId)
|
||||
)
|
||||
const data = JSON.parse(stdout)
|
||||
if (type === 'mr') {
|
||||
return mapMRToWorkItem(data, projectRef.path)
|
||||
return mapMRToWorkItem(data, projectRef.path, projectRef)
|
||||
}
|
||||
return mapIssueToWorkItem(data, projectRef.path)
|
||||
return mapIssueToWorkItem(data, projectRef.path, projectRef)
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
@@ -337,11 +332,18 @@ export async function listWorkItems(
|
||||
state: MRListState = 'opened',
|
||||
page = 1,
|
||||
perPage = 20,
|
||||
preference?: IssueSourcePreference
|
||||
preference?: IssueSourcePreference,
|
||||
query?: string,
|
||||
connectionId?: string | null
|
||||
): Promise<GitLabPagedResult<GitLabWorkItem>> {
|
||||
const issueState = mrStateToIssueState(state)
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts)
|
||||
const { source: projectRef } = await resolveIssueSource(
|
||||
repoPath,
|
||||
preference,
|
||||
knownHosts,
|
||||
connectionId
|
||||
)
|
||||
if (!projectRef) {
|
||||
return {
|
||||
items: [],
|
||||
@@ -366,13 +368,13 @@ export async function listWorkItems(
|
||||
// raw issues API directly and run mapIssueToWorkItem against the
|
||||
// raw payload instead.
|
||||
const [mrs, issues] = await Promise.all([
|
||||
listMergeRequests(repoPath, state, page, perPage, preference),
|
||||
listMergeRequests(repoPath, state, page, perPage, preference, query, connectionId),
|
||||
issueState === null
|
||||
? Promise.resolve({
|
||||
items: [] as GitLabWorkItem[],
|
||||
error: undefined as ClassifiedError | undefined
|
||||
})
|
||||
: fetchIssuesAsWorkItems(repoPath, projectRef, issueState, perPage)
|
||||
: fetchIssuesAsWorkItems(repoPath, projectRef, issueState, perPage, query, connectionId)
|
||||
])
|
||||
const merged = [...mrs.items, ...issues.items].sort((a, b) =>
|
||||
(b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')
|
||||
@@ -401,21 +403,25 @@ export async function fetchIssuesAsWorkItems(
|
||||
repoPath: string,
|
||||
projectRef: ProjectRef,
|
||||
state: IssueListState,
|
||||
perPage: number
|
||||
perPage: number,
|
||||
query?: string,
|
||||
connectionId?: string | null
|
||||
): Promise<{ items: GitLabWorkItem[]; error: ClassifiedError | undefined }> {
|
||||
await acquire()
|
||||
try {
|
||||
const stateParam = state === 'all' ? '' : `&state=${state}`
|
||||
const searchParam = query?.trim() ? `&search=${encodeURIComponent(query.trim())}` : ''
|
||||
const { stdout } = await glabExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
`projects/${encodedProject(projectRef.path)}/issues?per_page=${perPage}&order_by=updated_at&sort=desc${stateParam}`
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
`projects/${encodedProject(projectRef.path)}/issues?per_page=${perPage}&order_by=updated_at&sort=desc${stateParam}${searchParam}`
|
||||
],
|
||||
{ cwd: repoPath }
|
||||
glabRepoExecOptions(repoPath, connectionId)
|
||||
)
|
||||
const data = JSON.parse(stdout) as Parameters<typeof mapIssueToWorkItem>[0][]
|
||||
return {
|
||||
items: data.map((d) => mapIssueToWorkItem(d, projectRef.path)),
|
||||
items: data.map((d) => mapIssueToWorkItem(d, projectRef.path, projectRef)),
|
||||
error: undefined
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -439,15 +445,27 @@ export async function fetchIssuesAsWorkItems(
|
||||
* work directly from a mention/assignment without going to gitlab.com
|
||||
* first.
|
||||
*/
|
||||
export async function listTodos(repoPath: string): Promise<GitLabTodo[]> {
|
||||
export async function listTodos(
|
||||
repoPath: string,
|
||||
connectionId?: string | null
|
||||
): Promise<GitLabTodo[]> {
|
||||
const projectRef = await getProjectRef(repoPath, await getGlabKnownHosts(), connectionId)
|
||||
if (connectionId && !projectRef) {
|
||||
return []
|
||||
}
|
||||
await acquire()
|
||||
try {
|
||||
// Why: per_page=50 keeps the first-page round-trip small. Pagination
|
||||
// is left for a follow-up — most users have <50 pending todos in
|
||||
// practice and the UI shows the highest-priority ones first.
|
||||
const { stdout } = await glabExecFileAsync(
|
||||
['api', '--paginate', 'todos?state=pending&per_page=50'],
|
||||
{ cwd: repoPath }
|
||||
[
|
||||
'api',
|
||||
...(projectRef ? glabHostnameArgs(projectRef, connectionId) : []),
|
||||
'--paginate',
|
||||
'todos?state=pending&per_page=50'
|
||||
],
|
||||
glabRepoExecOptions(repoPath, connectionId)
|
||||
)
|
||||
type RESTTodo = {
|
||||
id?: number
|
||||
@@ -498,11 +516,15 @@ export async function listTodos(repoPath: string): Promise<GitLabTodo[]> {
|
||||
|
||||
async function withProjectRef<T>(
|
||||
repoPath: string,
|
||||
preference: IssueSourcePreference | undefined,
|
||||
connectionId: string | null | undefined,
|
||||
explicitProjectRef: ProjectRef | null | undefined,
|
||||
fn: (projectRef: ProjectRef, repoFlag: string) => Promise<T>,
|
||||
fallback: T
|
||||
): Promise<T> {
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const projectRef = await getProjectRef(repoPath, knownHosts)
|
||||
const projectRef =
|
||||
explicitProjectRef ??
|
||||
(await resolveIssueSource(repoPath, preference, await getGlabKnownHosts(), connectionId)).source
|
||||
if (!projectRef) {
|
||||
return fallback
|
||||
}
|
||||
@@ -511,14 +533,30 @@ async function withProjectRef<T>(
|
||||
|
||||
export async function closeMR(
|
||||
repoPath: string,
|
||||
iid: number
|
||||
iid: number,
|
||||
preference?: IssueSourcePreference,
|
||||
connectionId?: string | null,
|
||||
projectRef?: ProjectRef | null
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
return withProjectRef<{ ok: true } | { ok: false; error: string }>(
|
||||
repoPath,
|
||||
async (_pr, repoFlag) => {
|
||||
preference,
|
||||
connectionId,
|
||||
projectRef,
|
||||
async (projectRef, repoFlag) => {
|
||||
await acquire()
|
||||
try {
|
||||
await glabExecFileAsync(['mr', 'close', String(iid), '-R', repoFlag], { cwd: repoPath })
|
||||
await glabExecFileAsync(
|
||||
[
|
||||
'mr',
|
||||
'close',
|
||||
String(iid),
|
||||
'-R',
|
||||
repoFlag,
|
||||
...glabHostnameArgs(projectRef, connectionId)
|
||||
],
|
||||
glabRepoExecOptions(repoPath, connectionId)
|
||||
)
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
@@ -539,14 +577,30 @@ export async function closeMR(
|
||||
|
||||
export async function reopenMR(
|
||||
repoPath: string,
|
||||
iid: number
|
||||
iid: number,
|
||||
preference?: IssueSourcePreference,
|
||||
connectionId?: string | null,
|
||||
projectRef?: ProjectRef | null
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
return withProjectRef<{ ok: true } | { ok: false; error: string }>(
|
||||
repoPath,
|
||||
async (_pr, repoFlag) => {
|
||||
preference,
|
||||
connectionId,
|
||||
projectRef,
|
||||
async (projectRef, repoFlag) => {
|
||||
await acquire()
|
||||
try {
|
||||
await glabExecFileAsync(['mr', 'reopen', String(iid), '-R', repoFlag], { cwd: repoPath })
|
||||
await glabExecFileAsync(
|
||||
[
|
||||
'mr',
|
||||
'reopen',
|
||||
String(iid),
|
||||
'-R',
|
||||
repoFlag,
|
||||
...glabHostnameArgs(projectRef, connectionId)
|
||||
],
|
||||
glabRepoExecOptions(repoPath, connectionId)
|
||||
)
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
@@ -565,11 +619,17 @@ export async function reopenMR(
|
||||
export async function mergeMR(
|
||||
repoPath: string,
|
||||
iid: number,
|
||||
method: 'merge' | 'squash' | 'rebase' = 'merge'
|
||||
method: 'merge' | 'squash' | 'rebase' = 'merge',
|
||||
preference?: IssueSourcePreference,
|
||||
connectionId?: string | null,
|
||||
projectRef?: ProjectRef | null
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
return withProjectRef<{ ok: true } | { ok: false; error: string }>(
|
||||
repoPath,
|
||||
async (_pr, repoFlag) => {
|
||||
preference,
|
||||
connectionId,
|
||||
projectRef,
|
||||
async (projectRef, repoFlag) => {
|
||||
await acquire()
|
||||
try {
|
||||
// Why: glab mr merge accepts --squash and --rebase flags;
|
||||
@@ -578,8 +638,17 @@ export async function mergeMR(
|
||||
const methodFlag =
|
||||
method === 'squash' ? ['--squash'] : method === 'rebase' ? ['--rebase'] : []
|
||||
await glabExecFileAsync(
|
||||
['mr', 'merge', String(iid), '-R', repoFlag, '--yes', ...methodFlag],
|
||||
{ cwd: repoPath }
|
||||
[
|
||||
'mr',
|
||||
'merge',
|
||||
String(iid),
|
||||
'-R',
|
||||
repoFlag,
|
||||
'--yes',
|
||||
...methodFlag,
|
||||
...glabHostnameArgs(projectRef, connectionId)
|
||||
],
|
||||
glabRepoExecOptions(repoPath, connectionId)
|
||||
)
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
@@ -595,23 +664,30 @@ export async function mergeMR(
|
||||
export async function addMRComment(
|
||||
repoPath: string,
|
||||
iid: number,
|
||||
body: string
|
||||
body: string,
|
||||
preference?: IssueSourcePreference,
|
||||
connectionId?: string | null,
|
||||
projectRef?: ProjectRef | null
|
||||
): Promise<{ ok: true; comment: MRComment } | { ok: false; error: string }> {
|
||||
return withProjectRef<{ ok: true; comment: MRComment } | { ok: false; error: string }>(
|
||||
repoPath,
|
||||
preference,
|
||||
connectionId,
|
||||
projectRef,
|
||||
async (projectRef) => {
|
||||
await acquire()
|
||||
try {
|
||||
const { stdout } = await glabExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
'-X',
|
||||
'POST',
|
||||
`projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/notes`,
|
||||
'-f',
|
||||
`body=${body}`
|
||||
],
|
||||
{ cwd: repoPath }
|
||||
glabRepoExecOptions(repoPath, connectionId)
|
||||
)
|
||||
const data = JSON.parse(stdout) as {
|
||||
id?: number
|
||||
@@ -641,6 +717,73 @@ export async function addMRComment(
|
||||
)
|
||||
}
|
||||
|
||||
export async function updateMR(
|
||||
repoPath: string,
|
||||
iid: number,
|
||||
updates: {
|
||||
title?: string
|
||||
body?: string
|
||||
addLabels?: string[]
|
||||
removeLabels?: string[]
|
||||
},
|
||||
preference?: IssueSourcePreference,
|
||||
connectionId?: string | null,
|
||||
projectRef?: ProjectRef | null
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
return withProjectRef<{ ok: true } | { ok: false; error: string }>(
|
||||
repoPath,
|
||||
preference,
|
||||
connectionId,
|
||||
projectRef,
|
||||
async (projectRef) => {
|
||||
const fields: string[] = []
|
||||
const title = updates.title?.trim()
|
||||
if (updates.title !== undefined) {
|
||||
if (!title) {
|
||||
return { ok: false, error: 'Title is required' }
|
||||
}
|
||||
fields.push(`title=${title}`)
|
||||
}
|
||||
if (updates.body !== undefined) {
|
||||
fields.push(`description=${updates.body}`)
|
||||
}
|
||||
const addLabels = (updates.addLabels ?? []).filter((label) => label.trim().length > 0)
|
||||
const removeLabels = (updates.removeLabels ?? []).filter((label) => label.trim().length > 0)
|
||||
if (addLabels.length > 0) {
|
||||
fields.push(`add_labels=${addLabels.join(',')}`)
|
||||
}
|
||||
if (removeLabels.length > 0) {
|
||||
fields.push(`remove_labels=${removeLabels.join(',')}`)
|
||||
}
|
||||
if (fields.length === 0) {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
await glabExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
'-X',
|
||||
'PUT',
|
||||
`projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`,
|
||||
...fields.flatMap((field) => ['-f', field])
|
||||
],
|
||||
glabRepoExecOptions(repoPath, connectionId)
|
||||
)
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
return { ok: false, error: classifyGlabError(msg).message }
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
},
|
||||
{ ok: false, error: 'Could not resolve GitLab project for this repository' }
|
||||
)
|
||||
}
|
||||
|
||||
/** Re-export so callers don't need to know the gl-utils module split. */
|
||||
export { _resetProjectRefCache } from './gl-utils'
|
||||
export {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
/* eslint-disable max-lines -- Why: GitLab remote parsing coverage needs many URL/host fixtures against the same mocked git/glab helpers. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { gitExecFileAsyncMock, glabExecFileAsyncMock } = vi.hoisted(() => ({
|
||||
const { gitExecFileAsyncMock, glabExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
glabExecFileAsyncMock: vi.fn()
|
||||
glabExecFileAsyncMock: vi.fn(),
|
||||
sshExecMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', () => ({
|
||||
@@ -18,11 +20,13 @@ import {
|
||||
getIssueProjectRef,
|
||||
getGlabKnownHosts,
|
||||
getProjectRef,
|
||||
getProjectRefForRemote,
|
||||
parseGitLabProjectRef,
|
||||
parseGlabApiResponse,
|
||||
parseGlabAuthStatusHosts,
|
||||
resolveIssueSource
|
||||
} from './gl-utils'
|
||||
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
|
||||
describe('gitlab project ref parsing', () => {
|
||||
it('parses HTTPS and SSH GitLab.com remotes', () => {
|
||||
@@ -99,9 +103,15 @@ describe('gitlab project ref parsing', () => {
|
||||
describe('gitlab project ref resolution', () => {
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
sshExecMock.mockReset()
|
||||
unregisterSshGitProvider('conn-1')
|
||||
_resetProjectRefCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
unregisterSshGitProvider('conn-1')
|
||||
})
|
||||
|
||||
it('keeps getProjectRef origin-based', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: 'git@gitlab.com:fork/orca.git\n'
|
||||
@@ -155,6 +165,47 @@ describe('gitlab project ref resolution', () => {
|
||||
path: 'stablyai/orca'
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves project refs through the SSH git provider for connected repos', async () => {
|
||||
sshExecMock.mockResolvedValueOnce({ stdout: 'git@gitlab.com:remote/orca.git\n', stderr: '' })
|
||||
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
|
||||
|
||||
await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'remote/orca'
|
||||
})
|
||||
|
||||
expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo')
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not cache a missing SSH provider as a permanent null project ref', async () => {
|
||||
await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toBeNull()
|
||||
|
||||
sshExecMock.mockResolvedValueOnce({
|
||||
stdout: 'git@gitlab.com:remote/orca.git\n',
|
||||
stderr: ''
|
||||
})
|
||||
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
|
||||
|
||||
await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'remote/orca'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not cache transient SSH exec failures as permanent null project refs', async () => {
|
||||
sshExecMock
|
||||
.mockRejectedValueOnce(new Error('ssh tunnel not ready'))
|
||||
.mockResolvedValueOnce({ stdout: 'git@gitlab.com:remote/orca.git\n', stderr: '' })
|
||||
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
|
||||
|
||||
await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toBeNull()
|
||||
await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'remote/orca'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveIssueSource', () => {
|
||||
|
||||
+47
-15
@@ -2,6 +2,7 @@ import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { gitExecFileAsync, glabExecFileAsync } from '../git/runner'
|
||||
import type { ClassifiedError, GitLabProjectRef, IssueSourcePreference } from '../../shared/types'
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
|
||||
// Why: legacy generic execFile wrapper — only used by callers that don't need
|
||||
// WSL-aware routing. Repo-scoped callers should use glabExecFileAsync from
|
||||
@@ -167,22 +168,34 @@ export function parseGitLabProjectRef(
|
||||
export async function getProjectRefForRemote(
|
||||
repoPath: string,
|
||||
remoteName: string,
|
||||
knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS
|
||||
knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS,
|
||||
connectionId?: string | null
|
||||
): Promise<ProjectRef | null> {
|
||||
const cacheKey = `${repoPath}\0${remoteName}\0${knownHosts.join(',')}`
|
||||
const cacheKey = `${connectionId ?? 'local'}\0${repoPath}\0${remoteName}\0${knownHosts.join(',')}`
|
||||
if (projectRefCache.has(cacheKey)) {
|
||||
return projectRefCache.get(cacheKey)!
|
||||
}
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(['remote', 'get-url', remoteName], {
|
||||
cwd: repoPath
|
||||
})
|
||||
const sshGitProvider = connectionId ? getSshGitProvider(connectionId) : null
|
||||
if (connectionId && !sshGitProvider) {
|
||||
// Why: mobile can attempt GitLab loads before the SSH tunnel is ready.
|
||||
// Caching that transient state would poison later loads after connect.
|
||||
return null
|
||||
}
|
||||
const { stdout } = sshGitProvider
|
||||
? await sshGitProvider.exec(['remote', 'get-url', remoteName], repoPath)
|
||||
: await gitExecFileAsync(['remote', 'get-url', remoteName], { cwd: repoPath })
|
||||
const result = parseGitLabProjectRef(stdout, knownHosts)
|
||||
if (result) {
|
||||
projectRefCache.set(cacheKey, result)
|
||||
return result
|
||||
}
|
||||
} catch {
|
||||
if (connectionId) {
|
||||
// Why: remote SSH failures are often transient tunnel/process errors.
|
||||
// Do not cache them as "not a GitLab repo" for the rest of the session.
|
||||
return null
|
||||
}
|
||||
// ignore — non-GitLab remote or no remote configured
|
||||
}
|
||||
projectRefCache.set(cacheKey, null)
|
||||
@@ -191,20 +204,22 @@ export async function getProjectRefForRemote(
|
||||
|
||||
export async function getProjectRef(
|
||||
repoPath: string,
|
||||
knownHosts?: readonly string[]
|
||||
knownHosts?: readonly string[],
|
||||
connectionId?: string | null
|
||||
): Promise<ProjectRef | null> {
|
||||
return getProjectRefForRemote(repoPath, 'origin', knownHosts)
|
||||
return getProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId)
|
||||
}
|
||||
|
||||
export async function getIssueProjectRef(
|
||||
repoPath: string,
|
||||
knownHosts?: readonly string[]
|
||||
knownHosts?: readonly string[],
|
||||
connectionId?: string | null
|
||||
): Promise<ProjectRef | null> {
|
||||
const upstream = await getProjectRefForRemote(repoPath, 'upstream', knownHosts)
|
||||
const upstream = await getProjectRefForRemote(repoPath, 'upstream', knownHosts, connectionId)
|
||||
if (upstream) {
|
||||
return upstream
|
||||
}
|
||||
return getProjectRefForRemote(repoPath, 'origin', knownHosts)
|
||||
return getProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId)
|
||||
}
|
||||
|
||||
export type ResolvedIssueSource = {
|
||||
@@ -222,23 +237,40 @@ export type ResolvedIssueSource = {
|
||||
export async function resolveIssueSource(
|
||||
repoPath: string,
|
||||
preference: IssueSourcePreference | undefined,
|
||||
knownHosts?: readonly string[]
|
||||
knownHosts?: readonly string[],
|
||||
connectionId?: string | null
|
||||
): Promise<ResolvedIssueSource> {
|
||||
if (preference === 'upstream') {
|
||||
const upstream = await getProjectRefForRemote(repoPath, 'upstream', knownHosts)
|
||||
const upstream = await getProjectRefForRemote(repoPath, 'upstream', knownHosts, connectionId)
|
||||
if (upstream) {
|
||||
return { source: upstream, fellBack: false }
|
||||
}
|
||||
const origin = await getProjectRefForRemote(repoPath, 'origin', knownHosts)
|
||||
const origin = await getProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId)
|
||||
return { source: origin, fellBack: origin !== null }
|
||||
}
|
||||
if (preference === 'origin') {
|
||||
return {
|
||||
source: await getProjectRefForRemote(repoPath, 'origin', knownHosts),
|
||||
source: await getProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId),
|
||||
fellBack: false
|
||||
}
|
||||
}
|
||||
return { source: await getIssueProjectRef(repoPath, knownHosts), fellBack: false }
|
||||
return { source: await getIssueProjectRef(repoPath, knownHosts, connectionId), fellBack: false }
|
||||
}
|
||||
|
||||
export function glabRepoExecOptions(
|
||||
repoPath: string,
|
||||
connectionId?: string | null
|
||||
): { cwd?: string } {
|
||||
return connectionId ? {} : { cwd: repoPath }
|
||||
}
|
||||
|
||||
export function glabHostnameArgs(
|
||||
projectRef: Pick<ProjectRef, 'host'> | null | undefined,
|
||||
connectionId?: string | null
|
||||
): string[] {
|
||||
// Why: local glab commands can infer host from cwd; SSH-backed calls have
|
||||
// no local cwd, so self-hosted instances need an explicit hostname.
|
||||
return connectionId && projectRef?.host ? ['--hostname', projectRef.host] : []
|
||||
}
|
||||
|
||||
// ── Known-hosts discovery via `glab auth status` ────────────────────
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable max-lines -- Why: GitLab issue mutation/list coverage shares glab mocks across related endpoint cases. */
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as GlUtils from './gl-utils'
|
||||
|
||||
@@ -30,7 +31,15 @@ vi.mock('./gl-utils', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
import { addIssueComment, createIssue, getIssue, listIssues, updateIssue } from './issues'
|
||||
import {
|
||||
addIssueComment,
|
||||
createIssue,
|
||||
getIssue,
|
||||
listAssignableUsers,
|
||||
listIssues,
|
||||
listLabels,
|
||||
updateIssue
|
||||
} from './issues'
|
||||
|
||||
describe('gitlab issue operations', () => {
|
||||
beforeEach(() => {
|
||||
@@ -226,6 +235,62 @@ describe('gitlab issue operations', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('updateIssue applies body edits via the issue API', async () => {
|
||||
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
|
||||
await expect(updateIssue('/repo-root', 5, { body: 'Updated body' })).resolves.toEqual({
|
||||
ok: true
|
||||
})
|
||||
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['api', '-X', 'PUT', 'projects/stablyai%2Forca/issues/5', '-f', 'description=Updated body'],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
})
|
||||
|
||||
it('routes issue metadata reads through the selected SSH GitLab host', async () => {
|
||||
getIssueProjectRefMock
|
||||
.mockResolvedValueOnce({ host: 'git.internal', path: 'stablyai/orca' })
|
||||
.mockResolvedValueOnce({ host: 'git.internal', path: 'stablyai/orca' })
|
||||
glabExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'bug\nfeature\n' })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: '{"username":"alice","name":"Alice","avatar_url":"https://example.com/a.png"}\n'
|
||||
})
|
||||
|
||||
await expect(listLabels('/repo-root', 'upstream', 'conn-1')).resolves.toEqual([
|
||||
'bug',
|
||||
'feature'
|
||||
])
|
||||
await expect(listAssignableUsers('/repo-root', 'upstream', 'conn-1')).resolves.toEqual([
|
||||
{
|
||||
username: 'alice',
|
||||
name: 'Alice',
|
||||
avatarUrl: 'https://example.com/a.png'
|
||||
}
|
||||
])
|
||||
|
||||
expect(glabExecFileAsyncMock.mock.calls[0][0]).toEqual([
|
||||
'api',
|
||||
'--hostname',
|
||||
'git.internal',
|
||||
'--paginate',
|
||||
'projects/stablyai%2Forca/labels',
|
||||
'--jq',
|
||||
'.[].name'
|
||||
])
|
||||
expect(glabExecFileAsyncMock.mock.calls[1][0]).toEqual([
|
||||
'api',
|
||||
'--hostname',
|
||||
'git.internal',
|
||||
'--paginate',
|
||||
'projects/stablyai%2Forca/members/all?per_page=100',
|
||||
'--jq',
|
||||
'.[] | {username, name, avatar_url}'
|
||||
])
|
||||
})
|
||||
|
||||
it('addIssueComment posts to /notes and maps the response', async () => {
|
||||
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({
|
||||
@@ -256,6 +321,32 @@ describe('gitlab issue operations', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('addIssueComment passes hostname for SSH-backed self-hosted repos', async () => {
|
||||
getIssueProjectRefMock.mockResolvedValueOnce({
|
||||
host: 'gitlab.example.com',
|
||||
path: 'stablyai/orca'
|
||||
})
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ id: 100, body: 'Hello' })
|
||||
})
|
||||
|
||||
await addIssueComment('/repo-root', 5, 'Hello', undefined, 'conn-1')
|
||||
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
[
|
||||
'api',
|
||||
'--hostname',
|
||||
'gitlab.example.com',
|
||||
'-X',
|
||||
'POST',
|
||||
'projects/stablyai%2Forca/issues/5/notes',
|
||||
'-f',
|
||||
'body=Hello'
|
||||
],
|
||||
{}
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null from getIssue when project ref cannot be resolved', async () => {
|
||||
getIssueProjectRefMock.mockResolvedValueOnce(null)
|
||||
// Why: when there's no GitLab project ref the fallback path
|
||||
|
||||
+114
-36
@@ -13,7 +13,7 @@ import type {
|
||||
} from '../../shared/types'
|
||||
import { mapGitLabIssueInfo } from './mappers'
|
||||
// prettier-ignore
|
||||
import { glabExecFileAsync, acquire, release, getIssueProjectRef, resolveIssueSource, classifyGlabError, classifyListIssuesError, getGlabKnownHosts } from './gl-utils'
|
||||
import { glabExecFileAsync, acquire, release, getIssueProjectRef, resolveIssueSource, classifyGlabError, classifyListIssuesError, getGlabKnownHosts, glabRepoExecOptions, glabHostnameArgs, type ProjectRef } from './gl-utils'
|
||||
|
||||
// Why: parallel to GitHub's IssueListResult — distinguishes a successful-
|
||||
// empty listing from a failed fetch.
|
||||
@@ -40,16 +40,21 @@ function encodedProject(projectPath: string): string {
|
||||
*/
|
||||
export async function getIssue(
|
||||
repoPath: string,
|
||||
issueNumber: number
|
||||
issueNumber: number,
|
||||
connectionId?: string | null
|
||||
): Promise<GitLabIssueInfo | null> {
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const projectRef = await getIssueProjectRef(repoPath, knownHosts)
|
||||
const projectRef = await getIssueProjectRef(repoPath, knownHosts, connectionId)
|
||||
await acquire()
|
||||
try {
|
||||
if (projectRef) {
|
||||
const { stdout } = await glabExecFileAsync(
|
||||
['api', `projects/${encodedProject(projectRef.path)}/issues/${issueNumber}`],
|
||||
{ cwd: repoPath }
|
||||
[
|
||||
'api',
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
`projects/${encodedProject(projectRef.path)}/issues/${issueNumber}`
|
||||
],
|
||||
glabRepoExecOptions(repoPath, connectionId)
|
||||
)
|
||||
const data = JSON.parse(stdout)
|
||||
return mapGitLabIssueInfo(data)
|
||||
@@ -57,7 +62,7 @@ export async function getIssue(
|
||||
// Fallback for non-GitLab remotes — let glab infer the project from cwd.
|
||||
const { stdout } = await glabExecFileAsync(
|
||||
['issue', 'view', String(issueNumber), '--output', 'json'],
|
||||
{ cwd: repoPath }
|
||||
glabRepoExecOptions(repoPath, connectionId)
|
||||
)
|
||||
const data = JSON.parse(stdout)
|
||||
return mapGitLabIssueInfo(data)
|
||||
@@ -83,10 +88,16 @@ export async function listIssues(
|
||||
limit = 20,
|
||||
preference?: IssueSourcePreference,
|
||||
state: IssueListState = 'opened',
|
||||
assignee?: string
|
||||
assignee?: string,
|
||||
connectionId?: string | null
|
||||
): Promise<IssueListResult> {
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts)
|
||||
const { source: projectRef } = await resolveIssueSource(
|
||||
repoPath,
|
||||
preference,
|
||||
knownHosts,
|
||||
connectionId
|
||||
)
|
||||
await acquire()
|
||||
try {
|
||||
if (projectRef) {
|
||||
@@ -95,9 +106,10 @@ export async function listIssues(
|
||||
const { stdout } = await glabExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
`projects/${encodedProject(projectRef.path)}/issues?per_page=${limit}&order_by=updated_at&sort=desc${stateParam}${scopeParam}`
|
||||
],
|
||||
{ cwd: repoPath }
|
||||
glabRepoExecOptions(repoPath, connectionId)
|
||||
)
|
||||
const data = JSON.parse(stdout) as Record<string, unknown>[]
|
||||
// Why: GitLab's project issues endpoint returns true issues only
|
||||
@@ -126,7 +138,7 @@ export async function listIssues(
|
||||
...stateFlag,
|
||||
...assigneeFlag
|
||||
],
|
||||
{ cwd: repoPath }
|
||||
glabRepoExecOptions(repoPath, connectionId)
|
||||
)
|
||||
const data = JSON.parse(stdout) as unknown[]
|
||||
return {
|
||||
@@ -151,14 +163,20 @@ export async function createIssue(
|
||||
repoPath: string,
|
||||
title: string,
|
||||
body: string,
|
||||
preference?: IssueSourcePreference
|
||||
preference?: IssueSourcePreference,
|
||||
connectionId?: string | null
|
||||
): Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> {
|
||||
const trimmedTitle = title.trim()
|
||||
if (!trimmedTitle) {
|
||||
return { ok: false, error: 'Title is required' }
|
||||
}
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts)
|
||||
const { source: projectRef } = await resolveIssueSource(
|
||||
repoPath,
|
||||
preference,
|
||||
knownHosts,
|
||||
connectionId
|
||||
)
|
||||
if (!projectRef) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -170,6 +188,7 @@ export async function createIssue(
|
||||
const { stdout } = await glabExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
'-X',
|
||||
'POST',
|
||||
`projects/${encodedProject(projectRef.path)}/issues`,
|
||||
@@ -179,7 +198,7 @@ export async function createIssue(
|
||||
// Why: GitLab uses `description` (not `body`) for issue text.
|
||||
`description=${body}`
|
||||
],
|
||||
{ cwd: repoPath }
|
||||
glabRepoExecOptions(repoPath, connectionId)
|
||||
)
|
||||
const data = JSON.parse(stdout) as { iid?: number; web_url?: string; url?: string }
|
||||
if (typeof data.iid !== 'number') {
|
||||
@@ -201,19 +220,21 @@ export async function createIssue(
|
||||
/**
|
||||
* Update an existing GitLab issue.
|
||||
*
|
||||
* Why this path doesn't take a preference — mirrors github/updateIssue:
|
||||
* mutations target an issue number already bound to a worktree / linked
|
||||
* elsewhere. Routing through the live per-repo preference would let a
|
||||
* user open upstream#N, toggle selector to origin, save, and silently
|
||||
* write to a different project's issue with the same iid.
|
||||
* Why: callers that list through a per-repo issue source preference must
|
||||
* mutate the same GitLab project, or identical IIDs on origin/upstream can
|
||||
* silently edit the wrong issue.
|
||||
*/
|
||||
export async function updateIssue(
|
||||
repoPath: string,
|
||||
issueNumber: number,
|
||||
updates: GitLabIssueUpdate
|
||||
updates: GitLabIssueUpdate,
|
||||
preference?: IssueSourcePreference,
|
||||
connectionId?: string | null,
|
||||
projectRefOverride?: ProjectRef | null
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const projectRef = await getIssueProjectRef(repoPath, knownHosts)
|
||||
const projectRef =
|
||||
projectRefOverride ??
|
||||
(await resolveIssueSource(repoPath, preference, await getGlabKnownHosts(), connectionId)).source
|
||||
if (!projectRef) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -229,9 +250,17 @@ export async function updateIssue(
|
||||
await acquire()
|
||||
try {
|
||||
const cmd = updates.state === 'closed' ? 'close' : 'reopen'
|
||||
await glabExecFileAsync(['issue', cmd, String(issueNumber), '-R', repoFlag], {
|
||||
cwd: repoPath
|
||||
})
|
||||
await glabExecFileAsync(
|
||||
[
|
||||
'issue',
|
||||
cmd,
|
||||
String(issueNumber),
|
||||
'-R',
|
||||
repoFlag,
|
||||
...glabHostnameArgs(projectRef, connectionId)
|
||||
],
|
||||
glabRepoExecOptions(repoPath, connectionId)
|
||||
)
|
||||
} catch (err) {
|
||||
const stderr = err instanceof Error ? err.message : String(err)
|
||||
// Treat "already closed/reopened" as a no-op (matches gh path).
|
||||
@@ -243,8 +272,38 @@ export async function updateIssue(
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.body !== undefined) {
|
||||
await acquire()
|
||||
try {
|
||||
await glabExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
'-X',
|
||||
'PUT',
|
||||
`projects/${encodedProject(repoFlag)}/issues/${issueNumber}`,
|
||||
'-f',
|
||||
`description=${updates.body}`
|
||||
],
|
||||
glabRepoExecOptions(repoPath, connectionId)
|
||||
)
|
||||
} catch (err) {
|
||||
const stderr = err instanceof Error ? err.message : String(err)
|
||||
errors.push(classifyGlabError(stderr).message)
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
// Field edits via `glab issue update`.
|
||||
const editArgs: string[] = ['issue', 'update', String(issueNumber), '-R', repoFlag]
|
||||
const editArgs: string[] = [
|
||||
'issue',
|
||||
'update',
|
||||
String(issueNumber),
|
||||
'-R',
|
||||
repoFlag,
|
||||
...glabHostnameArgs(projectRef, connectionId)
|
||||
]
|
||||
let hasEditArgs = false
|
||||
|
||||
if (updates.title) {
|
||||
@@ -271,7 +330,7 @@ export async function updateIssue(
|
||||
if (hasEditArgs) {
|
||||
await acquire()
|
||||
try {
|
||||
await glabExecFileAsync(editArgs, { cwd: repoPath })
|
||||
await glabExecFileAsync(editArgs, glabRepoExecOptions(repoPath, connectionId))
|
||||
} catch (err) {
|
||||
const stderr = err instanceof Error ? err.message : String(err)
|
||||
errors.push(classifyGlabError(stderr).message)
|
||||
@@ -293,10 +352,14 @@ export async function updateIssue(
|
||||
export async function addIssueComment(
|
||||
repoPath: string,
|
||||
issueNumber: number,
|
||||
body: string
|
||||
body: string,
|
||||
preference?: IssueSourcePreference,
|
||||
connectionId?: string | null,
|
||||
projectRefOverride?: ProjectRef | null
|
||||
): Promise<GitLabCommentResult> {
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const projectRef = await getIssueProjectRef(repoPath, knownHosts)
|
||||
const projectRef =
|
||||
projectRefOverride ??
|
||||
(await resolveIssueSource(repoPath, preference, await getGlabKnownHosts(), connectionId)).source
|
||||
if (!projectRef) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -308,13 +371,14 @@ export async function addIssueComment(
|
||||
const { stdout } = await glabExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
'-X',
|
||||
'POST',
|
||||
`projects/${encodedProject(projectRef.path)}/issues/${issueNumber}/notes`,
|
||||
'-f',
|
||||
`body=${body}`
|
||||
],
|
||||
{ cwd: repoPath }
|
||||
glabRepoExecOptions(repoPath, connectionId)
|
||||
)
|
||||
const data = JSON.parse(stdout) as {
|
||||
id?: number
|
||||
@@ -345,10 +409,16 @@ export async function addIssueComment(
|
||||
|
||||
export async function listLabels(
|
||||
repoPath: string,
|
||||
preference?: IssueSourcePreference
|
||||
preference?: IssueSourcePreference,
|
||||
connectionId?: string | null
|
||||
): Promise<string[]> {
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts)
|
||||
const { source: projectRef } = await resolveIssueSource(
|
||||
repoPath,
|
||||
preference,
|
||||
knownHosts,
|
||||
connectionId
|
||||
)
|
||||
if (!projectRef) {
|
||||
return []
|
||||
}
|
||||
@@ -357,12 +427,13 @@ export async function listLabels(
|
||||
const { stdout } = await glabExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
'--paginate',
|
||||
`projects/${encodedProject(projectRef.path)}/labels`,
|
||||
'--jq',
|
||||
'.[].name'
|
||||
],
|
||||
{ cwd: repoPath }
|
||||
glabRepoExecOptions(repoPath, connectionId)
|
||||
)
|
||||
return stdout
|
||||
.trim()
|
||||
@@ -377,10 +448,16 @@ export async function listLabels(
|
||||
|
||||
export async function listAssignableUsers(
|
||||
repoPath: string,
|
||||
preference?: IssueSourcePreference
|
||||
preference?: IssueSourcePreference,
|
||||
connectionId?: string | null
|
||||
): Promise<GitLabAssignableUser[]> {
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts)
|
||||
const { source: projectRef } = await resolveIssueSource(
|
||||
repoPath,
|
||||
preference,
|
||||
knownHosts,
|
||||
connectionId
|
||||
)
|
||||
if (!projectRef) {
|
||||
return []
|
||||
}
|
||||
@@ -393,12 +470,13 @@ export async function listAssignableUsers(
|
||||
const { stdout } = await glabExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
'--paginate',
|
||||
`projects/${encodedProject(projectRef.path)}/members/all?per_page=100`,
|
||||
'--jq',
|
||||
'.[] | {username, name, avatar_url}'
|
||||
],
|
||||
{ cwd: repoPath }
|
||||
glabRepoExecOptions(repoPath, connectionId)
|
||||
)
|
||||
type RESTMember = { username?: string; name?: string | null; avatar_url?: string | null }
|
||||
const users: GitLabAssignableUser[] = []
|
||||
|
||||
@@ -247,7 +247,11 @@ type GitLabMRRawForWorkItem = {
|
||||
target_project_id?: number
|
||||
}
|
||||
|
||||
export function mapMRToWorkItem(data: GitLabMRRawForWorkItem, repoId: string): GitLabWorkItem {
|
||||
export function mapMRToWorkItem(
|
||||
data: GitLabMRRawForWorkItem,
|
||||
repoId: string,
|
||||
projectRef?: GitLabWorkItem['projectRef']
|
||||
): GitLabWorkItem {
|
||||
const labels = (data.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name))
|
||||
const number = data.iid ?? 0
|
||||
return {
|
||||
@@ -268,7 +272,8 @@ export function mapMRToWorkItem(data: GitLabMRRawForWorkItem, repoId: string): G
|
||||
data.source_project_id !== undefined &&
|
||||
data.target_project_id !== undefined &&
|
||||
data.source_project_id !== data.target_project_id,
|
||||
repoId
|
||||
repoId,
|
||||
...(projectRef ? { projectRef } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,7 +291,8 @@ type GitLabIssueRawForWorkItem = {
|
||||
|
||||
export function mapIssueToWorkItem(
|
||||
data: GitLabIssueRawForWorkItem,
|
||||
repoId: string
|
||||
repoId: string,
|
||||
projectRef?: GitLabWorkItem['projectRef']
|
||||
): GitLabWorkItem {
|
||||
const labels = (data.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name))
|
||||
const number = data.iid ?? 0
|
||||
@@ -303,7 +309,8 @@ export function mapIssueToWorkItem(
|
||||
labels,
|
||||
updatedAt: data.updated_at ?? '',
|
||||
author: data.author?.username ?? null,
|
||||
repoId
|
||||
repoId,
|
||||
...(projectRef ? { projectRef } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,12 +12,14 @@ import { mapIssueToWorkItem, mapMRToWorkItem } from './mappers'
|
||||
import {
|
||||
acquire,
|
||||
getGlabKnownHosts,
|
||||
getIssueProjectRef,
|
||||
getProjectRef,
|
||||
glabHostnameArgs,
|
||||
glabRepoExecOptions,
|
||||
glabExecFileAsync,
|
||||
release,
|
||||
resolveIssueSource,
|
||||
type ProjectRef
|
||||
} from './gl-utils'
|
||||
import type { IssueSourcePreference } from '../../shared/types'
|
||||
|
||||
function encodedProject(projectPath: string): string {
|
||||
return encodeURIComponent(projectPath)
|
||||
@@ -79,16 +81,18 @@ async function fetchDiscussions(
|
||||
repoPath: string,
|
||||
projectRef: ProjectRef,
|
||||
type: 'issue' | 'mr',
|
||||
iid: number
|
||||
iid: number,
|
||||
connectionId?: string | null
|
||||
): Promise<GitLabRawDiscussion[]> {
|
||||
const resource = type === 'mr' ? 'merge_requests' : 'issues'
|
||||
const { stdout } = await glabExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
'--paginate',
|
||||
`projects/${encodedProject(projectRef.path)}/${resource}/${iid}/discussions?per_page=100`
|
||||
],
|
||||
{ cwd: repoPath }
|
||||
glabRepoExecOptions(repoPath, connectionId)
|
||||
)
|
||||
return JSON.parse(stdout) as GitLabRawDiscussion[]
|
||||
}
|
||||
@@ -118,15 +122,17 @@ function mapPipelineJob(raw: GitLabRawJob): GitLabPipelineJob {
|
||||
async function fetchPipelineJobs(
|
||||
repoPath: string,
|
||||
projectRef: ProjectRef,
|
||||
pipelineId: number
|
||||
pipelineId: number,
|
||||
connectionId?: string | null
|
||||
): Promise<GitLabPipelineJob[]> {
|
||||
const { stdout } = await glabExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
'--paginate',
|
||||
`projects/${encodedProject(projectRef.path)}/pipelines/${pipelineId}/jobs?per_page=100`
|
||||
],
|
||||
{ cwd: repoPath }
|
||||
glabRepoExecOptions(repoPath, connectionId)
|
||||
)
|
||||
const data = JSON.parse(stdout) as GitLabRawJob[]
|
||||
return data.map(mapPipelineJob)
|
||||
@@ -157,33 +163,26 @@ type GitLabRawMR = Parameters<typeof mapMRToWorkItem>[0] & {
|
||||
export async function getWorkItemDetails(
|
||||
repoPath: string,
|
||||
iid: number,
|
||||
type: 'issue' | 'mr'
|
||||
type: 'issue' | 'mr',
|
||||
preference?: IssueSourcePreference,
|
||||
connectionId?: string | null,
|
||||
projectRefOverride?: ProjectRef | null
|
||||
): Promise<GitLabWorkItemDetails | null> {
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
// Why: issues honor the upstream/origin preference (issues live on
|
||||
// upstream when a fork is checked out). MRs always target origin —
|
||||
// the fork model puts MRs against the project the user pushes to.
|
||||
// Why: detail fetches must use the same project source as the list row
|
||||
// that opened them, otherwise forked repos can show a row from one remote
|
||||
// and a detail sheet from another.
|
||||
const projectRef =
|
||||
type === 'issue'
|
||||
? await getIssueProjectRef(repoPath, knownHosts)
|
||||
: await getProjectRef(repoPath, knownHosts)
|
||||
|
||||
projectRefOverride ??
|
||||
(await resolveIssueSource(repoPath, preference, await getGlabKnownHosts(), connectionId)).source
|
||||
if (!projectRef) {
|
||||
return null
|
||||
}
|
||||
await acquire()
|
||||
try {
|
||||
if (projectRef) {
|
||||
if (type === 'issue') {
|
||||
return await fetchIssueDetails(repoPath, projectRef, iid)
|
||||
}
|
||||
return await fetchMRDetails(repoPath, projectRef, iid)
|
||||
}
|
||||
// Fallback — let glab infer project from cwd. This path is taken when
|
||||
// the repo's remote host is not in getGlabKnownHosts() (e.g. a fresh
|
||||
// self-hosted instance), but glab itself can still resolve it from the
|
||||
// local git config.
|
||||
if (type === 'issue') {
|
||||
return await fetchIssueDetailsFallback(repoPath, iid)
|
||||
return await fetchIssueDetails(repoPath, projectRef, iid, connectionId)
|
||||
}
|
||||
return await fetchMRDetailsFallback(repoPath, iid)
|
||||
return await fetchMRDetails(repoPath, projectRef, iid, connectionId)
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
@@ -194,19 +193,25 @@ export async function getWorkItemDetails(
|
||||
async function fetchIssueDetails(
|
||||
repoPath: string,
|
||||
projectRef: ProjectRef,
|
||||
iid: number
|
||||
iid: number,
|
||||
connectionId?: string | null
|
||||
): Promise<GitLabWorkItemDetails | null> {
|
||||
// Why: fan out the two reads. Issues don't have a pipeline so this
|
||||
// pair covers everything the dialog renders.
|
||||
const [issueRes, discussions] = await Promise.all([
|
||||
glabExecFileAsync(['api', `projects/${encodedProject(projectRef.path)}/issues/${iid}`], {
|
||||
cwd: repoPath
|
||||
}),
|
||||
fetchDiscussions(repoPath, projectRef, 'issue', iid)
|
||||
glabExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
`projects/${encodedProject(projectRef.path)}/issues/${iid}`
|
||||
],
|
||||
glabRepoExecOptions(repoPath, connectionId)
|
||||
),
|
||||
fetchDiscussions(repoPath, projectRef, 'issue', iid, connectionId)
|
||||
])
|
||||
const issueRaw = JSON.parse(issueRes.stdout) as GitLabRawIssue
|
||||
const item: Omit<GitLabWorkItem, 'repoId'> = (() => {
|
||||
const full = mapIssueToWorkItem(issueRaw, projectRef.path)
|
||||
const full = mapIssueToWorkItem(issueRaw, projectRef.path, projectRef)
|
||||
// Why: omit repoId from the returned shape — the renderer stamps
|
||||
// it from the dialog's caller (TaskPage / picker) so the main
|
||||
// process doesn't need to know Orca's Repo.id.
|
||||
@@ -223,54 +228,36 @@ async function fetchIssueDetails(
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchIssueDetailsFallback(
|
||||
repoPath: string,
|
||||
iid: number
|
||||
): Promise<GitLabWorkItemDetails | null> {
|
||||
const { stdout } = await glabExecFileAsync(['issue', 'view', String(iid), '--output', 'json'], {
|
||||
cwd: repoPath
|
||||
})
|
||||
const issueRaw = JSON.parse(stdout) as GitLabRawIssue
|
||||
const item: Omit<GitLabWorkItem, 'repoId'> = (() => {
|
||||
const full = mapIssueToWorkItem(issueRaw, 'unknown')
|
||||
const { repoId: _repoId, ...rest } = full
|
||||
return rest
|
||||
})()
|
||||
return {
|
||||
item,
|
||||
body: issueRaw.description ?? '',
|
||||
comments: [],
|
||||
assignees: (issueRaw.assignees ?? [])
|
||||
.map((a) => a?.username)
|
||||
.filter((u): u is string => typeof u === 'string')
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMRDetails(
|
||||
repoPath: string,
|
||||
projectRef: ProjectRef,
|
||||
iid: number
|
||||
iid: number,
|
||||
connectionId?: string | null
|
||||
): Promise<GitLabWorkItemDetails | null> {
|
||||
// Why: MR detail + discussions in parallel. The pipeline jobs fetch
|
||||
// depends on `head_pipeline.id` from the MR payload, so it has to
|
||||
// wait — but it's a single follow-up call rather than a serial chain.
|
||||
const [mrRes, discussions] = await Promise.all([
|
||||
glabExecFileAsync(
|
||||
['api', `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`],
|
||||
{ cwd: repoPath }
|
||||
[
|
||||
'api',
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
`projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`
|
||||
],
|
||||
glabRepoExecOptions(repoPath, connectionId)
|
||||
),
|
||||
fetchDiscussions(repoPath, projectRef, 'mr', iid)
|
||||
fetchDiscussions(repoPath, projectRef, 'mr', iid, connectionId)
|
||||
])
|
||||
const mrRaw = JSON.parse(mrRes.stdout) as GitLabRawMR
|
||||
const item: Omit<GitLabWorkItem, 'repoId'> = (() => {
|
||||
const full = mapMRToWorkItem(mrRaw, projectRef.path)
|
||||
const full = mapMRToWorkItem(mrRaw, projectRef.path, projectRef)
|
||||
const { repoId: _repoId, ...rest } = full
|
||||
return rest
|
||||
})()
|
||||
const pipelineId = mrRaw.head_pipeline?.id
|
||||
const pipelineJobs =
|
||||
typeof pipelineId === 'number'
|
||||
? await fetchPipelineJobs(repoPath, projectRef, pipelineId).catch(() => [])
|
||||
? await fetchPipelineJobs(repoPath, projectRef, pipelineId, connectionId).catch(() => [])
|
||||
: undefined
|
||||
return {
|
||||
item,
|
||||
@@ -281,23 +268,3 @@ async function fetchMRDetails(
|
||||
...(pipelineJobs !== undefined ? { pipelineJobs } : {})
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMRDetailsFallback(
|
||||
repoPath: string,
|
||||
iid: number
|
||||
): Promise<GitLabWorkItemDetails | null> {
|
||||
const { stdout } = await glabExecFileAsync(['mr', 'view', String(iid), '--output', 'json'], {
|
||||
cwd: repoPath
|
||||
})
|
||||
const mrRaw = JSON.parse(stdout) as GitLabRawMR
|
||||
const item: Omit<GitLabWorkItem, 'repoId'> = (() => {
|
||||
const full = mapMRToWorkItem(mrRaw, 'unknown')
|
||||
const { repoId: _repoId, ...rest } = full
|
||||
return rest
|
||||
})()
|
||||
return {
|
||||
item,
|
||||
body: mrRaw.description ?? '',
|
||||
comments: []
|
||||
}
|
||||
}
|
||||
|
||||
+88
-20
@@ -65,6 +65,10 @@ function normalizeIssueAssignee(value: unknown): '@me' | undefined {
|
||||
return value === '@me' ? '@me' : undefined
|
||||
}
|
||||
|
||||
function repoConnectionId(repo: Repo): string | null {
|
||||
return repo.connectionId ?? null
|
||||
}
|
||||
|
||||
export function registerGitLabHandlers(store: Store): void {
|
||||
ipcMain.handle('gitlab:viewer', async () => {
|
||||
return getAuthenticatedViewer()
|
||||
@@ -72,20 +76,25 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
|
||||
ipcMain.handle('gitlab:projectSlug', async (_event, args: { repoPath: string }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
return getProjectSlug(repo.path)
|
||||
return getProjectSlug(repo.path, repoConnectionId(repo))
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'gitlab:mrForBranch',
|
||||
async (_event, args: { repoPath: string; branch: string; linkedMRIid?: number | null }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
return getMergeRequestForBranch(repo.path, args.branch, args.linkedMRIid ?? null)
|
||||
return getMergeRequestForBranch(
|
||||
repo.path,
|
||||
args.branch,
|
||||
args.linkedMRIid ?? null,
|
||||
repoConnectionId(repo)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('gitlab:mr', async (_event, args: { repoPath: string; iid: number }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
return getMergeRequest(repo.path, args.iid)
|
||||
return getMergeRequest(repo.path, args.iid, repoConnectionId(repo))
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
@@ -103,14 +112,21 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
const state = normalizeMRListState(args.state)
|
||||
const page = normalizePositiveInteger(args.page, 1, 10_000)
|
||||
const perPage = normalizePositiveInteger(args.perPage, 20, 100)
|
||||
const result = await listMergeRequests(repo.path, state, page, perPage)
|
||||
return result
|
||||
return listMergeRequests(
|
||||
repo.path,
|
||||
state,
|
||||
page,
|
||||
perPage,
|
||||
repo.issueSourcePreference,
|
||||
undefined,
|
||||
repoConnectionId(repo)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('gitlab:issue', async (_event, args: { repoPath: string; number: number }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
return getIssue(repo.path, args.number)
|
||||
return getIssue(repo.path, args.number, repoConnectionId(repo))
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
@@ -128,7 +144,14 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
const limit = normalizePositiveInteger(args.limit, 20, 100)
|
||||
const state = normalizeIssueListState(args.state)
|
||||
const assignee = normalizeIssueAssignee(args.assignee)
|
||||
const result = await listIssues(repo.path, limit, undefined, state, assignee)
|
||||
const result = await listIssues(
|
||||
repo.path,
|
||||
limit,
|
||||
repo.issueSourcePreference,
|
||||
state,
|
||||
assignee,
|
||||
repoConnectionId(repo)
|
||||
)
|
||||
// Why: Tasks page expects GitLabWorkItem[] so it can share row
|
||||
// rendering with MRs. Map IssueInfo → WorkItem here so the renderer
|
||||
// doesn't need a separate code path.
|
||||
@@ -152,7 +175,13 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
'gitlab:createIssue',
|
||||
async (_event, args: { repoPath: string; title: string; body: string }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
return createIssue(repo.path, args.title, args.body)
|
||||
return createIssue(
|
||||
repo.path,
|
||||
args.title,
|
||||
args.body,
|
||||
repo.issueSourcePreference,
|
||||
repoConnectionId(repo)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -160,7 +189,13 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
'gitlab:updateIssue',
|
||||
async (_event, args: { repoPath: string; number: number; updates: GitLabIssueUpdate }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
return updateIssue(repo.path, args.number, args.updates)
|
||||
return updateIssue(
|
||||
repo.path,
|
||||
args.number,
|
||||
args.updates,
|
||||
repo.issueSourcePreference,
|
||||
repoConnectionId(repo)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -168,18 +203,24 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
'gitlab:addIssueComment',
|
||||
async (_event, args: { repoPath: string; number: number; body: string }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
return addIssueComment(repo.path, args.number, args.body)
|
||||
return addIssueComment(
|
||||
repo.path,
|
||||
args.number,
|
||||
args.body,
|
||||
repo.issueSourcePreference,
|
||||
repoConnectionId(repo)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('gitlab:listLabels', async (_event, args: { repoPath: string }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
return listLabels(repo.path)
|
||||
return listLabels(repo.path, repo.issueSourcePreference, repoConnectionId(repo))
|
||||
})
|
||||
|
||||
ipcMain.handle('gitlab:listAssignableUsers', async (_event, args: { repoPath: string }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
return listAssignableUsers(repo.path)
|
||||
return listAssignableUsers(repo.path, repo.issueSourcePreference, repoConnectionId(repo))
|
||||
})
|
||||
|
||||
// Why: combined MR + issue list — Tasks screen and any future picker
|
||||
@@ -201,7 +242,10 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
repo.path,
|
||||
normalizeMRListState(args.state),
|
||||
normalizePositiveInteger(args.page, 1, 10_000),
|
||||
normalizePositiveInteger(args.perPage, 20, 100)
|
||||
normalizePositiveInteger(args.perPage, 20, 100),
|
||||
repo.issueSourcePreference,
|
||||
undefined,
|
||||
repoConnectionId(repo)
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -212,18 +256,24 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
'gitlab:workItemDetails',
|
||||
async (_event, args: { repoPath: string; iid: number; type: 'issue' | 'mr' }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
return getWorkItemDetails(repo.path, args.iid, args.type)
|
||||
return getWorkItemDetails(
|
||||
repo.path,
|
||||
args.iid,
|
||||
args.type,
|
||||
repo.issueSourcePreference,
|
||||
repoConnectionId(repo)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('gitlab:closeMR', async (_event, args: { repoPath: string; iid: number }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
return closeMR(repo.path, args.iid)
|
||||
return closeMR(repo.path, args.iid, repo.issueSourcePreference, repoConnectionId(repo))
|
||||
})
|
||||
|
||||
ipcMain.handle('gitlab:reopenMR', async (_event, args: { repoPath: string; iid: number }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
return reopenMR(repo.path, args.iid)
|
||||
return reopenMR(repo.path, args.iid, repo.issueSourcePreference, repoConnectionId(repo))
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
@@ -233,7 +283,13 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
args: { repoPath: string; iid: number; method?: 'merge' | 'squash' | 'rebase' }
|
||||
) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
return mergeMR(repo.path, args.iid, args.method ?? 'merge')
|
||||
return mergeMR(
|
||||
repo.path,
|
||||
args.iid,
|
||||
args.method ?? 'merge',
|
||||
repo.issueSourcePreference,
|
||||
repoConnectionId(repo)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -241,7 +297,13 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
'gitlab:addMRComment',
|
||||
async (_event, args: { repoPath: string; iid: number; body: string }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
return addMRComment(repo.path, args.iid, args.body)
|
||||
return addMRComment(
|
||||
repo.path,
|
||||
args.iid,
|
||||
args.body,
|
||||
repo.issueSourcePreference,
|
||||
repoConnectionId(repo)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -250,7 +312,7 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
// care about cwd because the endpoint is user-scoped.
|
||||
ipcMain.handle('gitlab:todos', async (_event, args: { repoPath: string }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
return listTodos(repo.path)
|
||||
return listTodos(repo.path, repoConnectionId(repo))
|
||||
})
|
||||
|
||||
// Why: paste-URL flow in the picker. The user pastes a GitLab URL that
|
||||
@@ -271,7 +333,13 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
const projectRef: ProjectRef = { host: args.host, path: args.path }
|
||||
const result = await getWorkItemByProjectRef(repo.path, projectRef, args.iid, args.type)
|
||||
const result = await getWorkItemByProjectRef(
|
||||
repo.path,
|
||||
projectRef,
|
||||
args.iid,
|
||||
args.type,
|
||||
repoConnectionId(repo)
|
||||
)
|
||||
// Why: only persist a recent entry when the lookup actually
|
||||
// produced an item. A 404 / auth failure shouldn't pollute the
|
||||
// user's recents list with project paths they can't read.
|
||||
|
||||
@@ -77,42 +77,30 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector
|
||||
'hostedReview:getCreationEligibility',
|
||||
async (_event, args: HostedReviewCreationEligibilityArgs) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
if (repo.connectionId) {
|
||||
return {
|
||||
provider: 'unsupported' as const,
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'unsupported_provider' as const,
|
||||
nextAction: null,
|
||||
defaultBaseRef: args.base ?? null,
|
||||
head: args.branch,
|
||||
title: null,
|
||||
body: null
|
||||
}
|
||||
}
|
||||
const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath)
|
||||
return getHostedReviewCreationEligibility({ ...args, repoPath: worktreePath })
|
||||
return getHostedReviewCreationEligibility({
|
||||
...args,
|
||||
repoPath: worktreePath,
|
||||
connectionId: repo.connectionId ?? null
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('hostedReview:create', async (_event, args: CreateHostedReviewArgs) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
if (repo.connectionId) {
|
||||
return {
|
||||
ok: false as const,
|
||||
code: 'unsupported_provider' as const,
|
||||
error: 'Creating pull requests from SSH worktrees is not supported yet.'
|
||||
}
|
||||
}
|
||||
const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath)
|
||||
const result = await createHostedReview(worktreePath, {
|
||||
provider: args.provider,
|
||||
base: args.base,
|
||||
head: args.head,
|
||||
title: args.title,
|
||||
body: args.body,
|
||||
draft: args.draft
|
||||
})
|
||||
const result = await createHostedReview(
|
||||
worktreePath,
|
||||
{
|
||||
provider: args.provider,
|
||||
base: args.base,
|
||||
head: args.head,
|
||||
title: args.title,
|
||||
body: args.body,
|
||||
draft: args.draft
|
||||
},
|
||||
repo.connectionId ?? null
|
||||
)
|
||||
if (result.ok && !stats.hasCountedPR(result.url)) {
|
||||
stats.record({
|
||||
type: 'pr_created',
|
||||
|
||||
+26
-6
@@ -32,6 +32,19 @@ import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
let sshStore: SshConnectionStore | null = null
|
||||
let connectionManager: SshConnectionManager | null = null
|
||||
let portForwardManager: SshPortForwardManager | null = null
|
||||
let registeredConnectSshTarget: ((targetId: string) => Promise<SshConnectionState>) | null = null
|
||||
let registeredGetSshState: ((targetId: string) => SshConnectionState | undefined) | null = null
|
||||
|
||||
export async function connectRegisteredSshTarget(targetId: string): Promise<SshConnectionState> {
|
||||
if (!registeredConnectSshTarget) {
|
||||
throw new Error('ssh_handlers_not_registered')
|
||||
}
|
||||
return registeredConnectSshTarget(targetId)
|
||||
}
|
||||
|
||||
export function getRegisteredSshState(targetId: string): SshConnectionState | undefined {
|
||||
return registeredGetSshState?.(targetId)
|
||||
}
|
||||
|
||||
// Why: one session per SSH target encapsulates the entire relay lifecycle
|
||||
// (multiplexer, providers, abort controller, state machine). Eliminates the
|
||||
@@ -370,8 +383,8 @@ export function registerSshHandlers(
|
||||
|
||||
// ── Connection lifecycle ───────────────────────────────────────────
|
||||
|
||||
ipcMain.handle('ssh:connect', async (_event, args: { targetId: string }) => {
|
||||
const reset = resetRelayInFlight.get(args.targetId)
|
||||
async function connectTarget(targetId: string): Promise<SshConnectionState> {
|
||||
const reset = resetRelayInFlight.get(targetId)
|
||||
if (reset) {
|
||||
await reset
|
||||
}
|
||||
@@ -379,18 +392,25 @@ export function registerSshHandlers(
|
||||
// Why: serialize concurrent ssh:connect calls for the same target.
|
||||
// Multiple tabs can fire connect simultaneously; without this, they
|
||||
// interleave and the first session leaks.
|
||||
const existing = connectInFlight.get(args.targetId)
|
||||
const existing = connectInFlight.get(targetId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const promise = doConnect(args.targetId)
|
||||
connectInFlight.set(args.targetId, promise)
|
||||
const promise = doConnect(targetId)
|
||||
connectInFlight.set(targetId, promise)
|
||||
try {
|
||||
return await promise
|
||||
} finally {
|
||||
connectInFlight.delete(args.targetId)
|
||||
connectInFlight.delete(targetId)
|
||||
}
|
||||
}
|
||||
|
||||
registeredConnectSshTarget = connectTarget
|
||||
registeredGetSshState = (targetId: string) => getPublicSshState(targetId)
|
||||
|
||||
ipcMain.handle('ssh:connect', async (_event, args: { targetId: string }) => {
|
||||
return connectTarget(args.targetId)
|
||||
})
|
||||
|
||||
async function doConnect(targetId: string): Promise<SshConnectionState> {
|
||||
|
||||
@@ -643,10 +643,6 @@ export async function createRemoteWorktree(
|
||||
store: Store,
|
||||
mainWindow: BrowserWindow
|
||||
): Promise<CreateWorktreeResult> {
|
||||
if (args.sparseCheckout) {
|
||||
throw new Error('Sparse checkout is not supported for remote SSH repos yet.')
|
||||
}
|
||||
|
||||
const provider = requireSshGitProvider(repo.connectionId!)
|
||||
|
||||
const settings = store.getSettings()
|
||||
@@ -721,6 +717,31 @@ export async function createRemoteWorktree(
|
||||
}
|
||||
}
|
||||
|
||||
const sparseDirectories = args.sparseCheckout
|
||||
? normalizeSparseDirectories(args.sparseCheckout.directories)
|
||||
: []
|
||||
if (args.sparseCheckout && sparseDirectories.length === 0) {
|
||||
throw new Error('Sparse checkout requires at least one repo-relative directory.')
|
||||
}
|
||||
let sparsePresetId: string | undefined
|
||||
if (args.sparseCheckout?.presetId) {
|
||||
const preset = store
|
||||
.getSparsePresets(repo.id)
|
||||
.find((entry) => entry.id === args.sparseCheckout?.presetId)
|
||||
if (preset?.repoId === repo.id) {
|
||||
try {
|
||||
const presetDirectories = normalizeSparseDirectories(preset.directories)
|
||||
const presetSet = new Set(presetDirectories)
|
||||
const directoriesMatch =
|
||||
presetDirectories.length === sparseDirectories.length &&
|
||||
sparseDirectories.every((entry) => presetSet.has(entry))
|
||||
sparsePresetId = directoriesMatch ? preset.id : undefined
|
||||
} catch {
|
||||
// Why: corrupt preset data should not block creation or falsely label the new worktree.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const remoteTrackingBase = await resolveRemoteTrackingBaseSsh(provider, repo.path, baseBranch)
|
||||
if (remoteTrackingBase) {
|
||||
try {
|
||||
@@ -799,7 +820,9 @@ export async function createRemoteWorktree(
|
||||
repo.path,
|
||||
branchName,
|
||||
remotePath,
|
||||
checkoutExistingBranch ? { checkoutExistingBranch } : { base: baseBranch }
|
||||
checkoutExistingBranch
|
||||
? { checkoutExistingBranch }
|
||||
: { base: baseBranch, ...(sparseDirectories.length > 0 ? { noCheckout: true } : {}) }
|
||||
)
|
||||
} catch (err) {
|
||||
if (
|
||||
@@ -819,6 +842,18 @@ export async function createRemoteWorktree(
|
||||
}
|
||||
throw err
|
||||
}
|
||||
if (sparseDirectories.length > 0) {
|
||||
try {
|
||||
// Why: SSH providers expose generic git exec, so the remote sparse flow
|
||||
// can mirror local addSparseWorktree without adding a relay method.
|
||||
await provider.exec(['sparse-checkout', 'init', '--cone'], remotePath)
|
||||
await provider.exec(['sparse-checkout', 'set', '--', ...sparseDirectories], remotePath)
|
||||
await provider.exec(['checkout', branchName], remotePath)
|
||||
} catch (err) {
|
||||
await provider.removeWorktree(remotePath, true).catch(() => undefined)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Re-list to get the created worktree info
|
||||
const gitWorktrees = await provider.listWorktrees(repo.path)
|
||||
@@ -862,12 +897,19 @@ export async function createRemoteWorktree(
|
||||
? { displayName: requestedName }
|
||||
: {}),
|
||||
...(isTuiAgent(args.createdWithAgent) ? { createdWithAgent: args.createdWithAgent } : {}),
|
||||
...(sparseDirectories.length > 0
|
||||
? {
|
||||
sparseDirectories,
|
||||
sparseBaseRef: baseBranch,
|
||||
sparsePresetId
|
||||
}
|
||||
: {}),
|
||||
...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}),
|
||||
...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}),
|
||||
...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}),
|
||||
...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}),
|
||||
...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}),
|
||||
...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}),
|
||||
...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}),
|
||||
...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {})
|
||||
}
|
||||
const meta = store.setWorktreeMeta(worktreeId, metaUpdates)
|
||||
@@ -1249,8 +1291,8 @@ export async function createLocalWorktree(
|
||||
...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}),
|
||||
...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}),
|
||||
...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}),
|
||||
...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}),
|
||||
...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}),
|
||||
...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}),
|
||||
...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {})
|
||||
}
|
||||
const meta = store.setWorktreeMeta(worktreeId, metaUpdates)
|
||||
|
||||
@@ -204,6 +204,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
recordOptimisticReconcileToken: ReturnType<typeof vi.fn>
|
||||
reconcileWorktreeBaseStatus: ReturnType<typeof vi.fn>
|
||||
clearOptimisticReconcileToken: ReturnType<typeof vi.fn>
|
||||
resolveManagedMrBase: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -367,11 +368,17 @@ describe('registerWorktreeHandlers', () => {
|
||||
emitWorktreeBaseStatus: vi.fn(),
|
||||
recordOptimisticReconcileToken: vi.fn().mockReturnValue('token-1'),
|
||||
reconcileWorktreeBaseStatus: vi.fn(),
|
||||
clearOptimisticReconcileToken: vi.fn()
|
||||
clearOptimisticReconcileToken: vi.fn(),
|
||||
resolveManagedMrBase: vi.fn().mockResolvedValue({ baseBranch: 'origin/mr-branch' })
|
||||
}
|
||||
registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never)
|
||||
})
|
||||
|
||||
it('clears the GitLab MR base handler before re-registering IPC handlers', () => {
|
||||
expect(removeHandlerMock).toHaveBeenCalledWith('worktrees:resolveMrBase')
|
||||
expect(handlers['worktrees:resolveMrBase']).toBeDefined()
|
||||
})
|
||||
|
||||
function mockKnownFeatureWorktree(path = '/workspace/feature-wt'): void {
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
@@ -961,6 +968,31 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('delegates GitLab MR base resolution through the runtime implementation', async () => {
|
||||
runtimeStub.resolveManagedMrBase.mockResolvedValueOnce({
|
||||
baseBranch: 'fork-mr-sha',
|
||||
pushTarget: { remoteName: 'origin', branchName: 'feature/mr' }
|
||||
})
|
||||
|
||||
const result = await handlers['worktrees:resolveMrBase'](null, {
|
||||
repoId: 'repo-1',
|
||||
mrIid: 42,
|
||||
sourceBranch: 'feature/mr',
|
||||
isCrossRepository: true
|
||||
})
|
||||
|
||||
expect(runtimeStub.resolveManagedMrBase).toHaveBeenCalledWith({
|
||||
repoSelector: 'id:repo-1',
|
||||
mrIid: 42,
|
||||
sourceBranch: 'feature/mr',
|
||||
isCrossRepository: true
|
||||
})
|
||||
expect(result).toEqual({
|
||||
baseBranch: 'fork-mr-sha',
|
||||
pushTarget: { remoteName: 'origin', branchName: 'feature/mr' }
|
||||
})
|
||||
})
|
||||
|
||||
it('persists linked issue, PR, and selected agent metadata during remote create', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
@@ -1120,6 +1152,102 @@ describe('registerWorktreeHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('creates sparse checkout metadata and remote sparse config for SSH worktrees', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/repo',
|
||||
displayName: 'ssh',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'conn-1',
|
||||
worktreeBaseRef: 'origin/main'
|
||||
}
|
||||
const provider = {
|
||||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'remote') {
|
||||
return { stdout: 'origin\n', stderr: '' }
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
}),
|
||||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
removeWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: '/remote/sparse-dashboard',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/sparse-dashboard',
|
||||
isBare: false,
|
||||
isSparse: true,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
}
|
||||
const mux = {
|
||||
request: vi.fn().mockResolvedValue(undefined),
|
||||
notify: vi.fn()
|
||||
}
|
||||
store.getRepos.mockReturnValue([repo])
|
||||
store.getRepo.mockReturnValue(repo)
|
||||
store.getSparsePresets.mockReturnValue([
|
||||
{
|
||||
id: 'preset-1',
|
||||
repoId: 'repo-ssh',
|
||||
name: 'App',
|
||||
directories: ['apps/mobile', 'packages/shared'],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
])
|
||||
getSshGitProviderMock.mockReturnValue(provider)
|
||||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||||
|
||||
const result = await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-ssh',
|
||||
name: 'sparse-dashboard',
|
||||
sparseCheckout: {
|
||||
directories: [' apps/mobile ', 'packages/shared', 'apps/mobile'],
|
||||
presetId: 'preset-1'
|
||||
}
|
||||
})
|
||||
|
||||
expect(provider.addWorktree).toHaveBeenCalledWith(
|
||||
'/remote/repo',
|
||||
'sparse-dashboard',
|
||||
'/remote/repo/../sparse-dashboard',
|
||||
{ base: 'origin/main', noCheckout: true }
|
||||
)
|
||||
expect(provider.exec).toHaveBeenCalledWith(
|
||||
['sparse-checkout', 'init', '--cone'],
|
||||
'/remote/repo/../sparse-dashboard'
|
||||
)
|
||||
expect(provider.exec).toHaveBeenCalledWith(
|
||||
['sparse-checkout', 'set', '--', 'apps/mobile', 'packages/shared'],
|
||||
'/remote/repo/../sparse-dashboard'
|
||||
)
|
||||
expect(provider.exec).toHaveBeenCalledWith(
|
||||
['checkout', 'sparse-dashboard'],
|
||||
'/remote/repo/../sparse-dashboard'
|
||||
)
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-ssh::/remote/sparse-dashboard',
|
||||
expect.objectContaining({
|
||||
sparseDirectories: ['apps/mobile', 'packages/shared'],
|
||||
sparseBaseRef: 'origin/main',
|
||||
sparsePresetId: 'preset-1'
|
||||
})
|
||||
)
|
||||
expect(result).toEqual({
|
||||
worktree: expect.objectContaining({
|
||||
isSparse: true,
|
||||
sparseDirectories: ['apps/mobile', 'packages/shared'],
|
||||
sparseBaseRef: 'origin/main',
|
||||
sparsePresetId: 'preset-1'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('does not create an SSH worktree when remote-tracking base refresh fails', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
|
||||
+11
-95
@@ -22,10 +22,8 @@ import {
|
||||
} from '../git/worktree'
|
||||
import { gitExecFileAsync } from '../git/runner'
|
||||
import { withWorktreeSpan } from '../observability/instrumentation'
|
||||
import { getDefaultRemote } from '../git/repo'
|
||||
import { resolveGitHubPrStartPoint } from '../github/pr-start-point'
|
||||
import { getProjectRef as getGlabProjectRef, getGlabKnownHosts } from '../gitlab/gl-utils'
|
||||
import { getWorkItemByProjectRef as getGitLabWorkItemByProjectRef } from '../gitlab/client'
|
||||
import { getDefaultRemote } from '../git/repo'
|
||||
import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees'
|
||||
import { getSshGitProvider, requireSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
@@ -234,6 +232,7 @@ export function registerWorktreeHandlers(
|
||||
ipcMain.removeHandler('worktrees:list')
|
||||
ipcMain.removeHandler('worktrees:create')
|
||||
ipcMain.removeHandler('worktrees:resolvePrBase')
|
||||
ipcMain.removeHandler('worktrees:resolveMrBase')
|
||||
ipcMain.removeHandler('worktrees:remove')
|
||||
ipcMain.removeHandler('worktrees:updateMeta')
|
||||
ipcMain.removeHandler('worktrees:listLineage')
|
||||
@@ -488,13 +487,8 @@ export function registerWorktreeHandlers(
|
||||
}
|
||||
)
|
||||
|
||||
// Why: GitLab parallel of worktrees:resolvePrBase. Same shape, same
|
||||
// semantics — caller passes mrIid (with optional source_branch +
|
||||
// isCrossRepository hints from the picker) and we return either a
|
||||
// `<remote>/<source_branch>` ref (same-project MRs) or a SHA fetched
|
||||
// from refs/merge-requests/<iid>/head (fork MRs). The returned value
|
||||
// is the workspace's base ref; the new worktree branch derives from
|
||||
// the workspace name, not from the source ref.
|
||||
// Why: keep desktop IPC and mobile/runtime RPC on the same MR base
|
||||
// resolution path so SSH repos do not regress differently by surface.
|
||||
ipcMain.handle(
|
||||
'worktrees:resolveMrBase',
|
||||
async (
|
||||
@@ -505,91 +499,13 @@ export function registerWorktreeHandlers(
|
||||
sourceBranch?: string
|
||||
isCrossRepository?: boolean
|
||||
}
|
||||
): Promise<{ baseBranch: string } | { error: string }> => {
|
||||
const repo = store.getRepo(args.repoId)
|
||||
if (!repo) {
|
||||
return { error: 'Repo not found' }
|
||||
}
|
||||
// Why: parity with the gh-side guard above. Remote SSH repos are
|
||||
// out of v1 scope; the picker disables the GitLab tab for them too.
|
||||
if (repo.connectionId) {
|
||||
return { error: 'MR start points are not supported for remote repos yet.' }
|
||||
}
|
||||
if (isFolderRepo(repo)) {
|
||||
return { error: 'Folder mode does not support creating worktrees.' }
|
||||
}
|
||||
|
||||
let sourceBranch = args.sourceBranch?.trim() ?? ''
|
||||
let isCrossRepository = args.isCrossRepository === true
|
||||
|
||||
if (!sourceBranch) {
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const projectRef = await getGlabProjectRef(repo.path, knownHosts)
|
||||
if (!projectRef) {
|
||||
return { error: 'No GitLab project found for this repository.' }
|
||||
}
|
||||
const item = await getGitLabWorkItemByProjectRef(repo.path, projectRef, args.mrIid, 'mr')
|
||||
if (!item || item.type !== 'mr') {
|
||||
return { error: `MR !${args.mrIid} not found.` }
|
||||
}
|
||||
sourceBranch = (item.branchName ?? '').trim()
|
||||
if (!sourceBranch) {
|
||||
return { error: `MR !${args.mrIid} has no source branch.` }
|
||||
}
|
||||
if (item.isCrossRepository === true) {
|
||||
isCrossRepository = true
|
||||
}
|
||||
}
|
||||
|
||||
let remote: string
|
||||
try {
|
||||
remote = await getDefaultRemote(repo.path)
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' }
|
||||
}
|
||||
|
||||
// Why: GitLab exposes every MR head (fork or same-project) as
|
||||
// refs/merge-requests/<iid>/head on the target project. Using that
|
||||
// ref lets us snapshot fork MRs without configuring the fork as a
|
||||
// remote — same SHA-as-baseBranch shape as the gh-side branch above.
|
||||
if (isCrossRepository) {
|
||||
const mrRef = `refs/merge-requests/${args.mrIid}/head`
|
||||
try {
|
||||
await gitExecFileAsync(['fetch', remote, mrRef], { cwd: repo.path })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { error: `Failed to fetch ${mrRef}: ${message.split('\n')[0]}` }
|
||||
}
|
||||
let sha: string
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', 'FETCH_HEAD'], {
|
||||
cwd: repo.path
|
||||
})
|
||||
sha = stdout.trim()
|
||||
} catch {
|
||||
return { error: `Could not resolve fork MR !${args.mrIid} head after fetch.` }
|
||||
}
|
||||
if (!sha) {
|
||||
return { error: `Empty SHA resolving fork MR !${args.mrIid} head.` }
|
||||
}
|
||||
return { baseBranch: sha }
|
||||
}
|
||||
|
||||
try {
|
||||
await gitExecFileAsync(['fetch', remote, sourceBranch], { cwd: repo.path })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { error: `Failed to fetch ${remote}/${sourceBranch}: ${message.split('\n')[0]}` }
|
||||
}
|
||||
|
||||
const remoteRef = `${remote}/${sourceBranch}`
|
||||
try {
|
||||
await gitExecFileAsync(['rev-parse', '--verify', remoteRef], { cwd: repo.path })
|
||||
} catch {
|
||||
return { error: `Remote ref ${remoteRef} does not exist after fetch.` }
|
||||
}
|
||||
|
||||
return { baseBranch: remoteRef }
|
||||
): Promise<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }> => {
|
||||
return runtime.resolveManagedMrBase({
|
||||
repoSelector: `id:${args.repoId}`,
|
||||
mrIid: args.mrIid,
|
||||
sourceBranch: args.sourceBranch,
|
||||
isCrossRepository: args.isCrossRepository
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mapLinearIssue } from './mappers'
|
||||
|
||||
describe('mapLinearIssue', () => {
|
||||
it('keeps core issue details when optional Linear relations fail', async () => {
|
||||
const issue = {
|
||||
id: 'issue-1',
|
||||
identifier: 'LIN-1',
|
||||
title: 'Investigate mobile detail',
|
||||
description: 'Body',
|
||||
url: 'https://linear.app/acme/issue/LIN-1',
|
||||
estimate: 2,
|
||||
priority: 1,
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
state: Promise.reject(new Error('state fetch failed')),
|
||||
team: Promise.reject(new Error('team fetch failed')),
|
||||
assignee: Promise.reject(new Error('assignee fetch failed')),
|
||||
project: Promise.reject(new Error('project fetch failed')),
|
||||
labels: async () => ({
|
||||
nodes: [{ id: 'label-1', name: 'Bug' }]
|
||||
}),
|
||||
children: async () => ({
|
||||
nodes: [
|
||||
{
|
||||
id: 'child-1',
|
||||
identifier: 'LIN-2',
|
||||
title: 'Child',
|
||||
url: 'https://linear.app/acme/issue/LIN-2'
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
await expect(
|
||||
mapLinearIssue(issue as never, { includeChildren: true, includeProject: true })
|
||||
).resolves.toMatchObject({
|
||||
id: 'issue-1',
|
||||
identifier: 'LIN-1',
|
||||
title: 'Investigate mobile detail',
|
||||
labels: ['Bug'],
|
||||
subIssues: [{ id: 'child-1', identifier: 'LIN-2' }],
|
||||
state: { name: '' },
|
||||
team: { id: '' },
|
||||
assignee: undefined,
|
||||
project: undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,14 @@ type MapLinearIssueOptions = {
|
||||
includeProject?: boolean
|
||||
}
|
||||
|
||||
async function optionalRelation<T>(value: Promise<T> | T): Promise<T | undefined> {
|
||||
try {
|
||||
return await value
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function mapLinearIssueChild(issue: Issue): LinearIssueChildSummary {
|
||||
return {
|
||||
id: issue.id,
|
||||
@@ -28,8 +36,12 @@ export async function mapLinearIssue(
|
||||
issue: Issue | IssueSearchResult,
|
||||
options: MapLinearIssueOptions = {}
|
||||
): Promise<LinearIssue> {
|
||||
const [state, team, assignee] = await Promise.all([issue.state, issue.team, issue.assignee])
|
||||
const project = options.includeProject ? await issue.project : undefined
|
||||
const [state, team, assignee, project] = await Promise.all([
|
||||
optionalRelation(issue.state),
|
||||
optionalRelation(issue.team),
|
||||
optionalRelation(issue.assignee),
|
||||
options.includeProject ? optionalRelation(issue.project) : Promise.resolve(undefined)
|
||||
])
|
||||
|
||||
// Why: IssueSearchResult does not expose the labels() relation method — only
|
||||
// the raw labelIds array. For Issue instances we resolve actual label names;
|
||||
|
||||
@@ -547,12 +547,16 @@ describe('SshGitProvider', () => {
|
||||
})
|
||||
|
||||
it('addWorktree sends git.addWorktree request', async () => {
|
||||
await provider.addWorktree('/home/user/repo', 'feature', '/home/user/feat', { base: 'main' })
|
||||
await provider.addWorktree('/home/user/repo', 'feature', '/home/user/feat', {
|
||||
base: 'main',
|
||||
noCheckout: true
|
||||
})
|
||||
expect(mux.request).toHaveBeenCalledWith('git.addWorktree', {
|
||||
repoPath: '/home/user/repo',
|
||||
branchName: 'feature',
|
||||
targetDir: '/home/user/feat',
|
||||
base: 'main'
|
||||
base: 'main',
|
||||
noCheckout: true
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -373,7 +373,7 @@ export class SshGitProvider implements IGitProvider {
|
||||
repoPath: string,
|
||||
branchName: string,
|
||||
targetDir: string,
|
||||
options?: { base?: string; checkoutExistingBranch?: boolean }
|
||||
options?: { base?: string; checkoutExistingBranch?: boolean; noCheckout?: boolean }
|
||||
): Promise<void> {
|
||||
await this.mux.request('git.addWorktree', {
|
||||
repoPath,
|
||||
|
||||
@@ -191,7 +191,7 @@ export type IGitProvider = {
|
||||
repoPath: string,
|
||||
branchName: string,
|
||||
targetDir: string,
|
||||
options?: { base?: string; checkoutExistingBranch?: boolean }
|
||||
options?: { base?: string; checkoutExistingBranch?: boolean; noCheckout?: boolean }
|
||||
): Promise<void>
|
||||
removeWorktree(
|
||||
worktreePath: string,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable max-lines -- Why: Claude rate-limit fallback tests share account/keychain/PTY mocks that would be noisier split apart. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -269,7 +270,15 @@ describe('fetchClaudeRateLimits', () => {
|
||||
})
|
||||
)
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ error: { type: 'rate_limit_error' } }), { status: 429 })
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
type: 'rate_limit_error',
|
||||
message: 'Rate limited. Please try again later.'
|
||||
}
|
||||
}),
|
||||
{ status: 429 }
|
||||
)
|
||||
)
|
||||
|
||||
await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { readdirSync, readFileSync, statSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ALL_RPC_METHODS } from './rpc/methods'
|
||||
|
||||
const MOBILE_DYNAMIC_RPC_METHODS = [
|
||||
// Why: computed sendRequest method names do not appear as literals in the
|
||||
// mobile source scan below, but still must stay mobile-authorized.
|
||||
'accounts.selectClaude',
|
||||
'accounts.selectCodex',
|
||||
'github.updateIssue',
|
||||
'github.updatePRState',
|
||||
'gitlab.updateIssue',
|
||||
'gitlab.updateMR'
|
||||
]
|
||||
|
||||
function listSourceFiles(root: string): string[] {
|
||||
const entries = readdirSync(root)
|
||||
const files: string[] = []
|
||||
for (const entry of entries) {
|
||||
const path = join(root, entry)
|
||||
const stat = statSync(path)
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...listSourceFiles(path))
|
||||
continue
|
||||
}
|
||||
if (!/\.[cm]?[jt]sx?$/.test(entry) || /\.test\.[cm]?[jt]sx?$/.test(entry)) {
|
||||
continue
|
||||
}
|
||||
files.push(path)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
function mobileLiteralRpcMethods(): string[] {
|
||||
const roots = [join(process.cwd(), 'mobile/app'), join(process.cwd(), 'mobile/src')]
|
||||
const methods = new Set<string>()
|
||||
for (const file of roots.flatMap(listSourceFiles)) {
|
||||
const source = readFileSync(file, 'utf8')
|
||||
for (const match of source.matchAll(/sendRequest\(\s*['"]([^'"]+)/g)) {
|
||||
methods.add(match[1]!)
|
||||
}
|
||||
for (const match of source.matchAll(/subscribe\(\s*['"]([^'"]+)/g)) {
|
||||
methods.add(match[1]!)
|
||||
}
|
||||
for (const match of source.matchAll(/method:\s*['"]([^'"]+)/g)) {
|
||||
const method = match[1]!
|
||||
if (method.includes('.')) {
|
||||
methods.add(method)
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...methods].sort()
|
||||
}
|
||||
|
||||
function mobileRpcMethods(): string[] {
|
||||
return [...new Set([...mobileLiteralRpcMethods(), ...MOBILE_DYNAMIC_RPC_METHODS])].sort()
|
||||
}
|
||||
|
||||
function mobileRpcAllowlist(): Set<string> {
|
||||
const source = readFileSync(join(process.cwd(), 'src/main/runtime/runtime-rpc.ts'), 'utf8')
|
||||
const allowlist = source.match(/const MOBILE_RPC_METHOD_ALLOWLIST = new Set\(\[([\s\S]*?)\]\)/)
|
||||
if (!allowlist) {
|
||||
throw new Error('MOBILE_RPC_METHOD_ALLOWLIST not found')
|
||||
}
|
||||
return new Set([...allowlist[1]!.matchAll(/'([^']+)'/g)].map((match) => match[1]!))
|
||||
}
|
||||
|
||||
function registeredRuntimeMethods(): Set<string> {
|
||||
return new Set(ALL_RPC_METHODS.map((method) => method.name))
|
||||
}
|
||||
|
||||
describe('mobile RPC allowlist', () => {
|
||||
it('allows every RPC method used by the mobile app', () => {
|
||||
// Why: mobile-scoped runtime tokens are checked before dispatch. A mobile
|
||||
// feature can compile and still fail at runtime if its method is missing here.
|
||||
const allowed = mobileRpcAllowlist()
|
||||
const missing = mobileRpcMethods().filter((method) => !allowed.has(method))
|
||||
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
|
||||
it('registers every RPC method used by the mobile app', () => {
|
||||
// Why: the allowlist check runs before dispatch, but an allowlisted mobile
|
||||
// method still fails at runtime if it was never added to ALL_RPC_METHODS.
|
||||
const registered = registeredRuntimeMethods()
|
||||
const missing = mobileRpcMethods().filter((method) => !registered.has(method))
|
||||
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
+1187
-108
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,80 @@ function makeRequest(method: string, params?: unknown): RpcRequest {
|
||||
}
|
||||
|
||||
describe('client UI RPC methods', () => {
|
||||
it('returns the runtime host agent settings needed by mobile create flows', async () => {
|
||||
const settings = {
|
||||
defaultTuiAgent: 'codex',
|
||||
agentCmdOverrides: { codex: 'codex --profile work' },
|
||||
defaultTaskSource: 'gitlab',
|
||||
defaultTaskViewPreset: 'my-prs',
|
||||
visibleTaskProviders: ['github', 'gitlab'],
|
||||
defaultRepoSelection: ['repo-1'],
|
||||
defaultLinearTeamSelection: ['team-1'],
|
||||
githubProjects: {
|
||||
pinned: [],
|
||||
recent: [],
|
||||
lastViewByProject: {},
|
||||
activeProject: null
|
||||
}
|
||||
}
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
getClientSettings: vi.fn(() => settings)
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(makeRequest('settings.get'))
|
||||
|
||||
expect(runtime.getClientSettings).toHaveBeenCalledTimes(1)
|
||||
expect(response).toMatchObject({ ok: true, result: { settings } })
|
||||
})
|
||||
|
||||
it('persists the runtime host task source setting for mobile Tasks', async () => {
|
||||
const settings = {
|
||||
defaultTuiAgent: null,
|
||||
agentCmdOverrides: {},
|
||||
defaultTaskSource: 'linear',
|
||||
defaultTaskViewPreset: 'issues',
|
||||
visibleTaskProviders: ['github', 'linear'],
|
||||
defaultRepoSelection: ['repo-1', 'repo-2'],
|
||||
defaultLinearTeamSelection: ['team-1', 'team-2'],
|
||||
githubProjects: {
|
||||
pinned: [],
|
||||
recent: [],
|
||||
lastViewByProject: {
|
||||
'organization:stablyai:1': { viewId: 'view-1' }
|
||||
},
|
||||
activeProject: { owner: 'stablyai', ownerType: 'organization', number: 1 }
|
||||
}
|
||||
}
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
updateClientSettings: vi.fn(() => settings)
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('settings.update', {
|
||||
defaultTuiAgent: 'codex',
|
||||
defaultTaskSource: 'linear',
|
||||
defaultTaskViewPreset: 'my-prs',
|
||||
defaultRepoSelection: settings.defaultRepoSelection,
|
||||
defaultLinearTeamSelection: ['team-1', 'team-2'],
|
||||
githubProjects: settings.githubProjects
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.updateClientSettings).toHaveBeenCalledWith({
|
||||
defaultTuiAgent: 'codex',
|
||||
defaultTaskSource: 'linear',
|
||||
defaultTaskViewPreset: 'my-prs',
|
||||
defaultRepoSelection: settings.defaultRepoSelection,
|
||||
defaultLinearTeamSelection: ['team-1', 'team-2'],
|
||||
githubProjects: settings.githubProjects
|
||||
})
|
||||
expect(response).toMatchObject({ ok: true, result: { settings } })
|
||||
})
|
||||
|
||||
it('returns the runtime host persisted UI state', async () => {
|
||||
const ui: PersistedUIState = {
|
||||
...getDefaultUIState(),
|
||||
@@ -66,7 +140,10 @@ describe('client UI RPC methods', () => {
|
||||
statusBarItems: ['codex'],
|
||||
taskResumeState: {
|
||||
githubMode: 'items',
|
||||
githubItemsQuery: 'is:open'
|
||||
githubItemsQuery: 'is:open',
|
||||
githubProjectHiddenFieldIdsByView: {
|
||||
'project-1:view-1': ['field-1']
|
||||
}
|
||||
},
|
||||
workspaceCleanup: {
|
||||
dismissals: {
|
||||
@@ -88,7 +165,13 @@ describe('client UI RPC methods', () => {
|
||||
const payload = {
|
||||
worktreeCardProperties: ['status', 'inline-agents'],
|
||||
statusBarItems: ['codex'],
|
||||
taskResumeState: { githubMode: 'items', githubItemsQuery: 'is:open' },
|
||||
taskResumeState: {
|
||||
githubMode: 'items',
|
||||
githubItemsQuery: 'is:open',
|
||||
githubProjectHiddenFieldIdsByView: {
|
||||
'project-1:view-1': ['field-1']
|
||||
}
|
||||
},
|
||||
workspaceCleanup: {
|
||||
dismissals: {
|
||||
'repo::/worktree': {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { isTuiAgent } from '../../../../shared/tui-agent-config'
|
||||
import type { PersistedUIState } from '../../../../shared/types'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
|
||||
@@ -27,6 +28,7 @@ const TaskResumeState = z
|
||||
githubMode: z.enum(['items', 'project']).optional(),
|
||||
githubItemsPreset: z.string().nullable().optional(),
|
||||
githubItemsQuery: z.string().optional(),
|
||||
githubProjectHiddenFieldIdsByView: z.record(z.string(), z.array(z.string())).optional(),
|
||||
linearPreset: z.enum(['assigned', 'created', 'all', 'completed']).optional(),
|
||||
linearQuery: z.string().optional()
|
||||
})
|
||||
@@ -44,6 +46,44 @@ const WorkspaceCleanup = z
|
||||
dismissals: z.record(z.string(), WorkspaceCleanupDismissal)
|
||||
})
|
||||
.strict()
|
||||
const GitHubProjectRef = z
|
||||
.object({
|
||||
owner: z.string(),
|
||||
ownerType: z.enum(['organization', 'user']),
|
||||
number: z.number().int()
|
||||
})
|
||||
.strict()
|
||||
const GitHubProjectSettings = z
|
||||
.object({
|
||||
pinned: z.array(GitHubProjectRef),
|
||||
recent: z.array(
|
||||
GitHubProjectRef.extend({
|
||||
lastOpenedAt: z.string()
|
||||
}).strict()
|
||||
),
|
||||
lastViewByProject: z.record(z.string(), z.object({ viewId: z.string() }).strict()),
|
||||
activeProject: GitHubProjectRef.nullable()
|
||||
})
|
||||
.strict()
|
||||
|
||||
const SettingsUpdate = z
|
||||
.object({
|
||||
defaultTuiAgent: z
|
||||
.unknown()
|
||||
.transform((value) =>
|
||||
value === null || value === 'blank' || isTuiAgent(value) ? value : undefined
|
||||
)
|
||||
.optional(),
|
||||
defaultTaskSource: z.enum(['github', 'gitlab', 'linear']).optional(),
|
||||
defaultTaskViewPreset: z
|
||||
.enum(['issues', 'my-issues', 'prs', 'my-prs', 'review', 'all'])
|
||||
.optional(),
|
||||
defaultRepoSelection: z.array(z.string()).nullable().optional(),
|
||||
defaultLinearTeamSelection: z.array(z.string()).nullable().optional(),
|
||||
githubProjects: GitHubProjectSettings.optional()
|
||||
})
|
||||
.strict()
|
||||
.default({})
|
||||
|
||||
const UiUpdate = z
|
||||
.object({
|
||||
@@ -120,6 +160,16 @@ const UiUpdate = z
|
||||
.default({})
|
||||
|
||||
export const CLIENT_UI_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'settings.get',
|
||||
params: null,
|
||||
handler: (_params, { runtime }) => ({ settings: runtime.getClientSettings() })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'settings.update',
|
||||
params: SettingsUpdate,
|
||||
handler: (params, { runtime }) => ({ settings: runtime.updateClientSettings(params) })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'ui.get',
|
||||
params: null,
|
||||
|
||||
@@ -300,6 +300,34 @@ describe('github RPC methods', () => {
|
||||
expect(response).toMatchObject({ ok: true, result: true })
|
||||
})
|
||||
|
||||
it('updates PR metadata on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
updateRepoPRDetails: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('github.updatePR', {
|
||||
repo: 'repo-1',
|
||||
prNumber: 7,
|
||||
updates: { title: 'New title', body: 'Updated body' },
|
||||
prRepo: { owner: 'acme', repo: 'widgets' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.updateRepoPRDetails).toHaveBeenCalledWith(
|
||||
'repo-1',
|
||||
7,
|
||||
{ title: 'New title', body: 'Updated body' },
|
||||
{
|
||||
owner: 'acme',
|
||||
repo: 'widgets'
|
||||
}
|
||||
)
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true } })
|
||||
})
|
||||
|
||||
it('merges PRs on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
@@ -323,6 +351,35 @@ describe('github RPC methods', () => {
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true } })
|
||||
})
|
||||
|
||||
it('routes PR reviewer mutations on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
requestRepoPRReviewers: vi.fn().mockResolvedValue({ ok: true }),
|
||||
removeRepoPRReviewers: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
|
||||
|
||||
const requestResponse = await dispatcher.dispatch(
|
||||
makeRequest('github.requestPRReviewers', {
|
||||
repo: 'repo-1',
|
||||
prNumber: 7,
|
||||
reviewers: ['octo']
|
||||
})
|
||||
)
|
||||
const removeResponse = await dispatcher.dispatch(
|
||||
makeRequest('github.removePRReviewers', {
|
||||
repo: 'repo-1',
|
||||
prNumber: 7,
|
||||
reviewers: ['octo']
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.requestRepoPRReviewers).toHaveBeenCalledWith('repo-1', 7, ['octo'])
|
||||
expect(runtime.removeRepoPRReviewers).toHaveBeenCalledWith('repo-1', 7, ['octo'])
|
||||
expect(requestResponse).toMatchObject({ ok: true, result: { ok: true } })
|
||||
expect(removeResponse).toMatchObject({ ok: true, result: { ok: true } })
|
||||
})
|
||||
|
||||
it('updates PR state on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
|
||||
@@ -103,6 +103,15 @@ const UpdatePrTitle = RepoSelector.extend({
|
||||
prRepo: SlugRepo.nullable().optional()
|
||||
})
|
||||
|
||||
const UpdatePr = RepoSelector.extend({
|
||||
prNumber: z.number().int().positive(),
|
||||
updates: z.object({
|
||||
title: OptionalString,
|
||||
body: z.string().optional()
|
||||
}),
|
||||
prRepo: SlugRepo.nullable().optional()
|
||||
})
|
||||
|
||||
const MergePr = RepoSelector.extend({
|
||||
prNumber: z.number().int().positive(),
|
||||
method: z.enum(['merge', 'squash', 'rebase']).optional(),
|
||||
@@ -398,6 +407,17 @@ export const GITHUB_METHODS: RpcMethod[] = [
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.updateRepoPRTitle(params.repo, params.prNumber, params.title, params.prRepo ?? null)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.updatePR',
|
||||
params: UpdatePr,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.updateRepoPRDetails(
|
||||
params.repo,
|
||||
params.prNumber,
|
||||
params.updates,
|
||||
params.prRepo ?? null
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.mergePR',
|
||||
params: MergePr,
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import type { RpcRequest } from '../core'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { GITLAB_METHODS } from './gitlab'
|
||||
|
||||
function makeRequest(method: string, params?: unknown): RpcRequest {
|
||||
return { id: 'req-1', authToken: 'tok', method, params }
|
||||
}
|
||||
|
||||
describe('gitlab RPC methods', () => {
|
||||
it('routes GitLab task queries and mutations to the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
listGitLabRepoWorkItems: vi.fn().mockResolvedValue({ items: [] }),
|
||||
listGitLabRepoTodos: vi.fn().mockResolvedValue([{ id: 1 }]),
|
||||
createGitLabRepoIssue: vi.fn().mockResolvedValue({ ok: true, number: 7 }),
|
||||
updateGitLabRepoIssue: vi.fn().mockResolvedValue({ ok: true }),
|
||||
addGitLabRepoIssueComment: vi.fn().mockResolvedValue({ ok: true }),
|
||||
addGitLabRepoMRComment: vi.fn().mockResolvedValue({ ok: true }),
|
||||
mergeGitLabRepoMR: vi.fn().mockResolvedValue({ ok: true }),
|
||||
updateGitLabRepoMRState: vi.fn().mockResolvedValue({ ok: true }),
|
||||
updateGitLabRepoMR: vi.fn().mockResolvedValue({ ok: true }),
|
||||
getGitLabRepoWorkItemDetails: vi.fn().mockResolvedValue({ body: 'Details' })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GITLAB_METHODS })
|
||||
const projectRef = { host: 'gitlab.example.com', path: 'group/project' }
|
||||
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('gitlab.listWorkItems', {
|
||||
repo: 'id:repo-1',
|
||||
state: 'opened',
|
||||
page: 1,
|
||||
perPage: 25,
|
||||
query: 'bug'
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('gitlab.createIssue', {
|
||||
repo: 'id:repo-1',
|
||||
title: 'Fix bug',
|
||||
body: 'Details'
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(makeRequest('gitlab.todos', { repo: 'id:repo-1' }))
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('gitlab.updateIssue', {
|
||||
repo: 'id:repo-1',
|
||||
number: 7,
|
||||
updates: { state: 'closed', title: 'Done', body: 'Updated body' },
|
||||
projectRef
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('gitlab.addIssueComment', {
|
||||
repo: 'id:repo-1',
|
||||
number: 7,
|
||||
body: 'looks good',
|
||||
projectRef
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('gitlab.addMRComment', {
|
||||
repo: 'id:repo-1',
|
||||
iid: 8,
|
||||
body: 'ship it',
|
||||
projectRef
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('gitlab.mergeMR', {
|
||||
repo: 'id:repo-1',
|
||||
iid: 8,
|
||||
method: 'squash',
|
||||
projectRef
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('gitlab.updateMRState', {
|
||||
repo: 'id:repo-1',
|
||||
iid: 8,
|
||||
state: 'closed',
|
||||
projectRef
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('gitlab.updateMR', {
|
||||
repo: 'id:repo-1',
|
||||
iid: 8,
|
||||
updates: { title: 'New title', body: 'New body', addLabels: ['bug'] },
|
||||
projectRef
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('gitlab.workItemDetails', {
|
||||
repo: 'id:repo-1',
|
||||
iid: 8,
|
||||
type: 'mr',
|
||||
projectRef
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.listGitLabRepoWorkItems).toHaveBeenCalledWith(
|
||||
'id:repo-1',
|
||||
'opened',
|
||||
1,
|
||||
25,
|
||||
'bug'
|
||||
)
|
||||
expect(runtime.createGitLabRepoIssue).toHaveBeenCalledWith('id:repo-1', 'Fix bug', 'Details')
|
||||
expect(runtime.listGitLabRepoTodos).toHaveBeenCalledWith('id:repo-1')
|
||||
expect(runtime.updateGitLabRepoIssue).toHaveBeenCalledWith(
|
||||
'id:repo-1',
|
||||
7,
|
||||
{
|
||||
state: 'closed',
|
||||
title: 'Done',
|
||||
body: 'Updated body'
|
||||
},
|
||||
projectRef
|
||||
)
|
||||
expect(runtime.addGitLabRepoIssueComment).toHaveBeenCalledWith(
|
||||
'id:repo-1',
|
||||
7,
|
||||
'looks good',
|
||||
projectRef
|
||||
)
|
||||
expect(runtime.addGitLabRepoMRComment).toHaveBeenCalledWith(
|
||||
'id:repo-1',
|
||||
8,
|
||||
'ship it',
|
||||
projectRef
|
||||
)
|
||||
expect(runtime.mergeGitLabRepoMR).toHaveBeenCalledWith('id:repo-1', 8, 'squash', projectRef)
|
||||
expect(runtime.updateGitLabRepoMRState).toHaveBeenCalledWith(
|
||||
'id:repo-1',
|
||||
8,
|
||||
'closed',
|
||||
projectRef
|
||||
)
|
||||
expect(runtime.updateGitLabRepoMR).toHaveBeenCalledWith(
|
||||
'id:repo-1',
|
||||
8,
|
||||
{
|
||||
title: 'New title',
|
||||
body: 'New body',
|
||||
addLabels: ['bug']
|
||||
},
|
||||
projectRef
|
||||
)
|
||||
expect(runtime.getGitLabRepoWorkItemDetails).toHaveBeenCalledWith(
|
||||
'id:repo-1',
|
||||
8,
|
||||
'mr',
|
||||
projectRef
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
import { z } from 'zod'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
|
||||
|
||||
const RepoSelector = z.object({
|
||||
repo: requiredString('Missing repo selector')
|
||||
})
|
||||
|
||||
const GitLabProjectRef = z
|
||||
.object({
|
||||
host: requiredString('Missing GitLab host'),
|
||||
path: requiredString('Missing GitLab project path')
|
||||
})
|
||||
.optional()
|
||||
|
||||
const WorkItemsList = RepoSelector.extend({
|
||||
state: z.enum(['opened', 'merged', 'closed', 'all']).optional(),
|
||||
page: OptionalFiniteNumber,
|
||||
perPage: OptionalFiniteNumber,
|
||||
query: OptionalString
|
||||
})
|
||||
|
||||
const CreateIssue = RepoSelector.extend({
|
||||
title: requiredString('Missing title'),
|
||||
body: z.string()
|
||||
})
|
||||
|
||||
const IssueUpdate = z.object({
|
||||
state: z.enum(['opened', 'closed']).optional(),
|
||||
title: z.string().optional(),
|
||||
body: z.string().optional(),
|
||||
addLabels: z.array(z.string()).optional(),
|
||||
removeLabels: z.array(z.string()).optional(),
|
||||
addAssignees: z.array(z.string()).optional(),
|
||||
removeAssignees: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
const UpdateIssue = RepoSelector.extend({
|
||||
number: z.number().int().positive(),
|
||||
updates: IssueUpdate,
|
||||
projectRef: GitLabProjectRef
|
||||
})
|
||||
|
||||
const UpdateMrState = RepoSelector.extend({
|
||||
iid: z.number().int().positive(),
|
||||
state: z.enum(['opened', 'closed']),
|
||||
projectRef: GitLabProjectRef
|
||||
})
|
||||
|
||||
const UpdateMr = RepoSelector.extend({
|
||||
iid: z.number().int().positive(),
|
||||
updates: z.object({
|
||||
title: z.string().optional(),
|
||||
body: z.string().optional(),
|
||||
addLabels: z.array(z.string()).optional(),
|
||||
removeLabels: z.array(z.string()).optional()
|
||||
}),
|
||||
projectRef: GitLabProjectRef
|
||||
})
|
||||
|
||||
const MergeMr = RepoSelector.extend({
|
||||
iid: z.number().int().positive(),
|
||||
method: z.enum(['merge', 'squash', 'rebase']).optional(),
|
||||
projectRef: GitLabProjectRef
|
||||
})
|
||||
|
||||
const AddIssueComment = RepoSelector.extend({
|
||||
number: z.number().int().positive(),
|
||||
body: requiredString('Comment body is required'),
|
||||
projectRef: GitLabProjectRef
|
||||
})
|
||||
|
||||
const AddMRComment = RepoSelector.extend({
|
||||
iid: z.number().int().positive(),
|
||||
body: requiredString('Comment body is required'),
|
||||
projectRef: GitLabProjectRef
|
||||
})
|
||||
|
||||
const WorkItemDetails = RepoSelector.extend({
|
||||
iid: z.number().int().positive(),
|
||||
type: z.enum(['issue', 'mr']),
|
||||
projectRef: GitLabProjectRef
|
||||
})
|
||||
|
||||
export const GITLAB_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'gitlab.listWorkItems',
|
||||
params: WorkItemsList,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.listGitLabRepoWorkItems(
|
||||
params.repo,
|
||||
params.state,
|
||||
params.page,
|
||||
params.perPage,
|
||||
params.query
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'gitlab.todos',
|
||||
params: RepoSelector,
|
||||
handler: async (params, { runtime }) => runtime.listGitLabRepoTodos(params.repo)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'gitlab.createIssue',
|
||||
params: CreateIssue,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.createGitLabRepoIssue(params.repo, params.title, params.body)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'gitlab.updateIssue',
|
||||
params: UpdateIssue,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.updateGitLabRepoIssue(params.repo, params.number, params.updates, params.projectRef)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'gitlab.addIssueComment',
|
||||
params: AddIssueComment,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.addGitLabRepoIssueComment(params.repo, params.number, params.body, params.projectRef)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'gitlab.addMRComment',
|
||||
params: AddMRComment,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.addGitLabRepoMRComment(params.repo, params.iid, params.body, params.projectRef)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'gitlab.mergeMR',
|
||||
params: MergeMr,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.mergeGitLabRepoMR(params.repo, params.iid, params.method, params.projectRef)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'gitlab.updateMRState',
|
||||
params: UpdateMrState,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.updateGitLabRepoMRState(params.repo, params.iid, params.state, params.projectRef)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'gitlab.updateMR',
|
||||
params: UpdateMr,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.updateGitLabRepoMR(params.repo, params.iid, params.updates, params.projectRef)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'gitlab.workItemDetails',
|
||||
params: WorkItemDetails,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.getGitLabRepoWorkItemDetails(params.repo, params.iid, params.type, params.projectRef)
|
||||
})
|
||||
]
|
||||
@@ -17,8 +17,10 @@ import { SESSION_TAB_METHODS } from './session-tabs'
|
||||
import { FILE_METHODS } from './files'
|
||||
import { GIT_METHODS } from './git'
|
||||
import { GITHUB_METHODS } from './github'
|
||||
import { GITLAB_METHODS } from './gitlab'
|
||||
import { HOSTED_REVIEW_METHODS } from './hosted-review'
|
||||
import { LINEAR_METHODS } from './linear'
|
||||
import { SSH_METHODS } from './ssh'
|
||||
import { SPEECH_METHODS } from './speech'
|
||||
import { CLIENT_UI_METHODS } from './client-ui'
|
||||
import { WORKSPACE_PORT_METHODS } from './workspace-ports'
|
||||
@@ -45,8 +47,10 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
|
||||
...FILE_METHODS,
|
||||
...GIT_METHODS,
|
||||
...GITHUB_METHODS,
|
||||
...GITLAB_METHODS,
|
||||
...HOSTED_REVIEW_METHODS,
|
||||
...LINEAR_METHODS,
|
||||
...SSH_METHODS,
|
||||
...SPEECH_METHODS,
|
||||
...WORKSPACE_PORT_METHODS,
|
||||
...CLIENT_UI_METHODS
|
||||
|
||||
@@ -61,9 +61,78 @@ describe('repo RPC methods', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('lists sparse checkout presets for a repo', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
listSparsePresets: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'preset-1',
|
||||
repoId: 'repo-1',
|
||||
name: 'Frontend',
|
||||
directories: ['src/renderer'],
|
||||
createdAt: 1,
|
||||
updatedAt: 2
|
||||
}
|
||||
])
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('repo.sparsePresets', { repo: 'repo-1' })
|
||||
)
|
||||
|
||||
expect(runtime.listSparsePresets).toHaveBeenCalledWith('repo-1')
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { presets: [{ id: 'preset-1', directories: ['src/renderer'] }] }
|
||||
})
|
||||
})
|
||||
|
||||
it('saves sparse checkout presets for a repo', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
saveSparsePreset: vi.fn().mockResolvedValue({
|
||||
id: 'preset-1',
|
||||
repoId: 'repo-1',
|
||||
name: 'Frontend',
|
||||
directories: ['src/renderer'],
|
||||
createdAt: 1,
|
||||
updatedAt: 2
|
||||
})
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('repo.saveSparsePreset', {
|
||||
repo: 'repo-1',
|
||||
name: 'Frontend',
|
||||
directories: ['src/renderer']
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.saveSparsePreset).toHaveBeenCalledWith('repo-1', {
|
||||
name: 'Frontend',
|
||||
directories: ['src/renderer']
|
||||
})
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { preset: { id: 'preset-1', directories: ['src/renderer'] } }
|
||||
})
|
||||
})
|
||||
|
||||
it('routes repository hook operations to the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
getRepoHooks: vi.fn().mockResolvedValue({
|
||||
hasHooksFile: true,
|
||||
hooks: { scripts: { setup: 'pnpm install' } },
|
||||
setupRunPolicy: 'run-by-default',
|
||||
source: 'orca.yaml',
|
||||
setupTrust: {
|
||||
contentHash: 'hash-1',
|
||||
scriptContent: 'pnpm install'
|
||||
}
|
||||
}),
|
||||
checkRepoHooks: vi.fn().mockResolvedValue({
|
||||
hasHooks: true,
|
||||
hooks: { scripts: { setup: 'pnpm install' } },
|
||||
@@ -88,6 +157,7 @@ describe('repo RPC methods', () => {
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
|
||||
|
||||
const hooksResponse = await dispatcher.dispatch(makeRequest('repo.hooks', { repo: 'repo-1' }))
|
||||
await dispatcher.dispatch(makeRequest('repo.hooksCheck', { repo: 'repo-1' }))
|
||||
await dispatcher.dispatch(makeRequest('repo.setupScriptImports', { repo: 'repo-1' }))
|
||||
await dispatcher.dispatch(makeRequest('repo.issueCommandRead', { repo: 'repo-1' }))
|
||||
@@ -98,9 +168,41 @@ describe('repo RPC methods', () => {
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.getRepoHooks).toHaveBeenCalledWith('repo-1')
|
||||
expect(hooksResponse).toMatchObject({
|
||||
ok: true,
|
||||
result: { setupTrust: { contentHash: 'hash-1', scriptContent: 'pnpm install' } }
|
||||
})
|
||||
expect(runtime.checkRepoHooks).toHaveBeenCalledWith('repo-1')
|
||||
expect(runtime.inspectRepoSetupScriptImports).toHaveBeenCalledWith('repo-1')
|
||||
expect(runtime.readRepoIssueCommand).toHaveBeenCalledWith('repo-1')
|
||||
expect(runtime.writeRepoIssueCommand).toHaveBeenCalledWith('repo-1', 'Fix it')
|
||||
})
|
||||
|
||||
it('persists GitHub issue source preference updates', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
updateRepo: vi.fn().mockResolvedValue({
|
||||
id: 'repo-1',
|
||||
path: '/srv/repo',
|
||||
issueSourcePreference: 'origin'
|
||||
})
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('repo.update', {
|
||||
repo: 'repo-1',
|
||||
updates: { issueSourcePreference: 'origin' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.updateRepo).toHaveBeenCalledWith('repo-1', {
|
||||
issueSourcePreference: 'origin'
|
||||
})
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { repo: { id: 'repo-1', issueSourcePreference: 'origin' } }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,7 +35,7 @@ const RepoUpdate = RepoSelector.extend({
|
||||
worktreeBaseRef: OptionalString,
|
||||
kind: z.enum(['git', 'folder']).optional(),
|
||||
symlinkPaths: z.array(z.string()).optional(),
|
||||
issueSourcePreference: z.enum(['auto', 'github', 'linear']).optional()
|
||||
issueSourcePreference: z.enum(['auto', 'upstream', 'origin']).optional()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -56,12 +56,36 @@ const RepoIssueCommandWrite = RepoSelector.extend({
|
||||
content: z.string()
|
||||
})
|
||||
|
||||
const RepoSparsePresetSave = RepoSelector.extend({
|
||||
id: OptionalString,
|
||||
name: requiredString('Missing preset name'),
|
||||
directories: z.array(z.string())
|
||||
})
|
||||
|
||||
export const REPO_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'repo.list',
|
||||
params: null,
|
||||
handler: (_params, { runtime }) => ({ repos: runtime.listRepos() })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'repo.sparsePresets',
|
||||
params: RepoSelector,
|
||||
handler: async (params, { runtime }) => ({
|
||||
presets: await runtime.listSparsePresets(params.repo)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'repo.saveSparsePreset',
|
||||
params: RepoSparsePresetSave,
|
||||
handler: async (params, { runtime }) => ({
|
||||
preset: await runtime.saveSparsePreset(params.repo, {
|
||||
...(params.id ? { id: params.id } : {}),
|
||||
name: params.name,
|
||||
directories: params.directories
|
||||
})
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'repo.add',
|
||||
params: RepoPath,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import type { RpcRequest } from '../core'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { SSH_METHODS } from './ssh'
|
||||
|
||||
const { connectRegisteredSshTargetMock, getRegisteredSshStateMock } = vi.hoisted(() => ({
|
||||
connectRegisteredSshTargetMock: vi.fn(),
|
||||
getRegisteredSshStateMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../ipc/ssh', () => ({
|
||||
connectRegisteredSshTarget: connectRegisteredSshTargetMock,
|
||||
getRegisteredSshState: getRegisteredSshStateMock
|
||||
}))
|
||||
|
||||
function makeRequest(method: string, params?: unknown): RpcRequest {
|
||||
return { id: 'req-1', authToken: 'tok', method, params }
|
||||
}
|
||||
|
||||
describe('ssh RPC methods', () => {
|
||||
it('returns the registered SSH target state', async () => {
|
||||
const state = {
|
||||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
}
|
||||
getRegisteredSshStateMock.mockReturnValueOnce(state)
|
||||
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(makeRequest('ssh.getState', { targetId: 'ssh-1' }))
|
||||
|
||||
expect(getRegisteredSshStateMock).toHaveBeenCalledWith('ssh-1')
|
||||
expect(response).toMatchObject({ ok: true, result: { state } })
|
||||
})
|
||||
|
||||
it('connects through the registered desktop SSH lifecycle', async () => {
|
||||
const state = {
|
||||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
}
|
||||
connectRegisteredSshTargetMock.mockResolvedValueOnce(state)
|
||||
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(makeRequest('ssh.connect', { targetId: 'ssh-1' }))
|
||||
|
||||
expect(connectRegisteredSshTargetMock).toHaveBeenCalledWith('ssh-1')
|
||||
expect(response).toMatchObject({ ok: true, result: { state } })
|
||||
})
|
||||
|
||||
it('returns null when the target has no registered state yet', async () => {
|
||||
getRegisteredSshStateMock.mockReturnValueOnce(undefined)
|
||||
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(makeRequest('ssh.getState', { targetId: 'ssh-1' }))
|
||||
|
||||
expect(response).toMatchObject({ ok: true, result: { state: null } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from 'zod'
|
||||
import { connectRegisteredSshTarget, getRegisteredSshState } from '../../../ipc/ssh'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
|
||||
const SshTarget = z.object({
|
||||
targetId: z.string().min(1)
|
||||
})
|
||||
|
||||
export const SSH_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'ssh.getState',
|
||||
params: SshTarget,
|
||||
handler: (params) => ({ state: getRegisteredSshState(params.targetId) ?? null })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'ssh.connect',
|
||||
params: SshTarget,
|
||||
handler: async (params) => ({ state: await connectRegisteredSshTarget(params.targetId) })
|
||||
})
|
||||
]
|
||||
@@ -338,7 +338,8 @@ const TerminalSplit = TerminalHandle.extend({
|
||||
.transform((v) => (v === 'vertical' || v === 'horizontal' ? v : undefined))
|
||||
.pipe(z.union([z.enum(['vertical', 'horizontal']), z.undefined()]))
|
||||
.optional(),
|
||||
command: OptionalString
|
||||
command: OptionalString,
|
||||
env: z.record(z.string(), z.string()).optional()
|
||||
})
|
||||
|
||||
const TerminalStop = z.object({
|
||||
@@ -571,7 +572,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
handler: async (params, { runtime }) => ({
|
||||
split: await runtime.splitTerminal(params.terminal, {
|
||||
direction: params.direction,
|
||||
command: params.command
|
||||
command: params.command,
|
||||
env: params.env
|
||||
})
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { z } from 'zod'
|
||||
import { isTuiAgent } from '../../../../shared/tui-agent-config'
|
||||
import {
|
||||
OptionalBoolean,
|
||||
OptionalFiniteNumber,
|
||||
OptionalPlainString,
|
||||
OptionalString,
|
||||
TriStateLinkedIssue
|
||||
} from '../schemas'
|
||||
|
||||
export const WorktreeListParams = z.object({
|
||||
repo: OptionalString,
|
||||
limit: OptionalFiniteNumber
|
||||
})
|
||||
|
||||
export const WorktreePsParams = z.object({
|
||||
limit: OptionalFiniteNumber
|
||||
})
|
||||
|
||||
export const WorktreeSortOrder = z.object({
|
||||
orderedIds: z.array(z.string())
|
||||
})
|
||||
|
||||
export const WorktreeSelector = z.object({
|
||||
worktree: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing worktree selector'))
|
||||
})
|
||||
|
||||
export const WorktreeCreate = z
|
||||
.object({
|
||||
repo: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing repo selector')),
|
||||
name: OptionalString,
|
||||
baseBranch: OptionalString,
|
||||
branchNameOverride: OptionalString,
|
||||
linkedIssue: TriStateLinkedIssue,
|
||||
linkedPR: TriStateLinkedIssue,
|
||||
linkedLinearIssue: z.string().optional(),
|
||||
linkedGitLabMR: TriStateLinkedIssue,
|
||||
linkedGitLabIssue: TriStateLinkedIssue,
|
||||
comment: OptionalString,
|
||||
displayName: OptionalString,
|
||||
workspaceStatus: OptionalString,
|
||||
manualOrder: OptionalFiniteNumber,
|
||||
sparseCheckout: z
|
||||
.object({
|
||||
directories: z.array(z.string()),
|
||||
presetId: OptionalString
|
||||
})
|
||||
.optional(),
|
||||
pushTarget: z
|
||||
.object({
|
||||
remoteName: z.string(),
|
||||
branchName: z.string(),
|
||||
remoteUrl: OptionalString
|
||||
})
|
||||
.optional(),
|
||||
runHooks: OptionalBoolean,
|
||||
activate: OptionalBoolean,
|
||||
parentWorktree: OptionalString,
|
||||
cwdParentWorktree: OptionalString,
|
||||
noParent: OptionalBoolean,
|
||||
callerTerminalHandle: OptionalString,
|
||||
orchestrationContext: z
|
||||
.object({
|
||||
parentWorktreeId: OptionalString,
|
||||
orchestrationRunId: OptionalString,
|
||||
taskId: OptionalString,
|
||||
coordinatorHandle: OptionalString
|
||||
})
|
||||
.optional(),
|
||||
setupDecision: z
|
||||
.unknown()
|
||||
.transform((v) =>
|
||||
typeof v === 'string' && (v === 'run' || v === 'skip' || v === 'inherit') ? v : undefined
|
||||
)
|
||||
.pipe(z.union([z.enum(['run', 'skip', 'inherit']), z.undefined()]))
|
||||
.optional(),
|
||||
// Why: mobile clients pass a startup command (e.g. 'claude') so the first
|
||||
// terminal pane launches the selected agent instead of an idle shell.
|
||||
startupCommand: OptionalString,
|
||||
// Why: task-driven mobile creates need desktop parity: the host chooses
|
||||
// the same default/detected agent and drafts the linked issue/PR URL into it.
|
||||
startupDraft: OptionalString,
|
||||
createdWithAgent: z
|
||||
.unknown()
|
||||
.transform((value) => (isTuiAgent(value) ? value : undefined))
|
||||
.optional()
|
||||
})
|
||||
.superRefine((params, ctx) => {
|
||||
if (params.parentWorktree && params.noParent === true) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Choose either --parent-worktree or --no-parent, not both.'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const WorktreeSet = WorktreeSelector.extend({
|
||||
displayName: OptionalString,
|
||||
// Why: empty comments are meaningful metadata updates, so use the plain
|
||||
// string parser instead of OptionalString's empty-as-undefined behavior.
|
||||
comment: OptionalPlainString,
|
||||
linkedIssue: TriStateLinkedIssue,
|
||||
linkedPR: TriStateLinkedIssue,
|
||||
linkedLinearIssue: z.union([z.string(), z.null()]).optional(),
|
||||
linkedGitLabMR: TriStateLinkedIssue,
|
||||
linkedGitLabIssue: TriStateLinkedIssue,
|
||||
isArchived: OptionalBoolean,
|
||||
isUnread: OptionalBoolean,
|
||||
isPinned: OptionalBoolean,
|
||||
sortOrder: OptionalFiniteNumber,
|
||||
manualOrder: OptionalFiniteNumber,
|
||||
lastActivityAt: OptionalFiniteNumber,
|
||||
createdAt: OptionalFiniteNumber,
|
||||
sparseDirectories: z.array(z.string()).optional(),
|
||||
sparseBaseRef: OptionalString,
|
||||
sparsePresetId: OptionalString,
|
||||
baseRef: OptionalString,
|
||||
workspaceStatus: OptionalString,
|
||||
pushTarget: z
|
||||
.object({
|
||||
remoteName: z.string(),
|
||||
branchName: z.string(),
|
||||
remoteUrl: OptionalString
|
||||
})
|
||||
.optional(),
|
||||
diffComments: z.array(z.unknown()).optional(),
|
||||
parentWorktree: OptionalString,
|
||||
noParent: OptionalBoolean
|
||||
}).superRefine((params, ctx) => {
|
||||
if (params.parentWorktree && params.noParent === true) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Choose either --parent-worktree or --no-parent, not both.'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const WorktreeRemove = WorktreeSelector.extend({
|
||||
force: OptionalBoolean,
|
||||
runHooks: OptionalBoolean
|
||||
})
|
||||
|
||||
export const WorktreeResolvePrBase = z.object({
|
||||
repo: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing repo selector')),
|
||||
prNumber: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0))
|
||||
.pipe(z.number().int().positive('Missing PR number')),
|
||||
headRefName: OptionalString,
|
||||
isCrossRepository: OptionalBoolean
|
||||
})
|
||||
|
||||
export const WorktreeResolveMrBase = z.object({
|
||||
repo: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing repo selector')),
|
||||
mrIid: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0))
|
||||
.pipe(z.number().int().positive('Missing MR number')),
|
||||
sourceBranch: OptionalString,
|
||||
isCrossRepository: OptionalBoolean
|
||||
})
|
||||
@@ -57,6 +57,7 @@ describe('worktree RPC methods', () => {
|
||||
setupDecision: 'skip',
|
||||
createdWithAgent: undefined,
|
||||
startup: undefined,
|
||||
startupDraft: undefined,
|
||||
lineage: {
|
||||
parentWorktree: 'id:parent',
|
||||
noParent: false,
|
||||
@@ -66,6 +67,35 @@ describe('worktree RPC methods', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards task startup drafts to runtime worktree creation', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
|
||||
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('worktree.create', {
|
||||
repo: 'repo-1',
|
||||
name: 'issue-123',
|
||||
startupDraft: 'https://github.com/stablyai/orca/issues/123',
|
||||
createdWithAgent: 'codex',
|
||||
activate: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.createManagedWorktree).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
repoSelector: 'repo-1',
|
||||
name: 'issue-123',
|
||||
activate: true,
|
||||
createdWithAgent: 'codex',
|
||||
startup: undefined,
|
||||
startupDraft: 'https://github.com/stablyai/orca/issues/123'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects worktree.create when both parent and no-parent are supplied', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
@@ -87,6 +117,56 @@ describe('worktree RPC methods', () => {
|
||||
expect(runtime.createManagedWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes explicit repo selectors to PR base resolution', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
resolveManagedPrBase: vi.fn().mockResolvedValue({ baseBranch: 'origin/pr-head' })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('worktree.resolvePrBase', {
|
||||
repo: 'id:repo-1',
|
||||
prNumber: 42,
|
||||
headRefName: 'feature/pr-head',
|
||||
isCrossRepository: false
|
||||
})
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: true })
|
||||
expect(runtime.resolveManagedPrBase).toHaveBeenCalledWith({
|
||||
repoSelector: 'id:repo-1',
|
||||
prNumber: 42,
|
||||
headRefName: 'feature/pr-head',
|
||||
isCrossRepository: false
|
||||
})
|
||||
})
|
||||
|
||||
it('passes explicit repo selectors to MR base resolution', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
resolveManagedMrBase: vi.fn().mockResolvedValue({ baseBranch: 'origin/mr-head' })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('worktree.resolveMrBase', {
|
||||
repo: 'id:repo-1',
|
||||
mrIid: 42,
|
||||
sourceBranch: 'feature/mr-head',
|
||||
isCrossRepository: false
|
||||
})
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: true })
|
||||
expect(runtime.resolveManagedMrBase).toHaveBeenCalledWith({
|
||||
repoSelector: 'id:repo-1',
|
||||
mrIid: 42,
|
||||
sourceBranch: 'feature/mr-head',
|
||||
isCrossRepository: false
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects worktree.set when both parent and no-parent are supplied', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
|
||||
@@ -1,161 +1,15 @@
|
||||
import { z } from 'zod'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import {
|
||||
OptionalBoolean,
|
||||
OptionalFiniteNumber,
|
||||
OptionalPlainString,
|
||||
OptionalString,
|
||||
TriStateLinkedIssue
|
||||
} from '../schemas'
|
||||
import { isTuiAgent } from '../../../../shared/tui-agent-config'
|
||||
|
||||
const WorktreeListParams = z.object({
|
||||
repo: OptionalString,
|
||||
limit: OptionalFiniteNumber
|
||||
})
|
||||
|
||||
const WorktreePsParams = z.object({
|
||||
limit: OptionalFiniteNumber
|
||||
})
|
||||
|
||||
const WorktreeSortOrder = z.object({
|
||||
orderedIds: z.array(z.string())
|
||||
})
|
||||
|
||||
const WorktreeSelector = z.object({
|
||||
worktree: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing worktree selector'))
|
||||
})
|
||||
|
||||
const WorktreeCreate = z
|
||||
.object({
|
||||
repo: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing repo selector')),
|
||||
name: OptionalString,
|
||||
baseBranch: OptionalString,
|
||||
branchNameOverride: OptionalString,
|
||||
linkedIssue: TriStateLinkedIssue,
|
||||
linkedPR: TriStateLinkedIssue,
|
||||
linkedLinearIssue: z.string().optional(),
|
||||
linkedGitLabMR: TriStateLinkedIssue,
|
||||
linkedGitLabIssue: TriStateLinkedIssue,
|
||||
comment: OptionalString,
|
||||
displayName: OptionalString,
|
||||
workspaceStatus: OptionalString,
|
||||
manualOrder: OptionalFiniteNumber,
|
||||
sparseCheckout: z
|
||||
.object({
|
||||
directories: z.array(z.string()),
|
||||
presetId: OptionalString
|
||||
})
|
||||
.optional(),
|
||||
pushTarget: z
|
||||
.object({
|
||||
remoteName: z.string(),
|
||||
branchName: z.string(),
|
||||
remoteUrl: OptionalString
|
||||
})
|
||||
.optional(),
|
||||
runHooks: OptionalBoolean,
|
||||
activate: OptionalBoolean,
|
||||
parentWorktree: OptionalString,
|
||||
cwdParentWorktree: OptionalString,
|
||||
noParent: OptionalBoolean,
|
||||
callerTerminalHandle: OptionalString,
|
||||
orchestrationContext: z
|
||||
.object({
|
||||
parentWorktreeId: OptionalString,
|
||||
orchestrationRunId: OptionalString,
|
||||
taskId: OptionalString,
|
||||
coordinatorHandle: OptionalString
|
||||
})
|
||||
.optional(),
|
||||
setupDecision: z
|
||||
.unknown()
|
||||
.transform((v) =>
|
||||
typeof v === 'string' && (v === 'run' || v === 'skip' || v === 'inherit') ? v : undefined
|
||||
)
|
||||
.pipe(z.union([z.enum(['run', 'skip', 'inherit']), z.undefined()]))
|
||||
.optional(),
|
||||
// Why: mobile clients pass a startup command (e.g. 'claude') so the first
|
||||
// terminal pane launches the selected agent instead of an idle shell.
|
||||
startupCommand: OptionalString,
|
||||
createdWithAgent: z
|
||||
.unknown()
|
||||
.transform((value) => (isTuiAgent(value) ? value : undefined))
|
||||
.optional()
|
||||
})
|
||||
.superRefine((params, ctx) => {
|
||||
if (params.parentWorktree && params.noParent === true) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Choose either --parent-worktree or --no-parent, not both.'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const WorktreeSet = WorktreeSelector.extend({
|
||||
displayName: OptionalString,
|
||||
// Why: empty comments are meaningful metadata updates, so use the plain
|
||||
// string parser instead of OptionalString's empty-as-undefined behavior.
|
||||
comment: OptionalPlainString,
|
||||
linkedIssue: TriStateLinkedIssue,
|
||||
linkedPR: TriStateLinkedIssue,
|
||||
linkedLinearIssue: z.union([z.string(), z.null()]).optional(),
|
||||
linkedGitLabMR: TriStateLinkedIssue,
|
||||
linkedGitLabIssue: TriStateLinkedIssue,
|
||||
isArchived: OptionalBoolean,
|
||||
isUnread: OptionalBoolean,
|
||||
isPinned: OptionalBoolean,
|
||||
sortOrder: OptionalFiniteNumber,
|
||||
manualOrder: OptionalFiniteNumber,
|
||||
lastActivityAt: OptionalFiniteNumber,
|
||||
createdAt: OptionalFiniteNumber,
|
||||
sparseDirectories: z.array(z.string()).optional(),
|
||||
sparseBaseRef: OptionalString,
|
||||
sparsePresetId: OptionalString,
|
||||
baseRef: OptionalString,
|
||||
workspaceStatus: OptionalString,
|
||||
pushTarget: z
|
||||
.object({
|
||||
remoteName: z.string(),
|
||||
branchName: z.string(),
|
||||
remoteUrl: OptionalString
|
||||
})
|
||||
.optional(),
|
||||
diffComments: z.array(z.unknown()).optional(),
|
||||
parentWorktree: OptionalString,
|
||||
noParent: OptionalBoolean
|
||||
}).superRefine((params, ctx) => {
|
||||
if (params.parentWorktree && params.noParent === true) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Choose either --parent-worktree or --no-parent, not both.'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const WorktreeRemove = WorktreeSelector.extend({
|
||||
force: OptionalBoolean,
|
||||
runHooks: OptionalBoolean
|
||||
})
|
||||
|
||||
const WorktreeResolvePrBase = z.object({
|
||||
repo: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing repo selector')),
|
||||
prNumber: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0))
|
||||
.pipe(z.number().int().positive('Missing PR number')),
|
||||
headRefName: OptionalString,
|
||||
isCrossRepository: OptionalBoolean
|
||||
})
|
||||
WorktreeCreate,
|
||||
WorktreeListParams,
|
||||
WorktreePsParams,
|
||||
WorktreeRemove,
|
||||
WorktreeResolveMrBase,
|
||||
WorktreeResolvePrBase,
|
||||
WorktreeSelector,
|
||||
WorktreeSet,
|
||||
WorktreeSortOrder
|
||||
} from './worktree-schemas'
|
||||
|
||||
export const WORKTREE_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
@@ -215,6 +69,7 @@ export const WORKTREE_METHODS: RpcMethod[] = [
|
||||
setupDecision: params.setupDecision,
|
||||
createdWithAgent: params.createdWithAgent,
|
||||
startup: params.startupCommand ? { command: params.startupCommand } : undefined,
|
||||
startupDraft: params.startupDraft,
|
||||
lineage: {
|
||||
parentWorktree: params.parentWorktree,
|
||||
...(params.cwdParentWorktree ? { cwdParentWorktree: params.cwdParentWorktree } : {}),
|
||||
@@ -271,12 +126,23 @@ export const WORKTREE_METHODS: RpcMethod[] = [
|
||||
params: WorktreeResolvePrBase,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.resolveManagedPrBase({
|
||||
repoId: params.repo,
|
||||
repoSelector: params.repo,
|
||||
prNumber: params.prNumber,
|
||||
headRefName: params.headRefName,
|
||||
isCrossRepository: params.isCrossRepository
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'worktree.resolveMrBase',
|
||||
params: WorktreeResolveMrBase,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.resolveManagedMrBase({
|
||||
repoSelector: params.repo,
|
||||
mrIid: params.mrIid,
|
||||
sourceBranch: params.sourceBranch,
|
||||
isCrossRepository: params.isCrossRepository
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'worktree.rm',
|
||||
params: WorktreeRemove,
|
||||
|
||||
@@ -779,6 +779,57 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
const browserSetViewport = vi.fn().mockResolvedValue({ ok: true })
|
||||
const browserDialogAccept = vi.fn().mockResolvedValue({ ok: true })
|
||||
const browserDialogDismiss = vi.fn().mockResolvedValue({ ok: true })
|
||||
const listGitHubProjects = vi.fn().mockResolvedValue({ ok: true, projects: [] })
|
||||
const listGitHubLabelsBySlug = vi.fn().mockResolvedValue({ ok: true, labels: ['bug'] })
|
||||
const listGitHubAssignableUsersBySlug = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, users: [{ login: 'alex' }] })
|
||||
const listGitHubIssueTypesBySlug = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
types: [{ id: 'type-1', name: 'Bug', color: 'RED', description: null }]
|
||||
})
|
||||
const updateGitHubProjectItemField = vi.fn().mockResolvedValue({ ok: true })
|
||||
const clearGitHubProjectItemField = vi.fn().mockResolvedValue({ ok: true })
|
||||
const updateGitHubIssueBySlug = vi.fn().mockResolvedValue({ ok: true })
|
||||
const updateGitHubIssueTypeBySlug = vi.fn().mockResolvedValue({ ok: true })
|
||||
const updateGitHubPullRequestBySlug = vi.fn().mockResolvedValue({ ok: true })
|
||||
const updateRepoIssue = vi.fn().mockResolvedValue({ ok: true })
|
||||
const listRepoLabels = vi.fn().mockResolvedValue(['bug'])
|
||||
const listRepoAssignableUsers = vi.fn().mockResolvedValue([{ login: 'alex' }])
|
||||
const addRepoIssueComment = vi.fn().mockResolvedValue({ ok: true, comment: { id: 2 } })
|
||||
const addRepoPRReviewComment = vi.fn().mockResolvedValue({ ok: true, comment: { id: 3 } })
|
||||
const addRepoPRReviewCommentReply = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
comment: { id: 4 }
|
||||
})
|
||||
const getRepoPRFileContents = vi.fn().mockResolvedValue({
|
||||
original: 'before',
|
||||
modified: 'after',
|
||||
originalIsBinary: false,
|
||||
modifiedIsBinary: false
|
||||
})
|
||||
const rerunRepoPRChecks = vi.fn().mockResolvedValue({ ok: true, count: 1 })
|
||||
const resolveRepoReviewThread = vi.fn().mockResolvedValue(true)
|
||||
const setRepoPRFileViewed = vi.fn().mockResolvedValue(true)
|
||||
const requestRepoPRReviewers = vi.fn().mockResolvedValue({ ok: true })
|
||||
const mergeRepoPR = vi.fn().mockResolvedValue({ ok: true })
|
||||
const addGitLabRepoIssueComment = vi.fn().mockResolvedValue({ ok: true })
|
||||
const addGitLabRepoMRComment = vi.fn().mockResolvedValue({ ok: true })
|
||||
const mergeGitLabRepoMR = vi.fn().mockResolvedValue({ ok: true })
|
||||
const addGitHubIssueCommentBySlug = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
comment: { id: 1, author: 'me', body: 'done', createdAt: '2026-01-01T00:00:00Z', url: '' }
|
||||
})
|
||||
const updateGitHubIssueCommentBySlug = vi.fn().mockResolvedValue({ ok: true })
|
||||
const deleteGitHubIssueCommentBySlug = vi.fn().mockResolvedValue({ ok: true })
|
||||
const linearSearchIssues = vi.fn().mockResolvedValue([])
|
||||
const linearSelectWorkspace = vi.fn().mockReturnValue({
|
||||
connected: true,
|
||||
selectedWorkspaceId: 'workspace-1'
|
||||
})
|
||||
const linearTeamLabels = vi.fn().mockResolvedValue([{ id: 'label-1', name: 'bug' }])
|
||||
const linearTeamMembers = vi.fn().mockResolvedValue([{ id: 'member-1', displayName: 'Alex' }])
|
||||
const linearAddIssueComment = vi.fn().mockResolvedValue({ ok: true, id: 'comment-1' })
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
getStatus: vi.fn().mockResolvedValue({ graphStatus: 'ok' }),
|
||||
@@ -796,7 +847,41 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
browserTabCreate,
|
||||
browserSetViewport,
|
||||
browserDialogAccept,
|
||||
browserDialogDismiss
|
||||
browserDialogDismiss,
|
||||
listGitHubProjects,
|
||||
listGitHubLabelsBySlug,
|
||||
listGitHubAssignableUsersBySlug,
|
||||
listGitHubIssueTypesBySlug,
|
||||
updateGitHubProjectItemField,
|
||||
clearGitHubProjectItemField,
|
||||
updateGitHubIssueBySlug,
|
||||
updateGitHubIssueTypeBySlug,
|
||||
updateGitHubPullRequestBySlug,
|
||||
updateRepoIssue,
|
||||
listRepoLabels,
|
||||
listRepoAssignableUsers,
|
||||
addRepoIssueComment,
|
||||
addRepoPRReviewComment,
|
||||
addRepoPRReviewCommentReply,
|
||||
getRepoPRFileContents,
|
||||
rerunRepoPRChecks,
|
||||
resolveRepoReviewThread,
|
||||
setRepoPRFileViewed,
|
||||
requestRepoPRReviewers,
|
||||
mergeRepoPR,
|
||||
addGitLabRepoIssueComment,
|
||||
addGitLabRepoMRComment,
|
||||
mergeGitLabRepoMR,
|
||||
addGitHubIssueCommentBySlug,
|
||||
updateGitHubIssueCommentBySlug,
|
||||
deleteGitHubIssueCommentBySlug,
|
||||
linearSearchIssues,
|
||||
linearSelectWorkspace,
|
||||
linearTeamLabels,
|
||||
linearTeamMembers,
|
||||
linearAddIssueComment,
|
||||
getClientSettings: vi.fn(() => ({ defaultTuiAgent: 'codex', agentCmdOverrides: {} })),
|
||||
updateClientSettings: vi.fn(() => ({ defaultTaskSource: 'linear' }))
|
||||
} as unknown as OrcaRuntimeService
|
||||
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false })
|
||||
server['deviceRegistry'] = new DeviceRegistry(userDataPath)
|
||||
@@ -822,6 +907,447 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_settings_get',
|
||||
method: 'settings.get',
|
||||
deviceToken: mobile.token
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_settings_update',
|
||||
method: 'settings.update',
|
||||
deviceToken: mobile.token,
|
||||
params: { defaultTaskSource: 'linear' }
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_github_projects',
|
||||
method: 'github.project.listAccessible',
|
||||
deviceToken: mobile.token,
|
||||
params: {}
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_project_issue_types',
|
||||
method: 'github.project.listIssueTypesBySlug',
|
||||
deviceToken: mobile.token,
|
||||
params: { owner: 'stablyai', repo: 'orca' }
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_project_labels',
|
||||
method: 'github.project.listLabelsBySlug',
|
||||
deviceToken: mobile.token,
|
||||
params: { owner: 'stablyai', repo: 'orca' }
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_project_assignees',
|
||||
method: 'github.project.listAssignableUsersBySlug',
|
||||
deviceToken: mobile.token,
|
||||
params: { owner: 'stablyai', repo: 'orca', seedLogins: ['alex'] }
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_project_update_issue',
|
||||
method: 'github.project.updateIssueBySlug',
|
||||
deviceToken: mobile.token,
|
||||
params: {
|
||||
owner: 'stablyai',
|
||||
repo: 'orca',
|
||||
number: 123,
|
||||
updates: { title: 'New title' }
|
||||
}
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_project_update_issue_type',
|
||||
method: 'github.project.updateIssueTypeBySlug',
|
||||
deviceToken: mobile.token,
|
||||
params: {
|
||||
owner: 'stablyai',
|
||||
repo: 'orca',
|
||||
number: 123,
|
||||
issueTypeId: 'type-1'
|
||||
}
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_project_update_field',
|
||||
method: 'github.project.updateItemField',
|
||||
deviceToken: mobile.token,
|
||||
params: {
|
||||
projectId: 'project-1',
|
||||
itemId: 'item-1',
|
||||
fieldId: 'field-1',
|
||||
value: { kind: 'text', text: 'Ready' }
|
||||
}
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_project_clear_field',
|
||||
method: 'github.project.clearItemField',
|
||||
deviceToken: mobile.token,
|
||||
params: {
|
||||
projectId: 'project-1',
|
||||
itemId: 'item-1',
|
||||
fieldId: 'field-1'
|
||||
}
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_project_update_pr',
|
||||
method: 'github.project.updatePullRequestBySlug',
|
||||
deviceToken: mobile.token,
|
||||
params: {
|
||||
owner: 'stablyai',
|
||||
repo: 'orca',
|
||||
number: 456,
|
||||
updates: { state: 'closed' }
|
||||
}
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_project_add_comment',
|
||||
method: 'github.project.addIssueCommentBySlug',
|
||||
deviceToken: mobile.token,
|
||||
params: {
|
||||
owner: 'stablyai',
|
||||
repo: 'orca',
|
||||
number: 123,
|
||||
body: 'done'
|
||||
}
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_project_update_comment',
|
||||
method: 'github.project.updateIssueCommentBySlug',
|
||||
deviceToken: mobile.token,
|
||||
params: {
|
||||
owner: 'stablyai',
|
||||
repo: 'orca',
|
||||
commentId: 101,
|
||||
body: 'edited'
|
||||
}
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_project_delete_comment',
|
||||
method: 'github.project.deleteIssueCommentBySlug',
|
||||
deviceToken: mobile.token,
|
||||
params: {
|
||||
owner: 'stablyai',
|
||||
repo: 'orca',
|
||||
commentId: 101
|
||||
}
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_github_update_issue',
|
||||
method: 'github.updateIssue',
|
||||
deviceToken: mobile.token,
|
||||
params: {
|
||||
repo: 'id:repo-1',
|
||||
number: 123,
|
||||
updates: { title: 'New title', addLabels: ['bug'] }
|
||||
}
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_github_labels',
|
||||
method: 'github.listLabels',
|
||||
deviceToken: mobile.token,
|
||||
params: { repo: 'id:repo-1' }
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_github_assignees',
|
||||
method: 'github.listAssignableUsers',
|
||||
deviceToken: mobile.token,
|
||||
params: { repo: 'id:repo-1' }
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_github_add_comment',
|
||||
method: 'github.addIssueComment',
|
||||
deviceToken: mobile.token,
|
||||
params: {
|
||||
repo: 'id:repo-1',
|
||||
number: 123,
|
||||
body: 'done'
|
||||
}
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_github_add_review_comment',
|
||||
method: 'github.addPRReviewComment',
|
||||
deviceToken: mobile.token,
|
||||
params: {
|
||||
repo: 'id:repo-1',
|
||||
prNumber: 456,
|
||||
commitId: 'abc123',
|
||||
path: 'src/app.ts',
|
||||
line: 10,
|
||||
body: 'please fix'
|
||||
}
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_github_reply_review_comment',
|
||||
method: 'github.addPRReviewCommentReply',
|
||||
deviceToken: mobile.token,
|
||||
params: {
|
||||
repo: 'id:repo-1',
|
||||
prNumber: 456,
|
||||
commentId: 99,
|
||||
body: 'fixed',
|
||||
threadId: 'thread-1',
|
||||
path: 'src/app.ts',
|
||||
line: 10
|
||||
}
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_github_pr_file_contents',
|
||||
method: 'github.prFileContents',
|
||||
deviceToken: mobile.token,
|
||||
params: {
|
||||
repo: 'id:repo-1',
|
||||
prNumber: 456,
|
||||
path: 'src/app.ts',
|
||||
status: 'modified',
|
||||
headSha: 'abc123',
|
||||
baseSha: 'def456'
|
||||
}
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_github_rerun_checks',
|
||||
method: 'github.rerunPRChecks',
|
||||
deviceToken: mobile.token,
|
||||
params: {
|
||||
repo: 'id:repo-1',
|
||||
prNumber: 456,
|
||||
headSha: 'abc123',
|
||||
failedOnly: true
|
||||
}
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_github_resolve_thread',
|
||||
method: 'github.resolveReviewThread',
|
||||
deviceToken: mobile.token,
|
||||
params: {
|
||||
repo: 'id:repo-1',
|
||||
threadId: 'thread-1',
|
||||
resolve: true
|
||||
}
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_github_file_viewed',
|
||||
method: 'github.setPRFileViewed',
|
||||
deviceToken: mobile.token,
|
||||
params: {
|
||||
repo: 'id:repo-1',
|
||||
pullRequestId: 'PR_kw',
|
||||
path: 'src/app.ts',
|
||||
viewed: true
|
||||
}
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_github_request_reviewers',
|
||||
method: 'github.requestPRReviewers',
|
||||
deviceToken: mobile.token,
|
||||
params: {
|
||||
repo: 'id:repo-1',
|
||||
prNumber: 456,
|
||||
reviewers: ['alex']
|
||||
}
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_github_merge_pr',
|
||||
method: 'github.mergePR',
|
||||
deviceToken: mobile.token,
|
||||
params: {
|
||||
repo: 'id:repo-1',
|
||||
prNumber: 456,
|
||||
method: 'squash'
|
||||
}
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_gitlab_add_issue_comment',
|
||||
method: 'gitlab.addIssueComment',
|
||||
deviceToken: mobile.token,
|
||||
params: {
|
||||
repo: 'id:repo-1',
|
||||
number: 123,
|
||||
body: 'done'
|
||||
}
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_gitlab_add_mr_comment',
|
||||
method: 'gitlab.addMRComment',
|
||||
deviceToken: mobile.token,
|
||||
params: {
|
||||
repo: 'id:repo-1',
|
||||
iid: 456,
|
||||
body: 'ship it'
|
||||
}
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_gitlab_merge_mr',
|
||||
method: 'gitlab.mergeMR',
|
||||
deviceToken: mobile.token,
|
||||
params: {
|
||||
repo: 'id:repo-1',
|
||||
iid: 456,
|
||||
method: 'merge'
|
||||
}
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_linear_search',
|
||||
method: 'linear.searchIssues',
|
||||
deviceToken: mobile.token,
|
||||
params: { query: 'auth', limit: 10, workspaceId: 'workspace-1' }
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_linear_select_workspace',
|
||||
method: 'linear.selectWorkspace',
|
||||
deviceToken: mobile.token,
|
||||
params: { workspaceId: 'workspace-1' }
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_linear_team_labels',
|
||||
method: 'linear.teamLabels',
|
||||
deviceToken: mobile.token,
|
||||
params: { teamId: 'team-1', workspaceId: 'workspace-1' }
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_linear_team_members',
|
||||
method: 'linear.teamMembers',
|
||||
deviceToken: mobile.token,
|
||||
params: { teamId: 'team-1', workspaceId: 'workspace-1' }
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_linear_add_comment',
|
||||
method: 'linear.addIssueComment',
|
||||
deviceToken: mobile.token,
|
||||
params: { issueId: 'issue-1', workspaceId: 'workspace-1', body: 'done' }
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_git_status',
|
||||
@@ -981,6 +1507,92 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
})
|
||||
)
|
||||
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_allowed', ok: true }))
|
||||
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_settings_get', ok: true }))
|
||||
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_settings_update', ok: true }))
|
||||
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_github_projects', ok: true }))
|
||||
expect(replies).toContainEqual(
|
||||
expect.objectContaining({ id: 'req_project_issue_types', ok: true })
|
||||
)
|
||||
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_project_labels', ok: true }))
|
||||
expect(replies).toContainEqual(
|
||||
expect.objectContaining({ id: 'req_project_assignees', ok: true })
|
||||
)
|
||||
expect(replies).toContainEqual(
|
||||
expect.objectContaining({ id: 'req_project_update_issue', ok: true })
|
||||
)
|
||||
expect(replies).toContainEqual(
|
||||
expect.objectContaining({ id: 'req_project_update_issue_type', ok: true })
|
||||
)
|
||||
expect(replies).toContainEqual(
|
||||
expect.objectContaining({ id: 'req_project_update_field', ok: true })
|
||||
)
|
||||
expect(replies).toContainEqual(
|
||||
expect.objectContaining({ id: 'req_project_clear_field', ok: true })
|
||||
)
|
||||
expect(replies).toContainEqual(
|
||||
expect.objectContaining({ id: 'req_project_update_pr', ok: true })
|
||||
)
|
||||
expect(replies).toContainEqual(
|
||||
expect.objectContaining({ id: 'req_project_add_comment', ok: true })
|
||||
)
|
||||
expect(replies).toContainEqual(
|
||||
expect.objectContaining({ id: 'req_project_update_comment', ok: true })
|
||||
)
|
||||
expect(replies).toContainEqual(
|
||||
expect.objectContaining({ id: 'req_project_delete_comment', ok: true })
|
||||
)
|
||||
expect(replies).toContainEqual(
|
||||
expect.objectContaining({ id: 'req_github_update_issue', ok: true })
|
||||
)
|
||||
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_github_labels', ok: true }))
|
||||
expect(replies).toContainEqual(
|
||||
expect.objectContaining({ id: 'req_github_assignees', ok: true })
|
||||
)
|
||||
expect(replies).toContainEqual(
|
||||
expect.objectContaining({ id: 'req_github_add_comment', ok: true })
|
||||
)
|
||||
expect(replies).toContainEqual(
|
||||
expect.objectContaining({ id: 'req_github_add_review_comment', ok: true })
|
||||
)
|
||||
expect(replies).toContainEqual(
|
||||
expect.objectContaining({ id: 'req_github_reply_review_comment', ok: true })
|
||||
)
|
||||
expect(replies).toContainEqual(
|
||||
expect.objectContaining({ id: 'req_github_pr_file_contents', ok: true })
|
||||
)
|
||||
expect(replies).toContainEqual(
|
||||
expect.objectContaining({ id: 'req_github_rerun_checks', ok: true })
|
||||
)
|
||||
expect(replies).toContainEqual(
|
||||
expect.objectContaining({ id: 'req_github_resolve_thread', ok: true })
|
||||
)
|
||||
expect(replies).toContainEqual(
|
||||
expect.objectContaining({ id: 'req_github_file_viewed', ok: true })
|
||||
)
|
||||
expect(replies).toContainEqual(
|
||||
expect.objectContaining({ id: 'req_github_request_reviewers', ok: true })
|
||||
)
|
||||
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_github_merge_pr', ok: true }))
|
||||
expect(replies).toContainEqual(
|
||||
expect.objectContaining({ id: 'req_gitlab_add_issue_comment', ok: true })
|
||||
)
|
||||
expect(replies).toContainEqual(
|
||||
expect.objectContaining({ id: 'req_gitlab_add_mr_comment', ok: true })
|
||||
)
|
||||
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_gitlab_merge_mr', ok: true }))
|
||||
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_linear_search', ok: true }))
|
||||
expect(replies).toContainEqual(
|
||||
expect.objectContaining({ id: 'req_linear_select_workspace', ok: true })
|
||||
)
|
||||
expect(replies).toContainEqual(
|
||||
expect.objectContaining({ id: 'req_linear_team_labels', ok: true })
|
||||
)
|
||||
expect(replies).toContainEqual(
|
||||
expect.objectContaining({ id: 'req_linear_team_members', ok: true })
|
||||
)
|
||||
expect(replies).toContainEqual(
|
||||
expect.objectContaining({ id: 'req_linear_add_comment', ok: true })
|
||||
)
|
||||
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_status', ok: true }))
|
||||
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_push', ok: true }))
|
||||
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_upstream', ok: true }))
|
||||
@@ -1038,6 +1650,116 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
worktree: 'id:wt-1',
|
||||
page: 'page-1'
|
||||
})
|
||||
expect(listGitHubIssueTypesBySlug).toHaveBeenCalledWith({
|
||||
owner: 'stablyai',
|
||||
repo: 'orca'
|
||||
})
|
||||
expect(listGitHubLabelsBySlug).toHaveBeenCalledWith({
|
||||
owner: 'stablyai',
|
||||
repo: 'orca'
|
||||
})
|
||||
expect(listGitHubAssignableUsersBySlug).toHaveBeenCalledWith({
|
||||
owner: 'stablyai',
|
||||
repo: 'orca',
|
||||
seedLogins: ['alex']
|
||||
})
|
||||
expect(updateGitHubIssueBySlug).toHaveBeenCalledWith({
|
||||
owner: 'stablyai',
|
||||
repo: 'orca',
|
||||
number: 123,
|
||||
updates: { title: 'New title' }
|
||||
})
|
||||
expect(updateGitHubIssueTypeBySlug).toHaveBeenCalledWith({
|
||||
owner: 'stablyai',
|
||||
repo: 'orca',
|
||||
number: 123,
|
||||
issueTypeId: 'type-1'
|
||||
})
|
||||
expect(updateGitHubPullRequestBySlug).toHaveBeenCalledWith({
|
||||
owner: 'stablyai',
|
||||
repo: 'orca',
|
||||
number: 456,
|
||||
updates: { state: 'closed' }
|
||||
})
|
||||
expect(addGitHubIssueCommentBySlug).toHaveBeenCalledWith({
|
||||
owner: 'stablyai',
|
||||
repo: 'orca',
|
||||
number: 123,
|
||||
body: 'done'
|
||||
})
|
||||
expect(updateGitHubIssueCommentBySlug).toHaveBeenCalledWith({
|
||||
owner: 'stablyai',
|
||||
repo: 'orca',
|
||||
commentId: 101,
|
||||
body: 'edited'
|
||||
})
|
||||
expect(deleteGitHubIssueCommentBySlug).toHaveBeenCalledWith({
|
||||
owner: 'stablyai',
|
||||
repo: 'orca',
|
||||
commentId: 101
|
||||
})
|
||||
expect(updateRepoIssue).toHaveBeenCalledWith('id:repo-1', 123, {
|
||||
title: 'New title',
|
||||
addLabels: ['bug']
|
||||
})
|
||||
expect(listRepoLabels).toHaveBeenCalledWith('id:repo-1')
|
||||
expect(listRepoAssignableUsers).toHaveBeenCalledWith('id:repo-1')
|
||||
expect(addRepoIssueComment).toHaveBeenCalledWith('id:repo-1', 123, 'done')
|
||||
expect(addRepoPRReviewComment).toHaveBeenCalledWith('id:repo-1', {
|
||||
prNumber: 456,
|
||||
commitId: 'abc123',
|
||||
path: 'src/app.ts',
|
||||
line: 10,
|
||||
startLine: undefined,
|
||||
body: 'please fix'
|
||||
})
|
||||
expect(addRepoPRReviewCommentReply).toHaveBeenCalledWith('id:repo-1', {
|
||||
prNumber: 456,
|
||||
commentId: 99,
|
||||
body: 'fixed',
|
||||
threadId: 'thread-1',
|
||||
path: 'src/app.ts',
|
||||
line: 10
|
||||
})
|
||||
expect(getRepoPRFileContents).toHaveBeenCalledWith('id:repo-1', {
|
||||
prNumber: 456,
|
||||
path: 'src/app.ts',
|
||||
oldPath: undefined,
|
||||
status: 'modified',
|
||||
headSha: 'abc123',
|
||||
baseSha: 'def456'
|
||||
})
|
||||
expect(rerunRepoPRChecks).toHaveBeenCalledWith('id:repo-1', 456, {
|
||||
headSha: 'abc123',
|
||||
failedOnly: true
|
||||
})
|
||||
expect(resolveRepoReviewThread).toHaveBeenCalledWith('id:repo-1', 'thread-1', true)
|
||||
expect(setRepoPRFileViewed).toHaveBeenCalledWith('id:repo-1', {
|
||||
pullRequestId: 'PR_kw',
|
||||
path: 'src/app.ts',
|
||||
viewed: true
|
||||
})
|
||||
expect(requestRepoPRReviewers).toHaveBeenCalledWith('id:repo-1', 456, ['alex'])
|
||||
expect(mergeRepoPR).toHaveBeenCalledWith('id:repo-1', 456, 'squash', null)
|
||||
expect(addGitLabRepoIssueComment).toHaveBeenCalledWith('id:repo-1', 123, 'done', undefined)
|
||||
expect(addGitLabRepoMRComment).toHaveBeenCalledWith('id:repo-1', 456, 'ship it', undefined)
|
||||
expect(mergeGitLabRepoMR).toHaveBeenCalledWith('id:repo-1', 456, 'merge', undefined)
|
||||
expect(updateGitHubProjectItemField).toHaveBeenCalledWith({
|
||||
projectId: 'project-1',
|
||||
itemId: 'item-1',
|
||||
fieldId: 'field-1',
|
||||
value: { kind: 'text', text: 'Ready' }
|
||||
})
|
||||
expect(clearGitHubProjectItemField).toHaveBeenCalledWith({
|
||||
projectId: 'project-1',
|
||||
itemId: 'item-1',
|
||||
fieldId: 'field-1'
|
||||
})
|
||||
expect(linearSearchIssues).toHaveBeenCalledWith('auth', 10, 'workspace-1')
|
||||
expect(linearSelectWorkspace).toHaveBeenCalledWith('workspace-1')
|
||||
expect(linearTeamLabels).toHaveBeenCalledWith('team-1', 'workspace-1')
|
||||
expect(linearTeamMembers).toHaveBeenCalledWith('team-1', 'workspace-1')
|
||||
expect(linearAddIssueComment).toHaveBeenCalledWith('issue-1', 'done', 'workspace-1')
|
||||
expect(removeClaudeAccount).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -156,12 +156,82 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
||||
'git.status',
|
||||
'git.unstage',
|
||||
'git.upstreamStatus',
|
||||
'github.createIssue',
|
||||
'github.addIssueComment',
|
||||
'github.addPRReviewComment',
|
||||
'github.addPRReviewCommentReply',
|
||||
'github.countWorkItems',
|
||||
'github.listAssignableUsers',
|
||||
'github.listLabels',
|
||||
'github.listWorkItems',
|
||||
'github.mergePR',
|
||||
'github.requestPRReviewers',
|
||||
'github.project.listAccessible',
|
||||
'github.project.listAssignableUsersBySlug',
|
||||
'github.project.listIssueTypesBySlug',
|
||||
'github.project.listLabelsBySlug',
|
||||
'github.project.listViews',
|
||||
'github.project.resolveRef',
|
||||
'github.project.addIssueCommentBySlug',
|
||||
'github.project.updateIssueCommentBySlug',
|
||||
'github.project.deleteIssueCommentBySlug',
|
||||
'github.project.clearItemField',
|
||||
'github.project.updateIssueBySlug',
|
||||
'github.project.updateIssueTypeBySlug',
|
||||
'github.project.updateItemField',
|
||||
'github.project.updatePullRequestBySlug',
|
||||
'github.project.viewTable',
|
||||
'github.project.workItemDetailsBySlug',
|
||||
'github.prFileContents',
|
||||
'github.prChecks',
|
||||
'github.rerunPRChecks',
|
||||
'github.resolveReviewThread',
|
||||
'github.setPRFileViewed',
|
||||
'github.updateIssue',
|
||||
'github.updatePR',
|
||||
'github.updatePRTitle',
|
||||
'github.updatePRState',
|
||||
'github.repoSlug',
|
||||
'github.workItem',
|
||||
'github.workItemDetails',
|
||||
'gitlab.createIssue',
|
||||
'gitlab.addIssueComment',
|
||||
'gitlab.addMRComment',
|
||||
'gitlab.listWorkItems',
|
||||
'gitlab.mergeMR',
|
||||
'gitlab.todos',
|
||||
'gitlab.updateIssue',
|
||||
'gitlab.updateMR',
|
||||
'gitlab.updateMRState',
|
||||
'gitlab.workItemDetails',
|
||||
'linear.getIssue',
|
||||
'linear.addIssueComment',
|
||||
'linear.connect',
|
||||
'linear.createIssue',
|
||||
'linear.issueComments',
|
||||
'linear.listIssues',
|
||||
'linear.listProjects',
|
||||
'linear.teamLabels',
|
||||
'linear.teamMembers',
|
||||
'linear.listTeams',
|
||||
'linear.searchIssues',
|
||||
'linear.selectWorkspace',
|
||||
'linear.status',
|
||||
'linear.teamStates',
|
||||
'linear.updateIssue',
|
||||
'markdown.readTab',
|
||||
'markdown.saveTab',
|
||||
'notifications.subscribe',
|
||||
'notifications.unsubscribe',
|
||||
'preflight.check',
|
||||
'preflight.detectAgents',
|
||||
'preflight.detectRemoteAgents',
|
||||
'repo.hooks',
|
||||
'repo.list',
|
||||
'repo.saveSparsePreset',
|
||||
'repo.searchRefs',
|
||||
'repo.sparsePresets',
|
||||
'repo.update',
|
||||
'session.tabs.activate',
|
||||
'session.tabs.close',
|
||||
'session.tabs.createTerminal',
|
||||
@@ -171,6 +241,14 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
||||
'session.tabs.subscribe',
|
||||
'session.tabs.subscribeAll',
|
||||
'session.tabs.unsubscribe',
|
||||
'settings.get',
|
||||
'settings.update',
|
||||
'ssh.connect',
|
||||
'ssh.getState',
|
||||
'speech.dictation.cancel',
|
||||
'speech.dictation.chunk',
|
||||
'speech.dictation.finish',
|
||||
'speech.dictation.start',
|
||||
'stats.summary',
|
||||
'status.get',
|
||||
'terminal.clearBuffer',
|
||||
@@ -193,6 +271,8 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
||||
'worktree.activate',
|
||||
'worktree.create',
|
||||
'worktree.ps',
|
||||
'worktree.resolveMrBase',
|
||||
'worktree.resolvePrBase',
|
||||
'worktree.rm',
|
||||
'worktree.set',
|
||||
'worktree.sleep'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable max-lines -- Why: hosted review creation permutations share large mocks; splitting would hide branch-specific expectations. */
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
@@ -10,7 +11,8 @@ const {
|
||||
getHostedReviewForBranchMock,
|
||||
ghExecFileAsyncMock,
|
||||
gitExecFileAsyncMock,
|
||||
getUpstreamStatusMock
|
||||
getUpstreamStatusMock,
|
||||
getSshGitProviderMock
|
||||
} = vi.hoisted(() => ({
|
||||
createGitHubPullRequestMock: vi.fn(),
|
||||
getRepoSlugMock: vi.fn(),
|
||||
@@ -21,7 +23,8 @@ const {
|
||||
getHostedReviewForBranchMock: vi.fn(),
|
||||
ghExecFileAsyncMock: vi.fn(),
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
getUpstreamStatusMock: vi.fn()
|
||||
getUpstreamStatusMock: vi.fn(),
|
||||
getSshGitProviderMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../github/client', () => ({
|
||||
@@ -56,6 +59,10 @@ vi.mock('../git/upstream', () => ({
|
||||
getUpstreamStatus: getUpstreamStatusMock
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: getSshGitProviderMock
|
||||
}))
|
||||
|
||||
vi.mock('./hosted-review', () => ({
|
||||
getHostedReviewForBranch: getHostedReviewForBranchMock
|
||||
}))
|
||||
@@ -73,7 +80,8 @@ function resetMocks(): void {
|
||||
getHostedReviewForBranchMock,
|
||||
ghExecFileAsyncMock,
|
||||
gitExecFileAsyncMock,
|
||||
getUpstreamStatusMock
|
||||
getUpstreamStatusMock,
|
||||
getSshGitProviderMock
|
||||
]) {
|
||||
mock.mockReset()
|
||||
}
|
||||
@@ -184,6 +192,71 @@ describe('createHostedReview', () => {
|
||||
expect(createGitHubPullRequestMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses the SSH git provider for remote hosted-review preflight', async () => {
|
||||
const remoteGit = {
|
||||
exec: vi.fn(async (args: string[]) => {
|
||||
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref' && args[2] === 'HEAD') {
|
||||
return { stdout: 'feature\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'status') {
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args[2] === 'HEAD@{u}') {
|
||||
return { stdout: 'origin/feature\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-list') {
|
||||
return { stdout: '0 0\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'log' && args.includes('--pretty=%s')) {
|
||||
return { stdout: 'Feature title\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'log') {
|
||||
return { stdout: '- Feature title\n', stderr: '' }
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
})
|
||||
}
|
||||
getSshGitProviderMock.mockReturnValue(remoteGit)
|
||||
|
||||
await expect(
|
||||
createHostedReview(
|
||||
'/remote/repo',
|
||||
{
|
||||
provider: 'github',
|
||||
base: 'main',
|
||||
head: 'feature',
|
||||
title: 'Feature'
|
||||
},
|
||||
'ssh-1'
|
||||
)
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
number: 12,
|
||||
url: 'https://github.com/acme/orca/pull/12'
|
||||
})
|
||||
|
||||
expect(remoteGit.exec).toHaveBeenCalledWith(
|
||||
['rev-parse', '--abbrev-ref', 'HEAD'],
|
||||
'/remote/repo'
|
||||
)
|
||||
expect(remoteGit.exec).toHaveBeenCalledWith(['status', '--porcelain'], '/remote/repo')
|
||||
expect(getUpstreamStatusMock).not.toHaveBeenCalled()
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['auth', 'status', '--hostname', 'github.com'],
|
||||
{}
|
||||
)
|
||||
expect(createGitHubPullRequestMock).toHaveBeenCalledWith(
|
||||
'/remote/repo',
|
||||
{
|
||||
provider: 'github',
|
||||
base: 'main',
|
||||
head: 'feature',
|
||||
title: 'Feature'
|
||||
},
|
||||
'ssh-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the existing review instead of creating a duplicate', async () => {
|
||||
getHostedReviewForBranchMock.mockResolvedValue({
|
||||
provider: 'github',
|
||||
@@ -287,6 +360,46 @@ describe('getHostedReviewCreationEligibility', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves remote eligibility through SSH repo metadata', async () => {
|
||||
const remoteGit = {
|
||||
exec: vi.fn(async (args: string[]) => {
|
||||
if (args[0] === 'log' && args.includes('--pretty=%s')) {
|
||||
return { stdout: 'Remote title\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'log') {
|
||||
return { stdout: '- Remote title\n', stderr: '' }
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
})
|
||||
}
|
||||
getSshGitProviderMock.mockReturnValue(remoteGit)
|
||||
|
||||
await expect(
|
||||
getHostedReviewCreationEligibility({
|
||||
repoPath: '/remote/repo',
|
||||
connectionId: 'ssh-1',
|
||||
branch: 'feature/create-pr',
|
||||
base: 'origin/main',
|
||||
hasUncommittedChanges: false,
|
||||
hasUpstream: true,
|
||||
ahead: 0,
|
||||
behind: 0
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
provider: 'github',
|
||||
canCreate: true,
|
||||
title: 'Remote title',
|
||||
body: '- Remote title'
|
||||
})
|
||||
|
||||
expect(getProjectSlugMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1')
|
||||
expect(getRepoSlugMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1')
|
||||
expect(getHostedReviewForBranchMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ repoPath: '/remote/repo', connectionId: 'ssh-1' })
|
||||
)
|
||||
expect(remoteGit.exec).toHaveBeenCalledWith(['log', '-1', '--pretty=%s'], '/remote/repo')
|
||||
})
|
||||
|
||||
it('offers push as the next action for authenticated branches with local-only commits', async () => {
|
||||
await expect(
|
||||
getHostedReviewCreationEligibility({
|
||||
|
||||
@@ -17,12 +17,19 @@ import { getBitbucketRepoSlug } from '../bitbucket/client'
|
||||
import { getGiteaRepoSlug } from '../gitea/client'
|
||||
import { createGitHubPullRequest, getRepoSlug } from '../github/client'
|
||||
import { acquire, ghExecFileAsync, gitExecFileAsync, release } from '../github/gh-utils'
|
||||
import { isNoUpstreamError, normalizeGitErrorMessage } from '../../shared/git-remote-error'
|
||||
import type { GitUpstreamStatus } from '../../shared/types'
|
||||
import { gitOptionalLocksDisabledEnv } from '../git/runner'
|
||||
import { resolveDefaultBaseRefViaExec } from '../git/repo'
|
||||
import { getUpstreamStatus } from '../git/upstream'
|
||||
import { getProjectSlug } from '../gitlab/client'
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { getHostedReviewForBranch } from './hosted-review'
|
||||
|
||||
type HostedReviewCreationEligibilityInput = HostedReviewCreationEligibilityArgs & {
|
||||
connectionId?: string | null
|
||||
}
|
||||
|
||||
function stripRefPrefix(ref: string): string {
|
||||
return normalizeHostedReviewHeadRef(ref)
|
||||
}
|
||||
@@ -36,11 +43,14 @@ function branchToTitle(branch: string): string {
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase())
|
||||
}
|
||||
|
||||
async function detectHostedReviewProvider(repoPath: string): Promise<HostedReviewProvider> {
|
||||
if (await getProjectSlug(repoPath)) {
|
||||
async function detectHostedReviewProvider(
|
||||
repoPath: string,
|
||||
connectionId?: string | null
|
||||
): Promise<HostedReviewProvider> {
|
||||
if (await getProjectSlug(repoPath, connectionId)) {
|
||||
return 'gitlab'
|
||||
}
|
||||
if (await getRepoSlug(repoPath)) {
|
||||
if (await getRepoSlug(repoPath, connectionId)) {
|
||||
return 'github'
|
||||
}
|
||||
if (await getBitbucketRepoSlug(repoPath)) {
|
||||
@@ -55,10 +65,16 @@ async function detectHostedReviewProvider(repoPath: string): Promise<HostedRevie
|
||||
return 'unsupported'
|
||||
}
|
||||
|
||||
async function isGitHubAuthenticated(repoPath: string): Promise<boolean> {
|
||||
async function isGitHubAuthenticated(
|
||||
repoPath: string,
|
||||
connectionId?: string | null
|
||||
): Promise<boolean> {
|
||||
await acquire()
|
||||
try {
|
||||
await ghExecFileAsync(['auth', 'status', '--hostname', 'github.com'], { cwd: repoPath })
|
||||
await ghExecFileAsync(
|
||||
['auth', 'status', '--hostname', 'github.com'],
|
||||
connectionId ? {} : { cwd: repoPath }
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
@@ -67,9 +83,33 @@ async function isGitHubAuthenticated(repoPath: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function getLatestCommitSubject(repoPath: string): Promise<string | null> {
|
||||
async function runGitForHostedReview(
|
||||
repoPath: string,
|
||||
args: string[],
|
||||
connectionId?: string | null
|
||||
): Promise<{ stdout: string; stderr?: string }> {
|
||||
if (connectionId) {
|
||||
const provider = getSshGitProvider(connectionId)
|
||||
if (!provider) {
|
||||
throw new Error(
|
||||
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
|
||||
)
|
||||
}
|
||||
return provider.exec(args, repoPath)
|
||||
}
|
||||
return gitExecFileAsync(args, { cwd: repoPath })
|
||||
}
|
||||
|
||||
async function getLatestCommitSubject(
|
||||
repoPath: string,
|
||||
connectionId?: string | null
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(['log', '-1', '--pretty=%s'], { cwd: repoPath })
|
||||
const { stdout } = await runGitForHostedReview(
|
||||
repoPath,
|
||||
['log', '-1', '--pretty=%s'],
|
||||
connectionId
|
||||
)
|
||||
const subject = stdout.trim()
|
||||
return subject || null
|
||||
} catch {
|
||||
@@ -77,14 +117,19 @@ async function getLatestCommitSubject(repoPath: string): Promise<string | null>
|
||||
}
|
||||
}
|
||||
|
||||
async function getCommitSummaryBody(repoPath: string, base: string | null): Promise<string | null> {
|
||||
async function getCommitSummaryBody(
|
||||
repoPath: string,
|
||||
base: string | null,
|
||||
connectionId?: string | null
|
||||
): Promise<string | null> {
|
||||
if (!base) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(
|
||||
const { stdout } = await runGitForHostedReview(
|
||||
repoPath,
|
||||
['log', '--pretty=format:- %s', '--max-count=20', `${base}..HEAD`],
|
||||
{ cwd: repoPath }
|
||||
connectionId
|
||||
)
|
||||
const body = stdout.trim()
|
||||
return body || null
|
||||
@@ -93,18 +138,34 @@ async function getCommitSummaryBody(repoPath: string, base: string | null): Prom
|
||||
}
|
||||
}
|
||||
|
||||
async function getDefaultBaseRef(repoPath: string): Promise<string | null> {
|
||||
return resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, { cwd: repoPath }))
|
||||
async function getDefaultBaseRef(
|
||||
repoPath: string,
|
||||
connectionId?: string | null
|
||||
): Promise<string | null> {
|
||||
return resolveDefaultBaseRefViaExec((argv) => runGitForHostedReview(repoPath, argv, connectionId))
|
||||
}
|
||||
|
||||
async function getCurrentBranch(repoPath: string): Promise<string> {
|
||||
const { stdout } = await gitExecFileAsync(['rev-parse', '--abbrev-ref', 'HEAD'], {
|
||||
cwd: repoPath
|
||||
})
|
||||
async function getCurrentBranch(repoPath: string, connectionId?: string | null): Promise<string> {
|
||||
const { stdout } = await runGitForHostedReview(
|
||||
repoPath,
|
||||
['rev-parse', '--abbrev-ref', 'HEAD'],
|
||||
connectionId
|
||||
)
|
||||
return stripRefPrefix(stdout.trim())
|
||||
}
|
||||
|
||||
async function hasUncommittedChanges(repoPath: string): Promise<boolean> {
|
||||
async function hasUncommittedChanges(
|
||||
repoPath: string,
|
||||
connectionId?: string | null
|
||||
): Promise<boolean> {
|
||||
if (connectionId) {
|
||||
const { stdout } = await runGitForHostedReview(
|
||||
repoPath,
|
||||
['status', '--porcelain'],
|
||||
connectionId
|
||||
)
|
||||
return stdout.trim().length > 0
|
||||
}
|
||||
const { stdout } = await gitExecFileAsync(['status', '--porcelain'], {
|
||||
cwd: repoPath,
|
||||
// Why: create-PR validation should not take Git's optional index lock while
|
||||
@@ -114,6 +175,47 @@ async function hasUncommittedChanges(repoPath: string): Promise<boolean> {
|
||||
return stdout.trim().length > 0
|
||||
}
|
||||
|
||||
async function getHostedReviewUpstreamStatus(
|
||||
repoPath: string,
|
||||
connectionId?: string | null
|
||||
): Promise<GitUpstreamStatus> {
|
||||
if (!connectionId) {
|
||||
return getUpstreamStatus(repoPath)
|
||||
}
|
||||
try {
|
||||
const { stdout: upstreamStdout } = await runGitForHostedReview(
|
||||
repoPath,
|
||||
['rev-parse', '--abbrev-ref', 'HEAD@{u}'],
|
||||
connectionId
|
||||
)
|
||||
const upstreamName = upstreamStdout.trim()
|
||||
if (!upstreamName) {
|
||||
return { hasUpstream: false, ahead: 0, behind: 0 }
|
||||
}
|
||||
|
||||
const { stdout: countsStdout } = await runGitForHostedReview(
|
||||
repoPath,
|
||||
['rev-list', '--left-right', '--count', 'HEAD...@{u}'],
|
||||
connectionId
|
||||
)
|
||||
const tokens = countsStdout.trim().split(/\s+/)
|
||||
if (tokens.length !== 2) {
|
||||
throw new Error(`Unexpected git rev-list output: ${JSON.stringify(countsStdout)}`)
|
||||
}
|
||||
const ahead = Number.parseInt(tokens[0]!, 10)
|
||||
const behind = Number.parseInt(tokens[1]!, 10)
|
||||
if (!Number.isFinite(ahead) || !Number.isFinite(behind) || ahead < 0 || behind < 0) {
|
||||
throw new Error(`Unparseable git rev-list counts: ${JSON.stringify(countsStdout)}`)
|
||||
}
|
||||
return { hasUpstream: true, upstreamName, ahead, behind }
|
||||
} catch (error) {
|
||||
if (isNoUpstreamError(error)) {
|
||||
return { hasUpstream: false, ahead: 0, behind: 0 }
|
||||
}
|
||||
throw new Error(normalizeGitErrorMessage(error, 'upstream'))
|
||||
}
|
||||
}
|
||||
|
||||
const blockedCreateResultByReason = {
|
||||
auth_required: {
|
||||
ok: false,
|
||||
@@ -191,10 +293,11 @@ function blockedEligibilityToCreateResult(
|
||||
|
||||
async function validateCurrentBranchCanCreateReview(
|
||||
repoPath: string,
|
||||
connectionId: string | null | undefined,
|
||||
input: CreateHostedReviewInput
|
||||
): Promise<CreateHostedReviewResult | null> {
|
||||
const requestedHead = input.head ? stripRefPrefix(input.head).trim() : ''
|
||||
const currentBranch = await getCurrentBranch(repoPath)
|
||||
const currentBranch = await getCurrentBranch(repoPath, connectionId)
|
||||
if (requestedHead && requestedHead !== currentBranch) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -205,8 +308,8 @@ async function validateCurrentBranchCanCreateReview(
|
||||
|
||||
try {
|
||||
const [dirty, upstreamStatus] = await Promise.all([
|
||||
hasUncommittedChanges(repoPath),
|
||||
getUpstreamStatus(repoPath)
|
||||
hasUncommittedChanges(repoPath, connectionId),
|
||||
getHostedReviewUpstreamStatus(repoPath, connectionId)
|
||||
])
|
||||
const eligibility = await getHostedReviewCreationEligibility({
|
||||
repoPath,
|
||||
@@ -215,7 +318,8 @@ async function validateCurrentBranchCanCreateReview(
|
||||
hasUncommittedChanges: dirty,
|
||||
hasUpstream: upstreamStatus.hasUpstream,
|
||||
ahead: upstreamStatus.ahead,
|
||||
behind: upstreamStatus.behind
|
||||
behind: upstreamStatus.behind,
|
||||
connectionId
|
||||
})
|
||||
// Why: renderer eligibility can be stale by submit time; the main process
|
||||
// is the last chance to avoid creating a PR from an out-of-date remote head.
|
||||
@@ -232,11 +336,12 @@ async function validateCurrentBranchCanCreateReview(
|
||||
}
|
||||
|
||||
export async function getHostedReviewCreationEligibility(
|
||||
args: HostedReviewCreationEligibilityArgs
|
||||
args: HostedReviewCreationEligibilityInput
|
||||
): Promise<HostedReviewCreationEligibility> {
|
||||
const branch = stripRefPrefix(args.branch).trim()
|
||||
const provider = await detectHostedReviewProvider(args.repoPath)
|
||||
const defaultBaseRef = args.base?.trim() || (await getDefaultBaseRef(args.repoPath))
|
||||
const provider = await detectHostedReviewProvider(args.repoPath, args.connectionId)
|
||||
const defaultBaseRef =
|
||||
args.base?.trim() || (await getDefaultBaseRef(args.repoPath, args.connectionId))
|
||||
const baseBranch = defaultBaseRef ? normalizeHostedReviewBaseRef(defaultBaseRef) : null
|
||||
const review = await getHostedReviewForBranch({
|
||||
repoPath: args.repoPath,
|
||||
@@ -246,11 +351,13 @@ export async function getHostedReviewCreationEligibility(
|
||||
linkedGitLabMR: args.linkedGitLabMR ?? null,
|
||||
linkedBitbucketPR: args.linkedBitbucketPR ?? null,
|
||||
linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null,
|
||||
linkedGiteaPR: args.linkedGiteaPR ?? null
|
||||
linkedGiteaPR: args.linkedGiteaPR ?? null,
|
||||
connectionId: args.connectionId ?? null
|
||||
})
|
||||
|
||||
const title = (await getLatestCommitSubject(args.repoPath)) ?? branchToTitle(branch)
|
||||
const body = await getCommitSummaryBody(args.repoPath, defaultBaseRef ?? null)
|
||||
const title =
|
||||
(await getLatestCommitSubject(args.repoPath, args.connectionId)) ?? branchToTitle(branch)
|
||||
const body = await getCommitSummaryBody(args.repoPath, defaultBaseRef ?? null, args.connectionId)
|
||||
const baseResult = {
|
||||
provider,
|
||||
review: review ? { number: review.number, url: review.url } : null,
|
||||
@@ -294,7 +401,7 @@ export async function getHostedReviewCreationEligibility(
|
||||
if ((args.behind ?? 0) > 0) {
|
||||
return { ...baseResult, canCreate: false, blockedReason: 'needs_sync', nextAction: 'sync' }
|
||||
}
|
||||
if (!(await isGitHubAuthenticated(args.repoPath))) {
|
||||
if (!(await isGitHubAuthenticated(args.repoPath, args.connectionId))) {
|
||||
return {
|
||||
...baseResult,
|
||||
canCreate: false,
|
||||
@@ -310,7 +417,8 @@ export async function getHostedReviewCreationEligibility(
|
||||
|
||||
export async function createHostedReview(
|
||||
repoPath: string,
|
||||
input: CreateHostedReviewInput
|
||||
input: CreateHostedReviewInput,
|
||||
connectionId?: string | null
|
||||
): Promise<CreateHostedReviewResult> {
|
||||
if (input.provider !== 'github') {
|
||||
return {
|
||||
@@ -319,7 +427,7 @@ export async function createHostedReview(
|
||||
error: 'Creating reviews for this provider is not supported yet.'
|
||||
}
|
||||
}
|
||||
const provider = await detectHostedReviewProvider(repoPath)
|
||||
const provider = await detectHostedReviewProvider(repoPath, connectionId)
|
||||
if (provider !== 'github') {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -327,9 +435,9 @@ export async function createHostedReview(
|
||||
error: 'Creating pull requests requires a GitHub remote.'
|
||||
}
|
||||
}
|
||||
const blocked = await validateCurrentBranchCanCreateReview(repoPath, input)
|
||||
const blocked = await validateCurrentBranchCanCreateReview(repoPath, connectionId, input)
|
||||
if (blocked) {
|
||||
return blocked
|
||||
}
|
||||
return createGitHubPullRequest(repoPath, input)
|
||||
return createGitHubPullRequest(repoPath, input, connectionId)
|
||||
}
|
||||
|
||||
@@ -82,7 +82,11 @@ describe('getHostedReviewForBranch', () => {
|
||||
})
|
||||
|
||||
await expect(
|
||||
getHostedReviewForBranch({ repoPath: '/repo', branch: 'refs/heads/feature' })
|
||||
getHostedReviewForBranch({
|
||||
repoPath: '/repo',
|
||||
connectionId: 'ssh-1',
|
||||
branch: 'refs/heads/feature'
|
||||
})
|
||||
).resolves.toEqual({
|
||||
provider: 'gitlab',
|
||||
number: 7,
|
||||
@@ -93,6 +97,8 @@ describe('getHostedReviewForBranch', () => {
|
||||
updatedAt: '2026-05-10T00:00:00.000Z',
|
||||
mergeable: 'MERGEABLE'
|
||||
})
|
||||
expect(getProjectSlugMock).toHaveBeenCalledWith('/repo', 'ssh-1')
|
||||
expect(getMergeRequestForBranchMock).toHaveBeenCalledWith('/repo', 'feature', null, 'ssh-1')
|
||||
expect(getPRForBranchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -119,11 +119,15 @@ export async function getHostedReviewForBranch(input: {
|
||||
// Why: branch review status is tied to the branch publishing remote.
|
||||
// GitHub and GitLab task/project surfaces may use richer per-provider
|
||||
// source preferences, but this core status should follow origin.
|
||||
const gitlabProject = await getProjectSlug(input.repoPath)
|
||||
const gitlabProject = await getProjectSlug(input.repoPath, input.connectionId)
|
||||
if (gitlabProject) {
|
||||
const mr =
|
||||
(await getMergeRequestForBranch(input.repoPath, branchName, input.linkedGitLabMR ?? null)) ??
|
||||
null
|
||||
(await getMergeRequestForBranch(
|
||||
input.repoPath,
|
||||
branchName,
|
||||
input.linkedGitLabMR ?? null,
|
||||
input.connectionId
|
||||
)) ?? null
|
||||
return mr ? mapGitLabReview(mr) : null
|
||||
}
|
||||
|
||||
|
||||
@@ -683,7 +683,7 @@ export type PreloadApi = {
|
||||
mrIid: number
|
||||
sourceBranch?: string
|
||||
isCrossRepository?: boolean
|
||||
}) => Promise<{ baseBranch: string } | { error: string }>
|
||||
}) => Promise<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }>
|
||||
remove: (args: { worktreeId: string; force?: boolean; skipArchive?: boolean }) => Promise<void>
|
||||
updateMeta: (args: { worktreeId: string; updates: Partial<WorktreeMeta> }) => Promise<Worktree>
|
||||
listLineage: () => Promise<Record<string, WorktreeLineage>>
|
||||
|
||||
@@ -497,7 +497,7 @@ const api = {
|
||||
mrIid: number
|
||||
sourceBranch?: string
|
||||
isCrossRepository?: boolean
|
||||
}): Promise<{ baseBranch: string } | { error: string }> =>
|
||||
}): Promise<{ baseBranch: string; pushTarget?: unknown } | { error: string }> =>
|
||||
ipcRenderer.invoke('worktrees:resolveMrBase', args),
|
||||
|
||||
remove: (args: { worktreeId: string; force?: boolean; skipArchive?: boolean }): Promise<void> =>
|
||||
|
||||
@@ -17,6 +17,7 @@ export async function addWorktreeOp(git: GitExec, params: Record<string, unknown
|
||||
const targetDir = params.targetDir as string
|
||||
const base = params.base as string | undefined
|
||||
const checkoutExistingBranch = params.checkoutExistingBranch === true
|
||||
const noCheckout = params.noCheckout === true
|
||||
|
||||
// Why: a branchName starting with '-' would be interpreted as a git flag,
|
||||
// potentially changing the command's semantics (e.g. "--detach").
|
||||
@@ -47,6 +48,9 @@ export async function addWorktreeOp(git: GitExec, params: Record<string, unknown
|
||||
const args = checkoutExistingBranch
|
||||
? ['worktree', 'add', targetDir, branchName]
|
||||
: ['worktree', 'add', '--no-track', '-b', branchName, targetDir]
|
||||
if (!checkoutExistingBranch && noCheckout) {
|
||||
args.splice(3, 0, '--no-checkout')
|
||||
}
|
||||
if (effectiveBase) {
|
||||
args.push(effectiveBase)
|
||||
}
|
||||
|
||||
@@ -865,6 +865,33 @@ describe('GitHandler', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('passes --no-checkout when sparse setup will checkout after configuration', async () => {
|
||||
const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt'])
|
||||
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // rev-parse refs/remotes/origin/main
|
||||
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add
|
||||
gitMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // --get
|
||||
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // --local set
|
||||
|
||||
await localDispatcher.callRequest('git.addWorktree', {
|
||||
repoPath: '/relay/repo',
|
||||
branchName: 'feature/sparse',
|
||||
targetDir: '/relay/wt',
|
||||
base: 'origin/main',
|
||||
noCheckout: true
|
||||
})
|
||||
|
||||
expect(gitMock.mock.calls[1]?.[0]).toEqual([
|
||||
'worktree',
|
||||
'add',
|
||||
'--no-track',
|
||||
'--no-checkout',
|
||||
'-b',
|
||||
'feature/sparse',
|
||||
'/relay/wt',
|
||||
'refs/remotes/origin/main'
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves an existing push.autoSetupRemote value (does not overwrite user-set false)', async () => {
|
||||
const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt'])
|
||||
gitMock.mockRejectedValueOnce(new Error('not a branch')) // rev-parse refs/heads/main^{commit}
|
||||
|
||||
@@ -463,7 +463,7 @@ export default function GitLabItemDialog({
|
||||
className="gap-1.5"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
Open in browser
|
||||
Open in GitLab
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{onCreateWorkspace ? (
|
||||
|
||||
@@ -1848,7 +1848,7 @@ function PaginationBar({
|
||||
}): React.JSX.Element {
|
||||
const pageNumbers = getPageNumbers(currentPage, totalPages)
|
||||
const btnClass =
|
||||
'inline-flex items-center gap-0.5 rounded-md px-2 py-1 text-sm text-muted-foreground transition hover:bg-muted/60 hover:text-foreground disabled:pointer-events-none disabled:opacity-40'
|
||||
'inline-flex w-24 items-center justify-center gap-0.5 rounded-md px-2 py-1 text-sm text-muted-foreground transition hover:bg-muted/60 hover:text-foreground disabled:pointer-events-none disabled:opacity-40'
|
||||
const numClass = (page: number): string =>
|
||||
cn(
|
||||
'inline-flex size-8 items-center justify-center rounded-md text-sm transition',
|
||||
@@ -2673,9 +2673,9 @@ export default function TaskPage(): React.JSX.Element {
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Why: GitLab queries don't work over SSH-relay (yet) and folder-
|
||||
// mode repos have no remotes to derive a project from. Filter both.
|
||||
const eligibleRepos = selectedRepos.filter((r) => !r.connectionId)
|
||||
// Why: folder-mode repos have no remotes to derive a GitLab project from;
|
||||
// SSH-backed Git repos go through the same provider-aware IPC path.
|
||||
const eligibleRepos = selectedRepos
|
||||
if (eligibleRepos.length === 0) {
|
||||
setGitlabItems([])
|
||||
setGitlabLoading(false)
|
||||
@@ -5343,7 +5343,7 @@ export default function TaskPage(): React.JSX.Element {
|
||||
e.stopPropagation()
|
||||
void window.api.shell.openUrl(item.url)
|
||||
}}
|
||||
aria-label="Open in browser"
|
||||
aria-label="Open in GitLab"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isIterationCurrent, type ProjectGroup } from './group-sort'
|
||||
import { isIterationCurrent, type ProjectGroup } from '../../../../shared/github-project-group-sort'
|
||||
|
||||
type Props = {
|
||||
group: ProjectGroup
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cn } from '@/lib/utils'
|
||||
import ColumnResizeHandle from './ColumnResizeHandle'
|
||||
import ProjectGroupHeader from './ProjectGroupHeader'
|
||||
import ProjectRow from './ProjectRow'
|
||||
import { groupRows, sortRows } from './group-sort'
|
||||
import { groupRows, sortRows } from '../../../../shared/github-project-group-sort'
|
||||
import { getAvailableColumns, loadHiddenColumns, saveHiddenColumns } from './columns'
|
||||
import {
|
||||
ACTION_COLUMN_WIDTH,
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
GitHubProjectTable,
|
||||
GitHubProjectView
|
||||
} from '../../../../shared/github-project-types'
|
||||
import { sortRows, groupRows } from './group-sort'
|
||||
import { sortRows, groupRows } from '../../../../shared/github-project-group-sort'
|
||||
|
||||
const singleSelectField: GitHubProjectField = {
|
||||
kind: 'single-select',
|
||||
@@ -205,6 +205,18 @@ describe('sortRows', () => {
|
||||
const sorted = sortRows(makeTable(view, rows), rows)
|
||||
expect(sorted.map((r) => r.id)).toEqual(['rHas', 'rEmpty'])
|
||||
})
|
||||
|
||||
it('keeps sort fallback finite when row positions are absent', () => {
|
||||
const view = makeView(singleSelectField)
|
||||
const rows = [
|
||||
{ ...makeRow('rA', 0, {}), position: undefined as unknown as number },
|
||||
{ ...makeRow('rB', 0, {}), position: undefined as unknown as number }
|
||||
]
|
||||
|
||||
const sorted = sortRows(makeTable(view, rows), rows)
|
||||
|
||||
expect(sorted.map((r) => r.id)).toEqual(['rA', 'rB'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupRows', () => {
|
||||
|
||||
@@ -1,37 +1,4 @@
|
||||
export type ComposerBranchSelection = {
|
||||
baseBranch: string
|
||||
branchNameOverride: string | undefined
|
||||
branchAutoName: string
|
||||
name: string | undefined
|
||||
lastAutoName: string | undefined
|
||||
}
|
||||
|
||||
export function resolveComposerBranchSelection(args: {
|
||||
refName: string
|
||||
localBranchName: string
|
||||
currentName: string
|
||||
lastAutoName: string
|
||||
}): ComposerBranchSelection {
|
||||
const trimmedCurrentName = args.currentName.trim()
|
||||
const shouldAutoName =
|
||||
!trimmedCurrentName ||
|
||||
args.currentName === args.lastAutoName ||
|
||||
args.localBranchName.startsWith(trimmedCurrentName) ||
|
||||
args.refName.startsWith(trimmedCurrentName)
|
||||
if (!shouldAutoName) {
|
||||
return {
|
||||
baseBranch: args.refName,
|
||||
branchNameOverride: undefined,
|
||||
branchAutoName: '',
|
||||
name: undefined,
|
||||
lastAutoName: undefined
|
||||
}
|
||||
}
|
||||
return {
|
||||
baseBranch: args.refName,
|
||||
branchNameOverride: args.localBranchName,
|
||||
branchAutoName: args.localBranchName,
|
||||
name: args.localBranchName,
|
||||
lastAutoName: args.localBranchName
|
||||
}
|
||||
}
|
||||
export {
|
||||
resolveComposerBranchSelection,
|
||||
type ComposerBranchSelection
|
||||
} from '../../../shared/composer-branch-selection'
|
||||
|
||||
@@ -124,7 +124,11 @@ export type ComposerCardProps = {
|
||||
onSmartBranchSelect: (refName: string, localBranchName: string) => void
|
||||
onSmartLinearIssueSelect: (issue: LinearIssue) => void
|
||||
/** GitLab parallel of onBaseBranchPrSelect. */
|
||||
onBaseBranchMrSelect?: (baseBranch: string, item: GitLabWorkItem) => void
|
||||
onBaseBranchMrSelect?: (
|
||||
baseBranch: string,
|
||||
item: GitLabWorkItem,
|
||||
pushTarget?: GitPushTarget
|
||||
) => void
|
||||
smartNameSelection: SmartWorkspaceNameSelection | null
|
||||
onClearSmartNameSelection: () => void
|
||||
agentPrompt: string
|
||||
@@ -1450,8 +1454,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
// semantics — except the note prefill uses GitLab's `!N` MR convention
|
||||
// so a glance at the worktree sidebar makes the provider obvious.
|
||||
const handleBaseBranchMrSelect = useCallback(
|
||||
(nextBaseBranch: string, item: GitLabWorkItem): void => {
|
||||
(nextBaseBranch: string, item: GitLabWorkItem, nextPushTarget?: GitPushTarget): void => {
|
||||
setBaseBranch(nextBaseBranch)
|
||||
setPushTarget(nextPushTarget)
|
||||
setBranchNameOverride(undefined)
|
||||
branchAutoNameRef.current = ''
|
||||
setStartFromResetHint(null)
|
||||
@@ -1551,7 +1556,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
if ('error' in result) {
|
||||
return
|
||||
}
|
||||
handleBaseBranchMrSelect(result.baseBranch, item)
|
||||
handleBaseBranchMrSelect(result.baseBranch, item, result.pushTarget)
|
||||
})
|
||||
},
|
||||
[applyLinkedGitLabWorkItem, eligibleRepos, handleBaseBranchMrSelect, selectedRepo]
|
||||
|
||||
@@ -984,6 +984,43 @@ describe('useIpcEvents updater integration', () => {
|
||||
title: 'Terminal 1'
|
||||
})
|
||||
|
||||
storeState.terminalLayoutsByTabId = {
|
||||
'tab-existing': {
|
||||
root: { type: 'leaf', leafId: 'leaf-source' },
|
||||
activeLeafId: 'leaf-source',
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { 'leaf-source': 'pty-bg' }
|
||||
}
|
||||
}
|
||||
updateTabPtyId.mockClear()
|
||||
setTabLayout.mockClear()
|
||||
createTerminalListenerRef.current({
|
||||
worktreeId: 'wt-2',
|
||||
ptyId: 'pty-split-background',
|
||||
tabId: 'tab-existing',
|
||||
leafId: 'leaf-split-background',
|
||||
splitFromLeafId: 'leaf-source',
|
||||
splitDirection: 'vertical',
|
||||
activate: false
|
||||
})
|
||||
|
||||
expect(updateTabPtyId).toHaveBeenCalledWith('tab-existing', 'pty-split-background')
|
||||
expect(setTabLayout).toHaveBeenCalledWith('tab-existing', {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', leafId: 'leaf-source' },
|
||||
second: { type: 'leaf', leafId: 'leaf-split-background' },
|
||||
ratio: 0.5
|
||||
},
|
||||
activeLeafId: 'leaf-source',
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: {
|
||||
'leaf-source': 'pty-bg',
|
||||
'leaf-split-background': 'pty-split-background'
|
||||
}
|
||||
})
|
||||
|
||||
const splitLayout = {
|
||||
root: {
|
||||
type: 'split',
|
||||
|
||||
@@ -130,10 +130,15 @@ function addSplitLeafToLayout(
|
||||
newLeafId: string,
|
||||
ptyId: string,
|
||||
direction: TerminalSplitDirection,
|
||||
title?: string | null
|
||||
title?: string | null,
|
||||
activateNewLeaf = true
|
||||
): TerminalLayoutSnapshot {
|
||||
const root = layout?.root ?? { type: 'leaf', leafId: sourceLeafId }
|
||||
const existingLeafIds = collectLeafIdsInOrder(root)
|
||||
const nextActiveLeafId =
|
||||
activateNewLeaf || !layout?.activeLeafId || !existingLeafIds.includes(layout.activeLeafId)
|
||||
? newLeafId
|
||||
: layout.activeLeafId
|
||||
const nextRoot = existingLeafIds.includes(newLeafId)
|
||||
? root
|
||||
: (() => {
|
||||
@@ -152,7 +157,7 @@ function addSplitLeafToLayout(
|
||||
return {
|
||||
...(layout ?? { root: null, activeLeafId: null, expandedLeafId: null }),
|
||||
root: nextRoot,
|
||||
activeLeafId: newLeafId,
|
||||
activeLeafId: nextActiveLeafId,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: {
|
||||
...layout?.ptyIdsByLeafId,
|
||||
@@ -850,7 +855,8 @@ export function useIpcEvents(): void {
|
||||
leafId,
|
||||
ptyId,
|
||||
splitDirection ?? 'horizontal',
|
||||
title
|
||||
title,
|
||||
shouldActivate
|
||||
)
|
||||
)
|
||||
window.dispatchEvent(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { toast } from 'sonner'
|
||||
import { useAppStore, type AppState } from '@/store'
|
||||
import { AGENT_CATALOG } from '@/lib/agent-catalog'
|
||||
import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft'
|
||||
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup'
|
||||
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
|
||||
import { pickTuiAgent } from '../../../shared/tui-agent-selection'
|
||||
import { activateAndRevealWorktree, type AgentStartedTelemetry } from '@/lib/worktree-activation'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import {
|
||||
@@ -69,26 +69,6 @@ export type LaunchWorkItemDirectArgs = {
|
||||
telemetrySource?: WorkspaceCreateTelemetrySource
|
||||
}
|
||||
|
||||
function pickAgent(
|
||||
preferred: TuiAgent | 'blank' | null | undefined,
|
||||
detected: Set<TuiAgent>
|
||||
): TuiAgent | null {
|
||||
// Why: honor the explicit default when the agent is actually installed. A
|
||||
// stale preference (uninstalled binary) must not block the flow — fall
|
||||
// through to the first matching detected agent in catalog order, which
|
||||
// matches the quick-composer's auto-pick behavior and keeps the experience
|
||||
// consistent regardless of where the user launches the workspace from.
|
||||
if (preferred && preferred !== 'blank' && detected.has(preferred)) {
|
||||
return preferred
|
||||
}
|
||||
for (const entry of AGENT_CATALOG) {
|
||||
if (detected.has(entry.id)) {
|
||||
return entry.id
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function resolveDirectPrStartPoint(
|
||||
repoId: string,
|
||||
prNumber: number,
|
||||
@@ -272,7 +252,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
|
||||
const worktreePath = result.worktree.path
|
||||
|
||||
const detectedIds = new Set(await detectedAgentsPromise)
|
||||
effectiveAgent = pickAgent(settings?.defaultTuiAgent, detectedIds)
|
||||
effectiveAgent = pickTuiAgent(settings?.defaultTuiAgent, detectedIds)
|
||||
if (effectiveAgent) {
|
||||
// Why: direct task launch creates and starts the workspace in separate
|
||||
// steps so agent detection can overlap git worktree creation. Persist
|
||||
|
||||
@@ -9,6 +9,8 @@ import { isShellProcess } from '@/lib/tui-agent-startup'
|
||||
import type { OrcaHooks, TaskViewPresetId } from '../../../shared/types'
|
||||
import { resolveHookCommandSourcePolicy } from '../../../shared/hook-command-source-policy'
|
||||
import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition'
|
||||
import { slugifyForWorkspaceName } from '../../../shared/workspace-name'
|
||||
export { getLinkedWorkItemSuggestedName } from '../../../shared/workspace-name'
|
||||
|
||||
/**
|
||||
* Why: the TaskPage's preset buttons and the openTaskPage prefetcher both need
|
||||
@@ -158,43 +160,6 @@ export function getSetupConfig(
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: branch names and on-disk worktree directories must be short, lowercase,
|
||||
// and ASCII-safe. Free-form text (prompts, GitHub titles) often contains
|
||||
// emoji, CJK, or hundreds of characters, which would otherwise make
|
||||
// sanitizeWorktreeName either produce a ludicrously long name or throw
|
||||
// "Invalid worktree name" when every character is stripped.
|
||||
function slugifyForWorkspaceName(input: string): string {
|
||||
return (
|
||||
input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\\/]+/g, '-')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
// Why: git check-ref-format rejects any ref containing `..`, so a prompt
|
||||
// like "../../foo" must not turn into a branch seed with internal `..`
|
||||
// sequences (the main-process sanitizer collapses these too, but we
|
||||
// mirror the rule here so the renderer preview matches the real name).
|
||||
.replace(/\.{2,}/g, '.')
|
||||
.replace(/^[.-]+|[.-]+$/g, '')
|
||||
.slice(0, 48)
|
||||
.replace(/[-._]+$/g, '')
|
||||
)
|
||||
}
|
||||
|
||||
export function getLinkedWorkItemSuggestedName(item: { title: string }): string {
|
||||
const withoutLeadingNumber = item.title
|
||||
.trim()
|
||||
.replace(/^(?:issue|pr|pull request)\s*#?\d+\s*[:-]\s*/i, '')
|
||||
.replace(/^#\d+\s*[:-]\s*/, '')
|
||||
.replace(/\(#\d+\)/gi, '')
|
||||
.replace(/\b#\d+\b/g, '')
|
||||
.trim()
|
||||
const seed = withoutLeadingNumber || item.title.trim()
|
||||
return slugifyForWorkspaceName(seed)
|
||||
}
|
||||
|
||||
export function getWorkspaceSeedName(args: {
|
||||
explicitName: string
|
||||
prompt: string
|
||||
|
||||
@@ -1,173 +1,6 @@
|
||||
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
|
||||
import type { TuiAgent } from '../../../shared/types'
|
||||
|
||||
export type AgentStartupPlan = {
|
||||
/** Why: surfaces the agent id so downstream paste-draft logic can resolve
|
||||
* the per-agent draft injection strategy without re-deriving from the
|
||||
* launch command string. */
|
||||
agent: TuiAgent
|
||||
launchCommand: string
|
||||
expectedProcess: string
|
||||
followupPrompt: string | null
|
||||
/** Why: text to type into the live agent input WITHOUT submitting it (no
|
||||
* trailing \r). Used by the quick-create flow to pre-fill a linked work
|
||||
* item URL so the user can edit/add to it before sending. Independent from
|
||||
* `followupPrompt` so the call site can choose: type-and-submit (followup)
|
||||
* or type-and-leave-pending (draft). */
|
||||
draftPrompt?: string | null
|
||||
/** Why: env vars to apply at PTY spawn time. Currently used to deliver
|
||||
* pi's `ORCA_PI_PREFILL` so the overlay's `orca-prefill` extension
|
||||
* picks it up on session_start. Plumbed into `startup.env` by the
|
||||
* activation site, NOT into the shell command, so the value isn't
|
||||
* visibly prefixed onto the terminal. */
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
function quoteStartupArg(value: string, platform: NodeJS.Platform): string {
|
||||
if (platform === 'win32') {
|
||||
return `"${value.replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`
|
||||
}
|
||||
|
||||
export function buildAgentStartupPlan(args: {
|
||||
agent: TuiAgent
|
||||
prompt: string
|
||||
cmdOverrides: Partial<Record<TuiAgent, string>>
|
||||
platform: NodeJS.Platform
|
||||
allowEmptyPromptLaunch?: boolean
|
||||
}): AgentStartupPlan | null {
|
||||
const { agent, prompt, cmdOverrides, platform, allowEmptyPromptLaunch = false } = args
|
||||
const trimmedPrompt = prompt.trim()
|
||||
const config = TUI_AGENT_CONFIG[agent]
|
||||
const baseCommand = cmdOverrides[agent] ?? config.launchCmd
|
||||
|
||||
if (!trimmedPrompt) {
|
||||
if (!allowEmptyPromptLaunch) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
agent,
|
||||
launchCommand: baseCommand,
|
||||
expectedProcess: config.expectedProcess,
|
||||
followupPrompt: null
|
||||
}
|
||||
}
|
||||
|
||||
const quotedPrompt = quoteStartupArg(trimmedPrompt, platform)
|
||||
|
||||
if (config.promptInjectionMode === 'argv') {
|
||||
return {
|
||||
agent,
|
||||
launchCommand: `${baseCommand} ${quotedPrompt}`,
|
||||
expectedProcess: config.expectedProcess,
|
||||
followupPrompt: null
|
||||
}
|
||||
}
|
||||
|
||||
if (config.promptInjectionMode === 'flag-prompt') {
|
||||
return {
|
||||
agent,
|
||||
launchCommand: `${baseCommand} --prompt ${quotedPrompt}`,
|
||||
expectedProcess: config.expectedProcess,
|
||||
followupPrompt: null
|
||||
}
|
||||
}
|
||||
|
||||
if (config.promptInjectionMode === 'flag-prompt-interactive') {
|
||||
return {
|
||||
agent,
|
||||
launchCommand: `${baseCommand} --prompt-interactive ${quotedPrompt}`,
|
||||
expectedProcess: config.expectedProcess,
|
||||
followupPrompt: null
|
||||
}
|
||||
}
|
||||
|
||||
if (config.promptInjectionMode === 'flag-interactive') {
|
||||
return {
|
||||
agent,
|
||||
launchCommand: `${baseCommand} -i ${quotedPrompt}`,
|
||||
expectedProcess: config.expectedProcess,
|
||||
followupPrompt: null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agent,
|
||||
launchCommand: baseCommand,
|
||||
expectedProcess: config.expectedProcess,
|
||||
// Why: several agent TUIs either lack a documented "start interactive
|
||||
// session with this prompt" flag or vary too much across versions. For
|
||||
// those agents Orca launches the TUI first, then types the composed prompt
|
||||
// into the live session once the agent owns the terminal.
|
||||
followupPrompt: trimmedPrompt
|
||||
}
|
||||
}
|
||||
|
||||
export type AgentDraftLaunchPlan = {
|
||||
agent: TuiAgent
|
||||
launchCommand: string
|
||||
expectedProcess: string
|
||||
/** Why: env-var-based prefill (currently pi via the overlay-installed
|
||||
* `orca-prefill` extension) ships the draft text in the PTY-spawn
|
||||
* environment instead of via a CLI flag. Callers MUST plumb this into
|
||||
* the queued `startup.env` so it reaches the shell that launches the
|
||||
* agent. Empty/undefined when the agent uses a CLI flag (Claude). */
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Why: when the agent's CLI exposes a documented "prefill but don't submit"
|
||||
* flag (currently only `claude --prefill <text>`), launch with that flag so
|
||||
* the TUI mounts with the draft already in its input box. This is strictly
|
||||
* better than the post-launch bracketed-paste fallback in agent-paste-draft.ts
|
||||
* because it eliminates the empirical readiness wait entirely — the agent
|
||||
* controls when its input is rendered.
|
||||
*
|
||||
* Returns `null` when the agent has no native prefill flag; callers fall
|
||||
* back to the paste-after-ready path.
|
||||
*/
|
||||
export function buildAgentDraftLaunchPlan(args: {
|
||||
agent: TuiAgent
|
||||
draft: string
|
||||
cmdOverrides: Partial<Record<TuiAgent, string>>
|
||||
platform: NodeJS.Platform
|
||||
}): AgentDraftLaunchPlan | null {
|
||||
const { agent, draft, cmdOverrides, platform } = args
|
||||
const config = TUI_AGENT_CONFIG[agent]
|
||||
const trimmed = draft.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
const baseCommand = cmdOverrides[agent] ?? config.launchCmd
|
||||
if (config.draftPromptFlag) {
|
||||
const quoted = quoteStartupArg(trimmed, platform)
|
||||
return {
|
||||
agent,
|
||||
launchCommand: `${baseCommand} ${config.draftPromptFlag} ${quoted}`,
|
||||
expectedProcess: config.expectedProcess
|
||||
}
|
||||
}
|
||||
if (config.draftPromptEnvVar) {
|
||||
// Why: the env var is set on the PTY-spawn env (not embedded in the
|
||||
// shell command) so the value never has to be shell-escaped and the
|
||||
// user doesn't see a `FOO='...'` prefix typed into their terminal.
|
||||
// Append a clear-var command so the var is unset from the shell env
|
||||
// once the agent exits — otherwise re-running the agent in the same
|
||||
// terminal would inherit the stale value and re-prefill with the old URL.
|
||||
const clearVar =
|
||||
platform === 'win32'
|
||||
? `set "${config.draftPromptEnvVar}="`
|
||||
: `unset ${config.draftPromptEnvVar}`
|
||||
return {
|
||||
agent,
|
||||
launchCommand: `${baseCommand}; ${clearVar}`,
|
||||
expectedProcess: config.expectedProcess,
|
||||
env: { [config.draftPromptEnvVar]: trimmed }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export { isShellProcess } from '../../../shared/agent-detection'
|
||||
export {
|
||||
buildAgentDraftLaunchPlan,
|
||||
buildAgentStartupPlan,
|
||||
isShellProcess
|
||||
} from '../../../shared/tui-agent-startup'
|
||||
export type { AgentDraftLaunchPlan, AgentStartupPlan } from '../../../shared/tui-agent-startup'
|
||||
|
||||
@@ -669,6 +669,8 @@ function createWorktreesApi(): NonNullable<Partial<PreloadApi>['worktrees']> {
|
||||
linkedIssue: args.linkedIssue,
|
||||
linkedPR: args.linkedPR,
|
||||
linkedLinearIssue: args.linkedLinearIssue,
|
||||
linkedGitLabIssue: args.linkedGitLabIssue,
|
||||
linkedGitLabMR: args.linkedGitLabMR,
|
||||
displayName: args.displayName,
|
||||
sparseCheckout: args.sparseCheckout,
|
||||
pushTarget: args.pushTarget,
|
||||
@@ -685,9 +687,13 @@ function createWorktreesApi(): NonNullable<Partial<PreloadApi>['worktrees']> {
|
||||
headRefName,
|
||||
isCrossRepository
|
||||
}),
|
||||
resolveMrBase: async () => ({
|
||||
error: 'GitLab merge request base resolution is unavailable on web.'
|
||||
}),
|
||||
resolveMrBase: async ({ repoId, mrIid, sourceBranch, isCrossRepository }) =>
|
||||
callRuntimeResult('worktree.resolveMrBase', {
|
||||
repo: repoId,
|
||||
mrIid,
|
||||
sourceBranch,
|
||||
isCrossRepository
|
||||
}),
|
||||
remove: async ({ worktreeId, force }) => {
|
||||
cachedWorktrees = null
|
||||
await callRuntimeResult('worktree.rm', { worktree: worktreeId, force })
|
||||
@@ -1763,9 +1769,13 @@ function mapRepoPathArg(args: unknown): unknown {
|
||||
return args
|
||||
}
|
||||
const record = args as Record<string, unknown>
|
||||
const repoId = typeof record.repoId === 'string' && record.repoId.trim() ? record.repoId : null
|
||||
return {
|
||||
...record,
|
||||
repo: record.repoPath
|
||||
// Why: runtime repo selectors accept loose path/name forms, but duplicate
|
||||
// checked-out repos can make those ambiguous. The renderer already passes
|
||||
// Orca's repo id on task calls, so prefer the explicit selector.
|
||||
repo: repoId ? `id:${repoId}` : record.repoPath
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
export type ComposerBranchSelection = {
|
||||
baseBranch: string
|
||||
branchNameOverride: string | undefined
|
||||
branchAutoName: string
|
||||
name: string | undefined
|
||||
lastAutoName: string | undefined
|
||||
}
|
||||
|
||||
export function resolveComposerBranchSelection(args: {
|
||||
refName: string
|
||||
localBranchName: string
|
||||
currentName: string
|
||||
lastAutoName: string
|
||||
}): ComposerBranchSelection {
|
||||
const trimmedCurrentName = args.currentName.trim()
|
||||
const shouldAutoName =
|
||||
!trimmedCurrentName ||
|
||||
args.currentName === args.lastAutoName ||
|
||||
args.localBranchName.startsWith(trimmedCurrentName) ||
|
||||
args.refName.startsWith(trimmedCurrentName)
|
||||
if (!shouldAutoName) {
|
||||
return {
|
||||
baseBranch: args.refName,
|
||||
branchNameOverride: undefined,
|
||||
branchAutoName: '',
|
||||
name: undefined,
|
||||
lastAutoName: undefined
|
||||
}
|
||||
}
|
||||
return {
|
||||
baseBranch: args.refName,
|
||||
branchNameOverride: args.localBranchName,
|
||||
branchAutoName: args.localBranchName,
|
||||
name: args.localBranchName,
|
||||
lastAutoName: args.localBranchName
|
||||
}
|
||||
}
|
||||
+4
-14
@@ -1,12 +1,12 @@
|
||||
// Why: grouping and sorting of Project rows is deterministic shared logic
|
||||
// driven by `selectedView` — it must not depend on fetch ordering. Keeping it
|
||||
// in a pure module makes the comparator easily fixture-testable.
|
||||
// in a pure shared module lets desktop and mobile render Project views the same.
|
||||
import type {
|
||||
GitHubProjectField,
|
||||
GitHubProjectRow,
|
||||
GitHubProjectSort,
|
||||
GitHubProjectTable
|
||||
} from '../../../../shared/github-project-types'
|
||||
} from './github-project-types'
|
||||
|
||||
export type ProjectGroup = {
|
||||
/** Stable key used for React reconciliation. */
|
||||
@@ -63,7 +63,6 @@ function getFieldValueForGrouping(
|
||||
iteration: null
|
||||
}
|
||||
}
|
||||
// Fallback: use stringified value as both key and label.
|
||||
const label = deriveStringValue(value)
|
||||
return { key: `raw:${label}`, label, orderHint: 0, iteration: null }
|
||||
}
|
||||
@@ -122,7 +121,6 @@ export function groupRows(
|
||||
const entries = Array.from(buckets.entries())
|
||||
// Ordering rules per design doc §Grouping.
|
||||
entries.sort((a, b) => {
|
||||
// Empty group always last.
|
||||
if (a[0] === EMPTY_GROUP_KEY) {
|
||||
return 1
|
||||
}
|
||||
@@ -146,7 +144,6 @@ function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProje
|
||||
const field = sort.field
|
||||
const aValue = a.fieldValuesByFieldId[field.id]
|
||||
const bValue = b.fieldValuesByFieldId[field.id]
|
||||
// Missing values sort last (regardless of direction).
|
||||
if (!aValue && !bValue) {
|
||||
return 0
|
||||
}
|
||||
@@ -208,12 +205,7 @@ function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProje
|
||||
}
|
||||
} else {
|
||||
// Why: unknown sort-field kind — ignore this sort field and fall through
|
||||
// to tie-breaks (and eventually row.position). Dev-time warning gated so
|
||||
// production builds don't spam the console.
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[projectView] unknown sort-field kind', field)
|
||||
}
|
||||
// to tie-breaks (and eventually row.position).
|
||||
return 0
|
||||
}
|
||||
return sort.direction === 'DESC' ? -cmp : cmp
|
||||
@@ -229,14 +221,12 @@ export function sortRows(table: GitHubProjectTable, rows: GitHubProjectRow[]): G
|
||||
return cmp
|
||||
}
|
||||
}
|
||||
// Final tie-break: row.position preserves GitHub rank order.
|
||||
return a.position - b.position
|
||||
return (a.position ?? UNKNOWN_INDEX_SENTINEL) - (b.position ?? UNKNOWN_INDEX_SENTINEL)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
export function isIterationCurrent(iteration: { startDate: string; duration: number }): boolean {
|
||||
// Parse as YYYY-MM-DD in UTC to avoid TZ-shift false negatives near midnight.
|
||||
const start = new Date(`${iteration.startDate}T00:00:00Z`).getTime()
|
||||
if (Number.isNaN(start)) {
|
||||
return false
|
||||
@@ -146,6 +146,9 @@ export type GitLabWorkItem = {
|
||||
/** Stamped by the renderer fetcher / optimistic stubs so cross-project
|
||||
* views can attribute rows. Mirrors GitHubWorkItem.repoId. */
|
||||
repoId: string
|
||||
/** Exact GitLab project that produced this row. Mutations/details must use it
|
||||
* instead of re-resolving the repo preference later. */
|
||||
projectRef?: GitLabProjectRef
|
||||
}
|
||||
|
||||
export type GitLabMRFile = {
|
||||
@@ -245,9 +248,8 @@ export type GitLabWorkItemDetails = {
|
||||
export type GitLabIssueUpdate = {
|
||||
state?: 'opened' | 'closed'
|
||||
title?: string
|
||||
/** Mirrors GitHubIssueUpdate.body — kept ignored on the repoPath-based
|
||||
* flow for the same backward-compat reason. Slug-addressed write paths
|
||||
* cover the body-edit case end-to-end. */
|
||||
/** Why: `glab issue update` handles title/labels/assignees, while body edits
|
||||
* use the REST issue endpoint so mobile can save the markdown description. */
|
||||
body?: string
|
||||
addLabels?: string[]
|
||||
removeLabels?: string[]
|
||||
|
||||
@@ -27,7 +27,8 @@ export const RUNTIME_CAPABILITIES = [
|
||||
'browser.screencast.v1',
|
||||
'terminal.binary-stream.v1',
|
||||
'terminal.multiplex.v1',
|
||||
'workspace-ports.v1'
|
||||
'workspace-ports.v1',
|
||||
'mobile.tasks.v1'
|
||||
] as const
|
||||
|
||||
export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {})
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { z } from 'zod'
|
||||
import { FEATURE_WALL_MAX_DWELL_MS } from './feature-wall-telemetry'
|
||||
import { SETUP_SCRIPT_IMPORT_PROVIDERS } from './setup-script-import-providers'
|
||||
import { WORKSPACE_SOURCE_VALUES, type WorkspaceSource } from './workspace-source'
|
||||
|
||||
import { AGENT_HOOK_TARGETS } from './agent-hook-types'
|
||||
import { ONBOARDING_FINAL_STEP } from './constants'
|
||||
@@ -129,15 +130,8 @@ export const workspaceCreateErrorClassSchema = z.enum([
|
||||
])
|
||||
export type WorkspaceCreateErrorClass = z.infer<typeof workspaceCreateErrorClassSchema>
|
||||
|
||||
export const workspaceSourceSchema = z.enum([
|
||||
'command_palette',
|
||||
'sidebar',
|
||||
'shortcut',
|
||||
'drag_drop',
|
||||
'onboarding',
|
||||
'unknown'
|
||||
])
|
||||
export type WorkspaceSource = z.infer<typeof workspaceSourceSchema>
|
||||
export const workspaceSourceSchema = z.enum(WORKSPACE_SOURCE_VALUES)
|
||||
export type { WorkspaceSource }
|
||||
|
||||
export const launchSourceSchema = z.enum([
|
||||
'command_palette',
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { pickTuiAgent } from './tui-agent-selection'
|
||||
|
||||
describe('pickTuiAgent', () => {
|
||||
it('uses an installed preferred agent', () => {
|
||||
expect(pickTuiAgent('codex', ['claude', 'codex'])).toBe('codex')
|
||||
})
|
||||
|
||||
it('falls back in desktop catalog order when the preference is absent or stale', () => {
|
||||
expect(pickTuiAgent(null, ['cursor', 'codex'])).toBe('codex')
|
||||
expect(pickTuiAgent('gemini', ['cursor', 'codex'])).toBe('codex')
|
||||
})
|
||||
|
||||
it('respects the explicit blank terminal preference', () => {
|
||||
expect(pickTuiAgent('blank', ['cursor', 'claude'])).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { TuiAgent } from './types'
|
||||
|
||||
// Keep this order in sync with the desktop agent catalog. It defines the
|
||||
// automatic fallback priority when the user has not chosen a default agent.
|
||||
export const TUI_AGENT_AUTO_PICK_ORDER = [
|
||||
'claude',
|
||||
'codex',
|
||||
'grok',
|
||||
'copilot',
|
||||
'opencode',
|
||||
'pi',
|
||||
'gemini',
|
||||
'antigravity',
|
||||
'aider',
|
||||
'goose',
|
||||
'amp',
|
||||
'kilo',
|
||||
'kiro',
|
||||
'crush',
|
||||
'aug',
|
||||
'autohand',
|
||||
'cline',
|
||||
'codebuff',
|
||||
'continue',
|
||||
'cursor',
|
||||
'droid',
|
||||
'kimi',
|
||||
'mistral-vibe',
|
||||
'qwen-code',
|
||||
'rovo',
|
||||
'hermes',
|
||||
'openclaw'
|
||||
] as const satisfies readonly TuiAgent[]
|
||||
|
||||
export function pickTuiAgent(
|
||||
preferred: TuiAgent | 'blank' | null | undefined,
|
||||
detected: Iterable<TuiAgent>
|
||||
): TuiAgent | null {
|
||||
if (preferred === 'blank') {
|
||||
return null
|
||||
}
|
||||
const detectedSet = detected instanceof Set ? detected : new Set(detected)
|
||||
if (preferred && detectedSet.has(preferred)) {
|
||||
return preferred
|
||||
}
|
||||
for (const agent of TUI_AGENT_AUTO_PICK_ORDER) {
|
||||
if (detectedSet.has(agent)) {
|
||||
return agent
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from './tui-agent-startup'
|
||||
|
||||
describe('tui agent startup plans', () => {
|
||||
it('uses POSIX quoting when the target shell is Linux', () => {
|
||||
const plan = buildAgentStartupPlan({
|
||||
agent: 'claude',
|
||||
prompt: "fix Bob's branch",
|
||||
cmdOverrides: {},
|
||||
platform: 'linux'
|
||||
})
|
||||
|
||||
expect(plan?.launchCommand).toBe("claude 'fix Bob'\\''s branch'")
|
||||
})
|
||||
|
||||
it('uses PowerShell quoting by default when the target shell is Windows', () => {
|
||||
const plan = buildAgentStartupPlan({
|
||||
agent: 'claude',
|
||||
prompt: 'fix Bob\'s "quoted" branch',
|
||||
cmdOverrides: {},
|
||||
platform: 'win32'
|
||||
})
|
||||
|
||||
expect(plan?.launchCommand).toBe("claude 'fix Bob''s \"quoted\" branch'")
|
||||
})
|
||||
|
||||
it('uses cmd escaping when requested explicitly', () => {
|
||||
const plan = buildAgentStartupPlan({
|
||||
agent: 'claude',
|
||||
prompt: 'fix "quoted" & %PATH%',
|
||||
cmdOverrides: {},
|
||||
platform: 'win32',
|
||||
shell: 'cmd'
|
||||
})
|
||||
|
||||
expect(plan?.launchCommand).toBe('claude "fix ^"quoted^" ^& ^%PATH^%"')
|
||||
})
|
||||
|
||||
it('clears draft environment variables with the target shell syntax', () => {
|
||||
expect(
|
||||
buildAgentDraftLaunchPlan({
|
||||
agent: 'pi',
|
||||
draft: 'https://github.com/acme/repo/issues/42',
|
||||
cmdOverrides: {},
|
||||
platform: 'win32'
|
||||
})?.launchCommand
|
||||
).toBe('pi; Remove-Item Env:ORCA_PI_PREFILL -ErrorAction SilentlyContinue')
|
||||
|
||||
expect(
|
||||
buildAgentDraftLaunchPlan({
|
||||
agent: 'pi',
|
||||
draft: 'https://github.com/acme/repo/issues/42',
|
||||
cmdOverrides: {},
|
||||
platform: 'win32',
|
||||
shell: 'cmd'
|
||||
})?.launchCommand
|
||||
).toBe('pi & set "ORCA_PI_PREFILL="')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
import { isShellProcess } from './agent-detection'
|
||||
import { TUI_AGENT_CONFIG } from './tui-agent-config'
|
||||
import type { TuiAgent } from './types'
|
||||
|
||||
export type AgentStartupPlan = {
|
||||
agent: TuiAgent
|
||||
launchCommand: string
|
||||
expectedProcess: string
|
||||
followupPrompt: string | null
|
||||
draftPrompt?: string | null
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
export type AgentStartupShell = 'posix' | 'powershell' | 'cmd'
|
||||
|
||||
function resolveStartupShell(
|
||||
platform: NodeJS.Platform,
|
||||
shell?: AgentStartupShell
|
||||
): AgentStartupShell {
|
||||
return shell ?? (platform === 'win32' ? 'powershell' : 'posix')
|
||||
}
|
||||
|
||||
function quoteStartupArg(value: string, shell: AgentStartupShell): string {
|
||||
if (shell === 'powershell') {
|
||||
return `'${value.replace(/'/g, "''")}'`
|
||||
}
|
||||
if (shell === 'cmd') {
|
||||
return `"${value.replace(/([\^&|<>()%!"])/g, '^$1')}"`
|
||||
}
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`
|
||||
}
|
||||
|
||||
function clearEnvCommand(name: string, shell: AgentStartupShell): string {
|
||||
if (shell === 'powershell') {
|
||||
return `Remove-Item Env:${name} -ErrorAction SilentlyContinue`
|
||||
}
|
||||
if (shell === 'cmd') {
|
||||
return `set "${name}="`
|
||||
}
|
||||
return `unset ${name}`
|
||||
}
|
||||
|
||||
function commandSeparator(shell: AgentStartupShell): string {
|
||||
return shell === 'cmd' ? ' & ' : '; '
|
||||
}
|
||||
|
||||
export function buildAgentStartupPlan(args: {
|
||||
agent: TuiAgent
|
||||
prompt: string
|
||||
cmdOverrides: Partial<Record<TuiAgent, string>>
|
||||
platform: NodeJS.Platform
|
||||
shell?: AgentStartupShell
|
||||
allowEmptyPromptLaunch?: boolean
|
||||
}): AgentStartupPlan | null {
|
||||
const { agent, prompt, cmdOverrides, platform, allowEmptyPromptLaunch = false } = args
|
||||
const shell = resolveStartupShell(platform, args.shell)
|
||||
const trimmedPrompt = prompt.trim()
|
||||
const config = TUI_AGENT_CONFIG[agent]
|
||||
const baseCommand = cmdOverrides[agent] ?? config.launchCmd
|
||||
|
||||
if (!trimmedPrompt) {
|
||||
if (!allowEmptyPromptLaunch) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
agent,
|
||||
launchCommand: baseCommand,
|
||||
expectedProcess: config.expectedProcess,
|
||||
followupPrompt: null
|
||||
}
|
||||
}
|
||||
|
||||
const quotedPrompt = quoteStartupArg(trimmedPrompt, shell)
|
||||
|
||||
if (config.promptInjectionMode === 'argv') {
|
||||
return {
|
||||
agent,
|
||||
launchCommand: `${baseCommand} ${quotedPrompt}`,
|
||||
expectedProcess: config.expectedProcess,
|
||||
followupPrompt: null
|
||||
}
|
||||
}
|
||||
|
||||
if (config.promptInjectionMode === 'flag-prompt') {
|
||||
return {
|
||||
agent,
|
||||
launchCommand: `${baseCommand} --prompt ${quotedPrompt}`,
|
||||
expectedProcess: config.expectedProcess,
|
||||
followupPrompt: null
|
||||
}
|
||||
}
|
||||
|
||||
if (config.promptInjectionMode === 'flag-prompt-interactive') {
|
||||
return {
|
||||
agent,
|
||||
launchCommand: `${baseCommand} --prompt-interactive ${quotedPrompt}`,
|
||||
expectedProcess: config.expectedProcess,
|
||||
followupPrompt: null
|
||||
}
|
||||
}
|
||||
|
||||
if (config.promptInjectionMode === 'flag-interactive') {
|
||||
return {
|
||||
agent,
|
||||
launchCommand: `${baseCommand} -i ${quotedPrompt}`,
|
||||
expectedProcess: config.expectedProcess,
|
||||
followupPrompt: null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agent,
|
||||
launchCommand: baseCommand,
|
||||
expectedProcess: config.expectedProcess,
|
||||
followupPrompt: trimmedPrompt
|
||||
}
|
||||
}
|
||||
|
||||
export type AgentDraftLaunchPlan = {
|
||||
agent: TuiAgent
|
||||
launchCommand: string
|
||||
expectedProcess: string
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
export function buildAgentDraftLaunchPlan(args: {
|
||||
agent: TuiAgent
|
||||
draft: string
|
||||
cmdOverrides: Partial<Record<TuiAgent, string>>
|
||||
platform: NodeJS.Platform
|
||||
shell?: AgentStartupShell
|
||||
}): AgentDraftLaunchPlan | null {
|
||||
const { agent, draft, cmdOverrides, platform } = args
|
||||
const shell = resolveStartupShell(platform, args.shell)
|
||||
const config = TUI_AGENT_CONFIG[agent]
|
||||
const trimmed = draft.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
const baseCommand = cmdOverrides[agent] ?? config.launchCmd
|
||||
if (config.draftPromptFlag) {
|
||||
const quoted = quoteStartupArg(trimmed, shell)
|
||||
return {
|
||||
agent,
|
||||
launchCommand: `${baseCommand} ${config.draftPromptFlag} ${quoted}`,
|
||||
expectedProcess: config.expectedProcess
|
||||
}
|
||||
}
|
||||
if (config.draftPromptEnvVar) {
|
||||
const clearVar = clearEnvCommand(config.draftPromptEnvVar, shell)
|
||||
return {
|
||||
agent,
|
||||
launchCommand: `${baseCommand}${commandSeparator(shell)}${clearVar}`,
|
||||
expectedProcess: config.expectedProcess,
|
||||
env: { [config.draftPromptEnvVar]: trimmed }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export { isShellProcess }
|
||||
+4
-3
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable max-lines */
|
||||
import type { SshRemotePtyLease, SshTarget } from './ssh-types'
|
||||
import type { Automation, AutomationRun } from './automations-types'
|
||||
import type { WorkspaceSource } from './telemetry-events'
|
||||
import type { WorkspaceSource } from './workspace-source'
|
||||
import type { GitHubProjectSettings } from './github-project-types'
|
||||
import type {
|
||||
AgentStatusState,
|
||||
@@ -18,7 +18,7 @@ import type { KeybindingOverrides } from './keybindings'
|
||||
|
||||
// Re-exported for backward compat with renderer call sites that import
|
||||
// `WorkspaceCreateTelemetrySource` from '../../../shared/types'.
|
||||
export type { WorkspaceSource as WorkspaceCreateTelemetrySource } from './telemetry-events'
|
||||
export type { WorkspaceSource as WorkspaceCreateTelemetrySource } from './workspace-source'
|
||||
export type { TaskProvider } from './task-providers'
|
||||
export type {
|
||||
GitBranchChangeStatus,
|
||||
@@ -1255,8 +1255,8 @@ export type CreateWorktreeArgs = {
|
||||
linkedIssue?: number
|
||||
linkedPR?: number
|
||||
linkedLinearIssue?: string
|
||||
linkedGitLabMR?: number
|
||||
linkedGitLabIssue?: number
|
||||
linkedGitLabMR?: number
|
||||
pushTarget?: GitPushTarget
|
||||
workspaceStatus?: WorkspaceStatus
|
||||
manualOrder?: number
|
||||
@@ -1997,6 +1997,7 @@ export type TaskResumeState = {
|
||||
githubMode?: 'items' | 'project'
|
||||
githubItemsPreset?: TaskViewPresetId | null
|
||||
githubItemsQuery?: string
|
||||
githubProjectHiddenFieldIdsByView?: Record<string, string[]>
|
||||
linearPreset?: 'assigned' | 'created' | 'all' | 'completed'
|
||||
linearQuery?: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getLinkedWorkItemSuggestedName,
|
||||
resolveWorkspaceCreateName,
|
||||
slugifyForWorkspaceName
|
||||
} from './workspace-name'
|
||||
|
||||
describe('slugifyForWorkspaceName', () => {
|
||||
it('keeps workspace seed slugs short, ascii-safe, and git-ref-safe', () => {
|
||||
expect(slugifyForWorkspaceName('../../Fix mobile Tasks 🚀')).toBe('fix-mobile-tasks')
|
||||
expect(slugifyForWorkspaceName('feature/add issue drawer')).toBe('feature-add-issue-drawer')
|
||||
expect(slugifyForWorkspaceName('a'.repeat(80))).toBe('a'.repeat(48))
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLinkedWorkItemSuggestedName', () => {
|
||||
it('removes duplicated issue and PR numbers from linked titles', () => {
|
||||
expect(getLinkedWorkItemSuggestedName({ title: 'Issue #123: Fix mobile Tasks' })).toBe(
|
||||
'fix-mobile-tasks'
|
||||
)
|
||||
expect(getLinkedWorkItemSuggestedName({ title: 'Add mobile drawer (#812)' })).toBe(
|
||||
'add-mobile-drawer'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveWorkspaceCreateName', () => {
|
||||
it('preserves explicit user-entered names for the host worktree sanitizer', () => {
|
||||
expect(
|
||||
resolveWorkspaceCreateName({
|
||||
draft: 'feature/something',
|
||||
fallback: 'issue-123'
|
||||
})
|
||||
).toBe('feature/something')
|
||||
expect(
|
||||
resolveWorkspaceCreateName({
|
||||
draft: '日本語 テスト',
|
||||
fallback: 'issue-123'
|
||||
})
|
||||
).toBe('日本語 テスト')
|
||||
})
|
||||
|
||||
it('uses the stable fallback when the draft is blank', () => {
|
||||
expect(resolveWorkspaceCreateName({ draft: ' ', fallback: 'pr-9' })).toBe('pr-9')
|
||||
expect(resolveWorkspaceCreateName({ draft: undefined, fallback: 'issue-4' })).toBe('issue-4')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
export function slugifyForWorkspaceName(input: string): string {
|
||||
return (
|
||||
input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\\/]+/g, '-')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
// Why: git check-ref-format rejects any ref containing `..`, so previews
|
||||
// must match the main-process sanitizer before workspace creation.
|
||||
.replace(/\.{2,}/g, '.')
|
||||
.replace(/^[.-]+|[.-]+$/g, '')
|
||||
.slice(0, 48)
|
||||
.replace(/[-._]+$/g, '')
|
||||
)
|
||||
}
|
||||
|
||||
export function getLinkedWorkItemSuggestedName(item: { title: string }): string {
|
||||
const withoutLeadingNumber = item.title
|
||||
.trim()
|
||||
.replace(/^(?:issue|pr|pull request)\s*#?\d+\s*[:-]\s*/i, '')
|
||||
.replace(/^#\d+\s*[:-]\s*/, '')
|
||||
.replace(/\(#\d+\)/gi, '')
|
||||
.replace(/\b#\d+\b/g, '')
|
||||
.trim()
|
||||
const seed = withoutLeadingNumber || item.title.trim()
|
||||
return slugifyForWorkspaceName(seed)
|
||||
}
|
||||
|
||||
export function resolveWorkspaceCreateName(args: {
|
||||
draft: string | undefined
|
||||
fallback: string
|
||||
}): string {
|
||||
return args.draft?.trim() || args.fallback
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export const WORKSPACE_SOURCE_VALUES = [
|
||||
'command_palette',
|
||||
'sidebar',
|
||||
'shortcut',
|
||||
'drag_drop',
|
||||
'onboarding',
|
||||
'unknown'
|
||||
] as const
|
||||
|
||||
export type WorkspaceSource = (typeof WORKSPACE_SOURCE_VALUES)[number]
|
||||
Reference in New Issue
Block a user