Skip to content

ref(toolkit-core): Start the shared API client with errors and response reading - #1732

Merged
BYK merged 10 commits into
mainfrom
feat/shared-api-client
Oct 29, 2026
Merged

BYK merged 10 commits into
mainfrom
feat/shared-api-client

Conversation

@sentry-junior

@sentry-junior sentry-junior Bot commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

Starts the shared Sentry API client in toolkit-core/src/api-client/ (step 1 of #1669, task group 8). The MCP and the CLI both use it for errors, API paths, and response reading.

It is the first of a stack of PRs by layer (see "Next" below).

What changes

  • Portability guard (8.1). Lint bans node:* imports and Node globals such as Buffer and process in toolkit-core. A second typecheck (tsconfig.browser.json) checks it against the DOM library with no Node types, and pnpm run tsc runs both. The node:zlib cache codec is the one exception. Its types move to response-cache/body.ts, so the policy and store modules stay portable.

  • Standard Schema validation. validateResponse takes any Standard Schema validator: Zod from the MCP, valibot from the CLI. toolkit-core depends only on @standard-schema/spec types. Lint bans zod and valibot imports in its source; they are dev dependencies for the tests only.

  • Shared errors and apiPath. ApiError, its status subclasses, createApiError, UserInputError, ConfigurationError, and apiPath move from mcp-core to @sentry/toolkit-core/api-client/*. mcp-core/src/errors.ts re-exports the two general errors, so its importers don't change.

  • Shared response reading. readErrorResponse reads a failed response once: its text, parsed JSON, string detail, whether it is an HTML page, and the rate-limit wait. readJsonResponse is the MCP's Content-Type-checked JSON parse.

    • SentryApiService uses both.
    • The CLI's throwRawApiError uses readErrorResponse.
    • Each product still maps the result to its own errors (MCP ApiError subclasses, CLI exit-coded ApiError).
  • mcp-core builds ESM only. Once the errors moved, the CJS build hit a rolldown "canonical name not found" panic. Nothing loads that CJS output:

    • @sentry/mcp-core is private and "type": "module".
    • All 29 of its exports point at .js, with no require condition.
    • Every consumer is in the workspace: mcp-server bundles it as a devDependency through those .js exports, mcp-cloudflare bundles it with Vite, and mcp-test-client and mcp-server-evals are private ESM packages.
    • No source requires it or reads its .cjs files.

    @sentry/mcp-server's own CJS and ESM output is unchanged. I tried upgrading tsdown first, to 0.15.12, 0.18.4, 0.20.3, 0.22.14, and 0.23.4. Every version broke the mcp-server-mocks build: 0.15.12 failed declaration output (members is not iterable), and 0.18.4 and later hit a rolldown JSON parse "recursion limit exceeded" on src/fixtures/trace.json. 0.18.4 and 0.20.3 also failed the mcp-core and mcp-server builds. Bundling toolkit-core into the dist (noExternal) would have duplicated the error classes across dists and broken instanceof between packages.

  • Docs. The toolkit-core README now has an "API client" section. api-patterns.md, error-handling.md, and the logging skill point at the new error module.

Behavior changes

  • MCP, unreadable error body. If the body of a failed response cannot be read, the caller now gets an ApiError for the status instead of the raw stream error.
  • MCP, "no string detail" warning. The validationErrorMessage on the "Failed to parse validated API error response" warning is now a fixed string instead of Zod's message. ApiErrorSchema is deleted; nothing else used it.

Messages, details, retryAfterMs, and the CLI's error detail are otherwise unchanged.

Next

This is the bottom of a stack of PRs by layer for the rest of #1669 group 8. Each one is based on the PR below it:

  1. Move pagination, cache policy, retry, and transport under api-client/.
  2. Shared endpoint wrappers on top of @sentry/api, with SentryApiService switched onto them.
  3. The CLI host seam, with the CLI's lib/api/ wrappers switched onto the shared client.
  4. Merge the wrappers both products share.

This PR does not touch packages/mcp-server, packages/cli/src/lib/mcp.ts, or the evals stdio entry; the @sentry/mcp-server retirement PR owns those.

Commits

Each codemod run is a generated commit whose message gives the command, and each hand fix is a separate commit:

  1. 13053adab Portability guard (hand)
  2. f13df0d24 Standard Schema validation (hand)
  3. bea21d648 mcp-core ESM only (hand)
  4. 0d083bdf3 Move errors and apiPath (generated, move-shared-errors.mjs)
  5. 4ddc54813 Move the client.ts request-path test back next to client.ts (hand)
  6. 143da6e39 Shared response reading (hand)
  7. b776eb55b MCP JSON parsing on the shared reader (generated, use-shared-json-response.mjs)
  8. 44976334d MCP error responses on the shared reader (hand)
  9. 7027a482d CLI raw error responses on the shared reader (hand)
  10. bf0b248ac Docs (hand)

Codemods

The scripts are not in the tree. To re-run one, check out the parent of its generated commit, run the script from a scratch directory with ts-morph@28.0.0 installed, then run oxfmt on the changed files. The output should match the commit.

move-shared-errors.mjs (commit 0d083bd)
node move-shared-errors.mjs <repo-root>
oxfmt <added and modified files>
// Codemod: move the MCP's API error classes and API path helper into
// toolkit-core's shared api-client, and rewrite every importer.
//
// Usage: node move-shared-errors.mjs <toolkit-repo-root>
// (run from a directory where `ts-morph@28.0.0` is installed)
import { realpathSync } from "node:fs";
import path from "node:path";
import { Project } from "ts-morph";

const root = path.resolve(process.argv[2] ?? ".");
const mcpSrc = path.join(root, "packages/mcp-core/src");
const coreSrc = path.join(root, "packages/toolkit-core/src");

// Classes the shared client throws, moved out of mcp-core's general errors.
const MOVED_CLASSES = ["UserInputError", "ConfigurationError"];
// mcp-core files that move into toolkit-core/src/api-client/.
const MOVED_FILES = [
  "api-client/errors.ts",
  "api-client/api-path.ts",
  "api-client/api-path.test.ts",
];

const project = new Project({
  tsConfigFilePath: path.join(root, "packages/mcp-core/tsconfig.json"),
});
project.addSourceFilesAtPaths(path.join(coreSrc, "**/*.ts"));

// 1. Move the classes into the API errors module; mcp-core/src/errors.ts
//    re-exports them so its existing importers keep working.
const generalErrors = project.getSourceFileOrThrow(
  path.join(mcpSrc, "errors.ts"),
);
const apiErrors = project.getSourceFileOrThrow(
  path.join(mcpSrc, "api-client/errors.ts"),
);
const classTexts = MOVED_CLASSES.map((name) => {
  const declaration = generalErrors.getClassOrThrow(name);
  const text = declaration.getFullText().trim();
  declaration.remove();
  return text;
});
apiErrors.insertText(0, `${classTexts.join("\n\n")}\n\n`);
generalErrors.insertExportDeclaration(0, {
  namedExports: [...MOVED_CLASSES].sort(),
  moduleSpecifier: "./api-client/errors",
});

// 2. Move the files. ts-morph rewrites relative imports in and to them.
const movedPaths = new Set();
for (const file of MOVED_FILES) {
  const sourceFile = project.getSourceFileOrThrow(path.join(mcpSrc, file));
  sourceFile.move(path.join(coreSrc, "api-client", path.basename(file)));
  movedPaths.add(sourceFile.getFilePath());
}

// Moved files take the moved classes from the API errors module directly,
// not through mcp-core's re-export.
for (const movedPath of movedPaths) {
  const sourceFile = project.getSourceFileOrThrow(movedPath);
  for (const declaration of sourceFile.getImportDeclarations()) {
    if (declaration.getModuleSpecifierSourceFile() !== generalErrors) continue;
    const names = declaration.getNamedImports().map((n) => n.getName());
    if (names.every((name) => MOVED_CLASSES.includes(name))) {
      declaration.setModuleSpecifier(apiErrors);
    }
  }
}

// 3. Normalize specifiers that point into toolkit-core: relative with `.js`
//    inside toolkit-core, `@sentry/toolkit-core/<path>` everywhere else.
function realPath(file) {
  try {
    return realpathSync(file);
  } catch {
    return file;
  }
}

for (const sourceFile of project.getSourceFiles()) {
  const importer = sourceFile.getFilePath();
  const declarations = [
    ...sourceFile.getImportDeclarations(),
    ...sourceFile.getExportDeclarations(),
  ];
  for (const declaration of declarations) {
    const target = declaration.getModuleSpecifierSourceFile();
    if (!target) continue;
    const targetPath = realPath(target.getFilePath());
    if (!targetPath.startsWith(`${coreSrc}/`)) continue;
    // Leave specifiers the codemod did not affect as they are.
    if (!movedPaths.has(importer) && !movedPaths.has(targetPath)) continue;
    const withoutExt = targetPath.replace(/\.ts$/, "");
    let specifier;
    if (importer.startsWith(`${coreSrc}/`)) {
      specifier = path.relative(path.dirname(importer), withoutExt);
      if (!specifier.startsWith(".")) specifier = `./${specifier}`;
      specifier = `${specifier}.js`;
    } else {
      specifier = `@sentry/toolkit-core/${path
        .relative(coreSrc, withoutExt)
        .replace(/\/index$/, "")}`;
    }
    if (declaration.getModuleSpecifierValue() !== specifier) {
      declaration.setModuleSpecifier(specifier);
    }
  }
}

await project.save();
use-shared-json-response.mjs (commit b776eb5)
node use-shared-json-response.mjs <repo-root>
oxfmt packages/mcp-core/src/api-client/client.ts
// Codemod: replace SentryApiService's private parseJsonResponse with the
// shared readJsonResponse from toolkit-core, and delete the private copy.
//
// Usage: node use-shared-json-response.mjs <toolkit-repo-root>
// (run from a directory where `ts-morph@28.0.0` is installed)
import path from "node:path";
import { Project, SyntaxKind } from "ts-morph";

const root = path.resolve(process.argv[2] ?? ".");
const clientPath = path.join(root, "packages/mcp-core/src/api-client/client.ts");

const project = new Project({ skipAddingFilesFromTsConfig: true });
const client = project.addSourceFileAtPath(clientPath);
const service = client.getClassOrThrow("SentryApiService");

// 1. `this.parseJsonResponse(x)` -> `readJsonResponse(x)`.
const calls = client
  .getDescendantsOfKind(SyntaxKind.CallExpression)
  .filter((call) => call.getExpression().getText() === "this.parseJsonResponse")
  .reverse();
for (const call of calls) {
  call.getExpression().replaceWithText("readJsonResponse");
}

// 2. Delete the private method it replaces.
service.getMethodOrThrow("parseJsonResponse").remove();

// 3. Import the shared helper.
client.addImportDeclaration({
  namedImports: ["readJsonResponse"],
  moduleSpecifier: "@sentry/toolkit-core/api-client/response",
});

await project.save();
console.log(`rewrote ${calls.length} call sites`);

Verification

At bf0b248ac, rebased on main after #1722 merged:

  • pnpm run tsc passes. It includes toolkit-core's browser-target typecheck and the CLI typecheck.
  • pnpm run lint passes (oxlint, oxfmt, ast-grep rules), and so does pnpm run docs:check.
  • Tests: toolkit-core 306, mcp-core 1881 (6 skipped), and mcp-cloudflare 443 pass.
  • Before the rebase, the full pnpm run test also passed mcp-server (87), mcp-test-client (80), docs-mcp (49), local (322), and cli-docs.
  • CLI (test/lib, test/commands): 11718 pass and 6 fail in 5 files (completions, dsn/code-scanner, init/tools/file-changes, telemetry, upgrade).
    • All 6 test permission errors (EACCES/EPERM/read-only). They fail only because the sandbox runs with CAP_DAC_OVERRIDE, so a chmod 000 file stays readable.
    • This PR touches none of that code; CI is the check for them.
  • Both generated commits reproduce exactly when their codemod is re-run on the rebased parent.
  • The diff has no codemod scripts, ast-grep or ts-morph config, or ts-morph dependency.

via BYK.

--

View Junior Session in Sentry

Lint bans node:* imports and Node globals in toolkit-core, and a second
typecheck runs it against the DOM library with no Node types. The cache
codec keeps node:zlib; its types move to body.ts so the policy and stores
stay portable.
validateResponse takes any Standard Schema validator, so the MCP keeps Zod
and the CLI keeps valibot. toolkit-core depends only on the interface's
types, and lint now bans zod and valibot imports in shared modules.
Nothing loads mcp-core's CJS output. The package is private, "type":
"module", and all 29 exports point at .js with no "require" condition,
so require() would resolve to the ESM files anyway. Its consumers are
workspace-only: mcp-server bundles it as a devDependency through those
.js exports, mcp-cloudflare bundles it with Vite, and mcp-test-client
and mcp-server-evals are private and ESM. No source in the repo requires
it or reads its .cjs files.

Dropping the CJS build also avoids the rolldown "canonical name not
found" panic hit by the CJS pass once the API errors move to
toolkit-core. mcp-server's own CJS+ESM output is unchanged.
Generated by the ts-morph codemod move-shared-errors.mjs (script in the PR
description), run from a scratch directory with ts-morph@28.0.0 installed:

  node move-shared-errors.mjs <repo-root>
  oxfmt <added and modified files>

It moves mcp-core's api-client/errors.ts, api-path.ts, and api-path.test.ts
into toolkit-core/src/api-client/, moves UserInputError and
ConfigurationError into the shared errors module (mcp-core/src/errors.ts
re-exports them), and rewrites every importer.
The codemod moved api-path.test.ts into toolkit-core, but its second block
reads mcp-core's client.ts source to check every request path uses apiPath.
That block moves back to mcp-core until client.ts moves too.
readErrorResponse reads a failed response once: its text, parsed JSON,
the server's string `detail`, whether it is an HTML page, and the
rate-limit wait. Each host maps that to its own errors. readJsonResponse
is the MCP's Content-Type-checked JSON parse, moved here for both.
Generated by the ts-morph codemod use-shared-json-response.mjs (script in
the PR description), run from a scratch directory with ts-morph@28.0.0
installed:

  node use-shared-json-response.mjs <repo-root>
  oxfmt packages/mcp-core/src/api-client/client.ts

It rewrites the 18 `this.parseJsonResponse(...)` calls in
SentryApiService to toolkit-core's readJsonResponse, deletes the private
method, and imports the shared one.
SentryApiService.request reads a failed response with toolkit-core's
readErrorResponse and maps it to ApiError subclasses as before: the
same messages, details, retryAfterMs, and warnings. The Zod
ApiErrorSchema it used for the `detail` check is gone.

Two small changes: if the error body cannot be read, the caller now gets
an ApiError for the status instead of the stream error, and the
"no string detail" warning's validationErrorMessage is a fixed string
instead of Zod's message.
throwRawApiError takes the body, parsed JSON, and `detail` from
toolkit-core's readErrorResponse instead of parsing them itself. The
detail it puts on ApiError is unchanged: the server's string detail,
the JSON body for statuses other than 401 and 403, the text for
non-JSON bodies, and the status text when the body cannot be read.
The toolkit-core README covers the api-client modules and their
portability guard, api-patterns.md says where ApiError and error reading
live now, and the error-handling doc and logging skill point at the
moved errors module.
@vercel

vercel Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
sentry-local Ready Ready Preview Oct 29, 2026 7:35pm UTC

Request Review

@github-actions github-actions Bot added the risk: medium PR risk score: medium label Oct 29, 2026
sentry-junior Bot added a commit that referenced this pull request Oct 29, 2026
Group 8 now ships as a stack split by layer: the foundation (#1732),
the transport-layer move, endpoint wrappers with the MCP switch, the
CLI host seam and switch, then merged shared wrappers. 6.1 is done in
#1722.

Co-Authored-By: BYK <burak.kaya@sentry.io>
@BYK
BYK enabled auto-merge (squash) October 29, 2026 19:43
@BYK
BYK merged commit 9409280 into main Oct 29, 2026
43 checks passed
@BYK
BYK deleted the feat/shared-api-client branch October 29, 2026 19:51
sentry-junior Bot added a commit that referenced this pull request Oct 29, 2026
… 8.3 and 8.4

Co-Authored-By: BYK <burak.kaya@sentry.io>
BYK added a commit that referenced this pull request Oct 29, 2026
… api-client (#1736)

Moves pagination, the response-cache policy, retry, and transport under
`toolkit-core/src/api-client/`, the layout they will have inside
`@sentry/api` (#1669, task 8.3). Every importer is rewritten. Nothing
else changes.

It's the second PR in the group 8 stack. #1732, the foundation, has
merged, so this one targets `main`.

## What changes

- **Moves.** Only the relative imports inside the moved files change:
  - `pagination.ts` → `api-client/pagination.ts`
  - `api-retry.ts` → `api-client/retry.ts`
  - `api-transport.ts` → `api-client/transport.ts`
-
`response-cache/{body,index,invalidation,keys,memory-store,policy,store,tiers}.ts`
→ `api-client/response-cache/`
  - Their tests move with them.
- **Stays put:** `response-cache/codec.ts`. It's the `node:zlib` codec,
host storage that won't move into `@sentry/api`. It's still excluded
from the browser typecheck, and the `./response-cache/codec` export
doesn't change.
- **Exports:** `./pagination`, `./api-retry`, `./api-transport`, and
`./response-cache` are replaced by the existing `./api-client/*`
wildcard plus one `./api-client/response-cache` entry for the directory
index. `@sentry/toolkit-core` is `private`, and every consumer is in
this workspace and rewritten here.
- **Importers.** The codemod rewrites 11 import specifiers across the
CLI (including `script/generate-api-schema.ts`),
`mcp-core/src/api-client/client.ts`, and toolkit-core.
- **Docs:** `api-patterns.md`, the toolkit-core README, and code
comments name the new subpaths.

## Behavior changes

None.

This PR does not touch `packages/mcp-server`,
`packages/cli/src/lib/mcp.ts`, or the evals stdio entry. The
`@sentry/mcp-server` retirement PR owns those.

## Commits

1. `85fae9151` Move the modules and rewrite importers (**generated**,
`move-transport-layer.mjs`)
2. `85df504be` Point comments and docs at the new paths (hand)

## Verification

- The generated commit reproduces exactly: check out `origin/main`
(`9409280c1`), run the command below, then oxfmt on the changed files.
The tree matches `85fae9151`.
- `pnpm run tsc` (including toolkit-core's browser typecheck), `pnpm run
lint`, and `node scripts/check-doc-links.mjs` pass.
- Tests: toolkit-core 306 passed; mcp-core 1881 passed, 6 skipped;
mcp-cloudflare 443 passed; mcp-server 87 passed; CLI `test/lib
test/commands test/script` 11895 passed, 46 skipped, **6 failed**.
- The 6 CLI failures are sandbox-only, and they fail the same way
without this change. The sandbox runs with `CAP_DAC_OVERRIDE`, so these
permission tests can't make a file unreadable: `completions` (EACCES,
EPERM), `dsn/code-scanner` (unreadable file), `init/tools/file-changes`
(I/O failure), `telemetry` (read-only DB), and `upgrade` (`acquireLock`
permission error). CI runs them normally.

## Codemod

The script isn't in the tree. To re-run it, check out the parent of the
generated commit and run it from a scratch directory with
`ts-morph@28.0.0` installed. Then run oxfmt on the added, modified, and
renamed package files.

<details>
<summary><code>move-transport-layer.mjs</code> (commit
85fae91)</summary>

```sh
node move-transport-layer.mjs <repo-root>
git add -A -- packages
oxfmt $(git diff --cached --name-only --diff-filter=AMR)
```

```js
// Codemod: move toolkit-core's pagination, response-cache policy, api-retry,
// and api-transport modules under src/api-client/, and rewrite every
// importer and the package's exports. The node:zlib cache codec stays at
// src/response-cache/codec.ts as a host-side module.
//
// Usage: node move-transport-layer.mjs <toolkit-repo-root>
// (run from a directory where `ts-morph@28.0.0` is installed)
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { Node, Project } from "ts-morph";

const root = path.resolve(process.argv[2] ?? ".");
const coreDir = path.join(root, "packages/toolkit-core");
const coreSrc = path.join(coreDir, "src");

// Old path -> new path, relative to toolkit-core/src.
const MOVES = new Map([
  ["pagination.ts", "api-client/pagination.ts"],
  ["pagination.test.ts", "api-client/pagination.test.ts"],
  ["api-retry.ts", "api-client/retry.ts"],
  ["api-retry.test.ts", "api-client/retry.test.ts"],
  ["api-transport.ts", "api-client/transport.ts"],
  ["api-transport.test.ts", "api-client/transport.test.ts"],
]);
const CACHE_FILES = [
  "body.ts",
  "index.ts",
  "invalidation.ts",
  "invalidation.test.ts",
  "keys.ts",
  "keys.test.ts",
  "memory-store.ts",
  "memory-store.test.ts",
  "policy.ts",
  "policy.test.ts",
  "policy.property.test.ts",
  "store.ts",
  "tiers.ts",
  "tiers.test.ts",
];
for (const file of CACHE_FILES) {
  MOVES.set(`response-cache/${file}`, `api-client/response-cache/${file}`);
}

// Package subpath -> new subpath. `response-cache/codec` does not move.
const SPECIFIERS = new Map([
  ["@sentry/toolkit-core/api-retry", "@sentry/toolkit-core/api-client/retry"],
  [
    "@sentry/toolkit-core/api-transport",
    "@sentry/toolkit-core/api-client/transport",
  ],
  [
    "@sentry/toolkit-core/pagination",
    "@sentry/toolkit-core/api-client/pagination",
  ],
  [
    "@sentry/toolkit-core/response-cache",
    "@sentry/toolkit-core/api-client/response-cache",
  ],
]);

// 1. Move the files inside toolkit-core. ts-morph rewrites relative imports
//    in and to them; then give every rewritten specifier the `.js` suffix
//    the package uses.
const core = new Project({
  tsConfigFilePath: path.join(coreDir, "tsconfig.json"),
});
const moved = new Set();
for (const [from, to] of MOVES) {
  const sourceFile = core.getSourceFileOrThrow(path.join(coreSrc, from));
  sourceFile.move(path.join(coreSrc, to));
  moved.add(sourceFile.getFilePath());
}
for (const sourceFile of core.getSourceFiles()) {
  const importer = sourceFile.getFilePath();
  for (const declaration of [
    ...sourceFile.getImportDeclarations(),
    ...sourceFile.getExportDeclarations(),
  ]) {
    const target = declaration.getModuleSpecifierSourceFile();
    if (!target || !declaration.getModuleSpecifierValue()?.startsWith(".")) {
      continue;
    }
    const targetPath = target.getFilePath();
    if (!moved.has(importer) && !moved.has(targetPath)) continue;
    let specifier = path.relative(
      path.dirname(importer),
      targetPath.replace(/\.ts$/, ""),
    );
    if (!specifier.startsWith(".")) specifier = `./${specifier}`;
    declaration.setModuleSpecifier(`${specifier}.js`);
  }
}
await core.save();

// 2. Rewrite package specifiers everywhere else, including dynamic imports
//    and vi.mock() calls: any string literal equal to an old subpath.
const others = new Project({ skipAddingFilesFromTsConfig: true });
others.addSourceFilesAtPaths([
  path.join(root, "{packages,apps}/*/{src,test,script,scripts}/**/*.{ts,tsx,mts}"),
  `!${path.join(root, "**/node_modules/**")}`,
  `!${path.join(root, "**/dist/**")}`,
  `!${path.join(coreDir, "**")}`,
]);
let rewritten = 0;
for (const sourceFile of others.getSourceFiles()) {
  let changed = false;
  sourceFile.forEachDescendant((node) => {
    if (!Node.isStringLiteral(node)) return;
    const next = SPECIFIERS.get(node.getLiteralValue());
    if (next) {
      node.setLiteralValue(next);
      changed = true;
      rewritten += 1;
    }
  });
  if (changed) await sourceFile.save();
}

// 3. Point the package exports at the new paths. The `./api-client/*`
//    wildcard covers pagination, retry, and transport; the cache needs its
//    index.
const manifestPath = path.join(coreDir, "package.json");
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
const exports = {};
for (const [key, value] of Object.entries(manifest.exports)) {
  if (["./api-retry", "./api-transport", "./pagination", "./response-cache"].includes(key)) {
    continue;
  }
  if (key === "./api-client/*") {
    exports["./api-client/response-cache"] = {
      types: "./src/api-client/response-cache/index.ts",
      default: "./src/api-client/response-cache/index.ts",
    };
  }
  exports[key] = value;
}
manifest.exports = exports;
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);

console.log(`moved ${moved.size} files, rewrote ${rewritten} specifiers`);
```

</details>

<!-- junior-request-attribution:start -->
via **BYK**.
<!-- junior-request-attribution:end -->

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:agent%3Aa6eddc0161cff61914454da9f74fcdf1 -->

--

[View Junior Session in
Sentry](https://sentry.sentry.io/explore/conversations/agent%3Aa6eddc0161cff61914454da9f74fcdf1/?project=4510944073809921)

<!-- junior-session-footer:end -->

---------

Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com>
Co-authored-by: BYK <burak.kaya@sentry.io>
BYK added a commit that referenced this pull request Oct 29, 2026
…mar (#1730)

Part of #1564. Dashboard commands parsed their positionals by hand
inside `func`, and issue commands used their own grammar (#1729). This
moves both onto the shared ID grammar (`grammar/id-arguments.pegjs`,
#1714), parsed before `func` runs. Event view's issue checks (#1733,
#1738) use the new issue kind too.

**Shared grammar: kinds with names.** `IdKind.names` adds one idea to
`id-arguments.pegjs`: a kind may name an item without its ID, and
anything that is not, and may not be, an ID is a name. Kinds without
names keep their grammar paths exactly as before. Kinds with names keep
each line whole (no splitting at spaces), so `Top 10` stays one title
and never becomes title `Top` + ID `10`.
- `ID_KINDS.dashboard`: a numeric ID or a title, which may hold spaces
and slashes (`Latency/p95`). `<org>/[<project>/]<digits>` reads as a
path only when slugs come before the digits, so `Errors.v2/123` and
`/123` are titles. After a target, the title takes the rest of the
arguments (`my-org/ My Dashboard`). A bare first word names an org
(`orgScoped`).
- `ID_KINDS.issue`: an identifier (numeric ID, short ID, selector, issue
URL) is one whole token (`wholeIds`). `src/lib/issue-identifier.ts`
takes it apart with the #1729 grammar, which is unchanged. Text with
spaces is a name, meaning a search.
- `dashboard-arguments.pegjs` is gone. `dashboard list` keeps its thin
`parseDashboardListArgs`, because a bare word there is a title filter
(see #1711).

**Bare first word: org or title?** For `dashboard view My Dashboard`,
the parser makes no decision. It returns the structural reading (org
`My`, title `Dashboard`) and the whole input as one title
(`nameWithTarget`, surfaced as `orgOrTitle`). `resolveDashboardInput`
decides after auth:
- First it checks whether `My` is an org: the cached org list and region
cache, then `GET /organizations/My/` only on a cache miss.
- If the org exists, the title lookup in that org is final, even when it
misses.
- On a 404 or 403 it warns ``No org 'My'; treating "My Dashboard" as the
dashboard title. Use my-org/ to name an org.`` and looks up "My
Dashboard" in the auto-detected org.
- If that misses too, one error names both readings (no org `My` to find
`Dashboard` in, and no `My Dashboard` in `<detected-org>`) and hints at
`<org>/ Dashboard` and at quoting.
- A 5xx or network error from the org check propagates, with no
fallback.
- It never falls back for `my-org/ …` (explicit org), a numeric ref, or
a URL.

**Issue search text.** `issue view "login timeout"` resolves through
`GET /organizations/{org}/issues/?query=…` in the detected org. One hit
is the issue. Zero or several hits raise a `ResolutionError` that lists
up to 5 candidates as runnable commands and points at `sentry issue list
<org>/ --query '…'`. I checked the endpoint against getsentry/sentry
(`organization_group_index.py`, `group_index/index.py`): `query` takes
free text, an exact event ID or short ID gets a fast lookup, and an
explicit query replaces the default `is:unresolved`.

**Search hints.** Text where a trace, span, log, or replay ID belongs
(`trace view login`) now fails with ``Not a trace ID: "login". To
search, try `sentry trace list --query 'login'`.``. The hint comes from
`validate()`, so commands that recover malformed IDs (`trace view`,
`span view`, `log view`) recover first and use the hint only as their
final error. Event IDs get no hint, because `event list` lists one
issue's events.

**event view.** `ID_KINDS.issue.distinctForm` (uppercase short ID or
numeric) replaces `looksLikeIssueShortId` + `isAllDigits` and keeps
#1738's order: `<org>/<short-id>`, then `<short-id>/<event-id>` (even an
all-digit event ID), then `<org>/<numeric-id>`. I added a regression
test for the all-digit event ID and one for that order.

**Behavior changes:**
- `dashboard view my-org My Dashboard` (unquoted title) looks up "My
Dashboard". Before, it used `My` and dropped `Dashboard`. The same goes
for `my-org/ My Dashboard` and `https://acme.sentry.io/ My Dashboard`.
- `dashboard view My Dashboard`: when there is no org `My` (404/403), it
falls back to the title "My Dashboard" in the detected org, with the
warning above. If neither reading finds a dashboard, one error names
both. Dashboard commands with one dashboard ref get this (`view`,
`restore`, `revisions`, `widget edit/delete`, and `widget add` after it
splits off its widget title).
- A numeric ref first is the dashboard, as with every ID kind:
`dashboard view 42 X`, `my-org/42 X`. Before, the first of two arguments
was always the target. After a target, a numeric ID keeps the rest as
extra (`my-org 123 extra` → ID 123, ignored `extra`) instead of joining
them into a title. Arguments after the ID go through the shared rules:
extra, reversed (`42 my-org/`), or the shared "reference different
projects" error when they name another target.
- A lone non-Sentry URL is no longer taken as a dashboard title.
- `issue view "login timeout"` (text with spaces) searches instead of
failing as a malformed identifier. Control characters and URLs are still
rejected.
- Trace/span/log/replay IDs that are plain text get a search hint
instead of "Invalid trace ID".
- Parse errors (no dashboard, an org URL without a dashboard, a
dashboard URL passed to `list`, an invalid target slug) fail before the
auth guard, as in the earlier commits on this branch. They print `Failed
to parse "…" for <placeholder>: …` and exit 252.
- `widget add/edit/delete`: an argument error comes before flag errors
like "Specify --index or --title". `widget add`'s positional completes
cached dashboards. `parseDashboardTarget` had no callers left and is
removed.

**Lint ratchet (#1739):** the dashboard entries are off both
allow-lists, since these commands now parse before `func`. The issue
entries (`issue/utils.ts`, `issue/merge.ts`, which need an array issue
positional) stay.

**Syncing:** this branch merges `main` (no rebase). 54ebfa2 and 7f52f19
merge #1729, #1732–#1734, #1736, #1738 and #1739.

Verification:
- `tsc`, `pnpm run lint` (oxlint, format, ast-grep) and
`scripts/cli-lint-rules.test.mjs` (70/70) pass.
- vitest on `test/lib`, `test/grammar` and the dashboard, issue, event,
trace, span, log, replay and feedback command tests: 10,158 passed. The
6 failures are the sandbox-only permission tests (completions
EACCES/EPERM, code-scanner, file-changes, telemetry readonly, upgrade
lock), which also fail on `main`. The full CLI suite shows the same 6,
plus e2e/eval files that need a build or credentials.
- New tests:
- The bare-org fallback: the org from cache and from the API (no
fallback), org missing then fallback hit with the warning, a 403, org
missing then fallback miss with the combined error, a 5xx (propagates,
no fallback), `my-org/ My Dashboard`, and a numeric ref.
  - Issue search with 0, 1 and several hits.
  - Search hints, including `validate: false` deferral.
  - #1738's event view order.
- Differential runs against this PR's previous parsers:
- Issue arguments: 8,575 cases. 1,388 former errors are now searches
(text with spaces), with 0 other differences.
- Dashboard view: 32,552 cases over 0–3 arguments. Every difference is
in one of the categories above: a numeric ID first (2,600), arguments
after a dashboard URL that name another target (688), a numeric ID after
a target with more words (475), and a lone non-Sentry URL (1).

<!-- junior-request-attribution:start -->
via **BYK**.
<!-- junior-request-attribution:end -->

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:agent%3A5a5e7e4543124325a44d731c08dd6089 -->

--

[View Junior Session in
Sentry](https://sentry.sentry.io/explore/conversations/agent%3A5a5e7e4543124325a44d731c08dd6089/?project=4510944073809921)

<!-- junior-session-footer:end -->

---------

Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com>
Co-authored-by: BYK <burak.kaya@sentry.io>
sentry-junior Bot added a commit that referenced this pull request Oct 30, 2026
main moved response parsing into toolkit-core's readJsonResponse
(#1732, #1737), which still throws a plain Error for an empty or
non-JSON body. validateEvents is the only caller that lets a 400 reach
that parser, so classify its non-JSON 400s there with createApiError.
Successful non-JSON responses stay reportable.

This branch was successfully deployed

2 active deployments
Preview — bf0b248a Deployed Oct 29, 2026 by vercel[bot]
Actions — bf0b248a Deployed Oct 29, 2026 by sentry-junior[bot] via eval #1405
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk: medium PR risk score: medium

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant