diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7295f102..3d714718 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/.gitignore b/.gitignore index 60cff34f..451f64e4 100644 --- a/.gitignore +++ b/.gitignore @@ -53,5 +53,8 @@ docs/.astro # Finder (MacOS) folder config .DS_Store +# Generated files (rebuilt at build time) +src/generated/ + # OpenCode .opencode/ diff --git a/AGENTS.md b/AGENTS.md index 0644a287..8201696b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -767,69 +767,65 @@ mock.module("./some-module", () => ({ ### Architecture - -* **api-client.ts split into domain modules under src/lib/api/**: The original monolithic \`src/lib/api-client.ts\` (1,977 lines) was split into 12 focused domain modules under \`src/lib/api/\`: infrastructure.ts (shared helpers, types, raw requests), organizations.ts, projects.ts, teams.ts, repositories.ts, issues.ts, events.ts, traces.ts, logs.ts, seer.ts, trials.ts, users.ts. The original \`api-client.ts\` was converted to a ~100-line barrel re-export file preserving all existing import paths. The \`biome.jsonc\` override for \`noBarrelFile\` already includes \`api-client.ts\`. When adding new API functions, place them in the appropriate domain module under \`src/lib/api/\`, not in the barrel file. + +* **API client wraps all errors as CliError subclasses — no raw exceptions escape**: The API client (src/lib/api-client.ts) wraps ALL errors as CliError subclasses (ApiError or AuthError) — no raw exceptions escape. Commands don't need try-catch for error display; the central handler in app.ts formats CliError cleanly. Only add try-catch when a command needs to handle errors specially (e.g., login continuing despite user-info fetch failure). - -* **CLI telemetry DSN is public write-only — safe to embed in install script**: The CLI's Sentry DSN (\`SENTRY\_CLI\_DSN\` in \`src/lib/constants.ts\`) is a public write-only ingest key already baked into every binary. Safe to hardcode in install scripts. Opt-out: \`SENTRY\_CLI\_NO\_TELEMETRY=1\`. + +* **Sentry API: events require org+project, issues have legacy global endpoint**: Sentry API scoping: Events require org+project in URL path (\`/projects/{org}/{project}/events/{id}/\`). Issues use legacy global endpoint (\`/api/0/issues/{id}/\`) without org context. Traces need only org (\`/organizations/{org}/trace/{traceId}/\`). Two-step lookup for events: fetch issue → extract org/project from response → fetch event. Cross-project event search possible via Discover endpoint \`/organizations/{org}/events/\` with \`query=id:{eventId}\`. - -* **cli.sentry.dev is served from gh-pages branch via GitHub Pages**: \`cli.sentry.dev\` is served from gh-pages branch via GitHub Pages. Craft's gh-pages target runs \`git rm -r -f .\` before extracting docs — persist extra files via \`postReleaseCommand\` in \`.craft.yml\`. Install script supports \`--channel nightly\`, downloading from the \`nightly\` release tag directly. version.json is only used by upgrade/version-check flow. + +* **Sentry CLI authenticated fetch architecture with response caching**: \`createAuthenticatedFetch()\` wraps fetch with auth, 30s timeout, retry (max 2), 401 refresh, and span tracing. Response caching integrates BEFORE auth/retry via \`http-cache-semantics\` (RFC 7234) with filesystem storage at \`~/.sentry/cache/responses/\`. URL-based fallback TTL tiers: immutable (24hr), stable (5min), volatile (60s), no-cache (0). Only GET 2xx cached. \`--fresh\` and \`SENTRY\_NO\_CACHE=1\` bypass cache. Cache cleared on login/logout. \`hasServerCacheDirectives(policy)\` distinguishes \`max-age=0\` from missing headers. - -* **Nightly delta upgrade buildNightlyPatchGraph fetches ALL patch tags — O(N) HTTP calls**: Delta upgrade in \`src/lib/delta-upgrade.ts\` supports stable (GitHub Releases) and nightly (GHCR) channels. \`filterAndSortChainTags\` filters \`patch-\*\` tags by version range using \`Bun.semver.order()\`. GHCR uses \`fetchWithRetry\` (10s timeout + 1 retry; blobs 30s) with optional \`signal?: AbortSignal\` combined via \`AbortSignal.any()\`. \`isExternalAbort(error, signal)\` skips retries for external aborts — critical for background prefetch. Patches cached to \`~/.sentry/patch-cache/\` (file-based, 7-day TTL). \`loadCachedChain\` stitches patches for multi-hop offline upgrades. + +* **Sentry CLI has two distribution channels with different runtimes**: Sentry CLI ships two ways: (1) Standalone binary via \`Bun.build()\` with \`compile: true\`. (2) npm package via esbuild producing CJS \`dist/bin.cjs\` for Node 22+, with Bun API polyfills from \`script/node-polyfills.ts\`. \`Bun.$\` has NO polyfill — use \`execSync\` instead. \`require()\` in ESM is safe (Bun native, esbuild resolves at bundle time). - -* **npm bundle requires Node.js >= 22 due to node:sqlite polyfill**: The npm package (dist/bin.cjs) requires Node.js >= 22 because the bun:sqlite polyfill uses \`node:sqlite\`. A runtime version guard in the esbuild banner catches this early. When writing esbuild banner strings in TS template literals, double-escape: \`\\\\\\\n\` in TS → \`\\\n\` in output → newline at runtime. Single \`\\\n\` produces a literal newline inside a JS string, causing SyntaxError. - - -* **Numeric issue ID resolution returns org:undefined despite API success**: Numeric issue ID resolution in \`resolveNumericIssue()\`: (1) try DSN/env/config for org, (2) if found use \`getIssueInOrg(org, id)\` with region routing, (3) else fall back to unscoped \`getIssue(id)\`, (4) extract org from \`issue.permalink\` via \`parseSentryUrl\` as final fallback. \`parseSentryUrl\` handles path-based (\`/organizations/{org}/...\`) and subdomain-style URLs. \`matchSubdomainOrg()\` filters region subdomains by requiring slug length > 2. Self-hosted uses path-based only. - - -* **Seer trial prompt uses middleware layering in bin.ts error handling chain**: The CLI's error recovery middlewares in \`bin.ts\` are layered: \`main() → executeWithAutoAuth() → executeWithSeerTrialPrompt() → runCommand()\`. Seer trial prompts (for \`no\_budget\`/\`not\_enabled\` errors) are caught by the inner wrapper; auth errors bubble up to the outer wrapper. After successful auth login retry, the retry also goes through \`executeWithSeerTrialPrompt\` (not \`runCommand\` directly) so the full middleware chain applies. Trial check API: \`GET /api/0/customers/{org}/\` → \`productTrials\[]\` (prefer \`seerUsers\`, fallback \`seerAutofix\`). Start trial: \`PUT /api/0/customers/{org}/product-trial/\`. The \`/customers/\` endpoint is getsentry SaaS-only; self-hosted 404s gracefully. \`ai\_disabled\` errors are excluded (admin's explicit choice). \`startSeerTrial\` accepts \`category\` from the trial object — don't hardcode it. + +* **Sentry CLI resolve-target cascade has 5 priority levels with env var support**: Resolve-target cascade (src/lib/resolve-target.ts) has 5 priority levels: (1) Explicit CLI flags, (2) SENTRY\_ORG/SENTRY\_PROJECT env vars, (3) SQLite config defaults, (4) DSN auto-detection, (5) Directory name inference. SENTRY\_PROJECT supports combo notation \`org/project\` — when used, SENTRY\_ORG is ignored. If combo parse fails (e.g. \`org/\`), the entire value is discarded. The \`resolveFromEnvVars()\` helper is injected into all four resolution functions. ### Decision - -* **Raw markdown output for non-interactive terminals, rendered for TTY**: Markdown-first output pipeline: custom renderer in \`src/lib/formatters/markdown.ts\` walks \`marked\` tokens to produce ANSI-styled output. Commands build CommonMark using helpers (\`mdKvTable()\`, \`mdRow()\`, \`colorTag()\`, \`escapeMarkdownCell()\`, \`safeCodeSpan()\`) and pass through \`renderMarkdown()\`. \`isPlainOutput()\` precedence: \`SENTRY\_PLAIN\_OUTPUT\` > \`NO\_COLOR\` > \`FORCE\_COLOR\` > \`!isTTY\`. \`--json\` always outputs JSON. Colors defined in \`COLORS\` object in \`colors.ts\`. Tests run non-TTY so assertions match raw CommonMark; use \`stripAnsi()\` helper for rendered-mode assertions. + +* **Issue list global limit with fair per-project distribution and representation guarantees**: \`issue list --limit\` is a global total across all detected projects. \`fetchWithBudget\` Phase 1 divides evenly, Phase 2 redistributes surplus via cursor resume. \`trimWithProjectGuarantee\` ensures at least 1 issue per project before filling remaining slots. JSON output wraps in \`{ data, hasMore }\` with optional \`errors\` array. Compound cursor (pipe-separated) enables \`-c last\` for multi-target pagination, keyed by sorted target fingerprint. - -* **whoami should be separate from auth status command**: The \`sentry auth whoami\` command should be a dedicated command separate from \`sentry auth status\`. They serve different purposes: \`status\` shows everything about auth state (token, expiry, defaults, org verification), while \`whoami\` just shows user identity (name, email, username, ID) by fetching live from \`/auth/\` endpoint. \`sentry whoami\` should be a top-level alias (like \`sentry issues\` → \`sentry issue list\`). \`whoami\` should support \`--json\` for machine consumption and be lightweight — no credential verification, no defaults listing. + +* **Sentry CLI config dir should stay at ~/.sentry/, not move to XDG**: Config dir stays at \`~/.sentry/\` (not XDG). The readonly DB errors on macOS are from \`sudo brew install\` creating root-owned files. Fixes: (1) bestEffort() makes setup steps non-fatal, (2) tryRepairReadonly() detects root-owned files and prints \`sudo chown\` instructions, (3) \`sentry cli fix\` handles ownership repair. Ownership must be checked BEFORE permissions — root-owned files cause chmod to EPERM. ### Gotcha - -* **@sentry/api SDK passes Request object to custom fetch — headers lost on Node.js**: @sentry/api SDK calls \`\_fetch(request)\` with no init object. In \`authenticatedFetch\`, \`init\` is undefined so \`prepareHeaders\` creates empty headers — on Node.js this strips Content-Type (HTTP 415). Fix: fall back to \`input.headers\` when \`init\` is undefined. Use \`unwrapPaginatedResult\` (not \`unwrapResult\`) to access the Response's Link header for pagination. \`per\_page\` is not in SDK types; cast query to pass it at runtime. + +* **brew is not in VALID\_METHODS but Homebrew formula passes --method brew**: Homebrew install: \`isHomebrewInstall()\` detects via Cellar realpath (checked before stored install info). Upgrade command tells users \`brew upgrade getsentry/tools/sentry\`. Formula runs \`sentry cli setup --method brew --no-modify-path\` as post\_install. Version pinning throws 'unsupported\_operation'. Uses .gz artifacts. Tap at getsentry/tools. + + +* **Bun mock.module() leaks globally across test files in same process**: Bun's mock.module() replaces modules globally and leaks across test files in the same process. Solution: tests using mock.module() must run in a separate \`bun test\` invocation. In package.json, use \`bun run test:unit && bun run test:isolated\` instead of \`bun test\`. The \`test/isolated/\` directory exists for these tests. This was the root cause of ~100 test failures (getsentry/cli#258). - -* **Bun binary build requires SENTRY\_CLIENT\_ID env var**: The build script (\`script/bundle.ts\`) requires \`SENTRY\_CLIENT\_ID\` environment variable and exits with code 1 if missing. When building locally, use \`bun run --env-file=.env.local build\` or set the env var explicitly. The binary build (\`bun run build\`) also needs it. Without it you get: \`Error: SENTRY\_CLIENT\_ID environment variable is required.\` + +* **Making clearAuth() async breaks model-based tests — use non-async Promise\ return instead**: Making \`clearAuth()\` \`async\` breaks fast-check model-based tests — real async yields (macrotasks) during \`asyncModelRun\` cause \`createIsolatedDbContext\` cleanup to interleave. Fix: keep non-async, return \`clearResponseCache().catch(...)\` directly. Model-based tests should NOT await it. Also: model-based tests need explicit timeouts (e.g., \`30\_000\`) — Bun's default 5s causes false failures during shrinking. - -* **GitHub immutable releases prevent rolling nightly tag pattern**: getsentry/cli has immutable GitHub releases — assets can't be modified and tags can NEVER be reused. Nightly builds publish to GHCR with versioned tags like \`nightly-0.14.0-dev.1772661724\`, not GitHub Releases or npm. \`fetchManifest()\` throws \`UpgradeError("network\_error")\` for both network failures and non-200 — callers must check message for HTTP 404/403. Craft with no \`preReleaseCommand\` silently skips \`bump-version.sh\` if only target is \`github\`. + +* **Multiregion mock must include all control silo API routes**: When changing which Sentry API endpoint a function uses, mock routes must be updated in BOTH \`test/mocks/routes.ts\` (single-region) AND \`test/mocks/multiregion.ts\` \`createControlSiloRoutes()\`. Missing the multiregion mock causes 404s in multi-region test scenarios. - -* **Install script: BSD sed and awk JSON parsing breaks OCI digest extraction**: The install script parses OCI manifests with awk (no jq). Key trap: BSD sed \`\n\` is literal, not newline. Fix: single awk pass tracking last-seen \`"digest"\`, printing when \`"org.opencontainers.image.title"\` matches target. The config digest (\`sha256:44136fa...\`) is a 2-byte \`{}\` blob — downloading it instead of the real binary causes \`gunzip: unexpected end of file\`. + +* **Sentry /users/me/ endpoint returns 403 for OAuth tokens — use /auth/ instead**: The Sentry \`/users/me/\` endpoint returns 403 for OAuth tokens. Use \`/auth/\` instead — it works with ALL token types and lives on the control silo. In the CLI, \`getControlSiloUrl()\` handles routing correctly. \`SentryUserSchema\` (with \`.passthrough()\`) handles the \`/auth/\` response since it only requires \`id\`. - -* **Multiple mockFetch calls replace each other — use unified mocks for multi-endpoint tests**: Bun test mocking gotchas: (1) \`mockFetch()\` replaces \`globalThis.fetch\` — calling it twice replaces the first mock. Use a single unified fetch mock dispatching by URL pattern. (2) \`mock.module()\` pollutes the module registry for ALL subsequent test files. Tests using it must live in \`test/isolated/\` and run via \`test:isolated\`. This also causes \`delta-upgrade.test.ts\` to fail when run alongside \`test/isolated/delta-upgrade.test.ts\` — the isolated test's \`mock.module()\` replaces \`CLI\_VERSION\` for all subsequent files. (3) For \`Bun.spawn\`, use direct property assignment in \`beforeEach\`/\`afterEach\`. + +* **Stricli command context uses this.stdout not this.process.stdout**: In Stricli command \`func()\` handlers, use \`this.stdout\` and \`this.stderr\` directly — NOT \`this.process.stdout\`. The \`SentryContext\` interface has both \`process\` and \`stdout\`/\`stderr\` as separate top-level properties. Test mock contexts typically provide \`stdout\` but not a full \`process\` object, so \`this.process.stdout\` causes \`TypeError: undefined is not an object\` at runtime in tests even though TypeScript doesn't flag it. - -* **useTestConfigDir without isolateProjectRoot causes DSN scanning of repo tree**: \`useTestConfigDir()\` creates temp dirs under \`.test-tmp/\` in the repo tree. Without \`{ isolateProjectRoot: true }\`, \`findProjectRoot\` walks up and finds the repo's \`.git\`, causing DSN detection to scan real source code and trigger network calls against test mocks (timeouts). Always pass \`isolateProjectRoot: true\` when tests exercise \`resolveOrg\`, \`detectDsn\`, or \`findProjectRoot\`. + +* **Stricli defaultCommand blends default command flags into route completions**: When a Stricli route map has \`defaultCommand\` set, requesting completions for that route (e.g. \`\["issues", ""]\`) returns both the subcommand names AND the default command's flags/positional completions. This means completion tests that compare against \`extractCommandTree()\` subcommand lists will fail for groups with defaultCommand, since the actual completions include extra entries like \`--limit\`, \`--query\`, etc. Solution: track \`hasDefaultCommand\` in the command tree and skip strict subcommand-matching assertions for those groups. ### Pattern - -* **Org-scoped SDK calls follow getOrgSdkConfig + unwrapResult pattern**: All org-scoped API calls in src/lib/api-client.ts: (1) call \`getOrgSdkConfig(orgSlug)\` for regional URL + SDK config, (2) spread into SDK function: \`{ ...config, path: { organization\_id\_or\_slug: orgSlug, ... } }\`, (3) pass to \`unwrapResult(result, errorContext)\`. Shared helpers \`resolveAllTargets\`/\`resolveOrgAndProject\` must NOT call \`fetchProjectId\` — commands that need it enrich targets themselves. + +* **Extract logic from Stricli func() handlers into standalone functions for testability**: Stricli command \`func()\` handlers are hard to unit test because they require full command context setup. To boost coverage, extract flag validation and body-building logic into standalone exported functions (e.g., \`resolveBody()\` extracted from the \`api\` command's \`func()\`). This moved ~20 lines of mutual-exclusivity checks and flag routing from an untestable handler into a directly testable pure function. Property-based tests on the extracted function drove patch coverage from 78% to 97%. The general pattern: keep \`func()\` as a thin orchestrator that calls exported helpers. This also keeps biome complexity under the limit (max 15). - -* **PR workflow: wait for Seer and Cursor BugBot before resolving**: After pushing a PR in the getsentry/cli repo, the CI pipeline includes Seer Code Review and Cursor Bugbot as advisory checks. Both typically take 2-3 minutes but may not trigger on draft PRs — only ready-for-review PRs reliably get bot reviews. The workflow is: push → wait for all CI (including npm build jobs which test the actual bundle) → check for inline review comments from Seer/BugBot → fix if needed → repeat. Use \`gh pr checks \ --watch\` to monitor. Review comments are fetched via \`gh api repos/OWNER/REPO/pulls/NUM/comments\` and \`gh api repos/OWNER/REPO/pulls/NUM/reviews\`. + +* **Non-essential DB cache writes should be guarded with try-catch**: Non-essential DB cache writes (e.g., \`setUserInfo()\` in whoami.ts and login.ts) must be wrapped in try-catch. If the DB is broken, the cache write shouldn't crash the command when its primary operation already succeeded. In login.ts specifically, \`getCurrentUser()\` failure after token save must not block authentication — wrap in try-catch, log warning to stderr, let login succeed. This differs from \`getUserRegions()\` failure which should \`clearAuth()\` and fail hard (indicates invalid token). - -* **Shared pagination infrastructure: buildPaginationContextKey and parseCursorFlag**: List commands with cursor pagination use \`buildPaginationContextKey(type, identifier, flags)\` for composite context keys and \`parseCursorFlag(value)\` accepting \`"last"\` magic value. Critical: \`resolveCursor()\` must be called inside the \`org-all\` override closure, not before \`dispatchOrgScopedList\` — otherwise cursor validation errors fire before the correct mode-specific error. + +* **Stricli buildCommand output config injects json flag into func params**: When a Stricli command uses \`output: { json: true, human: formatFn }\`, the framework injects \`--json\` and \`--fields\` flags automatically. The \`func\` handler receives these as its first parameter. Type it explicitly (e.g., \`flags: { json?: boolean }\`) rather than \`\_flags: unknown\` to access the json flag for conditional behavior (e.g., skipping interactive output in JSON mode). The \`human\` formatter runs on the returned \`data\` for non-JSON output. Commands that produce interactive side effects (browser prompts, QR codes) should check \`flags.json\` and skip them when true. - -* **Telemetry instrumentation pattern: withTracingSpan + captureException for handled errors**: For graceful-fallback operations, use \`withTracingSpan\` from \`src/lib/telemetry.ts\` for child spans and \`captureException\` from \`@sentry/bun\` (named import — Biome forbids namespace imports) with \`level: 'warning'\` for non-fatal errors. \`withTracingSpan\` uses \`onlyIfParent: true\` — no-op without active transaction. User-visible fallbacks use \`log.warn()\` not \`log.debug()\`. Several commands bypass telemetry by importing \`buildCommand\` from \`@stricli/core\` directly instead of \`../../lib/command.js\` (trace/list, trace/view, log/view, api.ts, help.ts). +### Preference - -* **Testing Stricli command func() bodies via spyOn mocking**: To unit-test a Stricli command's \`func()\` body: (1) \`const func = await cmd.loader()\`, (2) \`func.call(mockContext, flags, ...args)\` with mock \`stdout\`, \`stderr\`, \`cwd\`, \`setContext\`. (3) \`spyOn\` namespace imports to mock dependencies (e.g., \`spyOn(apiClient, 'getLogs')\`). The \`loader()\` return type union causes \`.call()\` LSP errors — these are false positives that pass \`tsc --noEmit\`. When API functions are renamed (e.g., \`getLog\` → \`getLogs\`), update both spy target name AND mock return shape (single → array). Slug normalization (\`normalizeSlug\`) replaces underscores with dashes but does NOT lowercase — test assertions must match original casing (e.g., \`'CAM-82X'\` not \`'cam-82x'\`). + +* **PR workflow: address review comments, resolve threads, wait for CI**: User's PR workflow after creation: (1) Wait for CI checks to pass, (2) Check for unresolved review comments via \`gh api\` for PR review comments, (3) Fix issues in follow-up commits (not amends), (4) Reply to the comment thread explaining the fix, (5) Resolve the thread programmatically via \`gh api graphql\` with \`resolveReviewThread\` mutation, (6) Push and wait for CI again, (7) Final sweep for any remaining unresolved comments. Use \`git notes add\` to attach implementation plans to commits. Branch naming: \`fix/descriptive-slug\` or \`feat/descriptive-slug\`. diff --git a/bun.lock b/bun.lock index 7dcf0a52..5150711f 100644 --- a/bun.lock +++ b/bun.lock @@ -9,7 +9,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", @@ -272,7 +272,7 @@ "@prisma/instrumentation": ["@prisma/instrumentation@7.2.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.207.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-Rh9Z4x5kEj1OdARd7U18AtVrnL6rmLSI0qYShaB4W7Wx5BKbgzndWF+QnuzMb7GLfVdlT5aYCXoPQVYuYtVu0g=="], - "@sentry/api": ["@sentry/api@0.21.0", "", {}, "sha512-Q4Et4FfIbZ9gETLLG+SJ0/IpUCXC3W0C/o7jOQAfOj0qUgK5Rk0w1AHs23GTJhiN1QdlFSyy4QNS+JComkK6EQ=="], + "@sentry/api": ["@sentry/api@0.54.0", "", {}, "sha512-ZBvp9Q95WDr2CCEsjUH3hEhm2rCfV8Mta5s7Y+ksXbR6VGmCPtY2Wkm5ISIcb/yYP/HhdQmPg4mYPtYpEti4yQ=="], "@sentry/babel-plugin-component-annotate": ["@sentry/babel-plugin-component-annotate@2.23.1", "", {}, "sha512-l1z8AvI6k9I+2z49OgvP3SlzB1M0Lw24KtceiJibNaSyQwxsItoT9/XftZ/8BBtkosVmNOTQhL1eUsSkuSv1LA=="], diff --git a/package.json b/package.json index 796c1c90..18ce8b45 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", @@ -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" }, diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 24d8adfc..885643cc 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -737,6 +737,20 @@ Initialize Sentry in your project - `--features ... - Features to enable: errors,tracing,logs,replay,metrics` - `-t, --team - Team slug to create the project under` +### Schema + +Browse the Sentry API schema + +#### `sentry schema ` + +Browse the Sentry API schema + +**Flags:** +- `--all - Show all endpoints in a flat list` +- `-q, --search - Search endpoints by keyword` +- `--json - Output as JSON` +- `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` + ## Global Options All commands support the following global options: diff --git a/script/generate-api-schema.ts b/script/generate-api-schema.ts new file mode 100644 index 00000000..38af7b1c --- /dev/null +++ b/script/generate-api-schema.ts @@ -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>; +}; + +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> { + const pkgDir = resolve( + require.resolve("@sentry/api/package.json"), + "..", + "dist" + ); + const js = await Bun.file(`${pkgDir}/index.js`).text(); + const results = new Map(); + + // 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)` +); diff --git a/src/app.ts b/src/app.ts index 1f67eaf3..a3350b1e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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"; @@ -75,6 +76,7 @@ export const routes = buildRouteMap({ trial: trialRoute, init: initCommand, api: apiCommand, + schema: schemaCommand, issues: issueListCommand, orgs: orgListCommand, projects: projectListCommand, diff --git a/src/commands/schema.ts b/src/commands/schema.ts new file mode 100644 index 00000000..c47b0951 --- /dev/null +++ b/src/commands/schema.ts @@ -0,0 +1,342 @@ +/** + * sentry schema + * + * Browse and search the Sentry API schema. Shows available resources, + * operations, and endpoint details from the generated API index. + * + * Usage: + * sentry schema → list all resources + * sentry schema --all → flat list of all endpoints + * sentry schema --search → search endpoints by keyword + * sentry schema → show endpoints for a resource + * sentry schema → show detailed endpoint info + * sentry schema monitor* → glob search for resources + */ + +import type { SentryContext } from "../context.js"; +import { + type ApiEndpoint, + getAllEndpoints, + getEndpoint, + getEndpointsByResource, + getResourceSummaries, + type ResourceSummary, + searchEndpoints, +} from "../lib/api-schema.js"; +import { buildCommand } from "../lib/command.js"; +import { OutputError } from "../lib/errors.js"; +import { bold, cyan, muted, yellow } from "../lib/formatters/colors.js"; +import { filterFields } from "../lib/formatters/json.js"; +import { + colorTag, + mdKvTable, + renderMarkdown, +} from "../lib/formatters/markdown.js"; +import { CommandOutput } from "../lib/formatters/output.js"; + +// --------------------------------------------------------------------------- +// Output data types +// --------------------------------------------------------------------------- + +/** Discriminated union of all possible schema command outputs */ +type SchemaResult = + | { kind: "resources"; resources: ResourceSummary[] } + | { kind: "endpoints"; endpoints: readonly ApiEndpoint[] } + | { kind: "endpoint"; endpoint: ApiEndpoint }; + +// --------------------------------------------------------------------------- +// Human formatters +// --------------------------------------------------------------------------- + +/** Format a method string with appropriate coloring */ +function formatMethod(method: string): string { + switch (method) { + case "GET": + return cyan(method); + case "POST": + return bold(method); + case "PUT": + return yellow(method); + case "DELETE": + return bold(method); + default: + return method; + } +} + +/** Format the resource summary table (default view) */ +function formatResourceList(resources: ResourceSummary[]): string { + if (resources.length === 0) { + return muted("No resources found."); + } + + const maxName = Math.max(...resources.map((r) => r.name.length)); + const maxCount = Math.max( + ...resources.map((r) => String(r.endpointCount).length) + ); + const padding = 4; + + const header = `${bold("RESOURCE".padEnd(maxName + padding))}${bold("COUNT".padEnd(maxCount + padding))}${bold("METHODS")}`; + + const rows = resources.map((r) => { + const name = cyan(r.name.padEnd(maxName + padding)); + const count = String(r.endpointCount).padEnd(maxCount + padding); + const methods = muted(r.methods.join(", ")); + return `${name}${count}${methods}`; + }); + + return [header, ...rows].join("\n"); +} + +/** Format a flat list of endpoints */ +function formatEndpointList(endpoints: readonly ApiEndpoint[]): string { + if (endpoints.length === 0) { + return muted("No endpoints found."); + } + + const maxMethod = Math.max(...endpoints.map((e) => e.method.length)); + const padding = 2; + + return endpoints + .map((e) => { + const colored = formatMethod(e.method); + const padded = `${colored}${" ".repeat(maxMethod + padding - e.method.length)}`; + const path = e.path; + const label = muted(e.operationId || e.fn); + const deprecated = e.deprecated ? yellow(" [deprecated]") : ""; + return `${padded}${path} ${label}${deprecated}`; + }) + .join("\n"); +} + +/** Format a single endpoint in detail using mdKvTable + renderMarkdown */ +function formatEndpointDetail(endpoint: ApiEndpoint): string { + const kvRows: [string, string][] = [ + ["Resource", endpoint.resource], + ["Operation", endpoint.operationId], + ...(endpoint.fn + ? [["Function", `\`${endpoint.fn}\``] as [string, string]] + : []), + ]; + + if (endpoint.deprecated) { + kvRows.push(["Status", colorTag("yellow", "deprecated")]); + } + + if (endpoint.pathParams.length > 0) { + kvRows.push(["Path Params", endpoint.pathParams.join(", ")]); + } + + if (endpoint.queryParams.length > 0) { + kvRows.push(["Query Params", endpoint.queryParams.join(", ")]); + } + + const heading = `## ${endpoint.method} \`${endpoint.path}\``; + const parts = [heading, "", mdKvTable(kvRows)]; + + if (endpoint.description) { + parts.push("", endpoint.description); + } + + return renderMarkdown(parts.join("\n")); +} + +/** Human renderer for all schema result variants */ +function formatSchemaHuman(data: SchemaResult): string { + switch (data.kind) { + case "resources": + return formatResourceList(data.resources); + case "endpoints": + return formatEndpointList(data.endpoints); + case "endpoint": + return formatEndpointDetail(data.endpoint); + default: + return ""; + } +} + +// --------------------------------------------------------------------------- +// JSON transform — strip the internal `kind` discriminant +// --------------------------------------------------------------------------- + +/** + * Transform schema output for JSON serialization. + * Strips the internal `kind` discriminant and applies `--fields` filtering. + */ +function jsonTransformSchema(data: SchemaResult, fields?: string[]): unknown { + let result: unknown; + switch (data.kind) { + case "resources": + result = data.resources; + break; + case "endpoints": + result = data.endpoints; + break; + case "endpoint": + result = data.endpoint; + break; + default: + result = data; + } + if (fields && fields.length > 0) { + return filterFields(result, fields); + } + return result; +} + +// --------------------------------------------------------------------------- +// Query resolution +// --------------------------------------------------------------------------- + +/** + * Resolve a resource + optional operation into a SchemaResult. + * Throws OutputError with fallback data for not-found cases. + */ +function resolveResourceQuery( + resource: string, + operation?: string +): SchemaResult { + // Glob-style search: if the resource arg contains * or ?, match resources + if (resource.includes("*") || resource.includes("?")) { + // Convert glob pattern to regex: * → .*, ? → ., escape other special chars + const escaped = resource + .toLowerCase() + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*") + .replace(/\?/g, "."); + const pattern = new RegExp(`^${escaped}$`); + const allResources = getResourceSummaries(); + const matched = allResources.filter((r) => + pattern.test(r.name.toLowerCase()) + ); + if (matched.length === 0) { + throw new OutputError({ + kind: "resources", + resources: allResources, + } satisfies SchemaResult); + } + const endpoints = matched.flatMap((r) => getEndpointsByResource(r.name)); + return { kind: "endpoints", endpoints }; + } + + // Resource + operation: show single endpoint detail + if (operation) { + const endpoint = getEndpoint(resource, operation); + if (endpoint) { + return { kind: "endpoint", endpoint }; + } + // Show endpoints for this resource if it exists, otherwise show all resources + const resourceEndpoints = getEndpointsByResource(resource); + if (resourceEndpoints.length > 0) { + throw new OutputError({ + kind: "endpoints", + endpoints: resourceEndpoints, + } satisfies SchemaResult); + } + throw new OutputError({ + kind: "resources", + resources: getResourceSummaries(), + } satisfies SchemaResult); + } + + // Resource only: show all endpoints for that resource + const endpoints = getEndpointsByResource(resource); + if (endpoints.length === 0) { + throw new OutputError({ + kind: "resources", + resources: getResourceSummaries(), + } satisfies SchemaResult); + } + return { kind: "endpoints", endpoints }; +} + +// --------------------------------------------------------------------------- +// Command definition +// --------------------------------------------------------------------------- + +type SchemaFlags = { + readonly all: boolean; + readonly search?: string; +}; + +export const schemaCommand = buildCommand({ + docs: { + brief: "Browse the Sentry API schema", + fullDescription: + "Browse and search the Sentry API schema. Shows available resources, " + + "operations, and endpoint details. Use with --json for machine-readable output.\n\n" + + "Examples:\n" + + " sentry schema List all API resources\n" + + " sentry schema issues Show endpoints for a resource\n" + + " sentry schema issues list Show details for one endpoint\n" + + " sentry schema --all Flat list of all endpoints\n" + + " sentry schema --search monitor Search endpoints by keyword", + }, + output: { + human: formatSchemaHuman, + jsonTransform: jsonTransformSchema, + }, + parameters: { + flags: { + all: { + kind: "boolean", + brief: "Show all endpoints in a flat list", + default: false, + }, + search: { + kind: "parsed", + parse: String, + brief: "Search endpoints by keyword", + optional: true, + }, + }, + aliases: { q: "search" }, + positional: { + kind: "array", + parameter: { + brief: "Resource name and optional operation", + parse: String, + placeholder: "resource", + }, + }, + }, + // biome-ignore lint/suspicious/useAwait: Stricli requires AsyncGenerator but schema queries are synchronous (in-memory JSON) + async *func(this: SentryContext, flags: SchemaFlags, ...args: string[]) { + const [resource, operation] = args; + + // --search takes priority + if (flags.search) { + const results = searchEndpoints(flags.search); + if (results.length === 0) { + throw new OutputError({ + kind: "endpoints", + endpoints: [], + } satisfies SchemaResult); + } + return yield new CommandOutput({ + kind: "endpoints", + endpoints: results, + }); + } + + // --all: flat endpoint list + if (flags.all) { + return yield new CommandOutput({ + kind: "endpoints", + endpoints: getAllEndpoints(), + }); + } + + // No positional args: show resource summary + if (!resource) { + const resources = getResourceSummaries(); + return yield new CommandOutput({ + kind: "resources", + resources, + }); + } + + // Resolve resource (with optional operation or glob pattern) + return yield new CommandOutput(resolveResourceQuery(resource, operation)); + }, +}); diff --git a/src/lib/api-schema.ts b/src/lib/api-schema.ts new file mode 100644 index 00000000..27a669b9 --- /dev/null +++ b/src/lib/api-schema.ts @@ -0,0 +1,136 @@ +/** + * API Schema Query Functions + * + * Loads the generated API schema index and provides query functions + * for the `sentry schema` command. The schema is generated by + * `script/generate-api-schema.ts` from Sentry's OpenAPI specification. + */ + +import endpoints from "../generated/api-schema.json"; + +/** A single API endpoint entry from the generated schema */ +export type ApiEndpoint = { + /** SDK function name from @sentry/api (empty string if unmapped) */ + fn: string; + /** HTTP method (GET, POST, PUT, DELETE, PATCH) */ + method: string; + /** URL path template with {param} placeholders */ + path: string; + /** Human-readable description of the endpoint */ + description: string; + /** Path parameter names extracted from the URL template */ + pathParams: string[]; + /** Query parameter names from the OpenAPI spec */ + queryParams: string[]; + /** Whether the endpoint is deprecated */ + deprecated: boolean; + /** Resource group name derived from URL (e.g., "projects", "issues") */ + resource: string; + /** Human-readable operation ID from OpenAPI (e.g., "List an Organization's Issues") */ + operationId: string; +}; + +/** Summary of a resource group with its operations */ +export type ResourceSummary = { + /** Resource group name */ + name: string; + /** Number of endpoints in this resource */ + endpointCount: number; + /** List of distinct HTTP methods available */ + methods: string[]; +}; + +/** Type the imported JSON array */ +const schema = endpoints as ApiEndpoint[]; + +/** Get all unique resource names, sorted alphabetically */ +export function getAllResources(): string[] { + const resources = new Set(schema.map((e) => e.resource)); + return [...resources].sort(); +} + +/** Get all endpoints for a given resource name (case-insensitive) */ +export function getEndpointsByResource(resource: string): ApiEndpoint[] { + const lower = resource.toLowerCase(); + return schema.filter((e) => e.resource.toLowerCase() === lower); +} + +/** + * Get a specific endpoint by resource + operationId substring (case-insensitive). + * Prefers operationIds that start with the query over substring matches. + */ +export function getEndpoint( + resource: string, + operationQuery: string +): ApiEndpoint | undefined { + const lowerResource = resource.toLowerCase(); + const lowerQuery = operationQuery.toLowerCase(); + + const candidates = schema.filter( + (e) => e.resource.toLowerCase() === lowerResource + ); + + // Priority 1: operationId starts with the query (e.g., "list" → "List an Org's Issues") + const startMatch = candidates.find( + (e) => + e.operationId.toLowerCase().startsWith(lowerQuery) || + e.fn.toLowerCase().startsWith(lowerQuery) + ); + if (startMatch) { + return startMatch; + } + + // Priority 2: substring match anywhere in operationId or fn + return candidates.find( + (e) => + e.operationId.toLowerCase().includes(lowerQuery) || + e.fn.toLowerCase().includes(lowerQuery) + ); +} + +/** Search endpoints by query string (matches fn, path, description, resource, operationId) */ +export function searchEndpoints(query: string): ApiEndpoint[] { + const lower = query.toLowerCase(); + return schema.filter( + (e) => + e.fn.toLowerCase().includes(lower) || + e.path.toLowerCase().includes(lower) || + e.description.toLowerCase().includes(lower) || + e.resource.toLowerCase().includes(lower) || + e.operationId.toLowerCase().includes(lower) + ); +} + +/** Get all endpoints (returns a readonly reference to the internal array) */ +export function getAllEndpoints(): readonly ApiEndpoint[] { + return schema; +} + +/** Get resource summary: name, endpoint count, and methods for each resource */ +export function getResourceSummaries(): ResourceSummary[] { + const map = new Map< + string, + { endpointCount: number; methods: Set } + >(); + + for (const endpoint of schema) { + const existing = map.get(endpoint.resource); + if (existing) { + existing.endpointCount += 1; + existing.methods.add(endpoint.method); + } else { + map.set(endpoint.resource, { + endpointCount: 1, + methods: new Set([endpoint.method]), + }); + } + } + + return [...map.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, info]) => ({ + name, + endpointCount: info.endpointCount, + methods: [...info.methods].sort(), + })); +} diff --git a/test/lib/api-schema.test.ts b/test/lib/api-schema.test.ts new file mode 100644 index 00000000..2e26b936 --- /dev/null +++ b/test/lib/api-schema.test.ts @@ -0,0 +1,156 @@ +/** + * Unit Tests for API Schema Query Functions + */ + +import { describe, expect, test } from "bun:test"; +import { + getAllEndpoints, + getAllResources, + getEndpoint, + getEndpointsByResource, + getResourceSummaries, + searchEndpoints, +} from "../../src/lib/api-schema.js"; + +describe("getAllEndpoints", () => { + test("returns non-empty array", () => { + const endpoints = getAllEndpoints(); + expect(endpoints.length).toBeGreaterThan(100); + }); + + test("every endpoint has required fields", () => { + for (const ep of getAllEndpoints()) { + expect(typeof ep.fn).toBe("string"); + expect(typeof ep.method).toBe("string"); + expect(typeof ep.path).toBe("string"); + expect(typeof ep.resource).toBe("string"); + expect(typeof ep.operationId).toBe("string"); + expect(Array.isArray(ep.queryParams)).toBe(true); + expect(ep.method.length).toBeGreaterThan(0); + expect(ep.path.startsWith("/api/0/")).toBe(true); + } + }); + + test("methods are valid HTTP methods", () => { + const validMethods = new Set(["GET", "POST", "PUT", "DELETE", "PATCH"]); + for (const ep of getAllEndpoints()) { + expect(validMethods.has(ep.method)).toBe(true); + } + }); +}); + +describe("getAllResources", () => { + test("returns sorted unique strings", () => { + const resources = getAllResources(); + expect(resources.length).toBeGreaterThan(10); + + // Verify sorted + const sorted = [...resources].sort(); + expect(resources).toEqual(sorted); + + // Verify unique + expect(new Set(resources).size).toBe(resources.length); + }); + + test("contains known resources", () => { + const resources = getAllResources(); + expect(resources).toContain("issues"); + expect(resources).toContain("projects"); + expect(resources).toContain("organizations"); + expect(resources).toContain("teams"); + }); +}); + +describe("getEndpointsByResource", () => { + test("returns endpoints for known resource", () => { + const endpoints = getEndpointsByResource("issues"); + expect(endpoints.length).toBeGreaterThan(0); + for (const ep of endpoints) { + expect(ep.resource).toBe("issues"); + } + }); + + test("is case-insensitive", () => { + const lower = getEndpointsByResource("issues"); + const upper = getEndpointsByResource("Issues"); + expect(lower).toEqual(upper); + }); + + test("returns empty array for unknown resource", () => { + expect(getEndpointsByResource("nonexistent")).toEqual([]); + }); +}); + +describe("getEndpoint", () => { + test("finds specific endpoint by operationId substring", () => { + const ep = getEndpoint("issues", "Retrieve an Issue"); + expect(ep).toBeDefined(); + expect(ep?.resource).toBe("issues"); + expect(ep?.method).toBe("GET"); + expect(ep?.fn).toBe("retrieveAnIssue"); + }); + + test("is case-insensitive", () => { + const ep1 = getEndpoint("issues", "list"); + const ep2 = getEndpoint("Issues", "List"); + expect(ep1).toEqual(ep2); + }); + + test("returns undefined for unknown endpoint", () => { + expect(getEndpoint("issues", "xyznonexistent123")).toBeUndefined(); + expect(getEndpoint("nonexistent", "list")).toBeUndefined(); + }); +}); + +describe("searchEndpoints", () => { + test("finds endpoints by resource name", () => { + const results = searchEndpoints("issues"); + expect(results.length).toBeGreaterThan(0); + }); + + test("finds endpoints by path fragment", () => { + const results = searchEndpoints("organizations"); + expect(results.length).toBeGreaterThan(0); + }); + + test("finds endpoints by description keyword", () => { + const results = searchEndpoints("replay"); + expect(results.length).toBeGreaterThan(0); + }); + + test("is case-insensitive", () => { + const lower = searchEndpoints("issues"); + const upper = searchEndpoints("ISSUES"); + expect(lower).toEqual(upper); + }); + + test("returns empty for no match", () => { + expect(searchEndpoints("xyznonexistent123")).toEqual([]); + }); +}); + +describe("getResourceSummaries", () => { + test("returns summaries for all resources", () => { + const summaries = getResourceSummaries(); + const resources = getAllResources(); + expect(summaries.length).toBe(resources.length); + }); + + test("each summary has correct shape", () => { + for (const summary of getResourceSummaries()) { + expect(typeof summary.name).toBe("string"); + expect(typeof summary.endpointCount).toBe("number"); + expect(summary.endpointCount).toBeGreaterThan(0); + expect(Array.isArray(summary.methods)).toBe(true); + expect(summary.methods.length).toBeGreaterThan(0); + } + }); + + test("methods are sorted and unique", () => { + for (const summary of getResourceSummaries()) { + const sorted = [...summary.methods].sort(); + expect(summary.methods).toEqual(sorted); + expect(new Set(summary.methods).size).toBe(summary.methods.length); + } + }); +});