From 7aff74b6799919a6e4ae874c3f61e1d07539a903 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 8 Sep 2026 19:42:33 +0200 Subject: [PATCH 1/5] feat(issue): link and unlink external tracker issues --- .../cli-docs/src/content/docs/contributing.md | 2 +- apps/cli-docs/src/content/docs/self-hosted.md | 2 +- apps/cli-docs/src/fragments/commands/issue.md | 71 ++ packages/cli/DEVELOPMENT.md | 2 +- .../sentry-cli/skills/sentry-cli/SKILL.md | 2 + .../skills/sentry-cli/references/issue.md | 42 + packages/cli/script/generate-sdk.ts | 2 +- packages/cli/src/commands/issue/index.ts | 8 +- packages/cli/src/commands/issue/link-utils.ts | 49 ++ packages/cli/src/commands/issue/link.ts | 76 ++ packages/cli/src/commands/issue/unlink.ts | 67 ++ packages/cli/src/lib/api/infrastructure.ts | 7 +- packages/cli/src/lib/api/issue-app-links.ts | 743 ++++++++++++++++++ .../cli/src/lib/api/issue-integrations.ts | 522 ++++++++++++ packages/cli/src/lib/complete.ts | 2 + .../cli/src/lib/formatters/issue-links.ts | 29 + packages/cli/src/lib/issue-links.ts | 237 ++++++ packages/cli/src/lib/oauth.ts | 1 + packages/cli/src/lib/scope-recovery.ts | 34 +- packages/cli/src/lib/sentry-client.ts | 73 +- .../cli/test/commands/issue/link.func.test.ts | 225 ++++++ .../test/commands/issue/unlink.func.test.ts | 250 ++++++ .../cli/test/lib/api/issue-app-links.test.ts | 447 +++++++++++ .../test/lib/api/issue-integrations.test.ts | 480 +++++++++++ packages/cli/test/lib/issue-links.test.ts | 245 ++++++ packages/cli/test/lib/oauth.test.ts | 6 + packages/cli/test/lib/scope-recovery.test.ts | 54 +- packages/cli/test/lib/sdk-positionals.test.ts | 20 +- packages/cli/test/lib/sentry-client.test.ts | 89 +++ 29 files changed, 3744 insertions(+), 43 deletions(-) create mode 100644 packages/cli/src/commands/issue/link-utils.ts create mode 100644 packages/cli/src/commands/issue/link.ts create mode 100644 packages/cli/src/commands/issue/unlink.ts create mode 100644 packages/cli/src/lib/api/issue-app-links.ts create mode 100644 packages/cli/src/lib/api/issue-integrations.ts create mode 100644 packages/cli/src/lib/formatters/issue-links.ts create mode 100644 packages/cli/src/lib/issue-links.ts create mode 100644 packages/cli/test/commands/issue/link.func.test.ts create mode 100644 packages/cli/test/commands/issue/unlink.func.test.ts create mode 100644 packages/cli/test/lib/api/issue-app-links.test.ts create mode 100644 packages/cli/test/lib/api/issue-integrations.test.ts create mode 100644 packages/cli/test/lib/issue-links.test.ts diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index 6cd16ca68c..8717a50ecc 100644 --- a/apps/cli-docs/src/content/docs/contributing.md +++ b/apps/cli-docs/src/content/docs/contributing.md @@ -64,7 +64,7 @@ cli/ │ │ ├── docs/ # list, query │ │ ├── event/ # list, send, view │ │ ├── feedback/ # list, view -│ │ ├── issue/ # archive, events, explain, list, merge, plan, resolve, unresolve, view +│ │ ├── issue/ # archive, events, explain, link, list, merge, plan, resolve, unlink, unresolve, view │ │ ├── local/ # run, serve │ │ ├── log/ # list, view │ │ ├── monitor/ # list, run diff --git a/apps/cli-docs/src/content/docs/self-hosted.md b/apps/cli-docs/src/content/docs/self-hosted.md index 4301ef43ea..04f2b70416 100644 --- a/apps/cli-docs/src/content/docs/self-hosted.md +++ b/apps/cli-docs/src/content/docs/self-hosted.md @@ -56,7 +56,7 @@ If your instance is on an older version or you prefer not to create an OAuth app 1. Go to **Settings → Developer Settings → Personal Tokens** in your Sentry instance (or visit `https://sentry.example.com/settings/account/api/auth-tokens/new-token/`) 2. Create a new token with the following scopes: -`project:read`, `project:write`, `project:admin`, `org:read`, `event:read`, `event:write`, `member:read`, `team:read`, `team:write`, `team:admin`, `alerts:read`, `alerts:write` +`project:read`, `project:write`, `project:admin`, `org:read`, `event:read`, `event:write`, `event:admin`, `member:read`, `team:read`, `team:write`, `team:admin`, `alerts:read`, `alerts:write` 3. Pass it to the CLI: diff --git a/apps/cli-docs/src/fragments/commands/issue.md b/apps/cli-docs/src/fragments/commands/issue.md index 41fd673b0a..37974cf34b 100644 --- a/apps/cli-docs/src/fragments/commands/issue.md +++ b/apps/cli-docs/src/fragments/commands/issue.md @@ -297,3 +297,74 @@ sentry issue ignore CLI-G5 --until auto | `10users/2hours` | 10 users within 2 hours | | *(omitted)* | Archive forever | ::: + +### Link an external issue + +Link an existing tracker issue to a Sentry issue: + +```bash +sentry issue link FRONT-123 --external-issue https://github.com/example/app/issues/42 +sentry issue link FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 +sentry issue link FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error +``` + +The matching integration must already be installed in the Sentry organization. +Native integrations include GitHub, GitHub Enterprise, Jira, Jira Server, +GitLab, Bitbucket, and Azure DevOps. Linear uses its installed Sentry App. +Use `--integration ` if more than one native integration matches the URL. +Other Sentry Apps require `--app ` and must expose an issue-link form; +additional required form values can be supplied with `--field name=value`. + +```bash +sentry issue link my-org/FRONT-123 --external-issue https://github.com/example/app/issues/42 --dry-run +sentry issue link my-org/FRONT-123 --external-issue https://github.com/example/app/issues/42 --json +``` + +`--dry-run` discovers the integration and prepares the link without submitting a +write. The provider validates the remote issue when the link is submitted. +An existing matching link succeeds with `changed: false`. A Sentry App that +already links this issue to a different resource must be unlinked first. + +This command creates an association only. It does not create a tracker issue, +resolve the Sentry issue, or link a commit or pull request. Existing integration +status-sync settings continue to apply after linking. + +#### Link permissions + +Linking requires `event:write` and access to the Sentry project. The CLI requests +`event:write` and `event:admin` during OAuth login. If an older OAuth session lacks +the requested scopes, the CLI offers reauthorization after a permission error. +In non-interactive mode, follow the `sentry auth refresh` command shown in the +error. Environment tokens must be updated separately. + +### Unlink an external issue + +Remove an association without deleting either issue: + +```bash +sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/issues/42 +sentry issue unlink my-org/FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 --yes +sentry issue unlink FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error --dry-run +``` + +Use `--yes` for non-interactive execution. `--dry-run` shows whether the link +exists without removing it. If the association is already absent, the command +succeeds with `changed: false`. + +Unlink matches the URL against stored associations and sends Sentry's internal +link ID to the existing DELETE endpoint. It does not require fetching the ticket +from the remote tracker, so a deleted remote ticket can still be unlinked. +For a custom Sentry App, select it with `--app `; unlink does not require +the app to expose a link form. Use `--integration ` to disambiguate native +integration links. + +#### Unlink permissions + +Unlink requires **`event:admin` in both the token and your effective project +permissions**. Being a project member does not automatically grant it. The +organization's “Let Members Delete Events” setting and team roles affect whether +you have this permission. + +New OAuth sessions request this scope. For an older session, follow the +reauthorization guidance shown by the CLI. Granting a token more scopes does not +override the organization's project-access policy. diff --git a/packages/cli/DEVELOPMENT.md b/packages/cli/DEVELOPMENT.md index 4bc3f3a3d5..adde93e505 100644 --- a/packages/cli/DEVELOPMENT.md +++ b/packages/cli/DEVELOPMENT.md @@ -67,7 +67,7 @@ When creating your Sentry OAuth application: - `project:read`, `project:write`, `project:admin` - `org:read` - - `event:read`, `event:write` + - `event:read`, `event:write`, `event:admin` - `member:read` - `team:read`, `team:write`, `team:admin` - `alerts:read`, `alerts:write` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 4b8b785ba6..cd4e3d6954 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -382,6 +382,8 @@ Manage Sentry issues - `sentry issue unresolve ` — Reopen a resolved issue - `sentry issue archive ` — Archive (ignore) an issue - `sentry issue merge ` — Merge 2+ issues into a single canonical group +- `sentry issue link ` — Link an existing external issue +- `sentry issue unlink ` — Unlink an external issue → Full flags and examples: `references/issue.md` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md index d1c554d385..e1be9ebdef 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -353,4 +353,46 @@ sentry issue merge cli-k9 cli-15h --into cli-k9 # alias form # Non-error issue types (performance, info, etc.) cannot be merged ``` +### `sentry issue link ` + +Link an existing external issue + +**Flags:** +- `--external-issue - URL of an existing external issue to link or unlink` +- `--integration - Native integration ID, when multiple installations match` +- `--app - Sentry App slug (automatically detected for Linear URLs)` +- `-n, --dry-run - Show what would happen without making changes` +- `--field ... - Additional Sentry App link form field (name=value, repeatable)` + +**Examples:** + +```bash +sentry issue link FRONT-123 --external-issue https://github.com/example/app/issues/42 +sentry issue link FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 +sentry issue link FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error + +sentry issue link my-org/FRONT-123 --external-issue https://github.com/example/app/issues/42 --dry-run +sentry issue link my-org/FRONT-123 --external-issue https://github.com/example/app/issues/42 --json +``` + +### `sentry issue unlink ` + +Unlink an external issue + +**Flags:** +- `--external-issue - URL of an existing external issue to link or unlink` +- `--integration - Native integration ID, when multiple installations match` +- `--app - Sentry App slug (automatically detected for Linear URLs)` +- `-y, --yes - Skip confirmation prompt` +- `-f, --force - Force the operation without confirmation` +- `-n, --dry-run - Show what would happen without making changes` + +**Examples:** + +```bash +sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/issues/42 +sentry issue unlink my-org/FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 --yes +sentry issue unlink FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error --dry-run +``` + All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/packages/cli/script/generate-sdk.ts b/packages/cli/script/generate-sdk.ts index bd24e77f37..1cb4c5308a 100644 --- a/packages/cli/script/generate-sdk.ts +++ b/packages/cli/script/generate-sdk.ts @@ -160,7 +160,7 @@ function extractSdkFlags(command: Command): SdkFlagInfo[] { flags.push({ name, kind, - tsType, + tsType: def.variadic ? `Array<${tsType}>` : tsType, optional, default: def.default, brief: def.brief, diff --git a/packages/cli/src/commands/issue/index.ts b/packages/cli/src/commands/issue/index.ts index 09b72e94bb..9bf26d7395 100644 --- a/packages/cli/src/commands/issue/index.ts +++ b/packages/cli/src/commands/issue/index.ts @@ -2,10 +2,12 @@ import { buildRouteMap } from "../../lib/route-map.js"; import { archiveCommand } from "./archive.js"; import { eventsCommand } from "./events.js"; import { explainCommand } from "./explain.js"; +import { linkCommand } from "./link.js"; import { listCommand } from "./list.js"; import { mergeCommand } from "./merge.js"; import { planCommand } from "./plan.js"; import { resolveCommand } from "./resolve.js"; +import { unlinkCommand } from "./unlink.js"; import { unresolveCommand } from "./unresolve.js"; import { viewCommand } from "./view.js"; @@ -20,6 +22,8 @@ export const issueRoute = buildRouteMap({ unresolve: unresolveCommand, archive: archiveCommand, merge: mergeCommand, + link: linkCommand, + unlink: unlinkCommand, }, // `reopen` is a friendlier synonym for `unresolve`, `ignore` for `archive`. aliases: { reopen: "unresolve", ignore: "archive" }, @@ -37,7 +41,9 @@ export const issueRoute = buildRouteMap({ " resolve Mark an issue as resolved (optionally in a release)\n" + " unresolve Reopen a resolved issue (alias: reopen)\n" + " archive Archive/ignore an issue (alias: ignore)\n" + - " merge Merge 2+ issues into a single group\n\n" + + " merge Merge 2+ issues into a single group\n" + + " link Link an existing external issue\n" + + " unlink Remove an external issue link\n\n" + "Magic selectors (available for view, events, explain, plan, resolve, unresolve, archive):\n" + " @latest Most recent unresolved issue\n" + " @most_frequent Issue with the highest event frequency\n\n" + diff --git a/packages/cli/src/commands/issue/link-utils.ts b/packages/cli/src/commands/issue/link-utils.ts new file mode 100644 index 0000000000..5287df6c6d --- /dev/null +++ b/packages/cli/src/commands/issue/link-utils.ts @@ -0,0 +1,49 @@ +/** Shared arguments for external issue association commands. */ + +import { ValidationError } from "../../lib/errors.js"; + +/** Flags identifying an existing external issue and its Sentry integration. */ +export const EXTERNAL_ISSUE_FLAGS = { + "external-issue": { + kind: "parsed", + parse: String, + brief: "URL of an existing external issue to link or unlink", + }, + integration: { + kind: "parsed", + parse: String, + brief: "Native integration ID, when multiple installations match", + optional: true, + }, + app: { + kind: "parsed", + parse: String, + brief: "Sentry App slug (automatically detected for Linear URLs)", + optional: true, + }, +} as const; + +/** Parse repeated App form fields while rejecting ambiguous duplicate keys. */ +export function parseIssueLinkFields( + fields: readonly string[] | undefined +): Record | undefined { + if (!fields?.length) { + return; + } + const result: Record = {}; + for (const field of fields) { + const separator = field.indexOf("="); + const key = field.slice(0, separator); + if ( + separator < 1 || + ["__proto__", "constructor", "prototype"].includes(key) || + Object.hasOwn(result, key) + ) { + throw new ValidationError( + "Each --field must be a unique name=value pair." + ); + } + result[key] = field.slice(separator + 1); + } + return result; +} diff --git a/packages/cli/src/commands/issue/link.ts b/packages/cli/src/commands/issue/link.ts new file mode 100644 index 0000000000..02e1a8093e --- /dev/null +++ b/packages/cli/src/commands/issue/link.ts @@ -0,0 +1,76 @@ +/** Associate an existing tracker issue with a Sentry issue. */ + +import type { SentryContext } from "../../context.js"; +import { buildCommand } from "../../lib/command.js"; +import { ContextError } from "../../lib/errors.js"; +import { formatIssueLinkResult } from "../../lib/formatters/issue-links.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { linkExternalIssue } from "../../lib/issue-links.js"; +import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../lib/mutate-command.js"; +import { EXTERNAL_ISSUE_FLAGS, parseIssueLinkFields } from "./link-utils.js"; +import { issueIdPositional, resolveIssue } from "./utils.js"; + +type LinkFlags = { + readonly "external-issue": string; + readonly integration?: string; + readonly app?: string; + readonly field?: string[]; + readonly "dry-run": boolean; +}; + +export const linkCommand = buildCommand({ + docs: { + brief: "Link an existing external issue", + fullDescription: + "Link an existing GitHub, Jira, Linear, or other supported tracker issue.\n" + + "The integration must be installed in your Sentry organization.\n" + + "This does not create a remote issue or resolve the Sentry issue.\n\n" + + "Requires event:write and access to the Sentry project.\n\n" + + "Examples:\n" + + " sentry issue link FRONT-123 --external-issue https://github.com/example/app/issues/42\n" + + " sentry issue link my-org/FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42\n" + + " sentry issue link FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error\n" + + " sentry issue link FRONT-123 --external-issue https://github.com/example/app/issues/42 --dry-run", + }, + output: { human: formatIssueLinkResult }, + parameters: { + positional: issueIdPositional, + flags: { + ...EXTERNAL_ISSUE_FLAGS, + "dry-run": DRY_RUN_FLAG, + field: { + kind: "parsed", + parse: String, + brief: "Additional Sentry App link form field (name=value, repeatable)", + variadic: true, + optional: true, + }, + }, + aliases: DRY_RUN_ALIASES, + }, + async *func(this: SentryContext, flags: LinkFlags, issueArg: string) { + const fields = parseIssueLinkFields(flags.field); + const { org, issue } = await resolveIssue({ + issueArg, + cwd: this.cwd, + command: "link", + }); + if (!org) { + throw new ContextError( + "Organization", + "sentry issue link /ISSUE --external-issue " + ); + } + const result = await linkExternalIssue({ + orgSlug: org, + issueId: issue.id, + projectId: issue.project?.id, + url: flags["external-issue"], + integrationId: flags.integration, + appSlug: flags.app, + fields, + dryRun: flags["dry-run"], + }); + yield new CommandOutput(result); + }, +}); diff --git a/packages/cli/src/commands/issue/unlink.ts b/packages/cli/src/commands/issue/unlink.ts new file mode 100644 index 0000000000..7978d3cdde --- /dev/null +++ b/packages/cli/src/commands/issue/unlink.ts @@ -0,0 +1,67 @@ +/** Remove an external issue association without deleting the external issue. */ + +import type { SentryContext } from "../../context.js"; +import { formatIssueLinkResult } from "../../lib/formatters/issue-links.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { unlinkExternalIssue } from "../../lib/issue-links.js"; +import { + buildDeleteCommand, + confirmByTyping, + isConfirmationBypassed, +} from "../../lib/mutate-command.js"; +import { EXTERNAL_ISSUE_FLAGS } from "./link-utils.js"; +import { issueIdPositional, resolveOrgAndIssueId } from "./utils.js"; + +type UnlinkFlags = { + readonly "external-issue": string; + readonly integration?: string; + readonly app?: string; + readonly "dry-run": boolean; + readonly yes: boolean; + readonly force: boolean; +}; + +export const unlinkCommand = buildDeleteCommand({ + docs: { + brief: "Unlink an external issue", + fullDescription: + "Remove the link between a Sentry issue and an external tracker issue.\n" + + "This does not delete the external issue or change the Sentry issue's status.\n\n" + + "Requires event:admin and access to the Sentry project.\n" + + "Your token must include event:admin even if your project role grants it.\n\n" + + "Examples:\n" + + " sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/issues/42\n" + + " sentry issue unlink my-org/FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 --yes\n" + + " sentry issue unlink FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error --dry-run", + }, + output: { human: formatIssueLinkResult }, + parameters: { + positional: issueIdPositional, + flags: EXTERNAL_ISSUE_FLAGS, + }, + async *func(this: SentryContext, flags: UnlinkFlags, issueArg: string) { + const { org, issueId } = await resolveOrgAndIssueId({ + issueArg, + cwd: this.cwd, + command: "unlink", + }); + if (!(flags["dry-run"] || isConfirmationBypassed(flags))) { + const confirmed = await confirmByTyping( + issueArg, + `Type '${issueArg}' to unlink ${flags["external-issue"]}:` + ); + if (!confirmed) { + return { hint: "Cancelled." }; + } + } + const result = await unlinkExternalIssue({ + orgSlug: org, + issueId, + url: flags["external-issue"], + integrationId: flags.integration, + appSlug: flags.app, + dryRun: flags["dry-run"], + }); + yield new CommandOutput(result); + }, +}); diff --git a/packages/cli/src/lib/api/infrastructure.ts b/packages/cli/src/lib/api/infrastructure.ts index 33b4cfb1bf..26b336aa09 100644 --- a/packages/cli/src/lib/api/infrastructure.ts +++ b/packages/cli/src/lib/api/infrastructure.ts @@ -23,6 +23,7 @@ import { getApiBaseUrl, getDefaultSdkConfig, getSdkConfig, + type SentryRequestOptions, } from "../sentry-client.js"; /** @@ -178,7 +179,7 @@ const zstdCompressAsync = typeof zstdCompressCb === "function" ? promisify(zstdCompressCb) : null; /** Options for raw API requests to Sentry endpoints. */ -export type ApiRequestOptions = { +export type ApiRequestOptions = SentryRequestOptions & { method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; body?: unknown; /** @@ -492,7 +493,7 @@ export async function apiRequestToRegion( options: ApiRequestOptions = {} ): Promise<{ data: T; headers: Headers }> { const { method = "GET", body, bodyEncoding, params, schema } = options; - const config = getSdkConfig(regionUrl); + const config = getSdkConfig(regionUrl, options); const searchParams = buildSearchParams(params); const normalizedEndpoint = endpoint.startsWith("/") @@ -649,7 +650,7 @@ export async function apiRequestToRegionNoContent( options: Omit = {} ): Promise { const { method = "GET", body, params } = options; - const config = getSdkConfig(regionUrl); + const config = getSdkConfig(regionUrl, options); const searchParams = buildSearchParams(params); const normalizedEndpoint = endpoint.startsWith("/") diff --git a/packages/cli/src/lib/api/issue-app-links.ts b/packages/cli/src/lib/api/issue-app-links.ts new file mode 100644 index 0000000000..4508bc75ce --- /dev/null +++ b/packages/cli/src/lib/api/issue-app-links.ts @@ -0,0 +1,743 @@ +/** + * Link existing tracker issues through installed Sentry Apps' issue-link forms. + * App callbacks and search URIs come only from the installed component schema. + */ + +import { + type GroupExternalIssueResponse, + type ListOrganizationSentryAppInstallationsResponse, + listOrganizationIssueExternalIssues, + listOrganizationSentryAppInstallations, +} from "@sentry/api"; +import { + vGroupExternalIssueResponse, + vListOrganizationSentryAppInstallationsResponse, +} from "@sentry/api/valibot"; +import { + array, + boolean, + type GenericSchema, + type InferOutput, + nullish, + number, + object, + optional, + picklist, + safeParse, + string, + tuple, + union, + unknown, +} from "valibot"; +import { ApiError, ValidationError } from "../errors.js"; +import { resolveOrgRegion } from "../region.js"; +import { getControlSiloUrl, getSdkConfig } from "../sentry-client.js"; +import { + apiRequestToRegion, + apiRequestToRegionNoContent, + MAX_PAGINATION_PAGES, + type PaginatedResponse, + parseLinkHeader, + unwrapPaginatedResult, +} from "./infrastructure.js"; + +/** A stored Sentry App association; id identifies the link, not the remote ticket. */ +export type AppIssueLink = GroupExternalIssueResponse[number]; +type AppInstallation = ListOrganizationSentryAppInstallationsResponse[number]; +const ChoiceSchema = tuple([ + union([string(), number()]), + union([string(), number()]), +]); +const FieldSchema = object({ + name: string(), + type: picklist(["select", "text", "textarea"]), + choices: optional(array(ChoiceSchema)), + options: optional(array(ChoiceSchema)), + defaultValue: nullish(union([string(), number()])), + depends_on: optional(array(string())), + multiple: optional(boolean()), + uri: optional(string()), +}); +const LinkFormSchema = object({ + uri: string(), + required_fields: optional(array(FieldSchema)), + optional_fields: optional(array(FieldSchema)), +}); +const ComponentSchema = object({ + type: string(), + error: optional(unknown()), + sentryApp: object({ slug: string(), uuid: string() }), + schema: object({ link: optional(LinkFormSchema) }), +}); +const ComponentsSchema = array(ComponentSchema); +const ChoicesResponseSchema = object({ choices: array(ChoiceSchema) }); +type Choice = InferOutput; +type Field = InferOutput; +type LinkForm = InferOutput; +type Component = InferOutput; + +/** Inputs for a read-only preflight of the app's existing-issue link action. */ +export type ResolveAppIssueLinkOptions = { + /** Organization containing the Sentry issue and app installation. */ + orgSlug: string; + /** Numeric Sentry group ID, required by external-issue-actions. */ + issueId: string; + /** Existing external resource URL. */ + url: string; + /** Installed app slug; defaults to linear for a linear.app issue URL. */ + appSlug?: string; + /** Sentry project ID, forwarded to app searches that need project context. */ + projectId?: string; + /** Additional form values keyed by names from the installed link schema. */ + fields?: Record; +}; + +/** Read-only preflight result. Pass to linkAppIssue to execute the app action. */ +export type PreparedAppIssueLink = { + /** Organization and numeric Sentry issue being linked. */ + orgSlug: string; + /** Numeric Sentry group ID. */ + issueId: string; + /** Installed app slug and requested external URL for display/dry-run. */ + appSlug: string; + /** Requested external resource URL. */ + url: string; + /** Human-facing issue key when available. */ + displayName: string; + /** UUID selected from this organization's installed apps. */ + installationUuid: string; + /** Link action URI supplied by the installed app schema. */ + uri: string; + /** Validated form fields, sent at the top level of the action request. */ + fields: Record; + /** Existing association to the same target; no callback is needed. */ + existing?: AppIssueLink; +}; + +const LINEAR_ISSUE_PATH = /^\/([^/]+)\/issue\/([a-z][a-z0-9]*-\d+)(?:\/|$)/i; +const TARGET_FIELD = + /^(issue_?id|issue|external_?issue|external_?id|issue_?url|url)$/i; +const RESERVED_FIELDS = new Set([ + "groupId", + "action", + "uri", + "__proto__", + "constructor", + "prototype", +]); +const TRAILING_SLASHES = /\/+$/; +const CHOICE_LABEL_TOKENS = /[^A-Z0-9-]+/; +const URL_FIELD = /url/i; +const NUMERIC_ID = /^\d+$/; + +function parseTarget(raw: string) { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new ValidationError( + "External issue must be a valid HTTP(S) URL", + "url" + ); + } + if ( + !["http:", "https:"].includes(url.protocol) || + url.username || + url.password + ) { + throw new ValidationError( + "External issue must be an HTTP(S) URL without credentials", + "url" + ); + } + const linear = + url.hostname === "linear.app" ? LINEAR_ISSUE_PATH.exec(url.pathname) : null; + if (url.hostname === "linear.app" && !linear) { + throw new ValidationError( + "Expected a Linear issue URL containing /issue/TEAM-123", + "url" + ); + } + const identity = linear + ? `linear.app/${linear[1]?.toLowerCase()}/${linear[2]?.toUpperCase()}` + : `${url.origin}${url.pathname.replace(TRAILING_SLASHES, "")}${url.search}${url.hash}`; + return { url, identity, key: linear?.[2]?.toUpperCase() }; +} + +/** Match a stored target by URL, ignoring Linear title suffixes; reject ambiguous matches. */ +export function findAppIssueLink( + links: AppIssueLink[], + url: string, + appSlug?: string +): AppIssueLink | undefined { + const target = parseTarget(url); + const matches = links.filter( + (link) => + (!appSlug || link.serviceType === appSlug) && + parseTarget(link.webUrl).identity === target.identity + ); + if (matches.length > 1) { + throw new ValidationError( + "Multiple app links match this URL; specify the app with --app", + "app" + ); + } + return matches[0]; +} + +async function listAll( + fetchPage: ( + cursor: string | undefined + ) => Promise>, + endpoint: string, + schema: GenericSchema +): Promise { + const result: T[] = []; + const seen = new Set(); + let cursor: string | undefined; + for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { + const { data, nextCursor } = await fetchPage(cursor); + const parsed = safeParse(schema, data); + if (!parsed.success) { + throw new ApiError( + "Unexpected API response when listing app issue links", + 0, + undefined, + endpoint + ); + } + result.push(...parsed.output); + cursor = nextCursor; + if (!cursor) { + return result; + } + if (seen.has(cursor)) { + throw new ApiError( + "App issue link pagination repeated a cursor", + 0, + undefined, + endpoint + ); + } + seen.add(cursor); + } + throw new ApiError( + "App issue link pagination exceeded the safety limit", + 0, + undefined, + endpoint + ); +} + +function groupPath(orgSlug: string, issueId: string): string { + if ( + !orgSlug || + orgSlug === "." || + orgSlug === ".." || + !NUMERIC_ID.test(issueId) + ) { + throw new ValidationError( + "App links require an organization and numeric Sentry issue ID", + "issueId" + ); + } + return `/organizations/${encodeURIComponent(orgSlug)}/issues/${encodeURIComponent(issueId)}/external-issues/`; +} + +/** Retrieve all app associations in the issue's region, bypassing stale cached preflights. */ +export async function listAppIssueLinks( + orgSlug: string, + issueId: string +): Promise { + const endpoint = groupPath(orgSlug, issueId); + const config = getSdkConfig(await resolveOrgRegion(orgSlug), { + cache: "no-store", + }); + return listAll( + async (cursor) => { + const result = await listOrganizationIssueExternalIssues({ + ...config, + path: { organization_id_or_slug: orgSlug, issue_id: issueId }, + // SDK0.256.0 omits cursor from this paginated endpoint's query schema. + query: { cursor } as never, + }); + return unwrapPaginatedResult(result, "Failed to list app issue links"); + }, + endpoint, + vGroupExternalIssueResponse + ); +} + +function checkExisting( + links: AppIssueLink[], + url: string, + appSlug: string +): AppIssueLink | undefined { + const existing = findAppIssueLink(links, url, appSlug); + if ( + links.some( + (link) => link.serviceType === appSlug && link.id !== existing?.id + ) + ) { + throw new ValidationError( + `This issue already has a different ${appSlug} link. Unlink it before linking another issue.`, + "app" + ); + } + return existing; +} + +function validateUri(uri: unknown): asserts uri is string { + if ( + typeof uri !== "string" || + !uri.startsWith("/") || + uri.startsWith("//") || + uri.includes("\\") + ) { + throw new ValidationError( + "The installed app has an invalid relative action URI", + "app" + ); + } +} + +async function resolveInstallation( + orgSlug: string, + appSlug: string +): Promise { + const config = getSdkConfig(getControlSiloUrl(), { cache: "no-store" }); + const installations = await listAll( + async (cursor) => { + const result = await listOrganizationSentryAppInstallations({ + ...config, + path: { organization_id_or_slug: orgSlug }, + query: { cursor }, + }); + return unwrapPaginatedResult( + result, + "Failed to list Sentry App installations" + ); + }, + `/organizations/${encodeURIComponent(orgSlug)}/sentry-app-installations/`, + vListOrganizationSentryAppInstallationsResponse + ); + const matches = installations.filter( + (item) => + item.organization.slug === orgSlug && + item.app.slug === appSlug && + item.status === "installed" + ); + const installation = matches[0]; + if (matches.length !== 1 || !installation) { + throw new ValidationError( + matches.length + ? `Multiple installed apps match ${appSlug}` + : `App ${appSlug} is not installed in this organization`, + "app" + ); + } + return installation; +} + +async function getLinkForm( + orgSlug: string, + installation: AppInstallation +): Promise { + const endpoint = `/organizations/${encodeURIComponent(orgSlug)}/sentry-app-components/`; + // SDK0.256.0 has no operation for installed app UI components. + const components = await listAll( + async (cursor) => { + const { data, headers } = await apiRequestToRegion( + getControlSiloUrl(), + endpoint, + { + params: { filter: "issue-link", cursor }, + cache: "no-store", + } + ); + return { + data, + nextCursor: parseLinkHeader(headers.get("Link")).nextCursor, + }; + }, + endpoint, + ComponentsSchema + ); + const matches = components.filter( + (item) => + item.type === "issue-link" && + item.sentryApp.uuid === installation.app.uuid + ); + if (matches.length !== 1 || !matches[0]?.schema.link) { + throw new ValidationError( + `App ${installation.app.slug} does not expose an unambiguous issue-link form`, + "app" + ); + } + const component = matches[0]; + if (component.error) { + throw new ApiError( + `App ${installation.app.slug} could not prepare its issue-link form`, + 0, + JSON.stringify(component.error) + ); + } + const form = component.schema.link; + if (!form) { + throw new ValidationError("App has no link form", "app"); + } + validateUri(form.uri); + return form; +} + +async function getChoices({ + installationUuid, + field, + query, + values, + projectId, +}: { + installationUuid: string; + field: Field; + query: string; + values: Record; + projectId?: string; +}): Promise { + if (!field.uri) { + return field.choices ?? field.options ?? []; + } + validateUri(field.uri); + const dependentData: Record = {}; + for (const name of field.depends_on ?? []) { + if (values[name] === undefined) { + throw new ValidationError( + `App field ${field.name} requires --field ${name}=VALUE`, + "field" + ); + } + dependentData[name] = values[name]; + } + // SDK0.256.0 does not expose app form option searches. + const { data } = await apiRequestToRegion<{ choices: Choice[] }>( + getControlSiloUrl(), + `/sentry-app-installations/${encodeURIComponent(installationUuid)}/external-requests/`, + { + params: { + uri: field.uri, + query, + projectId, + dependentData: field.depends_on?.length + ? JSON.stringify(dependentData) + : undefined, + }, + cache: "no-store", + schema: ChoicesResponseSchema, + } + ); + if (!Array.isArray(data.choices)) { + throw new ApiError("App search returned an invalid choices response", 0); + } + return data.choices; +} + +function selectChoice( + choices: Choice[], + query: string, + linearKey?: string +): string | number { + const matches = choices.filter( + ([value, label]) => + String(value) === query || + String(label) === query || + (linearKey !== undefined && + String(label) + .toUpperCase() + .split(CHOICE_LABEL_TOKENS) + .find((token) => token.length > 0) === linearKey) + ); + const choice = matches[0]; + if (matches.length !== 1 || !choice) { + throw new ValidationError( + matches.length + ? "App search returned multiple exact issue matches" + : "App search did not return an exact match for the external issue", + "url" + ); + } + return choice[0]; +} + +async function resolveFields( + options: ResolveAppIssueLinkOptions, + form: LinkForm, + installationUuid: string +): Promise> { + const required = form.required_fields ?? []; + const fields = [...required, ...(form.optional_fields ?? [])]; + const values = seedFields(fields, options.fields ?? {}); + const targetField = findTargetField(fields, required); + const pending = fields.filter( + (field) => field === targetField || values[field.name] !== undefined + ); + const resolved = new Set(); + while (pending.length) { + const index = pending.findIndex((item) => + (item.depends_on ?? []).every((name) => resolved.has(name)) + ); + const field = pending[index]; + if (!field) { + throw new ValidationError( + "App link fields have missing or circular dependencies; supply the required --field values", + "field" + ); + } + pending.splice(index, 1); + values[field.name] = await resolveFieldValue({ + field, + targetField, + values, + options, + installationUuid, + }); + resolved.add(field.name); + } + const missing = required.filter( + (field) => values[field.name] === undefined || values[field.name] === "" + ); + if (missing.length) { + throw new ValidationError( + `Missing app link fields: ${missing.map((field) => `--field ${field.name}=VALUE`).join(", ")}`, + "field" + ); + } + return values; +} + +function seedFields( + fields: Field[], + supplied: Record +): Record { + const values: Record = {}; + if (new Set(fields.map((field) => field.name)).size !== fields.length) { + throw new ValidationError( + "App link schema contains duplicate field names", + "app" + ); + } + for (const [name, value] of Object.entries(supplied)) { + if ( + RESERVED_FIELDS.has(name) || + !fields.some((field) => field.name === name) + ) { + throw new ValidationError( + `Unknown or reserved app link field: ${name}`, + "field" + ); + } + values[name] = value; + } + for (const field of fields) { + if (RESERVED_FIELDS.has(field.name)) { + throw new ValidationError( + `App link schema uses reserved field ${field.name}`, + "app" + ); + } + if (field.multiple) { + throw new ValidationError( + `App link field ${field.name} requires multiple values and is not supported`, + "field" + ); + } + if ( + values[field.name] === undefined && + field.defaultValue !== undefined && + field.defaultValue !== null + ) { + values[field.name] = field.defaultValue; + } + } + return values; +} + +function findTargetField(fields: Field[], required: Field[]): Field { + const candidates = fields.filter((field) => TARGET_FIELD.test(field.name)); + let targetField = candidates.length === 1 ? candidates[0] : undefined; + if (candidates.length === 0 && required.length === 1) { + targetField = required[0]; + } + if (!targetField) { + throw new ValidationError( + "Cannot identify one external issue field in the app link schema", + "app" + ); + } + return targetField; +} + +async function resolveFieldValue({ + field, + targetField, + values, + options, + installationUuid, +}: { + field: Field; + targetField: Field; + values: Record; + options: ResolveAppIssueLinkOptions; + installationUuid: string; +}): Promise { + const target = parseTarget(options.url); + const query = target.key ?? options.url; + const input = field === targetField ? query : String(values[field.name]); + let value: string | number = input; + if (field.type === "select") { + value = selectChoice( + await getChoices({ + installationUuid, + field, + query: input, + values, + projectId: options.projectId, + }), + input, + field === targetField ? target.key : undefined + ); + } else if (field === targetField && URL_FIELD.test(field.name)) { + value = options.url; + } + if ( + field === targetField && + options.fields?.[field.name] !== undefined && + options.fields[field.name] !== String(value) && + options.fields[field.name] !== query + ) { + throw new ValidationError( + `App field ${field.name} conflicts with the requested issue URL`, + "field" + ); + } + return value; +} + +/** Resolve the installed app and form using reads only; never register a local-only fallback. */ +export async function resolveAppIssueLink( + options: ResolveAppIssueLinkOptions +): Promise { + const target = parseTarget(options.url); + const appSlug = options.appSlug ?? (target.key ? "linear" : undefined); + if (!appSlug) { + throw new ValidationError( + "Specify --app for this external issue URL", + "app" + ); + } + if (!NUMERIC_ID.test(options.issueId)) { + throw new ValidationError( + "App linking requires the numeric Sentry issue ID", + "issueId" + ); + } + const existing = checkExisting( + await listAppIssueLinks(options.orgSlug, options.issueId), + options.url, + appSlug + ); + const installation = await resolveInstallation(options.orgSlug, appSlug); + if (existing) { + return { + ...options, + appSlug, + installationUuid: installation.uuid, + displayName: existing.displayName, + uri: "", + fields: {}, + existing, + }; + } + const form = await getLinkForm(options.orgSlug, installation); + return { + orgSlug: options.orgSlug, + issueId: options.issueId, + appSlug, + url: options.url, + displayName: target.key ?? options.url, + installationUuid: installation.uuid, + uri: form.uri, + fields: await resolveFields(options, form, installation.uuid), + }; +} + +/** Execute one app callback after a fresh singleton check; concurrent server-side replacements remain possible. */ +export async function linkAppIssue( + prepared: PreparedAppIssueLink +): Promise<{ link: AppIssueLink; changed: boolean }> { + const existing = checkExisting( + await listAppIssueLinks(prepared.orgSlug, prepared.issueId), + prepared.url, + prepared.appSlug + ); + if (existing) { + return { link: existing, changed: false }; + } + if (prepared.existing) { + throw new ValidationError( + "The app link changed after preflight; run the command again", + "url" + ); + } + validateUri(prepared.uri); + // SDK0.256.0 only has direct registration, which skips the app's link callback. + const { data: link } = await apiRequestToRegion( + getControlSiloUrl(), + `/sentry-app-installations/${encodeURIComponent(prepared.installationUuid)}/external-issue-actions/`, + { + method: "POST", + body: { + ...prepared.fields, + groupId: prepared.issueId, + action: "link", + uri: prepared.uri, + }, + retry: false, + cache: "no-store", + schema: vGroupExternalIssueResponse.item, + } + ); + if ( + String(link.issueId) !== prepared.issueId || + link.serviceType !== prepared.appSlug || + parseTarget(link.webUrl).identity !== parseTarget(prepared.url).identity + ) { + throw new ApiError( + "The app returned a different issue after linking; inspect the current links before retrying", + 0 + ); + } + return { link, changed: true }; +} + +/** Remove only the selected local app association; this existing endpoint requires event:admin. */ +export async function unlinkAppIssueLink( + orgSlug: string, + issueId: string, + linkId: string +): Promise { + if (!NUMERIC_ID.test(linkId)) { + throw new ValidationError( + "App unlink requires the numeric association ID", + "linkId" + ); + } + // The SDK's installation unlink uses different auth; this group-scoped operation is absent. + await apiRequestToRegionNoContent( + await resolveOrgRegion(orgSlug), + `${groupPath(orgSlug, issueId)}${encodeURIComponent(linkId)}/`, + { + method: "DELETE", + retry: false, + cache: "no-store", + } + ); +} diff --git a/packages/cli/src/lib/api/issue-integrations.ts b/packages/cli/src/lib/api/issue-integrations.ts new file mode 100644 index 0000000000..80846ecd0b --- /dev/null +++ b/packages/cli/src/lib/api/issue-integrations.ts @@ -0,0 +1,522 @@ +/** Existing issue-tracker links through Sentry's native integrations. */ +import { + deleteOrganizationIssueIntegration, + type ExternalIssueLinkResponse, + type IntegrationIssueConfigResponse, + type LinkExternalIssueRequest, + type ListOrganizationReposResponse, + listOrganizationRepos, + updateOrganizationIssueIntegration, +} from "@sentry/api"; + +import { ValidationError } from "../errors.js"; +import { resolveOrgRegion } from "../region.js"; +import { getSdkConfig } from "../sentry-client.js"; +import { + API_MAX_PER_PAGE, + apiRequestToRegion, + MAX_PAGINATION_PAGES, + type PaginatedResponse, + parseLinkHeader, + unwrapPaginatedResult, + unwrapResult, +} from "./infrastructure.js"; + +/** An existing reference to a tracker issue, stored by a native integration. */ +export type NativeIssueLink = Pick< + ExternalIssueLinkResponse, + "key" | "url" | "displayName" +> & { + /** Internal Sentry ExternalIssue ID, required by the unlink endpoint. */ + id: string; + /** ID of the installed Sentry integration that owns this reference. */ + integrationId: string; + /** Native integration provider key, such as github or jira_server. */ + provider: string; + /** Issue title, when supplied by the list endpoint. */ + title?: string; +}; + +type NativeIntegration = Pick< + IntegrationIssueConfigResponse, + "id" | "name" | "domainName" | "status" | "provider" +> & { + externalIssues: Omit[]; +}; + +/** Read-only resolution result used for previews and a subsequent link mutation. */ +export type PreparedNativeIssueLink = { + /** Sentry organization containing the source issue. */ + orgSlug: string; + /** Numeric Sentry issue ID. */ + issueId: string; + /** Regional API origin resolved for this organization. */ + regionUrl: string; + /** Selected native integration ID. */ + integrationId: string; + /** Native integration provider key. */ + provider: string; + /** Canonical tracker issue URL for the preview. */ + url: string; + /** Provider issue key for the preview. */ + key: string; + /** The backend also requires repo for GitHub and Bitbucket; the SDK schema omits it. */ + body: LinkExternalIssueRequest & { repo?: string }; + /** Reference found during fresh preflight; avoids a duplicate mutation. */ + existing?: NativeIssueLink; +}; + +type ParsedTarget = Pick; + +const TRAILING_SLASH = /\/+$/; +const REPOSITORY_ISSUE = /^\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/[^/]+)?$/; +const GITLAB_ISSUE = /^\/(.+?)(?:\/-)?\/issues\/(\d+)$/; +const JIRA_ISSUE = /^(.*?)\/browse\/([A-Z][A-Z0-9_]*-\d+)$/i; +const WORK_ITEM = /^(.*?)\/_workitems\/edit\/(\d+)$/; +const SCM_CHANGE = + /(?:^\/[^/]+\/[^/]+\/(?:pulls?|pull-requests|commits?)\/|\/-\/(?:merge_requests|commits?)\/)/; + +function issuePath(orgSlug: string, issueId: string): string { + return `/organizations/${encodeURIComponent(orgSlug)}/issues/${encodeURIComponent(issueId)}/integrations/`; +} + +function parseUrl(value: string): URL { + let url: URL; + try { + url = new URL(value); + } catch { + throw new ValidationError( + "External issue must be an absolute HTTP(S) URL." + ); + } + if ( + !["https:", "http:"].includes(url.protocol) || + url.username || + url.password + ) { + throw new ValidationError( + "External issue must be an HTTP(S) URL without credentials." + ); + } + url.hash = ""; + url.search = ""; + url.pathname = url.pathname.replace(TRAILING_SLASH, ""); + return url; +} + +function integrationUrl(integration: NativeIntegration): URL | undefined { + if (!integration.domainName) { + return; + } + const domain = integration.domainName; + // Older personal Bitbucket installations store only the username. + if (integration.provider.key === "bitbucket" && !domain.includes("/")) { + return parseUrl(`https://bitbucket.org/${domain}`); + } + return parseUrl(domain.includes("://") ? domain : `https://${domain}`); +} + +function parseRepositoryIssue(url: URL): ParsedTarget | undefined { + const match = REPOSITORY_ISSUE.exec(url.pathname); + if (!(match?.[1] && match[2])) { + return; + } + const repo = match[1]; + const number = match[2]; + return { + url: `${url.origin}/${repo}/issues/${number}`, + key: `${repo}#${number}`, + body: { repo, externalIssue: number }, + }; +} + +function parseGitlabIssue(url: URL): ParsedTarget | undefined { + const match = GITLAB_ISSUE.exec(url.pathname); + if (!(match?.[1] && match[2])) { + return; + } + const project = match[1]; + const number = match[2]; + return { + url: `${url.origin}/${project}/-/issues/${number}`, + key: `${url.host}:${project}#${number}`, + body: { externalIssue: `${project}#${number}` }, + }; +} + +function parseJiraIssue(url: URL): ParsedTarget | undefined { + const match = JIRA_ISSUE.exec(url.pathname); + if (!match?.[2]) { + return; + } + const key = match[2].toUpperCase(); + return { + url: `${url.origin}${match[1]}/browse/${key}`, + key, + body: { externalIssue: key }, + }; +} + +function azureAccount(url: URL): string | undefined { + if (url.hostname === "dev.azure.com") { + return url.pathname.split("/").find(Boolean)?.toLowerCase(); + } + if (url.hostname.endsWith(".visualstudio.com")) { + return url.hostname.slice(0, -".visualstudio.com".length); + } +} + +function parseAzureIssue(url: URL, domain: URL): ParsedTarget | undefined { + const account = azureAccount(domain); + const match = WORK_ITEM.exec(url.pathname); + if (!(account && account === azureAccount(url) && match?.[2])) { + return; + } + const key = match[2]; + return { + url: `${domain.origin}${domain.pathname.replace(TRAILING_SLASH, "")}/_workitems/edit/${key}`, + key, + body: { externalIssue: key }, + }; +} + +function parseScopedGitlabIssue( + url: URL, + domain: URL, + domainName: string +): ParsedTarget | undefined { + const target = parseGitlabIssue(url); + const group = domain.pathname.replace(TRAILING_SLASH, ""); + if (group && !url.pathname.startsWith(`${group}/`)) { + return; + } + return target + ? { ...target, key: `${domainName}:${target.body.externalIssue}` } + : undefined; +} + +function parseTarget( + url: URL, + integration: NativeIntegration +): ParsedTarget | undefined { + const domain = integrationUrl(integration); + if (!(domain && integration.domainName)) { + return; + } + const provider = integration.provider.key; + if (provider === "vsts") { + return parseAzureIssue(url, domain); + } + if (domain.host !== url.host) { + return; + } + if (["github", "github_enterprise", "bitbucket"].includes(provider)) { + const target = parseRepositoryIssue(url); + const account = domain.pathname.split("/").find(Boolean); + if ( + account && + target?.body.repo?.split("/")[0]?.toLowerCase() !== account.toLowerCase() + ) { + return; + } + return target; + } + if (provider === "gitlab") { + return parseScopedGitlabIssue(url, domain, integration.domainName); + } + if (provider === "jira" || provider === "jira_server") { + const prefix = domain.pathname.replace(TRAILING_SLASH, ""); + if (prefix && !url.pathname.startsWith(`${prefix}/browse/`)) { + return; + } + return parseJiraIssue(url); + } +} + +async function listFreshPages( + fetchPage: (cursor?: string) => Promise> +): Promise { + const items: T[] = []; + let cursor: string | undefined; + for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { + const response = await fetchPage(cursor); + items.push(...response.data); + cursor = response.nextCursor; + if (!cursor) { + return items; + } + } + throw new ValidationError( + "Too many results to resolve the issue link safely." + ); +} + +async function listIntegrations( + orgSlug: string, + issueId: string +): Promise { + const regionUrl = await resolveOrgRegion(orgSlug); + // The SDK exposes integration detail and mutations, but no issue-integration list. + return listFreshPages(async (cursor) => { + const response = await apiRequestToRegion( + regionUrl, + issuePath(orgSlug, issueId), + { + params: { cursor, per_page: API_MAX_PER_PAGE }, + cache: "no-store", + } + ); + return { + data: response.data, + nextCursor: parseLinkHeader(response.headers.get("Link")).nextCursor, + }; + }); +} + +async function listFreshRepositories( + orgSlug: string +): Promise { + const config = getSdkConfig(await resolveOrgRegion(orgSlug), { + cache: "no-store", + }); + return listFreshPages(async (cursor) => { + // per_page is supported by Sentry's paginator but absent from the SDK query type. + const query = { cursor, per_page: API_MAX_PER_PAGE }; + const result = await listOrganizationRepos({ + ...config, + path: { organization_id_or_slug: orgSlug }, + query, + }); + return unwrapPaginatedResult( + result, + "Failed to list repositories" + ); + }); +} + +function flattenLinks(integrations: NativeIntegration[]): NativeIssueLink[] { + return integrations.flatMap((integration) => + integration.externalIssues.map((link) => ({ + ...link, + id: String(link.id), + integrationId: integration.id, + provider: integration.provider.key, + url: + parseTarget(parseUrl(link.url), integration)?.url ?? + parseUrl(link.url).href, + })) + ); +} + +/** Fetch every link fresh, including links whose provider is no longer supported. */ +export async function listNativeIssueLinks( + orgSlug: string, + issueId: string +): Promise { + return flattenLinks(await listIntegrations(orgSlug, issueId)); +} + +function matchesNativeUrl(link: NativeIssueLink, target: URL): boolean { + const existing = parseUrl(link.url); + if (link.provider === "vsts") { + const issueId = WORK_ITEM.exec(target.pathname)?.[2]; + const account = azureAccount(target); + return Boolean( + issueId && + account && + account === azureAccount(existing) && + issueId === WORK_ITEM.exec(existing.pathname)?.[2] + ); + } + if (existing.host !== target.host) { + return false; + } + if (link.provider === "gitlab") { + const existingMatch = GITLAB_ISSUE.exec(existing.pathname); + const targetMatch = GITLAB_ISSUE.exec(target.pathname); + return Boolean( + existingMatch && + targetMatch && + existingMatch[1] === targetMatch[1] && + existingMatch[2] === targetMatch[2] + ); + } + if (["github", "github_enterprise", "bitbucket"].includes(link.provider)) { + const key = parseRepositoryIssue(target)?.key.toLowerCase(); + return Boolean( + key && key === parseRepositoryIssue(existing)?.key.toLowerCase() + ); + } + if (["jira", "jira_server"].includes(link.provider)) { + const canonical = parseJiraIssue(target)?.url; + return Boolean(canonical && canonical === parseJiraIssue(existing)?.url); + } + return existing.href === target.href; +} + +/** Match local link metadata without contacting the issue tracker. */ +export function findNativeIssueLink( + links: NativeIssueLink[], + url: string, + integrationId?: string +): NativeIssueLink | undefined { + const target = parseUrl(url); + const matches = links.filter( + (link) => + (!integrationId || integrationId === link.integrationId) && + matchesNativeUrl(link, target) + ); + if (matches.length > 1) { + throw new ValidationError( + "This issue is linked through multiple integrations. Specify --integration ." + ); + } + return matches[0]; +} + +/** Prepare a reference using installed integration metadata; performs no mutations. */ +export async function resolveNativeIssueLink(options: { + orgSlug: string; + issueId: string; + url: string; + integrationId?: string; +}): Promise { + const url = parseUrl(options.url); + if (SCM_CHANGE.test(url.pathname)) { + throw new ValidationError( + "External issue linking accepts tracker issues, not commits or pull requests." + ); + } + const integrations = await listIntegrations(options.orgSlug, options.issueId); + let candidates = integrations.flatMap((integration) => { + if ( + integration.status !== "active" || + (options.integrationId && options.integrationId !== integration.id) + ) { + return []; + } + const target = parseTarget(url, integration); + return target ? [{ integration, target }] : []; + }); + if ( + candidates.some(({ integration }) => + ["github", "github_enterprise"].includes(integration.provider.key) + ) + ) { + const repositories = await listFreshRepositories(options.orgSlug); + candidates = candidates.flatMap(({ integration, target }) => { + if (!["github", "github_enterprise"].includes(integration.provider.key)) { + return [{ integration, target }]; + } + const repository = repositories.find( + (repo) => + repo.status === "active" && + repo.integrationId === integration.id && + repo.name.toLowerCase() === target.body.repo?.toLowerCase() + ); + if (!repository) { + return []; + } + // Sentry's repository lookup is case-sensitive; use its registered spelling. + return [ + { + integration, + target: { + ...target, + body: { ...target.body, repo: repository.name }, + key: `${repository.name}#${target.body.externalIssue}`, + url: `${url.origin}/${repository.name}/issues/${target.body.externalIssue}`, + }, + }, + ]; + }); + } + if (candidates.length === 0) { + throw new ValidationError( + "No installed native issue-tracker integration matches this URL. Check --integration, or use --app for a Sentry App." + ); + } + if (candidates.length > 1) { + throw new ValidationError( + `Multiple integrations match this URL. Specify --integration : ${candidates.map(({ integration }) => `${integration.id} (${integration.name})`).join(", ")}` + ); + } + const selected = candidates[0]; + if (!selected) { + throw new ValidationError("No matching integration."); + } + return { + orgSlug: options.orgSlug, + issueId: options.issueId, + regionUrl: await resolveOrgRegion(options.orgSlug), + integrationId: selected.integration.id, + provider: selected.integration.provider.key, + ...selected.target, + existing: findNativeIssueLink( + flattenLinks(integrations), + selected.target.url, + selected.integration.id + ), + }; +} + +/** Link an existing tracker issue, without a comment or automatic mutation retry. */ +export async function linkNativeIssue( + prepared: PreparedNativeIssueLink +): Promise<{ link: NativeIssueLink; changed: boolean }> { + if (prepared.existing) { + return { link: prepared.existing, changed: false }; + } + const result = await updateOrganizationIssueIntegration({ + ...getSdkConfig(prepared.regionUrl, { + retry: false, + cache: "no-store", + }), + path: { + organization_id_or_slug: prepared.orgSlug, + issue_id: prepared.issueId, + integration_id: prepared.integrationId, + }, + body: prepared.body, + }); + const data = unwrapResult( + result, + "Failed to link external issue" + ); + return { + link: { + ...data, + id: String(data.id), + integrationId: String(data.integrationId), + provider: prepared.provider, + }, + changed: true, + }; +} + +/** The DELETE identifier is Sentry's ExternalIssue ID, not the provider key. */ +export async function unlinkNativeIssueLink( + orgSlug: string, + issueId: string, + link: NativeIssueLink +): Promise { + const externalIssue = Number(link.id); + if (!Number.isSafeInteger(externalIssue) || externalIssue <= 0) { + throw new ValidationError( + "External issue link ID must be a safe positive integer." + ); + } + const result = await deleteOrganizationIssueIntegration({ + ...getSdkConfig(await resolveOrgRegion(orgSlug), { + retry: false, + cache: "no-store", + }), + path: { + organization_id_or_slug: orgSlug, + issue_id: issueId, + integration_id: link.integrationId, + }, + query: { externalIssue }, + }); + unwrapResult(result, "Failed to unlink external issue"); +} diff --git a/packages/cli/src/lib/complete.ts b/packages/cli/src/lib/complete.ts index 03bb15bfe4..038d156bab 100644 --- a/packages/cli/src/lib/complete.ts +++ b/packages/cli/src/lib/complete.ts @@ -97,6 +97,8 @@ export const ORG_PROJECT_COMMANDS = new Set([ "issue explain", "issue plan", "issue resolve", + "issue link", + "issue unlink", "issue unresolve", "issue archive", "issue merge", diff --git a/packages/cli/src/lib/formatters/issue-links.ts b/packages/cli/src/lib/formatters/issue-links.ts new file mode 100644 index 0000000000..e033ec0a00 --- /dev/null +++ b/packages/cli/src/lib/formatters/issue-links.ts @@ -0,0 +1,29 @@ +/** Human-readable results for linking and unlinking existing external issues. */ + +import type { ExternalIssueLinkResult } from "../issue-links.js"; +import { renderMarkdown, safeCodeSpan } from "./markdown.js"; + +/** Render the association outcome without implying that either issue was resolved. */ +export function formatIssueLinkResult(result: ExternalIssueLinkResult): string { + const external = safeCodeSpan(result.externalIssue.url); + const issue = safeCodeSpan(`${result.org}/${result.issueId}`); + if (result.dryRun) { + const needsChange = + result.action === "link" ? !result.linked : result.linked; + return renderMarkdown( + needsChange + ? `Would ${result.action} ${external} ${result.action === "link" ? "to" : "from"} ${issue}. (dry run)` + : `Already ${result.linked ? "linked" : "unlinked"}: ${external}. (dry run)` + ); + } + if (!result.changed) { + return renderMarkdown( + `Already ${result.linked ? "linked" : "unlinked"}: ${external}.` + ); + } + return renderMarkdown( + result.linked + ? `Linked ${external} to ${issue}.` + : `Unlinked ${external} from ${issue}. The external issue was not deleted.` + ); +} diff --git a/packages/cli/src/lib/issue-links.ts b/packages/cli/src/lib/issue-links.ts new file mode 100644 index 0000000000..8a61ac7267 --- /dev/null +++ b/packages/cli/src/lib/issue-links.ts @@ -0,0 +1,237 @@ +/** + * Link and unlink existing external issues through Sentry's native integrations + * and Sentry Apps. These operations leave the Sentry issue's status unchanged. + */ + +import { + findAppIssueLink, + linkAppIssue, + listAppIssueLinks, + resolveAppIssueLink, + unlinkAppIssueLink, +} from "./api/issue-app-links.js"; +import { + findNativeIssueLink, + linkNativeIssue, + listNativeIssueLinks, + resolveNativeIssueLink, + unlinkNativeIssueLink, +} from "./api/issue-integrations.js"; +import { ValidationError } from "./errors.js"; +import { resolveOrgRegion } from "./region.js"; +import { invalidateCachedResponsesMatching } from "./response-cache.js"; +import { getApiBaseUrl } from "./sentry-client.js"; + +/** An external resource selected for linking to a Sentry issue. */ +export type ExternalIssueLinkOptions = { + /** Organization containing the Sentry issue. */ + orgSlug: string; + /** Numeric Sentry issue ID. */ + issueId: string; + /** Project context required by some Sentry App searches. */ + projectId?: string; + /** URL of an existing external issue. */ + url: string; + /** Native integration ID, when multiple installations match. */ + integrationId?: string; + /** Sentry App slug; Linear URLs select the Linear app automatically. */ + appSlug?: string; + /** Additional fields required by a Sentry App's link form. */ + fields?: Record; + /** Inspect the operation without submitting a mutation. */ + dryRun?: boolean; +}; + +/** Result shared by human and JSON output for external issue mutations. */ +export type ExternalIssueLinkResult = { + /** Organization containing the Sentry issue. */ + org: string; + /** Numeric Sentry issue ID. */ + issueId: string; + /** Requested operation. */ + action: "link" | "unlink"; + /** Whether the external issue remains linked after the operation. */ + linked: boolean; + /** Whether this invocation changed an association. */ + changed: boolean; + /** True when no mutation was submitted. */ + dryRun?: boolean; + /** Canonical external issue identity when available. */ + externalIssue: { + /** Sentry's internal external-issue record ID, not the tracker key. */ + id?: string; + /** Tracker key or display name. */ + identifier?: string; + /** External issue URL. */ + url: string; + /** Native provider key or Sentry App slug. */ + provider?: string; + }; +}; + +/** Validate the URL and select the native integration or Sentry App workflow. */ +function usesSentryApp(options: ExternalIssueLinkOptions): boolean { + let url: URL; + try { + url = new URL(options.url); + } catch { + throw new ValidationError("--external-issue must be a complete issue URL."); + } + if ( + !["https:", "http:"].includes(url.protocol) || + url.username || + url.password + ) { + throw new ValidationError( + "--external-issue must be an HTTP(S) URL without embedded credentials." + ); + } + const app = Boolean(options.appSlug) || url.hostname === "linear.app"; + if (app && options.integrationId) { + throw new ValidationError( + "--integration selects a native integration. Use --app for a Sentry App." + ); + } + if (!app && options.fields && Object.keys(options.fields).length > 0) { + throw new ValidationError( + "--field requires a Sentry App selected with --app." + ); + } + return app; +} + +/** App callbacks run on the control silo, so invalidate the issue's regional cache too. */ +async function invalidateIssueLinks( + options: ExternalIssueLinkOptions +): Promise { + const regionUrl = await resolveOrgRegion(options.orgSlug); + const base = getApiBaseUrl(); + const issuePath = `/api/0/organizations/${encodeURIComponent(options.orgSlug)}/issues/${encodeURIComponent(options.issueId)}/`; + await Promise.all([ + invalidateCachedResponsesMatching(new URL(issuePath, regionUrl).href), + invalidateCachedResponsesMatching(new URL(issuePath, base).href), + invalidateCachedResponsesMatching( + new URL(`/api/0/issues/${encodeURIComponent(options.issueId)}/`, base) + .href + ), + ]); +} + +/** Associate an existing ticket; a dry run performs only discovery and validation. */ +export async function linkExternalIssue( + options: ExternalIssueLinkOptions +): Promise { + const base = { + org: options.orgSlug, + issueId: options.issueId, + action: "link" as const, + dryRun: options.dryRun, + }; + if (usesSentryApp(options)) { + const prepared = await resolveAppIssueLink(options); + if (options.dryRun) { + return { + ...base, + linked: Boolean(prepared.existing), + changed: false, + externalIssue: { + id: prepared.existing?.id, + url: options.url, + provider: options.appSlug ?? "linear", + }, + }; + } + const { link: appLink, changed: appChanged } = await linkAppIssue(prepared); + if (appChanged) { + await invalidateIssueLinks(options); + } + return { + ...base, + linked: true, + changed: appChanged, + externalIssue: { + id: appLink.id, + identifier: appLink.displayName, + url: appLink.webUrl, + provider: appLink.serviceType, + }, + }; + } + const prepared = await resolveNativeIssueLink(options); + if (options.dryRun) { + return { + ...base, + linked: Boolean(prepared.existing), + changed: false, + externalIssue: { + id: prepared.existing?.id, + identifier: prepared.key, + url: prepared.url, + provider: prepared.provider, + }, + }; + } + const { link, changed } = await linkNativeIssue(prepared); + if (changed) { + await invalidateIssueLinks(options); + } + return { + ...base, + linked: true, + changed, + externalIssue: { + id: link.id, + identifier: link.key, + url: link.url, + provider: link.provider, + }, + }; +} + +/** Remove a stored association without contacting or deleting the remote ticket. */ +export async function unlinkExternalIssue( + options: ExternalIssueLinkOptions +): Promise { + const base = { + org: options.orgSlug, + issueId: options.issueId, + action: "unlink" as const, + dryRun: options.dryRun, + }; + if (usesSentryApp(options)) { + const links = await listAppIssueLinks(options.orgSlug, options.issueId); + const link = findAppIssueLink(links, options.url, options.appSlug); + if (link && !options.dryRun) { + await unlinkAppIssueLink(options.orgSlug, options.issueId, link.id); + await invalidateIssueLinks(options); + } + return { + ...base, + linked: Boolean(link && options.dryRun), + changed: Boolean(link && !options.dryRun), + externalIssue: { + id: link?.id, + identifier: link?.displayName, + url: link?.webUrl ?? options.url, + provider: link?.serviceType ?? options.appSlug ?? "linear", + }, + }; + } + const links = await listNativeIssueLinks(options.orgSlug, options.issueId); + const link = findNativeIssueLink(links, options.url, options.integrationId); + if (link && !options.dryRun) { + await unlinkNativeIssueLink(options.orgSlug, options.issueId, link); + await invalidateIssueLinks(options); + } + return { + ...base, + linked: Boolean(link && options.dryRun), + changed: Boolean(link && !options.dryRun), + externalIssue: { + id: link?.id, + identifier: link?.key, + url: link?.url ?? options.url, + provider: link?.provider, + }, + }; +} diff --git a/packages/cli/src/lib/oauth.ts b/packages/cli/src/lib/oauth.ts index a5ccd798fb..46bfc8b1c9 100644 --- a/packages/cli/src/lib/oauth.ts +++ b/packages/cli/src/lib/oauth.ts @@ -87,6 +87,7 @@ export const OAUTH_SCOPES: readonly string[] = [ "org:read", "event:read", "event:write", + "event:admin", "member:read", "team:read", "team:write", diff --git a/packages/cli/src/lib/scope-recovery.ts b/packages/cli/src/lib/scope-recovery.ts index 395f3f1b79..7c3468303d 100644 --- a/packages/cli/src/lib/scope-recovery.ts +++ b/packages/cli/src/lib/scope-recovery.ts @@ -3,17 +3,22 @@ import { getCurrentAuthScopes } from "./api/auth.js"; import { assertAutoLoginHostTrusted } from "./auto-auth.js"; import { type AuthSource, getAuthConfig } from "./db/auth.js"; import { ApiError, AuthError } from "./errors.js"; -import type { LoginResult } from "./interactive-login.js"; +import type { + InteractiveLoginOptions, + LoginResult, +} from "./interactive-login.js"; import { interactivePromptsAllowed } from "./interactive-prompts.js"; import { logger } from "./logger.js"; import { OAUTH_SCOPES } from "./oauth.js"; -type InteractiveLogin = () => Promise; +type InteractiveLogin = ( + options?: InteractiveLoginOptions +) => Promise; type OAuthScopeState = | { kind: "current" } | { kind: "invalid" } - | { kind: "missing"; scopes: string[] }; + | { kind: "missing"; scopes: string[]; requestedScopes: string[] }; export type ScopeRecoveryRuntime = { assertTrustedHost: () => void; @@ -52,7 +57,11 @@ async function inspectOAuthScopes( const grantedSet = new Set(granted); const missing = OAUTH_SCOPES.filter((scope) => !grantedSet.has(scope)); return missing.length > 0 - ? { kind: "missing", scopes: missing } + ? { + kind: "missing", + scopes: missing, + requestedScopes: [...new Set([...OAUTH_SCOPES, ...granted])], + } : { kind: "current" }; } catch (error) { if ( @@ -120,6 +129,15 @@ async function refreshOAuthScopes( runtime: ScopeRecoveryRuntime ): Promise { if (!(runtime.inputIsTty() && runtime.promptsAllowed())) { + if (state.kind === "missing") { + const scopeArgs = state.requestedScopes + .map((scope) => `--scope ${scope}`) + .join(" "); + runtime.write( + `Your CLI authorization is missing ${state.scopes.join(", ")}.\n` + + `Re-authenticate with: sentry auth refresh ${scopeArgs}\n` + ); + } return false; } @@ -133,7 +151,13 @@ async function refreshOAuthScopes( "Your CLI authorization is no longer valid. Starting authorization...\n\n" ); } - return Boolean(await runInteractiveLogin()); + return Boolean( + await runInteractiveLogin( + state.kind === "missing" + ? { scope: state.requestedScopes.join(" ") } + : undefined + ) + ); } /** Refresh a stored OAuth grant when it lacks any scope requested by this CLI. */ diff --git a/packages/cli/src/lib/sentry-client.ts b/packages/cli/src/lib/sentry-client.ts index bb8b3653fa..655d4dfdb1 100644 --- a/packages/cli/src/lib/sentry-client.ts +++ b/packages/cli/src/lib/sentry-client.ts @@ -65,6 +65,14 @@ const ENDPOINT_TIMEOUT_OVERRIDES: TimeoutOverride[] = [ /** Maximum retry attempts for failed requests */ const MAX_RETRIES = 2; +/** Per-request controls for mutations with external effects and fresh preflights. */ +export type SentryRequestOptions = { + /** False sends exactly one request, without HTTP, network, timeout, or 401 replay. */ + retry?: boolean; + /** Bypass both reads and writes of the local response cache. */ + cache?: "no-store"; +}; + /** Maximum backoff delay between retries in milliseconds */ const MAX_BACKOFF_MS = 10_000; @@ -507,16 +515,17 @@ async function buildAttemptFactory( async function fetchWithRetry( input: Request | string | URL, init: RequestInit | undefined, - method: string, - fullUrl: string + request: { method: string; fullUrl: string; options: SentryRequestOptions } ): Promise { + const { method, fullUrl, options } = request; const { token } = await refreshToken(); const headers = prepareHeaders(input, init, token); const attemptFactory = await buildAttemptFactory(input, init); const timeoutMs = resolveTimeoutMs(fullUrl); + const maxRetries = options.retry === false ? 0 : MAX_RETRIES; - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { - const isLastAttempt = attempt === MAX_RETRIES; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const isLastAttempt = attempt === maxRetries; const { input: attemptInput, init: attemptInit } = attemptFactory(); const result = await executeAttempt({ input: attemptInput, @@ -524,17 +533,20 @@ async function fetchWithRetry( headers, isLastAttempt, timeoutMs, + retry: options.retry, }); if (result.action === "done") { // Use getAuthToken() instead of captured `token` — after a 401 refresh, // handleUnauthorized stores a new token in the DB - cacheResponse( - method, - fullUrl, - authHeaders(getAuthToken()), - result.response - ); + if (options.cache !== "no-store") { + cacheResponse( + method, + fullUrl, + authHeaders(getAuthToken()), + result.response + ); + } await invalidateAfterMutation(method, fullUrl, result.response); return result.response; } @@ -572,10 +584,9 @@ async function fetchWithRetry( * * @returns A fetch-compatible function for use with @sentry/api SDK functions */ -function createAuthenticatedFetch(): ( - input: Request | string | URL, - init?: RequestInit -) => Promise { +function createAuthenticatedFetch( + options: SentryRequestOptions = {} +): (input: Request | string | URL, init?: RequestInit) => Promise { return function authenticatedFetch( input: Request | string | URL, init?: RequestInit @@ -604,11 +615,10 @@ function createAuthenticatedFetch(): ( // Check cache before auth/retry for GET requests. // Uses current token (no refresh) so lookups are fast but Vary-correct. - const cached = await tryCacheHit( - method, - fullUrl, - authHeaders(getAuthToken()) - ); + const cached = + options.cache === "no-store" + ? undefined + : await tryCacheHit(method, fullUrl, authHeaders(getAuthToken())); if (cached) { span.setAttribute("http.response.status_code", cached.status); log.debug( @@ -617,7 +627,14 @@ function createAuthenticatedFetch(): ( return cached; } - const response = await fetchWithRetry(input, init, method, fullUrl); + const requestInit = options.cache + ? { ...init, cache: options.cache } + : init; + const response = await fetchWithRetry(input, requestInit, { + method, + fullUrl, + options, + }); span.setAttribute("http.response.status_code", response.status); if (!response.ok) { span.setStatus({ code: 2, message: `${response.status}` }); @@ -641,6 +658,7 @@ type ExecuteAttemptArgs = { headers: Headers; isLastAttempt: boolean; timeoutMs: number; + retry?: boolean; }; async function executeAttempt({ @@ -649,6 +667,7 @@ async function executeAttempt({ headers, isLastAttempt, timeoutMs, + retry, }: ExecuteAttemptArgs): Promise { try { const response = await fetchWithTimeout({ @@ -658,7 +677,9 @@ async function executeAttempt({ externalSignal: init?.signal, timeoutMs, }); - return handleResponse(response, headers, isLastAttempt); + return retry === false + ? { action: "done", response } + : handleResponse(response, headers, isLastAttempt); } catch (error) { return handleFetchError(error, init?.signal, isLastAttempt); } @@ -714,7 +735,10 @@ export function getControlSiloUrl(): string { * const result = await listOrganizations({ ...config }); * ``` */ -export function getSdkConfig(regionUrl: string) { +export function getSdkConfig( + regionUrl: string, + options: SentryRequestOptions = {} +) { const normalizedBase = regionUrl.endsWith("/") ? regionUrl.slice(0, -1) : regionUrl; @@ -723,7 +747,10 @@ export function getSdkConfig(regionUrl: string) { // SDK functions already include /api/0/ in their URL paths, // so baseUrl should be the plain region URL without /api/0. baseUrl: normalizedBase, - fetch: getAuthenticatedFetch(), + fetch: + options.retry !== undefined || options.cache !== undefined + ? (createAuthenticatedFetch(options) as typeof fetch) + : getAuthenticatedFetch(), throwOnError: false as const, }; } diff --git a/packages/cli/test/commands/issue/link.func.test.ts b/packages/cli/test/commands/issue/link.func.test.ts new file mode 100644 index 0000000000..5d2de0ccd7 --- /dev/null +++ b/packages/cli/test/commands/issue/link.func.test.ts @@ -0,0 +1,225 @@ +/** Tests the issue link command, including its shared output wrapper. */ + +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { linkCommand } from "../../../src/commands/issue/link.js"; +import { resolveIssue } from "../../../src/commands/issue/utils.js"; +import { updateIssueStatus } from "../../../src/lib/api-client.js"; +import { + ApiError, + ContextError, + ValidationError, +} from "../../../src/lib/errors.js"; +import { + type ExternalIssueLinkResult, + linkExternalIssue, +} from "../../../src/lib/issue-links.js"; +import type { SentryIssue } from "../../../src/types/sentry.js"; + +vi.mock("../../../src/commands/issue/utils.js", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../../../src/commands/issue/utils.js") + >()), + resolveIssue: vi.fn(), +})); + +vi.mock("../../../src/lib/api-client.js", async (importOriginal) => ({ + ...(await importOriginal()), + updateIssueStatus: vi.fn(), +})); + +vi.mock("../../../src/lib/issue-links.js", () => ({ + linkExternalIssue: vi.fn(), +})); + +const externalUrl = "https://github.com/example/app/issues/42"; +const defaultFlags = { + "external-issue": externalUrl, + "dry-run": false, + json: false, +}; +const issue = { + id: "123456789", + shortId: "APP-42", + title: "TypeError: boom", + culprit: "handler", + count: "10", + userCount: 3, + firstSeen: "2026-03-01T00:00:00Z", + lastSeen: "2026-04-03T12:00:00Z", + level: "error", + status: "unresolved", + permalink: "https://sentry.io/organizations/test-org/issues/123456789/", + project: { id: "456", slug: "test-project", name: "Test Project" }, +} as SentryIssue; +const linkedResult: ExternalIssueLinkResult = { + org: "test-org", + issueId: "123456789", + action: "link", + linked: true, + changed: true, + externalIssue: { + id: "789", + identifier: "example/app#42", + url: externalUrl, + provider: "github", + }, +}; + +function createMockContext() { + const stdoutWrite = vi.fn((_chunk: string) => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: vi.fn((_chunk: string) => true) }, + cwd: "/tmp/example-project", + }, + output: () => stdoutWrite.mock.calls.map(([chunk]) => chunk).join(""), + }; +} + +describe("issue link", () => { + beforeEach(() => { + vi.mocked(resolveIssue).mockReset(); + vi.mocked(linkExternalIssue).mockReset(); + vi.mocked(updateIssueStatus).mockClear(); + vi.mocked(resolveIssue).mockResolvedValue({ org: "test-org", issue }); + vi.mocked(linkExternalIssue).mockResolvedValue(linkedResult); + }); + + test("forwards resolved organization, issue and project with the integration selector", async () => { + const { context, output } = createMockContext(); + const func = await linkCommand.loader(); + await func.call( + context, + { ...defaultFlags, integration: "99" }, + "test-org/APP-42" + ); + + expect(resolveIssue).toHaveBeenCalledExactlyOnceWith({ + issueArg: "test-org/APP-42", + cwd: "/tmp/example-project", + command: "link", + }); + expect(linkExternalIssue).toHaveBeenCalledExactlyOnceWith({ + orgSlug: "test-org", + issueId: "123456789", + projectId: "456", + url: externalUrl, + integrationId: "99", + appSlug: undefined, + fields: undefined, + dryRun: false, + }); + expect(output()).toContain("Linked"); + expect(output()).toContain(externalUrl); + expect(output()).toContain("test-org/123456789"); + expect(updateIssueStatus).not.toHaveBeenCalled(); + }); + + test("forwards an App selector and parses repeatable fields without losing values", async () => { + const { context } = createMockContext(); + const func = await linkCommand.loader(); + await func.call( + context, + { + ...defaultFlags, + "external-issue": "https://tracker.example/issues/42", + app: "custom-tracker", + field: ["team=team-1", "query=key=value", "optional="], + }, + "APP-42" + ); + + expect(linkExternalIssue).toHaveBeenCalledExactlyOnceWith({ + orgSlug: "test-org", + issueId: "123456789", + projectId: "456", + url: "https://tracker.example/issues/42", + integrationId: undefined, + appSlug: "custom-tracker", + fields: { team: "team-1", query: "key=value", optional: "" }, + dryRun: false, + }); + expect(updateIssueStatus).not.toHaveBeenCalled(); + }); + + test.each([ + ["team"], + ["=team-1"], + ["team=one", "team=two"], + ["__proto__=value"], + ["constructor=value"], + ["prototype=value"], + ])("rejects malformed or ambiguous --field input %j before resolving or writing", async (...fields) => { + const { context, output } = createMockContext(); + const func = await linkCommand.loader(); + + await expect( + func.call( + context, + { ...defaultFlags, app: "custom-tracker", field: fields }, + "APP-42" + ) + ).rejects.toBeInstanceOf(ValidationError); + + expect(resolveIssue).not.toHaveBeenCalled(); + expect(linkExternalIssue).not.toHaveBeenCalled(); + expect(output()).toBe(""); + }); + + test("requires organization context before linking a numeric issue", async () => { + vi.mocked(resolveIssue).mockResolvedValue({ org: undefined, issue }); + const { context } = createMockContext(); + const func = await linkCommand.loader(); + + await expect( + func.call(context, defaultFlags, "123456789") + ).rejects.toBeInstanceOf(ContextError); + expect(linkExternalIssue).not.toHaveBeenCalled(); + }); + + test("renders a dry-run preview while forwarding the no-write flag", async () => { + vi.mocked(linkExternalIssue).mockResolvedValue({ + ...linkedResult, + linked: false, + changed: false, + dryRun: true, + }); + const { context, output } = createMockContext(); + const func = await linkCommand.loader(); + await func.call(context, { ...defaultFlags, "dry-run": true }, "APP-42"); + + expect(linkExternalIssue).toHaveBeenCalledWith( + expect.objectContaining({ dryRun: true }) + ); + expect(output()).toContain("Would link"); + expect(output()).toContain("dry run"); + expect(updateIssueStatus).not.toHaveBeenCalled(); + }); + + test.each([ + true, + false, + ])("emits structured link state with changed=%s in JSON", async (changed) => { + const result = { ...linkedResult, changed }; + vi.mocked(linkExternalIssue).mockResolvedValue(result); + const { context, output } = createMockContext(); + const func = await linkCommand.loader(); + await func.call(context, { ...defaultFlags, json: true }, "APP-42"); + + expect(JSON.parse(output())).toEqual(result); + expect(updateIssueStatus).not.toHaveBeenCalled(); + }); + + test("propagates the original 403 so scope recovery can handle it", async () => { + const error = new ApiError("Permission denied", 403, "Missing event:write"); + vi.mocked(linkExternalIssue).mockRejectedValue(error); + const { context, output } = createMockContext(); + const func = await linkCommand.loader(); + + await expect( + func.call(context, { ...defaultFlags, json: true }, "APP-42") + ).rejects.toBe(error); + expect(output()).toBe(""); + }); +}); diff --git a/packages/cli/test/commands/issue/unlink.func.test.ts b/packages/cli/test/commands/issue/unlink.func.test.ts new file mode 100644 index 0000000000..60af635e87 --- /dev/null +++ b/packages/cli/test/commands/issue/unlink.func.test.ts @@ -0,0 +1,250 @@ +/** Tests the issue unlink command with its real destructive-command guard. */ + +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { unlinkCommand } from "../../../src/commands/issue/unlink.js"; +import { resolveOrgAndIssueId } from "../../../src/commands/issue/utils.js"; +import { updateIssueStatus } from "../../../src/lib/api-client.js"; +import { ApiError } from "../../../src/lib/errors.js"; +import { + type ExternalIssueLinkResult, + unlinkExternalIssue, +} from "../../../src/lib/issue-links.js"; +import { confirmByTyping } from "../../../src/lib/mutate-command.js"; + +const { mockIsatty } = vi.hoisted(() => ({ mockIsatty: vi.fn(() => false) })); + +vi.mock("node:tty", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isatty: mockIsatty, + default: { ...actual, isatty: mockIsatty }, + }; +}); + +vi.mock("../../../src/commands/issue/utils.js", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../../../src/commands/issue/utils.js") + >()), + resolveOrgAndIssueId: vi.fn(), +})); + +vi.mock("../../../src/lib/api-client.js", async (importOriginal) => ({ + ...(await importOriginal()), + updateIssueStatus: vi.fn(), +})); + +vi.mock("../../../src/lib/issue-links.js", () => ({ + unlinkExternalIssue: vi.fn(), +})); + +vi.mock("../../../src/lib/mutate-command.js", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../../../src/lib/mutate-command.js") + >()), + confirmByTyping: vi.fn(), +})); + +const externalUrl = "https://github.com/example/app/issues/42"; +const defaultFlags = { + "external-issue": externalUrl, + "dry-run": false, + yes: false, + force: false, + json: false, +}; +const unlinkedResult: ExternalIssueLinkResult = { + org: "test-org", + issueId: "123456789", + action: "unlink", + linked: false, + changed: true, + externalIssue: { + id: "789", + identifier: "example/app#42", + url: externalUrl, + provider: "github", + }, +}; + +function createMockContext() { + const stdoutWrite = vi.fn((_chunk: string) => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: vi.fn((_chunk: string) => true) }, + cwd: "/tmp/example-project", + }, + output: () => stdoutWrite.mock.calls.map(([chunk]) => chunk).join(""), + }; +} + +describe("issue unlink", () => { + beforeEach(() => { + mockIsatty.mockReset().mockReturnValue(false); + vi.mocked(resolveOrgAndIssueId).mockReset(); + vi.mocked(unlinkExternalIssue).mockReset(); + vi.mocked(confirmByTyping).mockReset().mockResolvedValue(true); + vi.mocked(updateIssueStatus).mockClear(); + vi.mocked(resolveOrgAndIssueId).mockResolvedValue({ + org: "test-org", + issueId: "123456789", + }); + vi.mocked(unlinkExternalIssue).mockResolvedValue(unlinkedResult); + }); + + test.each([ + { + selector: { integration: "99" }, + expected: { integrationId: "99", appSlug: undefined }, + }, + { + selector: { app: "custom-tracker" }, + expected: { integrationId: undefined, appSlug: "custom-tracker" }, + }, + ])("forwards resolved issue context and selector $selector", async ({ + selector, + expected, + }) => { + const { context, output } = createMockContext(); + const func = await unlinkCommand.loader(); + await func.call( + context, + { ...defaultFlags, ...selector, yes: true }, + "test-org/APP-42" + ); + + expect(resolveOrgAndIssueId).toHaveBeenCalledExactlyOnceWith({ + issueArg: "test-org/APP-42", + cwd: "/tmp/example-project", + command: "unlink", + }); + expect(unlinkExternalIssue).toHaveBeenCalledExactlyOnceWith({ + orgSlug: "test-org", + issueId: "123456789", + url: externalUrl, + ...expected, + dryRun: false, + }); + expect(confirmByTyping).not.toHaveBeenCalled(); + expect(output()).toContain("Unlinked"); + expect(output()).toContain("external issue was not deleted"); + expect(updateIssueStatus).not.toHaveBeenCalled(); + }); + + test("refuses non-interactive mutation without explicit confirmation before resolving", async () => { + const { context, output } = createMockContext(); + const func = await unlinkCommand.loader(); + + await expect(func.call(context, defaultFlags, "APP-42")).rejects.toThrow( + "Use --yes or --force to confirm." + ); + + expect(resolveOrgAndIssueId).not.toHaveBeenCalled(); + expect(confirmByTyping).not.toHaveBeenCalled(); + expect(unlinkExternalIssue).not.toHaveBeenCalled(); + expect(output()).toBe(""); + }); + + test.each([ + "yes", + "force", + ] as const)("allows non-interactive --%s without prompting", async (flag) => { + const { context } = createMockContext(); + const func = await unlinkCommand.loader(); + await func.call(context, { ...defaultFlags, [flag]: true }, "APP-42"); + + expect(confirmByTyping).not.toHaveBeenCalled(); + expect(unlinkExternalIssue).toHaveBeenCalledExactlyOnceWith({ + orgSlug: "test-org", + issueId: "123456789", + url: externalUrl, + integrationId: undefined, + appSlug: undefined, + dryRun: false, + }); + }); + + test("confirms the selected issue and external URL before unlinking interactively", async () => { + mockIsatty.mockReturnValue(true); + const { context } = createMockContext(); + const func = await unlinkCommand.loader(); + await func.call(context, defaultFlags, "test-org/APP-42"); + + expect(confirmByTyping).toHaveBeenCalledExactlyOnceWith( + "test-org/APP-42", + `Type 'test-org/APP-42' to unlink ${externalUrl}:` + ); + expect(unlinkExternalIssue).toHaveBeenCalledOnce(); + expect(vi.mocked(confirmByTyping).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(unlinkExternalIssue).mock.invocationCallOrder[0] + ); + }); + + test("cancelling confirmation leaves the association untouched", async () => { + mockIsatty.mockReturnValue(true); + vi.mocked(confirmByTyping).mockResolvedValue(false); + const { context, output } = createMockContext(); + const func = await unlinkCommand.loader(); + await func.call(context, defaultFlags, "APP-42"); + + expect(confirmByTyping).toHaveBeenCalledOnce(); + expect(unlinkExternalIssue).not.toHaveBeenCalled(); + expect(output()).toContain("Cancelled."); + expect(updateIssueStatus).not.toHaveBeenCalled(); + }); + + test("permits --dry-run without a TTY and preserves current link state in JSON", async () => { + const result = { + ...unlinkedResult, + linked: true, + changed: false, + dryRun: true, + }; + vi.mocked(unlinkExternalIssue).mockResolvedValue(result); + const { context, output } = createMockContext(); + const func = await unlinkCommand.loader(); + await func.call( + context, + { ...defaultFlags, "dry-run": true, json: true }, + "APP-42" + ); + + expect(confirmByTyping).not.toHaveBeenCalled(); + expect(unlinkExternalIssue).toHaveBeenCalledWith( + expect.objectContaining({ dryRun: true }) + ); + expect(JSON.parse(output())).toEqual(result); + expect(updateIssueStatus).not.toHaveBeenCalled(); + }); + + test.each([ + true, + false, + ])("emits structured unlink state with changed=%s in JSON", async (changed) => { + const result = { ...unlinkedResult, changed }; + vi.mocked(unlinkExternalIssue).mockResolvedValue(result); + const { context, output } = createMockContext(); + const func = await unlinkCommand.loader(); + await func.call( + context, + { ...defaultFlags, yes: true, json: true }, + "APP-42" + ); + + expect(JSON.parse(output())).toEqual(result); + expect(updateIssueStatus).not.toHaveBeenCalled(); + }); + + test("propagates the original 403 so scope recovery can handle it", async () => { + const error = new ApiError("Permission denied", 403, "Missing event:admin"); + vi.mocked(unlinkExternalIssue).mockRejectedValue(error); + const { context, output } = createMockContext(); + const func = await unlinkCommand.loader(); + + await expect( + func.call(context, { ...defaultFlags, yes: true, json: true }, "APP-42") + ).rejects.toBe(error); + expect(output()).toBe(""); + }); +}); diff --git a/packages/cli/test/lib/api/issue-app-links.test.ts b/packages/cli/test/lib/api/issue-app-links.test.ts new file mode 100644 index 0000000000..d6243b9722 --- /dev/null +++ b/packages/cli/test/lib/api/issue-app-links.test.ts @@ -0,0 +1,447 @@ +/** Contract tests for installed app callbacks, singleton protection, and regional unlinking. */ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + type AppIssueLink, + findAppIssueLink, + linkAppIssue, + listAppIssueLinks, + resolveAppIssueLink, + unlinkAppIssueLink, +} from "../../../src/lib/api/issue-app-links.js"; +import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { setOrgRegion } from "../../../src/lib/db/regions.js"; +import { ApiError, ValidationError } from "../../../src/lib/errors.js"; +import { resetAuthenticatedFetch } from "../../../src/lib/sentry-client.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("issue-app-links-"); + +const ORG = "example-org"; +const ISSUE = "123"; +const URL = "https://linear.app/example/issue/ENG-42/fix-crash"; +const OPTIONS = { orgSlug: ORG, issueId: ISSUE, url: URL, projectId: "77" }; +const LINK: AppIssueLink = { + id: "99", + issueId: ISSUE, + serviceType: "linear", + displayName: "ENG-42", + webUrl: URL, +}; +const INSTALLATION = { + uuid: "install-uuid", + status: "installed", + organization: { slug: ORG }, + app: { uuid: "app-uuid", slug: "linear", sentryAppId: 12 }, +}; +const FORM = { + uri: "/hooks/sentry/issues/link", + required_fields: [ + { name: "issueId", type: "select", uri: "/hooks/sentry/issues/search" }, + ], +}; +const COMPONENT = { + type: "issue-link", + sentryApp: { uuid: "app-uuid", slug: "linear" }, + schema: { link: FORM }, +}; + +let originalFetch: typeof globalThis.fetch; +let calls: Request[]; +let links: AppIssueLink[]; +let choices: [string, string][]; +let form: unknown; +let installation: typeof INSTALLATION; +let actionStatus: number; +let actionLink: AppIssueLink; + +function json(data: unknown, status = 200, headers?: HeadersInit): Response { + return Response.json(data, { status, headers }); +} + +beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + setOrgRegion(ORG, "https://de.sentry.io"); + resetAuthenticatedFetch(); + calls = []; + links = []; + choices = [["linear-uuid", "ENG-42: Fix the crash"]]; + form = FORM; + installation = INSTALLATION; + actionStatus = 200; + actionLink = LINK; + globalThis.fetch = mockFetch(async (input, init) => { + const request = new Request(input, init); + calls.push(request.clone()); + const path = new globalThis.URL(request.url).pathname; + if (path.endsWith("/external-issues/") && request.method === "GET") { + return json(links); + } + if (path.endsWith("/sentry-app-installations/")) { + return json([installation]); + } + if (path.endsWith("/sentry-app-components/")) { + return json([{ ...COMPONENT, schema: { link: form } }]); + } + if (path.endsWith("/external-requests/")) { + return json({ choices }); + } + if (path.endsWith("/external-issue-actions/")) { + return json( + actionStatus === 200 ? actionLink : { detail: "Provider failed" }, + actionStatus + ); + } + if (request.method === "DELETE") { + return new Response(null, { status: 204 }); + } + throw new Error(`Unexpected request: ${request.method} ${request.url}`); + }); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + resetAuthenticatedFetch(); +}); + +function writes(): Request[] { + return calls.filter((request) => request.method !== "GET"); +} + +describe("app issue-link action", () => { + test("resolves Linear key to UUID read-only, then sends the schema URI and fields top-level", async () => { + const prepared = await resolveAppIssueLink(OPTIONS); + expect(writes()).toHaveLength(0); + expect(prepared.fields).toEqual({ issueId: "linear-uuid" }); + const search = calls.find((request) => + request.url.includes("external-requests") + ); + expect(search?.url).toContain( + "https://sentry.io/api/0/sentry-app-installations/install-uuid/" + ); + expect(search?.url).toContain("query=ENG-42"); + expect(search?.url).toContain("projectId=77"); + expect(calls[0]?.url).toContain( + "https://de.sentry.io/api/0/organizations/" + ); + expect(await linkAppIssue(prepared)).toEqual({ changed: true, link: LINK }); + expect(writes()).toHaveLength(1); + expect(await writes()[0]?.json()).toEqual({ + groupId: ISSUE, + action: "link", + uri: FORM.uri, + issueId: "linear-uuid", + }); + expect(calls.every((request) => request.cache === "no-store")).toBe(true); + }); + + test("does not treat a prefix search result as an exact Linear issue", async () => { + choices = [["wrong", "ENG-420: Other issue"]]; + await expect(resolveAppIssueLink(OPTIONS)).rejects.toThrow( + "did not return an exact match" + ); + expect(writes()).toHaveLength(0); + }); + + test("does not select an issue whose title merely mentions the requested key", async () => { + choices = [["wrong", "ENG-99: Follow up on ENG-42"]]; + await expect(resolveAppIssueLink(OPTIONS)).rejects.toThrow( + "did not return an exact match" + ); + expect(writes()).toHaveLength(0); + }); + + test("rejects multiple exact matches rather than selecting the first", async () => { + choices.push(["another-uuid", "ENG-42: Another issue"]); + await expect(resolveAppIssueLink(OPTIONS)).rejects.toThrow( + "multiple exact issue matches" + ); + expect(writes()).toHaveLength(0); + }); + + test("recognizes an existing Linear link even when the URL title changes", async () => { + links = [ + { ...LINK, webUrl: "https://linear.app/example/issue/eng-42/new-title" }, + ]; + const prepared = await resolveAppIssueLink(OPTIONS); + expect(prepared.existing?.id).toBe(LINK.id); + expect(await linkAppIssue(prepared)).toEqual({ + changed: false, + link: links[0], + }); + expect( + calls.some((request) => request.url.includes("sentry-app-components")) + ).toBe(false); + expect(writes()).toHaveLength(0); + }); + + test("refuses to replace another issue linked to the same app", async () => { + links = [ + { ...LINK, webUrl: "https://linear.app/example/issue/ENG-99/other" }, + ]; + await expect(resolveAppIssueLink(OPTIONS)).rejects.toThrow( + "Unlink it before" + ); + expect(writes()).toHaveLength(0); + }); + + test("rechecks the singleton immediately before calling the app", async () => { + const prepared = await resolveAppIssueLink(OPTIONS); + links = [ + { ...LINK, webUrl: "https://linear.app/example/issue/ENG-99/other" }, + ]; + await expect(linkAppIssue(prepared)).rejects.toThrow("Unlink it before"); + expect(writes()).toHaveLength(0); + }); + + test("does not blindly replay a failed app action", async () => { + const prepared = await resolveAppIssueLink(OPTIONS); + actionStatus = 503; + await expect(linkAppIssue(prepared)).rejects.toBeInstanceOf(ApiError); + expect(writes()).toHaveLength(1); + }); + + test("reports a different returned target without retrying or deleting it", async () => { + const prepared = await resolveAppIssueLink(OPTIONS); + actionLink = { + ...LINK, + webUrl: "https://linear.app/example/issue/ENG-99/other", + }; + await expect(linkAppIssue(prepared)).rejects.toThrow( + "different issue after linking" + ); + expect(writes()).toHaveLength(1); + }); + + test("requires an installation in the requested organization", async () => { + installation = { ...INSTALLATION, organization: { slug: "other-org" } }; + await expect(resolveAppIssueLink(OPTIONS)).rejects.toThrow( + "not installed in this organization" + ); + expect(writes()).toHaveLength(0); + }); + + test("finds the installed app on later SDK cursor pages", async () => { + const defaultFetch = globalThis.fetch; + globalThis.fetch = mockFetch(async (input, init) => { + const request = new Request(input, init); + const parsed = new globalThis.URL(request.url); + if (!parsed.pathname.endsWith("/sentry-app-installations/")) { + return defaultFetch(input, init); + } + calls.push(request); + if (parsed.searchParams.get("cursor") === "install-page-2") { + return json([INSTALLATION]); + } + return json([], 200, { + Link: '; rel="next"; results="true"; cursor="install-page-2"', + }); + }); + const prepared = await resolveAppIssueLink(OPTIONS); + expect(prepared.installationUuid).toBe(INSTALLATION.uuid); + const pages = calls.filter((request) => + request.url.includes("/sentry-app-installations/?") + ); + expect(pages).toHaveLength(1); + expect(pages[0]?.url).toBe( + "https://sentry.io/api/0/organizations/example-org/sentry-app-installations/?cursor=install-page-2" + ); + expect(writes()).toHaveLength(0); + }); + + test("propagates SDK installation errors without attempting the callback", async () => { + const defaultFetch = globalThis.fetch; + globalThis.fetch = mockFetch(async (input, init) => { + const request = new Request(input, init); + if (!request.url.includes("/sentry-app-installations/")) { + return defaultFetch(input, init); + } + calls.push(request); + return json({ detail: "Installation access denied" }, 403); + }); + await expect(resolveAppIssueLink(OPTIONS)).rejects.toBeInstanceOf(ApiError); + expect(writes()).toHaveLength(0); + }); + + test("rejects unsupported app link forms instead of using direct registration", async () => { + form = undefined; + await expect(resolveAppIssueLink(OPTIONS)).rejects.toThrow( + "does not expose" + ); + expect(writes()).toHaveLength(0); + }); + + test("does not allow user fields to override the action URI or target", async () => { + await expect( + resolveAppIssueLink({ ...OPTIONS, fields: { uri: "/create" } }) + ).rejects.toThrow("reserved app link field"); + await expect( + resolveAppIssueLink({ ...OPTIONS, fields: { issueId: "different-uuid" } }) + ).rejects.toThrow("conflicts"); + expect(writes()).toHaveLength(0); + }); + + test("resolves dependent choices using validated field values", async () => { + form = { + ...FORM, + required_fields: [ + { ...FORM.required_fields[0], depends_on: ["team"] }, + { + name: "team", + type: "select", + choices: [["team-uuid", "Engineering"]], + }, + ], + }; + const prepared = await resolveAppIssueLink({ + ...OPTIONS, + fields: { team: "Engineering" }, + }); + expect(prepared.fields).toEqual({ + team: "team-uuid", + issueId: "linear-uuid", + }); + const search = calls.find((request) => + request.url.includes("external-requests") + ); + expect( + new globalThis.URL(search?.url ?? "").searchParams.get("dependentData") + ).toBe('{"team":"team-uuid"}'); + }); + + test("reports required fields rather than sending a partial form", async () => { + form = { + ...FORM, + required_fields: [ + ...FORM.required_fields, + { name: "team", type: "text" }, + ], + }; + await expect(resolveAppIssueLink(OPTIONS)).rejects.toThrow( + "--field team=VALUE" + ); + expect(writes()).toHaveLength(0); + }); + + test("accepts numeric labels in the app's static select options", async () => { + form = { + ...FORM, + required_fields: [ + ...FORM.required_fields, + { name: "team", type: "select", options: [["team-uuid", 42]] }, + ], + }; + const prepared = await resolveAppIssueLink({ + ...OPTIONS, + fields: { team: "42" }, + }); + expect(prepared.fields).toEqual({ + team: "team-uuid", + issueId: "linear-uuid", + }); + expect(writes()).toHaveLength(0); + }); +}); + +describe("list and unlink app associations", () => { + test("follows cursor pages and never uses a pagination URL as a request target", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const request = new Request(input, init); + calls.push(request); + if (new globalThis.URL(request.url).searchParams.has("cursor")) { + return json([{ ...LINK, id: "100", serviceType: "another-app" }]); + } + return json([LINK], 200, { + Link: '; rel="next"; results="true"; cursor="next-page"', + }); + }); + expect(await listAppIssueLinks(ORG, ISSUE)).toHaveLength(2); + expect(calls[1]?.url).toContain( + "https://de.sentry.io/api/0/organizations/example-org/issues/123/external-issues/?cursor=next-page" + ); + }); + + test("fails on cursor loops instead of returning incomplete links", async () => { + globalThis.fetch = mockFetch(async () => + json([LINK], 200, { + Link: '; rel="next"; results="true"; cursor="same"', + }) + ); + await expect(listAppIssueLinks(ORG, ISSUE)).rejects.toThrow( + "repeated a cursor" + ); + }); + + test("deletes the group association ID in its region without an app schema", async () => { + await unlinkAppIssueLink(ORG, ISSUE, LINK.id); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe( + "https://de.sentry.io/api/0/organizations/example-org/issues/123/external-issues/99/" + ); + expect(calls[0]?.method).toBe("DELETE"); + }); + + test("rejects relative path segments before issuing an unlink", async () => { + await expect(unlinkAppIssueLink(ORG, ISSUE, "..")).rejects.toThrow( + ValidationError + ); + await expect(unlinkAppIssueLink(ORG, "..", LINK.id)).rejects.toThrow( + ValidationError + ); + expect(calls).toHaveLength(0); + }); + + test("propagates unlink permissions without falling back to installation registration", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + calls.push(new Request(input, init)); + return json({ detail: "Requires event:admin" }, 403); + }); + await expect( + unlinkAppIssueLink(ORG, ISSUE, LINK.id) + ).rejects.toBeInstanceOf(ApiError); + expect(calls).toHaveLength(1); + }); + + test("matches generic URLs and refuses ambiguity across apps", () => { + const link = { + ...LINK, + webUrl: "https://tracker.example/issues/42/", + serviceType: "custom", + }; + expect(findAppIssueLink([link], "https://tracker.example/issues/42")).toBe( + link + ); + expect(() => + findAppIssueLink( + [link, { ...link, serviceType: "other" }], + "https://tracker.example/issues/42" + ) + ).toThrow(ValidationError); + expect( + findAppIssueLink([link], "https://tracker.example/issues/42", "other") + ).toBeUndefined(); + }); + + test("keeps query and fragment identifiers distinct for generic apps", () => { + const link = { + ...LINK, + serviceType: "custom", + webUrl: "https://tracker.example/view?id=1#issue/42", + }; + expect(findAppIssueLink([link], link.webUrl, "custom")).toBe(link); + expect( + findAppIssueLink( + [link], + "https://tracker.example/view?id=2#issue/42", + "custom" + ) + ).toBeUndefined(); + expect( + findAppIssueLink( + [link], + "https://tracker.example/view?id=1#issue/43", + "custom" + ) + ).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/api/issue-integrations.test.ts b/packages/cli/test/lib/api/issue-integrations.test.ts new file mode 100644 index 0000000000..3b9901ec69 --- /dev/null +++ b/packages/cli/test/lib/api/issue-integrations.test.ts @@ -0,0 +1,480 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + findNativeIssueLink, + linkNativeIssue, + listNativeIssueLinks, + type NativeIssueLink, + resolveNativeIssueLink, + unlinkNativeIssueLink, +} from "../../../src/lib/api/issue-integrations.js"; +import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { setOrgRegion } from "../../../src/lib/db/regions.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; + +const REGION = "https://eu.sentry.io"; +const INTEGRATIONS = "/api/0/organizations/test-org/issues/42/integrations/"; +const REPOSITORIES = "/api/0/organizations/test-org/repos/"; +const SOURCE = { orgSlug: "test-org", issueId: "42" }; +const JIRA_URL = "https://tracker.example.com/browse/PROJ-7"; +const LINK: NativeIssueLink = { + id: "1234", + integrationId: "10", + provider: "jira", + key: "PROJ-7", + url: JIRA_URL, + displayName: "PROJ-7", + title: "Example issue", +}; + +function integration( + provider = "jira", + domainName = "tracker.example.com", + id = "10", + externalIssues: NativeIssueLink[] = [] +) { + return { + id, + name: `Example ${provider}`, + domainName, + provider: { key: provider }, + status: "active", + externalIssues, + }; +} + +function json(data: unknown, headers?: HeadersInit): Response { + return Response.json(data, { status: 200, headers }); +} + +function mockApi( + respond: (request: Request) => Response | Promise +): Request[] { + const requests: Request[] = []; + globalThis.fetch = mockFetch(async (input, init) => { + const request = new Request(input, init); + requests.push(request); + return respond(request); + }); + return requests; +} + +describe("native tracker issue links", () => { + useTestConfigDir("native-issue-links-"); + let originalFetch: typeof fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + setAuthToken("test-token", 3600, "test-refresh"); + setOrgRegion(SOURCE.orgSlug, REGION); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test.each([ + { + provider: "jira", + domain: "tracker.example.com", + url: `${JIRA_URL.toLowerCase()}/?source=cli#details`, + canonical: JIRA_URL, + body: { externalIssue: "PROJ-7" }, + }, + { + provider: "jira_server", + domain: "tracker.example.com", + url: "https://tracker.example.com/jira/browse/PROJ-7", + canonical: "https://tracker.example.com/jira/browse/PROJ-7", + body: { externalIssue: "PROJ-7" }, + }, + { + provider: "gitlab", + domain: "gitlab.example.com/group/subgroup", + url: "https://gitlab.example.com/group/subgroup/project/-/issues/7", + canonical: "https://gitlab.example.com/group/subgroup/project/-/issues/7", + body: { externalIssue: "group/subgroup/project#7" }, + }, + { + provider: "bitbucket", + domain: "bitbucket.org/workspace", + url: "https://bitbucket.org/workspace/repo/issues/7/a-title", + canonical: "https://bitbucket.org/workspace/repo/issues/7", + body: { repo: "workspace/repo", externalIssue: "7" }, + }, + { + provider: "bitbucket", + domain: "username", + url: "https://bitbucket.org/username/commits/issues/7", + canonical: "https://bitbucket.org/username/commits/issues/7", + body: { repo: "username/commits", externalIssue: "7" }, + }, + { + provider: "vsts", + domain: "https://example.visualstudio.com", + url: "https://dev.azure.com/example/project/_workitems/edit/7", + canonical: "https://example.visualstudio.com/_workitems/edit/7", + body: { externalIssue: "7" }, + }, + { + provider: "vsts", + domain: "https://dev.azure.com/example", + url: "https://example.visualstudio.com/project/_workitems/edit/7", + canonical: "https://dev.azure.com/example/_workitems/edit/7", + body: { externalIssue: "7" }, + }, + ])("prepares $provider without creating or commenting", async (fixture) => { + const requests = mockApi((request) => { + expect(new URL(request.url).pathname).toBe(INTEGRATIONS); + expect(new URL(request.url).origin).toBe(REGION); + expect(request.cache).toBe("no-store"); + expect(request.method).toBe("GET"); + return json([integration(fixture.provider, fixture.domain)]); + }); + + const prepared = await resolveNativeIssueLink({ + ...SOURCE, + url: fixture.url, + }); + + expect(prepared).toMatchObject({ + ...SOURCE, + regionUrl: REGION, + integrationId: "10", + provider: fixture.provider, + url: fixture.canonical, + body: fixture.body, + }); + expect(prepared.existing).toBeUndefined(); + expect(requests).toHaveLength(1); + }); + + test.each([ + { provider: "github", host: "github.com" }, + { provider: "github_enterprise", host: "github.example.com" }, + ])("matches the registered $provider installation and spelling", async ({ + provider, + host, + }) => { + const requests = mockApi((request) => { + const { pathname } = new URL(request.url); + expect(request.method).toBe("GET"); + if (pathname === INTEGRATIONS) { + return json([ + integration(provider, `${host}/owner`, "10"), + integration(provider, `${host}/owner`, "20"), + ]); + } + expect(pathname).toBe(REPOSITORIES); + return json([ + { name: "Owner/Repo", integrationId: "20", status: "active" }, + ]); + }); + + const prepared = await resolveNativeIssueLink({ + ...SOURCE, + url: `https://${host}/OWNER/repo/issues/7`, + }); + + expect(prepared).toMatchObject({ + integrationId: "20", + body: { repo: "Owner/Repo", externalIssue: "7" }, + url: `https://${host}/Owner/Repo/issues/7`, + }); + expect(requests).toHaveLength(2); + }); + + test("does not select GitHub repositories absent from the installation", async () => { + mockApi((request) => + json( + new URL(request.url).pathname === INTEGRATIONS + ? [integration("github", "github.com/owner")] + : [{ name: "owner/repo", integrationId: "20", status: "active" }] + ) + ); + await expect( + resolveNativeIssueLink({ + ...SOURCE, + url: "https://github.com/owner/repo/issues/7", + }) + ).rejects.toThrow("No installed native"); + }); + + test("preserves repository pagination and provider body fields through the SDK", async () => { + const githubLink = { + ...LINK, + provider: "github", + key: "owner/repo#7", + url: "https://github.com/owner/repo/issues/7", + }; + const requests = mockApi(async (request) => { + const url = new URL(request.url); + expect(url.origin).toBe(REGION); + expect(request.cache).toBe("no-store"); + if (request.method === "PUT") { + expect(url.pathname).toBe(`${INTEGRATIONS}10/`); + expect(await request.json()).toEqual({ + repo: "owner/repo", + externalIssue: "7", + }); + return json(githubLink); + } + expect(request.method).toBe("GET"); + if (url.pathname === INTEGRATIONS) { + return json([integration("github", "github.com/owner")]); + } + expect(url.pathname).toBe(REPOSITORIES); + expect(url.searchParams.get("per_page")).toBe("100"); + if (!url.searchParams.has("cursor")) { + return json([], { + Link: '; rel="next"; results="true"; cursor="second"', + }); + } + expect(url.searchParams.get("cursor")).toBe("second"); + return json([ + { name: "owner/repo", integrationId: "10", status: "active" }, + ]); + }); + + const prepared = await resolveNativeIssueLink({ + ...SOURCE, + url: githubLink.url, + }); + expect(await linkNativeIssue(prepared)).toEqual({ + link: githubLink, + changed: true, + }); + expect(requests).toHaveLength(4); + }); + + test("fetches all integration pages before deciding the link is absent", async () => { + const requests = mockApi((request) => { + const url = new URL(request.url); + expect(url.pathname).toBe(INTEGRATIONS); + expect(url.searchParams.get("per_page")).toBe("100"); + if (!url.searchParams.has("cursor")) { + return json([integration("jira", "other.example.com")], { + Link: '; rel="next"; results="true"; cursor="second"', + }); + } + expect(url.searchParams.get("cursor")).toBe("second"); + return json([ + integration("jira", "tracker.example.com", "20", [ + { ...LINK, integrationId: "20" }, + ]), + ]); + }); + + const prepared = await resolveNativeIssueLink({ ...SOURCE, url: JIRA_URL }); + expect(prepared.existing?.id).toBe(LINK.id); + expect(prepared.integrationId).toBe("20"); + expect(requests).toHaveLength(2); + }); + + test("fresh duplicate preflight prevents PUT", async () => { + const requests = mockApi((request) => { + expect(request.method).toBe("GET"); + return json([integration("jira", "tracker.example.com", "10", [LINK])]); + }); + + const prepared = await resolveNativeIssueLink({ ...SOURCE, url: JIRA_URL }); + expect(await linkNativeIssue(prepared)).toEqual({ + link: LINK, + changed: false, + }); + expect(requests).toHaveLength(1); + }); + + test("PUT sends the existing key without a comment and reads bypass stale cache", async () => { + const requests = mockApi(async (request) => { + expect(new URL(request.url).origin).toBe(REGION); + expect(request.cache).toBe("no-store"); + if (request.method === "PUT") { + expect(new URL(request.url).pathname).toBe(`${INTEGRATIONS}10/`); + expect(await request.json()).toEqual({ externalIssue: "PROJ-7" }); + return json({ ...LINK, id: 1234, integrationId: 10 }); + } + expect(request.method).toBe("GET"); + return json([integration()]); + }); + + await listNativeIssueLinks(SOURCE.orgSlug, SOURCE.issueId); + const prepared = await resolveNativeIssueLink({ ...SOURCE, url: JIRA_URL }); + expect(await linkNativeIssue(prepared)).toEqual({ + link: LINK, + changed: true, + }); + expect(requests.map((request) => request.method)).toEqual([ + "GET", + "GET", + "PUT", + ]); + }); + + test.each([ + "PUT", + "DELETE", + ])("does not retry failed %s mutations", async (method) => { + const requests = mockApi((request) => { + if (request.method === "GET") { + return json([integration()]); + } + expect(request.method).toBe(method); + return new Response(JSON.stringify({ detail: "Temporary error" }), { + status: 503, + }); + }); + const prepared = await resolveNativeIssueLink({ ...SOURCE, url: JIRA_URL }); + const mutation = + method === "PUT" + ? linkNativeIssue(prepared) + : unlinkNativeIssueLink(SOURCE.orgSlug, SOURCE.issueId, LINK); + await expect(mutation).rejects.toThrow(); + expect( + requests.filter((request) => request.method === method) + ).toHaveLength(1); + }); + + test("DELETE uses Sentry's ExternalIssue ID and accepts an empty 204 response", async () => { + const requests = mockApi((request) => { + const url = new URL(request.url); + expect(url.origin).toBe(REGION); + expect(request.cache).toBe("no-store"); + expect(request.method).toBe("DELETE"); + expect(url.pathname).toBe(`${INTEGRATIONS}10/`); + expect(url.searchParams.get("externalIssue")).toBe("1234"); + return new Response(null, { status: 204 }); + }); + await unlinkNativeIssueLink(SOURCE.orgSlug, SOURCE.issueId, LINK); + expect(requests).toHaveLength(1); + }); + + test("rejects an unlink ID that the SDK numeric query cannot represent exactly", async () => { + const requests = mockApi(() => new Response(null, { status: 204 })); + await expect( + unlinkNativeIssueLink(SOURCE.orgSlug, SOURCE.issueId, { + ...LINK, + id: "9007199254740993", + }) + ).rejects.toThrow("safe positive integer"); + expect(requests).toHaveLength(0); + }); + + test("requires integration selection when multiple Jira installations match", async () => { + mockApi(() => + json([integration(), integration("jira", "tracker.example.com", "20")]) + ); + await expect( + resolveNativeIssueLink({ ...SOURCE, url: JIRA_URL }) + ).rejects.toThrow("--integration"); + const prepared = await resolveNativeIssueLink({ + ...SOURCE, + url: JIRA_URL, + integrationId: "20", + }); + expect(prepared.integrationId).toBe("20"); + }); + + test.each([ + { + provider: "gitlab", + domain: "gitlab.example.com/team", + url: "https://gitlab.example.com/another/repo/issues/7", + }, + { + provider: "jira", + domain: "https://tracker.example.com/jira", + url: JIRA_URL, + }, + { + provider: "vsts", + domain: "https://dev.azure.com/example", + url: "https://dev.azure.com/another/project/_workitems/edit/7", + }, + { + provider: "bitbucket", + domain: "bitbucket.org/team", + url: "https://bitbucket.org/another/repo/issues/7", + }, + ])("rejects a URL outside the $provider installation", async ({ + provider, + domain, + url, + }) => { + mockApi(() => json([integration(provider, domain)])); + await expect(resolveNativeIssueLink({ ...SOURCE, url })).rejects.toThrow( + "No installed native" + ); + }); + + test.each([ + "https://github.com/owner/repo/pull/7", + "https://gitlab.com/owner/repo/-/merge_requests/7", + "https://github.com/owner/repo/commit/abcdef", + "https://username:secret@tracker.example.com/browse/PROJ-7", + "javascript:alert(1)", + "PROJ-7", + ])("rejects unsupported input before API calls: %s", async (url) => { + const requests = mockApi(() => json([])); + await expect(resolveNativeIssueLink({ ...SOURCE, url })).rejects.toThrow(); + expect(requests).toHaveLength(0); + }); +}); + +describe("findNativeIssueLink", () => { + test.each([ + { + provider: "jira", + existing: JIRA_URL, + target: `${JIRA_URL.toLowerCase()}/?source=cli#details`, + }, + { + provider: "gitlab", + existing: "https://gitlab.com/group/repo/issues/7", + target: "https://gitlab.com/group/repo/-/issues/7", + }, + { + provider: "github", + existing: "https://github.com/owner/repo/issues/7", + target: "https://github.com/OWNER/Repo/issues/7/", + }, + { + provider: "bitbucket", + existing: "https://bitbucket.org/owner/repo/issues/7/a-title", + target: "https://bitbucket.org/owner/repo/issues/7", + }, + { + provider: "vsts", + existing: "https://example.visualstudio.com/_workitems/edit/7", + target: "https://dev.azure.com/example/project/_workitems/edit/7", + }, + ])("matches $provider using stored metadata alone", ({ + provider, + existing, + target, + }) => { + const link = { ...LINK, provider, url: existing }; + expect(findNativeIssueLink([link], target)).toBe(link); + }); + + test("rejects ambiguous links and accepts an integration selector", () => { + const second = { ...LINK, id: "5678", integrationId: "20" }; + expect(() => findNativeIssueLink([LINK, second], JIRA_URL)).toThrow( + "--integration" + ); + expect(findNativeIssueLink([LINK, second], JIRA_URL, "20")).toBe(second); + }); + + test("never equates invalid URLs just because both lack a parsed issue key", () => { + const link = { + ...LINK, + provider: "github", + url: "https://github.com/owner/repo/pull/7", + }; + expect( + findNativeIssueLink([link], "https://github.com/owner/repo/pull/8") + ).toBeUndefined(); + expect( + findNativeIssueLink([LINK], "https://other.example.com/browse/PROJ-7") + ).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/issue-links.test.ts b/packages/cli/test/lib/issue-links.test.ts new file mode 100644 index 0000000000..0985422631 --- /dev/null +++ b/packages/cli/test/lib/issue-links.test.ts @@ -0,0 +1,245 @@ +/** Exercise provider routing, dry runs and association-only mutation outcomes. */ + +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { + findAppIssueLink, + linkAppIssue, + listAppIssueLinks, + resolveAppIssueLink, + unlinkAppIssueLink, +} from "../../src/lib/api/issue-app-links.js"; +import { + findNativeIssueLink, + linkNativeIssue, + listNativeIssueLinks, + resolveNativeIssueLink, + unlinkNativeIssueLink, +} from "../../src/lib/api/issue-integrations.js"; +import { ApiError } from "../../src/lib/errors.js"; +import { formatIssueLinkResult } from "../../src/lib/formatters/issue-links.js"; +import { + linkExternalIssue, + unlinkExternalIssue, +} from "../../src/lib/issue-links.js"; +import { invalidateCachedResponsesMatching } from "../../src/lib/response-cache.js"; + +vi.mock("../../src/lib/api/issue-app-links.js"); +vi.mock("../../src/lib/api/issue-integrations.js"); +vi.mock("../../src/lib/response-cache.js"); +vi.mock("../../src/lib/region.js", () => ({ + resolveOrgRegion: vi.fn().mockResolvedValue("https://de.sentry.io"), +})); +vi.mock("../../src/lib/sentry-client.js", () => ({ + getApiBaseUrl: () => "https://sentry.io", +})); + +const nativeLink = { + id: "810", + integrationId: "20", + provider: "github", + key: "example/app#42", + displayName: "example/app#42", + url: "https://github.com/example/app/issues/42", +}; +const appLink = { + id: "910", + issueId: "123", + serviceType: "linear", + displayName: "APP-42", + webUrl: "https://linear.app/example/issue/APP-42/fix-error", +}; +const options = { + orgSlug: "example", + issueId: "123", + url: nativeLink.url, +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(resolveNativeIssueLink).mockResolvedValue({ + ...options, + regionUrl: "https://de.sentry.io", + integrationId: nativeLink.integrationId, + provider: nativeLink.provider, + key: nativeLink.key, + body: { repo: "example/app", externalIssue: "42" }, + }); + vi.mocked(linkNativeIssue).mockResolvedValue({ + link: nativeLink, + changed: true, + }); + vi.mocked(listNativeIssueLinks).mockResolvedValue([nativeLink]); + vi.mocked(findNativeIssueLink).mockReturnValue(nativeLink); + vi.mocked(resolveAppIssueLink).mockResolvedValue({ + ...options, + url: appLink.webUrl, + appSlug: "linear", + displayName: appLink.displayName, + installationUuid: "installation", + uri: "/link", + fields: { issueId: "remote-uuid" }, + }); + vi.mocked(linkAppIssue).mockResolvedValue({ link: appLink, changed: true }); + vi.mocked(listAppIssueLinks).mockResolvedValue([appLink]); + vi.mocked(findAppIssueLink).mockReturnValue(appLink); +}); + +describe("external issue associations", () => { + test("native link returns the internal association ID and invalidates issue views", async () => { + const result = await linkExternalIssue(options); + expect(result).toMatchObject({ + action: "link", + changed: true, + linked: true, + externalIssue: { id: "810", identifier: "example/app#42" }, + }); + expect(resolveAppIssueLink).not.toHaveBeenCalled(); + expect(invalidateCachedResponsesMatching).toHaveBeenCalledWith( + "https://de.sentry.io/api/0/organizations/example/issues/123/" + ); + expect(invalidateCachedResponsesMatching).toHaveBeenCalledWith( + "https://sentry.io/api/0/issues/123/" + ); + }); + + test("Linear routes through the app workflow with project context", async () => { + const appOptions = { ...options, url: appLink.webUrl, projectId: "456" }; + const result = await linkExternalIssue(appOptions); + expect(resolveAppIssueLink).toHaveBeenCalledWith(appOptions); + expect(linkNativeIssue).not.toHaveBeenCalled(); + expect(result.externalIssue).toEqual({ + id: appLink.id, + identifier: "APP-42", + url: appLink.webUrl, + provider: "linear", + }); + }); + + test("an explicitly selected app accepts a non-Linear resource URL", async () => { + await linkExternalIssue({ ...options, appSlug: "custom-tracker" }); + expect(resolveAppIssueLink).toHaveBeenCalledWith( + expect.objectContaining({ + appSlug: "custom-tracker", + url: nativeLink.url, + }) + ); + expect(resolveNativeIssueLink).not.toHaveBeenCalled(); + }); + + test.each([ + nativeLink.url, + appLink.webUrl, + ])("dry-run link submits no mutation: %s", async (url) => { + const result = await linkExternalIssue({ ...options, url, dryRun: true }); + expect(result).toMatchObject({ + linked: false, + changed: false, + dryRun: true, + }); + expect(linkNativeIssue).not.toHaveBeenCalled(); + expect(linkAppIssue).not.toHaveBeenCalled(); + expect(invalidateCachedResponsesMatching).not.toHaveBeenCalled(); + expect(formatIssueLinkResult(result)).toContain("Would link"); + }); + + test("already-linked is a successful no-op, with no cache mutation", async () => { + vi.mocked(linkNativeIssue).mockResolvedValue({ + link: nativeLink, + changed: false, + }); + const result = await linkExternalIssue(options); + expect(result).toMatchObject({ linked: true, changed: false }); + expect(formatIssueLinkResult(result)).toContain("Already linked"); + expect(invalidateCachedResponsesMatching).not.toHaveBeenCalled(); + }); + + test("unlink selects a stored native association, without resolving the remote issue", async () => { + const result = await unlinkExternalIssue(options); + expect(unlinkNativeIssueLink).toHaveBeenCalledWith( + "example", + "123", + nativeLink + ); + expect(resolveNativeIssueLink).not.toHaveBeenCalled(); + expect(resolveAppIssueLink).not.toHaveBeenCalled(); + expect(result).toMatchObject({ linked: false, changed: true }); + expect(formatIssueLinkResult(result)).toContain( + "external issue was not deleted" + ); + }); + + test("unlink uses the stored app record ID, without invoking a link workflow", async () => { + const result = await unlinkExternalIssue({ + ...options, + url: appLink.webUrl, + }); + expect(unlinkAppIssueLink).toHaveBeenCalledWith("example", "123", "910"); + expect(resolveAppIssueLink).not.toHaveBeenCalled(); + expect(linkAppIssue).not.toHaveBeenCalled(); + expect(result).toMatchObject({ linked: false, changed: true }); + }); + + test.each([ + nativeLink.url, + appLink.webUrl, + ])("dry-run unlink preserves the link: %s", async (url) => { + const result = await unlinkExternalIssue({ ...options, url, dryRun: true }); + expect(result).toMatchObject({ + linked: true, + changed: false, + dryRun: true, + }); + expect(unlinkNativeIssueLink).not.toHaveBeenCalled(); + expect(unlinkAppIssueLink).not.toHaveBeenCalled(); + expect(formatIssueLinkResult(result)).toContain("Would unlink"); + }); + + test.each([ + nativeLink.url, + appLink.webUrl, + ])("missing association is already unlinked: %s", async (url) => { + vi.mocked(findNativeIssueLink).mockReturnValue(undefined); + vi.mocked(findAppIssueLink).mockReturnValue(undefined); + const result = await unlinkExternalIssue({ ...options, url }); + expect(result).toMatchObject({ linked: false, changed: false }); + expect(unlinkNativeIssueLink).not.toHaveBeenCalled(); + expect(unlinkAppIssueLink).not.toHaveBeenCalled(); + }); + + test("a failed link read is not treated as an empty list", async () => { + const error = new ApiError("Forbidden", 403); + vi.mocked(listNativeIssueLinks).mockRejectedValue(error); + await expect(unlinkExternalIssue(options)).rejects.toBe(error); + expect(unlinkNativeIssueLink).not.toHaveBeenCalled(); + }); + + test("a failed write propagates without claiming success or falling back to another provider", async () => { + const error = new ApiError("Forbidden", 403); + vi.mocked(linkNativeIssue).mockRejectedValue(error); + await expect(linkExternalIssue(options)).rejects.toBe(error); + expect(linkNativeIssue).toHaveBeenCalledTimes(1); + expect(linkAppIssue).not.toHaveBeenCalled(); + expect(invalidateCachedResponsesMatching).not.toHaveBeenCalled(); + }); + + test.each([ + "not-a-url", + "file:///tmp/issue", + "https://user:secret@example.com/issue/42", + ])("invalid targets fail before API calls: %s", async (url) => { + await expect(linkExternalIssue({ ...options, url })).rejects.toThrow(); + expect(resolveNativeIssueLink).not.toHaveBeenCalled(); + expect(resolveAppIssueLink).not.toHaveBeenCalled(); + }); + + test("conflicting native and app selectors fail before API calls", async () => { + await expect( + linkExternalIssue({ + ...options, + appSlug: "linear", + integrationId: "20", + }) + ).rejects.toThrow("--integration"); + expect(resolveAppIssueLink).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/test/lib/oauth.test.ts b/packages/cli/test/lib/oauth.test.ts index 4d32db281f..2e8fbc1a9a 100644 --- a/packages/cli/test/lib/oauth.test.ts +++ b/packages/cli/test/lib/oauth.test.ts @@ -27,6 +27,12 @@ describe("resolveOAuthScopeString", () => { expect(OAUTH_SCOPES).toContain("team:admin"); }); + test("default scopes allow linking and unlinking external issues", () => { + const scopes = resolveOAuthScopeString().split(" "); + expect(scopes).toContain("event:write"); + expect(scopes).toContain("event:admin"); + }); + test("default (no selection) returns the full OAUTH_SCOPES set", () => { expect(resolveOAuthScopeString()).toBe(OAUTH_SCOPES.join(" ")); expect(resolveOAuthScopeString({})).toBe(OAUTH_SCOPES.join(" ")); diff --git a/packages/cli/test/lib/scope-recovery.test.ts b/packages/cli/test/lib/scope-recovery.test.ts index 11ff67e1ef..53b193534a 100644 --- a/packages/cli/test/lib/scope-recovery.test.ts +++ b/packages/cli/test/lib/scope-recovery.test.ts @@ -48,12 +48,44 @@ describe("runWithScopeRecovery", () => { expect(proceed).toHaveBeenCalledTimes(2); expect(testRuntime.getAuthScopes).toHaveBeenCalledOnce(); expect(testRuntime.assertTrustedHost).toHaveBeenCalledOnce(); - expect(login).toHaveBeenCalledWith(); + expect(login).toHaveBeenCalledWith({ scope: OAUTH_SCOPES.join(" ") }); expect(testRuntime.write).toHaveBeenCalledWith( expect.stringContaining("team:admin") ); }); + test("preserves custom grants when recovering issue unlink authorization", async () => { + const error = new ApiError("Forbidden", 403); + const proceed = vi + .fn<(argv: string[]) => Promise>() + .mockRejectedValueOnce(error) + .mockResolvedValueOnce(); + const login = vi.fn().mockResolvedValue({ method: "oauth" }); + const testRuntime = runtime({ + getAuthScopes: vi + .fn() + .mockResolvedValue([ + ...OAUTH_SCOPES.filter((scope) => scope !== "event:admin"), + "org:write", + ]), + }); + + await runWithScopeRecovery( + proceed, + ["issue", "unlink"], + login, + testRuntime + ); + + expect(login).toHaveBeenCalledWith({ + scope: [...OAUTH_SCOPES, "org:write"].join(" "), + }); + expect(proceed).toHaveBeenCalledTimes(2); + expect(testRuntime.write).toHaveBeenCalledWith( + expect.stringContaining("missing event:admin") + ); + }); + test("does not re-authorize a role or policy 403 when the token has every scope", async () => { const error = new ApiError("Forbidden", 403); const proceed = vi.fn().mockRejectedValue(error); @@ -155,38 +187,52 @@ describe("runWithScopeRecovery", () => { test("checks scopes but does not launch OAuth without an interactive TTY", async () => { const error = new ApiError("Forbidden", 403); - const getAuthScopes = vi.fn().mockResolvedValue([]); + const getAuthScopes = vi + .fn() + .mockResolvedValue([ + ...OAUTH_SCOPES.filter((scope) => scope !== "event:admin"), + "org:write", + ]); const login = vi.fn(); + const testRuntime = runtime({ getAuthScopes, inputIsTty: () => false }); await expect( runWithScopeRecovery( vi.fn().mockRejectedValue(error), [], login, - runtime({ getAuthScopes, inputIsTty: () => false }) + testRuntime ) ).rejects.toBe(error); expect(getAuthScopes).toHaveBeenCalledOnce(); expect(login).not.toHaveBeenCalled(); + expect(testRuntime.write).toHaveBeenCalledWith( + "Your CLI authorization is missing event:admin.\n" + + `Re-authenticate with: sentry auth refresh ${[...OAUTH_SCOPES, "org:write"].map((scope) => `--scope ${scope}`).join(" ")}\n` + ); }); test("does not launch OAuth when JSON output disables prompts", async () => { const error = new ApiError("Forbidden", 403); const getAuthScopes = vi.fn().mockResolvedValue([]); const login = vi.fn(); + const testRuntime = runtime({ getAuthScopes, promptsAllowed: () => false }); await expect( runWithScopeRecovery( vi.fn().mockRejectedValue(error), ["--json"], login, - runtime({ getAuthScopes, promptsAllowed: () => false }) + testRuntime ) ).rejects.toBe(error); expect(getAuthScopes).toHaveBeenCalledOnce(); expect(login).not.toHaveBeenCalled(); + expect(testRuntime.write).toHaveBeenCalledWith( + expect.stringContaining("sentry auth refresh --scope project:read") + ); }); test("preserves the original error when scope inspection fails", async () => { diff --git a/packages/cli/test/lib/sdk-positionals.test.ts b/packages/cli/test/lib/sdk-positionals.test.ts index 4b48c4767f..c3073d95bb 100644 --- a/packages/cli/test/lib/sdk-positionals.test.ts +++ b/packages/cli/test/lib/sdk-positionals.test.ts @@ -13,15 +13,15 @@ import { describe, expect, test } from "vitest"; import { createSDKMethods } from "../../src/sdk.generated.js"; -type RecordedCall = { path: string[]; positional: string[] }; +type RecordedCall = { path: string[]; flags: unknown; positional: string[] }; function createRecordingSDK(): { calls: RecordedCall[]; sdk: ReturnType; } { const calls: RecordedCall[] = []; - const invoke = ((path: string[], _flags: unknown, positional: string[]) => { - calls.push({ path, positional }); + const invoke = ((path: string[], flags: unknown, positional: string[]) => { + calls.push({ path, flags, positional }); return Promise.resolve(undefined); }) as Parameters[0]; @@ -29,6 +29,20 @@ function createRecordingSDK(): { } describe("generated SDK positional arguments", () => { + test("issue link forwards repeated form fields as an array", async () => { + const { calls, sdk } = createRecordingSDK(); + await sdk.issue.link({ + issue: "example/APP-42", + externalIssue: "https://linear.app/example/issue/APP-42/title", + field: ["team=engineering", "label=bug"], + }); + expect(calls[0]).toMatchObject({ + path: ["issue", "link"], + positional: ["example/APP-42"], + flags: { field: ["team=engineering", "label=bug"] }, + }); + }); + test("release deploy passes version, environment and name as separate tokens", async () => { const { calls, sdk } = createRecordingSDK(); diff --git a/packages/cli/test/lib/sentry-client.test.ts b/packages/cli/test/lib/sentry-client.test.ts index 2caaf9e330..281245f0a8 100644 --- a/packages/cli/test/lib/sentry-client.test.ts +++ b/packages/cli/test/lib/sentry-client.test.ts @@ -6,6 +6,10 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { setAuthToken } from "../../src/lib/db/auth.js"; import { TimeoutError } from "../../src/lib/errors.js"; +import { + getCachedResponse, + storeCachedResponse, +} from "../../src/lib/response-cache.js"; import { __injectTimeoutOverrideForTests, __resolveRequestTimeoutMsForTests, @@ -35,6 +39,91 @@ function getAuthenticatedFetch(): typeof fetch { return getSdkConfig(REGION_URL).fetch as typeof fetch; } +describe("per-request transport controls", () => { + test("retry false sends a mutation once for transient HTTP and network failures", async () => { + let callCount = 0; + globalThis.fetch = mockFetch(async () => { + callCount += 1; + return new Response("provider unavailable", { status: 503 }); + }); + const fetchOnce = getSdkConfig(REGION_URL, { retry: false }).fetch; + expect( + (await fetchOnce(`${REGION_URL}/api/0/issue-link/`, { method: "POST" })) + .status + ).toBe(503); + expect(callCount).toBe(1); + + const networkError = new TypeError( + "connection reset after request body sent" + ); + globalThis.fetch = mockFetch(async () => { + callCount += 1; + throw networkError; + }); + await expect( + fetchOnce(`${REGION_URL}/api/0/issue-link/`, { method: "POST" }) + ).rejects.toBe(networkError); + expect(callCount).toBe(2); + }); + + test("retry false returns an unauthorized mutation without replaying it", async () => { + let callCount = 0; + globalThis.fetch = mockFetch(async () => { + callCount += 1; + return new Response("unauthorized", { status: 401 }); + }); + const fetchOnce = getSdkConfig(REGION_URL, { retry: false }).fetch; + const response = await fetchOnce(`${REGION_URL}/api/0/issue-link/`, { + method: "POST", + }); + expect(response.status).toBe(401); + expect(callCount).toBe(1); + }); + + test("no-store preflight bypasses both cache lookup and cache storage", async () => { + const url = `${REGION_URL}/api/0/organizations/example/issues/1/external-issues/`; + const headers = { Authorization: "Bearer test-token" }; + await storeCachedResponse( + "GET", + url, + headers, + Response.json( + { source: "cached" }, + { + headers: { + "Cache-Control": "max-age=600", + Date: new Date().toUTCString(), + }, + } + ) + ); + expect( + await (await getCachedResponse("GET", url, headers))?.json() + ).toEqual({ source: "cached" }); + let callCount = 0; + globalThis.fetch = mockFetch(async (_input, init) => { + callCount += 1; + expect(init?.cache).toBe("no-store"); + return Response.json( + { source: "fresh" }, + { + headers: { + "Cache-Control": "max-age=600", + Date: new Date().toUTCString(), + }, + } + ); + }); + const freshFetch = getSdkConfig(REGION_URL, { cache: "no-store" }).fetch; + expect(await (await freshFetch(url)).json()).toEqual({ source: "fresh" }); + expect(await (await freshFetch(url)).json()).toEqual({ source: "fresh" }); + expect(callCount).toBe(2); + expect( + await (await getCachedResponse("GET", url, headers))?.json() + ).toEqual({ source: "cached" }); + }); +}); + describe("fetchWithRetry / buildAttemptFactory", () => { test("retries a POST with a string body without re-consuming the body", async () => { const marker = "__test_string_body__"; From 0b24c9064b27846d929f7757814ef3737f12229b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 8 Sep 2026 20:09:18 +0200 Subject: [PATCH 2/5] fix(issue): validate external link API responses --- packages/cli/src/lib/api/infrastructure.ts | 3 +- packages/cli/src/lib/api/issue-app-links.ts | 15 +-- .../cli/src/lib/api/issue-integrations.ts | 79 ++++++++++-- packages/cli/test/lib/api-client.test.ts | 19 +++ .../test/lib/api/issue-integrations.test.ts | 122 ++++++++++++++++-- 5 files changed, 205 insertions(+), 33 deletions(-) diff --git a/packages/cli/src/lib/api/infrastructure.ts b/packages/cli/src/lib/api/infrastructure.ts index 26b336aa09..2230bf5e80 100644 --- a/packages/cli/src/lib/api/infrastructure.ts +++ b/packages/cli/src/lib/api/infrastructure.ts @@ -21,7 +21,6 @@ import { logger } from "../logger.js"; import { resolveOrgRegion } from "../region.js"; import { getApiBaseUrl, - getDefaultSdkConfig, getSdkConfig, type SentryRequestOptions, } from "../sentry-client.js"; @@ -764,7 +763,7 @@ export async function rawApiRequest( }> { const { method = "GET", body, params, headers: customHeaders = {} } = options; - const config = getDefaultSdkConfig(); + const config = getSdkConfig(getApiBaseUrl(), options); const searchParams = buildSearchParams(params); const normalizedEndpoint = endpoint.startsWith("/") diff --git a/packages/cli/src/lib/api/issue-app-links.ts b/packages/cli/src/lib/api/issue-app-links.ts index 4508bc75ce..d59b10721b 100644 --- a/packages/cli/src/lib/api/issue-app-links.ts +++ b/packages/cli/src/lib/api/issue-app-links.ts @@ -185,6 +185,7 @@ export function findAppIssueLink( return matches[0]; } +/** Fetch a complete, validated collection; partial results cannot safely authorize a link mutation. */ async function listAll( fetchPage: ( cursor: string | undefined @@ -268,6 +269,7 @@ export async function listAppIssueLinks( ); } +/** Preserve the app's single association per Sentry issue; replacing a target requires explicit unlink. */ function checkExisting( links: AppIssueLink[], url: string, @@ -368,13 +370,14 @@ async function getLinkForm( item.type === "issue-link" && item.sentryApp.uuid === installation.app.uuid ); - if (matches.length !== 1 || !matches[0]?.schema.link) { + const component = matches[0]; + const form = component?.schema.link; + if (matches.length !== 1 || !component || !form) { throw new ValidationError( `App ${installation.app.slug} does not expose an unambiguous issue-link form`, "app" ); } - const component = matches[0]; if (component.error) { throw new ApiError( `App ${installation.app.slug} could not prepare its issue-link form`, @@ -382,10 +385,6 @@ async function getLinkForm( JSON.stringify(component.error) ); } - const form = component.schema.link; - if (!form) { - throw new ValidationError("App has no link form", "app"); - } validateUri(form.uri); return form; } @@ -434,9 +433,6 @@ async function getChoices({ schema: ChoicesResponseSchema, } ); - if (!Array.isArray(data.choices)) { - throw new ApiError("App search returned an invalid choices response", 0); - } return data.choices; } @@ -467,6 +463,7 @@ function selectChoice( return choice[0]; } +/** Resolve form dependencies while keeping the target field bound to the requested issue URL. */ async function resolveFields( options: ResolveAppIssueLinkOptions, form: LinkForm, diff --git a/packages/cli/src/lib/api/issue-integrations.ts b/packages/cli/src/lib/api/issue-integrations.ts index 80846ecd0b..7440ad8bf1 100644 --- a/packages/cli/src/lib/api/issue-integrations.ts +++ b/packages/cli/src/lib/api/issue-integrations.ts @@ -2,14 +2,27 @@ import { deleteOrganizationIssueIntegration, type ExternalIssueLinkResponse, - type IntegrationIssueConfigResponse, type LinkExternalIssueRequest, type ListOrganizationReposResponse, listOrganizationRepos, updateOrganizationIssueIntegration, } from "@sentry/api"; - -import { ValidationError } from "../errors.js"; +import { + vExternalIssueLinkResponse, + vIntegrationIssueConfigResponse, + vListOrganizationReposResponse, +} from "@sentry/api/valibot"; +import { + array, + type InferOutput, + nullish, + object, + optional, + pick, + safeParse, + string, +} from "valibot"; +import { ApiError, ValidationError } from "../errors.js"; import { resolveOrgRegion } from "../region.js"; import { getSdkConfig } from "../sentry-client.js"; import { @@ -37,12 +50,30 @@ export type NativeIssueLink = Pick< title?: string; }; -type NativeIntegration = Pick< - IntegrationIssueConfigResponse, - "id" | "name" | "domainName" | "status" | "provider" -> & { - externalIssues: Omit[]; -}; +// The private list serializes link IDs as strings; the SDK mutation uses numbers. +const NativeIntegrationSchema = object({ + ...pick(vIntegrationIssueConfigResponse, [ + "id", + "name", + "domainName", + "status", + "provider", + ]).entries, + externalIssues: array( + object({ + ...pick(vExternalIssueLinkResponse, ["key", "url", "displayName"]) + .entries, + id: string(), + title: nullish(string()), + }) + ), +}); +const NativeIntegrationsSchema = array(NativeIntegrationSchema); +const NativeIssueMutationSchema = object({ + ...vExternalIssueLinkResponse.entries, + title: optional(string()), +}); +type NativeIntegration = InferOutput; /** Read-only resolution result used for previews and a subsequent link mutation. */ export type PreparedNativeIssueLink = { @@ -233,6 +264,11 @@ function parseTarget( } } +/** + * Retrieve every page before choosing an integration or checking existing links. + * Partial results could miss a duplicate or hide an ambiguous match, so reaching + * the safety limit must fail instead of returning the partial list from autoPaginate. + */ async function listFreshPages( fetchPage: (cursor?: string) => Promise> ): Promise { @@ -264,6 +300,7 @@ async function listIntegrations( { params: { cursor, per_page: API_MAX_PER_PAGE }, cache: "no-store", + schema: NativeIntegrationsSchema, } ); return { @@ -287,10 +324,18 @@ async function listFreshRepositories( path: { organization_id_or_slug: orgSlug }, query, }); - return unwrapPaginatedResult( + const page = unwrapPaginatedResult( result, "Failed to list repositories" ); + const parsed = safeParse(vListOrganizationReposResponse, page.data); + if (!parsed.success) { + throw new ApiError( + "Unexpected response format when listing repositories", + 0 + ); + } + return { ...page, data: parsed.output }; }); } @@ -299,6 +344,7 @@ function flattenLinks(integrations: NativeIntegration[]): NativeIssueLink[] { integration.externalIssues.map((link) => ({ ...link, id: String(link.id), + title: link.title ?? undefined, integrationId: integration.id, provider: integration.provider.key, url: @@ -479,10 +525,17 @@ export async function linkNativeIssue( }, body: prepared.body, }); - const data = unwrapResult( - result, - "Failed to link external issue" + const parsed = safeParse( + NativeIssueMutationSchema, + unwrapResult(result, "Failed to link external issue") ); + if (!parsed.success) { + throw new ApiError( + "Unexpected response format after linking; inspect the current links before retrying", + 0 + ); + } + const data = parsed.output; return { link: { ...data, diff --git a/packages/cli/test/lib/api-client.test.ts b/packages/cli/test/lib/api-client.test.ts index a50479197c..dba264bce2 100644 --- a/packages/cli/test/lib/api-client.test.ts +++ b/packages/cli/test/lib/api-client.test.ts @@ -326,6 +326,25 @@ describe("buildSearchParams", () => { }); describe("rawApiRequest", () => { + test("honors per-request retry and cache controls", async () => { + const requests: Request[] = []; + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push(new Request(input, init)); + return Response.json({ detail: "Service unavailable" }, { status: 503 }); + }; + + const result = await rawApiRequest("test/", { + method: "POST", + body: { action: "link" }, + retry: false, + cache: "no-store", + }); + + expect(result.status).toBe(503); + expect(requests).toHaveLength(1); + expect(requests[0]?.cache).toBe("no-store"); + }); + test("sends GET request without body", async () => { const requests: Request[] = []; diff --git a/packages/cli/test/lib/api/issue-integrations.test.ts b/packages/cli/test/lib/api/issue-integrations.test.ts index 3b9901ec69..ec5d609f26 100644 --- a/packages/cli/test/lib/api/issue-integrations.test.ts +++ b/packages/cli/test/lib/api/issue-integrations.test.ts @@ -9,6 +9,7 @@ import { } from "../../../src/lib/api/issue-integrations.js"; import { setAuthToken } from "../../../src/lib/db/auth.js"; import { setOrgRegion } from "../../../src/lib/db/regions.js"; +import { ApiError } from "../../../src/lib/errors.js"; import { mockFetch, useTestConfigDir } from "../../helpers.js"; const REGION = "https://eu.sentry.io"; @@ -36,12 +37,30 @@ function integration( id, name: `Example ${provider}`, domainName, - provider: { key: provider }, + provider: { + key: provider, + slug: provider, + name: `Example ${provider}`, + canAdd: true, + canDisable: false, + features: ["issue-basic"], + aspects: {}, + }, status: "active", externalIssues, }; } +function repository(name: string, integrationId: string) { + return { + id: "100", + name, + integrationId, + status: "active", + dateCreated: "2026-01-01T00:00:00Z", + }; +} + function json(data: unknown, headers?: HeadersInit): Response { return Response.json(data, { status: 200, headers }); } @@ -165,9 +184,7 @@ describe("native tracker issue links", () => { ]); } expect(pathname).toBe(REPOSITORIES); - return json([ - { name: "Owner/Repo", integrationId: "20", status: "active" }, - ]); + return json([repository("Owner/Repo", "20")]); }); const prepared = await resolveNativeIssueLink({ @@ -188,7 +205,7 @@ describe("native tracker issue links", () => { json( new URL(request.url).pathname === INTEGRATIONS ? [integration("github", "github.com/owner")] - : [{ name: "owner/repo", integrationId: "20", status: "active" }] + : [repository("owner/repo", "20")] ) ); await expect( @@ -199,6 +216,48 @@ describe("native tracker issue links", () => { ).rejects.toThrow("No installed native"); }); + test.each([ + { name: "non-array page", data: {} }, + { + name: "missing link array", + data: [{ ...integration(), externalIssues: undefined }], + }, + { + name: "invalid link record", + data: [{ ...integration(), externalIssues: [{}] }], + }, + ])("rejects an invalid integration response: $name", async ({ data }) => { + const requests = mockApi(() => json(data)); + await expect( + resolveNativeIssueLink({ ...SOURCE, url: JIRA_URL }).then(linkNativeIssue) + ).rejects.toBeInstanceOf(ApiError); + expect(requests.map((request) => request.method)).toEqual(["GET"]); + }); + + test.each([ + { name: "empty 204", response: () => new Response(null, { status: 204 }) }, + { name: "non-array page", response: () => json({}) }, + { + name: "invalid repository", + response: () => json([{ ...repository("owner/repo", "10"), name: 42 }]), + }, + ])("rejects an invalid SDK repository response: $name", async ({ + response, + }) => { + const requests = mockApi((request) => + new URL(request.url).pathname === INTEGRATIONS + ? json([integration("github", "github.com/owner")]) + : response() + ); + await expect( + resolveNativeIssueLink({ + ...SOURCE, + url: "https://github.com/owner/repo/issues/7", + }).then(linkNativeIssue) + ).rejects.toBeInstanceOf(ApiError); + expect(requests.map((request) => request.method)).toEqual(["GET", "GET"]); + }); + test("preserves repository pagination and provider body fields through the SDK", async () => { const githubLink = { ...LINK, @@ -216,7 +275,7 @@ describe("native tracker issue links", () => { repo: "owner/repo", externalIssue: "7", }); - return json(githubLink); + return json({ ...githubLink, id: 1234, integrationId: 10 }); } expect(request.method).toBe("GET"); if (url.pathname === INTEGRATIONS) { @@ -230,9 +289,7 @@ describe("native tracker issue links", () => { }); } expect(url.searchParams.get("cursor")).toBe("second"); - return json([ - { name: "owner/repo", integrationId: "10", status: "active" }, - ]); + return json([repository("owner/repo", "10")]); }); const prepared = await resolveNativeIssueLink({ @@ -310,6 +367,25 @@ describe("native tracker issue links", () => { ]); }); + test.each([ + { name: "empty 204", response: () => new Response(null, { status: 204 }) }, + { name: "empty object", response: () => json({}) }, + { name: "invalid numeric IDs", response: () => json(LINK) }, + ])("does not report success for an invalid SDK mutation response: $name", async ({ + response, + }) => { + const requests = mockApi((request) => + request.method === "GET" ? json([integration()]) : response() + ); + const prepared = await resolveNativeIssueLink({ ...SOURCE, url: JIRA_URL }); + const mutation = linkNativeIssue(prepared); + await expect(mutation).rejects.toBeInstanceOf(ApiError); + await expect(mutation).rejects.toThrow( + "inspect the current links before retrying" + ); + expect(requests.map((request) => request.method)).toEqual(["GET", "PUT"]); + }); + test.each([ "PUT", "DELETE", @@ -348,6 +424,34 @@ describe("native tracker issue links", () => { expect(requests).toHaveLength(1); }); + test("lists and unlinks an existing issue whose title is null", async () => { + const requests = mockApi((request) => { + if (request.method === "GET") { + return json([ + { ...integration(), externalIssues: [{ ...LINK, title: null }] }, + ]); + } + expect(request.method).toBe("DELETE"); + expect(new URL(request.url).searchParams.get("externalIssue")).toBe( + "1234" + ); + return new Response(null, { status: 204 }); + }); + const link = findNativeIssueLink( + await listNativeIssueLinks(SOURCE.orgSlug, SOURCE.issueId), + JIRA_URL + ); + expect(link).toEqual({ ...LINK, title: undefined }); + if (!link) { + throw new Error("Expected the existing external issue link"); + } + await unlinkNativeIssueLink(SOURCE.orgSlug, SOURCE.issueId, link); + expect(requests.map((request) => request.method)).toEqual([ + "GET", + "DELETE", + ]); + }); + test("rejects an unlink ID that the SDK numeric query cannot represent exactly", async () => { const requests = mockApi(() => new Response(null, { status: 204 })); await expect( From 578a09b068719554b8cc3ce2641b156252916ddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 8 Sep 2026 20:58:43 +0200 Subject: [PATCH 3/5] fix(issue): link and unlink GitHub pull request references --- apps/cli-docs/src/fragments/commands/issue.md | 14 +- .../skills/sentry-cli/references/issue.md | 6 +- packages/cli/src/commands/issue/link-utils.ts | 2 +- packages/cli/src/commands/issue/link.ts | 3 +- packages/cli/src/commands/issue/unlink.ts | 3 +- .../cli/src/lib/api/issue-integrations.ts | 34 ++-- .../test/lib/api/issue-integrations.test.ts | 151 +++++++++++++++++- 7 files changed, 188 insertions(+), 25 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/issue.md b/apps/cli-docs/src/fragments/commands/issue.md index 37974cf34b..040c81b87f 100644 --- a/apps/cli-docs/src/fragments/commands/issue.md +++ b/apps/cli-docs/src/fragments/commands/issue.md @@ -300,10 +300,11 @@ sentry issue ignore CLI-G5 --until auto ### Link an external issue -Link an existing tracker issue to a Sentry issue: +Link an existing tracker issue or GitHub pull request to a Sentry issue: ```bash sentry issue link FRONT-123 --external-issue https://github.com/example/app/issues/42 +sentry issue link FRONT-123 --external-issue https://github.com/example/app/pull/43 sentry issue link FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 sentry issue link FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error ``` @@ -325,9 +326,13 @@ write. The provider validates the remote issue when the link is submitted. An existing matching link succeeds with `changed: false`. A Sentry App that already links this issue to a different resource must be unlinked first. -This command creates an association only. It does not create a tracker issue, -resolve the Sentry issue, or link a commit or pull request. Existing integration -status-sync settings continue to apply after linking. +GitHub and GitHub Enterprise pull requests are stored as external references. +Their `/pull/NUMBER` and `/issues/NUMBER` URLs identify the same resource for +duplicate detection and unlinking. Linking a PR does not mark it as a fix or +resolve the Sentry issue. + +This command does not create a tracker issue or link a commit. Existing +integration status-sync settings continue to apply after linking. #### Link permissions @@ -343,6 +348,7 @@ Remove an association without deleting either issue: ```bash sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/issues/42 +sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/pull/43 --yes sentry issue unlink my-org/FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 --yes sentry issue unlink FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error --dry-run ``` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md index e1be9ebdef..dde651bea4 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -358,7 +358,7 @@ sentry issue merge cli-k9 cli-15h --into cli-k9 # alias form Link an existing external issue **Flags:** -- `--external-issue - URL of an existing external issue to link or unlink` +- `--external-issue - URL of an existing tracker issue or GitHub pull request` - `--integration - Native integration ID, when multiple installations match` - `--app - Sentry App slug (automatically detected for Linear URLs)` - `-n, --dry-run - Show what would happen without making changes` @@ -368,6 +368,7 @@ Link an existing external issue ```bash sentry issue link FRONT-123 --external-issue https://github.com/example/app/issues/42 +sentry issue link FRONT-123 --external-issue https://github.com/example/app/pull/43 sentry issue link FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 sentry issue link FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error @@ -380,7 +381,7 @@ sentry issue link my-org/FRONT-123 --external-issue https://github.com/example/a Unlink an external issue **Flags:** -- `--external-issue - URL of an existing external issue to link or unlink` +- `--external-issue - URL of an existing tracker issue or GitHub pull request` - `--integration - Native integration ID, when multiple installations match` - `--app - Sentry App slug (automatically detected for Linear URLs)` - `-y, --yes - Skip confirmation prompt` @@ -391,6 +392,7 @@ Unlink an external issue ```bash sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/issues/42 +sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/pull/43 --yes sentry issue unlink my-org/FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 --yes sentry issue unlink FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error --dry-run ``` diff --git a/packages/cli/src/commands/issue/link-utils.ts b/packages/cli/src/commands/issue/link-utils.ts index 5287df6c6d..fd2c5b08ed 100644 --- a/packages/cli/src/commands/issue/link-utils.ts +++ b/packages/cli/src/commands/issue/link-utils.ts @@ -7,7 +7,7 @@ export const EXTERNAL_ISSUE_FLAGS = { "external-issue": { kind: "parsed", parse: String, - brief: "URL of an existing external issue to link or unlink", + brief: "URL of an existing tracker issue or GitHub pull request", }, integration: { kind: "parsed", diff --git a/packages/cli/src/commands/issue/link.ts b/packages/cli/src/commands/issue/link.ts index 02e1a8093e..37392d9ab8 100644 --- a/packages/cli/src/commands/issue/link.ts +++ b/packages/cli/src/commands/issue/link.ts @@ -22,12 +22,13 @@ export const linkCommand = buildCommand({ docs: { brief: "Link an existing external issue", fullDescription: - "Link an existing GitHub, Jira, Linear, or other supported tracker issue.\n" + + "Link an existing tracker issue or GitHub pull request as an external reference.\n" + "The integration must be installed in your Sentry organization.\n" + "This does not create a remote issue or resolve the Sentry issue.\n\n" + "Requires event:write and access to the Sentry project.\n\n" + "Examples:\n" + " sentry issue link FRONT-123 --external-issue https://github.com/example/app/issues/42\n" + + " sentry issue link FRONT-123 --external-issue https://github.com/example/app/pull/43\n" + " sentry issue link my-org/FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42\n" + " sentry issue link FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error\n" + " sentry issue link FRONT-123 --external-issue https://github.com/example/app/issues/42 --dry-run", diff --git a/packages/cli/src/commands/issue/unlink.ts b/packages/cli/src/commands/issue/unlink.ts index 7978d3cdde..b2ff78ec4b 100644 --- a/packages/cli/src/commands/issue/unlink.ts +++ b/packages/cli/src/commands/issue/unlink.ts @@ -25,12 +25,13 @@ export const unlinkCommand = buildDeleteCommand({ docs: { brief: "Unlink an external issue", fullDescription: - "Remove the link between a Sentry issue and an external tracker issue.\n" + + "Remove an external tracker issue or GitHub pull request reference from a Sentry issue.\n" + "This does not delete the external issue or change the Sentry issue's status.\n\n" + "Requires event:admin and access to the Sentry project.\n" + "Your token must include event:admin even if your project role grants it.\n\n" + "Examples:\n" + " sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/issues/42\n" + + " sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/pull/43 --yes\n" + " sentry issue unlink my-org/FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 --yes\n" + " sentry issue unlink FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error --dry-run", }, diff --git a/packages/cli/src/lib/api/issue-integrations.ts b/packages/cli/src/lib/api/issue-integrations.ts index 7440ad8bf1..434358e1f4 100644 --- a/packages/cli/src/lib/api/issue-integrations.ts +++ b/packages/cli/src/lib/api/issue-integrations.ts @@ -101,6 +101,7 @@ type ParsedTarget = Pick; const TRAILING_SLASH = /\/+$/; const REPOSITORY_ISSUE = /^\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/[^/]+)?$/; +const GITHUB_PULL_REQUEST = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/[^/]+)?$/; const GITLAB_ISSUE = /^\/(.+?)(?:\/-)?\/issues\/(\d+)$/; const JIRA_ISSUE = /^(.*?)\/browse\/([A-Z][A-Z0-9_]*-\d+)$/i; const WORK_ITEM = /^(.*?)\/_workitems\/edit\/(\d+)$/; @@ -147,15 +148,22 @@ function integrationUrl(integration: NativeIntegration): URL | undefined { return parseUrl(domain.includes("://") ? domain : `https://${domain}`); } -function parseRepositoryIssue(url: URL): ParsedTarget | undefined { - const match = REPOSITORY_ISSUE.exec(url.pathname); +function parseRepositoryIssue( + url: URL, + provider: string +): ParsedTarget | undefined { + // GitHub exposes PRs through its issue API; both URL forms share repo#number. + const pullRequest = ["github", "github_enterprise"].includes(provider) + ? GITHUB_PULL_REQUEST.exec(url.pathname) + : null; + const match = pullRequest ?? REPOSITORY_ISSUE.exec(url.pathname); if (!(match?.[1] && match[2])) { return; } const repo = match[1]; const number = match[2]; return { - url: `${url.origin}/${repo}/issues/${number}`, + url: `${url.origin}/${repo}/${pullRequest ? "pull" : "issues"}/${number}`, key: `${repo}#${number}`, body: { repo, externalIssue: number }, }; @@ -242,7 +250,7 @@ function parseTarget( return; } if (["github", "github_enterprise", "bitbucket"].includes(provider)) { - const target = parseRepositoryIssue(url); + const target = parseRepositoryIssue(url, provider); const account = domain.pathname.split("/").find(Boolean); if ( account && @@ -388,9 +396,11 @@ function matchesNativeUrl(link: NativeIssueLink, target: URL): boolean { ); } if (["github", "github_enterprise", "bitbucket"].includes(link.provider)) { - const key = parseRepositoryIssue(target)?.key.toLowerCase(); + // Sentry's list response can reconstruct /issues/N for a linked /pull/N. + const key = parseRepositoryIssue(target, link.provider)?.key.toLowerCase(); return Boolean( - key && key === parseRepositoryIssue(existing)?.key.toLowerCase() + key && + key === parseRepositoryIssue(existing, link.provider)?.key.toLowerCase() ); } if (["jira", "jira_server"].includes(link.provider)) { @@ -428,9 +438,12 @@ export async function resolveNativeIssueLink(options: { integrationId?: string; }): Promise { const url = parseUrl(options.url); - if (SCM_CHANGE.test(url.pathname)) { + if ( + SCM_CHANGE.test(url.pathname) && + !GITHUB_PULL_REQUEST.test(url.pathname) + ) { throw new ValidationError( - "External issue linking accepts tracker issues, not commits or pull requests." + "External issue linking supports tracker issues and GitHub pull requests." ); } const integrations = await listIntegrations(options.orgSlug, options.issueId); @@ -471,7 +484,10 @@ export async function resolveNativeIssueLink(options: { ...target, body: { ...target.body, repo: repository.name }, key: `${repository.name}#${target.body.externalIssue}`, - url: `${url.origin}/${repository.name}/issues/${target.body.externalIssue}`, + url: target.url.replace( + `/${target.body.repo}/`, + `/${repository.name}/` + ), }, }, ]; diff --git a/packages/cli/test/lib/api/issue-integrations.test.ts b/packages/cli/test/lib/api/issue-integrations.test.ts index ec5d609f26..8096b237ce 100644 --- a/packages/cli/test/lib/api/issue-integrations.test.ts +++ b/packages/cli/test/lib/api/issue-integrations.test.ts @@ -10,6 +10,10 @@ import { import { setAuthToken } from "../../../src/lib/db/auth.js"; import { setOrgRegion } from "../../../src/lib/db/regions.js"; import { ApiError } from "../../../src/lib/errors.js"; +import { + linkExternalIssue, + unlinkExternalIssue, +} from "../../../src/lib/issue-links.js"; import { mockFetch, useTestConfigDir } from "../../helpers.js"; const REGION = "https://eu.sentry.io"; @@ -168,11 +172,18 @@ describe("native tracker issue links", () => { }); test.each([ - { provider: "github", host: "github.com" }, - { provider: "github_enterprise", host: "github.example.com" }, - ])("matches the registered $provider installation and spelling", async ({ + { provider: "github", host: "github.com", path: "issues" }, + { + provider: "github_enterprise", + host: "github.example.com", + path: "issues", + }, + { provider: "github", host: "github.com", path: "pull" }, + { provider: "github_enterprise", host: "github.example.com", path: "pull" }, + ])("prepares $provider /$path with the registered repository spelling", async ({ provider, host, + path, }) => { const requests = mockApi((request) => { const { pathname } = new URL(request.url); @@ -189,17 +200,125 @@ describe("native tracker issue links", () => { const prepared = await resolveNativeIssueLink({ ...SOURCE, - url: `https://${host}/OWNER/repo/issues/7`, + url: `https://${host}/OWNER/repo/${path}/7`, }); expect(prepared).toMatchObject({ integrationId: "20", body: { repo: "Owner/Repo", externalIssue: "7" }, - url: `https://${host}/Owner/Repo/issues/7`, + url: `https://${host}/Owner/Repo/${path}/7`, }); expect(requests).toHaveLength(2); }); + test.each([ + { provider: "github", host: "github.com" }, + { provider: "github_enterprise", host: "github.example.com" }, + ])("links, recognizes and unlinks a $provider PR across both URL forms", async ({ + provider, + host, + }) => { + const pullUrl = `https://${host}/Owner/Repo/pull/7`; + const issueUrl = `https://${host}/Owner/Repo/issues/7`; + const storedLink = { + ...LINK, + provider, + key: "Owner/Repo#7", + displayName: "Owner/Repo#7", + url: issueUrl, + }; + let linked = false; + const requests = mockApi(async (request) => { + const url = new URL(request.url); + expect(url.origin).toBe(REGION); + if (request.method === "PUT") { + expect(url.pathname).toBe(`${INTEGRATIONS}10/`); + expect(await request.json()).toEqual({ + repo: "Owner/Repo", + externalIssue: "7", + }); + linked = true; + // The mutation uses GitHub's html_url; listing reconstructs /issues/N. + return json({ + ...storedLink, + id: 1234, + integrationId: 10, + url: pullUrl, + }); + } + if (request.method === "DELETE") { + expect(url.pathname).toBe(`${INTEGRATIONS}10/`); + expect(url.searchParams.get("externalIssue")).toBe("1234"); + linked = false; + return new Response(null, { status: 204 }); + } + expect(request.method).toBe("GET"); + if (url.pathname === INTEGRATIONS) { + return json([ + integration( + provider, + `${host}/owner`, + "10", + linked ? [storedLink] : [] + ), + ]); + } + expect(url.pathname).toBe(REPOSITORIES); + return json([repository("Owner/Repo", "10")]); + }); + + const options = { + ...SOURCE, + url: `https://${host}/OWNER/repo/pull/7/files?source=cli#diff`, + }; + expect(await linkExternalIssue(options)).toMatchObject({ + linked: true, + changed: true, + externalIssue: { id: "1234", identifier: "Owner/Repo#7", url: pullUrl }, + }); + for (const url of [pullUrl, issueUrl]) { + expect(await linkExternalIssue({ ...SOURCE, url })).toMatchObject({ + linked: true, + changed: false, + externalIssue: { id: "1234" }, + }); + } + expect( + await unlinkExternalIssue({ ...options, dryRun: true }) + ).toMatchObject({ + linked: true, + changed: false, + dryRun: true, + }); + expect(await unlinkExternalIssue(options)).toMatchObject({ + linked: false, + changed: true, + externalIssue: { id: "1234" }, + }); + expect(await unlinkExternalIssue(options)).toMatchObject({ + linked: false, + changed: false, + }); + expect( + requests + .filter((request) => request.method !== "GET") + .map((request) => request.method) + ).toEqual(["PUT", "DELETE"]); + }); + + test("does not treat a Bitbucket pull request as an issue", async () => { + const requests = mockApi(() => + json([integration("bitbucket", "bitbucket.org/owner")]) + ); + await expect( + resolveNativeIssueLink({ + ...SOURCE, + url: "https://bitbucket.org/owner/repo/pull/7", + }) + ).rejects.toThrow("No installed native"); + expect(requests.every((request) => request.method === "GET")).toBe(true); + }); + test("does not select GitHub repositories absent from the installation", async () => { mockApi((request) => json( @@ -511,7 +630,8 @@ describe("native tracker issue links", () => { }); test.each([ - "https://github.com/owner/repo/pull/7", + "https://github.com/owner/repo/pulls/7", + "https://bitbucket.org/owner/repo/pull-requests/7", "https://gitlab.com/owner/repo/-/merge_requests/7", "https://github.com/owner/repo/commit/abcdef", "https://username:secret@tracker.example.com/browse/PROJ-7", @@ -568,7 +688,7 @@ describe("findNativeIssueLink", () => { expect(findNativeIssueLink([LINK, second], JIRA_URL, "20")).toBe(second); }); - test("never equates invalid URLs just because both lack a parsed issue key", () => { + test("distinguishes GitHub PR numbers, repositories and hosts", () => { const link = { ...LINK, provider: "github", @@ -577,6 +697,23 @@ describe("findNativeIssueLink", () => { expect( findNativeIssueLink([link], "https://github.com/owner/repo/pull/8") ).toBeUndefined(); + expect( + findNativeIssueLink([link], "https://github.com/owner/other/pull/7") + ).toBeUndefined(); + expect( + findNativeIssueLink([link], "https://other.example.com/owner/repo/pull/7") + ).toBeUndefined(); + }); + + test("never equates invalid URLs just because both lack a parsed issue key", () => { + const link = { + ...LINK, + provider: "github", + url: "https://github.com/owner/repo/commit/abc", + }; + expect( + findNativeIssueLink([link], "https://github.com/owner/repo/commit/def") + ).toBeUndefined(); expect( findNativeIssueLink([LINK], "https://other.example.com/browse/PROJ-7") ).toBeUndefined(); From c5fe6b18c6aa6cfc9c22a9b93dc979b77737ea5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Wed, 9 Sep 2026 13:36:21 +0200 Subject: [PATCH 4/5] fix(issue): accept external link URLs as positionals --- apps/cli-docs/src/fragments/commands/issue.md | 20 +++++------ .../sentry-cli/skills/sentry-cli/SKILL.md | 4 +-- .../skills/sentry-cli/references/issue.md | 26 +++++++------- packages/cli/src/commands/issue/link-utils.ts | 19 ++++++++--- packages/cli/src/commands/issue/link.ts | 32 ++++++++++------- packages/cli/src/commands/issue/unlink.ts | 29 ++++++++++------ packages/cli/src/lib/issue-links.ts | 4 +-- .../cli/test/commands/issue/link.func.test.ts | 29 +++++++++++----- .../test/commands/issue/unlink.func.test.ts | 34 +++++++++++++------ .../lib/scanner-flags.integration.test.ts | 34 ++++++++++++++++++- packages/cli/test/lib/sdk-positionals.test.ts | 23 +++++++++++-- 11 files changed, 175 insertions(+), 79 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/issue.md b/apps/cli-docs/src/fragments/commands/issue.md index 040c81b87f..68305c4199 100644 --- a/apps/cli-docs/src/fragments/commands/issue.md +++ b/apps/cli-docs/src/fragments/commands/issue.md @@ -303,10 +303,10 @@ sentry issue ignore CLI-G5 --until auto Link an existing tracker issue or GitHub pull request to a Sentry issue: ```bash -sentry issue link FRONT-123 --external-issue https://github.com/example/app/issues/42 -sentry issue link FRONT-123 --external-issue https://github.com/example/app/pull/43 -sentry issue link FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 -sentry issue link FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error +sentry issue link FRONT-123 https://github.com/example/app/issues/42 +sentry issue link FRONT-123 https://github.com/example/app/pull/43 +sentry issue link FRONT-123 https://example.atlassian.net/browse/APP-42 +sentry issue link FRONT-123 https://linear.app/example/issue/APP-42/fix-error ``` The matching integration must already be installed in the Sentry organization. @@ -317,8 +317,8 @@ Other Sentry Apps require `--app ` and must expose an issue-link form; additional required form values can be supplied with `--field name=value`. ```bash -sentry issue link my-org/FRONT-123 --external-issue https://github.com/example/app/issues/42 --dry-run -sentry issue link my-org/FRONT-123 --external-issue https://github.com/example/app/issues/42 --json +sentry issue link my-org/FRONT-123 https://github.com/example/app/issues/42 --dry-run +sentry issue link my-org/FRONT-123 https://github.com/example/app/issues/42 --json ``` `--dry-run` discovers the integration and prepares the link without submitting a @@ -347,10 +347,10 @@ error. Environment tokens must be updated separately. Remove an association without deleting either issue: ```bash -sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/issues/42 -sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/pull/43 --yes -sentry issue unlink my-org/FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 --yes -sentry issue unlink FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error --dry-run +sentry issue unlink FRONT-123 https://github.com/example/app/issues/42 +sentry issue unlink FRONT-123 https://github.com/example/app/pull/43 --yes +sentry issue unlink my-org/FRONT-123 https://example.atlassian.net/browse/APP-42 --yes +sentry issue unlink FRONT-123 https://linear.app/example/issue/APP-42/fix-error --dry-run ``` Use `--yes` for non-interactive execution. `--dry-run` shows whether the link diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index cd4e3d6954..9987792ed0 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -382,8 +382,8 @@ Manage Sentry issues - `sentry issue unresolve ` — Reopen a resolved issue - `sentry issue archive ` — Archive (ignore) an issue - `sentry issue merge ` — Merge 2+ issues into a single canonical group -- `sentry issue link ` — Link an existing external issue -- `sentry issue unlink ` — Unlink an external issue +- `sentry issue link ` — Link an existing external issue +- `sentry issue unlink ` — Unlink an external issue → Full flags and examples: `references/issue.md` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md index dde651bea4..ef22a643bb 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -353,12 +353,11 @@ sentry issue merge cli-k9 cli-15h --into cli-k9 # alias form # Non-error issue types (performance, info, etc.) cannot be merged ``` -### `sentry issue link ` +### `sentry issue link ` Link an existing external issue **Flags:** -- `--external-issue - URL of an existing tracker issue or GitHub pull request` - `--integration - Native integration ID, when multiple installations match` - `--app - Sentry App slug (automatically detected for Linear URLs)` - `-n, --dry-run - Show what would happen without making changes` @@ -367,21 +366,20 @@ Link an existing external issue **Examples:** ```bash -sentry issue link FRONT-123 --external-issue https://github.com/example/app/issues/42 -sentry issue link FRONT-123 --external-issue https://github.com/example/app/pull/43 -sentry issue link FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 -sentry issue link FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error +sentry issue link FRONT-123 https://github.com/example/app/issues/42 +sentry issue link FRONT-123 https://github.com/example/app/pull/43 +sentry issue link FRONT-123 https://example.atlassian.net/browse/APP-42 +sentry issue link FRONT-123 https://linear.app/example/issue/APP-42/fix-error -sentry issue link my-org/FRONT-123 --external-issue https://github.com/example/app/issues/42 --dry-run -sentry issue link my-org/FRONT-123 --external-issue https://github.com/example/app/issues/42 --json +sentry issue link my-org/FRONT-123 https://github.com/example/app/issues/42 --dry-run +sentry issue link my-org/FRONT-123 https://github.com/example/app/issues/42 --json ``` -### `sentry issue unlink ` +### `sentry issue unlink ` Unlink an external issue **Flags:** -- `--external-issue - URL of an existing tracker issue or GitHub pull request` - `--integration - Native integration ID, when multiple installations match` - `--app - Sentry App slug (automatically detected for Linear URLs)` - `-y, --yes - Skip confirmation prompt` @@ -391,10 +389,10 @@ Unlink an external issue **Examples:** ```bash -sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/issues/42 -sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/pull/43 --yes -sentry issue unlink my-org/FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 --yes -sentry issue unlink FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error --dry-run +sentry issue unlink FRONT-123 https://github.com/example/app/issues/42 +sentry issue unlink FRONT-123 https://github.com/example/app/pull/43 --yes +sentry issue unlink my-org/FRONT-123 https://example.atlassian.net/browse/APP-42 --yes +sentry issue unlink FRONT-123 https://linear.app/example/issue/APP-42/fix-error --dry-run ``` All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/packages/cli/src/commands/issue/link-utils.ts b/packages/cli/src/commands/issue/link-utils.ts index fd2c5b08ed..107bc71ee0 100644 --- a/packages/cli/src/commands/issue/link-utils.ts +++ b/packages/cli/src/commands/issue/link-utils.ts @@ -1,14 +1,23 @@ /** Shared arguments for external issue association commands. */ import { ValidationError } from "../../lib/errors.js"; +import { issueIdPositional } from "./utils.js"; + +/** Required source issue and existing external resource URL for link and unlink. */ +export const EXTERNAL_ISSUE_POSITIONALS = { + kind: "tuple", + parameters: [ + ...issueIdPositional.parameters, + { + placeholder: "url", + parse: String, + brief: "URL of an existing tracker issue or GitHub pull request", + }, + ], +} as const; /** Flags identifying an existing external issue and its Sentry integration. */ export const EXTERNAL_ISSUE_FLAGS = { - "external-issue": { - kind: "parsed", - parse: String, - brief: "URL of an existing tracker issue or GitHub pull request", - }, integration: { kind: "parsed", parse: String, diff --git a/packages/cli/src/commands/issue/link.ts b/packages/cli/src/commands/issue/link.ts index 37392d9ab8..dc00f12e65 100644 --- a/packages/cli/src/commands/issue/link.ts +++ b/packages/cli/src/commands/issue/link.ts @@ -7,11 +7,14 @@ import { formatIssueLinkResult } from "../../lib/formatters/issue-links.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { linkExternalIssue } from "../../lib/issue-links.js"; import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../lib/mutate-command.js"; -import { EXTERNAL_ISSUE_FLAGS, parseIssueLinkFields } from "./link-utils.js"; -import { issueIdPositional, resolveIssue } from "./utils.js"; +import { + EXTERNAL_ISSUE_FLAGS, + EXTERNAL_ISSUE_POSITIONALS, + parseIssueLinkFields, +} from "./link-utils.js"; +import { resolveIssue } from "./utils.js"; type LinkFlags = { - readonly "external-issue": string; readonly integration?: string; readonly app?: string; readonly field?: string[]; @@ -27,15 +30,15 @@ export const linkCommand = buildCommand({ "This does not create a remote issue or resolve the Sentry issue.\n\n" + "Requires event:write and access to the Sentry project.\n\n" + "Examples:\n" + - " sentry issue link FRONT-123 --external-issue https://github.com/example/app/issues/42\n" + - " sentry issue link FRONT-123 --external-issue https://github.com/example/app/pull/43\n" + - " sentry issue link my-org/FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42\n" + - " sentry issue link FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error\n" + - " sentry issue link FRONT-123 --external-issue https://github.com/example/app/issues/42 --dry-run", + " sentry issue link FRONT-123 https://github.com/example/app/issues/42\n" + + " sentry issue link FRONT-123 https://github.com/example/app/pull/43\n" + + " sentry issue link my-org/FRONT-123 https://example.atlassian.net/browse/APP-42\n" + + " sentry issue link FRONT-123 https://linear.app/example/issue/APP-42/fix-error\n" + + " sentry issue link FRONT-123 https://github.com/example/app/issues/42 --dry-run", }, output: { human: formatIssueLinkResult }, parameters: { - positional: issueIdPositional, + positional: EXTERNAL_ISSUE_POSITIONALS, flags: { ...EXTERNAL_ISSUE_FLAGS, "dry-run": DRY_RUN_FLAG, @@ -49,7 +52,12 @@ export const linkCommand = buildCommand({ }, aliases: DRY_RUN_ALIASES, }, - async *func(this: SentryContext, flags: LinkFlags, issueArg: string) { + async *func( + this: SentryContext, + flags: LinkFlags, + issueArg: string, + url: string + ) { const fields = parseIssueLinkFields(flags.field); const { org, issue } = await resolveIssue({ issueArg, @@ -59,14 +67,14 @@ export const linkCommand = buildCommand({ if (!org) { throw new ContextError( "Organization", - "sentry issue link /ISSUE --external-issue " + "sentry issue link /ISSUE " ); } const result = await linkExternalIssue({ orgSlug: org, issueId: issue.id, projectId: issue.project?.id, - url: flags["external-issue"], + url, integrationId: flags.integration, appSlug: flags.app, fields, diff --git a/packages/cli/src/commands/issue/unlink.ts b/packages/cli/src/commands/issue/unlink.ts index b2ff78ec4b..1f3070f4b1 100644 --- a/packages/cli/src/commands/issue/unlink.ts +++ b/packages/cli/src/commands/issue/unlink.ts @@ -9,11 +9,13 @@ import { confirmByTyping, isConfirmationBypassed, } from "../../lib/mutate-command.js"; -import { EXTERNAL_ISSUE_FLAGS } from "./link-utils.js"; -import { issueIdPositional, resolveOrgAndIssueId } from "./utils.js"; +import { + EXTERNAL_ISSUE_FLAGS, + EXTERNAL_ISSUE_POSITIONALS, +} from "./link-utils.js"; +import { resolveOrgAndIssueId } from "./utils.js"; type UnlinkFlags = { - readonly "external-issue": string; readonly integration?: string; readonly app?: string; readonly "dry-run": boolean; @@ -30,17 +32,22 @@ export const unlinkCommand = buildDeleteCommand({ "Requires event:admin and access to the Sentry project.\n" + "Your token must include event:admin even if your project role grants it.\n\n" + "Examples:\n" + - " sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/issues/42\n" + - " sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/pull/43 --yes\n" + - " sentry issue unlink my-org/FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 --yes\n" + - " sentry issue unlink FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error --dry-run", + " sentry issue unlink FRONT-123 https://github.com/example/app/issues/42\n" + + " sentry issue unlink FRONT-123 https://github.com/example/app/pull/43 --yes\n" + + " sentry issue unlink my-org/FRONT-123 https://example.atlassian.net/browse/APP-42 --yes\n" + + " sentry issue unlink FRONT-123 https://linear.app/example/issue/APP-42/fix-error --dry-run", }, output: { human: formatIssueLinkResult }, parameters: { - positional: issueIdPositional, + positional: EXTERNAL_ISSUE_POSITIONALS, flags: EXTERNAL_ISSUE_FLAGS, }, - async *func(this: SentryContext, flags: UnlinkFlags, issueArg: string) { + async *func( + this: SentryContext, + flags: UnlinkFlags, + issueArg: string, + url: string + ) { const { org, issueId } = await resolveOrgAndIssueId({ issueArg, cwd: this.cwd, @@ -49,7 +56,7 @@ export const unlinkCommand = buildDeleteCommand({ if (!(flags["dry-run"] || isConfirmationBypassed(flags))) { const confirmed = await confirmByTyping( issueArg, - `Type '${issueArg}' to unlink ${flags["external-issue"]}:` + `Type '${issueArg}' to unlink ${url}:` ); if (!confirmed) { return { hint: "Cancelled." }; @@ -58,7 +65,7 @@ export const unlinkCommand = buildDeleteCommand({ const result = await unlinkExternalIssue({ orgSlug: org, issueId, - url: flags["external-issue"], + url, integrationId: flags.integration, appSlug: flags.app, dryRun: flags["dry-run"], diff --git a/packages/cli/src/lib/issue-links.ts b/packages/cli/src/lib/issue-links.ts index 8a61ac7267..f7d857c8a9 100644 --- a/packages/cli/src/lib/issue-links.ts +++ b/packages/cli/src/lib/issue-links.ts @@ -75,7 +75,7 @@ function usesSentryApp(options: ExternalIssueLinkOptions): boolean { try { url = new URL(options.url); } catch { - throw new ValidationError("--external-issue must be a complete issue URL."); + throw new ValidationError("URL must be a complete external issue URL."); } if ( !["https:", "http:"].includes(url.protocol) || @@ -83,7 +83,7 @@ function usesSentryApp(options: ExternalIssueLinkOptions): boolean { url.password ) { throw new ValidationError( - "--external-issue must be an HTTP(S) URL without embedded credentials." + "URL must use HTTP(S) without embedded credentials." ); } const app = Boolean(options.appSlug) || url.hostname === "linear.app"; diff --git a/packages/cli/test/commands/issue/link.func.test.ts b/packages/cli/test/commands/issue/link.func.test.ts index 5d2de0ccd7..72e471956e 100644 --- a/packages/cli/test/commands/issue/link.func.test.ts +++ b/packages/cli/test/commands/issue/link.func.test.ts @@ -33,7 +33,6 @@ vi.mock("../../../src/lib/issue-links.js", () => ({ const externalUrl = "https://github.com/example/app/issues/42"; const defaultFlags = { - "external-issue": externalUrl, "dry-run": false, json: false, }; @@ -92,7 +91,8 @@ describe("issue link", () => { await func.call( context, { ...defaultFlags, integration: "99" }, - "test-org/APP-42" + "test-org/APP-42", + externalUrl ); expect(resolveIssue).toHaveBeenCalledExactlyOnceWith({ @@ -123,11 +123,11 @@ describe("issue link", () => { context, { ...defaultFlags, - "external-issue": "https://tracker.example/issues/42", app: "custom-tracker", field: ["team=team-1", "query=key=value", "optional="], }, - "APP-42" + "APP-42", + "https://tracker.example/issues/42" ); expect(linkExternalIssue).toHaveBeenCalledExactlyOnceWith({ @@ -158,7 +158,8 @@ describe("issue link", () => { func.call( context, { ...defaultFlags, app: "custom-tracker", field: fields }, - "APP-42" + "APP-42", + externalUrl ) ).rejects.toBeInstanceOf(ValidationError); @@ -173,7 +174,7 @@ describe("issue link", () => { const func = await linkCommand.loader(); await expect( - func.call(context, defaultFlags, "123456789") + func.call(context, defaultFlags, "123456789", externalUrl) ).rejects.toBeInstanceOf(ContextError); expect(linkExternalIssue).not.toHaveBeenCalled(); }); @@ -187,7 +188,12 @@ describe("issue link", () => { }); const { context, output } = createMockContext(); const func = await linkCommand.loader(); - await func.call(context, { ...defaultFlags, "dry-run": true }, "APP-42"); + await func.call( + context, + { ...defaultFlags, "dry-run": true }, + "APP-42", + externalUrl + ); expect(linkExternalIssue).toHaveBeenCalledWith( expect.objectContaining({ dryRun: true }) @@ -205,7 +211,12 @@ describe("issue link", () => { vi.mocked(linkExternalIssue).mockResolvedValue(result); const { context, output } = createMockContext(); const func = await linkCommand.loader(); - await func.call(context, { ...defaultFlags, json: true }, "APP-42"); + await func.call( + context, + { ...defaultFlags, json: true }, + "APP-42", + externalUrl + ); expect(JSON.parse(output())).toEqual(result); expect(updateIssueStatus).not.toHaveBeenCalled(); @@ -218,7 +229,7 @@ describe("issue link", () => { const func = await linkCommand.loader(); await expect( - func.call(context, { ...defaultFlags, json: true }, "APP-42") + func.call(context, { ...defaultFlags, json: true }, "APP-42", externalUrl) ).rejects.toBe(error); expect(output()).toBe(""); }); diff --git a/packages/cli/test/commands/issue/unlink.func.test.ts b/packages/cli/test/commands/issue/unlink.func.test.ts index 60af635e87..430160aa18 100644 --- a/packages/cli/test/commands/issue/unlink.func.test.ts +++ b/packages/cli/test/commands/issue/unlink.func.test.ts @@ -47,7 +47,6 @@ vi.mock("../../../src/lib/mutate-command.js", async (importOriginal) => ({ const externalUrl = "https://github.com/example/app/issues/42"; const defaultFlags = { - "external-issue": externalUrl, "dry-run": false, yes: false, force: false, @@ -111,7 +110,8 @@ describe("issue unlink", () => { await func.call( context, { ...defaultFlags, ...selector, yes: true }, - "test-org/APP-42" + "test-org/APP-42", + externalUrl ); expect(resolveOrgAndIssueId).toHaveBeenCalledExactlyOnceWith({ @@ -136,9 +136,9 @@ describe("issue unlink", () => { const { context, output } = createMockContext(); const func = await unlinkCommand.loader(); - await expect(func.call(context, defaultFlags, "APP-42")).rejects.toThrow( - "Use --yes or --force to confirm." - ); + await expect( + func.call(context, defaultFlags, "APP-42", externalUrl) + ).rejects.toThrow("Use --yes or --force to confirm."); expect(resolveOrgAndIssueId).not.toHaveBeenCalled(); expect(confirmByTyping).not.toHaveBeenCalled(); @@ -152,7 +152,12 @@ describe("issue unlink", () => { ] as const)("allows non-interactive --%s without prompting", async (flag) => { const { context } = createMockContext(); const func = await unlinkCommand.loader(); - await func.call(context, { ...defaultFlags, [flag]: true }, "APP-42"); + await func.call( + context, + { ...defaultFlags, [flag]: true }, + "APP-42", + externalUrl + ); expect(confirmByTyping).not.toHaveBeenCalled(); expect(unlinkExternalIssue).toHaveBeenCalledExactlyOnceWith({ @@ -169,7 +174,7 @@ describe("issue unlink", () => { mockIsatty.mockReturnValue(true); const { context } = createMockContext(); const func = await unlinkCommand.loader(); - await func.call(context, defaultFlags, "test-org/APP-42"); + await func.call(context, defaultFlags, "test-org/APP-42", externalUrl); expect(confirmByTyping).toHaveBeenCalledExactlyOnceWith( "test-org/APP-42", @@ -186,7 +191,7 @@ describe("issue unlink", () => { vi.mocked(confirmByTyping).mockResolvedValue(false); const { context, output } = createMockContext(); const func = await unlinkCommand.loader(); - await func.call(context, defaultFlags, "APP-42"); + await func.call(context, defaultFlags, "APP-42", externalUrl); expect(confirmByTyping).toHaveBeenCalledOnce(); expect(unlinkExternalIssue).not.toHaveBeenCalled(); @@ -207,7 +212,8 @@ describe("issue unlink", () => { await func.call( context, { ...defaultFlags, "dry-run": true, json: true }, - "APP-42" + "APP-42", + externalUrl ); expect(confirmByTyping).not.toHaveBeenCalled(); @@ -229,7 +235,8 @@ describe("issue unlink", () => { await func.call( context, { ...defaultFlags, yes: true, json: true }, - "APP-42" + "APP-42", + externalUrl ); expect(JSON.parse(output())).toEqual(result); @@ -243,7 +250,12 @@ describe("issue unlink", () => { const func = await unlinkCommand.loader(); await expect( - func.call(context, { ...defaultFlags, yes: true, json: true }, "APP-42") + func.call( + context, + { ...defaultFlags, yes: true, json: true }, + "APP-42", + externalUrl + ) ).rejects.toBe(error); expect(output()).toBe(""); }); diff --git a/packages/cli/test/lib/scanner-flags.integration.test.ts b/packages/cli/test/lib/scanner-flags.integration.test.ts index 1c19391c17..c481434b11 100644 --- a/packages/cli/test/lib/scanner-flags.integration.test.ts +++ b/packages/cli/test/lib/scanner-flags.integration.test.ts @@ -78,7 +78,11 @@ async function runApp( }; const exitCode = await run(app, args, context); - return { stdout, stderr, exitCode: exitCode ?? 0 }; + return { + stdout, + stderr, + exitCode: Number(context.process.exitCode ?? exitCode ?? 0), + }; } /** @@ -87,6 +91,34 @@ async function runApp( */ const NO_COMMAND_REGISTERED = "No command registered"; +describe("issue link and unlink URL arguments", () => { + test.each([ + "link", + "unlink", + ])("issue %s requires the URL positional", async (command) => { + const result = await runApp(["issue", command, "APP-42"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Expected argument for url"); + }); + + test.each([ + "link", + "unlink", + ])("issue %s rejects the removed external-issue flag", async (command) => { + const url = "https://github.com/example/app/pull/123"; + const result = await runApp([ + "issue", + command, + "APP-42", + url, + "--external-issue", + url, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("external-issue"); + }); +}); + describe("top-level flags on a leaf command (bash-hook, no auth)", () => { // bash-hook runs without auth and emits its script to stdout, so a successful // route + execution is observable regardless of where the global flag sits. diff --git a/packages/cli/test/lib/sdk-positionals.test.ts b/packages/cli/test/lib/sdk-positionals.test.ts index c3073d95bb..10d6bdcdc6 100644 --- a/packages/cli/test/lib/sdk-positionals.test.ts +++ b/packages/cli/test/lib/sdk-positionals.test.ts @@ -33,14 +33,33 @@ describe("generated SDK positional arguments", () => { const { calls, sdk } = createRecordingSDK(); await sdk.issue.link({ issue: "example/APP-42", - externalIssue: "https://linear.app/example/issue/APP-42/title", + url: "https://linear.app/example/issue/APP-42/title", field: ["team=engineering", "label=bug"], }); expect(calls[0]).toMatchObject({ path: ["issue", "link"], - positional: ["example/APP-42"], + positional: [ + "example/APP-42", + "https://linear.app/example/issue/APP-42/title", + ], flags: { field: ["team=engineering", "label=bug"] }, }); + expect(calls[0]?.flags).not.toHaveProperty("external-issue"); + }); + + test("issue unlink forwards the issue and URL as separate positionals", async () => { + const { calls, sdk } = createRecordingSDK(); + await sdk.issue.unlink({ + issue: "example/APP-42", + url: "https://github.com/example/app/pull/123", + yes: true, + }); + expect(calls[0]).toMatchObject({ + path: ["issue", "unlink"], + positional: ["example/APP-42", "https://github.com/example/app/pull/123"], + flags: { yes: true }, + }); + expect(calls[0]?.flags).not.toHaveProperty("external-issue"); }); test("release deploy passes version, environment and name as separate tokens", async () => { From 262c481b91a8165c8c6367db38b954ea4b76243d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Thu, 10 Sep 2026 17:53:21 +0200 Subject: [PATCH 5/5] fix(issue): correct link resolution and unlink scopes --- apps/cli-docs/src/content/docs/self-hosted.md | 2 +- apps/cli-docs/src/fragments/commands/issue.md | 26 +++-- packages/cli/DEVELOPMENT.md | 2 +- packages/cli/src/commands/issue/link.ts | 3 +- packages/cli/src/commands/issue/unlink.ts | 4 +- packages/cli/src/lib/api/issue-app-links.ts | 10 +- .../cli/src/lib/api/issue-integrations.ts | 83 +++++++++---- packages/cli/src/lib/oauth.ts | 1 - .../cli/test/lib/api/issue-app-links.test.ts | 38 ++++++ .../test/lib/api/issue-integrations.test.ts | 110 ++++++++++++++++-- packages/cli/test/lib/oauth.test.ts | 2 +- packages/cli/test/lib/scope-recovery.test.ts | 8 +- 12 files changed, 234 insertions(+), 55 deletions(-) diff --git a/apps/cli-docs/src/content/docs/self-hosted.md b/apps/cli-docs/src/content/docs/self-hosted.md index 04f2b70416..4301ef43ea 100644 --- a/apps/cli-docs/src/content/docs/self-hosted.md +++ b/apps/cli-docs/src/content/docs/self-hosted.md @@ -56,7 +56,7 @@ If your instance is on an older version or you prefer not to create an OAuth app 1. Go to **Settings → Developer Settings → Personal Tokens** in your Sentry instance (or visit `https://sentry.example.com/settings/account/api/auth-tokens/new-token/`) 2. Create a new token with the following scopes: -`project:read`, `project:write`, `project:admin`, `org:read`, `event:read`, `event:write`, `event:admin`, `member:read`, `team:read`, `team:write`, `team:admin`, `alerts:read`, `alerts:write` +`project:read`, `project:write`, `project:admin`, `org:read`, `event:read`, `event:write`, `member:read`, `team:read`, `team:write`, `team:admin`, `alerts:read`, `alerts:write` 3. Pass it to the CLI: diff --git a/apps/cli-docs/src/fragments/commands/issue.md b/apps/cli-docs/src/fragments/commands/issue.md index 68305c4199..f38390645c 100644 --- a/apps/cli-docs/src/fragments/commands/issue.md +++ b/apps/cli-docs/src/fragments/commands/issue.md @@ -312,9 +312,14 @@ sentry issue link FRONT-123 https://linear.app/example/issue/APP-42/fix-error The matching integration must already be installed in the Sentry organization. Native integrations include GitHub, GitHub Enterprise, Jira, Jira Server, GitLab, Bitbucket, and Azure DevOps. Linear uses its installed Sentry App. +GitLab resolves the repository through the integration's repository search; +it must be visible to that installation. Use `--integration ` if more than one native integration matches the URL. Other Sentry Apps require `--app ` and must expose an issue-link form; additional required form values can be supplied with `--field name=value`. +For other Apps, an issue select can be supplied by exact ID or label with +`--field`, for example `--app custom --field task_id=123`. The CLI checks +that the app's link response identifies the requested URL before reporting success. ```bash sentry issue link my-org/FRONT-123 https://github.com/example/app/issues/42 --dry-run @@ -336,9 +341,10 @@ integration status-sync settings continue to apply after linking. #### Link permissions -Linking requires `event:write` and access to the Sentry project. The CLI requests -`event:write` and `event:admin` during OAuth login. If an older OAuth session lacks -the requested scopes, the CLI offers reauthorization after a permission error. +Linking requires `event:write` and access to the Sentry project. Discovering +GitHub/GitLab repositories and Sentry Apps also requires `org:read`. Both scopes +are included in the default OAuth login. If an older OAuth session lacks the +requested scopes, the CLI offers reauthorization after a permission error. In non-interactive mode, follow the `sentry auth refresh` command shown in the error. Environment tokens must be updated separately. @@ -366,11 +372,11 @@ integration links. #### Unlink permissions -Unlink requires **`event:admin` in both the token and your effective project -permissions**. Being a project member does not automatically grant it. The -organization's “Let Members Delete Events” setting and team roles affect whether -you have this permission. +Unlink requires **`event:write` and access to the Sentry project**; `event:admin` +is also accepted. The organization's “Let Members Delete Events” setting does +not restrict unlinking on updated Sentry versions. -New OAuth sessions request this scope. For an older session, follow the -reauthorization guidance shown by the CLI. Granting a token more scopes does not -override the organization's project-access policy. +Older Sentry versions still require `event:admin` in the token and your effective +project permissions. For those installations, request it explicitly alongside +your existing scopes with `sentry auth refresh --scope ...`. Granting a token +more scopes does not override the organization's project-access policy. diff --git a/packages/cli/DEVELOPMENT.md b/packages/cli/DEVELOPMENT.md index adde93e505..4bc3f3a3d5 100644 --- a/packages/cli/DEVELOPMENT.md +++ b/packages/cli/DEVELOPMENT.md @@ -67,7 +67,7 @@ When creating your Sentry OAuth application: - `project:read`, `project:write`, `project:admin` - `org:read` - - `event:read`, `event:write`, `event:admin` + - `event:read`, `event:write` - `member:read` - `team:read`, `team:write`, `team:admin` - `alerts:read`, `alerts:write` diff --git a/packages/cli/src/commands/issue/link.ts b/packages/cli/src/commands/issue/link.ts index dc00f12e65..73e90a1919 100644 --- a/packages/cli/src/commands/issue/link.ts +++ b/packages/cli/src/commands/issue/link.ts @@ -28,7 +28,8 @@ export const linkCommand = buildCommand({ "Link an existing tracker issue or GitHub pull request as an external reference.\n" + "The integration must be installed in your Sentry organization.\n" + "This does not create a remote issue or resolve the Sentry issue.\n\n" + - "Requires event:write and access to the Sentry project.\n\n" + + "Requires event:write and access to the Sentry project.\n" + + "GitHub, GitLab and Sentry Apps also require org:read for discovery.\n\n" + "Examples:\n" + " sentry issue link FRONT-123 https://github.com/example/app/issues/42\n" + " sentry issue link FRONT-123 https://github.com/example/app/pull/43\n" + diff --git a/packages/cli/src/commands/issue/unlink.ts b/packages/cli/src/commands/issue/unlink.ts index 1f3070f4b1..decf8a9964 100644 --- a/packages/cli/src/commands/issue/unlink.ts +++ b/packages/cli/src/commands/issue/unlink.ts @@ -29,8 +29,8 @@ export const unlinkCommand = buildDeleteCommand({ fullDescription: "Remove an external tracker issue or GitHub pull request reference from a Sentry issue.\n" + "This does not delete the external issue or change the Sentry issue's status.\n\n" + - "Requires event:admin and access to the Sentry project.\n" + - "Your token must include event:admin even if your project role grants it.\n\n" + + "Requires event:write and access to the Sentry project.\n" + + "Older Sentry versions may still require event:admin.\n\n" + "Examples:\n" + " sentry issue unlink FRONT-123 https://github.com/example/app/issues/42\n" + " sentry issue unlink FRONT-123 https://github.com/example/app/pull/43 --yes\n" + diff --git a/packages/cli/src/lib/api/issue-app-links.ts b/packages/cli/src/lib/api/issue-app-links.ts index d59b10721b..146b80bf1b 100644 --- a/packages/cli/src/lib/api/issue-app-links.ts +++ b/packages/cli/src/lib/api/issue-app-links.ts @@ -586,7 +586,13 @@ async function resolveFieldValue({ installationUuid: string; }): Promise { const target = parseTarget(options.url); - const query = target.key ?? options.url; + // Generic selects can use provider IDs that cannot be inferred from the URL. + const query = + target.key ?? + (field === targetField && field.type === "select" + ? options.fields?.[field.name] + : undefined) ?? + options.url; const input = field === targetField ? query : String(values[field.name]); let value: string | number = input; if (field.type === "select") { @@ -715,7 +721,7 @@ export async function linkAppIssue( return { link, changed: true }; } -/** Remove only the selected local app association; this existing endpoint requires event:admin. */ +/** Remove only the selected local app association, using event:write or event:admin. */ export async function unlinkAppIssueLink( orgSlug: string, issueId: string, diff --git a/packages/cli/src/lib/api/issue-integrations.ts b/packages/cli/src/lib/api/issue-integrations.ts index 434358e1f4..36f8786c9c 100644 --- a/packages/cli/src/lib/api/issue-integrations.ts +++ b/packages/cli/src/lib/api/issue-integrations.ts @@ -73,6 +73,11 @@ const NativeIssueMutationSchema = object({ ...vExternalIssueLinkResponse.entries, title: optional(string()), }); +const GitlabRepositoriesSchema = object({ + repos: array( + object({ identifier: string(), name: string(), url: nullish(string()) }) + ), +}); type NativeIntegration = InferOutput; /** Read-only resolution result used for previews and a subsequent link mutation. */ @@ -219,19 +224,45 @@ function parseAzureIssue(url: URL, domain: URL): ParsedTarget | undefined { }; } -function parseScopedGitlabIssue( +/** + * Match GitLab's actual project URL to keep deployment prefixes out of project IDs. + * The private picker has no SDK operation and includes unregistered repositories; + * public integration metadata omits the deployment prefix. + */ +async function resolveGitlabIssue( + orgSlug: string, url: URL, - domain: URL, - domainName: string -): ParsedTarget | undefined { - const target = parseGitlabIssue(url); - const group = domain.pathname.replace(TRAILING_SLASH, ""); - if (group && !url.pathname.startsWith(`${group}/`)) { + integration: NativeIntegration +): Promise { + const match = GITLAB_ISSUE.exec(url.pathname); + if ( + integrationUrl(integration)?.host !== url.host || + !match?.[1] || + !match[2] + ) { return; } - return target - ? { ...target, key: `${domainName}:${target.body.externalIssue}` } - : undefined; + const projectUrl = `${url.origin}/${match[1]}`; + const { data } = await apiRequestToRegion( + await resolveOrgRegion(orgSlug), + `/organizations/${encodeURIComponent(orgSlug)}/integrations/${encodeURIComponent(integration.id)}/repos/`, + { + params: { search: match[1].split("/").at(-1) }, + cache: "no-store", + schema: GitlabRepositoriesSchema, + } + ); + const repository = data.repos.find( + (repo) => repo.url && parseUrl(repo.url).href === projectUrl + ); + if (!repository) { + return; + } + return { + url: `${projectUrl}/-/issues/${match[2]}`, + key: `${repository.name}#${match[2]}`, + body: { externalIssue: `${repository.identifier}#${match[2]}` }, + }; } function parseTarget( @@ -261,7 +292,7 @@ function parseTarget( return target; } if (provider === "gitlab") { - return parseScopedGitlabIssue(url, domain, integration.domainName); + return parseGitlabIssue(url); } if (provider === "jira" || provider === "jira_server") { const prefix = domain.pathname.replace(TRAILING_SLASH, ""); @@ -440,23 +471,31 @@ export async function resolveNativeIssueLink(options: { const url = parseUrl(options.url); if ( SCM_CHANGE.test(url.pathname) && - !GITHUB_PULL_REQUEST.test(url.pathname) + !GITHUB_PULL_REQUEST.test(url.pathname) && + !GITLAB_ISSUE.test(url.pathname) ) { throw new ValidationError( "External issue linking supports tracker issues and GitHub pull requests." ); } const integrations = await listIntegrations(options.orgSlug, options.issueId); - let candidates = integrations.flatMap((integration) => { - if ( - integration.status !== "active" || - (options.integrationId && options.integrationId !== integration.id) - ) { - return []; - } - const target = parseTarget(url, integration); - return target ? [{ integration, target }] : []; - }); + let candidates = ( + await Promise.all( + integrations.map(async (integration) => { + if ( + integration.status !== "active" || + (options.integrationId && options.integrationId !== integration.id) + ) { + return []; + } + const target = + integration.provider.key === "gitlab" + ? await resolveGitlabIssue(options.orgSlug, url, integration) + : parseTarget(url, integration); + return target ? [{ integration, target }] : []; + }) + ) + ).flat(); if ( candidates.some(({ integration }) => ["github", "github_enterprise"].includes(integration.provider.key) diff --git a/packages/cli/src/lib/oauth.ts b/packages/cli/src/lib/oauth.ts index 46bfc8b1c9..a5ccd798fb 100644 --- a/packages/cli/src/lib/oauth.ts +++ b/packages/cli/src/lib/oauth.ts @@ -87,7 +87,6 @@ export const OAUTH_SCOPES: readonly string[] = [ "org:read", "event:read", "event:write", - "event:admin", "member:read", "team:read", "team:write", diff --git a/packages/cli/test/lib/api/issue-app-links.test.ts b/packages/cli/test/lib/api/issue-app-links.test.ts index d6243b9722..c258170f1a 100644 --- a/packages/cli/test/lib/api/issue-app-links.test.ts +++ b/packages/cli/test/lib/api/issue-app-links.test.ts @@ -201,6 +201,44 @@ describe("app issue-link action", () => { expect(writes()).toHaveLength(1); }); + test.each([ + "static", + "search", + ])("links an explicit generic issue ID from %s choices", async (source) => { + const url = "https://tracker.example/tasks/123"; + installation = { + ...INSTALLATION, + app: { ...INSTALLATION.app, slug: "custom" }, + }; + choices = [["123", "An Issue"]]; + form = { + uri: "/sentry/tasks/link", + required_fields: [ + { + name: "task_id", + type: "select", + ...(source === "static" + ? { options: choices } + : { uri: "/sentry/tasks" }), + }, + ], + }; + const prepared = await resolveAppIssueLink({ + ...OPTIONS, + url, + appSlug: "custom", + fields: { task_id: "123" }, + }); + expect(prepared.fields).toEqual({ task_id: "123" }); + actionLink = { ...LINK, serviceType: "custom", webUrl: url }; + expect(await linkAppIssue(prepared)).toEqual({ + changed: true, + link: actionLink, + }); + expect(await writes()[0]?.json()).toMatchObject({ task_id: "123" }); + expect(writes()).toHaveLength(1); + }); + test("reports a different returned target without retrying or deleting it", async () => { const prepared = await resolveAppIssueLink(OPTIONS); actionLink = { diff --git a/packages/cli/test/lib/api/issue-integrations.test.ts b/packages/cli/test/lib/api/issue-integrations.test.ts index 8096b237ce..e61c38e14c 100644 --- a/packages/cli/test/lib/api/issue-integrations.test.ts +++ b/packages/cli/test/lib/api/issue-integrations.test.ts @@ -19,6 +19,8 @@ import { mockFetch, useTestConfigDir } from "../../helpers.js"; const REGION = "https://eu.sentry.io"; const INTEGRATIONS = "/api/0/organizations/test-org/issues/42/integrations/"; const REPOSITORIES = "/api/0/organizations/test-org/repos/"; +const GITLAB_REPOSITORIES = + "/api/0/organizations/test-org/integrations/10/repos/"; const SOURCE = { orgSlug: "test-org", issueId: "42" }; const JIRA_URL = "https://tracker.example.com/browse/PROJ-7"; const LINK: NativeIssueLink = { @@ -115,7 +117,34 @@ describe("native tracker issue links", () => { domain: "gitlab.example.com/group/subgroup", url: "https://gitlab.example.com/group/subgroup/project/-/issues/7", canonical: "https://gitlab.example.com/group/subgroup/project/-/issues/7", - body: { externalIssue: "group/subgroup/project#7" }, + repositoryUrl: "https://gitlab.example.com/group/subgroup/project", + body: { externalIssue: "456#7" }, + }, + { + provider: "gitlab", + domain: "gitlab.example.com/group/subgroup", + url: "https://gitlab.example.com/group/subgroup/pull/-/issues/7", + canonical: "https://gitlab.example.com/group/subgroup/pull/-/issues/7", + repositoryUrl: "https://gitlab.example.com/group/subgroup/pull", + body: { externalIssue: "456#7" }, + }, + { + provider: "gitlab", + domain: "gitlab.example.com", + url: "https://gitlab.example.com/gitlab/group/project/issues/7", + canonical: "https://gitlab.example.com/gitlab/group/project/-/issues/7", + repositoryUrl: "https://gitlab.example.com/gitlab/group/project", + body: { externalIssue: "456#7" }, + }, + { + provider: "gitlab", + domain: "gitlab.example.com/group/subgroup", + url: "https://gitlab.example.com/services/gitlab/group/subgroup/project/-/issues/7", + canonical: + "https://gitlab.example.com/services/gitlab/group/subgroup/project/-/issues/7", + repositoryUrl: + "https://gitlab.example.com/services/gitlab/group/subgroup/project", + body: { externalIssue: "456#7" }, }, { provider: "bitbucket", @@ -147,11 +176,30 @@ describe("native tracker issue links", () => { }, ])("prepares $provider without creating or commenting", async (fixture) => { const requests = mockApi((request) => { - expect(new URL(request.url).pathname).toBe(INTEGRATIONS); - expect(new URL(request.url).origin).toBe(REGION); + const url = new URL(request.url); + expect(url.origin).toBe(REGION); expect(request.cache).toBe("no-store"); expect(request.method).toBe("GET"); - return json([integration(fixture.provider, fixture.domain)]); + if (url.pathname === INTEGRATIONS) { + return json([integration(fixture.provider, fixture.domain)]); + } + expect(url.pathname).toBe(GITLAB_REPOSITORIES); + expect(url.searchParams.get("search")).toBe( + fixture.repositoryUrl?.split("/").at(-1) + ); + return json({ + repos: [ + { + identifier: "456", + name: "Group / Project", + url: fixture.repositoryUrl, + isInstalled: false, + externalId: "gitlab.example.com:456", + defaultBranch: null, + }, + ], + searchable: true, + }); }); const prepared = await resolveNativeIssueLink({ @@ -168,7 +216,10 @@ describe("native tracker issue links", () => { body: fixture.body, }); expect(prepared.existing).toBeUndefined(); - expect(requests).toHaveLength(1); + expect(requests).toHaveLength(fixture.provider === "gitlab" ? 2 : 1); + if (fixture.provider === "gitlab") { + expect(prepared.key).toBe("Group / Project#7"); + } }); test.each([ @@ -543,11 +594,31 @@ describe("native tracker issue links", () => { expect(requests).toHaveLength(1); }); - test("lists and unlinks an existing issue whose title is null", async () => { + test.each([ + { + provider: "jira", + domain: "tracker.example.com", + url: JIRA_URL, + canonical: JIRA_URL, + }, + { + provider: "gitlab", + domain: "gitlab.example.com/group", + url: "https://gitlab.example.com/gitlab/group/project/issues/7", + canonical: "https://gitlab.example.com/gitlab/group/project/-/issues/7", + }, + ])("lists and unlinks a stored $provider reference without discovery", async ({ + provider, + domain, + url, + canonical, + }) => { + const storedLink = { ...LINK, provider, url, title: null }; const requests = mockApi((request) => { if (request.method === "GET") { + expect(new URL(request.url).pathname).toBe(INTEGRATIONS); return json([ - { ...integration(), externalIssues: [{ ...LINK, title: null }] }, + { ...integration(provider, domain), externalIssues: [storedLink] }, ]); } expect(request.method).toBe("DELETE"); @@ -558,9 +629,9 @@ describe("native tracker issue links", () => { }); const link = findNativeIssueLink( await listNativeIssueLinks(SOURCE.orgSlug, SOURCE.issueId), - JIRA_URL + url ); - expect(link).toEqual({ ...LINK, title: undefined }); + expect(link).toEqual({ ...storedLink, url: canonical, title: undefined }); if (!link) { throw new Error("Expected the existing external issue link"); } @@ -603,6 +674,11 @@ describe("native tracker issue links", () => { domain: "gitlab.example.com/team", url: "https://gitlab.example.com/another/repo/issues/7", }, + { + provider: "gitlab", + domain: "gitlab.example.com/team", + url: "https://gitlab.example.com/wrong/team/repo/issues/7", + }, { provider: "jira", domain: "https://tracker.example.com/jira", @@ -623,7 +699,21 @@ describe("native tracker issue links", () => { domain, url, }) => { - mockApi(() => json([integration(provider, domain)])); + mockApi((request) => + json( + new URL(request.url).pathname === INTEGRATIONS + ? [integration(provider, domain)] + : { + repos: [ + { + identifier: "456", + name: "Team / Repo", + url: "https://gitlab.example.com/team/repo", + }, + ], + } + ) + ); await expect(resolveNativeIssueLink({ ...SOURCE, url })).rejects.toThrow( "No installed native" ); diff --git a/packages/cli/test/lib/oauth.test.ts b/packages/cli/test/lib/oauth.test.ts index 2e8fbc1a9a..0925766841 100644 --- a/packages/cli/test/lib/oauth.test.ts +++ b/packages/cli/test/lib/oauth.test.ts @@ -30,7 +30,7 @@ describe("resolveOAuthScopeString", () => { test("default scopes allow linking and unlinking external issues", () => { const scopes = resolveOAuthScopeString().split(" "); expect(scopes).toContain("event:write"); - expect(scopes).toContain("event:admin"); + expect(scopes).not.toContain("event:admin"); }); test("default (no selection) returns the full OAUTH_SCOPES set", () => { diff --git a/packages/cli/test/lib/scope-recovery.test.ts b/packages/cli/test/lib/scope-recovery.test.ts index 53b193534a..847d974743 100644 --- a/packages/cli/test/lib/scope-recovery.test.ts +++ b/packages/cli/test/lib/scope-recovery.test.ts @@ -65,7 +65,7 @@ describe("runWithScopeRecovery", () => { getAuthScopes: vi .fn() .mockResolvedValue([ - ...OAUTH_SCOPES.filter((scope) => scope !== "event:admin"), + ...OAUTH_SCOPES.filter((scope) => scope !== "event:write"), "org:write", ]), }); @@ -82,7 +82,7 @@ describe("runWithScopeRecovery", () => { }); expect(proceed).toHaveBeenCalledTimes(2); expect(testRuntime.write).toHaveBeenCalledWith( - expect.stringContaining("missing event:admin") + expect.stringContaining("missing event:write") ); }); @@ -190,7 +190,7 @@ describe("runWithScopeRecovery", () => { const getAuthScopes = vi .fn() .mockResolvedValue([ - ...OAUTH_SCOPES.filter((scope) => scope !== "event:admin"), + ...OAUTH_SCOPES.filter((scope) => scope !== "event:write"), "org:write", ]); const login = vi.fn(); @@ -208,7 +208,7 @@ describe("runWithScopeRecovery", () => { expect(getAuthScopes).toHaveBeenCalledOnce(); expect(login).not.toHaveBeenCalled(); expect(testRuntime.write).toHaveBeenCalledWith( - "Your CLI authorization is missing event:admin.\n" + + "Your CLI authorization is missing event:write.\n" + `Re-authenticate with: sentry auth refresh ${[...OAUTH_SCOPES, "org:write"].map((scope) => `--scope ${scope}`).join(" ")}\n` ); });