Skip to content
Open
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: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ jobs:
key: node-modules-${{ hashFiles('bun.lock', 'patches/**') }}
- if: steps.cache.outputs.cache-hit != 'true'
run: bun install --frozen-lockfile
- name: Generate API Schema
run: bun run generate:schema
- name: Check SKILL.md
id: check
run: bun run check:skill
Expand Down Expand Up @@ -143,6 +145,7 @@ jobs:
key: node-modules-${{ hashFiles('bun.lock', 'patches/**') }}
- if: steps.cache.outputs.cache-hit != 'true'
run: bun install --frozen-lockfile
- run: bun run generate:schema
- run: bun run lint
- run: bun run typecheck
- run: bun run check:deps
Expand All @@ -167,6 +170,8 @@ jobs:
key: node-modules-${{ hashFiles('bun.lock', 'patches/**') }}
- if: steps.cache.outputs.cache-hit != 'true'
run: bun install --frozen-lockfile
- name: Generate API Schema
run: bun run generate:schema
- name: Unit Tests
run: bun run test:unit
- name: Isolated Tests
Expand Down Expand Up @@ -412,6 +417,8 @@ jobs:
path: dist-bin
- name: Make binary executable
run: chmod +x dist-bin/sentry-linux-x64
- name: Generate API Schema
run: bun run generate:schema
- name: E2E Tests
env:
SENTRY_CLI_BINARY: ${{ github.workspace }}/dist-bin/sentry-linux-x64
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,8 @@ docs/.astro
# Finder (MacOS) folder config
.DS_Store

# Generated files (rebuilt at build time)
src/generated/

# OpenCode
.opencode/
80 changes: 38 additions & 42 deletions AGENTS.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 6 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"@biomejs/biome": "2.3.8",
"@clack/prompts": "^0.11.0",
"@mastra/client-js": "^1.4.0",
"@sentry/api": "^0.21.0",
"@sentry/api": "^0.54.0",
"@sentry/bun": "10.39.0",
"@sentry/esbuild-plugin": "^2.23.0",
"@sentry/node": "10.39.0",
Expand Down Expand Up @@ -59,10 +59,10 @@
"@stricli/core@1.2.5": "patches/@stricli%2Fcore@1.2.5.patch"
},
"scripts": {
"dev": "bun run src/bin.ts",
"build": "bun run script/build.ts --single",
"build:all": "bun run script/build.ts",
"bundle": "bun run script/bundle.ts",
"dev": "bun run generate:schema && bun run src/bin.ts",
"build": "bun run generate:schema && bun run script/build.ts --single",
"build:all": "bun run generate:schema && bun run script/build.ts",
"bundle": "bun run generate:schema && bun run script/bundle.ts",
"typecheck": "tsc --noEmit",
"lint": "bunx ultracite check",
"lint:fix": "bunx ultracite fix",
Expand All @@ -72,6 +72,7 @@
"test:e2e": "bun test --timeout 15000 test/e2e",
"test:init-eval": "bun test test/init-eval --timeout 600000 --concurrency 6",
"generate:skill": "bun run script/generate-skill.ts",
"generate:schema": "bun run script/generate-api-schema.ts",
"check:skill": "bun run script/check-skill.ts",
"check:deps": "bun run script/check-no-deps.ts"
},
Expand Down
14 changes: 14 additions & 0 deletions plugins/sentry-cli/skills/sentry-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,20 @@ Initialize Sentry in your project
- `--features <value>... - Features to enable: errors,tracing,logs,replay,metrics`
- `-t, --team <value> - Team slug to create the project under`

### Schema

Browse the Sentry API schema

#### `sentry schema <resource...>`

Browse the Sentry API schema

**Flags:**
- `--all - Show all endpoints in a flat list`
- `-q, --search <value> - Search endpoints by keyword`
- `--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
224 changes: 224 additions & 0 deletions script/generate-api-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
#!/usr/bin/env bun
/**
* Generate API Schema Index from Sentry's OpenAPI Specification
*
* Fetches the dereferenced OpenAPI spec from the sentry-api-schema repository
* and extracts a lightweight JSON index of all API endpoints. This index is
* bundled into the CLI for runtime introspection via `sentry schema`.
*
* Data source: https://github.com/getsentry/sentry-api-schema
* - openapi-derefed.json — full dereferenced OpenAPI 3.0 spec
*
* Also reads SDK function names from the installed @sentry/api package to
* map operationIds to their TypeScript SDK function names.
*
* Usage:
* bun run script/generate-api-schema.ts
*
* Output:
* src/generated/api-schema.json
*/

import { resolve } from "node:path";

const OUTPUT_PATH = "src/generated/api-schema.json";

/**
* Build the OpenAPI spec URL from the installed @sentry/api version.
* The sentry-api-schema repo tags match the @sentry/api npm version.
*/
function getOpenApiUrl(): string {
const pkgPath = require.resolve("@sentry/api/package.json");
const pkg = require(pkgPath) as { version: string };
return `https://raw.githubusercontent.com/getsentry/sentry-api-schema/${pkg.version}/openapi-derefed.json`;
}

/** Regex to extract path parameters from URL templates */
const PATH_PARAM_PATTERN = /\{(\w+)\}/g;

// Single source of truth for the ApiEndpoint type lives in src/lib/api-schema.ts.
// Re-export so this file remains a module (required for top-level await).
export type { ApiEndpoint } from "../src/lib/api-schema.js";

import type { ApiEndpoint } from "../src/lib/api-schema.js";

// ---------------------------------------------------------------------------
// OpenAPI Types (minimal subset we need)
// ---------------------------------------------------------------------------

type OpenApiSpec = {
paths: Record<string, Record<string, OpenApiOperation>>;
};

