diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index 6cd16ca68..8717a50ec 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/fragments/commands/issue.md b/apps/cli-docs/src/fragments/commands/issue.md index 41fd673b0..f38390645 100644 --- a/apps/cli-docs/src/fragments/commands/issue.md +++ b/apps/cli-docs/src/fragments/commands/issue.md @@ -297,3 +297,86 @@ 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 or GitHub pull request to a Sentry issue: + +```bash +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. +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 +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 +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. + +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 + +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. + +### Unlink an external issue + +Remove an association without deleting either issue: + +```bash +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 +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: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. + +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/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 4b8b785ba..9987792ed 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 d1c554d38..ef22a643b 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:** +- `--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 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 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 ` + +Unlink an external issue + +**Flags:** +- `--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 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/script/generate-sdk.ts b/packages/cli/script/generate-sdk.ts index bd24e77f3..1cb4c5308 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 09b72e94b..9bf26d739 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 000000000..107bc71ee --- /dev/null +++ b/packages/cli/src/commands/issue/link-utils.ts @@ -0,0 +1,58 @@ +/** 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 = { + 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 000000000..73e90a191 --- /dev/null +++ b/packages/cli/src/commands/issue/link.ts @@ -0,0 +1,86 @@ +/** 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, + EXTERNAL_ISSUE_POSITIONALS, + parseIssueLinkFields, +} from "./link-utils.js"; +import { resolveIssue } from "./utils.js"; + +type LinkFlags = { + 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 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" + + "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" + + " 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: EXTERNAL_ISSUE_POSITIONALS, + 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, + url: 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 " + ); + } + const result = await linkExternalIssue({ + orgSlug: org, + issueId: issue.id, + projectId: issue.project?.id, + url, + 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 000000000..decf8a996 --- /dev/null +++ b/packages/cli/src/commands/issue/unlink.ts @@ -0,0 +1,75 @@ +/** 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, + EXTERNAL_ISSUE_POSITIONALS, +} from "./link-utils.js"; +import { resolveOrgAndIssueId } from "./utils.js"; + +type UnlinkFlags = { + 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 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: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" + + " 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: EXTERNAL_ISSUE_POSITIONALS, + flags: EXTERNAL_ISSUE_FLAGS, + }, + async *func( + this: SentryContext, + flags: UnlinkFlags, + issueArg: string, + url: 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 ${url}:` + ); + if (!confirmed) { + return { hint: "Cancelled." }; + } + } + const result = await unlinkExternalIssue({ + orgSlug: org, + issueId, + url, + 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 33b4cfb1b..2230bf5e8 100644 --- a/packages/cli/src/lib/api/infrastructure.ts +++ b/packages/cli/src/lib/api/infrastructure.ts @@ -21,8 +21,8 @@ import { logger } from "../logger.js"; import { resolveOrgRegion } from "../region.js"; import { getApiBaseUrl, - getDefaultSdkConfig, getSdkConfig, + type SentryRequestOptions, } from "../sentry-client.js"; /** @@ -178,7 +178,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 +492,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 +649,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("/") @@ -763,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 new file mode 100644 index 000000000..146b80bf1 --- /dev/null +++ b/packages/cli/src/lib/api/issue-app-links.ts @@ -0,0 +1,746 @@ +/** + * 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]; +} + +/** Fetch a complete, validated collection; partial results cannot safely authorize a link mutation. */ +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 + ); +} + +/** Preserve the app's single association per Sentry issue; replacing a target requires explicit unlink. */ +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 + ); + 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" + ); + } + if (component.error) { + throw new ApiError( + `App ${installation.app.slug} could not prepare its issue-link form`, + 0, + JSON.stringify(component.error) + ); + } + 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, + } + ); + 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]; +} + +/** Resolve form dependencies while keeping the target field bound to the requested issue URL. */ +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); + // 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") { + 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, using event:write or 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 000000000..36f8786c9 --- /dev/null +++ b/packages/cli/src/lib/api/issue-integrations.ts @@ -0,0 +1,630 @@ +/** Existing issue-tracker links through Sentry's native integrations. */ +import { + deleteOrganizationIssueIntegration, + type ExternalIssueLinkResponse, + type LinkExternalIssueRequest, + type ListOrganizationReposResponse, + listOrganizationRepos, + updateOrganizationIssueIntegration, +} from "@sentry/api"; +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 { + 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; +}; + +// 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()), +}); +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. */ +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 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+)$/; +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, + 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}/${pullRequest ? "pull" : "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 }, + }; +} + +/** + * 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, + integration: NativeIntegration +): Promise { + const match = GITLAB_ISSUE.exec(url.pathname); + if ( + integrationUrl(integration)?.host !== url.host || + !match?.[1] || + !match[2] + ) { + return; + } + 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( + 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, provider); + const account = domain.pathname.split("/").find(Boolean); + if ( + account && + target?.body.repo?.split("/")[0]?.toLowerCase() !== account.toLowerCase() + ) { + return; + } + return target; + } + if (provider === "gitlab") { + return parseGitlabIssue(url); + } + if (provider === "jira" || provider === "jira_server") { + const prefix = domain.pathname.replace(TRAILING_SLASH, ""); + if (prefix && !url.pathname.startsWith(`${prefix}/browse/`)) { + return; + } + return parseJiraIssue(url); + } +} + +/** + * 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 { + 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", + schema: NativeIntegrationsSchema, + } + ); + 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, + }); + 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 }; + }); +} + +function flattenLinks(integrations: NativeIntegration[]): NativeIssueLink[] { + return integrations.flatMap((integration) => + integration.externalIssues.map((link) => ({ + ...link, + id: String(link.id), + title: link.title ?? undefined, + 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)) { + // 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, link.provider)?.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) && + !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 = ( + 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) + ) + ) { + 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: target.url.replace( + `/${target.body.repo}/`, + `/${repository.name}/` + ), + }, + }, + ]; + }); + } + 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 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, + 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 03bb15bfe..038d156ba 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 000000000..e033ec0a0 --- /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 000000000..f7d857c8a --- /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("URL must be a complete external issue URL."); + } + if ( + !["https:", "http:"].includes(url.protocol) || + url.username || + url.password + ) { + throw new ValidationError( + "URL must use HTTP(S) 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/scope-recovery.ts b/packages/cli/src/lib/scope-recovery.ts index 395f3f1b7..7c3468303 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 bb8b3653f..655d4dfdb 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 000000000..72e471956 --- /dev/null +++ b/packages/cli/test/commands/issue/link.func.test.ts @@ -0,0 +1,236 @@ +/** 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 = { + "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", + externalUrl + ); + + 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, + app: "custom-tracker", + field: ["team=team-1", "query=key=value", "optional="], + }, + "APP-42", + "https://tracker.example/issues/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", + externalUrl + ) + ).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", externalUrl) + ).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", + externalUrl + ); + + 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", + externalUrl + ); + + 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", 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 new file mode 100644 index 000000000..430160aa1 --- /dev/null +++ b/packages/cli/test/commands/issue/unlink.func.test.ts @@ -0,0 +1,262 @@ +/** 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 = { + "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", + externalUrl + ); + + 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", externalUrl) + ).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", + externalUrl + ); + + 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", externalUrl); + + 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", externalUrl); + + 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", + externalUrl + ); + + 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", + externalUrl + ); + + 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", + externalUrl + ) + ).rejects.toBe(error); + expect(output()).toBe(""); + }); +}); diff --git a/packages/cli/test/lib/api-client.test.ts b/packages/cli/test/lib/api-client.test.ts index a50479197..dba264bce 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-app-links.test.ts b/packages/cli/test/lib/api/issue-app-links.test.ts new file mode 100644 index 000000000..c258170f1 --- /dev/null +++ b/packages/cli/test/lib/api/issue-app-links.test.ts @@ -0,0 +1,485 @@ +/** 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.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 = { + ...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 000000000..e61c38e14 --- /dev/null +++ b/packages/cli/test/lib/api/issue-integrations.test.ts @@ -0,0 +1,811 @@ +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 { 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"; +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 = { + 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, + 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 }); +} + +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", + 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", + 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) => { + const url = new URL(request.url); + expect(url.origin).toBe(REGION); + expect(request.cache).toBe("no-store"); + expect(request.method).toBe("GET"); + 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({ + ...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(fixture.provider === "gitlab" ? 2 : 1); + if (fixture.provider === "gitlab") { + expect(prepared.key).toBe("Group / Project#7"); + } + }); + + test.each([ + { 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); + 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([repository("Owner/Repo", "20")]); + }); + + const prepared = await resolveNativeIssueLink({ + ...SOURCE, + url: `https://${host}/OWNER/repo/${path}/7`, + }); + + expect(prepared).toMatchObject({ + integrationId: "20", + body: { repo: "Owner/Repo", externalIssue: "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( + new URL(request.url).pathname === INTEGRATIONS + ? [integration("github", "github.com/owner")] + : [repository("owner/repo", "20")] + ) + ); + await expect( + resolveNativeIssueLink({ + ...SOURCE, + url: "https://github.com/owner/repo/issues/7", + }) + ).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, + 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, id: 1234, integrationId: 10 }); + } + 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([repository("owner/repo", "10")]); + }); + + 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([ + { 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", + ])("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.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(provider, domain), externalIssues: [storedLink] }, + ]); + } + 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), + url + ); + expect(link).toEqual({ ...storedLink, url: canonical, 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( + 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: "gitlab", + domain: "gitlab.example.com/team", + url: "https://gitlab.example.com/wrong/team/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((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" + ); + }); + + test.each([ + "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", + "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("distinguishes GitHub PR numbers, repositories and hosts", () => { + 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://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(); + }); +}); 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 000000000..098542263 --- /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 4d32db281..092576684 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).not.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/scanner-flags.integration.test.ts b/packages/cli/test/lib/scanner-flags.integration.test.ts index 1c19391c1..c481434b1 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/scope-recovery.test.ts b/packages/cli/test/lib/scope-recovery.test.ts index 11ff67e1e..847d97474 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:write"), + "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:write") + ); + }); + 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:write"), + "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:write.\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 4b48c4767..10d6bdcdc 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,39 @@ 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", + 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", + "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 () => { 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 2caaf9e33..281245f0a 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__";