Skip to content

Commit eabdc72

Browse files
betegonclaude
andcommitted
refactor(dashboard): align list/view commands to codebase patterns
- Convert list from buildListCommand to buildCommand with return-based output - Extract formatDashboardListHuman for human-readable table rendering - Add --fresh flag to both list and view commands - Update list tests to include fresh flag Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5d6d389 commit eabdc72

File tree

3 files changed

+113
-79
lines changed

3 files changed

+113
-79
lines changed

src/commands/dashboard/list.ts

Lines changed: 68 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -8,51 +8,63 @@ import type { SentryContext } from "../../context.js";
88
import { listDashboards } from "../../lib/api-client.js";
99
import { parseOrgProjectArg } from "../../lib/arg-parsing.js";
1010
import { openInBrowser } from "../../lib/browser.js";
11-
import { ContextError } from "../../lib/errors.js";
12-
import { writeFooter, writeJson } from "../../lib/formatters/index.js";
11+
import { buildCommand } from "../../lib/command.js";
1312
import { escapeMarkdownCell } from "../../lib/formatters/markdown.js";
1413
import { type Column, writeTable } from "../../lib/formatters/table.js";
1514
import {
16-
buildListCommand,
17-
LIST_TARGET_POSITIONAL,
15+
applyFreshFlag,
16+
FRESH_ALIASES,
17+
FRESH_FLAG,
1818
} from "../../lib/list-command.js";
19-
import { resolveOrg } from "../../lib/resolve-target.js";
2019
import { buildDashboardsListUrl } from "../../lib/sentry-urls.js";
2120
import type { DashboardListItem } from "../../types/dashboard.js";
21+
import type { Writer } from "../../types/index.js";
22+
import { resolveOrgFromTarget } from "./resolve.js";
2223

2324
type ListFlags = {
2425
readonly web: boolean;
26+
readonly fresh: boolean;
2527
readonly json: boolean;
2628
readonly fields?: string[];
2729
};
2830

