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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"@clack/prompts": "0.11.0",
"@hono/node-server": "^2.0.0",
"@mastra/client-js": "^1.4.0",
"@sentry/api": "^0.141.0",
"@sentry/api": "^0.180.0",
"@sentry/core": "10.50.0",
"@sentry/node-core": "10.50.0",
"@sentry/sqlish": "^1.0.0",
Expand Down
12 changes: 6 additions & 6 deletions plugins/sentry-cli/skills/sentry-cli/references/issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ List issues in a project
| `id` | string | Numeric issue ID |
| `shortId` | string | Human-readable short ID (e.g. PROJ-ABC) |
| `title` | string | Issue title |
| `culprit` | string | Culprit string |
| `culprit` | string \| null | Culprit string |
| `count` | string | Total event count |
| `userCount` | number | Number of affected users |
| `firstSeen` | string | First occurrence (ISO 8601) |
| `lastSeen` | string | Most recent occurrence (ISO 8601) |
| `firstSeen` | string \| null | First occurrence (ISO 8601) |
| `lastSeen` | string \| null | Most recent occurrence (ISO 8601) |
| `level` | string | Severity level |
| `status` | string | Issue status |
| `permalink` | string | URL to the issue in Sentry |
Expand Down Expand Up @@ -190,11 +190,11 @@ View details of a specific issue
| `id` | string | Numeric issue ID |
| `shortId` | string | Human-readable short ID (e.g. PROJ-ABC) |
| `title` | string | Issue title |
| `culprit` | string | Culprit string |
| `culprit` | string \| null | Culprit string |
| `count` | string | Total event count |
| `userCount` | number | Number of affected users |
| `firstSeen` | string | First occurrence (ISO 8601) |
| `lastSeen` | string | Most recent occurrence (ISO 8601) |
| `firstSeen` | string \| null | First occurrence (ISO 8601) |
| `lastSeen` | string \| null | Most recent occurrence (ISO 8601) |
| `level` | string | Severity level |
| `status` | string | Issue status |
| `permalink` | string | URL to the issue in Sentry |
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 6 additions & 3 deletions src/commands/issue/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,17 +369,20 @@ function getComparator(
): (a: SentryIssue, b: SentryIssue) => number {
switch (sort) {
case "date":
return (a, b) => compareDates(a.lastSeen, b.lastSeen);
return (a, b) =>
compareDates(a.lastSeen ?? undefined, b.lastSeen ?? undefined);
case "new":
return (a, b) => compareDates(a.firstSeen, b.firstSeen);
return (a, b) =>
compareDates(a.firstSeen ?? undefined, b.firstSeen ?? undefined);
case "freq":
return (a, b) =>
Number.parseInt(b.count ?? "0", 10) -
Number.parseInt(a.count ?? "0", 10);
case "user":
return (a, b) => (b.userCount ?? 0) - (a.userCount ?? 0);
default:
return (a, b) => compareDates(a.lastSeen, b.lastSeen);
return (a, b) =>
compareDates(a.lastSeen ?? undefined, b.lastSeen ?? undefined);
}
}

