Skip to content

Commit 3918e5b

Browse files
authored
feat(issue-list): improve table display with title wrapping and relative time (#26)
* feat(issue-list): improve table display and add title wrapping Issue list improvements: - Change default limit from 25 to 10 issues per project - Remove status dot column (●) for cleaner output - Add SEEN column with relative time (2h ago, 1d ago, Jan 18) - Increase SHORT ID column width (15 → 22) for monorepo project names - Add title wrapping with proper indentation for long error messages - Build header programmatically for consistent column alignment Other improvements: - Fix api.ts variadic flag type errors (default: [] → optional: true) - Use colors module in auth commands and error output for consistency - Add formatIssueListHeader() and formatRelativeTime() helpers * style: fix lint issues in issue list and formatters
1 parent 6f9dbac commit 3918e5b

8 files changed

Lines changed: 176 additions & 33 deletions

File tree

packages/cli/src/bin.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ import { run } from "@stricli/core";
33
import { app } from "./app.js";
44
import { buildContext } from "./context.js";
55
import { formatError, getExitCode } from "./lib/errors.js";
6+
import { error } from "./lib/formatters/colors.js";
67

78
try {
89
await run(app, process.argv.slice(2), buildContext(process));
9-
} catch (error) {
10-
process.stderr.write(`Error: ${formatError(error)}\n`);
11-
process.exit(getExitCode(error));
10+
} catch (err) {
11+
process.stderr.write(`${error("Error:")} ${formatError(err)}\n`);
12+
process.exit(getExitCode(err));
1213
}

packages/cli/src/commands/api.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
1414

1515
type ApiFlags = {
1616
readonly method: HttpMethod;
17-
readonly field: string[];
18-
readonly header: string[];
17+
readonly field?: string[];
18+
readonly header?: string[];
1919
readonly include: boolean;
2020
readonly silent: boolean;
2121
};
@@ -231,14 +231,14 @@ export const apiCommand = buildCommand({
231231
parse: String,
232232
brief: "Request body field (key=value). Can be repeated.",
233233
variadic: true,
234-
default: [],
234+
optional: true,
235235
},
236236
header: {
237237
kind: "parsed",
238238
parse: String,
239239
brief: "Additional header (Key: Value). Can be repeated.",
240240
variadic: true,
241-
default: [],
241+
optional: true,
242242
},
243243
include: {
244244
kind: "boolean",
@@ -259,9 +259,14 @@ export const apiCommand = buildCommand({
259259
): Promise<void> {
260260
const { stdout } = this;
261261

262-
const body = flags.field?.length > 0 ? parseFields(flags.field) : undefined;
262+
const body =
263+
flags.field && flags.field.length > 0
264+
? parseFields(flags.field)
265+
: undefined;
263266
const headers =
264-
flags.header?.length > 0 ? parseHeaders(flags.header) : undefined;
267+
flags.header && flags.header.length > 0
268+
? parseHeaders(flags.header)
269+
: undefined;
265270

266271
const response = await rawApiRequest(endpoint, {
267272
method: flags.method,

packages/cli/src/commands/auth/login.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
setAuthToken,
1010
} from "../../lib/config.js";
1111
import { AuthError } from "../../lib/errors.js";
12+
import { success } from "../../lib/formatters/colors.js";
1213
import { completeOAuthFlow, performDeviceFlow } from "../../lib/oauth.js";
1314
import { generateQRCode } from "../../lib/qrcode.js";
1415

@@ -76,7 +77,7 @@ export const loginCommand = buildCommand({
7677
);
7778
}
7879

79-
stdout.write("✓ Authenticated with API token\n");
80+
stdout.write(`${success("✓")} Authenticated with API token\n`);
8081
stdout.write(` Config saved to: ${getConfigPath()}\n`);
8182
return;
8283
}
@@ -121,7 +122,7 @@ export const loginCommand = buildCommand({
121122
// Store the token
122123
await completeOAuthFlow(tokenResponse);
123124

124-
stdout.write("✓ Authentication successful!\n");
125+
stdout.write(`${success("✓")} Authentication successful!\n`);
125126
stdout.write(` Config saved to: ${getConfigPath()}\n`);
126127

127128
if (tokenResponse.expires_in) {

packages/cli/src/commands/auth/logout.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import { buildCommand } from "@stricli/core";
88
import type { SentryContext } from "../../context.js";
99
import { clearAuth, getConfigPath, isAuthenticated } from "../../lib/config.js";
10+
import { success } from "../../lib/formatters/colors.js";
1011

1112
export const logoutCommand = buildCommand({
1213
docs: {
@@ -26,7 +27,7 @@ export const logoutCommand = buildCommand({
2627
}
2728

2829
await clearAuth();
29-
stdout.write("✓ Logged out successfully.\n");
30+
stdout.write(`${success("✓")} Logged out successfully.\n`);
3031
stdout.write(` Credentials removed from: ${getConfigPath()}\n`);
3132
},
3233
});

packages/cli/src/commands/auth/status.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
readConfig,
1616
} from "../../lib/config.js";
1717
import { AuthError } from "../../lib/errors.js";
18+
import { error, success } from "../../lib/formatters/colors.js";
1819
import { formatExpiration, maskToken } from "../../lib/formatters/human.js";
1920
import type { SentryConfig, Writer } from "../../types/index.js";
2021

@@ -76,7 +77,7 @@ async function verifyCredentials(
7677
try {
7778
const orgs = await listOrganizations();
7879
stdout.write(
79-
`\n Access verified. You have access to ${orgs.length} organization(s):\n`
80+
`\n${success("✓")} Access verified. You have access to ${orgs.length} organization(s):\n`
8081
);
8182

8283
const maxDisplay = 5;
@@ -86,9 +87,9 @@ async function verifyCredentials(
8687
if (orgs.length > maxDisplay) {
8788
stdout.write(` ... and ${orgs.length - maxDisplay} more\n`);
8889
}
89-
} catch (error) {
90-
const message = error instanceof Error ? error.message : String(error);
91-
stderr.write(`\n Could not verify credentials: ${message}\n`);
90+
} catch (err) {
91+
const message = err instanceof Error ? err.message : String(err);
92+
stderr.write(`\n${error("✗")} Could not verify credentials: ${message}\n`);
9293
}
9394
}
9495

@@ -120,7 +121,7 @@ export const statusCommand = buildCommand({
120121
throw new AuthError("not_authenticated");
121122
}
122123

123-
stdout.write("Status: Authenticated \n\n");
124+
stdout.write(`Status: Authenticated ${success("✓")}\n\n`);
124125

125126
writeTokenInfo(stdout, config, flags.showToken);
126127
await writeDefaults(stdout);

packages/cli/src/commands/issue/list.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { listIssues } from "../../lib/api-client.js";
1010
import { ContextError } from "../../lib/errors.js";
1111
import {
1212
divider,
13+
formatIssueListHeader,
1314
formatIssueRow,
1415
info,
1516
muted,
@@ -56,16 +57,20 @@ function writeListHeader(
5657
count: number
5758
): void {
5859
stdout.write(`Issues in ${org}/${project} (showing ${count}):\n\n`);
59-
stdout.write(muted("● LEVEL SHORT ID COUNT TITLE\n"));
60+
stdout.write(muted(`${formatIssueListHeader()}\n`));
6061
stdout.write(`${divider(80)}\n`);
6162
}
6263

6364
/**
6465
* Write formatted issue rows to stdout.
6566
*/
66-
function writeIssueRows(stdout: Writer, issues: SentryIssue[]): void {
67+
function writeIssueRows(
68+
stdout: Writer,
69+
issues: SentryIssue[],
70+
termWidth: number
71+
): void {
6772
for (const issue of issues) {
68-
stdout.write(`${formatIssueRow(issue)}\n`);
73+
stdout.write(`${formatIssueRow(issue, termWidth)}\n`);
6974
}
7075
}
7176

@@ -110,7 +115,7 @@ export const listCommand = buildCommand({
110115
parse: numberParser,
111116
brief: "Maximum number of issues to return",
112117
// Stricli requires string defaults (raw CLI input); numberParser converts to number
113-
default: "25",
118+
default: "10",
114119
},
115120
sort: {
116121
kind: "parsed",
@@ -166,7 +171,10 @@ export const listCommand = buildCommand({
166171
target.projectDisplay,
167172
issues.length
168173
);
169-
writeIssueRows(stdout, issues);
174+
175+
// Get terminal width for wrapping long titles
176+
const termWidth = process.stdout.columns || 80;
177+
writeIssueRows(stdout, issues, termWidth);
170178
writeListFooter(stdout);
171179

172180
// Show detection source if auto-detected

packages/cli/src/lib/formatters/human.ts

Lines changed: 127 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,10 @@ export function formatStatusLabel(status: string | undefined): string {
9494
if (!status) {
9595
return `${statusColor("●", status)} Unknown`;
9696
}
97-
return STATUS_LABELS[status as IssueStatus] ?? `${statusColor("●", status)} Unknown`;
97+
return (
98+
STATUS_LABELS[status as IssueStatus] ??
99+
`${statusColor("●", status)} Unknown`
100+
);
98101
}
99102

100103
// ─────────────────────────────────────────────────────────────────────────────
@@ -152,21 +155,134 @@ export function divider(length = 80, char = "─"): string {
152155
return muted(char.repeat(length));
153156
}
154157

158+
// ─────────────────────────────────────────────────────────────────────────────
159+
// Date Formatting
160+
// ─────────────────────────────────────────────────────────────────────────────
161+
162+
/**
163+
* Format a date as relative time (e.g., "2h ago", "3d ago") or short date for older dates.
164+
*
165+
* - < 1 hour: "Xm ago"
166+
* - < 24 hours: "Xh ago"
167+
* - < 3 days: "Xd ago"
168+
* - >= 3 days: Short date (e.g., "Jan 18")
169+
*/
170+
export function formatRelativeTime(dateString: string | undefined): string {
171+
if (!dateString) {
172+
return muted("—").padEnd(10);
173+
}
174+
175+
const date = new Date(dateString);
176+
const now = Date.now();
177+
const diffMs = now - date.getTime();
178+
const diffMins = Math.floor(diffMs / 60_000);
179+
const diffHours = Math.floor(diffMs / 3_600_000);
180+
const diffDays = Math.floor(diffMs / 86_400_000);
181+
182+
let text: string;
183+
if (diffMins < 60) {
184+
text = `${diffMins}m ago`;
185+
} else if (diffHours < 24) {
186+
text = `${diffHours}h ago`;
187+
} else if (diffDays < 3) {
188+
text = `${diffDays}d ago`;
189+
} else {
190+
// Short date: "Jan 18"
191+
text = date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
192+
}
193+
194+
return text.padEnd(10);
195+
}
196+
155197
// ─────────────────────────────────────────────────────────────────────────────
156198
// Issue Formatting
157199
// ─────────────────────────────────────────────────────────────────────────────
158200

201+
/** Column widths for issue list table */
202+
const COL_LEVEL = 7;
203+
const COL_SHORT_ID = 22;
204+
const COL_COUNT = 5;
205+
const COL_SEEN = 10;
206+
207+
/** Column where title starts (sum of all previous columns + separators) */
208+
const TITLE_START_COL =
209+
COL_LEVEL + 1 + COL_SHORT_ID + 1 + COL_COUNT + 2 + COL_SEEN + 2; // = 50
210+
159211
/**
160-
* Format a single issue for list display (one line)
212+
* Format the header row for issue list table.
213+
* Uses same column widths as data rows to ensure alignment.
161214
*/
162-
export function formatIssueRow(issue: SentryIssue): string {
163-
const status = formatStatusIcon(issue.status);
164-
const levelText = (issue.level ?? "unknown").toUpperCase().padEnd(7);
215+
export function formatIssueListHeader(): string {
216+
return (
217+
"LEVEL".padEnd(COL_LEVEL) +
218+
" " +
219+
"SHORT ID".padEnd(COL_SHORT_ID) +
220+
" " +
221+
"COUNT".padStart(COL_COUNT) +
222+
" " +
223+
"SEEN".padEnd(COL_SEEN) +
224+
" " +
225+
"TITLE"
226+
);
227+
}
228+
229+
/**
230+
* Wrap long text with indentation for continuation lines.
231+
* Breaks at word boundaries when possible.
232+
*
233+
* @param text - Text to wrap
234+
* @param startCol - Column where text starts (for indenting continuation lines)
235+
* @param termWidth - Terminal width
236+
*/
237+
function wrapTitle(text: string, startCol: number, termWidth: number): string {
238+
const availableWidth = termWidth - startCol;
239+
240+
// No wrapping needed or terminal too narrow
241+
if (text.length <= availableWidth || availableWidth < 20) {
242+
return text;
243+
}
244+
245+
const indent = " ".repeat(startCol);
246+
const lines: string[] = [];
247+
let remaining = text;
248+
249+
while (remaining.length > 0) {
250+
if (remaining.length <= availableWidth) {
251+
lines.push(remaining);
252+
break;
253+
}
254+
255+
// Find break point (prefer word boundary)
256+
let breakAt = availableWidth;
257+
const lastSpace = remaining.lastIndexOf(" ", availableWidth);
258+
if (lastSpace > availableWidth * 0.5) {
259+
breakAt = lastSpace;
260+
}
261+
262+
lines.push(remaining.slice(0, breakAt).trimEnd());
263+
remaining = remaining.slice(breakAt).trimStart();
264+
}
265+
266+
// First line has no indent, continuation lines do
267+
return lines.join(`\n${indent}`);
268+
}
269+
270+
/**
271+
* Format a single issue for list display.
272+
* Wraps long titles with proper indentation.
273+
*
274+
* @param issue - Issue to format
275+
* @param termWidth - Terminal width for wrapping (default 80)
276+
*/
277+
export function formatIssueRow(issue: SentryIssue, termWidth = 80): string {
278+
const levelText = (issue.level ?? "unknown").toUpperCase().padEnd(COL_LEVEL);
165279
const level = levelColor(levelText, issue.level);
166-
const count = `${issue.count}`.padStart(5);
167-
const shortId = issue.shortId.padEnd(15);
280+
const shortId = issue.shortId.padEnd(COL_SHORT_ID);
281+
const count = `${issue.count}`.padStart(COL_COUNT);
282+
const seen = formatRelativeTime(issue.lastSeen);
283+
const title = wrapTitle(issue.title, TITLE_START_COL, termWidth);
168284

169-
return `${status} ${level} ${shortId} ${count} ${issue.title}`;
285+
return `${level} ${shortId} ${count} ${seen} ${title}`;
170286
}
171287

172288
/**
@@ -178,7 +294,9 @@ export function formatIssueDetails(issue: SentryIssue): string[] {
178294
// Header
179295
lines.push(`${issue.shortId}: ${issue.title}`);
180296
lines.push(
181-
muted("═".repeat(Math.min(80, issue.title.length + issue.shortId.length + 2)))
297+
muted(
298+
"═".repeat(Math.min(80, issue.title.length + issue.shortId.length + 2))
299+
)
182300
);
183301
lines.push("");
184302

packages/cli/test/e2e/issue.test.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,15 @@ describe("sentry issue list", () => {
7979
await setAuthToken(TEST_TOKEN);
8080

8181
const result = await runCli(
82-
["issue", "list", "--org", TEST_ORG, "--project", TEST_PROJECT, "--json"],
82+
[
83+
"issue",
84+
"list",
85+
"--org",
86+
TEST_ORG,
87+
"--project",
88+
TEST_PROJECT,
89+
"--json",
90+
],
8391
{
8492
env: { SENTRY_CLI_CONFIG_DIR: testConfigDir },
8593
}
@@ -90,7 +98,7 @@ describe("sentry issue list", () => {
9098
const data = JSON.parse(result.stdout);
9199
expect(Array.isArray(data)).toBe(true);
92100
},
93-
{ timeout: 15000 }
101+
{ timeout: 15_000 }
94102
);
95103
});
96104

0 commit comments

Comments
 (0)