29-
/** Resolve org slug from parsed target argument */
30-
async function resolveOrgFromTarget(
31-
parsed: ReturnType<typeof parseOrgProjectArg>,
32-
cwd: string
33-
): Promise<string> {
34-
switch (parsed.type) {
35-
case "explicit":
36-
case "org-all":
37-
return parsed.org;
38-
case "project-search":
39-
case "auto-detect": {
40-
const resolved = await resolveOrg({ cwd });
41-
if (!resolved) {
42-
throw new ContextError("Organization", "sentry dashboard list <org>/");
43-
}
44-
return resolved.org;
45-
}
46-
default: {
47-
const _exhaustive: never = parsed;
48-
throw new Error(
49-
`Unexpected parsed type: ${(_exhaustive as { type: string }).type}`
50-
);
51-
}
31+
/**
32+
* Format dashboard list for human-readable terminal output.
33+
*
34+
* Renders a table with ID, title, and widget count columns.
35+
* Returns "No dashboards found." for empty results.
36+
*/
37+
function formatDashboardListHuman(dashboards: DashboardListItem[]): string {
38+
if (dashboards.length === 0) {
39+
return "No dashboards found.";
5240
}
41+
42+
type DashboardRow = {
43+
id: string;
44+
title: string;
45+
widgets: string;
46+
};
47+
48+
const rows: DashboardRow[] = dashboards.map((d) => ({
49+
id: d.id,
50+
title: escapeMarkdownCell(d.title),
51+
widgets: String(d.widgetDisplay?.length ?? 0),
52+
}));
53+
54+
const columns: Column<DashboardRow>[] = [
55+
{ header: "ID", value: (r) => r.id },
56+
{ header: "TITLE", value: (r) => r.title },
57+
{ header: "WIDGETS", value: (r) => r.widgets },
58+
];
59+
60+
const parts: string[] = [];
61+
const buffer: Writer = { write: (s) => parts.push(s) };
62+
writeTable(buffer, rows, columns);
63+
64+
return parts.join("").trimEnd();
5365
}
5466

55-
export const listCommand = buildListCommand("dashboard", {
67+
export const listCommand = buildCommand({
5668
docs: {
5769
brief: "List dashboards",
5870
fullDescription:
@@ -63,66 +75,52 @@ export const listCommand = buildListCommand("dashboard", {
6375
" sentry dashboard list --json\n" +
6476
" sentry dashboard list --web",
6577
},
66-
output: "json",
78+
output: { json: true, human: formatDashboardListHuman },
6779
parameters: {
68-
positional: LIST_TARGET_POSITIONAL,
80+
positional: {
81+
kind: "tuple",
82+
parameters: [
83+
{
84+
placeholder: "org/project",
85+
brief:
86+
"<org>/ (all projects), <org>/<project>, or <project> (search)",
87+
parse: String,
88+
optional: true,
89+
},
90+
],
91+
},
6992
flags: {
7093
web: {
7194
kind: "boolean",
7295
brief: "Open in browser",
7396
default: false,
7497
},
98+
fresh: FRESH_FLAG,
7599
},
76-
aliases: { w: "web" },
100+
aliases: { ...FRESH_ALIASES, w: "web" },
77101
},
78-
async func(
79-
this: SentryContext,
80-
flags: ListFlags,
81-
target?: string
82-
): Promise<void> {
83-
const { stdout, cwd } = this;
102+
async func(this: SentryContext, flags: ListFlags, target?: string) {
103+
applyFreshFlag(flags);
104+
const { cwd } = this;
84105

85106
const parsed = parseOrgProjectArg(target);
86-
const orgSlug = await resolveOrgFromTarget(parsed, cwd);
107+
const orgSlug = await resolveOrgFromTarget(
108+
parsed,
109+
cwd,
110+
"sentry dashboard list <org>/"
111+
);
87112

88113
if (flags.web) {
89114
await openInBrowser(buildDashboardsListUrl(orgSlug), "dashboards");
90115
return;
91116
}
92117

93118
const dashboards = await listDashboards(orgSlug);
119+
const url = buildDashboardsListUrl(orgSlug);
94120

95-
if (flags.json) {
96-
writeJson(stdout, dashboards, flags.fields);
97-
return;
98-
}
99-
100-
if (dashboards.length === 0) {
101-
stdout.write("No dashboards found.\n");
102-
return;
103-
}
104-
105-
type DashboardRow = {
106-
id: string;
107-
title: string;
108-
widgets: string;
121+
return {
122+
data: dashboards,
123+
hint: dashboards.length > 0 ? `Dashboards: ${url}` : undefined,
109124
};
110-
111-
const rows: DashboardRow[] = dashboards.map((d: DashboardListItem) => ({
112-
id: d.id,
113-
title: escapeMarkdownCell(d.title),
114-
widgets: String(d.widgetDisplay?.length ?? 0),
115-
}));
116-
117-
const columns: Column<DashboardRow>[] = [
118-
{ header: "ID", value: (r) => r.id },
119-
{ header: "TITLE", value: (r) => r.title },
120-
{ header: "WIDGETS", value: (r) => r.widgets },
121-
];
122-
123-
writeTable(stdout, rows, columns);
124-
125-
const url = buildDashboardsListUrl(orgSlug);
126-
writeFooter(stdout, `Dashboards: ${url}`);
127125
},
128126
});

src/commands/dashboard/view.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import { parseOrgProjectArg } from "../../lib/arg-parsing.js";
1010
import { openInBrowser } from "../../lib/browser.js";
1111
import { buildCommand } from "../../lib/command.js";
1212
import { formatDashboardView } from "../../lib/formatters/human.js";
13+
import {
14+
applyFreshFlag,
15+
FRESH_ALIASES,
16+
FRESH_FLAG,
17+
} from "../../lib/list-command.js";
1318
import { buildDashboardUrl } from "../../lib/sentry-urls.js";
1419
import type { DashboardDetail } from "../../types/dashboard.js";
1520
import {
@@ -20,6 +25,7 @@ import {
2025

2126
type ViewFlags = {
2227
readonly web: boolean;
28+
readonly fresh: boolean;
2329
readonly json: boolean;
2430
readonly fields?: string[];
2531
};
@@ -57,10 +63,12 @@ export const viewCommand = buildCommand({
5763
brief: "Open in browser",
5864
default: false,
5965
},
66+
fresh: FRESH_FLAG,
6067
},
61-
aliases: { w: "web" },
68+
aliases: { ...FRESH_ALIASES, w: "web" },
6269
},
6370
async func(this: SentryContext, flags: ViewFlags, ...args: string[]) {
71+
applyFreshFlag(flags);
6472
const { cwd } = this;
6573

6674
const { dashboardRef, targetArg } = parseDashboardPositionalArgs(args);

test/commands/dashboard/list.test.ts

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,11 @@ describe("dashboard list command", () => {
9292

9393
const { context, stdoutWrite } = createMockContext();
9494
const func = await listCommand.loader();
95-
await func.call(context, { json: true, web: false }, undefined);
95+
await func.call(
96+
context,
97+
{ json: true, web: false, fresh: false },
98+
undefined
99+
);
96100

97101
const output = stdoutWrite.mock.calls.map((c) => c[0]).join("");
98102
const parsed = JSON.parse(output);
@@ -109,7 +113,11 @@ describe("dashboard list command", () => {
109113

110114
const { context, stdoutWrite } = createMockContext();
111115
const func = await listCommand.loader();
112-
await func.call(context, { json: true, web: false }, undefined);
116+
await func.call(
117+
context,
118+
{ json: true, web: false, fresh: false },
119+
undefined
120+
);
113121

114122
const output = stdoutWrite.mock.calls.map((c) => c[0]).join("");
115123
expect(JSON.parse(output)).toEqual([]);
@@ -121,7 +129,11 @@ describe("dashboard list command", () => {
121129

122130
const { context, stdoutWrite } = createMockContext();
123131
const func = await listCommand.loader();
124-
await func.call(context, { json: false, web: false }, undefined);
132+
await func.call(
133+
context,
134+
{ json: false, web: false, fresh: false },
135+
undefined
136+
);
125137

126138
const output = stdoutWrite.mock.calls.map((c) => c[0]).join("");
127139
expect(output).toContain("ID");
@@ -137,7 +149,11 @@ describe("dashboard list command", () => {
137149

138150
const { context, stdoutWrite } = createMockContext();
139151
const func = await listCommand.loader();
140-
await func.call(context, { json: false, web: false }, undefined);
152+
await func.call(
153+
context,
154+
{ json: false, web: false, fresh: false },
155+
undefined
156+
);
141157

142158
const output = stdoutWrite.mock.calls.map((c) => c[0]).join("");
143159
expect(output).toContain("No dashboards found.");
@@ -149,7 +165,11 @@ describe("dashboard list command", () => {
149165

150166
const { context, stdoutWrite } = createMockContext();
151167
const func = await listCommand.loader();
152-
await func.call(context, { json: false, web: false }, undefined);
168+
await func.call(
169+
context,
170+
{ json: false, web: false, fresh: false },
171+
undefined
172+
);
153173

154174
const output = stdoutWrite.mock.calls.map((c) => c[0]).join("");
155175
expect(output).toContain("dashboards");
@@ -162,7 +182,11 @@ describe("dashboard list command", () => {
162182

163183
const { context } = createMockContext();
164184
const func = await listCommand.loader();
165-
await func.call(context, { json: true, web: false }, "my-org/");
185+
await func.call(
186+
context,
187+
{ json: true, web: false, fresh: false },
188+
"my-org/"
189+
);
166190

167191
expect(listDashboardsSpy).toHaveBeenCalledWith("my-org");
168192
});
@@ -174,7 +198,7 @@ describe("dashboard list command", () => {
174198
const func = await listCommand.loader();
175199

176200
await expect(
177-
func.call(context, { json: false, web: false }, undefined)
201+
func.call(context, { json: false, web: false, fresh: false }, undefined)
178202
).rejects.toThrow("Organization");
179203
});
180204

@@ -183,7 +207,11 @@ describe("dashboard list command", () => {
183207

184208
const { context } = createMockContext();
185209
const func = await listCommand.loader();
186-
await func.call(context, { json: false, web: true }, undefined);
210+
await func.call(
211+
context,
212+
{ json: false, web: true, fresh: false },
213+
undefined
214+
);
187215

188216
expect(openInBrowserSpy).toHaveBeenCalled();
189217
expect(listDashboardsSpy).not.toHaveBeenCalled();

0 commit comments

Comments
 (0)