Skip to content
Closed
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
44 changes: 44 additions & 0 deletions plugins/sentry-cli/skills/sentry-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,37 @@ Update the Sentry CLI to the latest version
- `--json - Output as JSON`
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`

### Dashboard

Manage Sentry dashboards

#### `sentry dashboard list <org/project>`

List dashboards

**Flags:**
- `-w, --web - Open in browser`
- `--json - Output as JSON`
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`

#### `sentry dashboard view <args...>`

View a dashboard

**Flags:**
- `-w, --web - Open in browser`
- `--json - Output as JSON`
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`

#### `sentry dashboard create <args...>`

Create a dashboard

**Flags:**
- `--widget-json <value> - Path to JSON file containing widget definitions`
- `--json - Output as JSON`
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`

### Repo

Work with Sentry repositories
Expand Down Expand Up @@ -680,6 +711,19 @@ Initialize Sentry in your project
- `--dry-run - Preview changes without applying them`
- `--features <value>... - Features to enable: errors,tracing,logs,replay,metrics`

### Dashboards

List dashboards

#### `sentry dashboards <org/project>`

List dashboards

**Flags:**
- `-w, --web - Open in browser`
- `--json - Output as JSON`
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`

### Issues

List issues in a project
Expand Down
5 changes: 5 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { apiCommand } from "./commands/api.js";
import { authRoute } from "./commands/auth/index.js";
import { whoamiCommand } from "./commands/auth/whoami.js";
import { cliRoute } from "./commands/cli/index.js";
import { dashboardRoute } from "./commands/dashboard/index.js";
import { listCommand as dashboardListCommand } from "./commands/dashboard/list.js";
import { eventRoute } from "./commands/event/index.js";
import { helpCommand } from "./commands/help.js";
import { initCommand } from "./commands/init.js";
Expand Down Expand Up @@ -42,6 +44,7 @@ import { error as errorColor, warning } from "./lib/formatters/colors.js";
* Used to suggest the correct command when users type e.g. `sentry projects view cli`.
*/
const PLURAL_TO_SINGULAR: Record<string, string> = {
dashboards: "dashboard",
issues: "issue",
orgs: "org",
projects: "project",
Expand All @@ -57,6 +60,7 @@ export const routes = buildRouteMap({
help: helpCommand,
auth: authRoute,
cli: cliRoute,
dashboard: dashboardRoute,
org: orgRoute,
project: projectRoute,
repo: repoRoute,
Expand All @@ -67,6 +71,7 @@ export const routes = buildRouteMap({
trace: traceRoute,
init: initCommand,
api: apiCommand,
dashboards: dashboardListCommand,
issues: issueListCommand,
orgs: orgListCommand,
projects: projectListCommand,
Expand Down
223 changes: 223 additions & 0 deletions src/commands/dashboard/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
/**
* sentry dashboard create
*
* Create a new dashboard in a Sentry organization.
*/

import type { SentryContext } from "../../context.js";
import { createDashboard, getProject } from "../../lib/api-client.js";
import {
type ParsedOrgProject,
parseOrgProjectArg,
} from "../../lib/arg-parsing.js";
import { buildCommand } from "../../lib/command.js";
import { ContextError, ValidationError } from "../../lib/errors.js";
import { formatDashboardCreated } from "../../lib/formatters/human.js";
import {
fetchProjectId,
resolveAllTargets,
resolveOrg,
resolveProjectBySlug,
toNumericId,
} from "../../lib/resolve-target.js";
import { buildDashboardUrl } from "../../lib/sentry-urls.js";
import {
assignDefaultLayout,
type DashboardDetail,
type DashboardWidget,
DashboardWidgetSchema,
} from "../../types/dashboard.js";

type CreateFlags = {
readonly "widget-json"?: string;
readonly json: boolean;
readonly fields?: string[];
};

type CreateResult = DashboardDetail & { url: string };

/**
* Parse array positional args for `dashboard create`.
*
* Handles:
* - `<title>` — title only (auto-detect org/project)
* - `<target> <title>` — explicit target + title
*/
function parsePositionalArgs(args: string[]): {
title: string;
targetArg: string | undefined;
} {
if (args.length === 0) {
throw new ValidationError("Dashboard title is required.", "title");
}
if (args.length === 1) {
return { title: args[0] as string, targetArg: undefined };
}
// Two args: first is target, second is title
return { title: args[1] as string, targetArg: args[0] as string };
}

/** Result of resolving org + project IDs from the parsed target */
type ResolvedDashboardTarget = {
orgSlug: string;
projectIds: number[];
};

/** Enrich targets that lack a projectId by calling the project API */
async function enrichTargetProjectIds(
targets: { org: string; project: string; projectId?: number }[]
): Promise<number[]> {
const enriched = await Promise.all(
targets.map(async (t) => {
if (t.projectId !== undefined) {
return t.projectId;
}
try {
const info = await getProject(t.org, t.project);
return toNumericId(info.id);
} catch {
return;
}
})
);
return enriched.filter((id): id is number => id !== undefined);
}

/** Resolve org and project IDs from the parsed target argument */
async function resolveDashboardTarget(
parsed: ParsedOrgProject,
cwd: string
): Promise<ResolvedDashboardTarget> {
switch (parsed.type) {
case "explicit": {
const pid = await fetchProjectId(parsed.org, parsed.project);
return {
orgSlug: parsed.org,
projectIds: pid !== undefined ? [pid] : [],
};
}
case "org-all":
return { orgSlug: parsed.org, projectIds: [] };

case "project-search": {
const found = await resolveProjectBySlug(
parsed.projectSlug,
"sentry dashboard create <org>/<project> <title>"
);
const pid = await fetchProjectId(found.org, found.project);
return {
orgSlug: found.org,
projectIds: pid !== undefined ? [pid] : [],
};
}
case "auto-detect": {
const result = await resolveAllTargets({ cwd });
if (result.targets.length === 0) {
const resolved = await resolveOrg({ cwd });
if (!resolved) {
throw new ContextError(
"Organization",
"sentry dashboard create <org>/ <title>"
);
}
return { orgSlug: resolved.org, projectIds: [] };
}
const orgSlug = (result.targets[0] as (typeof result.targets)[0]).org;
const projectIds = await enrichTargetProjectIds(result.targets);
return { orgSlug, projectIds };
}
default: {
const _exhaustive: never = parsed;
throw new Error(
`Unexpected parsed type: ${(_exhaustive as { type: string }).type}`
);
}
}
}

export const createCommand = buildCommand({
docs: {
brief: "Create a dashboard",
fullDescription:
"Create a new Sentry dashboard.\n\n" +
"Examples:\n" +
" sentry dashboard create 'My Dashboard'\n" +
" sentry dashboard create my-org/ 'My Dashboard'\n" +
" sentry dashboard create my-org/my-project 'My Dashboard'\n" +
" sentry dashboard create 'My Dashboard' --widget-json widgets.json",
},
output: {
json: true,
human: formatDashboardCreated,
},
parameters: {
positional: {
kind: "array",
parameter: {
brief: "[<org/project>] <title>",
parse: String,
},
},
flags: {
"widget-json": {
kind: "parsed",
parse: String,
brief: "Path to JSON file containing widget definitions",
optional: true,
},
},
},
async func(this: SentryContext, flags: CreateFlags, ...args: string[]) {
const { cwd } = this;

const { title, targetArg } = parsePositionalArgs(args);
const parsed = parseOrgProjectArg(targetArg);
const { orgSlug, projectIds } = await resolveDashboardTarget(parsed, cwd);

const widgets: DashboardWidget[] = [];
const widgetJsonPath = flags["widget-json"];
if (widgetJsonPath) {
const file = Bun.file(widgetJsonPath);
if (!(await file.exists())) {
throw new ValidationError(
`Widget JSON file not found: ${widgetJsonPath}`,
"widget-json"
);
}
const raw = await file.text();
let widgetParsed: unknown;
try {
widgetParsed = JSON.parse(raw);
} catch {
throw new ValidationError(
`Invalid JSON in widget file: ${widgetJsonPath}`,
"widget-json"
);
}

const arr = Array.isArray(widgetParsed) ? widgetParsed : [widgetParsed];
for (const item of arr) {
const result = DashboardWidgetSchema.safeParse(item);
if (!result.success) {
throw new ValidationError(
`Invalid widget definition: ${result.error.message}`,
"widget-json"
);
}
// Assign layout sequentially so each widget stacks below the previous
widgets.push(assignDefaultLayout(result.data, widgets));
}
}

const dashboard = await createDashboard(orgSlug, {
title,
widgets,
projects: projectIds.length > 0 ? projectIds : undefined,
});
const url = buildDashboardUrl(orgSlug, dashboard.id);

return {
data: { ...dashboard, url } as CreateResult,
};
},
});
25 changes: 25 additions & 0 deletions src/commands/dashboard/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { buildRouteMap } from "@stricli/core";
import { createCommand } from "./create.js";
import { listCommand } from "./list.js";
import { viewCommand } from "./view.js";
import { widgetRoute } from "./widget/index.js";

export const dashboardRoute = buildRouteMap({
routes: {
list: listCommand,
view: viewCommand,
create: createCommand,
widget: widgetRoute,
},
docs: {
brief: "Manage Sentry dashboards",
fullDescription:
"View and manage dashboards in your Sentry organization.\n\n" +
"Commands:\n" +
" list List dashboards\n" +
" view View a dashboard\n" +
" create Create a dashboard\n" +
" widget Manage dashboard widgets (add, edit, delete)",
hideRoute: {},
},
});
Loading
Loading