Fix issue workspace names with apostrophes (#4848)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-07 22:57:52 -04:00 committed by GitHub
parent bed624573d
commit 880fd964ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 112 additions and 11 deletions

View File

@ -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: 'Cant enable browser notifications'
})
).toBe('cant-enable-browser-notifications')
})
})

View File

@ -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, '-')

View File

@ -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('Cant 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({

View File

@ -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())