From 60935354ccc57f6c3159346b2d26e465635247e4 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 5 Feb 2026 22:00:07 +0000 Subject: [PATCH 1/2] fix(alias): prevent double-dash in aliases when slug is prefix of another MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When one project slug is a prefix of another (e.g., 'cli' and 'cli-website'), the alias generation would produce malformed aliases ending with '-', resulting in double-dashes like 'cli--1' when combined with issue suffixes. This fix detects prefix relationships between slugs and uses the suffix after the prefix for differentiation: - 'cli' + 'cli-website' → aliases 'c' and 'w' → 'c-33', 'w-1' - 'api' + 'api-server' + 'api-server-v2' → aliases 'a', 's', 'v' Also fixes SHORT ID highlighting in multi-project mode to highlight the portion that corresponds to the alias (e.g., 'CLI-WEBSITE-1' highlights 'WEBSITE-1' to match alias 'w-1'). --- src/lib/alias.ts | 53 +++++++++++++++++++++- src/lib/formatters/human.ts | 79 ++++++++++++++++++++++++++------- test/lib/alias.property.test.ts | 39 ++++++++++++++++ test/lib/alias.test.ts | 54 ++++++++++++++++++++++ 4 files changed, 208 insertions(+), 17 deletions(-) diff --git a/src/lib/alias.ts b/src/lib/alias.ts index 3cd77d23..bfc22da4 100644 --- a/src/lib/alias.ts +++ b/src/lib/alias.ts @@ -85,7 +85,7 @@ export function findShortestUniquePrefixes( // Find the shortest prefix that's unique among all strings while (prefixLen <= lowerStr.length) { - const prefix = lowerStr.slice(0, prefixLen); + let prefix = lowerStr.slice(0, prefixLen); const isUnique = strings.every((other) => { if (other === str) { return true; @@ -94,6 +94,14 @@ export function findShortestUniquePrefixes( }); if (isUnique) { + // Extend past any trailing word boundary characters (- or _) + while ( + (prefix.endsWith("-") || prefix.endsWith("_")) && + prefixLen < lowerStr.length + ) { + prefixLen += 1; + prefix = lowerStr.slice(0, prefixLen); + } result.set(str, prefix); break; } @@ -148,6 +156,46 @@ function groupByProjectSlug(pairs: OrgProjectPair[]): { return { projectToOrgs, collidingSlugs, uniqueSlugs }; } +/** + * Handle "one slug is prefix of another" relationships. + * e.g., ["cli", "cli-website"] → cli stays "cli", cli-website becomes "website" + * + * For each slug, finds the longest other slug that it starts with (plus dash), + * and uses the suffix after that prefix as its remainder. + * + * Skips prefix stripping if it would create a collision with another slug + * (e.g., won't strip "cli-" from "cli-website" if "website" is also a project). + */ +function applyPrefixRelationships( + slugs: string[], + slugToRemainder: Map +): void { + // Collect all existing remainders to detect potential collisions + const existingRemainders = new Set(slugToRemainder.values()); + const slugSet = new Set(slugs); + + for (const slug of slugs) { + let longestPrefix = ""; + for (const other of slugs) { + if ( + other !== slug && + slug.startsWith(`${other}-`) && + other.length > longestPrefix.length + ) { + longestPrefix = other; + } + } + if (longestPrefix) { + const suffix = slug.slice(longestPrefix.length + 1); + // Only apply if suffix is non-empty and won't collide with another slug + if (suffix && !slugSet.has(suffix) && !existingRemainders.has(suffix)) { + slugToRemainder.set(slug, suffix); + existingRemainders.add(suffix); + } + } + } +} + /** Internal: Processes unique (non-colliding) project slugs */ function processUniqueSlugs( pairs: OrgProjectPair[], @@ -171,6 +219,9 @@ function processUniqueSlugs( slugToRemainder.set(slug, remainder || slug); } + // Handle prefix relationships (e.g., "cli" is prefix of "cli-website") + applyPrefixRelationships(uniqueProjectSlugs, slugToRemainder); + const uniqueRemainders = [...slugToRemainder.values()]; const uniquePrefixes = findShortestUniquePrefixes(uniqueRemainders); diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 80bed448..bdfa1f6e 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -360,15 +360,62 @@ export type FormatShortIdOptions = { }; /** - * Format a short ID with the unique suffix highlighted with underline. + * Format short ID for multi-project mode by highlighting the alias characters. + * Only highlights the specific characters that form the alias: + * - CLI-25 with alias "c" → **C**LI-**25** + * - CLI-WEBSITE-4 with alias "w" → CLI-**W**EBSITE-**4** * - * Single project mode: "CRAFT-G" → "CRAFT-_G_" (suffix underlined) - * Multi-project mode: "DASHBOARD-A3" → "DASHBOARD-_A3_" (just suffix underlined, - * alias is shown in separate ALIAS column) + * @returns Formatted string if alias matches, null otherwise (to fall back to default) + */ +function formatShortIdWithAlias( + upperShortId: string, + projectAlias: string +): string | null { + // Extract project part of alias (handle "o1/d" format for collision cases) + const aliasProjectPart = projectAlias.includes("/") + ? projectAlias.split("/").pop() + : projectAlias; + + if (!aliasProjectPart) { + return null; + } + + const parts = upperShortId.split("-"); + const aliasUpper = aliasProjectPart.toUpperCase(); + const aliasLen = aliasUpper.length; + + // Find the part that starts with the alias + const matchIndex = parts.findIndex((part) => part.startsWith(aliasUpper)); + if (matchIndex < 0 || parts.length < 2) { + return null; + } + + // Build result: highlight alias prefix in matching part + highlight last part (issue suffix) + const lastIndex = parts.length - 1; + const result = parts.map((part, i) => { + if (i === matchIndex) { + // Highlight the alias prefix, keep the rest plain + return boldUnderline(part.slice(0, aliasLen)) + part.slice(aliasLen); + } + if (i === lastIndex) { + // Highlight the issue suffix (last part) + return boldUnderline(part); + } + return part; + }); + + return result.join("-"); +} + +/** + * Format a short ID with highlighting to show what the user can type as shorthand. + * + * - Single project: CLI-25 → CLI-**25** (suffix highlighted) + * - Multi-project: CLI-WEBSITE-4 with alias "w" → CLI-**W**EBSITE-**4** (alias chars highlighted) * - * @param shortId - Full short ID (e.g., "CRAFT-G", "SPOTLIGHT-WEBSITE-A3") + * @param shortId - Full short ID (e.g., "CLI-25", "CLI-WEBSITE-4") * @param options - Formatting options (projectSlug, projectAlias, isMultiProject) - * @returns Formatted short ID with underline highlights + * @returns Formatted short ID with highlights */ export function formatShortId( shortId: string, @@ -378,27 +425,27 @@ export function formatShortId( const opts: FormatShortIdOptions = typeof options === "string" ? { projectSlug: options } : (options ?? {}); - const { projectSlug } = opts; - - // Extract suffix from shortId (the part after PROJECT-) + const { projectSlug, projectAlias, isMultiProject } = opts; const upperShortId = shortId.toUpperCase(); - let suffix = shortId; - if (projectSlug) { - const prefix = `${projectSlug.toUpperCase()}-`; - if (upperShortId.startsWith(prefix)) { - suffix = shortId.slice(prefix.length); + + // In multi-project mode with an alias, highlight the part that the alias represents + if (isMultiProject && projectAlias) { + const formatted = formatShortIdWithAlias(upperShortId, projectAlias); + if (formatted) { + return formatted; } } - // Show full shortId with suffix highlighted + // Single-project mode or fallback: highlight just the issue suffix if (projectSlug) { const prefix = `${projectSlug.toUpperCase()}-`; if (upperShortId.startsWith(prefix)) { + const suffix = shortId.slice(prefix.length); return `${prefix}${boldUnderline(suffix.toUpperCase())}`; } } - return shortId.toUpperCase(); + return upperShortId; } /** diff --git a/test/lib/alias.property.test.ts b/test/lib/alias.property.test.ts index e9096fb2..a48d58a7 100644 --- a/test/lib/alias.property.test.ts +++ b/test/lib/alias.property.test.ts @@ -156,6 +156,28 @@ describe("property: findShortestUniquePrefixes", () => { ); }); + test("prefixes never end with dash or underscore", () => { + // Use slugs that may contain hyphens to test the extension logic + fcAssert( + property( + uniqueArray(slugWithHyphensArb, { + minLength: 1, + maxLength: 10, + comparator: (a, b) => a.toLowerCase() === b.toLowerCase(), + }), + (strings) => { + const prefixes = findShortestUniquePrefixes(strings); + + for (const prefix of prefixes.values()) { + expect(prefix.endsWith("-")).toBe(false); + expect(prefix.endsWith("_")).toBe(false); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + test("empty array returns empty map", () => { const prefixes = findShortestUniquePrefixes([]); expect(prefixes.size).toBe(0); @@ -304,6 +326,23 @@ describe("property: buildOrgAwareAliases", () => { ); }); + test("aliases never end with dash or underscore", () => { + fcAssert( + property( + array(orgProjectPairArb, { minLength: 1, maxLength: 10 }), + (pairs) => { + const { aliasMap } = buildOrgAwareAliases(pairs); + + for (const alias of aliasMap.values()) { + expect(alias.endsWith("-")).toBe(false); + expect(alias.endsWith("_")).toBe(false); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + test("empty input returns empty map", () => { const { aliasMap } = buildOrgAwareAliases([]); expect(aliasMap.size).toBe(0); diff --git a/test/lib/alias.test.ts b/test/lib/alias.test.ts index e1a2bb39..af2b2bca 100644 --- a/test/lib/alias.test.ts +++ b/test/lib/alias.test.ts @@ -152,4 +152,58 @@ describe("buildOrgAwareAliases specific outputs", () => { expect(org1Api).toMatch(/^o.*\/api$/); expect(org1App).toMatch(/^o.*\/app$/); }); + + test("handles slug that is prefix of another slug", () => { + const result = buildOrgAwareAliases([ + { org: "acme", project: "cli" }, + { org: "acme", project: "cli-website" }, + ]); + // "cli" is a prefix of "cli-website", so cli-website uses "website" as remainder + expect(result.aliasMap.get("acme/cli")).toBe("c"); + expect(result.aliasMap.get("acme/cli-website")).toBe("w"); + }); + + test("handles nested prefix relationships", () => { + const result = buildOrgAwareAliases([ + { org: "acme", project: "api" }, + { org: "acme", project: "api-server" }, + { org: "acme", project: "api-server-v2" }, + ]); + // api → api, api-server → server, api-server-v2 → v2 + expect(result.aliasMap.get("acme/api")).toBe("a"); + expect(result.aliasMap.get("acme/api-server")).toBe("s"); + expect(result.aliasMap.get("acme/api-server-v2")).toBe("v"); + }); + + test("no alias ends with dash or underscore", () => { + const result = buildOrgAwareAliases([ + { org: "acme", project: "cli" }, + { org: "acme", project: "cli-website" }, + { org: "acme", project: "cli-app" }, + ]); + for (const alias of result.aliasMap.values()) { + expect(alias.endsWith("-")).toBe(false); + expect(alias.endsWith("_")).toBe(false); + } + }); + + test("avoids collision when prefix-stripped remainder matches another slug", () => { + // Edge case: "cli-website" would become "website" after stripping "cli-", + // but "website" is already a project slug - must avoid duplicate aliases + const result = buildOrgAwareAliases([ + { org: "acme", project: "cli" }, + { org: "acme", project: "cli-website" }, + { org: "acme", project: "website" }, + ]); + + // All aliases must be unique + const aliases = [...result.aliasMap.values()]; + const uniqueAliases = new Set(aliases); + expect(uniqueAliases.size).toBe(aliases.length); + + // No alias should end with dash + for (const alias of aliases) { + expect(alias.endsWith("-")).toBe(false); + } + }); }); From 34238c3056880b592ac2819f9c249f7a3b6631bb Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 5 Feb 2026 23:34:53 +0000 Subject: [PATCH 2/2] fix: highlight correct part in multi-project short IDs - Search backwards through project parts to find rightmost alias match - Handle aliases with embedded dashes by matching joined project portion - Add tests for highlighting edge cases --- src/lib/formatters/human.ts | 54 +++++++++++++++++---------- test/lib/formatters/human.test.ts | 62 +++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 19 deletions(-) diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index bdfa1f6e..3138cc0b 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -364,6 +364,8 @@ export type FormatShortIdOptions = { * Only highlights the specific characters that form the alias: * - CLI-25 with alias "c" → **C**LI-**25** * - CLI-WEBSITE-4 with alias "w" → CLI-**W**EBSITE-**4** + * - API-APP-5 with alias "ap" → API-**AP**P-**5** (searches backwards to find correct part) + * - X-AB-5 with alias "x-a" → **X-A**B-**5** (handles aliases with embedded dashes) * * @returns Formatted string if alias matches, null otherwise (to fall back to default) */ @@ -381,30 +383,44 @@ function formatShortIdWithAlias( } const parts = upperShortId.split("-"); - const aliasUpper = aliasProjectPart.toUpperCase(); - const aliasLen = aliasUpper.length; - - // Find the part that starts with the alias - const matchIndex = parts.findIndex((part) => part.startsWith(aliasUpper)); - if (matchIndex < 0 || parts.length < 2) { + if (parts.length < 2) { return null; } - // Build result: highlight alias prefix in matching part + highlight last part (issue suffix) - const lastIndex = parts.length - 1; - const result = parts.map((part, i) => { - if (i === matchIndex) { - // Highlight the alias prefix, keep the rest plain - return boldUnderline(part.slice(0, aliasLen)) + part.slice(aliasLen); - } - if (i === lastIndex) { - // Highlight the issue suffix (last part) - return boldUnderline(part); + const aliasUpper = aliasProjectPart.toUpperCase(); + const aliasLen = aliasUpper.length; + const projectParts = parts.slice(0, -1); + const issueSuffix = parts.at(-1) ?? ""; + + // Method 1: For aliases without dashes, search backwards through project parts + // This handles cases like "api-app" where alias "ap" should match "APP" not "API" + if (!aliasUpper.includes("-")) { + for (let i = projectParts.length - 1; i >= 0; i--) { + const part = projectParts[i]; + if (part?.startsWith(aliasUpper)) { + // Found match - highlight alias prefix in this part and the issue suffix + const result = projectParts.map((p, idx) => { + if (idx === i) { + return boldUnderline(p.slice(0, aliasLen)) + p.slice(aliasLen); + } + return p; + }); + return `${result.join("-")}-${boldUnderline(issueSuffix)}`; + } } - return part; - }); + } - return result.join("-"); + // Method 2: For aliases with dashes (or if Method 1 found no match), + // match against the joined project portion + const projectPortion = projectParts.join("-"); + if (projectPortion.startsWith(aliasUpper)) { + // Highlight first aliasLen chars of project portion, plus issue suffix + const highlighted = boldUnderline(projectPortion.slice(0, aliasLen)); + const rest = projectPortion.slice(aliasLen); + return `${highlighted}${rest}-${boldUnderline(issueSuffix)}`; + } + + return null; } /** diff --git a/test/lib/formatters/human.test.ts b/test/lib/formatters/human.test.ts index 4896ee45..3cf17165 100644 --- a/test/lib/formatters/human.test.ts +++ b/test/lib/formatters/human.test.ts @@ -84,6 +84,68 @@ describe("formatShortId ANSI formatting", () => { }); }); +describe("formatShortId multi-project alias highlighting", () => { + // These tests verify the highlighting logic finds the correct part to highlight. + // Content is always verified (ANSI codes stripped); formatting presence depends on FORCE_COLOR. + + test("highlights rightmost matching part for ambiguous aliases", () => { + // Bug fix: For projects api-app, api-admin with aliases ap, ad + // API-APP-5 with alias "ap" should highlight APP (not API) + const result = formatShortId("API-APP-5", { + projectAlias: "ap", + isMultiProject: true, + }); + // Content is always correct - the text should be unchanged + expect(stripAnsi(result)).toBe("API-APP-5"); + }); + + test("highlights alias with embedded dash correctly", () => { + // Bug fix: For projects x-ab, xyz with aliases x-a, xy + // X-AB-5 with alias "x-a" should highlight X-A (joined project portion) + const result = formatShortId("X-AB-5", { + projectAlias: "x-a", + isMultiProject: true, + }); + expect(stripAnsi(result)).toBe("X-AB-5"); + }); + + test("highlights single char alias at start of multi-part short ID", () => { + // CLI-WEBSITE-4 with alias "w" should highlight W in WEBSITE (not CLI) + const result = formatShortId("CLI-WEBSITE-4", { + projectAlias: "w", + isMultiProject: true, + }); + expect(stripAnsi(result)).toBe("CLI-WEBSITE-4"); + }); + + test("highlights single char alias in simple short ID", () => { + // CLI-25 with alias "c" should highlight C in CLI + const result = formatShortId("CLI-25", { + projectAlias: "c", + isMultiProject: true, + }); + expect(stripAnsi(result)).toBe("CLI-25"); + }); + + test("handles org-prefixed alias format", () => { + // Alias "o1/d" should use "d" for matching against DASHBOARD-A3 + const result = formatShortId("DASHBOARD-A3", { + projectAlias: "o1/d", + isMultiProject: true, + }); + expect(stripAnsi(result)).toBe("DASHBOARD-A3"); + }); + + test("falls back gracefully when alias doesn't match", () => { + // If alias doesn't match any part, return plain text + const result = formatShortId("CLI-25", { + projectAlias: "xyz", + isMultiProject: true, + }); + expect(stripAnsi(result)).toBe("CLI-25"); + }); +}); + describe("formatIssueRow", () => { const mockIssue: SentryIssue = { id: "123",