Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/cli/flag-help-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export const FLAG_HELP_TEXT: Record<string, string> = {
interrupt: '--interrupt Send as an interrupt-style input when supported',
id: '--id <id> Identifier for a target item or permission',
issue: '--issue <number|null> Linked GitHub issue number',
'gitlab-issue':
'--gitlab-issue <number|url|null> Linked GitLab issue number or URL; null clears on set',
'gitlab-mr':
'--gitlab-mr <number|url|null> Linked GitLab merge request number or URL; null clears on set',
'linear-issue':
'--linear-issue <id|url|null> Linked Linear issue identifier or URL; null clears on set',
json: '--json Emit machine-readable JSON',
Expand Down
111 changes: 111 additions & 0 deletions src/cli/handlers/worktree-gitlab-link.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest'
import { getOptionalGitLabLinkFlag } from './worktree-gitlab-link'

function flags(entries: Record<string, string | boolean>): Map<string, string | boolean> {
return new Map(Object.entries(entries))
}

describe('getOptionalGitLabLinkFlag', () => {
it('leaves the slot alone when the flag is absent', () => {
expect(getOptionalGitLabLinkFlag(flags({}), 'issue')).toBeUndefined()
expect(getOptionalGitLabLinkFlag(flags({}), 'mr')).toBeUndefined()
})

it.each([
['42', 42],
['#42', 42],
[' 7 ', 7]
])('reads the issue reference %s', (input, expected) => {
expect(getOptionalGitLabLinkFlag(flags({ 'gitlab-issue': input }), 'issue')).toBe(expected)
})

it.each([
['77', 77],
['!77', 77]
])('reads the merge request reference %s', (input, expected) => {
expect(getOptionalGitLabLinkFlag(flags({ 'gitlab-mr': input }), 'mr')).toBe(expected)
})

it('reads a self-hosted issue URL, including a subgroup path', () => {
expect(
getOptionalGitLabLinkFlag(
flags({ 'gitlab-issue': 'https://gitlab.critel.li/group/sub/project/-/issues/923' }),
'issue'
)
).toBe(923)
})

it('reads a merge request URL with trailing segments', () => {
expect(
getOptionalGitLabLinkFlag(
flags({ 'gitlab-mr': 'https://gitlab.com/group/project/-/merge_requests/77/diffs' }),
'mr'
)
).toBe(77)
})

// Issues and merge requests are separate namespaces on GitLab, so a reference
// to one must never be taken as the other's number.
it('refuses a merge request reference in the issue flag', () => {
expect(() => getOptionalGitLabLinkFlag(flags({ 'gitlab-issue': '!42' }), 'issue')).toThrow(
/GitLab issue number/
)
expect(() =>
getOptionalGitLabLinkFlag(
flags({ 'gitlab-issue': 'https://gitlab.com/g/p/-/merge_requests/42' }),
'issue'
)
).toThrow(/GitLab issue number/)
})

it('refuses an issue reference in the merge request flag', () => {
expect(() => getOptionalGitLabLinkFlag(flags({ 'gitlab-mr': '#42' }), 'mr')).toThrow(
/merge request number/
)
expect(() =>
getOptionalGitLabLinkFlag(flags({ 'gitlab-mr': 'https://gitlab.com/g/p/-/issues/42' }), 'mr')
).toThrow(/merge request number/)
})

it('clears the slot on set, and refuses to on create', () => {
expect(
getOptionalGitLabLinkFlag(flags({ 'gitlab-issue': 'null' }), 'issue', { allowNull: true })
).toBeNull()
expect(
getOptionalGitLabLinkFlag(flags({ 'gitlab-mr': 'NULL' }), 'mr', { allowNull: true })
).toBeNull()
expect(() => getOptionalGitLabLinkFlag(flags({ 'gitlab-issue': 'null' }), 'issue')).toThrow(
/Omit --gitlab-issue on create/
)
})

it('reports a flag given without a value', () => {
expect(() => getOptionalGitLabLinkFlag(flags({ 'gitlab-issue': true }), 'issue')).toThrow(
'Missing value for --gitlab-issue'
)
})

it.each(['', ' ', '0', '-1', '4 2', '42x', 'STA-335', 'https://gitlab.com/g/p/-/issues/abc'])(
'refuses %s',
(input) => {
expect(() => getOptionalGitLabLinkFlag(flags({ 'gitlab-issue': input }), 'issue')).toThrow()
}
)

// A URL without GitLab's `/-/` separator is not a GitLab link, and a project
// path needs a group segment — neither may fall through to a number.
it.each(['https://gitlab.com/group/project/issues/42', 'https://gitlab.com/project/-/issues/42'])(
'refuses the non-GitLab URL shape %s',
(input) => {
expect(() => getOptionalGitLabLinkFlag(flags({ 'gitlab-issue': input }), 'issue')).toThrow()
}
)

// `/^\d+$/` accepts 400 digits, which parseInt turns into Infinity — persisting
// that writes `null` over the link it meant to set.
it('refuses a number too large to be an integer', () => {
expect(() =>
getOptionalGitLabLinkFlag(flags({ 'gitlab-issue': '9'.repeat(400) }), 'issue')
).toThrow()
})
})
97 changes: 97 additions & 0 deletions src/cli/handlers/worktree-gitlab-link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { parseGitLabIssueOrMRLink } from '../../shared/new-workspace/gitlab-links'
import { RuntimeClientError } from '../runtime-client'

/** Which GitLab slot a flag writes. The two are separate namespaces on GitLab,
* so a reference to one is never a valid value for the other. */
export type GitLabLinkKind = 'issue' | 'mr'

const FLAG_BY_KIND: Record<GitLabLinkKind, string> = {
issue: 'gitlab-issue',
mr: 'gitlab-mr'
}

// Why: GitLab writes `#42` for an issue and `!42` for a merge request. Accepting
// the wrong prefix would silently link the other namespace's number.
const PREFIX_BY_KIND: Record<GitLabLinkKind, string> = {
issue: '#',
mr: '!'
}

function parseNumericReference(input: string, kind: GitLabLinkKind): number | null {
const wrongPrefix = PREFIX_BY_KIND[kind === 'issue' ? 'mr' : 'issue']
if (input.startsWith(wrongPrefix)) {
return null
}
const digits = input.startsWith(PREFIX_BY_KIND[kind]) ? input.slice(1) : input
if (!/^\d+$/.test(digits)) {
return null
}
const parsed = Number.parseInt(digits, 10)
// Why: `/^\d+$/` accepts 400 digits, which parseInt turns into Infinity —
// JSON.stringify would then persist `null` over the link it meant to set.
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null
}

/**
* Resolve `--gitlab-issue` / `--gitlab-mr` into the number to persist.
* `undefined` means the flag was absent and the slot must be left alone;
* `null` means the caller asked to clear it.
*/
export function getOptionalGitLabLinkFlag(
flags: Map<string, string | boolean>,
kind: GitLabLinkKind,
options: { allowNull?: boolean } = {}
): number | null | undefined {
const name = FLAG_BY_KIND[kind]
const value = getPresentStringFlag(flags, name)
if (value === undefined) {
return undefined
}

const trimmed = value.trim()
if (trimmed.toLowerCase() === 'null') {
if (!options.allowNull) {
throw new RuntimeClientError(
'invalid_argument',
`Omit --${name} on create, or pass a GitLab ${kind === 'issue' ? 'issue' : 'merge request'} number or URL.`
)
}
return null
}

if (/^https?:\/\//i.test(trimmed)) {
const link = parseGitLabIssueOrMRLink(trimmed)
// Why: an issue URL passed to --gitlab-mr names a real GitLab item, just not
// this one. Taking its number would link a merge request that may not exist.
if (link?.type === kind && Number.isSafeInteger(link.number) && link.number > 0) {
return link.number
}
throw new RuntimeClientError('invalid_argument', badValueMessage(name, kind))
}

const number = parseNumericReference(trimmed, kind)
if (number === null) {
throw new RuntimeClientError('invalid_argument', badValueMessage(name, kind))
}
return number
}

function badValueMessage(name: string, kind: GitLabLinkKind): string {
return kind === 'issue'
? `Pass a GitLab issue number like 42 or #42, a GitLab issue URL, or null to clear --${name}.`
: `Pass a GitLab merge request number like 42 or !42, a GitLab merge request URL, or null to clear --${name}.`
}

function getPresentStringFlag(
flags: Map<string, string | boolean>,
name: string
): string | undefined {
if (!flags.has(name)) {
return undefined
}
const value = flags.get(name)
if (typeof value === 'string' && value.length > 0) {
return value
}
throw new RuntimeClientError('invalid_argument', `Missing value for --${name}`)
}
11 changes: 11 additions & 0 deletions src/cli/handlers/worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
resolveCreateParentSelector
} from './worktree-create-parent-selector'
import { getOptionalLinearIssueLinkFlag } from './worktree-linear-issue-link'
import { getOptionalGitLabLinkFlag } from './worktree-gitlab-link'

