Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion src/lib/alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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<string, string>
): 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[],
Expand All @@ -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);

Expand Down
95 changes: 79 additions & 16 deletions src/lib/formatters/human.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,15 +360,78 @@ 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**
* - 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)
*
* 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("-");
if (parts.length < 2) {
return null;
}

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)}`;
}
}
}

// 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;
}

/**
* 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,
Expand All @@ -378,27 +441,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;
}

/**
Expand Down
39 changes: 39 additions & 0 deletions test/lib/alias.property.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
54 changes: 54 additions & 0 deletions test/lib/alias.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
});
62 changes: 62 additions & 0 deletions test/lib/formatters/human.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading