Files
orca/src/shared/workspace-name.ts
T
Brennan Benson 9097ea0409 fix(naming): remove identifier-first name post-processing (#9821)
* fix(naming): remove identifier-first name post-processing

Workspace display names and tab titles were being rewritten after
generation by prompt-scanning heuristics from #8238 — a stray "#1" in
prose became a workspace named "#1 - Fix", and the rewrite ran
downstream of generation so user naming instructions couldn't override
it. Per the same principle as #9088, naming defaults stay minimal and
user overrides own the style.

- Delete work-item-reference.ts and display-name-from-work.ts (+tests).
- Auto-rename display names return to the humanized branch slug; tab
  titles return to the cleaned first prompt clause.
- Explicit create-from-work-item naming returns to action-first
  ("Review PR 1234").
- Keep #8238's URL-before-markdown strip-order bugfix in tab titles,
  with regression tests adjusted to the natural expectations.

* test(naming): pin incidental marker regression
2026-07-21 20:06:31 -07:00

262 lines
8.2 KiB
TypeScript

import {
collectCompactWorkspaceWords,
foldWorkspaceNameWhitespaceToHyphen
} from './workspace-name-text-scanner'
function normalizeApostrophes(input: string): string {
return input.replace(/[‘’]/g, "'")
}
// Why: contractions and possessives should not become stray `t` / `s` tokens
// in display names or extra hyphen segments in branch-safe workspace seeds.
function removeIntraWordApostrophes(input: string): string {
return normalizeApostrophes(input).replace(/([\p{L}\p{N}])'(?=[\p{L}\p{N}])/gu, '$1')
}
function stripDanglingDisplayApostrophes(input: string): string {
return normalizeApostrophes(input)
.replace(/(^|[^\p{L}\p{N}])'(?=[\p{L}\p{N}])/gu, '$1')
.replace(/([\p{L}\p{N}])'(?=$|[^\p{L}\p{N}])/gu, '$1')
}
export function slugifyForWorkspaceName(input: string): string {
const normalized = removeIntraWordApostrophes(input)
.trim()
.toLowerCase()
.replace(/[\\/]+/g, '-')
return (
foldWorkspaceNameWhitespaceToHyphen(normalized)
.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 seed = getLinkedWorkItemTitleSubject(item) || item.title.trim()
return slugifyForWorkspaceName(seed)
}
export type WorkspaceIntentWorkItem = {
type: 'issue' | 'pr' | 'mr'
number: number
title: string
provider?: 'github' | 'gitlab' | 'linear' | 'jira'
linearIdentifier?: string
jiraIdentifier?: string
}
export type WorkspaceIntentName = {
displayName: string
seedName: string
}
function getLinkedWorkItemTitleSubject(item: { title: string }): string {
return item.title
.trim()
.replace(/^(?:issue|pr|pull request|mr|merge request)\s*[#!]?\d+\s*[:-]\s*/i, '')
.replace(/^#\d+\s*[:-]\s*/, '')
.replace(/\([#!]?\d+\)/g, '')
.replace(/\b#\d+\b/g, '')
.trim()
}
// Why: generated workspace seeds are hyphenated; `issue-123-fix-title`
// must not be reinterpreted as the user explicitly asking to fix a new issue.
const ACTION_LABELS: [RegExp, string][] = [
[/(?:^|[^a-z0-9_-])(?:fix(?:e[sd])?|resolve|repair)(?:$|[^a-z0-9_-])/i, 'Fix'],
[/(?:^|[^a-z0-9_-])(?:debug|diagnose)(?:$|[^a-z0-9_-])/i, 'Debug'],
[/(?:^|[^a-z0-9_-])(?:review|look\s+over|inspect|check|safe|safety)(?:$|[^a-z0-9_-])/i, 'Review'],
[/(?:^|[^a-z0-9_-])(?:implement|build|ship)(?:$|[^a-z0-9_-])/i, 'Implement'],
[/(?:^|[^a-z0-9_-])(?:investigate|understand|triage)(?:$|[^a-z0-9_-])/i, 'Investigate'],
[/(?:^|[^a-z0-9_-])(?:add|create)(?:$|[^a-z0-9_-])/i, 'Add'],
[/(?:^|[^a-z0-9_-])(?:update|change)(?:$|[^a-z0-9_-])/i, 'Update'],
[/(?:^|[^a-z0-9_-])(?:refactor|simplify)(?:$|[^a-z0-9_-])/i, 'Refactor'],
[/(?:^|[^a-z0-9_-])(?:test|verify|validate)(?:$|[^a-z0-9_-])/i, 'Test']
]
const STOP_WORDS = new Set([
'a',
'an',
'and',
'for',
'from',
'in',
'is',
'it',
'of',
'on',
'or',
'the',
'this',
'to',
'with'
])
function detectIntentAction(sourceText: string): string | null {
for (const [pattern, label] of ACTION_LABELS) {
if (pattern.test(sourceText)) {
return label
}
}
return null
}
function titleCaseWord(word: string): string {
const normalized = normalizeApostrophes(word)
if (/^[A-Z]{2,}\d*$/.test(normalized) || /^[A-Z]+-\d+$/i.test(normalized)) {
return normalized.toUpperCase()
}
const acronymPossessive = normalized.match(/^([A-Z]{2,}\d*)'([sS])$/)
if (acronymPossessive) {
return `${acronymPossessive[1].toUpperCase()}'s`
}
const lower = normalized.toLowerCase()
const apostropheParts = lower.split("'")
if (apostropheParts.length === 2 && apostropheParts[0].length === 1 && apostropheParts[1]) {
return `${apostropheParts[0].toUpperCase()}'${apostropheParts[1]}`
}
return lower.charAt(0).toUpperCase() + lower.slice(1)
}
function compactWords(input: string, maxWords = 4): string {
// Why: workspace names can be derived from pasted prompts/URLs; collect only
// the visible words we need instead of splitting the full text.
const words = collectCompactWorkspaceWords(
stripDanglingDisplayApostrophes(input),
maxWords,
STOP_WORDS
)
return words.map(titleCaseWord).join(' ')
}
function escapeRegExp(input: string): string {
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function compactWorkItemTitle(title: string, item: WorkspaceIntentWorkItem): string {
const identifier = item.linearIdentifier ?? item.jiraIdentifier
let withoutPrefix = title
.trim()
.replace(/^(?:issue|pr|pull request|mr|merge request)\s*[#!]?\d+\s*[:-]\s*/i, '')
.replace(/\([#!]?\d+\)/g, '')
.replace(/^[^:]{1,32}:\s*/, '')
.trim()
if (item.number > 0) {
withoutPrefix = withoutPrefix.replace(new RegExp(`\\b[#!]?${item.number}\\b`, 'g'), '').trim()
}
if (identifier) {
withoutPrefix = withoutPrefix
.replace(new RegExp(`^${escapeRegExp(identifier)}\\s*[:-]?\\s*`, 'i'), '')
.trim()
}
return compactWords(withoutPrefix || title, 3)
}
function workItemIdentity(item: WorkspaceIntentWorkItem): string {
if (item.linearIdentifier) {
return item.linearIdentifier.toUpperCase()
}
if (item.jiraIdentifier) {
return item.jiraIdentifier.toUpperCase()
}
if (item.type === 'pr') {
return `PR ${item.number}`
}
if (item.type === 'mr') {
return `MR ${item.number}`
}
return `Issue ${item.number}`
}
export function getLinkedWorkItemWorkspaceName(
item: WorkspaceIntentWorkItem
): WorkspaceIntentName | null {
const identifier = item.linearIdentifier ?? item.jiraIdentifier
let subject = getLinkedWorkItemTitleSubject(item) || item.title.trim()
if (identifier) {
subject = subject
.replace(new RegExp(`^${escapeRegExp(identifier)}\\s*[:-]?\\s*`, 'i'), '')
.trim()
}
const displayName = [identifier, subject].filter(Boolean).join(' ') || workItemIdentity(item)
const seedName = slugifyForWorkspaceName(displayName)
if (!seedName) {
return null
}
return { displayName, seedName }
}
function defaultActionForWorkItem(item: WorkspaceIntentWorkItem): string | null {
return item.type === 'pr' || item.type === 'mr' ? 'Review' : null
}
/**
* Resolve the one human intent label that should drive first-create workspace
* identity. The display label and git-safe seed are derived together so the
* folder, branch, and sidebar name do not drift before work has started.
*/
export function getWorkspaceIntentName(args: {
sourceText?: string
workItem?: WorkspaceIntentWorkItem | null
fallbackName?: string
}): WorkspaceIntentName | null {
const sourceText = args.sourceText?.trim() ?? ''
const item = args.workItem ?? null
let displayName = ''
if (item) {
const action = detectIntentAction(sourceText) ?? defaultActionForWorkItem(item)
const identity = workItemIdentity(item)
if (action) {
displayName = `${action} ${identity}`
} else {
const subject = compactWorkItemTitle(item.title, item)
displayName = [identity, subject].filter(Boolean).join(' ')
}
} else if (sourceText) {
const compact = compactWords(sourceText, 5)
displayName = compact
}
if (!displayName && args.fallbackName?.trim()) {
displayName = args.fallbackName.trim()
}
if (!displayName) {
return null
}
const seedName = slugifyForWorkspaceName(displayName)
if (!seedName) {
return null
}
return { displayName, seedName }
}
export function getLinearIssueWorkspaceName(issue: { identifier: string; title: string }): string {
const key = slugifyForWorkspaceName(issue.identifier)
const titleSlug = getLinkedWorkItemSuggestedName(issue)
if (!key) {
return titleSlug
}
let dedupedTitleSlug = titleSlug
if (titleSlug === key) {
dedupedTitleSlug = ''
} else if (titleSlug.startsWith(`${key}-`)) {
dedupedTitleSlug = titleSlug.slice(key.length + 1)
}
return slugifyForWorkspaceName([key, dedupedTitleSlug].filter(Boolean).join('-'))
}
export function resolveWorkspaceCreateName(args: {
draft: string | undefined
fallback: string
}): string {
return args.draft?.trim() || args.fallback
}