Add mobile Tasks parity (#2452)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-05-21 20:26:07 -07:00
committed by GitHub
co-authored by Orca
parent 3efc669bd4
commit b1973657ea
123 changed files with 26492 additions and 1461 deletions
+5
View File
@@ -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(),
+68 -8
View File
@@ -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()
}
}
+1 -1
View File
@@ -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 }
}
+83 -71
View File
@@ -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'
],
{}
)
})
})
})
+52 -29
View File
@@ -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
View File
@@ -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 {
+54 -3
View File
@@ -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
View File
@@ -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` ────────────────────
+92 -1
View File
@@ -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
View File
@@ -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[] = []
+11 -4
View File
@@ -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 } : {})
}
}
+49 -82
View File
@@ -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
View File
@@ -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.
+17 -29
View File
@@ -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
View File
@@ -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> {
+49 -7
View File
@@ -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)
+129 -1
View File
@@ -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
View File
@@ -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
})
}
)
+48
View File
@@ -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
})
})
})
+14 -2
View File
@@ -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;
+6 -2
View File
@@ -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
})
})
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+10 -1
View File
@@ -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
File diff suppressed because it is too large Load Diff
+85 -2
View File
@@ -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': {
+50
View File
@@ -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',
+20
View File
@@ -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,
+158
View File
@@ -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
)
})
})
+151
View File
@@ -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)
})
]
+4
View File
@@ -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
+102
View File
@@ -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' } }
})
})
})
+25 -1
View File
@@ -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,
+65
View File
@@ -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 } })
})
})
+20
View File
@@ -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) })
})
]
+4 -2
View File
@@ -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',
+23 -157
View File
@@ -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,
+723 -1
View File
@@ -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()
})
+80
View File
@@ -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({
+140 -32
View File
@@ -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()
})
+7 -3
View File
@@ -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
}
+1 -1
View File
@@ -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>>
+1 -1
View File
@@ -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> =>
+4
View File
@@ -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)
}
+27
View File
@@ -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 ? (
+5 -5
View File
@@ -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'
+8 -3
View File
@@ -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',
+9 -3
View File
@@ -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
+2 -37
View File
@@ -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
+6 -173
View File
@@ -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'
+14 -4
View File
@@ -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
}
}
+37
View File
@@ -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
}
}
@@ -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
+5 -3
View File
@@ -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[]
+2 -1
View File
@@ -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 & {})
+3 -9
View File
@@ -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',
+17
View File
@@ -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()
})
})
+52
View File
@@ -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
}
+59
View File
@@ -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="')
})
})
+161
View File
@@ -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
View File
@@ -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
}
+47
View File
@@ -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')
})
})
+36
View File
@@ -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
}
+10
View File
@@ -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]