Expand Down
4 changes: 2 additions & 2 deletions src/lib/api/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export async function getLatestEvent(
...config,
path: {
organization_id_or_slug: orgSlug,
issue_id: Number(issueId),
issue_id: issueId,
event_id: "latest",
},
});
Expand Down Expand Up @@ -237,7 +237,7 @@ export async function listIssueEvents(
...config,
path: {
organization_id_or_slug: orgSlug,
issue_id: Number(issueId),
issue_id: issueId,
},
query: {
query: query || undefined,
Expand Down
10 changes: 8 additions & 2 deletions src/lib/formatters/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,16 +88,22 @@ export const header = (text: string): string => muted(text);

const STATUS_COLORS: Record<IssueStatus, (text: string) => string> = {
resolved: green,
resolvedInNextRelease: green,
unresolved: yellow,
ignored: muted,
muted,
};
Comment thread
sentry-warden[bot] marked this conversation as resolved.

/**
* Color text based on issue status (case-insensitive)
*/
export function statusColor(text: string, status: string | undefined): string {
const normalizedStatus = status?.toLowerCase() as IssueStatus;
const colorFn = STATUS_COLORS[normalizedStatus] ?? STATUS_COLORS.unresolved;
// Try exact match first (handles camelCase like resolvedInNextRelease),
// then fall back to lowercase (handles unexpected uppercase from older instances).
const colorFn =
STATUS_COLORS[status as IssueStatus] ??
STATUS_COLORS[status?.toLowerCase() as IssueStatus] ??
STATUS_COLORS.unresolved;
return colorFn(text);
}

Expand Down
8 changes: 6 additions & 2 deletions src/lib/formatters/human.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,18 @@ const FIXABILITY_TAGS: Record<FixabilityTier, Parameters<typeof colorTag>[0]> =

const STATUS_ICONS: Record<IssueStatus, string> = {
resolved: colorTag("green", "✓"),
resolvedInNextRelease: colorTag("green", "✓"),
unresolved: colorTag("yellow", "●"),
ignored: colorTag("muted", "−"),
muted: colorTag("muted", "−"),
};

const STATUS_LABELS: Record<IssueStatus, string> = {
resolved: `${colorTag("green", "✓")} Resolved`,
resolvedInNextRelease: `${colorTag("green", "✓")} Resolved in Next Release`,
unresolved: `${colorTag("yellow", "●")} Unresolved`,
ignored: `${colorTag("muted", "−")} Ignored`,
muted: `${colorTag("muted", "−")} Muted`,
};

/** Maximum features to display before truncating with "... and N more" */
Expand Down Expand Up @@ -628,12 +632,12 @@ export function writeIssueTable(
// SEEN — lastSeen
{
header: "SEEN",
value: ({ issue }) => formatRelativeTime(issue.lastSeen),
value: ({ issue }) => formatRelativeTime(issue.lastSeen ?? undefined),
},
// AGE — firstSeen
{
header: "AGE",
value: ({ issue }) => formatRelativeTime(issue.firstSeen),
value: ({ issue }) => formatRelativeTime(issue.firstSeen ?? undefined),
},
];

Expand Down
19 changes: 6 additions & 13 deletions src/types/sentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,24 +110,17 @@ export type SentryProject = Partial<SdkProjectListItem> & {
*/
export const ISSUE_STATUSES = [
"resolved",
"resolvedInNextRelease",
"unresolved",
"ignored",
"muted",
] as const satisfies readonly NonNullable<SdkIssueDetail["status"]>[];
export type IssueStatus = (typeof ISSUE_STATUSES)[number];

/**
* Compile-time exhaustiveness check for `ISSUE_STATUSES`.
* If the SDK ever adds a status that isn't in the tuple, this resolves to
* `never` and the assignment fails to typecheck. The tuple-wrapping
* (`[X] extends [Y]`) prevents distributive inference so the check fires
* on the union as a whole.
*/
type _IssueStatusParity = [NonNullable<SdkIssueDetail["status"]>] extends [
IssueStatus,
]
? true
: never;
const _ISSUE_STATUS_PARITY: _IssueStatusParity = true;
// Note: a reverse exhaustiveness check (SDK → ISSUE_STATUSES) is not possible here
// because RetrieveAnIssueResponses is a union of all HTTP response types, one of which
// has `status: string` (loose), making SdkIssueDetail["status"] resolve to `string`.
// The `satisfies` above catches the forward direction (invalid values in our tuple).

export const ISSUE_LEVELS = [
"fatal",
Expand Down
66 changes: 66 additions & 0 deletions test/commands/issue/list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1319,6 +1319,72 @@ describe("issue list: collapse parameter optimization", () => {
});
});

// ---------------------------------------------------------------------------
// getComparator — sort comparator with null-safe date coercion
// ---------------------------------------------------------------------------

import { __testing } from "../../../src/commands/issue/list.js";

const { getComparator } = __testing;

import type { SentryIssue } from "../../../src/types/index.js";

function makeIssue(overrides: Partial<SentryIssue> = {}): SentryIssue {
return {
id: "1",
shortId: "TEST-1",
title: "Test",
status: "unresolved",
level: "error",
count: "10",
userCount: 1,
firstSeen: "2024-01-01T00:00:00Z",
lastSeen: "2024-01-02T00:00:00Z",
permalink: "https://sentry.io/issues/1",
...overrides,
};
}

describe("getComparator", () => {
test("sort=new compares by firstSeen with null safety", () => {
const cmp = getComparator("new");
const older = makeIssue({ firstSeen: "2024-01-01T00:00:00Z" });
const newer = makeIssue({ firstSeen: "2024-01-02T00:00:00Z" });
expect(cmp(newer, older)).toBeLessThan(0);
expect(cmp(older, newer)).toBeGreaterThan(0);
});

test("sort=new handles null firstSeen", () => {
const cmp = getComparator("new");
const nullDate = makeIssue({ firstSeen: null as unknown as string });
const withDate = makeIssue({ firstSeen: "2024-01-01T00:00:00Z" });
// should not throw
expect(() => cmp(nullDate, withDate)).not.toThrow();
});

test("sort=date handles null lastSeen (covers ?? null branch)", () => {
const cmp = getComparator("date");
const nullDate = makeIssue({ lastSeen: null as unknown as string });
const withDate = makeIssue({ lastSeen: "2024-01-01T00:00:00Z" });
expect(cmp(nullDate, withDate)).not.toBe(undefined);
});

test("sort=new handles null firstSeen (covers ?? null branch)", () => {
const cmp = getComparator("new");
const nullDate = makeIssue({ firstSeen: null as unknown as string });
const withDate = makeIssue({ firstSeen: "2024-01-01T00:00:00Z" });
expect(cmp(nullDate, withDate)).not.toBe(undefined);
});

test("unknown sort value hits default branch (falls back to lastSeen)", () => {
// A value not in the switch exercises the default: compareDates(lastSeen) case
const cmp = getComparator("unknown_sort" as unknown as "date");
const older = makeIssue({ lastSeen: "2024-01-01T00:00:00Z" });
const newer = makeIssue({ lastSeen: "2024-01-02T00:00:00Z" });
expect(cmp(newer, older)).toBeLessThan(0);
});
});

// ---------------------------------------------------------------------------
// sanitizeQuery — tests moved to test/lib/search-query.test.ts
// ---------------------------------------------------------------------------
17 changes: 17 additions & 0 deletions test/lib/formatters/colors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,23 @@ describe("statusColor", () => {
expect(result).toContain("\x1b[");
expect(stripAnsi(result)).toBe("text");
});

test("resolvedInNextRelease → green-styled text", () => {
const result = statusColor("text", "resolvedInNextRelease");
expect(result).toContain("\x1b[");
expect(stripAnsi(result)).toBe("text");
});

test("muted → muted-styled text", () => {
const result = statusColor("text", "muted");
expect(result).toContain("\x1b[");
expect(stripAnsi(result)).toBe("text");
});

test("unknown status defaults to unresolved styling", () => {
const result = statusColor("text", "somethingNew");
expect(stripAnsi(result)).toBe("text");
});
});