type OpenApiOperation = {
operationId?: string;
description?: string;
deprecated?: boolean;
parameters?: OpenApiParameter[];
};

type OpenApiParameter = {
in: "path" | "query" | "header" | "cookie";
name: string;
required?: boolean;
description?: string;
schema?: { type?: string };
};

// ---------------------------------------------------------------------------
// SDK Function Name Mapping
// ---------------------------------------------------------------------------

/**
* Build a map from URL+method to SDK function name by parsing
* the @sentry/api index.js bundle.
*/
async function buildSdkFunctionMap(): Promise<Map<string, string>> {
const pkgDir = resolve(
require.resolve("@sentry/api/package.json"),
"..",
"dist"
);
const js = await Bun.file(`${pkgDir}/index.js`).text();
const results = new Map<string, string>();

// Match: var NAME = (options...) => (options...client ?? client).METHOD({
const funcPattern =
/var (\w+) = \(options\S*\) => \(options\S*client \?\? client\)\.(\w+)\(/g;
// Match: url: "..."
const urlPattern = /url: "([^"]+)"/g;

// Extract all function declarations with their positions
const funcs: { name: string; method: string; index: number }[] = [];
let match = funcPattern.exec(js);
while (match !== null) {
funcs.push({
name: match[1],
method: match[2].toUpperCase(),
index: match.index,
});
match = funcPattern.exec(js);
}

// Extract all URLs with their positions
const urls: { url: string; index: number }[] = [];
match = urlPattern.exec(js);
while (match !== null) {
urls.push({ url: match[1], index: match.index });
match = urlPattern.exec(js);
}

// Match each function to its nearest following URL
for (const func of funcs) {
const nextUrl = urls.find((u) => u.index > func.index);
if (nextUrl) {
const key = `${func.method}:${nextUrl.url}`;
results.set(key, func.name);
}
}

return results;
}

// ---------------------------------------------------------------------------
// Resource Derivation
// ---------------------------------------------------------------------------

/**
* Derive the resource name from a URL template.
* Uses the last non-parameter path segment.
*
* @example "/api/0/organizations/{org}/issues/" → "issues"
* @example "/api/0/issues/{issue_id}/" → "issues"
*/
function deriveResource(url: string): string {
const segments = url
.split("/")
.filter((s) => s.length > 0 && !s.startsWith("{"));
const meaningful = segments.filter((s) => s !== "api" && s !== "0");
return meaningful.at(-1) ?? "unknown";
}

/**
* Extract path parameter names from a URL template.
*/
function extractPathParams(url: string): string[] {
const params: string[] = [];
const pattern = new RegExp(
PATH_PARAM_PATTERN.source,
PATH_PARAM_PATTERN.flags
);
let match = pattern.exec(url);
while (match !== null) {
params.push(match[1]);
match = pattern.exec(url);
}
return params;
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------

const openApiUrl = getOpenApiUrl();
console.log(`Fetching OpenAPI spec from ${openApiUrl}...`);
const response = await fetch(openApiUrl);
if (!response.ok) {
throw new Error(
`Failed to fetch OpenAPI spec: ${response.status} ${response.statusText}`
);
}
const spec = (await response.json()) as OpenApiSpec;

console.log("Building SDK function name map from @sentry/api...");
const sdkMap = await buildSdkFunctionMap();

const endpoints: ApiEndpoint[] = [];
const HTTP_METHODS = ["get", "post", "put", "delete", "patch"];

for (const [urlPath, pathItem] of Object.entries(spec.paths)) {
for (const method of HTTP_METHODS) {
const operation = pathItem[method] as OpenApiOperation | undefined;
if (!operation) {
continue;
}

const methodUpper = method.toUpperCase();
const sdkKey = `${methodUpper}:${urlPath}`;
const fn = sdkMap.get(sdkKey) ?? "";

const queryParams = (operation.parameters ?? [])
.filter((p) => p.in === "query")
.map((p) => p.name);

endpoints.push({
fn,
method: methodUpper,
path: urlPath,
description: (operation.description ?? "").trim(),
pathParams: extractPathParams(urlPath),
queryParams,
deprecated:
operation.deprecated === true ||
fn.startsWith("deprecated") ||
(operation.operationId ?? "").toLowerCase().includes("deprecated"),
resource: deriveResource(urlPath),
operationId: operation.operationId ?? "",
});
}
}

// Sort by resource, then method for stable output
endpoints.sort((a, b) => {
const resourceCmp = a.resource.localeCompare(b.resource);
if (resourceCmp !== 0) {
return resourceCmp;
}
return a.operationId.localeCompare(b.operationId);
});

await Bun.write(OUTPUT_PATH, JSON.stringify(endpoints, null, 2));

console.log(
`Generated ${OUTPUT_PATH} (${endpoints.length} endpoints, ${Math.round(JSON.stringify(endpoints).length / 1024)}KB)`
);
2 changes: 2 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { projectRoute } from "./commands/project/index.js";
import { listCommand as projectListCommand } from "./commands/project/list.js";
import { repoRoute } from "./commands/repo/index.js";
import { listCommand as repoListCommand } from "./commands/repo/list.js";
import { schemaCommand } from "./commands/schema.js";
import { spanRoute } from "./commands/span/index.js";
import { listCommand as spanListCommand } from "./commands/span/list.js";
import { teamRoute } from "./commands/team/index.js";
Expand Down Expand Up @@ -75,6 +76,7 @@ export const routes = buildRouteMap({
trial: trialRoute,
init: initCommand,
api: apiCommand,
schema: schemaCommand,
issues: issueListCommand,
orgs: orgListCommand,
projects: projectListCommand,
Expand Down
Loading
Loading