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
6 changes: 3 additions & 3 deletions packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,9 +316,9 @@ what leaves a target for `func` to parse: the completion-only parsers
`resolve*FromArg` helpers. Commands not migrated yet are on
`UNBOUND_TARGET_PARSING_ALLOWED` in `lint-rules/cli-oxlint-plugin.js`, each
with its follow-up; the list is a ratchet, so a file that stops using them
fails until its entry is removed. `AD_HOC_ID_PARSING_ALLOWED` does the same
for ID positionals, including the completion-only `parseIssueTarget` and
`parseDashboardTarget`.
fails until its entry is removed. `sentry-cli/no-ad-hoc-id-arguments` has no
allow-list: no command may parse IDs by hand, and the completion-only
`parseIssueTarget` and `parseDashboardTarget` are rejected too.

**Release positionals:** `[<org>/]<version>` uses
`releaseParameter(USAGE_HINT, brief)` from `src/commands/release/parse.ts`
Expand Down
58 changes: 30 additions & 28 deletions packages/cli/lint-rules/cli-oxlint-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,15 @@ const BOUND_TARGET_TYPES = new Set([
"BoundArguments",
"ConversationArguments",
"DashboardArguments",
"DashboardCreateArguments",
"DashboardListArguments",
"EventViewArguments",
"InitArguments",
"IntegrationArguments",
"IssuesArguments",
"LogListArguments",
"LogViewArguments",
"ProjectCreateArguments",
"ReplayArguments",
"SpanListArguments",
"SpanViewArguments",
Expand Down Expand Up @@ -179,13 +182,6 @@ const AD_HOC_ID_PARSING = new Set([
"parseDashboardTarget",
]);

/**
* Commands still allowed to, each with its follow-up. A ratchet: an entry
* whose file no longer uses them is reported, so the list only shrinks.
* Empty: every command parses its IDs with `parseIdArguments()`.
*/
const AD_HOC_ID_PARSING_ALLOWED = new Map();

/**
* Helpers that leave a target positional for `func` to parse:
* `parseOrgTarget` and `parseOrgProjectTarget` only tag it for completion,
Expand All @@ -203,7 +199,10 @@ const UNBOUND_TARGET_PARSING = new Set([
"resolveProjectBoundFromArg",
]);

/** Commands still allowed to, each with its follow-up; a ratchet too. */
/**
* Commands still allowed to, each with its follow-up. A ratchet: an entry
* whose file no longer uses them is reported, so the list only shrinks.
*/
const UNBOUND_TARGET_PARSING_ALLOWED = new Map([
// Follow-ups (#1564):
// `[<org>/[<project>/]]<rule>`, resolving to every matching project
Expand All @@ -217,10 +216,6 @@ const UNBOUND_TARGET_PARSING_ALLOWED = new Map([
["/src/commands/alert/metrics/edit.ts", "#1564: alert rule target"],
["/src/commands/alert/metrics/list.ts", "#1564: alert rule target"],
["/src/commands/alert/metrics/view.ts", "#1564: alert rule target"],
// A target followed by other positionals
["/src/commands/dashboard/create.ts", "#1564: target and title"],
["/src/commands/init.ts", "#1564: target and directory"],
["/src/commands/project/create.ts", "#1564: name and platform"],
]);

/** The follow-up of `file` in an allow-list, if it is on it. */
Expand All @@ -231,24 +226,36 @@ function allowedFollowUp(allowed, file) {
return undefined;
}

/** Whether `node` uses one of `names`; imports and `typeof` do not. */
function usesName(node, names) {
return (
names.has(node.name) &&
node.parent?.type !== "ImportSpecifier" &&
node.parent?.type !== "TSTypeQuery"
);
}

/** Visitors reporting each use of one of `names` in a command file. */
function bannedNames(file, names, report, message) {
if (!file.includes("/src/commands/")) return {};
return {
Identifier(node) {
if (usesName(node, names)) report(node, message);
},
};
}

/**
* Visitors reporting each reference to one of `names` in a command file,
* except in files on `allowed`; those are reported instead when they no
* longer reference any, so the allow-list only shrinks. Imports and
* `typeof` references are not uses.
* Like {@link bannedNames}, except in files on `allowed`; those are reported
* instead when they no longer use any, so the allow-list only shrinks.
*/
function ratchetedNames(file, names, allowed, report, message) {
if (!file.includes("/src/commands/")) return {};
const followUp = allowedFollowUp(allowed, file);
let used = false;
return {
Identifier(node) {
if (
!names.has(node.name) ||
node.parent?.type === "ImportSpecifier" ||
node.parent?.type === "TSTypeQuery"
)
return;
if (!usesName(node, names)) return;
used = true;
if (followUp === undefined) report(node, message);
},
Expand Down Expand Up @@ -454,12 +461,7 @@ const rules = {
if (!file.includes("/src/") || file.endsWith("/lib/id-arguments.ts"))
return {};
return {
...ratchetedNames(
file,
AD_HOC_ID_PARSING,
AD_HOC_ID_PARSING_ALLOWED,
report,
),
...bannedNames(file, AD_HOC_ID_PARSING, report),
CallExpression(node) {
if (
ID_ARGUMENT_PARSERS.has(node.callee.name) &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@ Initialize Sentry in your project (experimental)

| Argument | Description |
|----------|-------------|
| `[<target>]` | &lt;org&gt;/&lt;project&gt;, &lt;org&gt;/, &lt;project&gt;, or a directory path |
| `[<directory>]` | Project directory (default: current directory) |
| `[<target>] [<directory>]` | Optional target (&lt;org&gt;/&lt;project&gt;, &lt;org&gt;/, or &lt;project&gt;) and directory (default: current directory) |

**Flags:**
- `-y, --yes - Accept non-interactive defaults (requires --features outside a TTY)`
Expand Down
77 changes: 51 additions & 26 deletions packages/cli/src/commands/dashboard/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,25 @@ import type { SentryContext } from "../../context.js";
import { createDashboard, getProject } from "../../lib/api-client.js";
import {
type ParsedOrgProject,
parseOrgProjectArg,
parseOrgProjectInput,
} from "../../lib/arg-parsing.js";
import { buildCommand } from "../../lib/command.js";
import { parseOrgProjectTarget } from "../../lib/completion-target.js";
import { ORG_PROJECT_TARGET } from "../../lib/completion-target.js";
import { ContextError, ValidationError } from "../../lib/errors.js";
import { formatDashboardCreated } from "../../lib/formatters/human.js";
import { CommandOutput } from "../../lib/formatters/output.js";
import {
type BoundArguments,
boundArguments,
} from "../../lib/positional-parse.js";
import {
fetchProjectId,
resolveAllTargets,
resolveOrg,
resolveProjectBoundSlug,
toNumericId,
} from "../../lib/resolve-target.js";
import { applySentryUrlContext } from "../../lib/sentry-url-parser.js";
import { buildDashboardUrl } from "../../lib/sentry-web-urls.js";
import { setOrgProjectContext } from "../../lib/telemetry.js";
import type { DashboardDetail } from "../../types/dashboard.js";
Expand All @@ -34,25 +39,34 @@ type CreateFlags = {

type CreateResult = DashboardDetail & { url: string };

/** `dashboard create`'s positionals, parsed: the title and the target. */
type CreateInput = {
readonly title: string;
readonly target: ParsedOrgProject;
/** Server of a pasted Sentry URL, for `applySentryUrlContext` */
readonly baseUrl?: string;
};

/**
* Parse array positional args for `dashboard create`.
* Parse `dashboard create`'s positionals without side effects.
*
* Handles:
* - `<title>` — title only (auto-detect org/project)
* - `<target> <title>` — explicit target + title
* - `<target> <title...>` — explicit target + title; the title's words may
* be separate arguments
*
* @throws {ValidationError} When there is no title or the target is invalid
*/
function parsePositionalArgs(args: string[]): {
title: string;
targetArg: string | undefined;
} {
if (args.length === 0) {
function parseCreateInput(args: readonly string[]): CreateInput {
const [first, ...rest] = args;
if (first === undefined) {
throw new ValidationError("Dashboard title is required.", "title");
}
if (args.length === 1) {
return { title: args[0] as string, targetArg: undefined };
if (rest.length === 0) {
return { title: first, target: { type: "auto-detect" } };
}
// Two args: first is target, second is title
return { title: args[1] as string, targetArg: args[0] as string };
const { parsed, baseUrl } = parseOrgProjectInput(first);
return { title: rest.join(" "), target: parsed, baseUrl };
}

/** Result of resolving org + project IDs from the parsed target */
Expand All @@ -61,6 +75,12 @@ type ResolvedDashboardTarget = {
projectIds: number[];
};

/** `dashboard create`'s `[<org/project>] <title>` arguments. */
export type DashboardCreateArguments = BoundArguments<
CreateInput,
ResolvedDashboardTarget
>;

/** Enrich targets that lack a projectId by calling the project API */
async function enrichTargetProjectIds(
targets: { org: string; project: string; projectId?: number }[],
Expand Down Expand Up @@ -163,22 +183,27 @@ export const createCommand = buildCommand({
human: formatDashboardCreated,
},
parameters: {
positional: {
kind: "array",
parameter: {
placeholder: "org/project/title",
brief: "[<org/project>] <title>",
parse: parseOrgProjectTarget,
positional: boundArguments({
placeholder: "org/project/title",
brief: "[<org/project>] <title>",
kind: ORG_PROJECT_TARGET,
parse: parseCreateInput,
resolve: async (input: CreateInput, context: SentryContext) => {
if (input.baseUrl) {
applySentryUrlContext(input.baseUrl);
}
return await resolveDashboardTarget(input.target, context.cwd);
},
},
}),
flags: {},
},
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);
async *func(
this: SentryContext,
_flags: CreateFlags,
args: DashboardCreateArguments,
) {
const { title } = args.parsed;
const { orgSlug, projectIds } = await args.resolved;

const dashboard = await withDashboardErrors(
{ orgSlug, operation: "create" },
Expand Down
Loading
Loading