Skip to content
Draft
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
7 changes: 4 additions & 3 deletions apps/cli-docs/src/fragments/commands/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,10 @@ Most of the time you don't need to switch: commands pick the stored account
that fits what they target, without changing the active account:

- **Organization** — when the target organization isn't in the active
account's organization list (cached at login and by `sentry org list`) but
another stored account has used it, that account is used. This includes
`oNNNN` IDs from DSNs.
account's organization list (cached at login and by `sentry org list`,
which lists every stored account's organizations) but another stored
account has used it, that account is used. This includes `oNNNN` IDs from
DSNs.
- **Server** — `SENTRY_HOST`/`SENTRY_URL`, `.sentryclirc`, or a pasted Sentry
URL for another account's server (for example a self-hosted issue link) uses
the account stored for that server.
Expand Down
20 changes: 20 additions & 0 deletions apps/cli-docs/src/fragments/commands/org.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,26 @@ my-org My Organization owner
another-org Another Org member
```

With several stored accounts (see `sentry auth list`), `sentry org list` lists
the organizations of every logged-in account together, each fetched with that
account's own credentials on its own server, plus an `ACCOUNT` column. An
organization reachable from several accounts on the same server is listed once,
under the active account if it is one of them, else the most recently used.
Accounts whose login fails are skipped with a warning. `SENTRY_HOST`/`SENTRY_URL` limits the list to accounts on that server,
and `--account` to a single account. The stored active account never changes.

```
$ sentry org list
SLUG NAME ACCOUNT
my-org My Organization jane@example.com on sentry.io
acme-internal Acme Internal jane@acme.com on sentry.acme.com
```

```bash
# Only one account's organizations
sentry org list --account jane@acme.com
```

```bash
# View organization details
sentry org view my-org
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ List organizations
**Flags:**
- `-n, --limit <value> - Maximum number of organizations to list - (default: "25")`
- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data`
- `--account <value> - Only list organizations of this stored account (email, username, or account id)`

### `sentry org view [<org>]`

Expand All @@ -33,6 +34,9 @@ View details of an organization
# List organizations
sentry org list

# Only one account's organizations
sentry org list --account jane@acme.com

# View organization details
sentry org view my-org

Expand Down
258 changes: 238 additions & 20 deletions packages/cli/src/commands/org/list.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,27 @@
/**
* sentry org list
*
* List organizations the user has access to.
* List organizations the user has access to — across every stored account
* when several are logged in.
*/

import type { SentryContext } from "../../context.js";
import { resolveAccountSelector } from "../../lib/account-selection.js";
import { listOrganizationsUncached } from "../../lib/api-client.js";
import { buildCommand } from "../../lib/command.js";
import { DEFAULT_SENTRY_HOST } from "../../lib/constants.js";
import {
type AccountSummary,
isSameServer,
toAccountSummary,
} from "../../lib/db/accounts.js";
import {
listUsableAccountsInScope,
withStoredAccount,
} from "../../lib/db/auth.js";
import { getAllOrgRegions } from "../../lib/db/regions.js";
import { ApiError, AuthError } from "../../lib/errors.js";
import { formatAccountLabel } from "../../lib/formatters/accounts.js";
import { escapeMarkdownCell } from "../../lib/formatters/markdown.js";
import { CommandOutput } from "../../lib/formatters/output.js";
import { type Column, writeTable } from "../../lib/formatters/table.js";
Expand All @@ -26,17 +39,40 @@ type ListFlags = {
readonly limit: number;
readonly json: boolean;
readonly fresh: boolean;
readonly account?: string;
readonly fields?: string[];
};

/** The stored account an organization was listed under (no secrets). */
type OrgListAccount = {
id: string;
host?: string;
email?: string;
username?: string;
/** Human label, e.g. `jane@acme.com on sentry.io`. */
label: string;
};

/**
* An organization enriched with an optional region display name.
* An organization enriched with an optional region display name and the
* stored account it was listed under.
*
* The `region` field is only present when the user's orgs span multiple
* regions (e.g. both US and EU). It is included in JSON output as well,
* providing region context to machine consumers.
* regions (e.g. both US and EU). `account` is only present when orgs from
* several stored accounts are listed. Both are included in JSON output as
* well, providing context to machine consumers.
*/
type OrgListEntry = SentryOrganization & { region?: string };
type OrgListEntry = SentryOrganization & {
region?: string;
account?: OrgListAccount;
};

/** One account's fetched orgs plus its slug → region URL cache. */
type AccountOrgs = {
account?: AccountSummary;
orgs: SentryOrganization[];
regions: Map<string, string>;
};

/**
* Extract a human-readable region name from a region URL.
Expand Down Expand Up @@ -89,12 +125,19 @@ function formatOrgListHuman(entries: OrgListEntry[]): string {
}

const showRegion = entries.some((e) => e.region !== undefined);
const showAccount = entries.some((e) => e.account !== undefined);

type OrgRow = { slug: string; name: string; region?: string };
type OrgRow = {
slug: string;
name: string;
region?: string;
account?: string;
};
const rows: OrgRow[] = entries.map((org) => ({
slug: org.slug,
name: org.name,
region: showRegion ? (org.region ?? "") : undefined,
account: org.account?.label,
}));

const columns: Column<OrgRow>[] = [
Expand All @@ -103,6 +146,14 @@ function formatOrgListHuman(entries: OrgListEntry[]): string {
? [{ header: "REGION", value: (r: OrgRow) => r.region ?? "" }]
: []),
{ header: "NAME", value: (r) => escapeMarkdownCell(r.name) },
...(showAccount
? [
{
header: "ACCOUNT",
value: (r: OrgRow) => escapeMarkdownCell(r.account ?? ""),
},
]
: []),
];

const parts: string[] = [];
Expand All @@ -112,14 +163,151 @@ function formatOrgListHuman(entries: OrgListEntry[]): string {
return parts.join("").trimEnd();
}

function toOrgListAccount(account: AccountSummary): OrgListAccount {
return {
id: account.id,
...(account.host ? { host: account.host } : {}),
...(account.email ? { email: account.email } : {}),
...(account.username ? { username: account.username } : {}),
label: formatAccountLabel(account),
};
}

/** Fetch orgs (and their cached regions) with the credentials in effect. */
async function fetchOrgs(account?: AccountSummary): Promise<AccountOrgs> {
const orgs = await listOrganizationsUncached();
return { account, orgs, regions: getAllOrgRegions() };
}

/** Fetch `account`'s orgs with its own credentials, on its own server. */
function fetchAccountOrgs(account: AccountSummary): Promise<AccountOrgs> {
const host = account.host;
if (!host) {
return Promise.reject(
new AuthError("not_authenticated", "Account has no server recorded.", {
skipAutoAuth: true,
}),
);
}
return withStoredAccount({ id: account.id, host }, () => fetchOrgs(account));
}

/**
* Fetch every account's orgs concurrently. An account that fails (expired
* login, unreachable server) is skipped with a warning so the others are
* still listed; when every account fails, the first error is thrown.
*/
async function fetchOrgsAcrossAccounts(
accounts: AccountSummary[],
): Promise<AccountOrgs[]> {
const settled = await Promise.allSettled(accounts.map(fetchAccountOrgs));
const fetched: AccountOrgs[] = [];
let firstError: unknown;
settled.forEach((result, i) => {
if (result.status === "fulfilled") {
fetched.push(result.value);
return;
}
const error = result.reason;
if (!(error instanceof AuthError || error instanceof ApiError)) {
throw error;
}
firstError ??= error;
const account = accounts[i];
log.warn(
`Skipped ${account ? formatAccountLabel(account) : "an account"}: ${error.message}`,
);
});
if (fetched.length === 0 && firstError !== undefined) {
throw firstError;
}
return fetched;
}

/**
* Flatten per-account results into list entries. An org reachable from
* several accounts on the same server is listed once, under the first of
* them (the active account, else the most recently used).
*/
function mergeAccountOrgs(results: AccountOrgs[]): {
orgs: SentryOrganization[];
accountOf: Map<SentryOrganization, AccountSummary | undefined>;
regionOf: Map<SentryOrganization, string | undefined>;
} {
const orgs: SentryOrganization[] = [];
const accountOf = new Map<SentryOrganization, AccountSummary | undefined>();
const regionOf = new Map<SentryOrganization, string | undefined>();
const seen: { host: string; slug: string }[] = [];
for (const { account, orgs: accountOrgs, regions } of results) {
for (const org of accountOrgs) {
const host = account?.host;
if (
host &&
seen.some((s) => s.slug === org.slug && isSameServer(s.host, host))
) {
continue;
}
if (host) {
seen.push({ host, slug: org.slug });
}
orgs.push(org);
accountOf.set(org, account);
regionOf.set(org, regions.get(org.slug));
}
}
return { orgs, accountOf, regionOf };
}

/**
* Which stored accounts to list: the one named by `--account`, else every
* usable account in scope. `undefined` means "the credentials in effect"
* (one or no stored account, or a forced env token), which keeps the
* single-account output unchanged.
*/
function resolveAccounts(selector?: string): AccountSummary[] | undefined {
if (selector !== undefined) {
const account = resolveAccountSelector(
selector,
"sentry org list --account",
{
fuzzy: true,
},
);
if (!account.usable) {
const who = account.email ?? account.username;
const relogin = who ? `sentry auth login ${who}` : "sentry auth login";
throw new AuthError(
"expired",
`${formatAccountLabel(account)} is logged out. Run '${relogin}'.`,
{ skipAutoAuth: true },
);
}
return [account];
}
// The active account first, then most recently used: its orgs lead the
// list, and an org it shares with another account is listed under it.
const accounts = listUsableAccountsInScope()
.map(toAccountSummary)
.sort((a, b) => Number(b.active) - Number(a.active));
return accounts.length > 1 ? accounts : undefined;
}

export const listCommand = buildCommand({
docs: {
brief: "List organizations",
fullDescription:
"List organizations that you have access to.\n\n" +
"With several stored accounts (see 'sentry auth list'), organizations\n" +
"from every logged-in account are listed together, with an ACCOUNT\n" +
"column showing which login reaches each one. Commands targeting an\n" +
"organization use that account automatically. An organization reachable\n" +
"from several accounts on the same server is listed once. Accounts whose\n" +
"login fails are skipped with a warning. SENTRY_HOST/SENTRY_URL limits the\n" +
"list to accounts on that server; --account limits it to one account.\n\n" +
"Examples:\n" +
" sentry org list\n" +
" sentry org list --limit 10\n" +
" sentry org list --account jane@example.com\n" +
" sentry org list --json\n\n" +
"Alias: `sentry orgs` → `sentry org list`",
},
Expand All @@ -128,33 +316,63 @@ export const listCommand = buildCommand({
flags: {
limit: buildListLimitFlag("organizations"),
fresh: FRESH_FLAG,
account: {
kind: "parsed",
parse: String,
brief:
"Only list organizations of this stored account (email, username, or account id)",
optional: true,
},
},
// Only -n for --limit; no -c since org list has no --cursor flag
aliases: { ...FRESH_ALIASES, n: "limit" },
},
async *func(this: SentryContext, flags: ListFlags) {
applyFreshFlag(flags);

const orgs = await withProgress(
const accounts = resolveAccounts(flags.account);
const crossAccount = accounts !== undefined && accounts.length > 1;
const results = await withProgress(
{
message: `Fetching organizations (up to ${flags.limit})...`,
message: crossAccount
? `Fetching organizations from ${accounts.length} accounts (up to ${flags.limit})...`
: `Fetching organizations (up to ${flags.limit})...`,
json: flags.json,
},
() => listOrganizationsUncached(),
async () => {
if (!accounts) {
return [await fetchOrgs()];
}
return crossAccount
? await fetchOrgsAcrossAccounts(accounts)
: await Promise.all(accounts.map(fetchAccountOrgs));
},
);
const { orgs, accountOf, regionOf } = mergeAccountOrgs(results);
const limitedOrgs = orgs.slice(0, flags.limit);

// Check if user has orgs in multiple regions
const orgRegions = getAllOrgRegions();
const uniqueRegions = new Set(orgRegions.values());
const showRegion = uniqueRegions.size > 1;

const entries: OrgListEntry[] = limitedOrgs.map((org) => ({
...org,
region: showRegion
? getRegionDisplayName(orgRegions.get(org.slug) ?? "")
: undefined,
}));
// Show regions when one login's orgs span several (e.g. US and EU).
// Accounts on different servers alone don't count: ACCOUNT shows those.
const showRegion = results.some(
({ orgs: accountOrgs, regions }) =>
new Set(
accountOrgs.flatMap((org) => {
const region = regions.get(org.slug);
return region ? [region] : [];
}),
).size > 1,
);

const entries: OrgListEntry[] = limitedOrgs.map((org) => {
const account = crossAccount ? accountOf.get(org) : undefined;
return {
...org,
region: showRegion
? getRegionDisplayName(regionOf.get(org) ?? "")
: undefined,
...(account ? { account: toOrgListAccount(account) } : {}),
};
});

const hints: string[] = [];
if (orgs.length > flags.limit) {
Expand Down
Loading
Loading