describe("levelColor", () => {
Expand Down
66 changes: 66 additions & 0 deletions test/lib/formatters/human.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
formatIssueSubtitle,
formatProjectCreated,
formatShortId,
formatStatusIcon,
formatStatusLabel,
formatUpgradeResult,
formatUserIdentity,
type IssueTableRow,
Expand Down Expand Up @@ -398,6 +400,24 @@ describe("writeIssueTable", () => {
// default impact (0.5)*0.6 + 0.75*0.4 = 0.30+0.30 = 0.60 → 60%
expect(text).toContain("60%");
});

test("renders table with null lastSeen and firstSeen (covers ?? null branches)", () => {
const { writer, output } = capture();
const rows: IssueTableRow[] = [
{
issue: {
...mockIssue,
lastSeen: null as unknown as string,
firstSeen: null as unknown as string,
},
orgSlug: "test-org",
formatOptions: { projectSlug: "dashboard" },
},
];
writeIssueTable(writer, rows);
// Should render without throwing; SEEN/AGE columns receive undefined
expect(stripAnsi(output())).toContain("Test issue");
});
});

describe("substatusLabel", () => {
Expand Down Expand Up @@ -848,3 +868,49 @@ describe("formatUpgradeResult", () => {
});
});
});

describe("formatStatusIcon", () => {
test("resolved shows green icon", () => {
expect(stripFormatting(formatStatusIcon("resolved"))).toContain("✓");
});

test("unresolved shows yellow icon", () => {
expect(stripFormatting(formatStatusIcon("unresolved"))).toContain("●");
});

test("resolvedInNextRelease shows green icon", () => {
expect(
stripFormatting(formatStatusIcon("resolvedInNextRelease"))
).toContain("✓");
});

test("muted shows muted icon", () => {
expect(stripFormatting(formatStatusIcon("muted"))).toContain("−");
});

test("unknown status falls back to yellow icon", () => {
expect(stripFormatting(formatStatusIcon("unknown"))).toContain("●");
});
});

describe("formatStatusLabel", () => {
test("resolved → Resolved label", () => {
expect(stripFormatting(formatStatusLabel("resolved"))).toContain(
"Resolved"
);
});

test("resolvedInNextRelease → Resolved in Next Release label", () => {
expect(
stripFormatting(formatStatusLabel("resolvedInNextRelease"))
).toContain("Resolved in Next Release");
});

test("muted → Muted label", () => {
expect(stripFormatting(formatStatusLabel("muted"))).toContain("Muted");
});

test("unknown status falls back to Unknown label", () => {
expect(stripFormatting(formatStatusLabel("unknown"))).toContain("Unknown");
});
});
Loading