diff --git a/mobile/src/tasks/mobile-workspace-name.test.ts b/mobile/src/tasks/mobile-workspace-name.test.ts new file mode 100644 index 000000000..e4d804435 --- /dev/null +++ b/mobile/src/tasks/mobile-workspace-name.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { getLinkedWorkItemSuggestedName } from './mobile-workspace-name' + +describe('mobile workspace names', () => { + it('removes apostrophes inside words instead of splitting them', () => { + expect( + getLinkedWorkItemSuggestedName({ + title: "Can't enable browser notifications" + }) + ).toBe('cant-enable-browser-notifications') + + expect( + getLinkedWorkItemSuggestedName({ + title: 'Can’t enable browser notifications' + }) + ).toBe('cant-enable-browser-notifications') + }) +}) diff --git a/mobile/src/tasks/mobile-workspace-name.ts b/mobile/src/tasks/mobile-workspace-name.ts index 0cf116155..52330e834 100644 --- a/mobile/src/tasks/mobile-workspace-name.ts +++ b/mobile/src/tasks/mobile-workspace-name.ts @@ -5,8 +5,14 @@ export function resolveMobileWorkspaceCreateName(args: { return args.draft?.trim() || args.fallback } +// Why: mirrors the desktop issue-name slugger so contractions do not create +// branch/path names like `can-t-enable` in mobile-created workspaces. +function removeIntraWordApostrophes(input: string): string { + return input.replace(/[‘’]/g, "'").replace(/([\p{L}\p{N}])'(?=[\p{L}\p{N}])/gu, '$1') +} + function slugifyForWorkspaceName(input: string): string { - return input + return removeIntraWordApostrophes(input) .trim() .toLowerCase() .replace(/[\\/]+/g, '-') diff --git a/src/shared/workspace-name.test.ts b/src/shared/workspace-name.test.ts index e01e5449f..e7db0f41c 100644 --- a/src/shared/workspace-name.test.ts +++ b/src/shared/workspace-name.test.ts @@ -13,6 +13,15 @@ describe('slugifyForWorkspaceName', () => { expect(slugifyForWorkspaceName('feature/add issue drawer')).toBe('feature-add-issue-drawer') expect(slugifyForWorkspaceName('a'.repeat(80))).toBe('a'.repeat(48)) }) + + it('removes apostrophes inside words instead of splitting them', () => { + expect(slugifyForWorkspaceName("Can't enable browser notifications")).toBe( + 'cant-enable-browser-notifications' + ) + expect(slugifyForWorkspaceName('Can’t enable browser notifications')).toBe( + 'cant-enable-browser-notifications' + ) + }) }) describe('getLinkedWorkItemSuggestedName', () => { @@ -90,6 +99,52 @@ describe('getWorkspaceIntentName', () => { }) }) + it('keeps contractions readable in linked issue display names', () => { + expect( + getWorkspaceIntentName({ + sourceText: 'https://github.com/acme/app/issues/4802', + workItem: { + type: 'issue', + number: 4802, + title: "Can't enable browser notifications from within a browser tab" + } + }) + ).toEqual({ + displayName: "Issue 4802 Can't Enable Browser", + seedName: 'issue-4802-cant-enable-browser' + }) + }) + + it('keeps single-letter contractions lowercase after the apostrophe', () => { + expect( + getWorkspaceIntentName({ + sourceText: 'https://github.com/acme/app/issues/17', + workItem: { + type: 'issue', + number: 17, + title: "i'm blocked on notifications" + } + }) + ).toEqual({ + displayName: "Issue 17 I'm Blocked Notifications", + seedName: 'issue-17-im-blocked-notifications' + }) + + expect( + getWorkspaceIntentName({ + sourceText: 'https://github.com/acme/app/issues/18', + workItem: { + type: 'issue', + number: 18, + title: "i'll update login" + } + }) + ).toEqual({ + displayName: "Issue 18 I'll Update Login", + seedName: 'issue-18-ill-update-login' + }) + }) + it('does not treat an auto-generated slug as explicit user intent', () => { expect( getWorkspaceIntentName({ diff --git a/src/shared/workspace-name.ts b/src/shared/workspace-name.ts index 04e63814e..f1625ca06 100644 --- a/src/shared/workspace-name.ts +++ b/src/shared/workspace-name.ts @@ -1,6 +1,22 @@ +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 ( - input + removeIntraWordApostrophes(input) .trim() .toLowerCase() .replace(/[\\/]+/g, '-') @@ -47,10 +63,7 @@ export type WorkspaceIntentName = { 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_-])(?: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'], @@ -87,17 +100,26 @@ function detectIntentAction(sourceText: string): string | null { } function titleCaseWord(word: string): string { - const lower = word.toLowerCase() - if (/^[A-Z]{2,}\d*$/.test(word) || /^[A-Z]+-\d+$/i.test(word)) { - return word.toUpperCase() + 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 input + return stripDanglingDisplayApostrophes(input) .replace(/https?:\/\/\S+/gi, ' ') - .replace(/[()[\]{}"']/g, ' ') + .replace(/[()[\]{}"]/g, ' ') .replace(/[#/\\:_-]+/g, ' ') .split(/\s+/) .map((word) => word.trim())