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
66 changes: 58 additions & 8 deletions src/commands/issue/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,26 @@ import {
getIssue,
getIssueByShortId,
getLatestEvent,
isShortId,
} from "../../lib/api-client.js";
import { getProjectByAlias } from "../../lib/config.js";
import { ContextError } from "../../lib/errors.js";
import {
formatEventDetails,
formatIssueDetails,
writeJson,
} from "../../lib/formatters/index.js";
import { resolveOrg } from "../../lib/resolve-target.js";
import {
expandToFullShortId,
isShortId,
isShortSuffix,
parseAliasSuffix,
} from "../../lib/issue-id.js";
import { resolveOrg, resolveOrgAndProject } from "../../lib/resolve-target.js";
import type { SentryEvent, SentryIssue, Writer } from "../../types/index.js";

type GetFlags = {
readonly org?: string;
readonly project?: string;
readonly json: boolean;
};

Expand Down Expand Up @@ -63,7 +70,11 @@ export const getCommand = buildCommand({
fullDescription:
"Retrieve detailed information about a Sentry issue by its ID or short ID. " +
"The latest event is automatically included for full context.\n\n" +
"For short IDs (e.g., SPOTLIGHT-ELECTRON-4D), the organization is resolved from:\n" +
"You can use just the unique suffix (e.g., 'G' instead of 'CRAFT-G') when " +
"project context is available from DSN detection or flags.\n\n" +
"In multi-project mode (after 'issue list'), use alias-suffix format (e.g., 'f-g' " +
"where 'f' is the project alias shown in the list).\n\n" +
"For short IDs, the organization is resolved from:\n" +
" 1. --org flag\n" +
" 2. Config defaults\n" +
" 3. SENTRY_DSN environment variable",
Expand All @@ -73,7 +84,8 @@ export const getCommand = buildCommand({
kind: "tuple",
parameters: [
{
brief: "Issue ID or short ID (e.g., JAVASCRIPT-ABC or 123456)",
brief:
"Issue ID, short ID, suffix, or alias-suffix (e.g., 123456, CRAFT-G, G, or f-g)",
parse: String,
},
],
Expand All @@ -86,6 +98,13 @@ export const getCommand = buildCommand({
"Organization slug (required for short IDs if not auto-detected)",
optional: true,
},
project: {
kind: "parsed",
parse: String,
brief:
"Project slug (required for short suffixes if not auto-detected)",
optional: true,
},
json: {
kind: "boolean",
brief: "Output as JSON",
Expand All @@ -101,18 +120,49 @@ export const getCommand = buildCommand({
const { stdout, cwd } = this;

let issue: SentryIssue;
let resolvedShortId = issueId;

// Check if input matches alias-suffix pattern (e.g., "f-g", "fr-a3")
// and if the alias exists in the cache
const aliasSuffix = parseAliasSuffix(issueId);
const projectEntry = aliasSuffix
? await getProjectByAlias(aliasSuffix.alias)
: null;

// Check if it's a short ID (contains letters) vs numeric ID
if (isShortId(issueId)) {
// Short ID requires organization context
if (aliasSuffix && projectEntry) {
// Valid alias found - expand suffix using the aliased project
resolvedShortId = expandToFullShortId(
aliasSuffix.suffix,
projectEntry.projectSlug
);
issue = await getIssueByShortId(projectEntry.orgSlug, resolvedShortId);
} else if (isShortSuffix(issueId)) {
// Short suffix - try to expand if project context is available
const target = await resolveOrgAndProject({
org: flags.org,
project: flags.project,
cwd,
});

if (target) {
// Expand suffix to full short ID (e.g., "12" → "CRAFT-12")
resolvedShortId = expandToFullShortId(issueId, target.project);
issue = await getIssueByShortId(target.org, resolvedShortId);
} else {
// No project context - treat as numeric ID (will fail if not numeric)
issue = await getIssue(issueId);
}
} else if (isShortId(issueId)) {
// Full short ID (e.g., "CRAFT-G") - normalize to uppercase
resolvedShortId = issueId.toUpperCase();
const resolved = await resolveOrg({ org: flags.org, cwd });
if (!resolved) {
throw new ContextError(
"Organization",
`sentry issue get ${issueId} --org <org-slug>`
);
}
issue = await getIssueByShortId(resolved.org, issueId);
issue = await getIssueByShortId(resolved.org, resolvedShortId);
} else {
// Numeric ID can be fetched directly
issue = await getIssue(issueId);
Expand Down
185 changes: 169 additions & 16 deletions src/commands/issue/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,16 @@

import { buildCommand, numberParser } from "@stricli/core";
import type { SentryContext } from "../../context.js";
import {
findCommonWordPrefix,
findShortestUniquePrefixes,
} from "../../lib/alias.js";
import { listIssues } from "../../lib/api-client.js";
import { clearProjectAliases, setProjectAliases } from "../../lib/config.js";
import { AuthError, ContextError } from "../../lib/errors.js";
import {
divider,
type FormatShortIdOptions,
formatIssueListHeader,
formatIssueRow,
muted,
Expand All @@ -20,7 +26,11 @@ import {
type ResolvedTarget,
resolveAllTargets,
} from "../../lib/resolve-target.js";
import type { SentryIssue, Writer } from "../../types/index.js";
import type {
ProjectAliasEntry,
SentryIssue,
Writer,
} from "../../types/index.js";

type ListFlags = {
readonly org?: string;
Expand Down Expand Up @@ -56,26 +66,51 @@ function writeListHeader(stdout: Writer, title: string): void {
stdout.write(`${divider(80)}\n`);
}

/** Issue with formatting options attached */
type IssueWithOptions = {
issue: SentryIssue;
formatOptions: FormatShortIdOptions;
};

/**
* Write formatted issue rows to stdout.
*/
function writeIssueRows(
stdout: Writer,
issues: SentryIssue[],
issues: IssueWithOptions[],
termWidth: number
): void {
for (const issue of issues) {
stdout.write(`${formatIssueRow(issue, termWidth)}\n`);
for (const { issue, formatOptions } of issues) {
stdout.write(`${formatIssueRow(issue, termWidth, formatOptions)}\n`);
}
}

/**
* Write footer with usage tip.
*
* @param stdout - Output writer
* @param mode - Display mode: 'single' (one project), 'multi' (multiple projects), or 'none'
*/
function writeListFooter(stdout: Writer): void {
stdout.write(
"\nTip: Use 'sentry issue get <SHORT_ID>' to view issue details.\n"
);
function writeListFooter(
stdout: Writer,
mode: "single" | "multi" | "none"
): void {
switch (mode) {
case "single":
stdout.write(
"\nTip: Use 'sentry issue get <ID>' to view details (bold part works as shorthand).\n"
);
break;
case "multi":
stdout.write(
"\nTip: Use 'sentry issue get <alias-suffix>' to view details (e.g., 'f-g').\n"
);
break;
default:
stdout.write(
"\nTip: Use 'sentry issue get <SHORT_ID>' to view issue details.\n"
);
}
}

/** Issue list with target context */
Expand All @@ -84,6 +119,87 @@ type IssueListResult = {
issues: SentryIssue[];
};

/** Result of building project aliases */
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.
* 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
*/
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);

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,
};
Comment thread
betegon marked this conversation as resolved.
}
Comment thread
betegon marked this conversation as resolved.

return { aliasMap, entries, strippedPrefix };
}

/**
* Attach formatting options to each issue based on alias map.
*/
function attachFormatOptions(
results: IssueListResult[],
aliasMap: Map<string, string>,
strippedPrefix: string
): 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,
},
};
})
);
}

/**
* Compare two optional date strings (most recent first).
*/
Expand Down Expand Up @@ -191,6 +307,7 @@ export const listCommand = buildCommand({
},
},
},
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: command entry point with inherent complexity
async func(this: SentryContext, flags: ListFlags): Promise<void> {
const { stdout, cwd } = this;

Expand Down Expand Up @@ -237,17 +354,46 @@ export const listCommand = buildCommand({
);
}

// Merge all issues from all projects and sort by user preference
const allIssues = validResults.flatMap((r) => r.issues);
allIssues.sort(getComparator(flags.sort));
// Determine display mode
const isMultiProject = validResults.length > 1;
const isSingleProject = validResults.length === 1;
const firstTarget = validResults[0]?.target;

// Build project alias map and cache it for multi-project mode
const { aliasMap, entries, strippedPrefix } = isMultiProject
? buildProjectAliasMap(validResults)
: {
aliasMap: new Map<string, string>(),
entries: {},
strippedPrefix: "",
};

if (isMultiProject) {
await setProjectAliases(entries);
Comment thread
betegon marked this conversation as resolved.
} else {
await clearProjectAliases();
}

// Attach formatting options to each issue
const issuesWithOptions = attachFormatOptions(
validResults,
aliasMap,
strippedPrefix
);

// Sort by user preference
issuesWithOptions.sort((a, b) =>
getComparator(flags.sort)(a.issue, b.issue)
);

// JSON output
if (flags.json) {
const allIssues = issuesWithOptions.map((i) => i.issue);
writeJson(stdout, allIssues);
return;
}

if (allIssues.length === 0) {
if (issuesWithOptions.length === 0) {
stdout.write("No issues found.\n");
if (footer) {
stdout.write(`\n${footer}\n`);
Expand All @@ -256,17 +402,24 @@ export const listCommand = buildCommand({
}

// Header depends on single vs multiple projects
const firstTarget = validResults[0]?.target;
const title =
validResults.length === 1 && firstTarget
isSingleProject && firstTarget
? `Issues in ${firstTarget.orgDisplay}/${firstTarget.projectDisplay}`
: `Issues from ${validResults.length} projects`;

writeListHeader(stdout, title);

const termWidth = process.stdout.columns || 80;
writeIssueRows(stdout, allIssues, termWidth);
writeListFooter(stdout);
writeIssueRows(stdout, issuesWithOptions, termWidth);

// Footer mode
let footerMode: "single" | "multi" | "none" = "none";
if (isMultiProject) {
footerMode = "multi";
} else if (isSingleProject) {
footerMode = "single";
}
writeListFooter(stdout, footerMode);

if (footer) {
stdout.write(`\n${footer}\n`);
Expand Down
Loading
Loading