Files
orca/src/shared/linear-links.ts
T
Jinjing 999e3a3a6d feat(sidebar): link Linear issues from Edit Worktree Details (#12380)
* feat(sidebar): link Linear issues from Edit Worktree Details

The Issue field only accepted GitHub numbers, so a workspace tracking a
Linear issue had no way to say so from the dialog — the link could only be
set at creation time or through `orca worktree set --linear-issue`.

Replaces the field with one provider-aware row: a chip suffix inside the
input selects GitHub or Linear, and pasting a URL flips the chip to match.
A bare key never steers the provider — Linear and Jira issue keys are
byte-identical in shape, so shape alone cannot decide one.

One issue per workspace. A changed field displaces the other provider's
slot and the row names what Save is about to unlink. GitLab and Jira links
are left alone: the row cannot display them, and nothing else in the UI
could restore one it dropped.

- Folder workspaces read-only (their link is creation-time only)
- Remote runtimes assert the capability before writing or clearing, since
  `worktree.set` parses in strip mode and would silently drop the keys
- `updateWorktreeMeta` now reports failure so the dialog can stay open
  instead of closing over a save that refetch reverted
- Parses are length-bounded — `matchGitHubItemPath` strips trailing
  slashes with an unanchored regex that is quadratic on a large paste

* fix(sidebar): respect one-issue-per-workspace rule conditionally

Only clear displaced issue links when they actually existed, preventing
unnecessary Linear keys in GitHub-only workspaces. Skip comment updates
when unchanged to avoid workspace reordering. Add accessibility to
displacement messages and improve folder workspace error handling.

* fix(sidebar): resolve workspace ambiguity and improve Linear issue linki

The same workspace ID can exist under multiple hosts — the owner index reports
this as ambiguous rather than guessing. Dialog callers now pass their repoId so
lookups are unambiguous. Linear identifiers without an org key are resolved
across all workspaces (not just the active organization). Added race-condition
protection for async issue lookups and better change detection to avoid clearing
work-item titles when re-saving an identifier in different spelling.
2026-08-04 13:42:25 -07:00

135 lines
4.3 KiB
TypeScript

export function buildLinearTeamUrl(args: {
organizationUrlKey?: string | null
teamKey?: string | null
}): string | null {
const organizationUrlKey = args.organizationUrlKey?.trim()
const teamKey = args.teamKey?.trim()
if (!organizationUrlKey || !teamKey) {
return null
}
return `https://linear.app/${encodeURIComponent(organizationUrlKey)}/team/${encodeURIComponent(teamKey)}/all`
}
export function buildLinearPersonalApiKeySettingsUrl(organizationUrlKey?: string | null): string {
const trimmed = organizationUrlKey?.trim()
return trimmed
? `https://linear.app/${encodeURIComponent(trimmed)}/settings/account/security`
: 'https://linear.app/settings/account/security'
}
export function buildLinearWorkspaceApiSettingsUrl(organizationUrlKey?: string | null): string {
const trimmed = organizationUrlKey?.trim()
return trimmed
? `https://linear.app/${encodeURIComponent(trimmed)}/settings/api`
: 'https://linear.app/settings/api'
}
export function buildLinearIssueUrl(args: {
identifier?: string | null
organizationUrlKey?: string | null
}): string | null {
const identifier = args.identifier?.trim()
const organizationUrlKey = args.organizationUrlKey?.trim()
if (!identifier || !organizationUrlKey) {
return null
}
return `https://linear.app/${encodeURIComponent(organizationUrlKey)}/issue/${encodeURIComponent(identifier)}`
}
export function getLinearOrganizationUrlKeyFromIssueUrl(issueUrl?: string | null): string | null {
if (!issueUrl) {
return null
}
try {
const parsed = new URL(issueUrl)
if (parsed.hostname !== 'linear.app') {
return null
}
return parsed.pathname.split('/').find(Boolean) ?? null
} catch {
return null
}
}
export type ParsedLinearIssueInput = {
identifier: string
organizationUrlKey?: string
}
export type LinearIssueLinkUpdates = {
linkedLinearIssue: string | null
linkedLinearIssueWorkspaceId: string | null
linkedLinearIssueOrganizationUrlKey: string | null
}
const LINEAR_IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9_]*-\d+$/
export function parseLinearIssueInput(input: string): ParsedLinearIssueInput | null {
const trimmed = input.trim()
if (!trimmed) {
return null
}
if (LINEAR_IDENTIFIER_PATTERN.test(trimmed)) {
return { identifier: trimmed.toUpperCase() }
}
try {
const parsed = new URL(trimmed)
// Why: hostname alone accepts file://linear.app/..., which then passes
// validation and persists as a link. The GitHub parser already gates on
// protocol; this keeps the two in step.
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
return null
}
if (parsed.hostname !== 'linear.app') {
return null
}
const parts = parsed.pathname.split('/').filter(Boolean)
const issueIndex = parts.indexOf('issue')
const organizationUrlKey = parts[0]
const rawIdentifier = issueIndex >= 0 ? parts[issueIndex + 1] : undefined
if (!organizationUrlKey || !rawIdentifier) {
return null
}
const identifier = decodeURIComponent(rawIdentifier).split(/[/?#]/)[0]
if (!LINEAR_IDENTIFIER_PATTERN.test(identifier)) {
return null
}
return {
identifier: identifier.toUpperCase(),
organizationUrlKey: decodeURIComponent(organizationUrlKey)
}
} catch {
return null
}
}
export const LINEAR_ISSUE_LINK_CLEARED: LinearIssueLinkUpdates = {
linkedLinearIssue: null,
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: null
}
/** Shared by the CLI and the meta dialog so both persist identical state for
* identical input. Returns null when the input is neither empty nor parseable. */
export function buildLinearIssueLinkUpdates(input: string): LinearIssueLinkUpdates | null {
if (input.trim() === '') {
return { ...LINEAR_ISSUE_LINK_CLEARED }
}
const parsed = parseLinearIssueInput(input)
if (!parsed) {
return null
}
return {
linkedLinearIssue: parsed.identifier,
// Both scoping fields describe the issue being REPLACED. A stale org key
// pins resolveLegacyLinearLinkWorkspace to the wrong org — not-found, or a
// silently wrong issue on a prefix collision. Nulling only costs a re-read.
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: parsed.organizationUrlKey ?? null
}
}