Skip to content

Commit 826fcc4

Browse files
committed
ref(commands): migrate issue/list to listCommand factory
Eliminates ~150 lines of boilerplate by using the shared listCommand factory. All resolution, alias, sorting, and fetch logic stays in the fetch callback. JSON output uses formatJson override; footer tip uses function form to pick the right tip text based on single vs multi-project mode.
1 parent 0478ca6 commit 826fcc4

File tree

1 file changed

+70
-148
lines changed

1 file changed

+70
-148
lines changed

src/commands/issue/list.ts

Lines changed: 70 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import {
1313
listProjects,
1414
} from "../../lib/api-client.js";
1515
import { parseOrgProjectArg } from "../../lib/arg-parsing.js";
16-
import { buildCommand, numberParser } from "../../lib/command.js";
1716
import {
1817
clearProjectAliases,
1918
setProjectAliases,
@@ -28,6 +27,10 @@ import {
2827
muted,
2928
writeJson,
3029
} from "../../lib/formatters/index.js";
30+
import {
31+
listCommand as buildListCommand,
32+
type ListResult,
33+
} from "../../lib/list-helpers.js";
3134
import {
3235
type ResolvedTarget,
3336
resolveAllTargets,
@@ -38,13 +41,6 @@ import type {
3841
Writer,
3942
} from "../../types/index.js";
4043

41-
type ListFlags = {
42-
readonly query?: string;
43-
readonly limit: number;
44-
readonly sort: "date" | "new" | "freq" | "user";
45-
readonly json: boolean;
46-
};
47-
4844
type SortValue = "date" | "new" | "freq" | "user";
4945

5046
const VALID_SORT_VALUES: SortValue[] = ["date", "new", "freq", "user"];
@@ -55,32 +51,6 @@ const USAGE_HINT = "sentry issue list <org>/<project>";
5551
/** Error type classification for fetch failures */
5652
type FetchErrorType = "permission" | "network" | "unknown";
5753

58-
function parseSort(value: string): SortValue {
59-
if (!VALID_SORT_VALUES.includes(value as SortValue)) {
60-
throw new Error(
61-
`Invalid sort value. Must be one of: ${VALID_SORT_VALUES.join(", ")}`
62-
);
63-
}
64-
return value as SortValue;
65-
}
66-
67-
/**
68-
* Write the issue list header with column titles.
69-
*
70-
* @param stdout - Output writer
71-
* @param title - Section title
72-
* @param isMultiProject - Whether to show ALIAS column for multi-project mode
73-
*/
74-
function writeListHeader(
75-
stdout: Writer,
76-
title: string,
77-
isMultiProject = false
78-
): void {
79-
stdout.write(`${title}:\n\n`);
80-
stdout.write(muted(`${formatIssueListHeader(isMultiProject)}\n`));
81-
stdout.write(`${divider(isMultiProject ? 96 : 80)}\n`);
82-
}
83-
8454
/** Issue with formatting options attached */
8555
type IssueWithOptions = {
8656
issue: SentryIssue;
@@ -100,34 +70,6 @@ function writeIssueRows(
10070
}
10171
}
10272

103-
/**
104-
* Write footer with usage tip.
105-
*
106-
* @param stdout - Output writer
107-
* @param mode - Display mode: 'single' (one project), 'multi' (multiple projects), or 'none'
108-
*/
109-
function writeListFooter(
110-
stdout: Writer,
111-
mode: "single" | "multi" | "none"
112-
): void {
113-
switch (mode) {
114-
case "single":
115-
stdout.write(
116-
"\nTip: Use 'sentry issue view <ID>' to view details (bold part works as shorthand).\n"
117-
);
118-
break;
119-
case "multi":
120-
stdout.write(
121-
"\nTip: Use 'sentry issue view <ALIAS>' to view details (see ALIAS column).\n"
122-
);
123-
break;
124-
default:
125-
stdout.write(
126-
"\nTip: Use 'sentry issue view <SHORT_ID>' to view issue details.\n"
127-
);
128-
}
129-
}
130-
13173
/** Issue list with target context */
13274
type IssueListResult = {
13375
target: ResolvedTarget;
@@ -380,7 +322,23 @@ async function fetchIssuesForTarget(
380322
}
381323
}
382324

383-
export const listCommand = buildCommand({
325+
/**
326+
* Pick the footer tip text based on display mode.
327+
*/
328+
function pickFooterTip(
329+
isMultiProject: boolean,
330+
hasSingleProject: boolean
331+
): string {
332+
if (isMultiProject) {
333+
return "Tip: Use 'sentry issue view <ALIAS>' to view details (see ALIAS column).";
334+
}
335+
if (hasSingleProject) {
336+
return "Tip: Use 'sentry issue view <ID>' to view details (bold part works as shorthand).";
337+
}
338+
return "Tip: Use 'sentry issue view <SHORT_ID>' to view issue details.";
339+
}
340+
341+
export const listCommand = buildListCommand<IssueWithOptions>({
384342
docs: {
385343
brief: "List issues in a project",
386344
fullDescription:
@@ -392,53 +350,19 @@ export const listCommand = buildCommand({
392350
" sentry issue list <project> # find project across all orgs\n\n" +
393351
"In monorepos with multiple Sentry projects, shows issues from all detected projects.",
394352
},
395-
parameters: {
396-
positional: {
397-
kind: "tuple",
398-
parameters: [
399-
{
400-
placeholder: "target",
401-
brief: "Target: <org>/<project>, <org>/, or <project>",
402-
parse: String,
403-
optional: true,
404-
},
405-
],
406-
},
407-
flags: {
408-
query: {
409-
kind: "parsed",
410-
parse: String,
411-
brief: "Search query (Sentry search syntax)",
412-
optional: true,
413-
},
414-
limit: {
415-
kind: "parsed",
416-
parse: numberParser,
417-
brief: "Maximum number of issues to return",
418-
// Stricli requires string defaults (raw CLI input); numberParser converts to number
419-
default: "10",
420-
},
421-
sort: {
422-
kind: "parsed",
423-
parse: parseSort,
424-
brief: "Sort by: date, new, freq, user",
425-
default: "date" as const,
426-
},
427-
json: {
428-
kind: "boolean",
429-
brief: "Output as JSON",
430-
default: false,
431-
},
432-
},
433-
aliases: { q: "query", s: "sort", n: "limit" },
353+
limit: 10,
354+
features: {
355+
query: true,
356+
sort: VALID_SORT_VALUES,
357+
},
358+
positional: {
359+
placeholder: "target",
360+
brief: "Target: <org>/<project>, <org>/, or <project>",
361+
optional: true,
434362
},
435363
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: command entry point with inherent complexity
436-
async func(
437-
this: SentryContext,
438-
flags: ListFlags,
439-
target?: string
440-
): Promise<void> {
441-
const { stdout, cwd, setContext } = this;
364+
async fetch(this: SentryContext, flags, target) {
365+
const { cwd, setContext } = this;
442366

443367
// Parse positional argument to determine resolution strategy
444368
const parsed = parseOrgProjectArg(target);
@@ -465,12 +389,12 @@ export const listCommand = buildCommand({
465389
}
466390

467391
// Fetch issues from all targets in parallel
468-
const results = await Promise.all(
392+
const fetchResults = await Promise.all(
469393
targets.map((t) =>
470394
fetchIssuesForTarget(t, {
471395
query: flags.query,
472396
limit: flags.limit,
473-
sort: flags.sort,
397+
sort: (flags.sort as SortValue | undefined) ?? "date",
474398
})
475399
)
476400
);
@@ -479,7 +403,7 @@ export const listCommand = buildCommand({
479403
const validResults: IssueListResult[] = [];
480404
const errorTypes = new Set<FetchErrorType>();
481405

482-
for (const result of results) {
406+
for (const result of fetchResults) {
483407
if (result.success) {
484408
validResults.push(result.data);
485409
} else {
@@ -535,47 +459,45 @@ export const listCommand = buildCommand({
535459
);
536460

537461
// Sort by user preference
462+
const sortValue = (flags.sort as SortValue | undefined) ?? "date";
538463
issuesWithOptions.sort((a, b) =>
539-
getComparator(flags.sort)(a.issue, b.issue)
464+
getComparator(sortValue)(a.issue, b.issue)
540465
);
541466

542-
// JSON output
543-
if (flags.json) {
544-
const allIssues = issuesWithOptions.map((i) => i.issue);
545-
writeJson(stdout, allIssues);
546-
return;
547-
}
548-
549-
if (issuesWithOptions.length === 0) {
550-
stdout.write("No issues found.\n");
551-
if (footer) {
552-
stdout.write(`\n${footer}\n`);
553-
}
554-
return;
555-
}
556-
557-
// Header depends on single vs multiple projects
558-
const title =
467+
// Build title for the header line (written by render)
468+
// Colon suffix matches original output: "Issues in org/project:"
469+
const header =
559470
isSingleProject && firstTarget
560-
? `Issues in ${firstTarget.orgDisplay}/${firstTarget.projectDisplay}`
561-
: `Issues from ${validResults.length} projects`;
562-
563-
writeListHeader(stdout, title, isMultiProject);
564-
471+
? `Issues in ${firstTarget.orgDisplay}/${firstTarget.projectDisplay}:`
472+
: `Issues from ${validResults.length} projects:`;
473+
474+
return {
475+
items: issuesWithOptions,
476+
footer,
477+
skippedSelfHosted,
478+
header,
479+
} satisfies ListResult<IssueWithOptions>;
480+
},
481+
render(items, stdout, _flags) {
482+
const isMultiProject = items[0]?.formatOptions.isMultiProject ?? false;
483+
// The factory already wrote the header title line; write only column headers + divider
484+
stdout.write("\n");
485+
stdout.write(muted(`${formatIssueListHeader(isMultiProject)}\n`));
486+
stdout.write(`${divider(isMultiProject ? 96 : 80)}\n`);
565487
const termWidth = process.stdout.columns || 80;
566-
writeIssueRows(stdout, issuesWithOptions, termWidth);
567-
568-
// Footer mode
569-
let footerMode: "single" | "multi" | "none" = "none";
570-
if (isMultiProject) {
571-
footerMode = "multi";
572-
} else if (isSingleProject) {
573-
footerMode = "single";
574-
}
575-
writeListFooter(stdout, footerMode);
576-
577-
if (footer) {
578-
stdout.write(`\n${footer}\n`);
579-
}
488+
writeIssueRows(stdout, items, termWidth);
489+
},
490+
formatJson(result, stdout) {
491+
writeJson(
492+
stdout,
493+
result.items.map((i) => i.issue)
494+
);
495+
},
496+
footerTip(result) {
497+
const isMultiProject =
498+
result.items[0]?.formatOptions.isMultiProject ?? false;
499+
const isSingleProject = result.items.length > 0 && !isMultiProject;
500+
return pickFooterTip(isMultiProject, isSingleProject);
580501
},
502+
emptyMessage: "No issues found.",
581503
});

0 commit comments

Comments
 (0)