mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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,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'
|
||||
|
||||
@@ -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)))]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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'
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user