function assertParentWorktreeFlagsCompatible(flags: Map<string, string | boolean>): void {
if (flags.has('parent-worktree') && flags.get('no-parent') === true) {
Expand Down Expand Up @@ -216,6 +217,8 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
}
}
const linearIssueLink = getOptionalLinearIssueLinkFlag(flags, 'linear-issue')
const linkedGitLabIssue = getOptionalGitLabLinkFlag(flags, 'issue')
const linkedGitLabMR = getOptionalGitLabLinkFlag(flags, 'mr')
const activate = flags.get('activate') === true || flags.get('run-hooks') === true
const name = getRequiredStringFlag(flags, 'name')
const result = await client.call<RuntimeWorktreeCreateResult>('worktree.create', {
Expand All @@ -226,6 +229,8 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
baseBranch: getOptionalStringFlag(flags, 'base-branch'),
linkedIssue: getOptionalNumberFlag(flags, 'issue'),
...linearIssueLink,
...(linkedGitLabIssue === undefined ? {} : { linkedGitLabIssue }),
...(linkedGitLabMR === undefined ? {} : { linkedGitLabMR }),
comment: getOptionalStringFlag(flags, 'comment'),
runHooks: flags.get('run-hooks') === true,
activate,
Expand Down Expand Up @@ -259,11 +264,17 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
const linearIssueLink = getOptionalLinearIssueLinkFlag(flags, 'linear-issue', {
allowNull: true
})
const linkedGitLabIssue = getOptionalGitLabLinkFlag(flags, 'issue', { allowNull: true })
const linkedGitLabMR = getOptionalGitLabLinkFlag(flags, 'mr', { allowNull: true })
const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.set', {
worktree: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client),
displayName: getOptionalStringFlag(flags, 'display-name'),
linkedIssue: getOptionalNullableNumberFlag(flags, 'issue'),
...linearIssueLink,
// Why: an absent flag must not emit the key at all — the update spreads raw,
// so a present-but-undefined key would erase the stored link.
...(linkedGitLabIssue === undefined ? {} : { linkedGitLabIssue }),
...(linkedGitLabMR === undefined ? {} : { linkedGitLabMR }),
comment: getOptionalStringFlag(flags, 'comment'),
workspaceStatus: getOptionalStringFlag(flags, 'workspace-status'),
parentWorktree: await getOptionalWorktreeSelector(flags, 'parent-worktree', cwd, client),
Expand Down
82 changes: 82 additions & 0 deletions src/cli/index-worktree-set.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,4 +391,86 @@ describe('orca cli worktree awareness', () => {
noParent: false
})
})

