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
96 changes: 45 additions & 51 deletions src/commands/issue/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -104,7 +109,7 @@ function writeListFooter(
break;
case "multi":
stdout.write(
"\nTip: Use 'sentry issue get <alias-suffix>' to view details (e.g., 'f-g').\n"
"\nTip: Use 'sentry issue get <ALIAS>' to view details (see ALIAS column).\n"
);
break;
default:
Expand All @@ -124,77 +129,67 @@ type IssueListResult = {
type AliasMapResult = {
aliasMap: Map<string, string>;
entries: Record<string, ProjectAliasEntry>;
/** 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<string, string>();
const entries: Record<string, ProjectAliasEntry> = {};

// 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<string, string>();
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<string, string>,
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,
},
};
})
Expand Down Expand Up @@ -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<string, string>(),
entries: {},
strippedPrefix: "",
};

if (isMultiProject) {
Expand All @@ -381,7 +375,7 @@ export const listCommand = buildCommand({
const issuesWithOptions = attachFormatOptions(
validResults,
aliasMap,
strippedPrefix
isMultiProject
);

// Sort by user preference
Expand Down Expand Up @@ -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);
Expand Down
160 changes: 160 additions & 0 deletions src/lib/alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
};

/** Internal: Groups pairs by project slug and identifies collisions */
function groupByProjectSlug(pairs: OrgProjectPair[]): {
projectToOrgs: Map<string, Set<string>>;
collidingSlugs: Set<string>;
uniqueSlugs: Set<string>;
} {
const projectToOrgs = new Map<string, Set<string>>();
for (const { org, project } of pairs) {
const orgs = projectToOrgs.get(project) ?? new Set();
orgs.add(org);
projectToOrgs.set(project, orgs);
}

const collidingSlugs = new Set<string>();
const uniqueSlugs = new Set<string>();
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<string>,
aliasMap: Map<string, string>
): 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<string, string>();

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<string, Set<string>>,
collidingSlugs: Set<string>,
aliasMap: Map<string, string>
): void {
// Get all orgs involved in collisions
const collidingOrgs = new Set<string>();
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<string, string>();

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