|
6 | 6 | */ |
7 | 7 |
|
8 | 8 | import type { OrgReleaseResponse } from "@sentry/api"; |
9 | | -import { listReleasesPaginated } from "../../lib/api-client.js"; |
| 9 | +import type { SentryContext } from "../../context.js"; |
| 10 | +import { |
| 11 | + listReleasesPaginated, |
| 12 | + type ReleaseSortValue, |
| 13 | +} from "../../lib/api-client.js"; |
| 14 | +import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; |
10 | 15 | import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; |
| 16 | +import { CommandOutput } from "../../lib/formatters/output.js"; |
11 | 17 | import { type Column, formatTable } from "../../lib/formatters/table.js"; |
12 | 18 | import { formatRelativeTime } from "../../lib/formatters/time-utils.js"; |
13 | 19 | import { |
14 | | - buildOrgListCommand, |
15 | | - type OrgListCommandDocs, |
| 20 | + buildListCommand, |
| 21 | + buildListLimitFlag, |
| 22 | + LIST_BASE_ALIASES, |
| 23 | + LIST_TARGET_POSITIONAL, |
16 | 24 | } from "../../lib/list-command.js"; |
17 | | -import type { OrgListConfig } from "../../lib/org-list.js"; |
| 25 | +import { |
| 26 | + dispatchOrgScopedList, |
| 27 | + jsonTransformListResult, |
| 28 | + type ListResult, |
| 29 | + type OrgListConfig, |
| 30 | +} from "../../lib/org-list.js"; |
18 | 31 | import { fmtPct } from "./format.js"; |
19 | 32 |
|
20 | 33 | export const PAGINATION_KEY = "release-list"; |
21 | 34 |
|
22 | 35 | type ReleaseWithOrg = OrgReleaseResponse & { orgSlug?: string }; |
23 | 36 |
|
| 37 | +/** Valid values for the `--sort` flag. */ |
| 38 | +const VALID_SORT_VALUES: ReleaseSortValue[] = [ |
| 39 | + "date", |
| 40 | + "sessions", |
| 41 | + "users", |
| 42 | + "crash_free_sessions", |
| 43 | + "crash_free_users", |
| 44 | +]; |
| 45 | + |
| 46 | +const DEFAULT_SORT: ReleaseSortValue = "date"; |
| 47 | + |
| 48 | +/** |
| 49 | + * Parse and validate the `--sort` flag value. |
| 50 | + * |
| 51 | + * @throws Error when value is not one of the accepted sort keys |
| 52 | + */ |
| 53 | +function parseSortFlag(value: string): ReleaseSortValue { |
| 54 | + if (VALID_SORT_VALUES.includes(value as ReleaseSortValue)) { |
| 55 | + return value as ReleaseSortValue; |
| 56 | + } |
| 57 | + throw new Error( |
| 58 | + `Invalid sort value. Must be one of: ${VALID_SORT_VALUES.join(", ")}` |
| 59 | + ); |
| 60 | +} |
| 61 | + |
24 | 62 | /** |
25 | 63 | * Extract health data from the first project that has it. |
26 | 64 | * |
@@ -60,52 +98,126 @@ const RELEASE_COLUMNS: Column<ReleaseWithOrg>[] = [ |
60 | 98 | { header: "DEPLOYS", value: (r) => String(r.deployCount ?? 0) }, |
61 | 99 | ]; |
62 | 100 |
|
63 | | -const releaseListConfig: OrgListConfig<OrgReleaseResponse, ReleaseWithOrg> = { |
64 | | - paginationKey: PAGINATION_KEY, |
65 | | - entityName: "release", |
66 | | - entityPlural: "releases", |
67 | | - commandPrefix: "sentry release list", |
68 | | - // listForOrg fetches a buffer page for multi-org fan-out. |
69 | | - // The framework truncates results to --limit after aggregation. |
70 | | - // health=true to populate per-project adoption/crash-free metrics. |
71 | | - listForOrg: async (org) => { |
72 | | - const { data } = await listReleasesPaginated(org, { |
73 | | - perPage: 100, |
74 | | - health: true, |
75 | | - }); |
76 | | - return data; |
77 | | - }, |
78 | | - listPaginated: (org, opts) => |
79 | | - listReleasesPaginated(org, { ...opts, health: true }), |
80 | | - withOrg: (release, orgSlug) => ({ ...release, orgSlug }), |
81 | | - displayTable: (releases: ReleaseWithOrg[]) => |
82 | | - formatTable(releases, RELEASE_COLUMNS), |
83 | | -}; |
| 101 | +/** |
| 102 | + * Build the OrgListConfig with the given sort value baked into API calls. |
| 103 | + * |
| 104 | + * We build this per-invocation so the `--sort` flag value flows into |
| 105 | + * `listForOrg` and `listPaginated` closures. |
| 106 | + */ |
| 107 | +function buildReleaseListConfig( |
| 108 | + sort: ReleaseSortValue |
| 109 | +): OrgListConfig<OrgReleaseResponse, ReleaseWithOrg> { |
| 110 | + return { |
| 111 | + paginationKey: PAGINATION_KEY, |
| 112 | + entityName: "release", |
| 113 | + entityPlural: "releases", |
| 114 | + commandPrefix: "sentry release list", |
| 115 | + listForOrg: async (org) => { |
| 116 | + const { data } = await listReleasesPaginated(org, { |
| 117 | + perPage: 100, |
| 118 | + health: true, |
| 119 | + sort, |
| 120 | + }); |
| 121 | + return data; |
| 122 | + }, |
| 123 | + listPaginated: (org, opts) => |
| 124 | + listReleasesPaginated(org, { ...opts, health: true, sort }), |
| 125 | + withOrg: (release, orgSlug) => ({ ...release, orgSlug }), |
| 126 | + displayTable: (releases: ReleaseWithOrg[]) => |
| 127 | + formatTable(releases, RELEASE_COLUMNS), |
| 128 | + }; |
| 129 | +} |
| 130 | + |
| 131 | +/** Format a ListResult as human-readable output. */ |
| 132 | +function formatListHuman(result: ListResult<ReleaseWithOrg>): string { |
| 133 | + const parts: string[] = []; |
| 134 | + |
| 135 | + if (result.items.length === 0) { |
| 136 | + if (result.hint) { |
| 137 | + parts.push(result.hint); |
| 138 | + } |
| 139 | + return parts.join("\n"); |
| 140 | + } |
| 141 | + |
| 142 | + parts.push(formatTable(result.items, RELEASE_COLUMNS)); |
| 143 | + |
| 144 | + if (result.header) { |
| 145 | + parts.push(`\n${result.header}`); |
| 146 | + } |
84 | 147 |
|
85 | | -const docs: OrgListCommandDocs = { |
86 | | - brief: "List releases with adoption and health metrics", |
87 | | - fullDescription: |
88 | | - "List releases in an organization with adoption and crash-free metrics.\n\n" + |
89 | | - "Health data (adoption %, crash-free session rate) is shown per-release\n" + |
90 | | - "from the first project that has session data.\n\n" + |
91 | | - "Target specification:\n" + |
92 | | - " sentry release list # auto-detect from DSN or config\n" + |
93 | | - " sentry release list <org>/ # list all releases in org (paginated)\n" + |
94 | | - " sentry release list <org>/<proj> # list releases in org (project context)\n" + |
95 | | - " sentry release list <org> # list releases in org\n\n" + |
96 | | - "Pagination:\n" + |
97 | | - " sentry release list <org>/ -c next # fetch next page\n" + |
98 | | - " sentry release list <org>/ -c prev # fetch previous page\n\n" + |
99 | | - "Examples:\n" + |
100 | | - " sentry release list # auto-detect or list all\n" + |
101 | | - " sentry release list my-org/ # list releases in my-org (paginated)\n" + |
102 | | - " sentry release list --limit 10\n" + |
103 | | - " sentry release list --json\n\n" + |
104 | | - "Alias: `sentry releases` → `sentry release list`", |
| 148 | + return parts.join(""); |
| 149 | +} |
| 150 | + |
| 151 | +type ListFlags = { |
| 152 | + readonly limit: number; |
| 153 | + readonly sort: ReleaseSortValue; |
| 154 | + readonly json: boolean; |
| 155 | + readonly cursor?: string; |
| 156 | + readonly fresh: boolean; |
| 157 | + readonly fields?: string[]; |
105 | 158 | }; |
106 | 159 |
|
107 | | -export const listCommand = buildOrgListCommand( |
108 | | - releaseListConfig, |
109 | | - docs, |
110 | | - "release" |
111 | | -); |
| 160 | +export const listCommand = buildListCommand("release", { |
| 161 | + docs: { |
| 162 | + brief: "List releases with adoption and health metrics", |
| 163 | + fullDescription: |
| 164 | + "List releases in an organization with adoption and crash-free metrics.\n\n" + |
| 165 | + "Health data (adoption %, crash-free session rate) is shown per-release\n" + |
| 166 | + "from the first project that has session data.\n\n" + |
| 167 | + "Sort options:\n" + |
| 168 | + " date # by creation date (default)\n" + |
| 169 | + " sessions # by total sessions\n" + |
| 170 | + " users # by total users\n" + |
| 171 | + " crash_free_sessions # by crash-free session rate\n" + |
| 172 | + " crash_free_users # by crash-free user rate\n\n" + |
| 173 | + "Target specification:\n" + |
| 174 | + " sentry release list # auto-detect from DSN or config\n" + |
| 175 | + " sentry release list <org>/ # list all releases in org (paginated)\n" + |
| 176 | + " sentry release list <org>/<proj> # list releases in org (project context)\n" + |
| 177 | + " sentry release list <org> # list releases in org\n\n" + |
| 178 | + "Pagination:\n" + |
| 179 | + " sentry release list <org>/ -c next # fetch next page\n" + |
| 180 | + " sentry release list <org>/ -c prev # fetch previous page\n\n" + |
| 181 | + "Examples:\n" + |
| 182 | + " sentry release list # auto-detect or list all\n" + |
| 183 | + " sentry release list my-org/ # list releases in my-org (paginated)\n" + |
| 184 | + " sentry release list --sort crash_free_sessions\n" + |
| 185 | + " sentry release list --limit 10\n" + |
| 186 | + " sentry release list --json\n\n" + |
| 187 | + "Alias: `sentry releases` → `sentry release list`", |
| 188 | + }, |
| 189 | + output: { |
| 190 | + human: formatListHuman, |
| 191 | + jsonTransform: (result: ListResult<ReleaseWithOrg>, fields?: string[]) => |
| 192 | + jsonTransformListResult(result, fields), |
| 193 | + }, |
| 194 | + parameters: { |
| 195 | + positional: LIST_TARGET_POSITIONAL, |
| 196 | + flags: { |
| 197 | + limit: buildListLimitFlag("releases"), |
| 198 | + sort: { |
| 199 | + kind: "parsed" as const, |
| 200 | + parse: parseSortFlag, |
| 201 | + brief: |
| 202 | + "Sort order: date, sessions, users, crash_free_sessions, crash_free_users", |
| 203 | + default: DEFAULT_SORT, |
| 204 | + }, |
| 205 | + }, |
| 206 | + aliases: { ...LIST_BASE_ALIASES, s: "sort" }, |
| 207 | + }, |
| 208 | + async *func(this: SentryContext, flags: ListFlags, target?: string) { |
| 209 | + const { cwd } = this; |
| 210 | + const parsed = parseOrgProjectArg(target); |
| 211 | + const config = buildReleaseListConfig(flags.sort); |
| 212 | + const result = await dispatchOrgScopedList({ |
| 213 | + config, |
| 214 | + cwd, |
| 215 | + flags, |
| 216 | + parsed, |
| 217 | + orgSlugMatchBehavior: "redirect", |
| 218 | + }); |
| 219 | + yield new CommandOutput(result); |
| 220 | + const hint = result.items.length > 0 ? result.hint : undefined; |
| 221 | + return { hint }; |
| 222 | + }, |
| 223 | +}); |
0 commit comments