it('passes a GitLab merge request reference through worktree.set', async () => {
queueFixtures(
callMock,
okFixture('req_set_gitlab_mr', {
worktree: { ...buildWorktree('/tmp/repo/child', 'feature/child'), linkedGitLabMR: 77 }
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})

await main(
['worktree', 'set', '--worktree', 'id:repo::/tmp/repo/child', '--gitlab-mr', '!77', '--json'],
'/tmp/repo'
)

expect(callMock).toHaveBeenCalledWith('worktree.set', {
worktree: 'id:repo::/tmp/repo/child',
displayName: undefined,
linkedIssue: undefined,
linkedGitLabMR: 77,
comment: undefined,
workspaceStatus: undefined,
parentWorktree: undefined,
noParent: false
})
})

it('clears a GitLab issue link with null', async () => {
queueFixtures(
callMock,
okFixture('req_set_gitlab_issue_clear', {
worktree: { ...buildWorktree('/tmp/repo/child', 'feature/child'), linkedGitLabIssue: null }
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})

await main(
[
'worktree',
'set',
'--worktree',
'id:repo::/tmp/repo/child',
'--gitlab-issue',
'null',
'--json'
],
'/tmp/repo'
)

expect(callMock).toHaveBeenCalledWith('worktree.set', {
worktree: 'id:repo::/tmp/repo/child',
displayName: undefined,
linkedIssue: undefined,
linkedGitLabIssue: null,
comment: undefined,
workspaceStatus: undefined,
parentWorktree: undefined,
noParent: false
})
})

// Why: the update spreads raw, so emitting the key at all on an untouched flag
// would erase the stored link.
it('omits both GitLab keys entirely when neither flag is passed', async () => {
queueFixtures(
callMock,
okFixture('req_set_no_gitlab', {
worktree: buildWorktree('/tmp/repo/child', 'feature/child')
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})

await main(
['worktree', 'set', '--worktree', 'id:repo::/tmp/repo/child', '--comment', 'note', '--json'],
'/tmp/repo'
)

const payload = callMock.mock.calls.at(-1)?.[1]
expect(payload).toBeDefined()
expect(Object.keys(payload ?? {})).not.toContain('linkedGitLabIssue')
expect(Object.keys(payload ?? {})).not.toContain('linkedGitLabMR')
})
})
6 changes: 4 additions & 2 deletions src/cli/root-help-text-secondary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,10 @@ export const ROOT_HELP_TEXT_SECONDARY = [
' orca environment show --environment <selector> [--json]',
' orca environment rm --environment <selector> [--json]',
' orca worktree list [--repo <selector>] [--limit <n>] [--json]',
' orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]',
' orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--gitlab-issue <number-or-url>] [--gitlab-mr <number-or-url>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]',
' orca worktree show --worktree <selector> [--json]',
' orca worktree current [--json]',
' orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--linear-issue <identifier-or-url|null>] [--comment <text>] [--workspace-status <id>] [--parent-worktree <selector>|--no-parent] [--json]',
' orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--linear-issue <identifier-or-url|null>] [--gitlab-issue <number-or-url|null>] [--gitlab-mr <number-or-url|null>] [--comment <text>] [--workspace-status <id>] [--parent-worktree <selector>|--no-parent] [--json]',
' orca worktree rm --worktree <selector> [--force] [--run-hooks] [--allow-failed-archive-hook] [--json]',
' orca worktree ps [--limit <n>] [--json]',
' orca file open <path> [--worktree <selector>] [--focus] [--json]',
Expand Down Expand Up @@ -164,6 +164,8 @@ export const ROOT_HELP_TEXT_SECONDARY = [
' $ orca worktree current',
' $ orca worktree set --worktree active --comment "waiting on review"',
' $ orca worktree set --worktree active --linear-issue null',
' $ orca worktree set --worktree active --gitlab-mr !77',
' $ orca worktree set --worktree active --gitlab-issue https://gitlab.example.com/group/project/-/issues/42',
' $ orca worktree ps --limit 10',
' $ orca file open-changed --mode diff',
' $ orca file open src/App.tsx',
Expand Down
Loading
Loading