mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
260 lines
8.0 KiB
TypeScript
260 lines
8.0 KiB
TypeScript
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 {
|
|
return (
|
|
removeIntraWordApostrophes(input)
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[\\/]+/g, '-')
|
|
.replace(/\s+/g, '-')
|
|
.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 {
|
|
return stripDanglingDisplayApostrophes(input)
|
|
.replace(/https?:\/\/\S+/gi, ' ')
|
|
.replace(/[()[\]{}"]/g, ' ')
|
|
.replace(/[#/\\:_-]+/g, ' ')
|
|
.split(/\s+/)
|
|
.map((word) => word.trim())
|
|
.filter(Boolean)
|
|
.filter((word) => !STOP_WORDS.has(word.toLowerCase()))
|
|
.slice(0, maxWords)
|
|
.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
|
|
}
|