From b530962b43c19c65cd8947840a1dd967f5f546b3 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 1 Apr 2026 21:05:02 +0000 Subject: [PATCH 1/2] feat(release): add release command group and CI finalization Add `sentry release` command group with 8 subcommands for full release lifecycle management, plus a CI post-release script for automated finalization. Commands: list, view, create, finalize, delete, deploy, set-commits, propose-version. Supports --auto (repo integration) and --local (git history) modes for commit association via simple-git. CI integration: script/finalize-release.ts runs in .craft.yml postReleaseCommand before version bump to create, set commits, finalize, and deploy releases automatically. Ref: https://github.com/getsentry/cli/issues/600 --- .github/workflows/sentry-release.yml | 35 ++ AGENTS.md | 11 + docs/public/.well-known/skills/index.json | 1 + docs/src/content/docs/commands/release.md | 80 ++++ package.json | 12 +- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 16 + .../skills/sentry-cli/references/release.md | 125 ++++++ script/generate-sdk.ts | 3 +- src/app.ts | 6 + src/commands/release/create.ts | 183 +++++++++ src/commands/release/delete.ts | 137 +++++++ src/commands/release/deploy.ts | 208 ++++++++++ src/commands/release/deploys.ts | 88 +++++ src/commands/release/finalize.ts | 138 +++++++ src/commands/release/index.ts | 37 ++ src/commands/release/list.ts | 85 +++++ src/commands/release/parse.ts | 55 +++ src/commands/release/propose-version.ts | 115 ++++++ src/commands/release/set-commits.ts | 328 ++++++++++++++++ src/commands/release/view.ts | 150 ++++++++ src/lib/api-client.ts | 12 + src/lib/api/releases.ts | 354 +++++++++++++++++ src/lib/complete.ts | 8 + src/lib/git.ts | 230 +++++++++++ src/lib/init/git.ts | 40 +- test/commands/release/create.test.ts | 154 ++++++++ test/commands/release/deploy.test.ts | 141 +++++++ test/commands/release/finalize.test.ts | 104 +++++ test/commands/release/propose-version.test.ts | 139 +++++++ test/commands/release/set-commits.test.ts | 191 ++++++++++ test/commands/release/view.test.ts | 137 +++++++ test/lib/api/releases.test.ts | 357 ++++++++++++++++++ test/lib/git.property.test.ts | 110 ++++++ test/lib/init/git.test.ts | 109 ++---- test/lib/release-parse.property.test.ts | 117 ++++++ 35 files changed, 3913 insertions(+), 103 deletions(-) create mode 100644 .github/workflows/sentry-release.yml create mode 100644 docs/src/content/docs/commands/release.md create mode 100644 plugins/sentry-cli/skills/sentry-cli/references/release.md create mode 100644 src/commands/release/create.ts create mode 100644 src/commands/release/delete.ts create mode 100644 src/commands/release/deploy.ts create mode 100644 src/commands/release/deploys.ts create mode 100644 src/commands/release/finalize.ts create mode 100644 src/commands/release/index.ts create mode 100644 src/commands/release/list.ts create mode 100644 src/commands/release/parse.ts create mode 100644 src/commands/release/propose-version.ts create mode 100644 src/commands/release/set-commits.ts create mode 100644 src/commands/release/view.ts create mode 100644 src/lib/api/releases.ts create mode 100644 src/lib/git.ts create mode 100644 test/commands/release/create.test.ts create mode 100644 test/commands/release/deploy.test.ts create mode 100644 test/commands/release/finalize.test.ts create mode 100644 test/commands/release/propose-version.test.ts create mode 100644 test/commands/release/set-commits.test.ts create mode 100644 test/commands/release/view.test.ts create mode 100644 test/lib/api/releases.test.ts create mode 100644 test/lib/git.property.test.ts create mode 100644 test/lib/release-parse.property.test.ts diff --git a/.github/workflows/sentry-release.yml b/.github/workflows/sentry-release.yml new file mode 100644 index 000000000..36f03683d --- /dev/null +++ b/.github/workflows/sentry-release.yml @@ -0,0 +1,35 @@ +name: Sentry Release +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + finalize: + name: Finalize Sentry Release + runs-on: ubuntu-latest + # Skip pre-releases (nightlies, dev versions) + if: "!github.event.release.prerelease" + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + # Tag names are bare semver (e.g., "0.24.0", no "v" prefix), + # matching both the npm package version and Sentry release version. + VERSION: ${{ github.event.release.tag_name }} + steps: + - name: Install CLI + run: npm install -g "sentry@${VERSION}" + + - name: Create release + run: sentry release create "sentry/${VERSION}" --project cli + + - name: Set commits + continue-on-error: true + run: sentry release set-commits "sentry/${VERSION}" --auto + + - name: Finalize release + run: sentry release finalize "sentry/${VERSION}" + + - name: Create deploy + run: sentry release deploy "sentry/${VERSION}" production diff --git a/AGENTS.md b/AGENTS.md index 6b88e7012..c62bcae8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,14 @@ bun run test:unit # Run unit tests only bun run test:e2e # Run e2e tests only ``` +## Rules: No Runtime Dependencies + +**CRITICAL**: All packages must be in `devDependencies`, never `dependencies`. Everything is bundled at build time via esbuild. CI enforces this with `bun run check:deps`. + +When adding a package, always use `bun add -d ` (the `-d` flag). + +When the `@sentry/api` SDK provides types for an API response, import them directly from `@sentry/api` instead of creating redundant Zod schemas in `src/types/sentry.ts`. + ## Rules: Use Bun APIs **CRITICAL**: This project uses Bun as runtime. Always prefer Bun-native APIs over Node.js equivalents. @@ -1038,4 +1046,7 @@ mock.module("./some-module", () => ({ * **validateWidgetEnums skipDeprecatedCheck for edit-path inherited datasets**: When editing a widget, \`effectiveDataset = flags.dataset ?? existing.widgetType\` may inherit a deprecated type (e.g., \`discover\`). The \`validateWidgetEnums\` deprecation check must be skipped for inherited values — only fire when the user explicitly passes \`--dataset\`. Solution: \`validateWidgetEnums(effectiveDisplay, effectiveDataset, { skipDeprecatedCheck: true })\` in \`edit.ts\`. The cross-validation between display type and dataset still runs on effective values, catching incompatible combos. The deprecation rejection helper \`rejectInvalidDataset()\` is extracted to keep \`validateWidgetEnums\` under Biome's complexity limit of 15. + + +* **set-commits default mode makes speculative --auto API call by design**: When \`release set-commits\` is called without \`--auto\` or \`--local\`, it tries auto-discovery first and falls back to local git on 400 error. This matches the reference sentry-cli behavior (parity-correct). A per-org negative cache in the \`metadata\` table (\`repos_configured.\` = \`"false"\`, 1-hour TTL) skips the speculative auto call on subsequent runs when no repo integration is configured. The cache clears on successful auto-discovery. diff --git a/docs/public/.well-known/skills/index.json b/docs/public/.well-known/skills/index.json index d7e648a67..2ddb8279c 100644 --- a/docs/public/.well-known/skills/index.json +++ b/docs/public/.well-known/skills/index.json @@ -13,6 +13,7 @@ "references/logs.md", "references/organizations.md", "references/projects.md", + "references/release.md", "references/setup.md", "references/sourcemap.md", "references/teams.md", diff --git a/docs/src/content/docs/commands/release.md b/docs/src/content/docs/commands/release.md new file mode 100644 index 000000000..ff7d41ca6 --- /dev/null +++ b/docs/src/content/docs/commands/release.md @@ -0,0 +1,80 @@ +--- +title: release +description: Release commands for the Sentry CLI +--- + +Work with Sentry releases + +## Commands + +### `sentry release list ` + +List releases + +### `sentry release view ` + +View release details + +### `sentry release create ` + +Create a release + +### `sentry release finalize ` + +Finalize a release + +### `sentry release delete ` + +Delete a release + +### `sentry release deploy [name]` + +Create a deploy for a release + +### `sentry release set-commits ` + +Set commits for a release + +### `sentry release propose-version` + +Propose a release version (outputs the current git HEAD SHA) + +All commands support `--json` for machine-readable output and `--fields` to select specific JSON fields. + + + +## Examples + +```bash +# List releases (auto-detect org) +sentry release list + +# List releases in a specific org +sentry release list my-org/ + +# View release details +sentry release view 1.0.0 +sentry release view my-org/1.0.0 + +# Create and finalize a release +sentry release create 1.0.0 --finalize + +# Create a release, then finalize separately +sentry release create 1.0.0 +sentry release set-commits 1.0.0 --auto +sentry release finalize 1.0.0 + +# Set commits from local git history +sentry release set-commits 1.0.0 --local + +# Create a deploy +sentry release deploy 1.0.0 production +sentry release deploy 1.0.0 staging "Deploy #42" + +# Propose a version from git HEAD +sentry release create $(sentry release propose-version) + +# Output as JSON +sentry release list --json +sentry release view 1.0.0 --json +``` diff --git a/package.json b/package.json index c2c385857..9da2477c8 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "git", "url": "git+https://github.com/getsentry/cli.git" }, + "main": "./dist/index.cjs", "devDependencies": { "@anthropic-ai/sdk": "^0.39.0", "@biomejs/biome": "2.3.8", @@ -43,11 +44,6 @@ "wrap-ansi": "^10.0.0", "zod": "^3.24.0" }, - "bin": { - "sentry": "./dist/bin.cjs" - }, - "main": "./dist/index.cjs", - "types": "./dist/index.d.cts", "exports": { ".": { "types": "./dist/index.d.cts", @@ -55,6 +51,9 @@ "default": "./dist/index.cjs" } }, + "bin": { + "sentry": "./dist/bin.cjs" + }, "description": "Sentry CLI - A command-line interface for using Sentry built by robots and humans for robots and humans", "engines": { "node": ">=22" @@ -94,5 +93,6 @@ "check:deps": "bun run script/check-no-deps.ts", "check:errors": "bun run script/check-error-patterns.ts" }, - "type": "module" + "type": "module", + "types": "./dist/index.d.cts" } diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 22dca3ecc..513a49801 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -313,6 +313,22 @@ Manage Sentry dashboards → Full flags and examples: `references/dashboards.md` +### Release + +Work with Sentry releases + +- `sentry release list ` — List releases +- `sentry release view ` — View release details +- `sentry release create ` — Create a release +- `sentry release finalize ` — Finalize a release +- `sentry release delete ` — Delete a release +- `sentry release deploy ` — Create a deploy for a release +- `sentry release deploys ` — List deploys for a release +- `sentry release set-commits ` — Set commits for a release +- `sentry release propose-version` — Propose a release version + +→ Full flags and examples: `references/release.md` + ### Repo Work with Sentry repositories diff --git a/plugins/sentry-cli/skills/sentry-cli/references/release.md b/plugins/sentry-cli/skills/sentry-cli/references/release.md new file mode 100644 index 000000000..8b57f6364 --- /dev/null +++ b/plugins/sentry-cli/skills/sentry-cli/references/release.md @@ -0,0 +1,125 @@ +--- +name: sentry-cli-release +version: 0.24.0-dev.0 +description: Sentry CLI release commands +requires: + bins: ["sentry"] + auth: true +--- + +# release Commands + +Work with Sentry releases + +### `sentry release list ` + +List releases + +**Flags:** +- `-n, --limit - Maximum number of releases to list - (default: "30")` +- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` +- `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` + +### `sentry release view ` + +View release details + +**Flags:** +- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` + +### `sentry release create ` + +Create a release + +**Flags:** +- `-p, --project - Associate with project(s), comma-separated` +- `--finalize - Immediately finalize the release (set dateReleased)` +- `--ref - Git ref (branch or tag name)` +- `--url - URL to the release source` +- `-n, --dry-run - Show what would happen without making changes` + +### `sentry release finalize ` + +Finalize a release + +**Flags:** +- `--released - Custom release timestamp (ISO 8601). Defaults to now.` +- `--url - URL for the release` +- `-n, --dry-run - Show what would happen without making changes` + +### `sentry release delete ` + +Delete a release + +**Flags:** +- `-y, --yes - Skip confirmation prompt` +- `-f, --force - Force the operation without confirmation` +- `-n, --dry-run - Show what would happen without making changes` + +### `sentry release deploy ` + +Create a deploy for a release + +**Flags:** +- `--url - URL for the deploy` +- `--started - Deploy start time (ISO 8601)` +- `--finished - Deploy finish time (ISO 8601)` +- `-t, --time - Deploy duration in seconds (sets started = now - time, finished = now)` +- `-n, --dry-run - Show what would happen without making changes` + +### `sentry release deploys ` + +List deploys for a release + +### `sentry release set-commits ` + +Set commits for a release + +**Flags:** +- `--auto - Use repository integration to auto-discover commits` +- `--local - Read commits from local git history` +- `--clear - Clear all commits from the release` +- `--commit - Explicit commit as REPO@SHA or REPO@PREV..SHA (comma-separated)` +- `--initial-depth - Number of commits to read with --local - (default: "20")` + +### `sentry release propose-version` + +Propose a release version + +**Examples:** + +```bash +# List releases (auto-detect org) +sentry release list + +# List releases in a specific org +sentry release list my-org/ + +# View release details +sentry release view 1.0.0 +sentry release view my-org/1.0.0 + +# Create and finalize a release +sentry release create 1.0.0 --finalize + +# Create a release, then finalize separately +sentry release create 1.0.0 +sentry release set-commits 1.0.0 --auto +sentry release finalize 1.0.0 + +# Set commits from local git history +sentry release set-commits 1.0.0 --local + +# Create a deploy +sentry release deploy 1.0.0 production +sentry release deploy 1.0.0 staging "Deploy #42" + +# Propose a version from git HEAD +sentry release create $(sentry release propose-version) + +# Output as JSON +sentry release list --json +sentry release view 1.0.0 --json +``` + +All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/script/generate-sdk.ts b/script/generate-sdk.ts index 084df5b3d..a3b8af09f 100644 --- a/script/generate-sdk.ts +++ b/script/generate-sdk.ts @@ -556,7 +556,8 @@ for (const { path, command } of allCommands) { let body: string; const brief = command.brief || path.join(" "); - const methodName = path.at(-1) ?? path[0]; + const rawName = path.at(-1) ?? path[0]; + const methodName = needsQuoting(rawName) ? `"${rawName}"` : rawName; const indent = " ".repeat(path.length - 1); if (hasVariadicPositional) { diff --git a/src/app.ts b/src/app.ts index ec752c64a..a0f621af8 100644 --- a/src/app.ts +++ b/src/app.ts @@ -25,6 +25,8 @@ import { orgRoute } from "./commands/org/index.js"; import { listCommand as orgListCommand } from "./commands/org/list.js"; import { projectRoute } from "./commands/project/index.js"; import { listCommand as projectListCommand } from "./commands/project/list.js"; +import { releaseRoute } from "./commands/release/index.js"; +import { listCommand as releaseListCommand } from "./commands/release/list.js"; import { repoRoute } from "./commands/repo/index.js"; import { listCommand as repoListCommand } from "./commands/repo/list.js"; import { schemaCommand } from "./commands/schema.js"; @@ -61,6 +63,7 @@ const PLURAL_TO_SINGULAR: Record = { issues: "issue", orgs: "org", projects: "project", + releases: "release", repos: "repo", teams: "team", logs: "log", @@ -79,6 +82,7 @@ export const routes = buildRouteMap({ dashboard: dashboardRoute, org: orgRoute, project: projectRoute, + release: releaseRoute, repo: repoRoute, team: teamRoute, issue: issueRoute, @@ -96,6 +100,7 @@ export const routes = buildRouteMap({ issues: issueListCommand, orgs: orgListCommand, projects: projectListCommand, + releases: releaseListCommand, repos: repoListCommand, teams: teamListCommand, logs: logListCommand, @@ -115,6 +120,7 @@ export const routes = buildRouteMap({ issues: true, orgs: true, projects: true, + releases: true, repos: true, teams: true, logs: true, diff --git a/src/commands/release/create.ts b/src/commands/release/create.ts new file mode 100644 index 000000000..c54cb82fc --- /dev/null +++ b/src/commands/release/create.ts @@ -0,0 +1,183 @@ +/** + * sentry release create + * + * Create a new Sentry release. + */ + +import type { OrgReleaseResponse } from "@sentry/api"; +import type { SentryContext } from "../../context.js"; +import { createRelease } from "../../lib/api-client.js"; +import { buildCommand } from "../../lib/command.js"; +import { ContextError } from "../../lib/errors.js"; +import { + colorTag, + escapeMarkdownInline, + mdKvTable, + renderMarkdown, + safeCodeSpan, +} from "../../lib/formatters/markdown.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../lib/mutate-command.js"; +import { resolveOrg } from "../../lib/resolve-target.js"; +import { parseReleaseArg } from "./parse.js"; + +function formatReleaseCreated(release: OrgReleaseResponse): string { + const lines: string[] = []; + lines.push(`## Release Created: ${escapeMarkdownInline(release.version)}`); + lines.push(""); + const kvRows: [string, string][] = []; + kvRows.push(["Version", safeCodeSpan(release.version)]); + kvRows.push([ + "Status", + release.dateReleased + ? colorTag("green", "Finalized") + : colorTag("yellow", "Unreleased"), + ]); + if (release.projects?.length) { + kvRows.push(["Projects", release.projects.map((p) => p.slug).join(", ")]); + } + lines.push(mdKvTable(kvRows)); + return renderMarkdown(lines.join("\n")); +} + +export const createCommand = buildCommand({ + docs: { + brief: "Create a release", + fullDescription: + "Create a new Sentry release.\n\n" + + "Examples:\n" + + " sentry release create 1.0.0\n" + + " sentry release create my-org/1.0.0\n" + + " sentry release create 1.0.0 --project my-project\n" + + " sentry release create 1.0.0 --project proj-a,proj-b\n" + + " sentry release create 1.0.0 --finalize\n" + + " sentry release create 1.0.0 --ref main\n" + + " sentry release create 1.0.0 --dry-run", + }, + output: { + human: (data: Record) => { + if (data.dryRun) { + const projects = (data.projects as string[]) || []; + return renderMarkdown( + `Would create release ${safeCodeSpan(String(data.version))}` + + (projects.length > 0 + ? ` in projects: ${projects.join(", ")}` + : "") + + (data.finalize ? " (finalized)" : "") + + " (dry run)" + ); + } + return formatReleaseCreated(data as OrgReleaseResponse); + }, + }, + parameters: { + positional: { + kind: "array", + parameter: { + placeholder: "org/version", + brief: "[/] - Release version to create", + parse: String, + }, + }, + flags: { + project: { + kind: "parsed", + parse: String, + brief: "Associate with project(s), comma-separated", + optional: true, + }, + finalize: { + kind: "boolean", + brief: "Immediately finalize the release (set dateReleased)", + default: false, + }, + ref: { + kind: "parsed", + parse: String, + brief: "Git ref (branch or tag name)", + optional: true, + }, + url: { + kind: "parsed", + parse: String, + brief: "URL to the release source", + optional: true, + }, + "dry-run": DRY_RUN_FLAG, + }, + aliases: { ...DRY_RUN_ALIASES, p: "project" }, + }, + async *func( + this: SentryContext, + flags: { + readonly project?: string; + readonly finalize: boolean; + readonly ref?: string; + readonly url?: string; + readonly "dry-run": boolean; + readonly json: boolean; + readonly fields?: string[]; + }, + ...args: string[] + ) { + const { cwd } = this; + + const joined = args.join(" ").trim(); + if (!joined) { + throw new ContextError( + "Release version", + "sentry release create [/]", + [] + ); + } + + const { version, orgSlug } = parseReleaseArg( + joined, + "sentry release create [/]" + ); + const resolved = await resolveOrg({ org: orgSlug, cwd }); + if (!resolved) { + throw new ContextError( + "Organization", + "sentry release create [/]" + ); + } + + const body: Parameters[1] = { version }; + if (flags.project) { + body.projects = flags.project.split(",").map((p) => p.trim()); + } + if (flags.ref) { + body.ref = flags.ref; + } + if (flags.url) { + body.url = flags.url; + } + if (flags.finalize) { + body.dateReleased = new Date().toISOString(); + } + + // Dry-run mode: show what would be created without calling the API + if (flags["dry-run"]) { + yield new CommandOutput({ + dryRun: true, + version, + projects: flags.project + ? flags.project.split(",").map((p) => p.trim()) + : [], + finalize: flags.finalize, + ref: flags.ref, + url: flags.url, + }); + return { hint: "Dry run — no release was created." }; + } + + const release = await createRelease(resolved.org, body); + yield new CommandOutput(release); + + const hint = flags.finalize + ? "Release created and finalized." + : `Release created. Finalize with: sentry release finalize ${version}`; + return { hint }; + }, +}); diff --git a/src/commands/release/delete.ts b/src/commands/release/delete.ts new file mode 100644 index 000000000..d0d13a1f6 --- /dev/null +++ b/src/commands/release/delete.ts @@ -0,0 +1,137 @@ +/** + * sentry release delete + * + * Permanently delete a Sentry release. + * + * Uses `buildDeleteCommand` — auto-injects `--yes`/`--force`/`--dry-run` + * flags and enforces the non-interactive guard before `func()` runs. + */ + +import type { SentryContext } from "../../context.js"; +import { deleteRelease, getRelease } from "../../lib/api-client.js"; +import { ContextError } from "../../lib/errors.js"; +import { renderMarkdown, safeCodeSpan } from "../../lib/formatters/markdown.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { + buildDeleteCommand, + confirmByTyping, + isConfirmationBypassed, +} from "../../lib/mutate-command.js"; +import { resolveOrg } from "../../lib/resolve-target.js"; +import { parseReleaseArg } from "./parse.js"; + +type DeleteResult = { + deleted: boolean; + org: string; + version: string; + dryRun?: boolean; +}; + +function formatReleaseDeleted(result: DeleteResult): string { + if (result.dryRun) { + return renderMarkdown( + `Would delete release ${safeCodeSpan(result.version)} from **${result.org}**. (dry run)` + ); + } + if (!result.deleted) { + return "Cancelled."; + } + return renderMarkdown( + `Release ${safeCodeSpan(result.version)} deleted from **${result.org}**.` + ); +} + +type DeleteFlags = { + readonly yes: boolean; + readonly force: boolean; + readonly "dry-run": boolean; + readonly json: boolean; + readonly fields?: string[]; +}; + +export const deleteCommand = buildDeleteCommand({ + docs: { + brief: "Delete a release", + fullDescription: + "Permanently delete a Sentry release.\n\n" + + "Examples:\n" + + " sentry release delete 1.0.0\n" + + " sentry release delete my-org/1.0.0\n" + + " sentry release delete 1.0.0 --yes\n" + + " sentry release delete 1.0.0 --dry-run", + }, + output: { + human: formatReleaseDeleted, + }, + parameters: { + positional: { + kind: "array", + parameter: { + placeholder: "org/version", + brief: "[/] - Release version to delete", + parse: String, + }, + }, + }, + async *func(this: SentryContext, flags: DeleteFlags, ...args: string[]) { + const { cwd } = this; + + const joined = args.join(" ").trim(); + if (!joined) { + throw new ContextError( + "Release version", + "sentry release delete [/]", + [] + ); + } + + const { version, orgSlug } = parseReleaseArg( + joined, + "sentry release delete [/]" + ); + const resolved = await resolveOrg({ org: orgSlug, cwd }); + if (!resolved) { + throw new ContextError( + "Organization", + "sentry release delete [/]" + ); + } + + // Verify the release exists before prompting for confirmation + const release = await getRelease(resolved.org, version); + + // Dry-run mode: show what would be deleted + if (flags["dry-run"]) { + yield new CommandOutput({ + deleted: false, + org: resolved.org, + version, + dryRun: true, + }); + return; + } + + // Confirmation gate — non-interactive guard is handled by buildDeleteCommand + if (!isConfirmationBypassed(flags)) { + const deployInfo = + release.deployCount && release.deployCount > 0 + ? ` (${release.deployCount} deploy${release.deployCount > 1 ? "s" : ""})` + : ""; + const confirmed = await confirmByTyping( + version, + `Type '${version}' to permanently delete this release${deployInfo}:` + ); + if (!confirmed) { + yield new CommandOutput({ + deleted: false, + org: resolved.org, + version, + }); + return { hint: "Cancelled." }; + } + } + + await deleteRelease(resolved.org, version); + yield new CommandOutput({ deleted: true, org: resolved.org, version }); + }, +}); diff --git a/src/commands/release/deploy.ts b/src/commands/release/deploy.ts new file mode 100644 index 000000000..98cf4c1da --- /dev/null +++ b/src/commands/release/deploy.ts @@ -0,0 +1,208 @@ +/** + * sentry release deploy + * + * Create a deploy for a release. + * Environment is the first positional arg (required), deploy name is optional second. + */ + +import type { DeployResponse } from "@sentry/api"; +import type { SentryContext } from "../../context.js"; +import { createReleaseDeploy } from "../../lib/api-client.js"; +import { buildCommand, numberParser } from "../../lib/command.js"; +import { ContextError, ValidationError } from "../../lib/errors.js"; +import { + mdKvTable, + renderMarkdown, + safeCodeSpan, +} from "../../lib/formatters/markdown.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { formatRelativeTime } from "../../lib/formatters/time-utils.js"; +import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../lib/mutate-command.js"; +import { resolveOrg } from "../../lib/resolve-target.js"; +import { parseReleaseArg } from "./parse.js"; + +function formatDeployCreated(data: Record): string { + if (data.dryRun) { + return renderMarkdown( + `Would create deploy for ${safeCodeSpan(String(data.environment))} environment (dry run)` + ); + } + const deploy = data as unknown as DeployResponse; + const lines: string[] = []; + lines.push("## Deploy Created"); + lines.push(""); + const kvRows: [string, string][] = []; + kvRows.push(["ID", deploy.id]); + kvRows.push(["Environment", safeCodeSpan(deploy.environment)]); + kvRows.push(["Name", deploy.name || "—"]); + kvRows.push(["URL", deploy.url || "—"]); + kvRows.push([ + "Started", + deploy.dateStarted ? formatRelativeTime(deploy.dateStarted) : "—", + ]); + kvRows.push([ + "Finished", + deploy.dateFinished ? formatRelativeTime(deploy.dateFinished) : "—", + ]); + lines.push(mdKvTable(kvRows)); + return renderMarkdown(lines.join("\n")); +} + +/** + * Parse the deploy positional args: `[org/]version environment [name]` + * + * The first arg is parsed as the release target (version with optional org prefix). + * The second arg is the required environment. + * The third arg is an optional deploy name. + */ +function parseDeployArgs(args: string[]): { + version: string; + orgSlug?: string; + environment: string; + name?: string; +} { + const first = args[0]; + const second = args[1]; + if (!(first && second)) { + throw new ContextError( + "Release version and environment", + "sentry release deploy [/] [name]", + [] + ); + } + + const { version, orgSlug } = parseReleaseArg( + first, + "sentry release deploy [/] " + ); + + const environment = second; + const name = args.length > 2 ? args.slice(2).join(" ") : undefined; + + return { version, orgSlug, environment, name }; +} + +export const deployCommand = buildCommand({ + docs: { + brief: "Create a deploy for a release", + fullDescription: + "Create a deploy record for a release in a specific environment.\n\n" + + "Examples:\n" + + " sentry release deploy 1.0.0 production\n" + + ' sentry release deploy my-org/1.0.0 staging "Deploy #42"\n' + + " sentry release deploy 1.0.0 production --url https://example.com\n" + + " sentry release deploy 1.0.0 production --time 120\n" + + " sentry release deploy 1.0.0 production --dry-run", + }, + output: { + human: formatDeployCreated, + }, + parameters: { + positional: { + kind: "array", + parameter: { + placeholder: "org/version environment name", + brief: "[/] [name]", + parse: String, + }, + }, + flags: { + url: { + kind: "parsed", + parse: String, + brief: "URL for the deploy", + optional: true, + }, + started: { + kind: "parsed", + parse: String, + brief: "Deploy start time (ISO 8601)", + optional: true, + }, + finished: { + kind: "parsed", + parse: String, + brief: "Deploy finish time (ISO 8601)", + optional: true, + }, + time: { + kind: "parsed", + parse: numberParser, + brief: + "Deploy duration in seconds (sets started = now - time, finished = now)", + optional: true, + }, + "dry-run": DRY_RUN_FLAG, + }, + aliases: { ...DRY_RUN_ALIASES, t: "time" }, + }, + async *func( + this: SentryContext, + flags: { + readonly url?: string; + readonly started?: string; + readonly finished?: string; + readonly time?: number; + readonly "dry-run": boolean; + readonly json: boolean; + readonly fields?: string[]; + }, + ...args: string[] + ) { + const { cwd } = this; + + const { version, orgSlug, environment, name } = parseDeployArgs(args); + const resolved = await resolveOrg({ org: orgSlug, cwd }); + if (!resolved) { + throw new ContextError( + "Organization", + "sentry release deploy [/] " + ); + } + + const body: Parameters[2] = { environment }; + if (name) { + body.name = name; + } + if (flags.url) { + body.url = flags.url; + } + if (flags.time !== undefined && (flags.started || flags.finished)) { + throw new ValidationError( + "--time cannot be used with --started or --finished. " + + "Use either --time for duration-based timing, or --started/--finished for explicit timestamps.", + "time" + ); + } + + if (flags.time !== undefined) { + const now = new Date(); + const started = new Date(now.getTime() - flags.time * 1000); + body.dateStarted = started.toISOString(); + body.dateFinished = now.toISOString(); + } else { + if (flags.started) { + body.dateStarted = flags.started; + } + if (flags.finished) { + body.dateFinished = flags.finished; + } + } + + if (flags["dry-run"]) { + yield new CommandOutput({ + dryRun: true, + version, + environment, + name, + url: flags.url, + dateStarted: body.dateStarted, + dateFinished: body.dateFinished, + }); + return { hint: "Dry run — no deploy was created." }; + } + + const deploy = await createReleaseDeploy(resolved.org, version, body); + yield new CommandOutput(deploy); + }, +}); diff --git a/src/commands/release/deploys.ts b/src/commands/release/deploys.ts new file mode 100644 index 000000000..0b7a746d5 --- /dev/null +++ b/src/commands/release/deploys.ts @@ -0,0 +1,88 @@ +/** + * sentry release deploys + * + * List deploys for a release. + */ + +import type { DeployResponse } from "@sentry/api"; +import type { SentryContext } from "../../context.js"; +import { listReleaseDeploys } from "../../lib/api-client.js"; +import { buildCommand } from "../../lib/command.js"; +import { ContextError } from "../../lib/errors.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { type Column, formatTable } from "../../lib/formatters/table.js"; +import { formatRelativeTime } from "../../lib/formatters/time-utils.js"; +import { resolveOrg } from "../../lib/resolve-target.js"; +import { parseReleaseArg } from "./parse.js"; + +const DEPLOY_COLUMNS: Column[] = [ + { header: "ENVIRONMENT", value: (d) => d.environment }, + { header: "NAME", value: (d) => d.name || "—" }, + { + header: "FINISHED", + value: (d) => (d.dateFinished ? formatRelativeTime(d.dateFinished) : "—"), + }, +]; + +function formatDeployList(deploys: DeployResponse[]): string { + if (deploys.length === 0) { + return "No deploys found for this release."; + } + return formatTable(deploys, DEPLOY_COLUMNS); +} + +export const deploysCommand = buildCommand({ + docs: { + brief: "List deploys for a release", + fullDescription: + "List all deploys recorded for a specific release.\n\n" + + "Examples:\n" + + " sentry release deploys 1.0.0\n" + + " sentry release deploys my-org/1.0.0\n" + + " sentry release deploys 1.0.0 --json", + }, + output: { + human: formatDeployList, + }, + parameters: { + positional: { + kind: "array", + parameter: { + placeholder: "org/version", + brief: "[/] - Release version", + parse: String, + }, + }, + }, + async *func( + this: SentryContext, + _flags: { readonly json: boolean; readonly fields?: string[] }, + ...args: string[] + ) { + const { cwd } = this; + + const joined = args.join(" ").trim(); + if (!joined) { + throw new ContextError( + "Release version", + "sentry release deploys [/]", + [] + ); + } + + const { version, orgSlug } = parseReleaseArg( + joined, + "sentry release deploys [/]" + ); + const resolved = await resolveOrg({ org: orgSlug, cwd }); + if (!resolved) { + throw new ContextError( + "Organization", + "sentry release deploys [/]" + ); + } + + const deploys = await listReleaseDeploys(resolved.org, version); + yield new CommandOutput(deploys); + }, +}); diff --git a/src/commands/release/finalize.ts b/src/commands/release/finalize.ts new file mode 100644 index 000000000..6d473663a --- /dev/null +++ b/src/commands/release/finalize.ts @@ -0,0 +1,138 @@ +/** + * sentry release finalize + * + * Finalize a release by setting its dateReleased to now. + */ + +import type { OrgReleaseResponse } from "@sentry/api"; +import type { SentryContext } from "../../context.js"; +import { updateRelease } from "../../lib/api-client.js"; +import { buildCommand } from "../../lib/command.js"; +import { ContextError } from "../../lib/errors.js"; +import { + colorTag, + escapeMarkdownInline, + mdKvTable, + renderMarkdown, + safeCodeSpan, +} from "../../lib/formatters/markdown.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../lib/mutate-command.js"; +import { resolveOrg } from "../../lib/resolve-target.js"; +import { parseReleaseArg } from "./parse.js"; + +function formatReleaseFinalized(data: Record): string { + if (data.dryRun) { + return renderMarkdown( + `Would finalize release ${safeCodeSpan(String(data.version))} (dry run)` + ); + } + const release = data as unknown as OrgReleaseResponse; + const lines: string[] = []; + lines.push(`## Release Finalized: ${escapeMarkdownInline(release.version)}`); + lines.push(""); + const kvRows: [string, string][] = []; + kvRows.push(["Version", safeCodeSpan(release.version)]); + kvRows.push(["Status", colorTag("green", "Finalized")]); + kvRows.push(["Released", release.dateReleased || "—"]); + lines.push(mdKvTable(kvRows)); + return renderMarkdown(lines.join("\n")); +} + +export const finalizeCommand = buildCommand({ + docs: { + brief: "Finalize a release", + fullDescription: + "Mark a release as finalized by setting its release date.\n\n" + + "Examples:\n" + + " sentry release finalize 1.0.0\n" + + " sentry release finalize my-org/1.0.0\n" + + " sentry release finalize 1.0.0 --released 2025-01-01T00:00:00Z\n" + + " sentry release finalize 1.0.0 --dry-run", + }, + output: { + human: formatReleaseFinalized, + }, + parameters: { + positional: { + kind: "array", + parameter: { + placeholder: "org/version", + brief: "[/] - Release version to finalize", + parse: String, + }, + }, + flags: { + released: { + kind: "parsed", + parse: String, + brief: "Custom release timestamp (ISO 8601). Defaults to now.", + optional: true, + }, + url: { + kind: "parsed", + parse: String, + brief: "URL for the release", + optional: true, + }, + "dry-run": DRY_RUN_FLAG, + }, + aliases: { ...DRY_RUN_ALIASES }, + }, + async *func( + this: SentryContext, + flags: { + readonly released?: string; + readonly url?: string; + readonly "dry-run": boolean; + readonly json: boolean; + readonly fields?: string[]; + }, + ...args: string[] + ) { + const { cwd } = this; + + const joined = args.join(" ").trim(); + if (!joined) { + throw new ContextError( + "Release version", + "sentry release finalize [/]", + [] + ); + } + + const { version, orgSlug } = parseReleaseArg( + joined, + "sentry release finalize [/]" + ); + const resolved = await resolveOrg({ org: orgSlug, cwd }); + if (!resolved) { + throw new ContextError( + "Organization", + "sentry release finalize [/]" + ); + } + if (flags["dry-run"]) { + yield new CommandOutput({ + dryRun: true, + version, + org: resolved.org, + released: flags.released || new Date().toISOString(), + }); + return { hint: "Dry run — release was not finalized." }; + } + + const body: Record = { + dateReleased: flags.released || new Date().toISOString(), + }; + if (flags.url) { + body.url = flags.url; + } + const release = await updateRelease(resolved.org, version, body); + yield new CommandOutput(release); + const hint = resolved.detectedFrom + ? `Detected from ${resolved.detectedFrom}` + : undefined; + return { hint }; + }, +}); diff --git a/src/commands/release/index.ts b/src/commands/release/index.ts new file mode 100644 index 000000000..0139d05e2 --- /dev/null +++ b/src/commands/release/index.ts @@ -0,0 +1,37 @@ +/** + * sentry release + * + * Route map for release management commands. + */ + +import { buildRouteMap } from "@stricli/core"; +import { createCommand } from "./create.js"; +import { deleteCommand } from "./delete.js"; +import { deployCommand } from "./deploy.js"; +import { deploysCommand } from "./deploys.js"; +import { finalizeCommand } from "./finalize.js"; +import { listCommand } from "./list.js"; +import { proposeVersionCommand } from "./propose-version.js"; +import { setCommitsCommand } from "./set-commits.js"; +import { viewCommand } from "./view.js"; + +export const releaseRoute = buildRouteMap({ + routes: { + list: listCommand, + view: viewCommand, + create: createCommand, + finalize: finalizeCommand, + delete: deleteCommand, + deploy: deployCommand, + deploys: deploysCommand, + "set-commits": setCommitsCommand, + "propose-version": proposeVersionCommand, + }, + docs: { + brief: "Work with Sentry releases", + fullDescription: + "List, create, finalize, and deploy Sentry releases.\n\n" + + "Alias: `sentry releases` → `sentry release list`", + hideRoute: {}, + }, +}); diff --git a/src/commands/release/list.ts b/src/commands/release/list.ts new file mode 100644 index 000000000..542c5ca62 --- /dev/null +++ b/src/commands/release/list.ts @@ -0,0 +1,85 @@ +/** + * sentry release list + * + * List releases in an organization with pagination support. + */ + +import type { OrgReleaseResponse } from "@sentry/api"; +import { listReleasesPaginated } from "../../lib/api-client.js"; +import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; +import { type Column, formatTable } from "../../lib/formatters/table.js"; +import { formatRelativeTime } from "../../lib/formatters/time-utils.js"; +import { + buildOrgListCommand, + type OrgListCommandDocs, +} from "../../lib/list-command.js"; +import type { OrgListConfig } from "../../lib/org-list.js"; + +export const PAGINATION_KEY = "release-list"; + +type ReleaseWithOrg = OrgReleaseResponse & { orgSlug?: string }; + +const RELEASE_COLUMNS: Column[] = [ + { header: "ORG", value: (r) => r.orgSlug || "" }, + { + header: "VERSION", + value: (r) => escapeMarkdownCell(r.shortVersion || r.version), + }, + { + header: "STATUS", + value: (r) => (r.dateReleased ? "Finalized" : "Unreleased"), + }, + { + header: "CREATED", + value: (r) => (r.dateCreated ? formatRelativeTime(r.dateCreated) : ""), + }, + { + header: "RELEASED", + value: (r) => (r.dateReleased ? formatRelativeTime(r.dateReleased) : "—"), + }, + { header: "COMMITS", value: (r) => String(r.commitCount ?? 0) }, + { header: "DEPLOYS", value: (r) => String(r.deployCount ?? 0) }, +]; + +const releaseListConfig: OrgListConfig = { + paginationKey: PAGINATION_KEY, + entityName: "release", + entityPlural: "releases", + commandPrefix: "sentry release list", + // listForOrg fetches a buffer page for multi-org fan-out. + // The framework truncates results to --limit after aggregation. + listForOrg: async (org) => { + const { data } = await listReleasesPaginated(org, { perPage: 100 }); + return data; + }, + listPaginated: (org, opts) => listReleasesPaginated(org, opts), + withOrg: (release, orgSlug) => ({ ...release, orgSlug }), + displayTable: (releases: ReleaseWithOrg[]) => + formatTable(releases, RELEASE_COLUMNS), +}; + +const docs: OrgListCommandDocs = { + brief: "List releases", + fullDescription: + "List releases in an organization.\n\n" + + "Target specification:\n" + + " sentry release list # auto-detect from DSN or config\n" + + " sentry release list / # list all releases in org (paginated)\n" + + " sentry release list / # list releases in org (project context)\n" + + " sentry release list # list releases in org\n\n" + + "Pagination:\n" + + " sentry release list / -c next # fetch next page\n" + + " sentry release list / -c prev # fetch previous page\n\n" + + "Examples:\n" + + " sentry release list # auto-detect or list all\n" + + " sentry release list my-org/ # list releases in my-org (paginated)\n" + + " sentry release list --limit 10\n" + + " sentry release list --json\n\n" + + "Alias: `sentry releases` → `sentry release list`", +}; + +export const listCommand = buildOrgListCommand( + releaseListConfig, + docs, + "release" +); diff --git a/src/commands/release/parse.ts b/src/commands/release/parse.ts new file mode 100644 index 000000000..5a07da06d --- /dev/null +++ b/src/commands/release/parse.ts @@ -0,0 +1,55 @@ +/** + * Release argument parsing helpers + * + * Release versions can contain special characters (`@`, `+`, `.`, `-`) + * that are valid in version strings but could be confused with org/project + * slug separators. This module provides version-aware parsing. + */ + +import { ValidationError } from "../../lib/errors.js"; + +/** Slug pattern: lowercase alphanumeric + hyphens, no leading/trailing hyphen */ +const SLUG_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/; + +/** + * Parse a release positional argument: `[/]`. + * + * Unlike `parseSlashSeparatedArg` (which splits on the last slash), release + * versions can contain slashes themselves, so we split on the FIRST slash + * only when the prefix looks like a valid org slug. + * + * Heuristic: if the part before the first `/` is a valid slug (lowercase + * alphanumeric + hyphens, no special chars like `@`), treat it as an org. + * Otherwise, the entire string is the version. + * + * @param arg - The raw positional argument (e.g., "my-org/1.0.0" or "sentry-cli@1.0.0") + * @param usageHint - Usage example for error messages + * @returns Parsed org slug (optional) and version string + * @throws {ValidationError} If the version is empty + */ +export function parseReleaseArg( + arg: string, + usageHint: string +): { version: string; orgSlug?: string } { + const firstSlash = arg.indexOf("/"); + + if (firstSlash > 0) { + const prefix = arg.slice(0, firstSlash); + const rest = arg.slice(firstSlash + 1); + + // Only treat as org/version if the prefix is a valid slug + // (no @, +, or other special chars that appear in version strings) + if (SLUG_RE.test(prefix) && rest.length > 0) { + return { orgSlug: prefix, version: rest }; + } + } + + if (!arg) { + throw new ValidationError( + `Release version is required.\n\n Usage: ${usageHint}`, + "version" + ); + } + + return { version: arg }; +} diff --git a/src/commands/release/propose-version.ts b/src/commands/release/propose-version.ts new file mode 100644 index 000000000..9c6b68296 --- /dev/null +++ b/src/commands/release/propose-version.ts @@ -0,0 +1,115 @@ +/** + * sentry release propose-version + * + * Propose a release version by checking CI environment variables, + * falling back to the current git HEAD SHA. + * + * Detection order (matching the original sentry-cli): + * 1. SENTRY_RELEASE env var + * 2. SOURCE_VERSION (Heroku) + * 3. HEROKU_BUILD_COMMIT / HEROKU_SLUG_COMMIT + * 4. CODEBUILD_RESOLVED_SOURCE_VERSION (AWS CodeBuild) + * 5. CIRCLE_SHA1 (CircleCI) + * 6. CF_PAGES_COMMIT_SHA (Cloudflare Pages) + * 7. GAE_DEPLOYMENT_ID (Google App Engine) + * 8. GITHUB_SHA (GitHub Actions) + * 9. VERCEL_GIT_COMMIT_SHA (Vercel) + * 10. RENDER_GIT_COMMIT (Render) + * 11. NETLIFY_COMMIT_SHA (Netlify) + * 12. CI_COMMIT_SHA (GitLab CI) + * 13. BITBUCKET_COMMIT (Bitbucket Pipelines) + * 14. TRAVIS_COMMIT (Travis CI) + * 15. Git HEAD SHA (fallback) + */ + +import type { SentryContext } from "../../context.js"; +import { buildCommand } from "../../lib/command.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { getHeadCommit } from "../../lib/git.js"; + +type ProposeVersionResult = { + version: string; + source: string; +}; + +function formatProposedVersion(result: ProposeVersionResult): string { + return result.version; +} + +/** + * CI environment variables checked in priority order. + * Each entry: [env var name, human-readable source label]. + */ +const CI_ENV_VARS: ReadonlyArray = [ + ["SENTRY_RELEASE", "SENTRY_RELEASE"], + ["SOURCE_VERSION", "SOURCE_VERSION (Heroku)"], + ["HEROKU_BUILD_COMMIT", "HEROKU_BUILD_COMMIT"], + ["HEROKU_SLUG_COMMIT", "HEROKU_SLUG_COMMIT"], + [ + "CODEBUILD_RESOLVED_SOURCE_VERSION", + "CODEBUILD_RESOLVED_SOURCE_VERSION (AWS)", + ], + ["CIRCLE_SHA1", "CIRCLE_SHA1 (CircleCI)"], + ["CF_PAGES_COMMIT_SHA", "CF_PAGES_COMMIT_SHA (Cloudflare)"], + ["GAE_DEPLOYMENT_ID", "GAE_DEPLOYMENT_ID (Google App Engine)"], + ["GITHUB_SHA", "GITHUB_SHA (GitHub Actions)"], + ["VERCEL_GIT_COMMIT_SHA", "VERCEL_GIT_COMMIT_SHA (Vercel)"], + ["RENDER_GIT_COMMIT", "RENDER_GIT_COMMIT (Render)"], + ["NETLIFY_COMMIT_SHA", "NETLIFY_COMMIT_SHA (Netlify)"], + ["CI_COMMIT_SHA", "CI_COMMIT_SHA (GitLab CI)"], + ["BITBUCKET_COMMIT", "BITBUCKET_COMMIT (Bitbucket Pipelines)"], + ["TRAVIS_COMMIT", "TRAVIS_COMMIT (Travis CI)"], +]; + +export const proposeVersionCommand = buildCommand({ + docs: { + brief: "Propose a release version", + fullDescription: + "Propose a release version from CI environment variables or git HEAD SHA.\n\n" + + "Detection order:\n" + + " 1. SENTRY_RELEASE env var\n" + + " 2. SOURCE_VERSION (Heroku)\n" + + " 3. HEROKU_BUILD_COMMIT / HEROKU_SLUG_COMMIT\n" + + " 4. CODEBUILD_RESOLVED_SOURCE_VERSION (AWS CodeBuild)\n" + + " 5. CIRCLE_SHA1 (CircleCI)\n" + + " 6. CF_PAGES_COMMIT_SHA (Cloudflare Pages)\n" + + " 7. GAE_DEPLOYMENT_ID (Google App Engine)\n" + + " 8. GITHUB_SHA (GitHub Actions)\n" + + " 9. VERCEL_GIT_COMMIT_SHA (Vercel)\n" + + " 10. RENDER_GIT_COMMIT (Render)\n" + + " 11. NETLIFY_COMMIT_SHA (Netlify)\n" + + " 12. CI_COMMIT_SHA (GitLab CI)\n" + + " 13. BITBUCKET_COMMIT (Bitbucket Pipelines)\n" + + " 14. TRAVIS_COMMIT (Travis CI)\n" + + " 15. Git HEAD SHA (fallback)\n\n" + + "Useful in CI scripts:\n" + + " sentry release create $(sentry release propose-version)\n\n" + + "Examples:\n" + + " sentry release propose-version\n" + + " sentry release propose-version --json", + }, + output: { + human: formatProposedVersion, + }, + parameters: {}, + async *func( + this: SentryContext, + _flags: { readonly json: boolean; readonly fields?: string[] } + ) { + const { cwd, env } = this; + + // Check CI environment variables in priority order + for (const [envVar, source] of CI_ENV_VARS) { + const value = env[envVar]?.trim(); + if (value) { + yield new CommandOutput({ version: value, source }); + return { hint: `Detected from ${source}` }; + } + } + + // Fall back to git HEAD SHA + const sha = await getHeadCommit(cwd); + yield new CommandOutput({ version: sha, source: "git" }); + return { hint: "Detected from git HEAD" }; + }, +}); diff --git a/src/commands/release/set-commits.ts b/src/commands/release/set-commits.ts new file mode 100644 index 000000000..c70252b84 --- /dev/null +++ b/src/commands/release/set-commits.ts @@ -0,0 +1,328 @@ +/** + * sentry release set-commits + * + * Associate commits with a release using auto-discovery or local git history. + */ + +import type { OrgReleaseResponse } from "@sentry/api"; +import type { SentryContext } from "../../context.js"; +import { + setCommitsAuto, + setCommitsLocal, + setCommitsWithRefs, + updateRelease, +} from "../../lib/api-client.js"; +import { buildCommand, numberParser } from "../../lib/command.js"; +import { getDatabase } from "../../lib/db/index.js"; +import { clearMetadata, getMetadata, setMetadata } from "../../lib/db/utils.js"; +import { ApiError, ContextError, ValidationError } from "../../lib/errors.js"; +import { + escapeMarkdownInline, + mdKvTable, + renderMarkdown, + safeCodeSpan, +} from "../../lib/formatters/markdown.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { + getCommitLog, + getRepositoryName, + isShallowRepository, +} from "../../lib/git.js"; +import { logger } from "../../lib/logger.js"; +import { resolveOrg } from "../../lib/resolve-target.js"; +import { parseReleaseArg } from "./parse.js"; + +const log = logger.withTag("release.set-commits"); + +/** Read commits from local git history and send to the Sentry API. */ +function setCommitsFromLocal( + org: string, + version: string, + cwd: string, + depth: number +): Promise { + const shallow = isShallowRepository(cwd); + if (shallow) { + log.warn( + "Repository is a shallow clone. Commit history may be incomplete. " + + "Consider running `git fetch --unshallow` or increasing --initial-depth." + ); + } + + const commits = getCommitLog(cwd, { depth }); + const repoName = getRepositoryName(cwd); + const commitsWithRepo = commits.map((c) => ({ + ...c, + repository: repoName, + })); + + return setCommitsLocal(org, version, commitsWithRepo); +} + +// --------------------------------------------------------------------------- +// Repo integration cache — skip speculative --auto call when we know +// the org has no repo integration configured. Stored in the metadata +// key-value table with a 1-hour TTL. +// --------------------------------------------------------------------------- + +/** Cache TTL: 1 hour in milliseconds */ +const REPO_CACHE_TTL_MS = 60 * 60 * 1000; + +/** Check if we've cached that this org has no repo integration */ +function hasNoRepoIntegration(orgSlug: string): boolean { + try { + const db = getDatabase(); + const key = `repos_configured.${orgSlug}`; + const checkedKey = `${key}.checked_at`; + const m = getMetadata(db, [key, checkedKey]); + const value = m.get(key); + const checkedAt = m.get(checkedKey); + if (value === "false" && checkedAt) { + const age = Date.now() - Number(checkedAt); + return age < REPO_CACHE_TTL_MS; + } + } catch { + // DB errors shouldn't block the command + } + return false; +} + +/** Cache that this org has no repo integration */ +function cacheNoRepoIntegration(orgSlug: string): void { + try { + const db = getDatabase(); + const key = `repos_configured.${orgSlug}`; + setMetadata(db, { + [key]: "false", + [`${key}.checked_at`]: String(Date.now()), + }); + } catch { + // Non-fatal + } +} + +/** Clear the negative cache (e.g., when auto succeeds) */ +function clearRepoIntegrationCache(orgSlug: string): void { + try { + const db = getDatabase(); + const key = `repos_configured.${orgSlug}`; + clearMetadata(db, [key, `${key}.checked_at`]); + } catch { + // Non-fatal + } +} + +/** + * Default mode: try auto-discovery, fall back to local git. + * + * Uses a per-org negative cache to skip the speculative auto API call + * when we already know the org has no repo integration (1-hour TTL). + */ +async function setCommitsDefault( + org: string, + version: string, + cwd: string, + depth: number +): Promise { + // Fast path: cached "no repos" — skip the API call entirely + if (hasNoRepoIntegration(org)) { + return setCommitsFromLocal(org, version, cwd, depth); + } + + try { + const release = await setCommitsAuto(org, version); + clearRepoIntegrationCache(org); + return release; + } catch (error) { + if (error instanceof ApiError && error.status === 400) { + cacheNoRepoIntegration(org); + log.warn( + "Could not auto-discover commits (no repository integration). " + + "Falling back to local git history." + ); + return setCommitsFromLocal(org, version, cwd, depth); + } + throw error; + } +} + +function formatCommitsSet(release: OrgReleaseResponse): string { + const lines: string[] = []; + lines.push(`## Commits Set: ${escapeMarkdownInline(release.version)}`); + lines.push(""); + const kvRows: [string, string][] = []; + kvRows.push(["Version", safeCodeSpan(release.version)]); + kvRows.push(["Commits", String(release.commitCount ?? 0)]); + lines.push(mdKvTable(kvRows)); + return renderMarkdown(lines.join("\n")); +} + +export const setCommitsCommand = buildCommand({ + docs: { + brief: "Set commits for a release", + fullDescription: + "Associate commits with a release.\n\n" + + "Use --auto to let Sentry discover commits via your repository integration,\n" + + "or --local to read commits from the local git history.\n\n" + + "Examples:\n" + + " sentry release set-commits 1.0.0 --auto\n" + + " sentry release set-commits my-org/1.0.0 --local\n" + + " sentry release set-commits 1.0.0 --local --initial-depth 50\n" + + " sentry release set-commits 1.0.0 --commit owner/repo@abc123..def456\n" + + " sentry release set-commits 1.0.0 --clear", + }, + output: { + human: formatCommitsSet, + }, + parameters: { + positional: { + kind: "array", + parameter: { + placeholder: "org/version", + brief: "[/] - Release version", + parse: String, + }, + }, + flags: { + auto: { + kind: "boolean", + brief: "Use repository integration to auto-discover commits", + default: false, + }, + local: { + kind: "boolean", + brief: "Read commits from local git history", + default: false, + }, + clear: { + kind: "boolean", + brief: "Clear all commits from the release", + default: false, + }, + commit: { + kind: "parsed", + parse: String, + brief: + "Explicit commit as REPO@SHA or REPO@PREV..SHA (comma-separated)", + optional: true, + }, + "initial-depth": { + kind: "parsed", + parse: numberParser, + brief: "Number of commits to read with --local", + default: "20", + }, + }, + }, + async *func( + this: SentryContext, + flags: { + readonly auto: boolean; + readonly local: boolean; + readonly clear: boolean; + readonly commit?: string; + readonly "initial-depth": number; + readonly json: boolean; + readonly fields?: string[]; + }, + ...args: string[] + ) { + const { cwd } = this; + + const joined = args.join(" ").trim(); + if (!joined) { + throw new ContextError( + "Release version", + "sentry release set-commits [/] --auto", + [] + ); + } + + const { version, orgSlug } = parseReleaseArg( + joined, + "sentry release set-commits [/]" + ); + const resolved = await resolveOrg({ org: orgSlug, cwd }); + if (!resolved) { + throw new ContextError( + "Organization", + "sentry release set-commits [/]" + ); + } + + // Clear mode: remove all commits regardless of other flags + if (flags.clear) { + const release = await updateRelease(resolved.org, version, { + commits: [], + }); + yield new CommandOutput(release); + return; + } + + // Validate mutual exclusivity of commit source flags + const modeFlags = [flags.auto, flags.local, !!flags.commit].filter(Boolean); + if (modeFlags.length > 1) { + throw new ValidationError( + "Only one of --auto, --local, or --commit can be used at a time.", + "commit" + ); + } + + // Explicit --commit mode: parse REPO@SHA or REPO@PREV..SHA pairs as refs + if (flags.commit) { + const refs = flags.commit.split(",").map((pair) => { + const trimmed = pair.trim(); + const atIdx = trimmed.lastIndexOf("@"); + if (atIdx <= 0) { + throw new ValidationError( + `Invalid commit format '${trimmed}'. Expected REPO@SHA or REPO@PREV..SHA.`, + "commit" + ); + } + const repository = trimmed.slice(0, atIdx); + const sha = trimmed.slice(atIdx + 1); + + // Support REPO@PREV..SHA range syntax + const rangeParts = sha.split(".."); + if (rangeParts.length === 2 && rangeParts[0] && rangeParts[1]) { + return { + repository, + commit: rangeParts[1], + previousCommit: rangeParts[0], + }; + } + + return { repository, commit: sha }; + }); + + const release = await setCommitsWithRefs(resolved.org, version, refs); + yield new CommandOutput(release); + return; + } + + let release: OrgReleaseResponse; + + if (flags.local) { + // Explicit --local: use local git only + release = await setCommitsFromLocal( + resolved.org, + version, + cwd, + flags["initial-depth"] + ); + } else if (flags.auto) { + // Explicit --auto: use repo integration, fail hard on error + release = await setCommitsAuto(resolved.org, version); + } else { + // Default (no flag): try auto with cached fallback + release = await setCommitsDefault( + resolved.org, + version, + cwd, + flags["initial-depth"] + ); + } + + yield new CommandOutput(release); + }, +}); diff --git a/src/commands/release/view.ts b/src/commands/release/view.ts new file mode 100644 index 000000000..96777183a --- /dev/null +++ b/src/commands/release/view.ts @@ -0,0 +1,150 @@ +/** + * sentry release view + * + * View details of a specific release. + */ + +import type { OrgReleaseResponse } from "@sentry/api"; +import type { SentryContext } from "../../context.js"; +import { getRelease } from "../../lib/api-client.js"; +import { buildCommand } from "../../lib/command.js"; +import { ContextError } from "../../lib/errors.js"; +import { + colorTag, + escapeMarkdownInline, + mdKvTable, + renderMarkdown, + safeCodeSpan, +} from "../../lib/formatters/markdown.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { formatRelativeTime } from "../../lib/formatters/time-utils.js"; +import { + applyFreshFlag, + FRESH_ALIASES, + FRESH_FLAG, +} from "../../lib/list-command.js"; +import { resolveOrg } from "../../lib/resolve-target.js"; +import { parseReleaseArg } from "./parse.js"; + +function formatReleaseDetails(release: OrgReleaseResponse): string { + const lines: string[] = []; + + lines.push( + `## Release ${escapeMarkdownInline(release.shortVersion || release.version)}` + ); + lines.push(""); + + const kvRows: [string, string][] = []; + kvRows.push(["Version", safeCodeSpan(release.version)]); + if (release.shortVersion && release.shortVersion !== release.version) { + kvRows.push(["Short Version", safeCodeSpan(release.shortVersion)]); + } + kvRows.push([ + "Status", + release.dateReleased + ? colorTag("green", "Finalized") + : colorTag("yellow", "Unreleased"), + ]); + if (release.dateCreated) { + kvRows.push(["Created", formatRelativeTime(release.dateCreated)]); + } + kvRows.push([ + "Released", + release.dateReleased ? formatRelativeTime(release.dateReleased) : "—", + ]); + kvRows.push([ + "First Event", + release.firstEvent ? formatRelativeTime(release.firstEvent) : "—", + ]); + kvRows.push([ + "Last Event", + release.lastEvent ? formatRelativeTime(release.lastEvent) : "—", + ]); + kvRows.push(["Ref", release.ref || "—"]); + kvRows.push(["Commits", String(release.commitCount ?? 0)]); + kvRows.push(["Deploys", String(release.deployCount ?? 0)]); + kvRows.push(["New Issues", String(release.newGroups ?? 0)]); + + if (release.projects?.length) { + kvRows.push(["Projects", release.projects.map((p) => p.slug).join(", ")]); + } + + if (release.lastDeploy) { + kvRows.push([ + "Last Deploy", + `${release.lastDeploy.environment} (${formatRelativeTime(release.lastDeploy.dateFinished)})`, + ]); + } + + lines.push(mdKvTable(kvRows)); + return renderMarkdown(lines.join("\n")); +} + +export const viewCommand = buildCommand({ + docs: { + brief: "View release details", + fullDescription: + "Show detailed information about a Sentry release.\n\n" + + "Examples:\n" + + " sentry release view 1.0.0\n" + + " sentry release view my-org/1.0.0\n" + + ' sentry release view "sentry-cli@0.24.0"\n' + + " sentry release view 1.0.0 --json", + }, + output: { + human: formatReleaseDetails, + }, + parameters: { + positional: { + kind: "array", + parameter: { + placeholder: "org/version", + brief: "[/] - Release version to view", + parse: String, + }, + }, + flags: { + fresh: FRESH_FLAG, + }, + aliases: { ...FRESH_ALIASES }, + }, + async *func( + this: SentryContext, + flags: { + readonly fresh: boolean; + readonly json: boolean; + readonly fields?: string[]; + }, + ...args: string[] + ) { + applyFreshFlag(flags); + const { cwd } = this; + + const joined = args.join(" ").trim(); + if (!joined) { + throw new ContextError( + "Release version", + "sentry release view [/]", + [] + ); + } + + const { version, orgSlug } = parseReleaseArg( + joined, + "sentry release view [/]" + ); + const resolved = await resolveOrg({ org: orgSlug, cwd }); + if (!resolved) { + throw new ContextError( + "Organization", + "sentry release view [/]" + ); + } + const release = await getRelease(resolved.org, version); + yield new CommandOutput(release); + const hint = resolved.detectedFrom + ? `Detected from ${resolved.detectedFrom}` + : undefined; + return { hint }; + }, +}); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index c78eb0fa4..247ae0fac 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -86,6 +86,18 @@ export { type ProjectWithOrg, tryGetPrimaryDsn, } from "./api/projects.js"; +export { + createRelease, + createReleaseDeploy, + deleteRelease, + getRelease, + listReleaseDeploys, + listReleasesPaginated, + setCommitsAuto, + setCommitsLocal, + setCommitsWithRefs, + updateRelease, +} from "./api/releases.js"; export { listRepositories, listRepositoriesPaginated, diff --git a/src/lib/api/releases.ts b/src/lib/api/releases.ts new file mode 100644 index 000000000..7105ea02b --- /dev/null +++ b/src/lib/api/releases.ts @@ -0,0 +1,354 @@ +/** + * Release API functions + * + * Functions for listing, creating, updating, deleting, and deploying + * Sentry releases in an organization. + */ + +import type { DeployResponse, OrgReleaseResponse } from "@sentry/api"; +import { + createADeploy, + createANewReleaseForAnOrganization, + deleteAnOrganization_sRelease, + listAnOrganization_sReleases, + listARelease_sDeploys, + retrieveAnOrganization_sRelease, + updateAnOrganization_sRelease, +} from "@sentry/api"; + +import { resolveOrgRegion } from "../region.js"; +import { + apiRequestToRegion, + getOrgSdkConfig, + type PaginatedResponse, + unwrapPaginatedResult, + unwrapResult, +} from "./infrastructure.js"; + +// We cast through `unknown` to bridge the gap between the SDK's internal +// return types and the public response types — the shapes are compatible +// at runtime. + +/** + * List releases in an organization with pagination control. + * Returns a single page of results with cursor metadata. + * + * @param orgSlug - Organization slug + * @param options - Pagination, query, and sort options + * @returns Single page of releases with cursor metadata + */ +export async function listReleasesPaginated( + orgSlug: string, + options: { + cursor?: string; + perPage?: number; + query?: string; + sort?: string; + } = {} +): Promise> { + const config = await getOrgSdkConfig(orgSlug); + + const result = await listAnOrganization_sReleases({ + ...config, + path: { organization_id_or_slug: orgSlug }, + // per_page and sort are supported at runtime but not in the OpenAPI spec + query: { + cursor: options.cursor, + per_page: options.perPage ?? 25, + query: options.query, + sort: options.sort, + } as { cursor?: string }, + }); + + return unwrapPaginatedResult( + result as + | { data: OrgReleaseResponse[]; error: undefined } + | { data: undefined; error: unknown }, + "Failed to list releases" + ); +} + +/** + * Get a single release by version. + * Version is URL-encoded by the SDK. + * + * @param orgSlug - Organization slug + * @param version - Release version string (e.g., "1.0.0", "sentry-cli@0.24.0") + * @returns Full release detail + */ +export async function getRelease( + orgSlug: string, + version: string +): Promise { + const config = await getOrgSdkConfig(orgSlug); + + const result = await retrieveAnOrganization_sRelease({ + ...config, + path: { + organization_id_or_slug: orgSlug, + version, + }, + }); + + const data = unwrapResult(result, `Failed to get release '${version}'`); + return data as unknown as OrgReleaseResponse; +} + +/** + * Create a new release. + * + * @param orgSlug - Organization slug + * @param body - Release creation payload + * @returns Created release detail + */ +export async function createRelease( + orgSlug: string, + body: { + version: string; + projects?: string[]; + ref?: string; + url?: string; + dateReleased?: string; + commits?: Array<{ + id: string; + repository?: string; + message?: string; + author_name?: string; + author_email?: string; + timestamp?: string; + }>; + } +): Promise { + const config = await getOrgSdkConfig(orgSlug); + + // Cast body through unknown — the SDK's body type requires `projects: string[]` + // as non-optional, but the API accepts it as optional at runtime. + const result = await createANewReleaseForAnOrganization({ + ...config, + path: { organization_id_or_slug: orgSlug }, + body: body as unknown as Parameters< + typeof createANewReleaseForAnOrganization + >[0]["body"], + }); + + // 208 = release already exists (idempotent) — treat as success + if (result.data) { + return result.data as unknown as OrgReleaseResponse; + } + const data = unwrapResult(result, "Failed to create release"); + return data as unknown as OrgReleaseResponse; +} + +/** + * Update a release. Used for finalization, setting refs, etc. + * + * @param orgSlug - Organization slug + * @param version - Release version (URL-encoded by SDK) + * @param body - Fields to update + * @returns Updated release detail + */ +export async function updateRelease( + orgSlug: string, + version: string, + body: { + ref?: string; + url?: string; + dateReleased?: string; + commits?: Array<{ + id: string; + repository?: string; + message?: string; + author_name?: string; + author_email?: string; + timestamp?: string; + }>; + } +): Promise { + const config = await getOrgSdkConfig(orgSlug); + + const result = await updateAnOrganization_sRelease({ + ...config, + path: { + organization_id_or_slug: orgSlug, + version, + }, + body: body as unknown as Parameters< + typeof updateAnOrganization_sRelease + >[0]["body"], + }); + + const data = unwrapResult(result, `Failed to update release '${version}'`); + return data as unknown as OrgReleaseResponse; +} + +/** + * Delete a release. + * + * @param orgSlug - Organization slug + * @param version - Release version + */ +export async function deleteRelease( + orgSlug: string, + version: string +): Promise { + const config = await getOrgSdkConfig(orgSlug); + + const result = await deleteAnOrganization_sRelease({ + ...config, + path: { + organization_id_or_slug: orgSlug, + version, + }, + }); + + unwrapResult(result, `Failed to delete release '${version}'`); +} + +/** + * List deploys for a release. + * + * @param orgSlug - Organization slug + * @param version - Release version + * @returns Array of deploy details + */ +export async function listReleaseDeploys( + orgSlug: string, + version: string +): Promise { + const config = await getOrgSdkConfig(orgSlug); + + const result = await listARelease_sDeploys({ + ...config, + path: { + organization_id_or_slug: orgSlug, + version, + }, + }); + + const data = unwrapResult( + result, + `Failed to list deploys for release '${version}'` + ); + return data as unknown as DeployResponse[]; +} + +/** + * Create a deploy for a release. + * + * @param orgSlug - Organization slug + * @param version - Release version + * @param body - Deploy creation payload + * @returns Created deploy detail + */ +export async function createReleaseDeploy( + orgSlug: string, + version: string, + body: { + environment: string; + name?: string; + url?: string; + dateStarted?: string; + dateFinished?: string; + } +): Promise { + const config = await getOrgSdkConfig(orgSlug); + + const result = await createADeploy({ + ...config, + path: { + organization_id_or_slug: orgSlug, + version, + }, + body: body as unknown as Parameters[0]["body"], + }); + + const data = unwrapResult(result, "Failed to create deploy"); + return data as unknown as DeployResponse; +} + +/** + * Set commits on a release using auto-discovery mode. + * + * This uses the internal API format `refs: [{repository: "auto", commit: "auto"}]` + * which is not part of the OpenAPI spec, so we use apiRequestToRegion directly. + * + * Requires a GitHub/GitLab/Bitbucket integration configured in Sentry. + * + * @param orgSlug - Organization slug + * @param version - Release version + * @returns Updated release detail with commit count + */ +export async function setCommitsAuto( + orgSlug: string, + version: string +): Promise { + const regionUrl = await resolveOrgRegion(orgSlug); + const encodedVersion = encodeURIComponent(version); + const { data } = await apiRequestToRegion( + regionUrl, + `organizations/${orgSlug}/releases/${encodedVersion}/`, + { + method: "PUT", + body: { + refs: [{ repository: "auto", commit: "auto" }], + }, + } + ); + return data; +} + +/** + * Set commits on a release using explicit refs (repository + commit range). + * + * Sends the refs format which supports previous commit for range-based + * commit association (matching the reference sentry-cli's `--commit REPO@PREV..SHA`). + * + * @param orgSlug - Organization slug + * @param version - Release version + * @param refs - Array of ref objects + * @returns Updated release detail + */ +export async function setCommitsWithRefs( + orgSlug: string, + version: string, + refs: Array<{ + repository: string; + commit: string; + previousCommit?: string; + }> +): Promise { + const regionUrl = await resolveOrgRegion(orgSlug); + const encodedVersion = encodeURIComponent(version); + const { data } = await apiRequestToRegion( + regionUrl, + `organizations/${orgSlug}/releases/${encodedVersion}/`, + { + method: "PUT", + body: { refs }, + } + ); + return data; +} + +/** + * Set commits on a release using explicit commit data. + * + * @param orgSlug - Organization slug + * @param version - Release version + * @param commits - Array of commit data + * @returns Updated release detail + */ +export function setCommitsLocal( + orgSlug: string, + version: string, + commits: Array<{ + id: string; + repository?: string; + message?: string; + author_name?: string; + author_email?: string; + timestamp?: string; + }> +): Promise { + return updateRelease(orgSlug, version, { commits }); +} diff --git a/src/lib/complete.ts b/src/lib/complete.ts index 23fa69963..044114d4e 100644 --- a/src/lib/complete.ts +++ b/src/lib/complete.ts @@ -116,6 +116,14 @@ export const ORG_PROJECT_COMMANDS = new Set([ */ export const ORG_ONLY_COMMANDS = new Set([ "org view", + "release list", + "release view", + "release create", + "release finalize", + "release delete", + "release deploy", + "release deploys", + "release set-commits", "team list", "repo list", "trial list", diff --git a/src/lib/git.ts b/src/lib/git.ts new file mode 100644 index 000000000..6c6127705 --- /dev/null +++ b/src/lib/git.ts @@ -0,0 +1,230 @@ +/** + * Centralized git helpers + * + * Low-level git primitives used across the CLI: release management, + * init wizard pre-flight checks, and version detection. + * + * Uses `execFileSync` (no shell) from `node:child_process` instead of + * `Bun.spawnSync` because this module is also used by the npm/Node + * distribution (via esbuild bundle), where Bun APIs are shimmed but + * `node:child_process` works natively. This avoids shell injection + * risks and is consistent with the AGENTS.md `execSync` exception. + */ + +import { execFileSync } from "node:child_process"; + +import { ValidationError } from "./errors.js"; + +/** Commit data structure matching the Sentry releases API */ +export type GitCommit = { + id: string; + message: string; + author_name: string; + author_email: string; + timestamp: string; + repository?: string; +}; + +/** + * Run a git command and return trimmed stdout. + * + * Uses `execFileSync` (no shell) to avoid shell injection risks. + * Arguments are passed as an array, not interpolated into a string. + * + * @param args - Git subcommand and arguments as separate strings + * @param cwd - Working directory + * @returns Trimmed stdout + * @throws On non-zero exit + */ +function git(args: string[], cwd?: string): string { + return execFileSync("git", args, { + cwd, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); +} + +// --------------------------------------------------------------------------- +// Repository status +// --------------------------------------------------------------------------- + +/** + * Check if the current directory is inside a git work tree. + * + * @param cwd - Working directory + * @returns true if inside a git work tree + */ +export function isInsideGitWorkTree(cwd?: string): boolean { + try { + git(["rev-parse", "--is-inside-work-tree"], cwd); + return true; + } catch { + return false; + } +} + +/** + * Get the list of uncommitted or untracked files. + * + * Returns each line from `git status --porcelain=v1` prefixed with `- `. + * Empty array if the working tree is clean or not a git repo. + * + * @param cwd - Working directory + * @returns Array of formatted status lines (e.g., `["- M src/index.ts", "- ?? new-file.ts"]`) + */ +export function getUncommittedFiles(cwd?: string): string[] { + try { + const raw = git(["status", "--porcelain=v1"], cwd); + if (!raw) { + return []; + } + return raw + .split("\n") + .filter((line) => line.length > 0) + .map((line) => `- ${line}`); + } catch { + return []; + } +} + +// --------------------------------------------------------------------------- +// Commit info +// --------------------------------------------------------------------------- + +/** + * Get the HEAD commit SHA. + * + * @param cwd - Working directory (defaults to process.cwd()) + * @returns 40-char commit SHA + * @throws {ValidationError} When not inside a git repository + */ +export function getHeadCommit(cwd?: string): string { + try { + return git(["rev-parse", "HEAD"], cwd); + } catch { + throw new ValidationError( + "Not a git repository. Run this command from within a git working tree.", + "git" + ); + } +} + +/** + * Check if the current repository is a shallow clone. + * + * @param cwd - Working directory + * @returns true if the repository is shallow + */ +export function isShallowRepository(cwd?: string): boolean { + try { + return git(["rev-parse", "--is-shallow-repository"], cwd) === "true"; + } catch { + return false; + } +} + +/** NUL byte used as record separator in git log output */ +const NUL = "\x00"; + +/** + * Get the commit log from HEAD, optionally limited by depth or a starting commit. + * + * Uses NUL-delimited output for robust parsing (commit messages can contain newlines). + * The format string uses git's `%x00` hex escape (not literal NUL bytes) because + * `execSync` rejects command strings containing null bytes. + * + * @param cwd - Working directory + * @param options - Log options + * @returns Array of commit data matching the Sentry releases API format + */ +export function getCommitLog( + cwd?: string, + options: { from?: string; depth?: number } = {} +): GitCommit[] { + const { from, depth = 20 } = options; + + // Format: hash, subject, author name, author email, author date (ISO) + // %x00 is git's hex escape for NUL — avoids literal NUL in the command string + const format = "%H%x00%s%x00%aN%x00%aE%x00%aI"; + const range = from ? `${from}..HEAD` : "HEAD"; + const raw = git( + ["log", `--format=${format}`, `--max-count=${depth}`, range], + cwd + ); + + if (!raw) { + return []; + } + + return raw.split("\n").map((line) => { + const [id, message, authorName, authorEmail, timestamp] = line.split(NUL); + return { + id: id ?? "", + message: message ?? "", + author_name: authorName ?? "", + author_email: authorEmail ?? "", + timestamp: timestamp ?? "", + }; + }); +} + +/** + * Get the repository name from the "origin" remote URL. + * + * Parses both HTTPS and SSH remote formats: + * - `https://github.com/owner/repo.git` → `owner/repo` + * - `git@github.com:owner/repo.git` → `owner/repo` + * + * @param cwd - Working directory + * @returns `owner/repo` string, or undefined if no origin remote + */ +export function getRepositoryName(cwd?: string): string | undefined { + try { + const url = git(["remote", "get-url", "origin"], cwd); + return parseRemoteUrl(url); + } catch { + return; + } +} + +/** SSH remote URL pattern: git@host:owner/repo.git */ +const SSH_REMOTE_RE = /:([^/][^:]+?)(?:\.git)?$/; + +/** Leading slash in URL pathname */ +const LEADING_SLASH_RE = /^\//; + +/** Trailing .git suffix */ +const DOT_GIT_SUFFIX_RE = /\.git$/; + +/** + * Parse a git remote URL to extract `owner/repo`. + * + * Handles HTTPS, SSH, and git:// protocols. Strips `.git` suffix. + * + * @param url - Remote URL string + * @returns `owner/repo` string, or undefined if unparseable + */ +export function parseRemoteUrl(url: string): string | undefined { + // Try URL parsing first — handles https://, ssh://, git:// protocols + // (including ssh://git@host:port/path which would confuse the SCP regex) + try { + const parsed = new URL(url); + const path = parsed.pathname + .replace(LEADING_SLASH_RE, "") + .replace(DOT_GIT_SUFFIX_RE, ""); + if (path.includes("/")) { + return path; + } + } catch { + // Not a valid URL — try SCP-style format below + } + + // SCP-style SSH format: git@github.com:owner/repo.git + // Only matched when URL parsing fails (avoids confusion with port numbers) + const sshMatch = url.match(SSH_REMOTE_RE); + if (sshMatch && url.includes("@")) { + return sshMatch[1]; + } + + return; +} diff --git a/src/lib/init/git.ts b/src/lib/init/git.ts index bb8773def..d3f7a4760 100644 --- a/src/lib/init/git.ts +++ b/src/lib/init/git.ts @@ -1,41 +1,39 @@ /** - * Git Safety Checks + * Git Safety Checks for Init Wizard * * Pre-flight checks to verify the user is in a git repo with a clean * working tree before the init wizard starts modifying files. + * + * Low-level git primitives live in `src/lib/git.ts`. This module + * re-exports them for backward compatibility and adds the interactive + * `checkGitStatus` orchestrator (coupled to `@clack/prompts` UI). */ import { confirm, isCancel, log } from "@clack/prompts"; +import { + getUncommittedFiles, + isInsideGitWorkTree as isInsideWorkTree, +} from "../git.js"; /** Maximum number of uncommitted files to display before truncating. */ const MAX_DISPLAYED_FILES = 5; +/** + * Check if the current directory is inside a git work tree. + * Thin wrapper that adapts the `{cwd}` object signature expected by the init wizard. + */ export function isInsideGitWorkTree(opts: { cwd: string }): boolean { - const result = Bun.spawnSync(["git", "rev-parse", "--is-inside-work-tree"], { - stdout: "ignore", - stderr: "ignore", - cwd: opts.cwd, - }); - return result.success; + return isInsideWorkTree(opts.cwd); } +/** + * Get uncommitted or untracked files formatted for display. + * Thin wrapper that adapts the `{cwd}` object signature expected by the init wizard. + */ export function getUncommittedOrUntrackedFiles(opts: { cwd: string; }): string[] { - const result = Bun.spawnSync(["git", "status", "--porcelain=v1"], { - stdout: "pipe", - stderr: "ignore", - cwd: opts.cwd, - }); - if (!(result.success && result.stdout)) { - return []; - } - return result.stdout - .toString() - .trimEnd() - .split("\n") - .filter((line) => line.length > 0) - .map((line) => `- ${line.trimEnd()}`); + return getUncommittedFiles(opts.cwd); } /** diff --git a/test/commands/release/create.test.ts b/test/commands/release/create.test.ts new file mode 100644 index 000000000..2c0ab3c5b --- /dev/null +++ b/test/commands/release/create.test.ts @@ -0,0 +1,154 @@ +/** + * Release Create Command Tests + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { createCommand } from "../../../src/commands/release/create.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; +import { useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("release-create-"); + +const sampleRelease: OrgReleaseResponse = { + id: 1, + version: "1.0.0", + shortVersion: "1.0.0", + status: "open", + dateCreated: "2025-01-01T00:00:00Z", + dateReleased: null, + commitCount: 0, + deployCount: 0, + newGroups: 0, + versionInfo: null, + data: {}, + authors: [], + projects: [ + { + id: 1, + slug: "my-project", + name: "My Project", + platform: null, + platforms: null, + hasHealthData: false, + newGroups: 0, + }, + ], +}; + +function createMockContext(cwd = "/tmp") { + const stdoutWrite = mock(() => true); + const stderrWrite = mock(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: stderrWrite }, + cwd, + }, + stdoutWrite, + stderrWrite, + }; +} + +describe("release create", () => { + let createReleaseSpy: ReturnType; + let resolveOrgSpy: ReturnType; + + beforeEach(() => { + createReleaseSpy = spyOn(apiClient, "createRelease"); + resolveOrgSpy = spyOn(resolveTarget, "resolveOrg"); + }); + + afterEach(() => { + createReleaseSpy.mockRestore(); + resolveOrgSpy.mockRestore(); + }); + + test("creates a release with version", async () => { + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + createReleaseSpy.mockResolvedValue(sampleRelease); + + const { context, stdoutWrite } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { finalize: false, json: true }, "my-org/1.0.0"); + + expect(createReleaseSpy).toHaveBeenCalledWith("my-org", { + version: "1.0.0", + }); + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.version).toBe("1.0.0"); + }); + + test("passes --project flag", async () => { + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + createReleaseSpy.mockResolvedValue(sampleRelease); + + const { context } = createMockContext(); + const func = await createCommand.loader(); + await func.call( + context, + { finalize: false, project: "my-project", json: true }, + "1.0.0" + ); + + expect(createReleaseSpy).toHaveBeenCalledWith("my-org", { + version: "1.0.0", + projects: ["my-project"], + }); + }); + + test("--finalize sets dateReleased", async () => { + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + createReleaseSpy.mockResolvedValue({ + ...sampleRelease, + dateReleased: "2025-01-01T00:00:00Z", + }); + + const { context } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { finalize: true, json: true }, "1.0.0"); + + const call = createReleaseSpy.mock.calls[0]; + const body = call[1]; + expect(body.dateReleased).toBeDefined(); + expect(typeof body.dateReleased).toBe("string"); + }); + + test("passes --ref flag", async () => { + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + createReleaseSpy.mockResolvedValue(sampleRelease); + + const { context } = createMockContext(); + const func = await createCommand.loader(); + await func.call( + context, + { finalize: false, ref: "main", json: true }, + "1.0.0" + ); + + expect(createReleaseSpy).toHaveBeenCalledWith("my-org", { + version: "1.0.0", + ref: "main", + }); + }); + + test("throws when no version provided", async () => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + + await expect( + func.call(context, { finalize: false, json: false }) + ).rejects.toThrow("Release version"); + }); +}); diff --git a/test/commands/release/deploy.test.ts b/test/commands/release/deploy.test.ts new file mode 100644 index 000000000..5cfd9bf55 --- /dev/null +++ b/test/commands/release/deploy.test.ts @@ -0,0 +1,141 @@ +/** + * Release Deploy Command Tests + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { deployCommand } from "../../../src/commands/release/deploy.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; +import { useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("release-deploy-"); + +const sampleDeploy: DeployResponse = { + id: "42", + environment: "production", + dateStarted: null, + dateFinished: "2025-01-01T12:00:00Z", + name: null, + url: null, +}; + +function createMockContext(cwd = "/tmp") { + const stdoutWrite = mock(() => true); + const stderrWrite = mock(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: stderrWrite }, + cwd, + }, + stdoutWrite, + stderrWrite, + }; +} + +describe("release deploy", () => { + let createRelaseDeploySpy: ReturnType; + let resolveOrgSpy: ReturnType; + + beforeEach(() => { + createRelaseDeploySpy = spyOn(apiClient, "createReleaseDeploy"); + resolveOrgSpy = spyOn(resolveTarget, "resolveOrg"); + }); + + afterEach(() => { + createRelaseDeploySpy.mockRestore(); + resolveOrgSpy.mockRestore(); + }); + + test("creates a deploy with environment positional", async () => { + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + createRelaseDeploySpy.mockResolvedValue(sampleDeploy); + + const { context, stdoutWrite } = createMockContext(); + const func = await deployCommand.loader(); + await func.call(context, { json: true }, "my-org/1.0.0", "production"); + + expect(createRelaseDeploySpy).toHaveBeenCalledWith("my-org", "1.0.0", { + environment: "production", + }); + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.environment).toBe("production"); + }); + + test("passes deploy name as third positional", async () => { + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + createRelaseDeploySpy.mockResolvedValue({ + ...sampleDeploy, + name: "Deploy #42", + }); + + const { context } = createMockContext(); + const func = await deployCommand.loader(); + await func.call( + context, + { json: true }, + "1.0.0", + "staging", + "Deploy", + "#42" + ); + + expect(createRelaseDeploySpy).toHaveBeenCalledWith( + "my-org", + "1.0.0", + expect.objectContaining({ environment: "staging", name: "Deploy #42" }) + ); + }); + + test("passes --url flag", async () => { + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + createRelaseDeploySpy.mockResolvedValue(sampleDeploy); + + const { context } = createMockContext(); + const func = await deployCommand.loader(); + await func.call( + context, + { url: "https://example.com", json: true }, + "1.0.0", + "production" + ); + + expect(createRelaseDeploySpy).toHaveBeenCalledWith( + "my-org", + "1.0.0", + expect.objectContaining({ + environment: "production", + url: "https://example.com", + }) + ); + }); + + test("throws when missing environment", async () => { + const { context } = createMockContext(); + const func = await deployCommand.loader(); + + await expect(func.call(context, { json: false }, "1.0.0")).rejects.toThrow( + "Release version and environment" + ); + }); + + test("throws when no args provided", async () => { + const { context } = createMockContext(); + const func = await deployCommand.loader(); + + await expect(func.call(context, { json: false })).rejects.toThrow( + "Release version and environment" + ); + }); +}); diff --git a/test/commands/release/finalize.test.ts b/test/commands/release/finalize.test.ts new file mode 100644 index 000000000..5edbc9a69 --- /dev/null +++ b/test/commands/release/finalize.test.ts @@ -0,0 +1,104 @@ +/** + * Release Finalize Command Tests + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { finalizeCommand } from "../../../src/commands/release/finalize.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; +import { useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("release-finalize-"); + +const finalizedRelease: OrgReleaseResponse = { + id: 1, + version: "1.0.0", + shortVersion: "1.0.0", + status: "open", + dateCreated: "2025-01-01T00:00:00Z", + dateReleased: "2025-06-15T12:00:00Z", + commitCount: 5, + deployCount: 0, + newGroups: 0, + versionInfo: null, + data: {}, + authors: [], + projects: [], +}; + +function createMockContext(cwd = "/tmp") { + const stdoutWrite = mock(() => true); + const stderrWrite = mock(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: stderrWrite }, + cwd, + }, + stdoutWrite, + stderrWrite, + }; +} + +describe("release finalize", () => { + let updateReleaseSpy: ReturnType; + let resolveOrgSpy: ReturnType; + + beforeEach(() => { + updateReleaseSpy = spyOn(apiClient, "updateRelease"); + resolveOrgSpy = spyOn(resolveTarget, "resolveOrg"); + }); + + afterEach(() => { + updateReleaseSpy.mockRestore(); + resolveOrgSpy.mockRestore(); + }); + + test("finalizes a release", async () => { + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + updateReleaseSpy.mockResolvedValue(finalizedRelease); + + const { context, stdoutWrite } = createMockContext(); + const func = await finalizeCommand.loader(); + await func.call(context, { json: true }, "my-org/1.0.0"); + + expect(updateReleaseSpy).toHaveBeenCalledWith( + "my-org", + "1.0.0", + expect.objectContaining({ dateReleased: expect.any(String) }) + ); + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.dateReleased).toBe("2025-06-15T12:00:00Z"); + }); + + test("throws when no version provided", async () => { + const { context } = createMockContext(); + const func = await finalizeCommand.loader(); + + await expect(func.call(context, { json: false })).rejects.toThrow( + "Release version" + ); + }); + + test("throws when org cannot be resolved", async () => { + resolveOrgSpy.mockResolvedValue(null); + + const { context } = createMockContext(); + const func = await finalizeCommand.loader(); + + await expect(func.call(context, { json: false }, "1.0.0")).rejects.toThrow( + "Organization" + ); + }); +}); diff --git a/test/commands/release/propose-version.test.ts b/test/commands/release/propose-version.test.ts new file mode 100644 index 000000000..e042fc6ee --- /dev/null +++ b/test/commands/release/propose-version.test.ts @@ -0,0 +1,139 @@ +/** + * Release Propose-Version Command Tests + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { proposeVersionCommand } from "../../../src/commands/release/propose-version.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as git from "../../../src/lib/git.js"; +import { useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("release-propose-version-"); + +function createMockContext(cwd = "/tmp", env: Record = {}) { + const stdoutWrite = mock(() => true); + const stderrWrite = mock(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: stderrWrite }, + cwd, + env, + }, + stdoutWrite, + stderrWrite, + }; +} + +describe("release propose-version", () => { + let getHeadCommitSpy: ReturnType; + + beforeEach(() => { + getHeadCommitSpy = spyOn(git, "getHeadCommit"); + }); + + afterEach(() => { + getHeadCommitSpy.mockRestore(); + }); + + test("outputs HEAD SHA in JSON mode when no env vars set", async () => { + getHeadCommitSpy.mockResolvedValue( + "abc123def456789012345678901234567890abcd" + ); + + const { context, stdoutWrite } = createMockContext(); + const func = await proposeVersionCommand.loader(); + await func.call(context, { json: true }); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.version).toBe("abc123def456789012345678901234567890abcd"); + }); + + test("outputs bare SHA in human mode", async () => { + getHeadCommitSpy.mockResolvedValue( + "abc123def456789012345678901234567890abcd" + ); + + const { context, stdoutWrite } = createMockContext(); + const func = await proposeVersionCommand.loader(); + await func.call(context, { json: false }); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("abc123def456789012345678901234567890abcd"); + }); + + test("passes cwd to getHeadCommit", async () => { + getHeadCommitSpy.mockResolvedValue("deadbeef"); + + const { context } = createMockContext("/my/project"); + const func = await proposeVersionCommand.loader(); + await func.call(context, { json: true }); + + expect(getHeadCommitSpy).toHaveBeenCalledWith("/my/project"); + }); + + test("propagates git errors", async () => { + getHeadCommitSpy.mockRejectedValue(new Error("Not a git repository")); + + const { context } = createMockContext(); + const func = await proposeVersionCommand.loader(); + + await expect(func.call(context, { json: false })).rejects.toThrow( + "Not a git repository" + ); + }); + + test("prefers SENTRY_RELEASE env var over git", async () => { + const { context, stdoutWrite } = createMockContext("/tmp", { + SENTRY_RELEASE: "my-release-1.0", + }); + const func = await proposeVersionCommand.loader(); + await func.call(context, { json: true }); + + // Should NOT call getHeadCommit + expect(getHeadCommitSpy).not.toHaveBeenCalled(); + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.version).toBe("my-release-1.0"); + }); + + test("prefers CI env vars in priority order", async () => { + const { context, stdoutWrite } = createMockContext("/tmp", { + CIRCLE_SHA1: "circle-sha", + SOURCE_VERSION: "heroku-version", + }); + const func = await proposeVersionCommand.loader(); + await func.call(context, { json: true }); + + // SOURCE_VERSION has higher priority than CIRCLE_SHA1 + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.version).toBe("heroku-version"); + }); + + test("skips empty env vars", async () => { + getHeadCommitSpy.mockResolvedValue("git-sha"); + + const { context, stdoutWrite } = createMockContext("/tmp", { + SENTRY_RELEASE: "", + SOURCE_VERSION: " ", + }); + const func = await proposeVersionCommand.loader(); + await func.call(context, { json: true }); + + // Empty/whitespace env vars are skipped, falls through to git + expect(getHeadCommitSpy).toHaveBeenCalled(); + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.version).toBe("git-sha"); + }); +}); diff --git a/test/commands/release/set-commits.test.ts b/test/commands/release/set-commits.test.ts new file mode 100644 index 000000000..f23488ab5 --- /dev/null +++ b/test/commands/release/set-commits.test.ts @@ -0,0 +1,191 @@ +/** + * Release Set-Commits Command Tests + * + * Tests the --commit flag parsing (single SHA, ranges, validation) + * and mode mutual exclusivity. + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import type { OrgReleaseResponse } from "@sentry/api"; +import { setCommitsCommand } from "../../../src/commands/release/set-commits.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; +import { useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("release-set-commits-"); + +const sampleRelease: OrgReleaseResponse = { + id: 1, + version: "1.0.0", + shortVersion: "1.0.0", + status: "open", + dateCreated: "2025-01-01T00:00:00Z", + dateReleased: null, + commitCount: 3, + deployCount: 0, + newGroups: 0, + authors: [], + projects: [], + data: {}, + versionInfo: null, +}; + +function createMockContext(cwd = "/tmp") { + const stdoutWrite = mock(() => true); + const stderrWrite = mock(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: stderrWrite }, + cwd, + }, + stdoutWrite, + stderrWrite, + }; +} + +describe("release set-commits --commit", () => { + let setCommitsWithRefsSpy: ReturnType; + let resolveOrgSpy: ReturnType; + + beforeEach(() => { + setCommitsWithRefsSpy = spyOn(apiClient, "setCommitsWithRefs"); + resolveOrgSpy = spyOn(resolveTarget, "resolveOrg"); + }); + + afterEach(() => { + setCommitsWithRefsSpy.mockRestore(); + resolveOrgSpy.mockRestore(); + }); + + test("parses single REPO@SHA", async () => { + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + setCommitsWithRefsSpy.mockResolvedValue(sampleRelease); + + const { context } = createMockContext(); + const func = await setCommitsCommand.loader(); + await func.call( + context, + { + auto: false, + local: false, + clear: false, + commit: "owner/repo@abc123", + "initial-depth": 20, + json: true, + }, + "1.0.0" + ); + + expect(setCommitsWithRefsSpy).toHaveBeenCalledWith("my-org", "1.0.0", [ + { repository: "owner/repo", commit: "abc123" }, + ]); + }); + + test("parses REPO@PREV..SHA range", async () => { + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + setCommitsWithRefsSpy.mockResolvedValue(sampleRelease); + + const { context } = createMockContext(); + const func = await setCommitsCommand.loader(); + await func.call( + context, + { + auto: false, + local: false, + clear: false, + commit: "owner/repo@abc123..def456", + "initial-depth": 20, + json: true, + }, + "1.0.0" + ); + + expect(setCommitsWithRefsSpy).toHaveBeenCalledWith("my-org", "1.0.0", [ + { + repository: "owner/repo", + commit: "def456", + previousCommit: "abc123", + }, + ]); + }); + + test("parses comma-separated refs", async () => { + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + setCommitsWithRefsSpy.mockResolvedValue(sampleRelease); + + const { context } = createMockContext(); + const func = await setCommitsCommand.loader(); + await func.call( + context, + { + auto: false, + local: false, + clear: false, + commit: "repo-a@sha1,repo-b@prev..sha2", + "initial-depth": 20, + json: true, + }, + "1.0.0" + ); + + expect(setCommitsWithRefsSpy).toHaveBeenCalledWith("my-org", "1.0.0", [ + { repository: "repo-a", commit: "sha1" }, + { repository: "repo-b", commit: "sha2", previousCommit: "prev" }, + ]); + }); + + test("throws on invalid format (no @)", async () => { + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + + const { context } = createMockContext(); + const func = await setCommitsCommand.loader(); + + await expect( + func.call( + context, + { + auto: false, + local: false, + clear: false, + commit: "invalid-no-at-sign", + "initial-depth": 20, + json: false, + }, + "1.0.0" + ) + ).rejects.toThrow("Invalid commit format"); + }); + + test("throws when --commit used with --auto", async () => { + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + + const { context } = createMockContext(); + const func = await setCommitsCommand.loader(); + + await expect( + func.call( + context, + { + auto: true, + local: false, + clear: false, + commit: "repo@sha", + "initial-depth": 20, + json: false, + }, + "1.0.0" + ) + ).rejects.toThrow("Only one of --auto, --local, or --commit"); + }); +}); diff --git a/test/commands/release/view.test.ts b/test/commands/release/view.test.ts new file mode 100644 index 000000000..2ad02cdf3 --- /dev/null +++ b/test/commands/release/view.test.ts @@ -0,0 +1,137 @@ +/** + * Release View Command Tests + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { viewCommand } from "../../../src/commands/release/view.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; +import { useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("release-view-"); + +const sampleRelease: OrgReleaseResponse = { + id: 1, + version: "1.0.0", + shortVersion: "1.0.0", + status: "open", + dateCreated: "2025-01-01T00:00:00Z", + dateReleased: null, + commitCount: 5, + deployCount: 1, + newGroups: 0, + ref: "main", + url: null, + versionInfo: null, + data: {}, + authors: [], + projects: [ + { + id: 1, + slug: "my-project", + name: "My Project", + platform: null, + platforms: null, + hasHealthData: false, + newGroups: 0, + }, + ], +}; + +function createMockContext(cwd = "/tmp") { + const stdoutWrite = mock(() => true); + const stderrWrite = mock(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: stderrWrite }, + cwd, + }, + stdoutWrite, + stderrWrite, + }; +} + +describe("release view", () => { + let getReleaseSpy: ReturnType; + let resolveOrgSpy: ReturnType; + + beforeEach(() => { + getReleaseSpy = spyOn(apiClient, "getRelease"); + resolveOrgSpy = spyOn(resolveTarget, "resolveOrg"); + }); + + afterEach(() => { + getReleaseSpy.mockRestore(); + resolveOrgSpy.mockRestore(); + }); + + test("displays release details in JSON mode", async () => { + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + getReleaseSpy.mockResolvedValue(sampleRelease); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, { fresh: false, json: true }, "my-org/1.0.0"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.version).toBe("1.0.0"); + expect(parsed.commitCount).toBe(5); + }); + + test("displays release details in human mode", async () => { + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + getReleaseSpy.mockResolvedValue(sampleRelease); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, { fresh: false, json: false }, "1.0.0"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("1.0.0"); + expect(output).toContain("Commits"); + }); + + test("resolves org from explicit prefix", async () => { + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + getReleaseSpy.mockResolvedValue(sampleRelease); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, { fresh: false, json: true }, "my-org/1.0.0"); + + expect(resolveOrgSpy).toHaveBeenCalledWith({ org: "my-org", cwd: "/tmp" }); + expect(getReleaseSpy).toHaveBeenCalledWith("my-org", "1.0.0"); + }); + + test("throws when no version provided", async () => { + const { context } = createMockContext(); + const func = await viewCommand.loader(); + + await expect( + func.call(context, { fresh: false, json: false }) + ).rejects.toThrow("Release version"); + }); + + test("throws when org cannot be resolved", async () => { + resolveOrgSpy.mockResolvedValue(null); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + + await expect( + func.call(context, { fresh: false, json: false }, "1.0.0") + ).rejects.toThrow("Organization"); + }); +}); diff --git a/test/lib/api/releases.test.ts b/test/lib/api/releases.test.ts new file mode 100644 index 000000000..4cf06dbf4 --- /dev/null +++ b/test/lib/api/releases.test.ts @@ -0,0 +1,357 @@ +/** + * Release API Function Tests + * + * Tests the real API function bodies by mocking globalThis.fetch. + * This ensures the functions correctly call the SDK, pass parameters, + * and transform responses. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import type { DeployResponse, OrgReleaseResponse } from "@sentry/api"; +import { + createRelease, + createReleaseDeploy, + deleteRelease, + getRelease, + listReleaseDeploys, + listReleasesPaginated, + setCommitsAuto, + setCommitsLocal, + updateRelease, +} from "../../../src/lib/api/releases.js"; +import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { setOrgRegion } from "../../../src/lib/db/regions.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("api-releases-"); + +const SAMPLE_RELEASE: OrgReleaseResponse = { + id: 1, + version: "1.0.0", + shortVersion: "1.0.0", + status: "open", + dateCreated: "2025-01-01T00:00:00Z", + dateReleased: null, + firstEvent: null, + lastEvent: null, + ref: null, + url: null, + commitCount: 0, + deployCount: 0, + newGroups: 0, + authors: [], + projects: [ + { + id: 1, + slug: "test-project", + name: "Test Project", + platform: "javascript", + platforms: ["javascript"], + hasHealthData: false, + newGroups: 0, + }, + ], + data: {}, + versionInfo: null, +}; + +const SAMPLE_DEPLOY: DeployResponse = { + id: "42", + environment: "production", + dateStarted: null, + dateFinished: "2025-01-01T12:00:00Z", + name: null, + url: null, +}; + +let originalFetch: typeof globalThis.fetch; + +/** Create a Link header for pagination */ +function linkHeader(cursor: string, hasResults: boolean): string { + return `; rel="next"; results="${hasResults}"; cursor="${cursor}"`; +} + +beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + setOrgRegion("test-org", "https://us.sentry.io"); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +// ============================================================================= +// getRelease +// ============================================================================= + +describe("getRelease", () => { + test("fetches a release by version", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.url).toContain("/releases/1.0.0/"); + return new Response(JSON.stringify(SAMPLE_RELEASE), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const release = await getRelease("test-org", "1.0.0"); + + expect(release.version).toBe("1.0.0"); + expect(release.shortVersion).toBe("1.0.0"); + expect(release.id).toBe(1); + }); +}); + +// ============================================================================= +// createRelease +// ============================================================================= + +describe("createRelease", () => { + test("creates a release with version and projects", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.method).toBe("POST"); + const body = (await req.json()) as { + version: string; + projects: string[]; + }; + expect(body.version).toBe("1.0.0"); + expect(body.projects).toEqual(["test-project"]); + return new Response(JSON.stringify(SAMPLE_RELEASE), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + }); + + const release = await createRelease("test-org", { + version: "1.0.0", + projects: ["test-project"], + }); + + expect(release.version).toBe("1.0.0"); + expect(release.projects).toHaveLength(1); + }); +}); + +// ============================================================================= +// updateRelease +// ============================================================================= + +describe("updateRelease", () => { + test("updates a release with dateReleased", async () => { + const updated = { ...SAMPLE_RELEASE, dateReleased: "2025-06-15T00:00:00Z" }; + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.method).toBe("PUT"); + expect(req.url).toContain("/releases/1.0.0/"); + const body = (await req.json()) as { dateReleased: string }; + expect(body.dateReleased).toBe("2025-06-15T00:00:00Z"); + return new Response(JSON.stringify(updated), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const release = await updateRelease("test-org", "1.0.0", { + dateReleased: "2025-06-15T00:00:00Z", + }); + + expect(release.dateReleased).toBe("2025-06-15T00:00:00Z"); + }); +}); + +// ============================================================================= +// deleteRelease +// ============================================================================= + +describe("deleteRelease", () => { + test("deletes a release", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.method).toBe("DELETE"); + expect(req.url).toContain("/releases/1.0.0/"); + return new Response(null, { status: 204 }); + }); + + // Should not throw + await deleteRelease("test-org", "1.0.0"); + }); +}); + +// ============================================================================= +// listReleaseDeploys +// ============================================================================= + +describe("listReleaseDeploys", () => { + test("returns deploys for a release", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.url).toContain("/releases/1.0.0/deploys/"); + return new Response(JSON.stringify([SAMPLE_DEPLOY]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const deploys = await listReleaseDeploys("test-org", "1.0.0"); + + expect(deploys).toHaveLength(1); + expect(deploys[0].environment).toBe("production"); + expect(deploys[0].id).toBe("42"); + }); +}); + +// ============================================================================= +// createReleaseDeploy +// ============================================================================= + +describe("createReleaseDeploy", () => { + test("creates a deploy for a release", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.method).toBe("POST"); + expect(req.url).toContain("/releases/1.0.0/deploys/"); + const body = (await req.json()) as { environment: string }; + expect(body.environment).toBe("production"); + return new Response(JSON.stringify(SAMPLE_DEPLOY), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + }); + + const deploy = await createReleaseDeploy("test-org", "1.0.0", { + environment: "production", + }); + + expect(deploy.environment).toBe("production"); + expect(deploy.id).toBe("42"); + }); +}); + +// ============================================================================= +// setCommitsAuto +// ============================================================================= + +describe("setCommitsAuto", () => { + test("sends auto refs to the API", async () => { + const withCommits = { ...SAMPLE_RELEASE, commitCount: 5 }; + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.method).toBe("PUT"); + expect(req.url).toContain("/releases/1.0.0/"); + const body = (await req.json()) as { + refs: Array<{ repository: string; commit: string }>; + }; + expect(body.refs).toEqual([{ repository: "auto", commit: "auto" }]); + return new Response(JSON.stringify(withCommits), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const release = await setCommitsAuto("test-org", "1.0.0"); + + expect(release.commitCount).toBe(5); + }); +}); + +// ============================================================================= +// setCommitsLocal +// ============================================================================= + +describe("setCommitsLocal", () => { + test("sends explicit commits to the API", async () => { + const withCommits = { ...SAMPLE_RELEASE, commitCount: 2 }; + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.method).toBe("PUT"); + const body = (await req.json()) as { + commits: Array<{ id: string; message: string }>; + }; + expect(body.commits).toHaveLength(1); + expect(body.commits[0].id).toBe("abc123"); + return new Response(JSON.stringify(withCommits), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const release = await setCommitsLocal("test-org", "1.0.0", [ + { + id: "abc123", + message: "fix: something", + author_name: "Test", + author_email: "test@example.com", + timestamp: "2025-01-01T00:00:00Z", + }, + ]); + + expect(release.commitCount).toBe(2); + }); +}); + +// ============================================================================= +// listReleasesPaginated +// ============================================================================= + +describe("listReleasesPaginated", () => { + test("returns a page of releases with cursor", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.url).toContain("/releases/"); + return new Response(JSON.stringify([SAMPLE_RELEASE]), { + status: 200, + headers: { + "Content-Type": "application/json", + link: linkHeader("abc:0:0", true), + }, + }); + }); + + const result = await listReleasesPaginated("test-org", { perPage: 25 }); + + expect(result.data).toHaveLength(1); + expect(result.data[0].version).toBe("1.0.0"); + expect(result.nextCursor).toBe("abc:0:0"); + }); + + test("returns no cursor when no more pages", async () => { + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify([SAMPLE_RELEASE]), { + status: 200, + headers: { + "Content-Type": "application/json", + link: linkHeader("abc:0:0", false), + }, + }) + ); + + const result = await listReleasesPaginated("test-org"); + + expect(result.data).toHaveLength(1); + expect(result.nextCursor).toBeUndefined(); + }); + + test("passes query and sort options", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + const url = new URL(req.url); + expect(url.searchParams.get("query")).toBe("1.0"); + expect(url.searchParams.get("sort")).toBe("date"); + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const result = await listReleasesPaginated("test-org", { + query: "1.0", + sort: "date", + }); + + expect(result.data).toHaveLength(0); + }); +}); diff --git a/test/lib/git.property.test.ts b/test/lib/git.property.test.ts new file mode 100644 index 000000000..3745f7327 --- /dev/null +++ b/test/lib/git.property.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "bun:test"; +import { array, constantFrom, assert as fcAssert, property } from "fast-check"; +import { parseRemoteUrl } from "../../src/lib/git.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +const slugChars = "abcdefghijklmnopqrstuvwxyz0123456789"; + +const ownerArb = array(constantFrom(...`${slugChars}-`.split("")), { + minLength: 1, + maxLength: 15, +}) + .map((chars) => chars.join("")) + .filter((s) => !(s.startsWith("-") || s.endsWith("-"))); + +const repoArb = array(constantFrom(...`${slugChars}-.`.split("")), { + minLength: 1, + maxLength: 20, +}) + .map((chars) => chars.join("")) + .filter((s) => !(s.startsWith("-") || s.endsWith("-") || s.startsWith("."))); + +describe("property: parseRemoteUrl", () => { + test("HTTPS URL → owner/repo", () => { + fcAssert( + property(ownerArb, repoArb, (owner, repo) => { + const url = `https://github.com/${owner}/${repo}.git`; + const result = parseRemoteUrl(url); + expect(result).toBe(`${owner}/${repo}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("HTTPS URL without .git suffix → owner/repo", () => { + fcAssert( + property(ownerArb, repoArb, (owner, repo) => { + const url = `https://github.com/${owner}/${repo}`; + const result = parseRemoteUrl(url); + expect(result).toBe(`${owner}/${repo}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("SSH URL → owner/repo", () => { + fcAssert( + property(ownerArb, repoArb, (owner, repo) => { + const url = `git@github.com:${owner}/${repo}.git`; + const result = parseRemoteUrl(url); + expect(result).toBe(`${owner}/${repo}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("SSH URL without .git suffix → owner/repo", () => { + fcAssert( + property(ownerArb, repoArb, (owner, repo) => { + const url = `git@github.com:${owner}/${repo}`; + const result = parseRemoteUrl(url); + expect(result).toBe(`${owner}/${repo}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("unit: parseRemoteUrl edge cases", () => { + test("standard GitHub HTTPS", () => { + expect(parseRemoteUrl("https://github.com/getsentry/cli.git")).toBe( + "getsentry/cli" + ); + }); + + test("standard GitHub SSH", () => { + expect(parseRemoteUrl("git@github.com:getsentry/cli.git")).toBe( + "getsentry/cli" + ); + }); + + test("GitLab HTTPS", () => { + expect(parseRemoteUrl("https://gitlab.com/my-group/my-project.git")).toBe( + "my-group/my-project" + ); + }); + + test("Bitbucket SSH", () => { + expect(parseRemoteUrl("git@bitbucket.org:team/repo.git")).toBe("team/repo"); + }); + + test("SSH with port (ssh:// protocol)", () => { + expect(parseRemoteUrl("ssh://git@github.com:22/owner/repo.git")).toBe( + "owner/repo" + ); + }); + + test("SSH with port (no .git)", () => { + expect(parseRemoteUrl("ssh://git@github.com:443/owner/repo")).toBe( + "owner/repo" + ); + }); + + test("empty string returns undefined", () => { + expect(parseRemoteUrl("")).toBeUndefined(); + }); + + test("plain string returns undefined", () => { + expect(parseRemoteUrl("not-a-url")).toBeUndefined(); + }); +}); diff --git a/test/lib/init/git.test.ts b/test/lib/init/git.test.ts index 3b61c67a8..e7adfdfe8 100644 --- a/test/lib/init/git.test.ts +++ b/test/lib/init/git.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference import * as clack from "@clack/prompts"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as gitLib from "../../../src/lib/git.js"; import { checkGitStatus, getUncommittedOrUntrackedFiles, @@ -11,13 +13,15 @@ const noop = () => { /* suppress output */ }; -let spawnSyncSpy: ReturnType; +let isInsideWorkTreeSpy: ReturnType; +let getUncommittedFilesSpy: ReturnType; let confirmSpy: ReturnType; let isCancelSpy: ReturnType; let logWarnSpy: ReturnType; beforeEach(() => { - spawnSyncSpy = spyOn(Bun, "spawnSync"); + isInsideWorkTreeSpy = spyOn(gitLib, "isInsideGitWorkTree"); + getUncommittedFilesSpy = spyOn(gitLib, "getUncommittedFiles"); confirmSpy = spyOn(clack, "confirm").mockResolvedValue(true); isCancelSpy = spyOn(clack, "isCancel").mockImplementation( (v: unknown) => v === Symbol.for("cancel") @@ -26,59 +30,43 @@ beforeEach(() => { }); afterEach(() => { - spawnSyncSpy.mockRestore(); + isInsideWorkTreeSpy.mockRestore(); + getUncommittedFilesSpy.mockRestore(); confirmSpy.mockRestore(); isCancelSpy.mockRestore(); logWarnSpy.mockRestore(); }); describe("isInsideGitWorkTree", () => { - test("returns true when git succeeds", () => { - spawnSyncSpy.mockReturnValue({ exitCode: 0, success: true }); + test("returns true when inside git work tree", () => { + isInsideWorkTreeSpy.mockReturnValue(true); expect(isInsideGitWorkTree({ cwd: "/tmp" })).toBe(true); - expect(spawnSyncSpy).toHaveBeenCalledWith( - ["git", "rev-parse", "--is-inside-work-tree"], - expect.objectContaining({ cwd: "/tmp" }) - ); + expect(isInsideWorkTreeSpy).toHaveBeenCalledWith("/tmp"); }); - test("returns false when git fails", () => { - spawnSyncSpy.mockReturnValue({ exitCode: 128, success: false }); + test("returns false when not in git repo", () => { + isInsideWorkTreeSpy.mockReturnValue(false); expect(isInsideGitWorkTree({ cwd: "/tmp" })).toBe(false); }); }); describe("getUncommittedOrUntrackedFiles", () => { - test("parses porcelain output into file list", () => { - spawnSyncSpy.mockReturnValue({ - stdout: Buffer.from(" M src/index.ts\n?? new-file.ts\n"), - exitCode: 0, - success: true, - }); + test("returns formatted file list from lib/git", () => { + getUncommittedFilesSpy.mockReturnValue([ + "- M src/index.ts", + "- ?? new-file.ts", + ]); const files = getUncommittedOrUntrackedFiles({ cwd: "/tmp" }); expect(files).toEqual(["- M src/index.ts", "- ?? new-file.ts"]); + expect(getUncommittedFilesSpy).toHaveBeenCalledWith("/tmp"); }); test("returns empty array for clean repo", () => { - spawnSyncSpy.mockReturnValue({ - stdout: Buffer.from(""), - exitCode: 0, - success: true, - }); - - expect(getUncommittedOrUntrackedFiles({ cwd: "/tmp" })).toEqual([]); - }); - - test("returns empty array on error", () => { - spawnSyncSpy.mockReturnValue({ - stdout: Buffer.from(""), - exitCode: 128, - success: false, - }); + getUncommittedFilesSpy.mockReturnValue([]); expect(getUncommittedOrUntrackedFiles({ cwd: "/tmp" })).toEqual([]); }); @@ -86,15 +74,8 @@ describe("getUncommittedOrUntrackedFiles", () => { describe("checkGitStatus", () => { test("returns true silently for clean git repo", async () => { - spawnSyncSpy - // isInsideGitWorkTree -> true - .mockReturnValueOnce({ exitCode: 0, success: true }) - // getUncommittedOrUntrackedFiles -> clean - .mockReturnValueOnce({ - stdout: Buffer.from(""), - exitCode: 0, - success: true, - }); + isInsideWorkTreeSpy.mockReturnValue(true); + getUncommittedFilesSpy.mockReturnValue([]); const result = await checkGitStatus({ cwd: "/tmp", yes: false }); @@ -104,7 +85,7 @@ describe("checkGitStatus", () => { }); test("prompts when not in git repo (interactive) and returns true on confirm", async () => { - spawnSyncSpy.mockReturnValue({ exitCode: 128, success: false }); + isInsideWorkTreeSpy.mockReturnValue(false); confirmSpy.mockResolvedValue(true); const result = await checkGitStatus({ cwd: "/tmp", yes: false }); @@ -118,7 +99,7 @@ describe("checkGitStatus", () => { }); test("prompts when not in git repo (interactive) and returns false on decline", async () => { - spawnSyncSpy.mockReturnValue({ exitCode: 128, success: false }); + isInsideWorkTreeSpy.mockReturnValue(false); confirmSpy.mockResolvedValue(false); const result = await checkGitStatus({ cwd: "/tmp", yes: false }); @@ -127,7 +108,7 @@ describe("checkGitStatus", () => { }); test("returns false without throwing when user cancels not-in-git-repo prompt", async () => { - spawnSyncSpy.mockReturnValue({ exitCode: 128, success: false }); + isInsideWorkTreeSpy.mockReturnValue(false); confirmSpy.mockResolvedValue(Symbol.for("cancel")); const result = await checkGitStatus({ cwd: "/tmp", yes: false }); @@ -136,7 +117,7 @@ describe("checkGitStatus", () => { }); test("warns and auto-continues when not in git repo with --yes", async () => { - spawnSyncSpy.mockReturnValue({ exitCode: 128, success: false }); + isInsideWorkTreeSpy.mockReturnValue(false); const result = await checkGitStatus({ cwd: "/tmp", yes: true }); @@ -148,15 +129,8 @@ describe("checkGitStatus", () => { }); test("shows files and prompts for dirty tree (interactive), returns true on confirm", async () => { - spawnSyncSpy - // isInsideGitWorkTree -> true - .mockReturnValueOnce({ exitCode: 0, success: true }) - // getUncommittedOrUntrackedFiles -> dirty - .mockReturnValueOnce({ - stdout: Buffer.from(" M dirty.ts\n"), - exitCode: 0, - success: true, - }); + isInsideWorkTreeSpy.mockReturnValue(true); + getUncommittedFilesSpy.mockReturnValue(["- M dirty.ts"]); confirmSpy.mockResolvedValue(true); const result = await checkGitStatus({ cwd: "/tmp", yes: false }); @@ -173,13 +147,8 @@ describe("checkGitStatus", () => { }); test("shows files and prompts for dirty tree (interactive), returns false on decline", async () => { - spawnSyncSpy - .mockReturnValueOnce({ exitCode: 0, success: true }) - .mockReturnValueOnce({ - stdout: Buffer.from(" M dirty.ts\n"), - exitCode: 0, - success: true, - }); + isInsideWorkTreeSpy.mockReturnValue(true); + getUncommittedFilesSpy.mockReturnValue(["- M dirty.ts"]); confirmSpy.mockResolvedValue(false); const result = await checkGitStatus({ cwd: "/tmp", yes: false }); @@ -188,13 +157,8 @@ describe("checkGitStatus", () => { }); test("returns false without throwing when user cancels dirty-tree prompt", async () => { - spawnSyncSpy - .mockReturnValueOnce({ exitCode: 0, success: true }) - .mockReturnValueOnce({ - stdout: Buffer.from(" M dirty.ts\n"), - exitCode: 0, - success: true, - }); + isInsideWorkTreeSpy.mockReturnValue(true); + getUncommittedFilesSpy.mockReturnValue(["- M dirty.ts"]); confirmSpy.mockResolvedValue(Symbol.for("cancel")); const result = await checkGitStatus({ cwd: "/tmp", yes: false }); @@ -203,13 +167,8 @@ describe("checkGitStatus", () => { }); test("warns with file list and auto-continues for dirty tree with --yes", async () => { - spawnSyncSpy - .mockReturnValueOnce({ exitCode: 0, success: true }) - .mockReturnValueOnce({ - stdout: Buffer.from(" M dirty.ts\n?? new.ts\n"), - exitCode: 0, - success: true, - }); + isInsideWorkTreeSpy.mockReturnValue(true); + getUncommittedFilesSpy.mockReturnValue(["- M dirty.ts", "- ?? new.ts"]); const result = await checkGitStatus({ cwd: "/tmp", yes: true }); diff --git a/test/lib/release-parse.property.test.ts b/test/lib/release-parse.property.test.ts new file mode 100644 index 000000000..3c28e07e0 --- /dev/null +++ b/test/lib/release-parse.property.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test"; +import { array, constantFrom, assert as fcAssert, property } from "fast-check"; +import { parseReleaseArg } from "../../src/commands/release/parse.js"; +import { ValidationError } from "../../src/lib/errors.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +const slugChars = "abcdefghijklmnopqrstuvwxyz0123456789"; + +const simpleSlugArb = array(constantFrom(...slugChars.split("")), { + minLength: 1, + maxLength: 15, +}).map((chars) => chars.join("")); + +const slugWithHyphensArb = array(constantFrom(...`${slugChars}-`.split("")), { + minLength: 2, + maxLength: 20, +}) + .map((chars) => chars.join("")) + .filter((s) => !(s.startsWith("-") || s.endsWith("-") || s.includes("--"))); + +const versionArb = array( + constantFrom(..."0123456789.abcdefghijklmnopqrstuvwxyz-+@".split("")), + { minLength: 1, maxLength: 20 } +).map((chars) => chars.join("")); + +describe("property: parseReleaseArg", () => { + test("round-trip: org/version → orgSlug + '/' + version === input", () => { + fcAssert( + property(slugWithHyphensArb, versionArb, (slug, version) => { + const input = `${slug}/${version}`; + const result = parseReleaseArg(input, "test"); + if (result.orgSlug) { + expect(`${result.orgSlug}/${result.version}`).toBe(input); + } else { + expect(result.version).toBe(input); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("version with @ is never split into org", () => { + fcAssert( + property( + versionArb.filter((v) => v.includes("@")), + (version) => { + const result = parseReleaseArg(version, "test"); + // The @ in the prefix would make it not match SLUG_RE + // so the whole string should be the version + expect(result.version).toBe(version); + expect(result.orgSlug).toBeUndefined(); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("valid slug prefix always extracts org", () => { + fcAssert( + property( + simpleSlugArb, + versionArb.filter((v) => v.length > 0), + (slug, version) => { + const input = `${slug}/${version}`; + const result = parseReleaseArg(input, "test"); + expect(result.orgSlug).toBe(slug); + expect(result.version).toBe(version); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("plain version without slash returns no org", () => { + fcAssert( + property( + versionArb.filter((v) => !v.includes("/") && v.length > 0), + (version) => { + const result = parseReleaseArg(version, "test"); + expect(result.orgSlug).toBeUndefined(); + expect(result.version).toBe(version); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("unit: parseReleaseArg edge cases", () => { + test("empty string throws ValidationError", () => { + expect(() => parseReleaseArg("", "test")).toThrow(ValidationError); + }); + + test("sentry-cli@0.24.0 is a plain version (no org)", () => { + const result = parseReleaseArg("sentry-cli@0.24.0", "test"); + expect(result.version).toBe("sentry-cli@0.24.0"); + expect(result.orgSlug).toBeUndefined(); + }); + + test("my-org/1.0.0 extracts org and version", () => { + const result = parseReleaseArg("my-org/1.0.0", "test"); + expect(result.orgSlug).toBe("my-org"); + expect(result.version).toBe("1.0.0"); + }); + + test("my-org/sentry-cli@0.24.0 extracts org and version with @", () => { + const result = parseReleaseArg("my-org/sentry-cli@0.24.0", "test"); + expect(result.orgSlug).toBe("my-org"); + expect(result.version).toBe("sentry-cli@0.24.0"); + }); + + test("version with leading slash treated as plain version", () => { + const result = parseReleaseArg("/1.0.0", "test"); + expect(result.version).toBe("/1.0.0"); + expect(result.orgSlug).toBeUndefined(); + }); +}); From 56e522d3e50db17e3032c7c977aab51724ff81d0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 2 Apr 2026 14:55:10 +0000 Subject: [PATCH 2/2] chore: regenerate skill files and command docs --- docs/src/content/docs/commands/index.md | 1 + docs/src/content/docs/commands/release.md | 126 ++++++++++++++++++++-- 2 files changed, 120 insertions(+), 7 deletions(-) diff --git a/docs/src/content/docs/commands/index.md b/docs/src/content/docs/commands/index.md index f688a0482..2961f29a7 100644 --- a/docs/src/content/docs/commands/index.md +++ b/docs/src/content/docs/commands/index.md @@ -14,6 +14,7 @@ The Sentry CLI provides commands for interacting with various Sentry resources. | [`dashboard`](./dashboard/) | Manage Sentry dashboards | | [`org`](./org/) | Work with Sentry organizations | | [`project`](./project/) | Work with Sentry projects | +| [`release`](./release/) | Work with Sentry releases | | [`repo`](./repo/) | Work with Sentry repositories | | [`team`](./team/) | Work with Sentry teams | | [`issue`](./issue/) | Manage Sentry issues | diff --git a/docs/src/content/docs/commands/release.md b/docs/src/content/docs/commands/release.md index ff7d41ca6..62b3ed784 100644 --- a/docs/src/content/docs/commands/release.md +++ b/docs/src/content/docs/commands/release.md @@ -11,33 +11,145 @@ Work with Sentry releases List releases -### `sentry release view ` +**Arguments:** + +| Argument | Description | +|----------|-------------| +| `` | <org>/ (all projects), <org>/<project>, or <project> (search) | + +**Options:** + +| Option | Description | +|--------|-------------| +| `-n, --limit ` | Maximum number of releases to list (default: "30") | +| `-f, --fresh` | Bypass cache, re-detect projects, and fetch fresh data | +| `-c, --cursor ` | Navigate pages: "next", "prev", "first" (or raw cursor string) | + +### `sentry release view ` View release details -### `sentry release create ` +**Arguments:** + +| Argument | Description | +|----------|-------------| +| `` | [<org>/]<version> - Release version to view | + +**Options:** + +| Option | Description | +|--------|-------------| +| `-f, --fresh` | Bypass cache, re-detect projects, and fetch fresh data | + +### `sentry release create ` Create a release -### `sentry release finalize ` +**Arguments:** + +| Argument | Description | +|----------|-------------| +| `` | [<org>/]<version> - Release version to create | + +**Options:** + +| Option | Description | +|--------|-------------| +| `-p, --project ` | Associate with project(s), comma-separated | +| `--finalize` | Immediately finalize the release (set dateReleased) | +| `--ref ` | Git ref (branch or tag name) | +| `--url ` | URL to the release source | +| `-n, --dry-run` | Show what would happen without making changes | + +### `sentry release finalize ` Finalize a release -### `sentry release delete ` +**Arguments:** + +| Argument | Description | +|----------|-------------| +| `` | [<org>/]<version> - Release version to finalize | + +**Options:** + +| Option | Description | +|--------|-------------| +| `--released ` | Custom release timestamp (ISO 8601). Defaults to now. | +| `--url ` | URL for the release | +| `-n, --dry-run` | Show what would happen without making changes | + +### `sentry release delete ` Delete a release -### `sentry release deploy [name]` +**Arguments:** + +| Argument | Description | +|----------|-------------| +| `` | [<org>/]<version> - Release version to delete | + +**Options:** + +| Option | Description | +|--------|-------------| +| `-y, --yes` | Skip confirmation prompt | +| `-f, --force` | Force the operation without confirmation | +| `-n, --dry-run` | Show what would happen without making changes | + +### `sentry release deploy ` Create a deploy for a release -### `sentry release set-commits ` +**Arguments:** + +| Argument | Description | +|----------|-------------| +| `` | [<org>/]<version> <environment> [name] | + +**Options:** + +| Option | Description | +|--------|-------------| +| `--url ` | URL for the deploy | +| `--started ` | Deploy start time (ISO 8601) | +| `--finished ` | Deploy finish time (ISO 8601) | +| `-t, --time