feat(cli): resolve Jira keys in workspace references (#28619)

Recognize Jira issue references in workspace creation while retaining site-qualified identity and existing provider parsing.

Co-authored-by: Eugenio Valeiras <eugenio.valeiras@gmail.com>
This commit is contained in:
Neil
2026-10-14 03:21:31 -07:00
committed by GitHub
co-authored by Eugenio Valeiras
parent ba81b43c26
commit 6d04aea3f1
11 changed files with 516 additions and 64 deletions
+2 -1
View File
@@ -141,13 +141,14 @@ ORCA worktree create --name task --run-hooks --json
```text
ORCA reference list --worktree name:api --json
ORCA reference add https://github.com/acme/api/pull/5123 https://linear.app/acme/issue/STA-1234 --worktree name:api --json
ORCA reference add ENG-123 --reference-site <jira-site-id> --worktree name:api --json
ORCA reference remove https://github.com/acme/api/pull/5123 --worktree name:api --json
ORCA reference remove --key <key-from-list> --worktree name:api --json
ORCA worktree create --name fix --reference https://github.com/acme/api/pull/5123 --reference https://linear.app/acme/issue/STA-1234 --json
ORCA reference find STA-1234 --json
```
- Add/remove require explicit `--worktree`, including `current` or a folder selector. Writes accept full URLs; `remove --key` also accepts opaque keys from list for URL-less entries. Source-unknown legacy links cannot match exact URLs; stored native issue keys remain searchable. Duplicate adds and absent removals do nothing; unrelated links and selection survive concurrent edits. Unsupported older hosts refuse writes.
- Add/remove require explicit `--worktree`, including `current` or a folder selector. Add and create accept full URLs or bare Jira keys. Keys resolve on the selected execution-host Jira site; use `--reference-site <id>` from `ORCA jira connection` to choose one. Other providers require full URLs, which need no Jira authentication. Every input must validate and each Jira key must resolve before writing. `remove --key` also accepts opaque keys from list for URL-less entries. Source-unknown legacy links cannot match exact URLs; stored native issue keys remain searchable. Duplicate adds and absent removals do nothing; unrelated links and selection survive concurrent edits. Unsupported older hosts refuse writes.
- Repeat create `--reference` to seed links before setup/agent startup. Do not combine it with legacy `--pr`, `--issue`, `--linear-issue`, `--gitlab-issue`, or `--gitlab-mr` flags. Legacy `worktree set --pr` still appends/selects; `linkedItems` remains the worktree output field.
- Find accepts a full URL or native issue key such as `STA-1234`; no bare numbers or custom shorthand. It searches stored links across sources, not conversation text (`ORCA search`). Narrow with `--worktree` or `--repo`; optionally `--include-archived`, `--limit` (default 50 workspaces). Check `truncated` before assuming complete coverage. Remote selectors refer to the execution host; never use local `current` for a paired host.
- Find returns all known agents in each matching workspace. `linked: true` requires recorded reference/session evidence; `linked: false` means ownership is unknown. Check `liveness` (`live`, `unverifiable`, `exited`) and choose the intended agent; never infer ownership merely from workspace membership.
File diff suppressed because one or more lines are too long
+5 -2
View File
@@ -5,7 +5,8 @@ const FILE_OPEN_FOCUS_HELP =
/** Per-command flag help, kept out of the shared help chain it would crowd. */
const COMMAND_SCOPED_FLAG_HELP: Record<string, Record<string, string>> = {
'reference add': {
url: '--url <url> Full review or issue URL; pass all URLs positionally or all as repeated --url'
url: '--url <url-or-jira-key> Full review/issue URL or Jira key; use positionals or repeated --url',
'reference-site': '--reference-site <id> Jira site for bare keys; see orca jira connection'
},
'reference remove': {
url: '--url <url> Full review or issue URL; pass all URLs positionally or all as repeated --url',
@@ -18,7 +19,9 @@ const COMMAND_SCOPED_FLAG_HELP: Record<string, Record<string, string>> = {
limit: '--limit <n> Maximum matching workspaces (default 50)'
},
'worktree create': {
reference: '--reference <url> Attach an issue or review URL; repeat for several',
reference:
'--reference <url-or-jira-key> Attach an issue/review URL or Jira key; repeat for several',
'reference-site': '--reference-site <id> Jira site for bare keys; see orca jira connection',
pr: '--pr <number> Linked GitHub pull request number',
'gitlab-issue': '--gitlab-issue <number|url> Linked GitLab issue in the source project',
'gitlab-mr': '--gitlab-mr <number|url> Linked GitLab merge request in the source project'
+15 -4
View File
@@ -16,7 +16,12 @@ import {
getRequiredStringFlag
} from '../flags'
import { printResult } from '../format'
import { assertReferenceWritesSupported, parseReferenceUrls } from '../reference-input'
import {
assertReferenceWritesSupported,
parseReferenceInputs,
parseReferenceUrls,
resolveReferenceInputs
} from '../reference-input'
import { RuntimeClientError, type RuntimeRpcSuccess } from '../runtime-client'
// Why: hosts from before reference.* still advertise the linked-items delta capability, so the
@@ -89,12 +94,18 @@ function formatFind(result: RuntimeReferenceFindResult): string {
async function mutateReferences(context: HandlerContext, operation: 'add' | 'remove') {
const { flags, client, json } = context
const target = workspaceTarget(context)
const input = parseReferenceUrls(getRepeatedStringFlag(flags, 'url'))
const values = getRepeatedStringFlag(flags, 'url')
const input =
operation === 'add'
? await resolveReferenceInputs(context, parseReferenceInputs(values))
: parseReferenceUrls(values)
const keys = operation === 'remove' ? getRepeatedStringFlag(flags, 'key') : []
if (input.length === 0 && keys.length === 0) {
throw new RuntimeClientError(
'invalid_argument',
operation === 'add' ? 'Pass at least one full URL.' : 'Pass at least one full URL or --key.'
operation === 'add'
? 'Pass at least one full URL or Jira issue key.'
: 'Pass at least one full URL or --key.'
)
}
await assertReferenceWritesSupported(client)
@@ -123,7 +134,7 @@ async function mutateReferences(context: HandlerContext, operation: 'add' | 'rem
if (changes.some((change) => change.changed)) {
// Why: storage may hold same-URL twins with different source contexts; derive from `base`
// so the host's delta merge never sees an unrequested twin as removed.
// parseReferenceUrls already dedupes input by identity.
// Input is already deduped by identity.
const linkedItems =
operation === 'add'
? [...base, ...input.filter((item) => !isLinked(getWorkspaceReferenceIdentity(item)))]
+12 -39
View File
@@ -32,7 +32,7 @@ import { projectWorktreePsTerminalVerdict } from '../worktree-ps-terminal-verdic
import {
assertWorkspaceTargetFlagsCompatible,
hasWorkspaceProjectTarget,
resolveProjectCreateRepoSelector
getCreateRepoSelector
} from '../worktree-project-target'
import {
assertWorktreeParentFlagsCompatible,
@@ -44,7 +44,11 @@ import { getOptionalWorktreeUnreadFlag } from './worktree-unread-flag'
import { getReviewTargetLinkFlags } from './worktree-review-link-flags'
import { withSetupDecisionRecovery } from './worktree-setup-decision-recovery'
import { assertGitLabLinkFlagProjectsMatch } from './worktree-gitlab-link-context'
import { assertReferenceWritesSupported, getCreateReferences } from '../reference-input'
import {
assertReferenceWritesSupported,
getCreateReferenceInputs,
resolveReferenceInputs
} from '../reference-input'
function getPresentStringFlag(
flags: Map<string, string | boolean>,
@@ -92,41 +96,6 @@ function getOptionalSetupDecision(
return setup
}
function getRepoSelectorFromWorktreeSelector(selector: string | undefined): string | undefined {
if (!selector?.startsWith('id:')) {
return undefined
}
const worktreeId = selector.slice('id:'.length)
const separatorIndex = worktreeId.indexOf('::')
if (separatorIndex <= 0) {
return undefined
}
return `id:${worktreeId.slice(0, separatorIndex)}`
}
async function getCreateRepoSelector(
flags: Map<string, string | boolean>,
cwdParentWorktree: string | undefined,
client: Parameters<CommandHandler>[0]['client']
): Promise<string> {
const projectRepoSelector = await resolveProjectCreateRepoSelector(flags, client)
if (projectRepoSelector) {
return projectRepoSelector
}
const explicitRepo = getPresentStringFlag(flags, 'repo')
if (explicitRepo) {
return explicitRepo
}
const inferredRepo = getRepoSelectorFromWorktreeSelector(cwdParentWorktree)
if (inferredRepo) {
return inferredRepo
}
throw new RuntimeClientError(
'invalid_argument',
'Missing repo selector. Pass --repo or run from inside an Orca-managed worktree.'
)
}
export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
'worktree ps': async ({ flags, client, json }) => {
const result = await client.call<WithAnnotatedHostScope<RuntimeWorktreePsResult>>(
@@ -162,8 +131,9 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
})
printResult(result, json, formatWorktreeShow)
},
'worktree create': async ({ flags, client, cwd, json, rawArgs }) => {
const linkedItems = getCreateReferences(flags)
'worktree create': async (context) => {
const { flags, client, cwd, json, rawArgs } = context
const referenceInputs = getCreateReferenceInputs(flags)
assertWorktreeParentFlagsCompatible(flags)
assertWorkspaceTargetFlagsCompatible(flags)
const reviewLinks = getReviewTargetLinkFlags(flags)
@@ -206,6 +176,9 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
const name = getRequiredStringFlag(flags, 'name')
const repo = await getCreateRepoSelector(flags, cwdParentWorktree, client)
await assertGitLabLinkFlagProjectsMatch(flags, client, { repo })
const linkedItems = referenceInputs
? await resolveReferenceInputs(context, referenceInputs)
: undefined
if (linkedItems) {
await assertReferenceWritesSupported(client)
}
+347
View File
@@ -0,0 +1,347 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
return createRuntimeClientModuleMock(mocks)
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: mocks.addEnvironmentFromPairingCodeMock,
listEnvironments: mocks.listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('child_process', async () => {
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
return createChildProcessModuleMock(mocks.spawnMock)
})
import { main } from './index'
import { pairRuntimeEnvironment, useWorktreeAwarenessEnvironment } from './index-test-harness'
import { buildWorktree, okFixture } from './test-fixtures'
import { RuntimeClientError } from './runtime/types'
import { JIRA_AGENT_ACCESS_RUNTIME_CAPABILITY } from '../shared/jira-agent-capability'
import { WORKTREE_LINKED_ITEMS_DELTA_RUNTIME_CAPABILITY } from '../shared/workspace-attachment-capabilities'
import {
getWorkspaceReferenceIdentity,
parseWorkspaceReferenceUrl
} from '../shared/workspace-reference-identity'
const site = {
id: 'site-1',
siteUrl: 'https://example.atlassian.net',
email: 'test@example.com',
displayName: 'Jira',
accountId: 'user-1'
}
const issue = {
id: '1001',
key: 'ENG-123',
siteId: site.id,
title: 'Fix login',
url: `${site.siteUrl}/browse/ENG-123`,
project: { id: '100', key: 'ENG', name: 'Engineering' },
issueType: { id: '10001', name: 'Bug' },
status: { id: '1', name: 'Open', categoryName: 'To Do', categoryKey: 'new' },
labels: [],
updatedAt: '2026-10-13',
createdAt: '2026-10-12'
}
const attachment = { ...parseWorkspaceReferenceUrl(issue.url), title: issue.title }
const reviewUrl = 'https://gitlab.com/acme/api/-/merge_requests/12'
const review = parseWorkspaceReferenceUrl(reviewUrl)
const workspace = { ...buildWorktree('/tmp/api', 'api'), kind: 'worktree', name: 'api' }
const addArgs = ['reference', 'add', 'eng-123', '--worktree', 'name:api']
const createArgs = [
'worktree',
'create',
'--name',
'fix',
'--repo',
'id:repo',
'--no-parent',
'--reference',
'eng-123',
'--json'
]
function responses(overrides: Record<string, unknown> = {}): void {
const values: Record<string, unknown> = {
'status.get': {
capabilities: [
JIRA_AGENT_ACCESS_RUNTIME_CAPABILITY,
WORKTREE_LINKED_ITEMS_DELTA_RUNTIME_CAPABILITY
]
},
'jira.status': { sites: [site], selectedSiteId: site.id },
'jira.lookupIssueSummary': issue,
'reference.list': { worktree: workspace, references: [] },
'worktree.create': { worktree: workspace },
...overrides
}
mocks.callMock.mockImplementation(async (method: string) => {
const result = values[method]
if (result instanceof Error) {
throw result
}
return okFixture(method, result)
})
}
function expectNoWrite(): void {
expect(mocks.callMock.mock.calls.map(([method]) => method)).not.toContain('worktree.set')
expect(mocks.callMock.mock.calls.map(([method]) => method)).not.toContain('worktree.create')
expect(mocks.callMock.mock.calls.map(([method]) => method)).not.toContain(
'folderWorkspace.update'
)
}
describe('Jira reference keys', () => {
useWorktreeAwarenessEnvironment(mocks)
beforeEach(() => {
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
pairRuntimeEnvironment(mocks.listEnvironmentsMock, 'remote')
responses()
})
it('adds a lowercase Jira key with its title using existing delta storage', async () => {
await main(addArgs, '/tmp/api')
expect(mocks.callMock).toHaveBeenCalledWith('jira.lookupIssueSummary', {
key: 'ENG-123',
siteId: site.id
})
expect(mocks.callMock).toHaveBeenCalledWith(
'worktree.set',
expect.objectContaining({
linkedItemsBase: [],
linkedItems: [attachment],
linkedItemsSelectionChanged: false
})
)
})
it('uses the selected execution host for Jira lookup and workspace mutation', async () => {
await main([...addArgs, '--environment', 'remote'], '/tmp/api')
expect(mocks.runtimeClientConstructorMock).toHaveBeenCalledWith(undefined, 'remote')
expect(mocks.callMock).toHaveBeenCalledWith('jira.lookupIssueSummary', {
key: 'ENG-123',
siteId: site.id
})
expect(mocks.callMock).toHaveBeenCalledWith('reference.list', { worktree: 'name:api' })
expect(mocks.callMock).toHaveBeenCalledWith('worktree.set', expect.anything())
})
it('refuses ambiguous sites rather than using activeSiteId', async () => {
responses({
'jira.status': {
sites: [site, { ...site, id: 'other' }],
selectedSiteId: 'all',
activeSiteId: site.id
}
})
await main(addArgs, '/tmp/api')
expect(console.error).toHaveBeenCalledWith(expect.stringContaining('--reference-site'))
expectNoWrite()
expect(mocks.callMock).not.toHaveBeenCalledWith('jira.lookupIssueSummary', expect.anything())
})
it.each(['all', 'missing'])('rejects invalid explicit site %s before lookup', async (id) => {
await main([...addArgs, '--reference-site', id], '/tmp/api')
expect(console.error).toHaveBeenCalled()
expect(mocks.callMock).not.toHaveBeenCalledWith('jira.lookupIssueSummary', expect.anything())
expectNoWrite()
})
it('selects an explicit site including a Server context path', async () => {
const server = {
...site,
id: 'server',
siteUrl: 'https://jira.example/jira',
authType: 'server'
}
const serverIssue = { ...issue, siteId: server.id, url: `${server.siteUrl}/browse/ENG-123` }
responses({
'jira.status': { sites: [site, server], selectedSiteId: 'all' },
'jira.lookupIssueSummary': serverIssue
})
await main([...addArgs, '--reference-site', server.id], '/tmp/api')
expect(mocks.callMock).toHaveBeenCalledWith('jira.lookupIssueSummary', {
key: 'ENG-123',
siteId: server.id
})
expect(mocks.callMock).toHaveBeenCalledWith(
'worktree.set',
expect.objectContaining({
linkedItems: [{ ...parseWorkspaceReferenceUrl(serverIssue.url), title: issue.title }]
})
)
})
it.each([
null,
{ ...issue, key: 'ENG-124' },
{ ...issue, siteId: 'other' },
{ ...issue, url: 'https://example.atlassian.net.evil.example/browse/ENG-123' },
{ ...issue, url: `${site.siteUrl}/other/browse/ENG-123` },
{ ...issue, url: `${site.siteUrl}/browse/ENG-124` },
{ ...issue, title: '' }
])('rejects an absent or mismatched summary %#', async (summary) => {
responses({ 'jira.lookupIssueSummary': summary })
await main(addArgs, '/tmp/api')
expect(console.error).toHaveBeenCalled()
expectNoWrite()
})
it('preserves Jira lookup errors without reading or updating workspace storage', async () => {
responses({
'jira.lookupIssueSummary': new RuntimeClientError('rpc_error', 'jira_summary_lookup:auth')
})
await main(addArgs, '/tmp/api')
expect(console.error).toHaveBeenCalledWith(expect.stringContaining('jira_summary_lookup:auth'))
expect(mocks.callMock).not.toHaveBeenCalledWith('reference.list', expect.anything())
expectNoWrite()
})
it('validates mixed URLs and keys before any request', async () => {
await main(
['reference', 'add', 'ENG-123', reviewUrl, 'invalid', '--worktree', 'name:api'],
'/tmp/api'
)
expect(mocks.callMock).not.toHaveBeenCalled()
})
it('deduplicates a key and its URL without losing the resolved title', async () => {
await main(
['reference', 'add', reviewUrl, 'eng-123', issue.url, 'ENG-123', '--worktree', 'name:api'],
'/tmp/api'
)
expect(mocks.callMock).toHaveBeenCalledWith(
'worktree.set',
expect.objectContaining({
linkedItems: [review, attachment]
})
)
expect(
mocks.callMock.mock.calls.filter(([method]) => method === 'jira.lookupIssueSummary')
).toHaveLength(1)
})
it('keeps stored metadata for an already linked issue and produces no write', async () => {
const stored = { ...attachment, title: 'Existing title' }
responses({
'reference.list': {
worktree: workspace,
references: [{ ...stored, key: getWorkspaceReferenceIdentity(stored), selected: false }]
}
})
await main([...addArgs, '--json'], '/tmp/api')
expectNoWrite()
expect(JSON.parse(vi.mocked(console.log).mock.calls[0][0]).result.changes[0]).toMatchObject({
changed: false,
reason: 'already_linked'
})
})
it('adds to a folder through the existing folder update', async () => {
responses({
'reference.list': {
worktree: { ...workspace, id: 'folder:folder1', kind: 'folder' },
references: []
}
})
await main(addArgs, '/tmp/api')
expect(mocks.callMock).toHaveBeenCalledWith('folderWorkspace.update', {
folderWorkspaceId: 'folder1',
updates: {
linkedItemsBase: [],
linkedItems: [attachment],
linkedItemsSelectionChanged: false
}
})
})
it('resolves create references before workspace creation', async () => {
await main(createArgs, '/tmp/api')
const methods = mocks.callMock.mock.calls.map(([method]) => method)
expect(methods.indexOf('jira.lookupIssueSummary')).toBeLessThan(
methods.indexOf('worktree.create')
)
expect(mocks.callMock).toHaveBeenCalledWith(
'worktree.create',
expect.objectContaining({ linkedItems: [attachment] })
)
})
it('creates nothing when the Jira reference cannot resolve', async () => {
responses({ 'jira.lookupIssueSummary': null })
await main(createArgs, '/tmp/api')
expectNoWrite()
})
it('creates with an explicitly selected Jira site', async () => {
responses({
'jira.status': {
sites: [site, { ...site, id: 'other' }],
selectedSiteId: 'all'
}
})
await main([...createArgs, '--reference-site', site.id], '/tmp/api')
expect(mocks.callMock).toHaveBeenCalledWith('jira.lookupIssueSummary', {
key: 'ENG-123',
siteId: site.id
})
expect(mocks.callMock).toHaveBeenCalledWith(
'worktree.create',
expect.objectContaining({ linkedItems: [attachment] })
)
})
it('writes nothing if a later Jira key does not resolve', async () => {
await main(['reference', 'add', 'ENG-123', 'ENG-124', '--worktree', 'name:api'], '/tmp/api')
expect(mocks.callMock).toHaveBeenCalledWith('jira.lookupIssueSummary', {
key: 'ENG-124',
siteId: site.id
})
expect(console.error).toHaveBeenCalled()
expectNoWrite()
})
it('validates required create flags before Jira lookup', async () => {
await main(
['worktree', 'create', '--repo', 'id:repo', '--no-parent', '--reference', 'ENG-123'],
'/tmp/api'
)
expect(mocks.callMock).not.toHaveBeenCalled()
expect(console.error).toHaveBeenCalledWith(expect.stringContaining('--name'))
})
it('keeps full Jira URLs usable without a saved Jira connection', async () => {
responses({ 'status.get': { capabilities: [WORKTREE_LINKED_ITEMS_DELTA_RUNTIME_CAPABILITY] } })
await main(['reference', 'add', issue.url, '--worktree', 'name:api'], '/tmp/api')
expect(mocks.callMock).not.toHaveBeenCalledWith('jira.status')
expect(mocks.callMock).toHaveBeenCalledWith(
'worktree.set',
expect.objectContaining({
linkedItems: [parseWorkspaceReferenceUrl(issue.url)]
})
)
})
it('requires Jira agent capability before resolving keys', async () => {
responses({ 'status.get': { capabilities: [WORKTREE_LINKED_ITEMS_DELTA_RUNTIME_CAPABILITY] } })
await main(addArgs, '/tmp/api')
expect(console.error).toHaveBeenCalledWith(expect.stringContaining('Update Orca'))
expect(mocks.callMock).not.toHaveBeenCalledWith('jira.status')
expectNoWrite()
})
})
+8 -5
View File
@@ -12,24 +12,27 @@ import { RuntimeClientError } from './runtime-client'
export function resolveJiraTarget(
context: HandlerContext,
input: string
input: string,
options?: { siteFlag?: string }
): Promise<{ site: JiraSite; siteId: string; key: string }>
export function resolveJiraTarget(
context: HandlerContext
): Promise<{ site: JiraSite; siteId: string }>
export async function resolveJiraTarget(
context: HandlerContext,
input?: string
input?: string,
options: { siteFlag?: string } = {}
): Promise<{ site: JiraSite; siteId: string; key?: string }> {
const parsed = input === undefined ? null : parseJiraIssueUrl(input)
if (input !== undefined && !parsed && !JIRA_ISSUE_KEY_PATTERN.test(input.trim())) {
throw new RuntimeClientError('invalid_argument', 'Expected a Jira issue key or browse URL.')
}
const explicit = getOptionalStringFlag(context.flags, 'site')?.trim()
const siteFlag = options.siteFlag ?? 'site'
const explicit = getOptionalStringFlag(context.flags, siteFlag)?.trim()
if (explicit === 'all') {
throw new RuntimeClientError(
'invalid_argument',
'Choose one Jira site ID; --site all is unsupported.'
`Choose one Jira site ID; --${siteFlag} all is unsupported.`
)
}
const runtime = await context.client.call<RuntimeStatus>('status.get')
@@ -53,7 +56,7 @@ export async function resolveJiraTarget(
'invalid_argument',
candidates.length === 0
? 'No matching connected Jira site. Check orca jira connection and Orca Jira settings.'
: 'Multiple Jira sites match. Select one with --site using orca jira connection.'
: `Multiple Jira sites match. Select one with --${siteFlag} using orca jira connection.`
)
}
const site = candidates[0]
+78 -5
View File
@@ -1,13 +1,23 @@
import type { WorkspaceAttachment } from '../shared/worktree/types'
import type { RuntimeStatus } from '../shared/runtime-types'
import type { JiraIssue } from '../shared/jira-types'
import {
isResolvedJiraIssueMatch,
JIRA_ISSUE_KEY_PATTERN,
parseJiraIssueUrl
} from '../shared/jira-issue-url'
import { WORKTREE_LINKED_ITEMS_DELTA_RUNTIME_CAPABILITY } from '../shared/workspace-attachment-capabilities'
import {
getWorkspaceReferenceIdentity,
parseWorkspaceReferenceUrl
} from '../shared/workspace-reference-identity'
import type { RuntimeClient } from './runtime-client'
import type { HandlerContext } from './dispatch'
import { RuntimeClientError } from './runtime-client'
import { getRepeatedStringFlag } from './flags'
import { getOptionalStringFlag, getRepeatedStringFlag } from './flags'
import { resolveJiraTarget } from './jira-target'
type ReferenceInput = { key: string } | { item: WorkspaceAttachment }
export function parseReferenceUrls(urls: string[]): WorkspaceAttachment[] {
try {
@@ -23,6 +33,65 @@ export function parseReferenceUrls(urls: string[]): WorkspaceAttachment[] {
}
}
export function parseReferenceInputs(values: string[]): ReferenceInput[] {
return values.map((value) =>
JIRA_ISSUE_KEY_PATTERN.test(value.trim())
? { key: value.trim().toUpperCase() }
: { item: parseReferenceUrls([value])[0] }
)
}
export async function resolveReferenceInputs(
context: HandlerContext,
inputs: ReferenceInput[]
): Promise<WorkspaceAttachment[]> {
getOptionalStringFlag(context.flags, 'reference-site')
const firstKey = inputs.find((input) => 'key' in input)
const target =
firstKey && 'key' in firstKey
? await resolveJiraTarget(context, firstKey.key, { siteFlag: 'reference-site' })
: undefined
const resolvedKeys = new Map<string, WorkspaceAttachment>()
const references = new Map<string, WorkspaceAttachment>()
for (const input of inputs) {
let item: WorkspaceAttachment
if ('item' in input) {
item = input.item
} else {
const cached = resolvedKeys.get(input.key)
if (cached) {
item = cached
} else {
if (!target) {
throw new RuntimeClientError('invalid_argument', 'No Jira site selected for issue keys.')
}
const { result: issue } = await context.client.call<JiraIssue | null>(
'jira.lookupIssueSummary',
{ key: input.key, siteId: target.siteId }
)
const parsed = parseJiraIssueUrl(issue?.url ?? '')
if (
!issue ||
!parsed ||
!isResolvedJiraIssueMatch(parsed, target.site, issue) ||
parsed.issueKey !== input.key ||
!issue.title.trim()
) {
throw new RuntimeClientError(
'jira_issue_unavailable',
`Jira did not return issue ${input.key} with a title on the selected site.`
)
}
item = { ...parseReferenceUrls([issue.url])[0], title: issue.title }
resolvedKeys.set(input.key, item)
}
}
const identity = getWorkspaceReferenceIdentity(item)
references.set(identity, { ...references.get(identity), ...item })
}
return [...references.values()]
}
export async function assertReferenceWritesSupported(client: RuntimeClient): Promise<void> {
const status = await client.call<RuntimeStatus>('status.get')
if (!status.result.capabilities?.includes(WORKTREE_LINKED_ITEMS_DELTA_RUNTIME_CAPABILITY)) {
@@ -33,10 +102,13 @@ export async function assertReferenceWritesSupported(client: RuntimeClient): Pro
}
}
export function getCreateReferences(
export function getCreateReferenceInputs(
flags: Map<string, string | boolean>
): WorkspaceAttachment[] | undefined {
): ReferenceInput[] | undefined {
if (!flags.has('reference')) {
if (flags.has('reference-site')) {
throw new RuntimeClientError('invalid_argument', '--reference-site requires --reference.')
}
return undefined
}
const legacy = ['issue', 'pr', 'linear-issue', 'gitlab-issue', 'gitlab-mr'].find((name) =>
@@ -49,8 +121,9 @@ export function getCreateReferences(
if (urls.length === 0) {
throw new RuntimeClientError(
'invalid_argument',
'--reference requires a full issue or review URL.'
'--reference requires a full issue or review URL, or a Jira issue key.'
)
}
return parseReferenceUrls(urls)
getOptionalStringFlag(flags, 'reference-site')
return parseReferenceInputs(urls)
}
+3 -2
View File
@@ -67,7 +67,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
repeatableFlags: ['reference'],
summary: 'Create a new Orca-managed worktree',
usage:
'orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--model <id>] [--effort <level>] [--setup run|skip|inherit] [--base-branch <ref>] [--reference <url>...] [--issue <number>] [--pr <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] [--retry-launch <operationId>] [--json]',
'orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--model <id>] [--effort <level>] [--setup run|skip|inherit] [--base-branch <ref>] [--reference <url-or-jira-key>...] [--reference-site <id>] [--issue <number>] [--pr <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] [--retry-launch <operationId>] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'repo',
@@ -82,6 +82,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
'retry-launch',
'base-branch',
'reference',
'reference-site',
'issue',
'pr',
'linear-issue',
@@ -95,7 +96,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
'activate'
],
notes: [
'Repeat --reference to attach full issue or review URLs before setup or agent startup. Cannot combine with legacy link flags.',
'Repeat --reference to attach full issue or review URLs, or Jira issue keys, before setup or agent startup. Use --reference-site to choose the Jira site for keys. Other providers require full URLs. Cannot combine with legacy link flags.',
'This creates a new checkout. For a fresh agent in an existing worktree, use `orca agent launch` instead.',
'By default, Orca records the new worktree as a child of the caller context when it can infer one from the Orca terminal or current directory.',
'If --repo is omitted, Orca infers the repo from the current Orca-managed worktree.',
+9 -4
View File
@@ -11,14 +11,19 @@ export const REFERENCE_COMMAND_SPECS: CommandSpec[] = [
{
path: ['reference', 'add'],
summary: 'Attach reviews or issues to a workspace',
usage: 'orca reference add <url>... --worktree <selector> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'url'],
usage:
'orca reference add <url-or-jira-key>... --worktree <selector> [--reference-site <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'url', 'reference-site'],
positionalArgs: ['url'],
variadicPositional: true,
repeatableFlags: ['url'],
notes: ['Full URLs only. Existing links are unchanged; all URLs are validated before writing.'],
notes: [
'Bare keys resolve Jira issues on the selected execution-host Jira site; use --reference-site to choose one explicitly. Other providers require full URLs.',
'Existing links are unchanged; all inputs are validated and Jira keys resolved before writing. Full URLs do not require Jira lookup.'
],
examples: [
'orca reference add https://github.com/acme/api/pull/5123 https://linear.app/acme/issue/STA-1234 --worktree name:api'
'orca reference add https://github.com/acme/api/pull/5123 https://linear.app/acme/issue/STA-1234 --worktree name:api',
'orca reference add ENG-123 --reference-site <id> --worktree name:api'
]
},
{
+35
View File
@@ -108,3 +108,38 @@ export async function resolveProjectCreateTarget(
setup
}
}
function getRepoSelectorFromWorktreeSelector(selector: string | undefined): string | undefined {
if (!selector?.startsWith('id:')) {
return undefined
}
const worktreeId = selector.slice('id:'.length)
const separatorIndex = worktreeId.indexOf('::')
if (separatorIndex <= 0) {
return undefined
}
return `id:${worktreeId.slice(0, separatorIndex)}`
}
export async function getCreateRepoSelector(
flags: Map<string, string | boolean>,
cwdParentWorktree: string | undefined,
client: RuntimeClient
): Promise<string> {
const projectRepoSelector = await resolveProjectCreateRepoSelector(flags, client)
if (projectRepoSelector) {
return projectRepoSelector
}
const explicitRepo = getPresentStringFlag(flags, 'repo')
if (explicitRepo) {
return explicitRepo
}
const inferredRepo = getRepoSelectorFromWorktreeSelector(cwdParentWorktree)
if (inferredRepo) {
return inferredRepo
}
throw new RuntimeClientError(
'invalid_argument',
'Missing repo selector. Pass --repo or run from inside an Orca-managed worktree.'
)
}