diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index ed0110db8..a362cc684 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -7,10 +7,7 @@ import { buildCommand, numberParser } from "@stricli/core"; import type { SentryContext } from "../../context.js"; -import { - findCommonWordPrefix, - findShortestUniquePrefixes, -} from "../../lib/alias.js"; +import { buildOrgAwareAliases } from "../../lib/alias.js"; import { listIssues } from "../../lib/api-client.js"; import { clearProjectAliases, setProjectAliases } from "../../lib/config.js"; import { createDsnFingerprint } from "../../lib/dsn/index.js"; @@ -60,11 +57,19 @@ function parseSort(value: string): SortValue { /** * Write the issue list header with column titles. + * + * @param stdout - Output writer + * @param title - Section title + * @param isMultiProject - Whether to show ALIAS column for multi-project mode */ -function writeListHeader(stdout: Writer, title: string): void { +function writeListHeader( + stdout: Writer, + title: string, + isMultiProject = false +): void { stdout.write(`${title}:\n\n`); - stdout.write(muted(`${formatIssueListHeader()}\n`)); - stdout.write(`${divider(80)}\n`); + stdout.write(muted(`${formatIssueListHeader(isMultiProject)}\n`)); + stdout.write(`${divider(isMultiProject ? 96 : 80)}\n`); } /** Issue with formatting options attached */ @@ -104,7 +109,7 @@ function writeListFooter( break; case "multi": stdout.write( - "\nTip: Use 'sentry issue get ' to view details (e.g., 'f-g').\n" + "\nTip: Use 'sentry issue get ' to view details (see ALIAS column).\n" ); break; default: @@ -124,77 +129,67 @@ type IssueListResult = { type AliasMapResult = { aliasMap: Map; entries: Record; - /** Common prefix that was stripped from project slugs */ - strippedPrefix: string; }; /** * Build project alias map using shortest unique prefix of project slug. + * Handles cross-org slug collisions by prefixing with org abbreviation. * Strips common word prefix before computing unique prefixes for cleaner aliases. * - * Example: spotlight-electron, spotlight-website, spotlight → e, w, s - * Example: frontend, functions, backend → fr, fu, b + * Single org examples: + * spotlight-electron, spotlight-website, spotlight → e, w, s + * frontend, functions, backend → fr, fu, b + * + * Cross-org collision example: + * org1:dashboard, org2:dashboard → o1:d, o2:d */ function buildProjectAliasMap(results: IssueListResult[]): AliasMapResult { - const aliasMap = new Map(); const entries: Record = {}; - // Get all project slugs - const projectSlugs = results.map((r) => r.target.project); - - // Strip common word prefix for cleaner aliases - const strippedPrefix = findCommonWordPrefix(projectSlugs); - - // Create remainders after stripping common prefix - // If stripping leaves empty string, use the original slug - const slugToRemainder = new Map(); - for (const slug of projectSlugs) { - const remainder = slug.slice(strippedPrefix.length); - slugToRemainder.set(slug, remainder || slug); - } - - // Find shortest unique prefix for each remainder - const remainders = [...slugToRemainder.values()]; - const prefixes = findShortestUniquePrefixes(remainders); + // Build org-aware aliases that handle cross-org collisions + const pairs = results.map((r) => ({ + org: r.target.org, + project: r.target.project, + })); + const { aliasMap } = buildOrgAwareAliases(pairs); + // Build entries record for storage for (const result of results) { - const projectSlug = result.target.project; - const remainder = slugToRemainder.get(projectSlug) ?? projectSlug; - const alias = prefixes.get(remainder) ?? remainder.charAt(0).toLowerCase(); - - const key = `${result.target.org}:${projectSlug}`; - aliasMap.set(key, alias); - entries[alias] = { - orgSlug: result.target.org, - projectSlug, - }; + const key = `${result.target.org}:${result.target.project}`; + const alias = aliasMap.get(key); + if (alias) { + entries[alias] = { + orgSlug: result.target.org, + projectSlug: result.target.project, + }; + } } - return { aliasMap, entries, strippedPrefix }; + return { aliasMap, entries }; } /** * Attach formatting options to each issue based on alias map. + * + * @param results - Issue list results with targets + * @param aliasMap - Map from "org:project" to alias + * @param isMultiProject - Whether in multi-project mode (shows ALIAS column) */ function attachFormatOptions( results: IssueListResult[], aliasMap: Map, - strippedPrefix: string + isMultiProject: boolean ): IssueWithOptions[] { return results.flatMap((result) => result.issues.map((issue) => { const key = `${result.target.org}:${result.target.project}`; const alias = aliasMap.get(key); - // Only pass strippedPrefix if this project actually has that prefix - // (e.g., "spotlight" doesn't have "spotlight-" prefix, but "spotlight-electron" does) - const hasPrefix = - strippedPrefix && result.target.project.startsWith(strippedPrefix); return { issue, formatOptions: { projectSlug: result.target.project, projectAlias: alias, - strippedPrefix: hasPrefix ? strippedPrefix : undefined, + isMultiProject, }, }; }) @@ -362,12 +357,11 @@ export const listCommand = buildCommand({ const firstTarget = validResults[0]?.target; // Build project alias map and cache it for multi-project mode - const { aliasMap, entries, strippedPrefix } = isMultiProject + const { aliasMap, entries } = isMultiProject ? buildProjectAliasMap(validResults) : { aliasMap: new Map(), entries: {}, - strippedPrefix: "", }; if (isMultiProject) { @@ -381,7 +375,7 @@ export const listCommand = buildCommand({ const issuesWithOptions = attachFormatOptions( validResults, aliasMap, - strippedPrefix + isMultiProject ); // Sort by user preference @@ -410,7 +404,7 @@ export const listCommand = buildCommand({ ? `Issues in ${firstTarget.orgDisplay}/${firstTarget.projectDisplay}` : `Issues from ${validResults.length} projects`; - writeListHeader(stdout, title); + writeListHeader(stdout, title, isMultiProject); const termWidth = process.stdout.columns || 80; writeIssueRows(stdout, issuesWithOptions, termWidth); diff --git a/src/lib/alias.ts b/src/lib/alias.ts index 148e44b5f..20f74306c 100644 --- a/src/lib/alias.ts +++ b/src/lib/alias.ts @@ -109,3 +109,163 @@ export function findShortestUniquePrefixes( return result; } + +/** Input pair for org-aware alias generation */ +export type OrgProjectPair = { + org: string; + project: string; +}; + +/** Result of org-aware alias generation */ +export type OrgAwareAliasResult = { + /** Map from "org:project" key to alias string */ + aliasMap: Map; +}; + +/** Internal: Groups pairs by project slug and identifies collisions */ +function groupByProjectSlug(pairs: OrgProjectPair[]): { + projectToOrgs: Map>; + collidingSlugs: Set; + uniqueSlugs: Set; +} { + const projectToOrgs = new Map>(); + for (const { org, project } of pairs) { + const orgs = projectToOrgs.get(project) ?? new Set(); + orgs.add(org); + projectToOrgs.set(project, orgs); + } + + const collidingSlugs = new Set(); + const uniqueSlugs = new Set(); + for (const [project, orgs] of projectToOrgs) { + if (orgs.size > 1) { + collidingSlugs.add(project); + } else { + uniqueSlugs.add(project); + } + } + + return { projectToOrgs, collidingSlugs, uniqueSlugs }; +} + +/** Internal: Processes unique (non-colliding) project slugs */ +function processUniqueSlugs( + pairs: OrgProjectPair[], + uniqueSlugs: Set, + aliasMap: Map +): void { + const uniqueProjects = pairs.filter((p) => uniqueSlugs.has(p.project)); + const uniqueProjectSlugs = [...new Set(uniqueProjects.map((p) => p.project))]; + + if (uniqueProjectSlugs.length === 0) { + return; + } + + // Strip common word prefix for cleaner aliases (e.g., "spotlight-" from + // "spotlight-electron", "spotlight-website") + const strippedPrefix = findCommonWordPrefix(uniqueProjectSlugs); + const slugToRemainder = new Map(); + + for (const slug of uniqueProjectSlugs) { + const remainder = slug.slice(strippedPrefix.length); + slugToRemainder.set(slug, remainder || slug); + } + + const uniqueRemainders = [...slugToRemainder.values()]; + const uniquePrefixes = findShortestUniquePrefixes(uniqueRemainders); + + for (const { org, project } of uniqueProjects) { + const remainder = slugToRemainder.get(project) ?? project; + const alias = + uniquePrefixes.get(remainder) ?? remainder.charAt(0).toLowerCase(); + aliasMap.set(`${org}:${project}`, alias); + } +} + +/** Internal: Processes colliding project slugs that need org prefixes */ +function processCollidingSlugs( + projectToOrgs: Map>, + collidingSlugs: Set, + aliasMap: Map +): void { + // Get all orgs involved in collisions + const collidingOrgs = new Set(); + for (const slug of collidingSlugs) { + const orgs = projectToOrgs.get(slug); + if (orgs) { + for (const org of orgs) { + collidingOrgs.add(org); + } + } + } + + const orgPrefixes = findShortestUniquePrefixes([...collidingOrgs]); + + // Compute unique prefixes for colliding project slugs to handle + // cases like "api" and "app" both colliding across orgs + const projectPrefixes = findShortestUniquePrefixes([...collidingSlugs]); + + for (const slug of collidingSlugs) { + const orgs = projectToOrgs.get(slug); + if (!orgs) { + continue; + } + + const projectPrefix = + projectPrefixes.get(slug) ?? slug.charAt(0).toLowerCase(); + + for (const org of orgs) { + const orgPrefix = orgPrefixes.get(org) ?? org.charAt(0).toLowerCase(); + aliasMap.set(`${org}:${slug}`, `${orgPrefix}:${projectPrefix}`); + } + } +} + +/** + * Build aliases for org/project pairs, handling cross-org slug collisions. + * + * - Unique project slugs → shortest unique prefix of project slug + * - Colliding slugs (same project in multiple orgs) → "{orgPrefix}:{projectPrefix}" + * + * Common word prefixes (like "spotlight-" in "spotlight-electron") are stripped + * before computing project prefixes to keep aliases short. + * + * @param pairs - Array of org/project pairs to generate aliases for + * @returns Map from "org:project" key to alias string + * + * @example + * // No collision - same as existing behavior + * buildOrgAwareAliases([ + * { org: "acme", project: "frontend" }, + * { org: "acme", project: "backend" } + * ]) + * // { aliasMap: Map { "acme:frontend" => "f", "acme:backend" => "b" } } + * + * @example + * // Collision: same project slug in different orgs + * buildOrgAwareAliases([ + * { org: "org1", project: "dashboard" }, + * { org: "org2", project: "dashboard" } + * ]) + * // { aliasMap: Map { "org1:dashboard" => "o1:d", "org2:dashboard" => "o2:d" } } + */ +export function buildOrgAwareAliases( + pairs: OrgProjectPair[] +): OrgAwareAliasResult { + const aliasMap = new Map(); + + if (pairs.length === 0) { + return { aliasMap }; + } + + const { projectToOrgs, collidingSlugs, uniqueSlugs } = + groupByProjectSlug(pairs); + + processUniqueSlugs(pairs, uniqueSlugs, aliasMap); + + if (collidingSlugs.size > 0) { + processCollidingSlugs(projectToOrgs, collidingSlugs, aliasMap); + } + + return { aliasMap }; +} diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index b5257619f..910b72b3d 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -263,19 +263,50 @@ export function formatRelativeTime(dateString: string | undefined): string { /** Column widths for issue list table */ const COL_LEVEL = 7; +const COL_ALIAS = 15; const COL_SHORT_ID = 22; const COL_COUNT = 5; const COL_SEEN = 10; -/** Column where title starts (sum of all previous columns + separators) */ +/** Column where title starts in single-project mode (no ALIAS column) */ const TITLE_START_COL = COL_LEVEL + 1 + COL_SHORT_ID + 1 + COL_COUNT + 2 + COL_SEEN + 2; // = 50 +/** Column where title starts in multi-project mode (with ALIAS column) */ +const TITLE_START_COL_MULTI = + COL_LEVEL + + 1 + + COL_ALIAS + + 1 + + COL_SHORT_ID + + 1 + + COL_COUNT + + 2 + + COL_SEEN + + 2; // = 66 + /** * Format the header row for issue list table. * Uses same column widths as data rows to ensure alignment. + * + * @param isMultiProject - Whether to include ALIAS column for multi-project mode */ -export function formatIssueListHeader(): string { +export function formatIssueListHeader(isMultiProject = false): string { + if (isMultiProject) { + return ( + "LEVEL".padEnd(COL_LEVEL) + + " " + + "ALIAS".padEnd(COL_ALIAS) + + " " + + "SHORT ID".padEnd(COL_SHORT_ID) + + " " + + "COUNT".padStart(COL_COUNT) + + " " + + "SEEN".padEnd(COL_SEEN) + + " " + + "TITLE" + ); + } return ( "LEVEL".padEnd(COL_LEVEL) + " " + @@ -336,21 +367,21 @@ function wrapTitle(text: string, startCol: number, termWidth: number): string { export type FormatShortIdOptions = { /** Project slug to determine the prefix for suffix highlighting */ projectSlug?: string; - /** Project alias (e.g., "e", "w", "s") for multi-project display */ + /** Project alias (e.g., "e", "w", "o1:d") for multi-project display */ projectAlias?: string; - /** Common prefix that was stripped to compute the alias (e.g., "spotlight-") */ - strippedPrefix?: string; + /** Whether in multi-project mode (shows ALIAS column) */ + isMultiProject?: boolean; }; /** * Format a short ID with the unique suffix highlighted with underline. * * Single project mode: "CRAFT-G" → "CRAFT-_G_" (suffix underlined) - * Multi-project mode: "SPOTLIGHT-WEBSITE-2A" with alias "w" and strippedPrefix "spotlight-" - * → "SPOTLIGHT-_W_EBSITE-_2A_" (alias char in remainder and suffix underlined) + * Multi-project mode: "DASHBOARD-A3" → "DASHBOARD-_A3_" (just suffix underlined, + * alias is shown in separate ALIAS column) * * @param shortId - Full short ID (e.g., "CRAFT-G", "SPOTLIGHT-WEBSITE-A3") - * @param options - Formatting options (projectSlug, projectAlias, strippedPrefix) + * @param options - Formatting options (projectSlug, projectAlias, isMultiProject) * @returns Formatted short ID with underline highlights */ export function formatShortId( @@ -361,7 +392,7 @@ export function formatShortId( const opts: FormatShortIdOptions = typeof options === "string" ? { projectSlug: options } : (options ?? {}); - const { projectSlug, projectAlias, strippedPrefix } = opts; + const { projectSlug } = opts; // Extract suffix from shortId (the part after PROJECT-) const upperShortId = shortId.toUpperCase(); @@ -373,30 +404,7 @@ export function formatShortId( } } - // Multi-project mode: highlight alias position and suffix - if (projectAlias && projectSlug) { - const upperSlug = projectSlug.toUpperCase(); - const aliasLen = projectAlias.length; - - // Find where the alias corresponds to in the project slug - // If strippedPrefix exists, the alias is from the remainder after stripping - const strippedLen = strippedPrefix?.length ?? 0; - const aliasStartInSlug = Math.min(strippedLen, upperSlug.length); - - // Build the formatted output: PROJECT-SLUG with alias part underlined, then -SUFFIX underlined - // e.g., "SPOTLIGHT-WEBSITE" with alias "w", strippedPrefix "spotlight-" - // → aliasStartInSlug = 10, so we underline chars 10-11 (the "W") - const beforeAlias = upperSlug.slice(0, aliasStartInSlug); - const aliasChars = upperSlug.slice( - aliasStartInSlug, - aliasStartInSlug + aliasLen - ); - const afterAlias = upperSlug.slice(aliasStartInSlug + aliasLen); - - return `${beforeAlias}${boldUnderline(aliasChars)}${afterAlias}-${boldUnderline(suffix.toUpperCase())}`; - } - - // Single project mode: show full shortId with suffix highlighted + // Show full shortId with suffix highlighted if (projectSlug) { const prefix = `${projectSlug.toUpperCase()}-`; if (upperShortId.startsWith(prefix)) { @@ -415,6 +423,22 @@ function getShortIdDisplayLength(shortId: string): number { return shortId.length; } +/** + * Compute the alias shorthand for an issue (e.g., "o1:d-a3", "w-2a"). + * This is what users type to reference the issue. + * + * @param shortId - Full short ID (e.g., "DASHBOARD-A3") + * @param projectAlias - Project alias (e.g., "o1:d", "w") + * @returns Alias shorthand (e.g., "o1:d-a3", "w-2a") or empty string if no alias + */ +function computeAliasShorthand(shortId: string, projectAlias?: string): string { + if (!projectAlias) { + return ""; + } + const suffix = shortId.split("-").pop()?.toLowerCase() ?? ""; + return `${projectAlias}-${suffix}`; +} + /** * Format a single issue for list display. * Wraps long titles with proper indentation. @@ -428,9 +452,17 @@ export function formatIssueRow( termWidth = 80, shortIdOptions?: FormatShortIdOptions | string ): string { + // Handle legacy string parameter (projectSlug only) + const opts: FormatShortIdOptions = + typeof shortIdOptions === "string" + ? { projectSlug: shortIdOptions } + : (shortIdOptions ?? {}); + + const { isMultiProject, projectAlias } = opts; + const levelText = (issue.level ?? "unknown").toUpperCase().padEnd(COL_LEVEL); const level = levelColor(levelText, issue.level); - const formattedShortId = formatShortId(issue.shortId, shortIdOptions); + const formattedShortId = formatShortId(issue.shortId, opts); // Calculate raw display length (without ANSI codes) for padding const rawLen = getShortIdDisplayLength(issue.shortId); @@ -438,8 +470,19 @@ export function formatIssueRow( const shortId = `${formattedShortId}${shortIdPadding}`; const count = `${issue.count}`.padStart(COL_COUNT); const seen = formatRelativeTime(issue.lastSeen); - const title = wrapTitle(issue.title, TITLE_START_COL, termWidth); + // Multi-project mode: include ALIAS column + if (isMultiProject) { + const aliasShorthand = computeAliasShorthand(issue.shortId, projectAlias); + const aliasPadding = " ".repeat( + Math.max(0, COL_ALIAS - aliasShorthand.length) + ); + const alias = `${aliasShorthand}${aliasPadding}`; + const title = wrapTitle(issue.title, TITLE_START_COL_MULTI, termWidth); + return `${level} ${alias} ${shortId} ${count} ${seen} ${title}`; + } + + const title = wrapTitle(issue.title, TITLE_START_COL, termWidth); return `${level} ${shortId} ${count} ${seen} ${title}`; } diff --git a/test/lib/alias.test.ts b/test/lib/alias.test.ts index 268e3b61f..840ad0145 100644 --- a/test/lib/alias.test.ts +++ b/test/lib/alias.test.ts @@ -4,6 +4,7 @@ import { describe, expect, test } from "bun:test"; import { + buildOrgAwareAliases, findCommonWordPrefix, findShortestUniquePrefixes, } from "../../src/lib/alias.js"; @@ -154,3 +155,158 @@ describe("alias generation integration", () => { expect(prefixes.get("worker")).toBe("w"); }); }); + +describe("buildOrgAwareAliases", () => { + test("returns empty map for empty input", () => { + const result = buildOrgAwareAliases([]); + expect(result.aliasMap.size).toBe(0); + }); + + test("single org multiple projects - no collision", () => { + const result = buildOrgAwareAliases([ + { org: "acme", project: "frontend" }, + { org: "acme", project: "backend" }, + ]); + expect(result.aliasMap.get("acme:frontend")).toBe("f"); + expect(result.aliasMap.get("acme:backend")).toBe("b"); + }); + + test("multiple orgs with unique project slugs - no collision", () => { + const result = buildOrgAwareAliases([ + { org: "org1", project: "frontend" }, + { org: "org2", project: "backend" }, + ]); + expect(result.aliasMap.get("org1:frontend")).toBe("f"); + expect(result.aliasMap.get("org2:backend")).toBe("b"); + }); + + test("same project slug in different orgs - collision", () => { + const result = buildOrgAwareAliases([ + { org: "org1", project: "dashboard" }, + { org: "org2", project: "dashboard" }, + ]); + + const alias1 = result.aliasMap.get("org1:dashboard"); + const alias2 = result.aliasMap.get("org2:dashboard"); + + // Both should have org-prefixed format with colon + expect(alias1).toContain(":"); + expect(alias2).toContain(":"); + + // Must be different aliases + expect(alias1).not.toBe(alias2); + + // Should follow pattern: orgPrefix:projectPrefix + expect(alias1).toMatch(/^o.*:d$/); + expect(alias2).toMatch(/^o.*:d$/); + }); + + test("collision with distinct org names", () => { + const result = buildOrgAwareAliases([ + { org: "acme-corp", project: "api" }, + { org: "bigco", project: "api" }, + ]); + + const alias1 = result.aliasMap.get("acme-corp:api"); + const alias2 = result.aliasMap.get("bigco:api"); + + // Org prefixes should be unique: "a" vs "b" + expect(alias1).toBe("a:a"); + expect(alias2).toBe("b:a"); + }); + + test("mixed - some colliding, some unique project slugs", () => { + const result = buildOrgAwareAliases([ + { org: "org1", project: "dashboard" }, + { org: "org2", project: "dashboard" }, + { org: "org1", project: "backend" }, + ]); + + // dashboard collides → org-prefixed aliases with colon + const dashAlias1 = result.aliasMap.get("org1:dashboard"); + const dashAlias2 = result.aliasMap.get("org2:dashboard"); + expect(dashAlias1).toContain(":"); + expect(dashAlias2).toContain(":"); + expect(dashAlias1).not.toBe(dashAlias2); + + // backend is unique → simple alias + const backendAlias = result.aliasMap.get("org1:backend"); + expect(backendAlias).toBe("b"); + }); + + test("preserves common word prefix stripping for unique projects", () => { + const result = buildOrgAwareAliases([ + { org: "acme", project: "spotlight-electron" }, + { org: "acme", project: "spotlight-website" }, + ]); + // Common prefix "spotlight-" is stripped internally, resulting in short aliases + expect(result.aliasMap.get("acme:spotlight-electron")).toBe("e"); + expect(result.aliasMap.get("acme:spotlight-website")).toBe("w"); + }); + + test("handles single project", () => { + const result = buildOrgAwareAliases([{ org: "acme", project: "frontend" }]); + expect(result.aliasMap.get("acme:frontend")).toBe("f"); + }); + + test("collision with similar org names uses longer prefixes", () => { + const result = buildOrgAwareAliases([ + { org: "organization1", project: "app" }, + { org: "organization2", project: "app" }, + ]); + + const alias1 = result.aliasMap.get("organization1:app"); + const alias2 = result.aliasMap.get("organization2:app"); + + // Both orgs start with "organization", so prefixes need to be longer + expect(alias1).not.toBe(alias2); + // Should include enough of the org to be unique + expect(alias1).toMatch(/:a$/); // ends with project prefix + expect(alias2).toMatch(/:a$/); + }); + + test("multiple collisions across same orgs", () => { + const result = buildOrgAwareAliases([ + { org: "org1", project: "api" }, + { org: "org2", project: "api" }, + { org: "org1", project: "web" }, + { org: "org2", project: "web" }, + ]); + + // All four should have org-prefixed aliases with colon + expect(result.aliasMap.get("org1:api")).toContain(":"); + expect(result.aliasMap.get("org2:api")).toContain(":"); + expect(result.aliasMap.get("org1:web")).toContain(":"); + expect(result.aliasMap.get("org2:web")).toContain(":"); + + // All should be unique + const aliases = [...result.aliasMap.values()]; + const uniqueAliases = new Set(aliases); + expect(uniqueAliases.size).toBe(aliases.length); + }); + + test("collision with same-letter project slugs uses unique project prefixes", () => { + // Both "api" and "app" start with "a" - need unique project prefixes + const result = buildOrgAwareAliases([ + { org: "org1", project: "api" }, + { org: "org2", project: "api" }, + { org: "org1", project: "app" }, + { org: "org2", project: "app" }, + ]); + + // All four should be unique + const aliases = [...result.aliasMap.values()]; + const uniqueAliases = new Set(aliases); + expect(uniqueAliases.size).toBe(4); + + // api and app should have different project prefixes (not both "a") + const org1Api = result.aliasMap.get("org1:api"); + const org1App = result.aliasMap.get("org1:app"); + expect(org1Api).not.toBe(org1App); + + // Project prefixes should distinguish api vs app + // e.g., "o1:api" vs "o1:app" + expect(org1Api).toMatch(/^o.*:api$/); + expect(org1App).toMatch(/^o.*:app$/); + }); +}); diff --git a/test/lib/formatters/human.test.ts b/test/lib/formatters/human.test.ts index b4ce0370c..09fe3bda1 100644 --- a/test/lib/formatters/human.test.ts +++ b/test/lib/formatters/human.test.ts @@ -3,7 +3,12 @@ */ import { describe, expect, test } from "bun:test"; -import { formatShortId } from "../../../src/lib/formatters/human.js"; +import { + formatIssueListHeader, + formatIssueRow, + formatShortId, +} from "../../../src/lib/formatters/human.js"; +import type { SentryIssue } from "../../../src/types/index.js"; // Helper to strip ANSI codes for content testing function stripAnsi(str: string): string { @@ -78,29 +83,26 @@ describe("formatShortId", () => { expect(stripAnsi(result)).toBe("FRONTEND-G"); }); - test("formats spotlight-website with stripped prefix", () => { + test("formats spotlight-website in multi-project mode", () => { const result = formatShortId("SPOTLIGHT-WEBSITE-2A", { projectSlug: "spotlight-website", projectAlias: "w", - strippedPrefix: "spotlight-", }); expect(stripAnsi(result)).toBe("SPOTLIGHT-WEBSITE-2A"); }); - test("formats spotlight-electron with stripped prefix", () => { + test("formats spotlight-electron in multi-project mode", () => { const result = formatShortId("SPOTLIGHT-ELECTRON-4Y", { projectSlug: "spotlight-electron", projectAlias: "e", - strippedPrefix: "spotlight-", }); expect(stripAnsi(result)).toBe("SPOTLIGHT-ELECTRON-4Y"); }); - test("formats spotlight (no stripped prefix applies) correctly", () => { + test("formats spotlight in multi-project mode", () => { const result = formatShortId("SPOTLIGHT-73", { projectSlug: "spotlight", projectAlias: "s", - strippedPrefix: "spotlight-", }); expect(stripAnsi(result)).toBe("SPOTLIGHT-73"); }); @@ -117,7 +119,6 @@ describe("formatShortId", () => { const result = formatShortId("spotlight-website-2a", { projectSlug: "spotlight-website", projectAlias: "w", - strippedPrefix: "spotlight-", }); expect(stripAnsi(result)).toBe("SPOTLIGHT-WEBSITE-2A"); }); @@ -147,7 +148,6 @@ describe("formatShortId", () => { const result = formatShortId("spotlight-website-2a", { projectSlug: "spotlight-website", projectAlias: "w", - strippedPrefix: "spotlight-", }); expect(stripAnsi(result)).toBe("SPOTLIGHT-WEBSITE-2A"); }); @@ -191,7 +191,6 @@ describe("formatShortId", () => { const formatted = formatShortId(shortId, { projectSlug: "spotlight-website", projectAlias: "w", - strippedPrefix: "spotlight-", }); expect(stripAnsi(formatted).length).toBe(shortId.length); }); @@ -216,7 +215,6 @@ describe("formatShortId", () => { const formatted = formatShortId(shortId, { projectSlug: "spotlight-electron", projectAlias: "e", - strippedPrefix: "spotlight-", }); expect(stripAnsi(formatted).length).toBe(shortId.length); }); @@ -227,7 +225,6 @@ describe("formatShortId", () => { const result = formatShortId("SPOTLIGHT-ELECTRON-4Y", { projectSlug: "spotlight-electron", projectAlias: "e", - strippedPrefix: "spotlight-", }); expect(stripAnsi(result)).toBe("SPOTLIGHT-ELECTRON-4Y"); }); @@ -236,7 +233,6 @@ describe("formatShortId", () => { const result = formatShortId("SPOTLIGHT-WEBSITE-2C", { projectSlug: "spotlight-website", projectAlias: "w", - strippedPrefix: "spotlight-", }); expect(stripAnsi(result)).toBe("SPOTLIGHT-WEBSITE-2C"); }); @@ -245,7 +241,6 @@ describe("formatShortId", () => { const result = formatShortId("SPOTLIGHT-73", { projectSlug: "spotlight", projectAlias: "s", - strippedPrefix: "spotlight-", }); expect(stripAnsi(result)).toBe("SPOTLIGHT-73"); }); @@ -297,11 +292,10 @@ describe("formatShortId", () => { } }); - test("multi-project mode applies formatting to alias and suffix", () => { + test("multi-project mode applies formatting to suffix", () => { const result = formatShortId("SPOTLIGHT-ELECTRON-4Y", { projectSlug: "spotlight-electron", projectAlias: "e", - strippedPrefix: "spotlight-", }); expect(stripAnsi(result)).toBe("SPOTLIGHT-ELECTRON-4Y"); if (colorsEnabled) { @@ -317,3 +311,86 @@ describe("formatShortId", () => { }); }); }); + +describe("formatIssueListHeader", () => { + test("single project mode does not include ALIAS column", () => { + const header = formatIssueListHeader(false); + expect(header).not.toContain("ALIAS"); + expect(header).toContain("LEVEL"); + expect(header).toContain("SHORT ID"); + expect(header).toContain("COUNT"); + expect(header).toContain("SEEN"); + expect(header).toContain("TITLE"); + }); + + test("multi-project mode includes ALIAS column", () => { + const header = formatIssueListHeader(true); + expect(header).toContain("ALIAS"); + expect(header).toContain("LEVEL"); + expect(header).toContain("SHORT ID"); + expect(header).toContain("COUNT"); + expect(header).toContain("SEEN"); + expect(header).toContain("TITLE"); + }); + + test("ALIAS column comes before SHORT ID in multi-project mode", () => { + const header = formatIssueListHeader(true); + const aliasIndex = header.indexOf("ALIAS"); + const shortIdIndex = header.indexOf("SHORT ID"); + expect(aliasIndex).toBeLessThan(shortIdIndex); + }); +}); + +describe("formatIssueRow", () => { + const mockIssue: SentryIssue = { + id: "123", + shortId: "DASHBOARD-A3", + title: "Test issue", + level: "error", + status: "unresolved", + count: "42", + userCount: 10, + firstSeen: "2024-01-01T00:00:00Z", + lastSeen: "2024-01-02T00:00:00Z", + permalink: "https://sentry.io/issues/123", + }; + + test("single project mode does not include alias column", () => { + const row = formatIssueRow(mockIssue, 80, { + projectSlug: "dashboard", + }); + // Should not have alias shorthand format + expect(stripAnsi(row)).not.toContain("o1:d-a3"); + }); + + test("multi-project mode includes alias column", () => { + const row = formatIssueRow(mockIssue, 120, { + projectSlug: "dashboard", + projectAlias: "o1:d", + isMultiProject: true, + }); + // Should contain the alias shorthand + expect(stripAnsi(row)).toContain("o1:d-a3"); + }); + + test("alias shorthand is lowercase", () => { + const row = formatIssueRow(mockIssue, 120, { + projectSlug: "dashboard", + projectAlias: "o1:d", + isMultiProject: true, + }); + // The alias shorthand should be lowercase + expect(stripAnsi(row)).toContain("o1:d-a3"); + expect(stripAnsi(row)).not.toContain("O1:D-A3"); + }); + + test("unique alias format works in multi-project mode", () => { + const row = formatIssueRow(mockIssue, 120, { + projectSlug: "dashboard", + projectAlias: "d", + isMultiProject: true, + }); + // Should contain simple alias shorthand + expect(stripAnsi(row)).toContain("d-a3"); + }); +});