orca/src/shared/workspace-name.ts

262 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}