From 7aff74b6799919a6e4ae874c3f61e1d07539a903 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 8 Sep 2026 19:42:33 +0200 Subject: [PATCH 01/17] feat(issue): link and unlink external tracker issues --- .../cli-docs/src/content/docs/contributing.md | 2 +- apps/cli-docs/src/content/docs/self-hosted.md | 2 +- apps/cli-docs/src/fragments/commands/issue.md | 71 ++ packages/cli/DEVELOPMENT.md | 2 +- .../sentry-cli/skills/sentry-cli/SKILL.md | 2 + .../skills/sentry-cli/references/issue.md | 42 + packages/cli/script/generate-sdk.ts | 2 +- packages/cli/src/commands/issue/index.ts | 8 +- packages/cli/src/commands/issue/link-utils.ts | 49 ++ packages/cli/src/commands/issue/link.ts | 76 ++ packages/cli/src/commands/issue/unlink.ts | 67 ++ packages/cli/src/lib/api/infrastructure.ts | 7 +- packages/cli/src/lib/api/issue-app-links.ts | 743 ++++++++++++++++++ .../cli/src/lib/api/issue-integrations.ts | 522 ++++++++++++ packages/cli/src/lib/complete.ts | 2 + .../cli/src/lib/formatters/issue-links.ts | 29 + packages/cli/src/lib/issue-links.ts | 237 ++++++ packages/cli/src/lib/oauth.ts | 1 + packages/cli/src/lib/scope-recovery.ts | 34 +- packages/cli/src/lib/sentry-client.ts | 73 +- .../cli/test/commands/issue/link.func.test.ts | 225 ++++++ .../test/commands/issue/unlink.func.test.ts | 250 ++++++ .../cli/test/lib/api/issue-app-links.test.ts | 447 +++++++++++ .../test/lib/api/issue-integrations.test.ts | 480 +++++++++++ packages/cli/test/lib/issue-links.test.ts | 245 ++++++ packages/cli/test/lib/oauth.test.ts | 6 + packages/cli/test/lib/scope-recovery.test.ts | 54 +- packages/cli/test/lib/sdk-positionals.test.ts | 20 +- packages/cli/test/lib/sentry-client.test.ts | 89 +++ 29 files changed, 3744 insertions(+), 43 deletions(-) create mode 100644 packages/cli/src/commands/issue/link-utils.ts create mode 100644 packages/cli/src/commands/issue/link.ts create mode 100644 packages/cli/src/commands/issue/unlink.ts create mode 100644 packages/cli/src/lib/api/issue-app-links.ts create mode 100644 packages/cli/src/lib/api/issue-integrations.ts create mode 100644 packages/cli/src/lib/formatters/issue-links.ts create mode 100644 packages/cli/src/lib/issue-links.ts create mode 100644 packages/cli/test/commands/issue/link.func.test.ts create mode 100644 packages/cli/test/commands/issue/unlink.func.test.ts create mode 100644 packages/cli/test/lib/api/issue-app-links.test.ts create mode 100644 packages/cli/test/lib/api/issue-integrations.test.ts create mode 100644 packages/cli/test/lib/issue-links.test.ts diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index 6cd16ca68c..8717a50ecc 100644 --- a/apps/cli-docs/src/content/docs/contributing.md +++ b/apps/cli-docs/src/content/docs/contributing.md @@ -64,7 +64,7 @@ cli/ │ │ ├── docs/ # list, query │ │ ├── event/ # list, send, view │ │ ├── feedback/ # list, view -│ │ ├── issue/ # archive, events, explain, list, merge, plan, resolve, unresolve, view +│ │ ├── issue/ # archive, events, explain, link, list, merge, plan, resolve, unlink, unresolve, view │ │ ├── local/ # run, serve │ │ ├── log/ # list, view │ │ ├── monitor/ # list, run diff --git a/apps/cli-docs/src/content/docs/self-hosted.md b/apps/cli-docs/src/content/docs/self-hosted.md index 4301ef43ea..04f2b70416 100644 --- a/apps/cli-docs/src/content/docs/self-hosted.md +++ b/apps/cli-docs/src/content/docs/self-hosted.md @@ -56,7 +56,7 @@ If your instance is on an older version or you prefer not to create an OAuth app 1. Go to **Settings → Developer Settings → Personal Tokens** in your Sentry instance (or visit `https://sentry.example.com/settings/account/api/auth-tokens/new-token/`) 2. Create a new token with the following scopes: -`project:read`, `project:write`, `project:admin`, `org:read`, `event:read`, `event:write`, `member:read`, `team:read`, `team:write`, `team:admin`, `alerts:read`, `alerts:write` +`project:read`, `project:write`, `project:admin`, `org:read`, `event:read`, `event:write`, `event:admin`, `member:read`, `team:read`, `team:write`, `team:admin`, `alerts:read`, `alerts:write` 3. Pass it to the CLI: diff --git a/apps/cli-docs/src/fragments/commands/issue.md b/apps/cli-docs/src/fragments/commands/issue.md index 41fd673b0a..37974cf34b 100644 --- a/apps/cli-docs/src/fragments/commands/issue.md +++ b/apps/cli-docs/src/fragments/commands/issue.md @@ -297,3 +297,74 @@ sentry issue ignore CLI-G5 --until auto | `10users/2hours` | 10 users within 2 hours | | *(omitted)* | Archive forever | ::: + +### Link an external issue + +Link an existing tracker issue to a Sentry issue: + +```bash +sentry issue link FRONT-123 --external-issue https://github.com/example/app/issues/42 +sentry issue link FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 +sentry issue link FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error +``` + +The matching integration must already be installed in the Sentry organization. +Native integrations include GitHub, GitHub Enterprise, Jira, Jira Server, +GitLab, Bitbucket, and Azure DevOps. Linear uses its installed Sentry App. +Use `--integration ` if more than one native integration matches the URL. +Other Sentry Apps require `--app ` and must expose an issue-link form; +additional required form values can be supplied with `--field name=value`. + +```bash +sentry issue link my-org/FRONT-123 --external-issue https://github.com/example/app/issues/42 --dry-run +sentry issue link my-org/FRONT-123 --external-issue https://github.com/example/app/issues/42 --json +``` + +`--dry-run` discovers the integration and prepares the link without submitting a +write. The provider validates the remote issue when the link is submitted. +An existing matching link succeeds with `changed: false`. A Sentry App that +already links this issue to a different resource must be unlinked first. + +This command creates an association only. It does not create a tracker issue, +resolve the Sentry issue, or link a commit or pull request. Existing integration +status-sync settings continue to apply after linking. + +#### Link permissions + +Linking requires `event:write` and access to the Sentry project. The CLI requests +`event:write` and `event:admin` during OAuth login. If an older OAuth session lacks +the requested scopes, the CLI offers reauthorization after a permission error. +In non-interactive mode, follow the `sentry auth refresh` command shown in the +error. Environment tokens must be updated separately. + +### Unlink an external issue + +Remove an association without deleting either issue: + +```bash +sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/issues/42 +sentry issue unlink my-org/FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 --yes +sentry issue unlink FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error --dry-run +``` + +Use `--yes` for non-interactive execution. `--dry-run` shows whether the link +exists without removing it. If the association is already absent, the command +succeeds with `changed: false`. + +Unlink matches the URL against stored associations and sends Sentry's internal +link ID to the existing DELETE endpoint. It does not require fetching the ticket +from the remote tracker, so a deleted remote ticket can still be unlinked. +For a custom Sentry App, select it with `--app `; unlink does not require +the app to expose a link form. Use `--integration ` to disambiguate native +integration links. + +#### Unlink permissions + +Unlink requires **`event:admin` in both the token and your effective project +permissions**. Being a project member does not automatically grant it. The +organization's “Let Members Delete Events” setting and team roles affect whether +you have this permission. + +New OAuth sessions request this scope. For an older session, follow the +reauthorization guidance shown by the CLI. Granting a token more scopes does not +override the organization's project-access policy. diff --git a/packages/cli/DEVELOPMENT.md b/packages/cli/DEVELOPMENT.md index 4bc3f3a3d5..adde93e505 100644 --- a/packages/cli/DEVELOPMENT.md +++ b/packages/cli/DEVELOPMENT.md @@ -67,7 +67,7 @@ When creating your Sentry OAuth application: - `project:read`, `project:write`, `project:admin` - `org:read` - - `event:read`, `event:write` + - `event:read`, `event:write`, `event:admin` - `member:read` - `team:read`, `team:write`, `team:admin` - `alerts:read`, `alerts:write` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 4b8b785ba6..cd4e3d6954 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -382,6 +382,8 @@ Manage Sentry issues - `sentry issue unresolve ` — Reopen a resolved issue - `sentry issue archive ` — Archive (ignore) an issue - `sentry issue merge ` — Merge 2+ issues into a single canonical group +- `sentry issue link ` — Link an existing external issue +- `sentry issue unlink ` — Unlink an external issue → Full flags and examples: `references/issue.md` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md index d1c554d385..e1be9ebdef 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -353,4 +353,46 @@ sentry issue merge cli-k9 cli-15h --into cli-k9 # alias form # Non-error issue types (performance, info, etc.) cannot be merged ``` +### `sentry issue link ` + +Link an existing external issue + +**Flags:** +- `--external-issue - URL of an existing external issue to link or unlink` +- `--integration - Native integration ID, when multiple installations match` +- `--app - Sentry App slug (automatically detected for Linear URLs)` +- `-n, --dry-run - Show what would happen without making changes` +- `--field ... - Additional Sentry App link form field (name=value, repeatable)` + +**Examples:** + +```bash +sentry issue link FRONT-123 --external-issue https://github.com/example/app/issues/42 +sentry issue link FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 +sentry issue link FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error + +sentry issue link my-org/FRONT-123 --external-issue https://github.com/example/app/issues/42 --dry-run +sentry issue link my-org/FRONT-123 --external-issue https://github.com/example/app/issues/42 --json +``` + +### `sentry issue unlink ` + +Unlink an external issue + +**Flags:** +- `--external-issue - URL of an existing external issue to link or unlink` +- `--integration - Native integration ID, when multiple installations match` +- `--app - Sentry App slug (automatically detected for Linear URLs)` +- `-y, --yes - Skip confirmation prompt` +- `-f, --force - Force the operation without confirmation` +- `-n, --dry-run - Show what would happen without making changes` + +**Examples:** + +```bash +sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/issues/42 +sentry issue unlink my-org/FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 --yes +sentry issue unlink FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error --dry-run +``` + All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/packages/cli/script/generate-sdk.ts b/packages/cli/script/generate-sdk.ts index bd24e77f37..1cb4c5308a 100644 --- a/packages/cli/script/generate-sdk.ts +++ b/packages/cli/script/generate-sdk.ts @@ -160,7 +160,7 @@ function extractSdkFlags(command: Command): SdkFlagInfo[] { flags.push({ name, kind, - tsType, + tsType: def.variadic ? `Array<${tsType}>` : tsType, optional, default: def.default, brief: def.brief, diff --git a/packages/cli/src/commands/issue/index.ts b/packages/cli/src/commands/issue/index.ts index 09b72e94bb..9bf26d7395 100644 --- a/packages/cli/src/commands/issue/index.ts +++ b/packages/cli/src/commands/issue/index.ts @@ -2,10 +2,12 @@ import { buildRouteMap } from "../../lib/route-map.js"; import { archiveCommand } from "./archive.js"; import { eventsCommand } from "./events.js"; import { explainCommand } from "./explain.js"; +import { linkCommand } from "./link.js"; import { listCommand } from "./list.js"; import { mergeCommand } from "./merge.js"; import { planCommand } from "./plan.js"; import { resolveCommand } from "./resolve.js"; +import { unlinkCommand } from "./unlink.js"; import { unresolveCommand } from "./unresolve.js"; import { viewCommand } from "./view.js"; @@ -20,6 +22,8 @@ export const issueRoute = buildRouteMap({ unresolve: unresolveCommand, archive: archiveCommand, merge: mergeCommand, + link: linkCommand, + unlink: unlinkCommand, }, // `reopen` is a friendlier synonym for `unresolve`, `ignore` for `archive`. aliases: { reopen: "unresolve", ignore: "archive" }, @@ -37,7 +41,9 @@ export const issueRoute = buildRouteMap({ " resolve Mark an issue as resolved (optionally in a release)\n" + " unresolve Reopen a resolved issue (alias: reopen)\n" + " archive Archive/ignore an issue (alias: ignore)\n" + - " merge Merge 2+ issues into a single group\n\n" + + " merge Merge 2+ issues into a single group\n" + + " link Link an existing external issue\n" + + " unlink Remove an external issue link\n\n" + "Magic selectors (available for view, events, explain, plan, resolve, unresolve, archive):\n" + " @latest Most recent unresolved issue\n" + " @most_frequent Issue with the highest event frequency\n\n" + diff --git a/packages/cli/src/commands/issue/link-utils.ts b/packages/cli/src/commands/issue/link-utils.ts new file mode 100644 index 0000000000..5287df6c6d --- /dev/null +++ b/packages/cli/src/commands/issue/link-utils.ts @@ -0,0 +1,49 @@ +/** Shared arguments for external issue association commands. */ + +import { ValidationError } from "../../lib/errors.js"; + +/** Flags identifying an existing external issue and its Sentry integration. */ +export const EXTERNAL_ISSUE_FLAGS = { + "external-issue": { + kind: "parsed", + parse: String, + brief: "URL of an existing external issue to link or unlink", + }, + integration: { + kind: "parsed", + parse: String, + brief: "Native integration ID, when multiple installations match", + optional: true, + }, + app: { + kind: "parsed", + parse: String, + brief: "Sentry App slug (automatically detected for Linear URLs)", + optional: true, + }, +} as const; + +/** Parse repeated App form fields while rejecting ambiguous duplicate keys. */ +export function parseIssueLinkFields( + fields: readonly string[] | undefined +): Record | undefined { + if (!fields?.length) { + return; + } + const result: Record = {}; + for (const field of fields) { + const separator = field.indexOf("="); + const key = field.slice(0, separator); + if ( + separator < 1 || + ["__proto__", "constructor", "prototype"].includes(key) || + Object.hasOwn(result, key) + ) { + throw new ValidationError( + "Each --field must be a unique name=value pair." + ); + } + result[key] = field.slice(separator + 1); + } + return result; +} diff --git a/packages/cli/src/commands/issue/link.ts b/packages/cli/src/commands/issue/link.ts new file mode 100644 index 0000000000..02e1a8093e --- /dev/null +++ b/packages/cli/src/commands/issue/link.ts @@ -0,0 +1,76 @@ +/** Associate an existing tracker issue with a Sentry issue. */ + +import type { SentryContext } from "../../context.js"; +import { buildCommand } from "../../lib/command.js"; +import { ContextError } from "../../lib/errors.js"; +import { formatIssueLinkResult } from "../../lib/formatters/issue-links.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { linkExternalIssue } from "../../lib/issue-links.js"; +import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../lib/mutate-command.js"; +import { EXTERNAL_ISSUE_FLAGS, parseIssueLinkFields } from "./link-utils.js"; +import { issueIdPositional, resolveIssue } from "./utils.js"; + +type LinkFlags = { + readonly "external-issue": string; + readonly integration?: string; + readonly app?: string; + readonly field?: string[]; + readonly "dry-run": boolean; +}; + +export const linkCommand = buildCommand({ + docs: { + brief: "Link an existing external issue", + fullDescription: + "Link an existing GitHub, Jira, Linear, or other supported tracker issue.\n" + + "The integration must be installed in your Sentry organization.\n" + + "This does not create a remote issue or resolve the Sentry issue.\n\n" + + "Requires event:write and access to the Sentry project.\n\n" + + "Examples:\n" + + " sentry issue link FRONT-123 --external-issue https://github.com/example/app/issues/42\n" + + " sentry issue link my-org/FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42\n" + + " sentry issue link FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error\n" + + " sentry issue link FRONT-123 --external-issue https://github.com/example/app/issues/42 --dry-run", + }, + output: { human: formatIssueLinkResult }, + parameters: { + positional: issueIdPositional, + flags: { + ...EXTERNAL_ISSUE_FLAGS, + "dry-run": DRY_RUN_FLAG, + field: { + kind: "parsed", + parse: String, + brief: "Additional Sentry App link form field (name=value, repeatable)", + variadic: true, + optional: true, + }, + }, + aliases: DRY_RUN_ALIASES, + }, + async *func(this: SentryContext, flags: LinkFlags, issueArg: string) { + const fields = parseIssueLinkFields(flags.field); + const { org, issue } = await resolveIssue({ + issueArg, + cwd: this.cwd, + command: "link", + }); + if (!org) { + throw new ContextError( + "Organization", + "sentry issue link /ISSUE --external-issue " + ); + } + const result = await linkExternalIssue({ + orgSlug: org, + issueId: issue.id, + projectId: issue.project?.id, + url: flags["external-issue"], + integrationId: flags.integration, + appSlug: flags.app, + fields, + dryRun: flags["dry-run"], + }); + yield new CommandOutput(result); + }, +}); diff --git a/packages/cli/src/commands/issue/unlink.ts b/packages/cli/src/commands/issue/unlink.ts new file mode 100644 index 0000000000..7978d3cdde --- /dev/null +++ b/packages/cli/src/commands/issue/unlink.ts @@ -0,0 +1,67 @@ +/** Remove an external issue association without deleting the external issue. */ + +import type { SentryContext } from "../../context.js"; +import { formatIssueLinkResult } from "../../lib/formatters/issue-links.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { unlinkExternalIssue } from "../../lib/issue-links.js"; +import { + buildDeleteCommand, + confirmByTyping, + isConfirmationBypassed, +} from "../../lib/mutate-command.js"; +import { EXTERNAL_ISSUE_FLAGS } from "./link-utils.js"; +import { issueIdPositional, resolveOrgAndIssueId } from "./utils.js"; + +type UnlinkFlags = { + readonly "external-issue": string; + readonly integration?: string; + readonly app?: string; + readonly "dry-run": boolean; + readonly yes: boolean; + readonly force: boolean; +}; + +export const unlinkCommand = buildDeleteCommand({ + docs: { + brief: "Unlink an external issue", + fullDescription: + "Remove the link between a Sentry issue and an external tracker issue.\n" + + "This does not delete the external issue or change the Sentry issue's status.\n\n" + + "Requires event:admin and access to the Sentry project.\n" + + "Your token must include event:admin even if your project role grants it.\n\n" + + "Examples:\n" + + " sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/issues/42\n" + + " sentry issue unlink my-org/FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 --yes\n" + + " sentry issue unlink FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error --dry-run", + }, + output: { human: formatIssueLinkResult }, + parameters: { + positional: issueIdPositional, + flags: EXTERNAL_ISSUE_FLAGS, + }, + async *func(this: SentryContext, flags: UnlinkFlags, issueArg: string) { + const { org, issueId } = await resolveOrgAndIssueId({ + issueArg, + cwd: this.cwd, + command: "unlink", + }); + if (!(flags["dry-run"] || isConfirmationBypassed(flags))) { + const confirmed = await confirmByTyping( + issueArg, + `Type '${issueArg}' to unlink ${flags["external-issue"]}:` + ); + if (!confirmed) { + return { hint: "Cancelled." }; + } + } + const result = await unlinkExternalIssue({ + orgSlug: org, + issueId, + url: flags["external-issue"], + integrationId: flags.integration, + appSlug: flags.app, + dryRun: flags["dry-run"], + }); + yield new CommandOutput(result); + }, +}); diff --git a/packages/cli/src/lib/api/infrastructure.ts b/packages/cli/src/lib/api/infrastructure.ts index 33b4cfb1bf..26b336aa09 100644 --- a/packages/cli/src/lib/api/infrastructure.ts +++ b/packages/cli/src/lib/api/infrastructure.ts @@ -23,6 +23,7 @@ import { getApiBaseUrl, getDefaultSdkConfig, getSdkConfig, + type SentryRequestOptions, } from "../sentry-client.js"; /** @@ -178,7 +179,7 @@ const zstdCompressAsync = typeof zstdCompressCb === "function" ? promisify(zstdCompressCb) : null; /** Options for raw API requests to Sentry endpoints. */ -export type ApiRequestOptions = { +export type ApiRequestOptions = SentryRequestOptions & { method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; body?: unknown; /** @@ -492,7 +493,7 @@ export async function apiRequestToRegion( options: ApiRequestOptions = {} ): Promise<{ data: T; headers: Headers }> { const { method = "GET", body, bodyEncoding, params, schema } = options; - const config = getSdkConfig(regionUrl); + const config = getSdkConfig(regionUrl, options); const searchParams = buildSearchParams(params); const normalizedEndpoint = endpoint.startsWith("/") @@ -649,7 +650,7 @@ export async function apiRequestToRegionNoContent( options: Omit = {} ): Promise { const { method = "GET", body, params } = options; - const config = getSdkConfig(regionUrl); + const config = getSdkConfig(regionUrl, options); const searchParams = buildSearchParams(params); const normalizedEndpoint = endpoint.startsWith("/") diff --git a/packages/cli/src/lib/api/issue-app-links.ts b/packages/cli/src/lib/api/issue-app-links.ts new file mode 100644 index 0000000000..4508bc75ce --- /dev/null +++ b/packages/cli/src/lib/api/issue-app-links.ts @@ -0,0 +1,743 @@ +/** + * Link existing tracker issues through installed Sentry Apps' issue-link forms. + * App callbacks and search URIs come only from the installed component schema. + */ + +import { + type GroupExternalIssueResponse, + type ListOrganizationSentryAppInstallationsResponse, + listOrganizationIssueExternalIssues, + listOrganizationSentryAppInstallations, +} from "@sentry/api"; +import { + vGroupExternalIssueResponse, + vListOrganizationSentryAppInstallationsResponse, +} from "@sentry/api/valibot"; +import { + array, + boolean, + type GenericSchema, + type InferOutput, + nullish, + number, + object, + optional, + picklist, + safeParse, + string, + tuple, + union, + unknown, +} from "valibot"; +import { ApiError, ValidationError } from "../errors.js"; +import { resolveOrgRegion } from "../region.js"; +import { getControlSiloUrl, getSdkConfig } from "../sentry-client.js"; +import { + apiRequestToRegion, + apiRequestToRegionNoContent, + MAX_PAGINATION_PAGES, + type PaginatedResponse, + parseLinkHeader, + unwrapPaginatedResult, +} from "./infrastructure.js"; + +/** A stored Sentry App association; id identifies the link, not the remote ticket. */ +export type AppIssueLink = GroupExternalIssueResponse[number]; +type AppInstallation = ListOrganizationSentryAppInstallationsResponse[number]; +const ChoiceSchema = tuple([ + union([string(), number()]), + union([string(), number()]), +]); +const FieldSchema = object({ + name: string(), + type: picklist(["select", "text", "textarea"]), + choices: optional(array(ChoiceSchema)), + options: optional(array(ChoiceSchema)), + defaultValue: nullish(union([string(), number()])), + depends_on: optional(array(string())), + multiple: optional(boolean()), + uri: optional(string()), +}); +const LinkFormSchema = object({ + uri: string(), + required_fields: optional(array(FieldSchema)), + optional_fields: optional(array(FieldSchema)), +}); +const ComponentSchema = object({ + type: string(), + error: optional(unknown()), + sentryApp: object({ slug: string(), uuid: string() }), + schema: object({ link: optional(LinkFormSchema) }), +}); +const ComponentsSchema = array(ComponentSchema); +const ChoicesResponseSchema = object({ choices: array(ChoiceSchema) }); +type Choice = InferOutput; +type Field = InferOutput; +type LinkForm = InferOutput; +type Component = InferOutput; + +/** Inputs for a read-only preflight of the app's existing-issue link action. */ +export type ResolveAppIssueLinkOptions = { + /** Organization containing the Sentry issue and app installation. */ + orgSlug: string; + /** Numeric Sentry group ID, required by external-issue-actions. */ + issueId: string; + /** Existing external resource URL. */ + url: string; + /** Installed app slug; defaults to linear for a linear.app issue URL. */ + appSlug?: string; + /** Sentry project ID, forwarded to app searches that need project context. */ + projectId?: string; + /** Additional form values keyed by names from the installed link schema. */ + fields?: Record; +}; + +/** Read-only preflight result. Pass to linkAppIssue to execute the app action. */ +export type PreparedAppIssueLink = { + /** Organization and numeric Sentry issue being linked. */ + orgSlug: string; + /** Numeric Sentry group ID. */ + issueId: string; + /** Installed app slug and requested external URL for display/dry-run. */ + appSlug: string; + /** Requested external resource URL. */ + url: string; + /** Human-facing issue key when available. */ + displayName: string; + /** UUID selected from this organization's installed apps. */ + installationUuid: string; + /** Link action URI supplied by the installed app schema. */ + uri: string; + /** Validated form fields, sent at the top level of the action request. */ + fields: Record; + /** Existing association to the same target; no callback is needed. */ + existing?: AppIssueLink; +}; + +const LINEAR_ISSUE_PATH = /^\/([^/]+)\/issue\/([a-z][a-z0-9]*-\d+)(?:\/|$)/i; +const TARGET_FIELD = + /^(issue_?id|issue|external_?issue|external_?id|issue_?url|url)$/i; +const RESERVED_FIELDS = new Set([ + "groupId", + "action", + "uri", + "__proto__", + "constructor", + "prototype", +]); +const TRAILING_SLASHES = /\/+$/; +const CHOICE_LABEL_TOKENS = /[^A-Z0-9-]+/; +const URL_FIELD = /url/i; +const NUMERIC_ID = /^\d+$/; + +function parseTarget(raw: string) { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new ValidationError( + "External issue must be a valid HTTP(S) URL", + "url" + ); + } + if ( + !["http:", "https:"].includes(url.protocol) || + url.username || + url.password + ) { + throw new ValidationError( + "External issue must be an HTTP(S) URL without credentials", + "url" + ); + } + const linear = + url.hostname === "linear.app" ? LINEAR_ISSUE_PATH.exec(url.pathname) : null; + if (url.hostname === "linear.app" && !linear) { + throw new ValidationError( + "Expected a Linear issue URL containing /issue/TEAM-123", + "url" + ); + } + const identity = linear + ? `linear.app/${linear[1]?.toLowerCase()}/${linear[2]?.toUpperCase()}` + : `${url.origin}${url.pathname.replace(TRAILING_SLASHES, "")}${url.search}${url.hash}`; + return { url, identity, key: linear?.[2]?.toUpperCase() }; +} + +/** Match a stored target by URL, ignoring Linear title suffixes; reject ambiguous matches. */ +export function findAppIssueLink( + links: AppIssueLink[], + url: string, + appSlug?: string +): AppIssueLink | undefined { + const target = parseTarget(url); + const matches = links.filter( + (link) => + (!appSlug || link.serviceType === appSlug) && + parseTarget(link.webUrl).identity === target.identity + ); + if (matches.length > 1) { + throw new ValidationError( + "Multiple app links match this URL; specify the app with --app", + "app" + ); + } + return matches[0]; +} + +async function listAll( + fetchPage: ( + cursor: string | undefined + ) => Promise>, + endpoint: string, + schema: GenericSchema +): Promise { + const result: T[] = []; + const seen = new Set(); + let cursor: string | undefined; + for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { + const { data, nextCursor } = await fetchPage(cursor); + const parsed = safeParse(schema, data); + if (!parsed.success) { + throw new ApiError( + "Unexpected API response when listing app issue links", + 0, + undefined, + endpoint + ); + } + result.push(...parsed.output); + cursor = nextCursor; + if (!cursor) { + return result; + } + if (seen.has(cursor)) { + throw new ApiError( + "App issue link pagination repeated a cursor", + 0, + undefined, + endpoint + ); + } + seen.add(cursor); + } + throw new ApiError( + "App issue link pagination exceeded the safety limit", + 0, + undefined, + endpoint + ); +} + +function groupPath(orgSlug: string, issueId: string): string { + if ( + !orgSlug || + orgSlug === "." || + orgSlug === ".." || + !NUMERIC_ID.test(issueId) + ) { + throw new ValidationError( + "App links require an organization and numeric Sentry issue ID", + "issueId" + ); + } + return `/organizations/${encodeURIComponent(orgSlug)}/issues/${encodeURIComponent(issueId)}/external-issues/`; +} + +/** Retrieve all app associations in the issue's region, bypassing stale cached preflights. */ +export async function listAppIssueLinks( + orgSlug: string, + issueId: string +): Promise { + const endpoint = groupPath(orgSlug, issueId); + const config = getSdkConfig(await resolveOrgRegion(orgSlug), { + cache: "no-store", + }); + return listAll( + async (cursor) => { + const result = await listOrganizationIssueExternalIssues({ + ...config, + path: { organization_id_or_slug: orgSlug, issue_id: issueId }, + // SDK0.256.0 omits cursor from this paginated endpoint's query schema. + query: { cursor } as never, + }); + return unwrapPaginatedResult(result, "Failed to list app issue links"); + }, + endpoint, + vGroupExternalIssueResponse + ); +} + +function checkExisting( + links: AppIssueLink[], + url: string, + appSlug: string +): AppIssueLink | undefined { + const existing = findAppIssueLink(links, url, appSlug); + if ( + links.some( + (link) => link.serviceType === appSlug && link.id !== existing?.id + ) + ) { + throw new ValidationError( + `This issue already has a different ${appSlug} link. Unlink it before linking another issue.`, + "app" + ); + } + return existing; +} + +function validateUri(uri: unknown): asserts uri is string { + if ( + typeof uri !== "string" || + !uri.startsWith("/") || + uri.startsWith("//") || + uri.includes("\\") + ) { + throw new ValidationError( + "The installed app has an invalid relative action URI", + "app" + ); + } +} + +async function resolveInstallation( + orgSlug: string, + appSlug: string +): Promise { + const config = getSdkConfig(getControlSiloUrl(), { cache: "no-store" }); + const installations = await listAll( + async (cursor) => { + const result = await listOrganizationSentryAppInstallations({ + ...config, + path: { organization_id_or_slug: orgSlug }, + query: { cursor }, + }); + return unwrapPaginatedResult( + result, + "Failed to list Sentry App installations" + ); + }, + `/organizations/${encodeURIComponent(orgSlug)}/sentry-app-installations/`, + vListOrganizationSentryAppInstallationsResponse + ); + const matches = installations.filter( + (item) => + item.organization.slug === orgSlug && + item.app.slug === appSlug && + item.status === "installed" + ); + const installation = matches[0]; + if (matches.length !== 1 || !installation) { + throw new ValidationError( + matches.length + ? `Multiple installed apps match ${appSlug}` + : `App ${appSlug} is not installed in this organization`, + "app" + ); + } + return installation; +} + +async function getLinkForm( + orgSlug: string, + installation: AppInstallation +): Promise { + const endpoint = `/organizations/${encodeURIComponent(orgSlug)}/sentry-app-components/`; + // SDK0.256.0 has no operation for installed app UI components. + const components = await listAll( + async (cursor) => { + const { data, headers } = await apiRequestToRegion( + getControlSiloUrl(), + endpoint, + { + params: { filter: "issue-link", cursor }, + cache: "no-store", + } + ); + return { + data, + nextCursor: parseLinkHeader(headers.get("Link")).nextCursor, + }; + }, + endpoint, + ComponentsSchema + ); + const matches = components.filter( + (item) => + item.type === "issue-link" && + item.sentryApp.uuid === installation.app.uuid + ); + if (matches.length !== 1 || !matches[0]?.schema.link) { + throw new ValidationError( + `App ${installation.app.slug} does not expose an unambiguous issue-link form`, + "app" + ); + } + const component = matches[0]; + if (component.error) { + throw new ApiError( + `App ${installation.app.slug} could not prepare its issue-link form`, + 0, + JSON.stringify(component.error) + ); + } + const form = component.schema.link; + if (!form) { + throw new ValidationError("App has no link form", "app"); + } + validateUri(form.uri); + return form; +} + +async function getChoices({ + installationUuid, + field, + query, + values, + projectId, +}: { + installationUuid: string; + field: Field; + query: string; + values: Record; + projectId?: string; +}): Promise { + if (!field.uri) { + return field.choices ?? field.options ?? []; + } + validateUri(field.uri); + const dependentData: Record = {}; + for (const name of field.depends_on ?? []) { + if (values[name] === undefined) { + throw new ValidationError( + `App field ${field.name} requires --field ${name}=VALUE`, + "field" + ); + } + dependentData[name] = values[name]; + } + // SDK0.256.0 does not expose app form option searches. + const { data } = await apiRequestToRegion<{ choices: Choice[] }>( + getControlSiloUrl(), + `/sentry-app-installations/${encodeURIComponent(installationUuid)}/external-requests/`, + { + params: { + uri: field.uri, + query, + projectId, + dependentData: field.depends_on?.length + ? JSON.stringify(dependentData) + : undefined, + }, + cache: "no-store", + schema: ChoicesResponseSchema, + } + ); + if (!Array.isArray(data.choices)) { + throw new ApiError("App search returned an invalid choices response", 0); + } + return data.choices; +} + +function selectChoice( + choices: Choice[], + query: string, + linearKey?: string +): string | number { + const matches = choices.filter( + ([value, label]) => + String(value) === query || + String(label) === query || + (linearKey !== undefined && + String(label) + .toUpperCase() + .split(CHOICE_LABEL_TOKENS) + .find((token) => token.length > 0) === linearKey) + ); + const choice = matches[0]; + if (matches.length !== 1 || !choice) { + throw new ValidationError( + matches.length + ? "App search returned multiple exact issue matches" + : "App search did not return an exact match for the external issue", + "url" + ); + } + return choice[0]; +} + +async function resolveFields( + options: ResolveAppIssueLinkOptions, + form: LinkForm, + installationUuid: string +): Promise> { + const required = form.required_fields ?? []; + const fields = [...required, ...(form.optional_fields ?? [])]; + const values = seedFields(fields, options.fields ?? {}); + const targetField = findTargetField(fields, required); + const pending = fields.filter( + (field) => field === targetField || values[field.name] !== undefined + ); + const resolved = new Set(); + while (pending.length) { + const index = pending.findIndex((item) => + (item.depends_on ?? []).every((name) => resolved.has(name)) + ); + const field = pending[index]; + if (!field) { + throw new ValidationError( + "App link fields have missing or circular dependencies; supply the required --field values", + "field" + ); + } + pending.splice(index, 1); + values[field.name] = await resolveFieldValue({ + field, + targetField, + values, + options, + installationUuid, + }); + resolved.add(field.name); + } + const missing = required.filter( + (field) => values[field.name] === undefined || values[field.name] === "" + ); + if (missing.length) { + throw new ValidationError( + `Missing app link fields: ${missing.map((field) => `--field ${field.name}=VALUE`).join(", ")}`, + "field" + ); + } + return values; +} + +function seedFields( + fields: Field[], + supplied: Record +): Record { + const values: Record = {}; + if (new Set(fields.map((field) => field.name)).size !== fields.length) { + throw new ValidationError( + "App link schema contains duplicate field names", + "app" + ); + } + for (const [name, value] of Object.entries(supplied)) { + if ( + RESERVED_FIELDS.has(name) || + !fields.some((field) => field.name === name) + ) { + throw new ValidationError( + `Unknown or reserved app link field: ${name}`, + "field" + ); + } + values[name] = value; + } + for (const field of fields) { + if (RESERVED_FIELDS.has(field.name)) { + throw new ValidationError( + `App link schema uses reserved field ${field.name}`, + "app" + ); + } + if (field.multiple) { + throw new ValidationError( + `App link field ${field.name} requires multiple values and is not supported`, + "field" + ); + } + if ( + values[field.name] === undefined && + field.defaultValue !== undefined && + field.defaultValue !== null + ) { + values[field.name] = field.defaultValue; + } + } + return values; +} + +function findTargetField(fields: Field[], required: Field[]): Field { + const candidates = fields.filter((field) => TARGET_FIELD.test(field.name)); + let targetField = candidates.length === 1 ? candidates[0] : undefined; + if (candidates.length === 0 && required.length === 1) { + targetField = required[0]; + } + if (!targetField) { + throw new ValidationError( + "Cannot identify one external issue field in the app link schema", + "app" + ); + } + return targetField; +} + +async function resolveFieldValue({ + field, + targetField, + values, + options, + installationUuid, +}: { + field: Field; + targetField: Field; + values: Record; + options: ResolveAppIssueLinkOptions; + installationUuid: string; +}): Promise { + const target = parseTarget(options.url); + const query = target.key ?? options.url; + const input = field === targetField ? query : String(values[field.name]); + let value: string | number = input; + if (field.type === "select") { + value = selectChoice( + await getChoices({ + installationUuid, + field, + query: input, + values, + projectId: options.projectId, + }), + input, + field === targetField ? target.key : undefined + ); + } else if (field === targetField && URL_FIELD.test(field.name)) { + value = options.url; + } + if ( + field === targetField && + options.fields?.[field.name] !== undefined && + options.fields[field.name] !== String(value) && + options.fields[field.name] !== query + ) { + throw new ValidationError( + `App field ${field.name} conflicts with the requested issue URL`, + "field" + ); + } + return value; +} + +/** Resolve the installed app and form using reads only; never register a local-only fallback. */ +export async function resolveAppIssueLink( + options: ResolveAppIssueLinkOptions +): Promise { + const target = parseTarget(options.url); + const appSlug = options.appSlug ?? (target.key ? "linear" : undefined); + if (!appSlug) { + throw new ValidationError( + "Specify --app for this external issue URL", + "app" + ); + } + if (!NUMERIC_ID.test(options.issueId)) { + throw new ValidationError( + "App linking requires the numeric Sentry issue ID", + "issueId" + ); + } + const existing = checkExisting( + await listAppIssueLinks(options.orgSlug, options.issueId), + options.url, + appSlug + ); + const installation = await resolveInstallation(options.orgSlug, appSlug); + if (existing) { + return { + ...options, + appSlug, + installationUuid: installation.uuid, + displayName: existing.displayName, + uri: "", + fields: {}, + existing, + }; + } + const form = await getLinkForm(options.orgSlug, installation); + return { + orgSlug: options.orgSlug, + issueId: options.issueId, + appSlug, + url: options.url, + displayName: target.key ?? options.url, + installationUuid: installation.uuid, + uri: form.uri, + fields: await resolveFields(options, form, installation.uuid), + }; +} + +/** Execute one app callback after a fresh singleton check; concurrent server-side replacements remain possible. */ +export async function linkAppIssue( + prepared: PreparedAppIssueLink +): Promise<{ link: AppIssueLink; changed: boolean }> { + const existing = checkExisting( + await listAppIssueLinks(prepared.orgSlug, prepared.issueId), + prepared.url, + prepared.appSlug + ); + if (existing) { + return { link: existing, changed: false }; + } + if (prepared.existing) { + throw new ValidationError( + "The app link changed after preflight; run the command again", + "url" + ); + } + validateUri(prepared.uri); + // SDK0.256.0 only has direct registration, which skips the app's link callback. + const { data: link } = await apiRequestToRegion( + getControlSiloUrl(), + `/sentry-app-installations/${encodeURIComponent(prepared.installationUuid)}/external-issue-actions/`, + { + method: "POST", + body: { + ...prepared.fields, + groupId: prepared.issueId, + action: "link", + uri: prepared.uri, + }, + retry: false, + cache: "no-store", + schema: vGroupExternalIssueResponse.item, + } + ); + if ( + String(link.issueId) !== prepared.issueId || + link.serviceType !== prepared.appSlug || + parseTarget(link.webUrl).identity !== parseTarget(prepared.url).identity + ) { + throw new ApiError( + "The app returned a different issue after linking; inspect the current links before retrying", + 0 + ); + } + return { link, changed: true }; +} + +/** Remove only the selected local app association; this existing endpoint requires event:admin. */ +export async function unlinkAppIssueLink( + orgSlug: string, + issueId: string, + linkId: string +): Promise { + if (!NUMERIC_ID.test(linkId)) { + throw new ValidationError( + "App unlink requires the numeric association ID", + "linkId" + ); + } + // The SDK's installation unlink uses different auth; this group-scoped operation is absent. + await apiRequestToRegionNoContent( + await resolveOrgRegion(orgSlug), + `${groupPath(orgSlug, issueId)}${encodeURIComponent(linkId)}/`, + { + method: "DELETE", + retry: false, + cache: "no-store", + } + ); +} diff --git a/packages/cli/src/lib/api/issue-integrations.ts b/packages/cli/src/lib/api/issue-integrations.ts new file mode 100644 index 0000000000..80846ecd0b --- /dev/null +++ b/packages/cli/src/lib/api/issue-integrations.ts @@ -0,0 +1,522 @@ +/** Existing issue-tracker links through Sentry's native integrations. */ +import { + deleteOrganizationIssueIntegration, + type ExternalIssueLinkResponse, + type IntegrationIssueConfigResponse, + type LinkExternalIssueRequest, + type ListOrganizationReposResponse, + listOrganizationRepos, + updateOrganizationIssueIntegration, +} from "@sentry/api"; + +import { ValidationError } from "../errors.js"; +import { resolveOrgRegion } from "../region.js"; +import { getSdkConfig } from "../sentry-client.js"; +import { + API_MAX_PER_PAGE, + apiRequestToRegion, + MAX_PAGINATION_PAGES, + type PaginatedResponse, + parseLinkHeader, + unwrapPaginatedResult, + unwrapResult, +} from "./infrastructure.js"; + +/** An existing reference to a tracker issue, stored by a native integration. */ +export type NativeIssueLink = Pick< + ExternalIssueLinkResponse, + "key" | "url" | "displayName" +> & { + /** Internal Sentry ExternalIssue ID, required by the unlink endpoint. */ + id: string; + /** ID of the installed Sentry integration that owns this reference. */ + integrationId: string; + /** Native integration provider key, such as github or jira_server. */ + provider: string; + /** Issue title, when supplied by the list endpoint. */ + title?: string; +}; + +type NativeIntegration = Pick< + IntegrationIssueConfigResponse, + "id" | "name" | "domainName" | "status" | "provider" +> & { + externalIssues: Omit[]; +}; + +/** Read-only resolution result used for previews and a subsequent link mutation. */ +export type PreparedNativeIssueLink = { + /** Sentry organization containing the source issue. */ + orgSlug: string; + /** Numeric Sentry issue ID. */ + issueId: string; + /** Regional API origin resolved for this organization. */ + regionUrl: string; + /** Selected native integration ID. */ + integrationId: string; + /** Native integration provider key. */ + provider: string; + /** Canonical tracker issue URL for the preview. */ + url: string; + /** Provider issue key for the preview. */ + key: string; + /** The backend also requires repo for GitHub and Bitbucket; the SDK schema omits it. */ + body: LinkExternalIssueRequest & { repo?: string }; + /** Reference found during fresh preflight; avoids a duplicate mutation. */ + existing?: NativeIssueLink; +}; + +type ParsedTarget = Pick; + +const TRAILING_SLASH = /\/+$/; +const REPOSITORY_ISSUE = /^\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/[^/]+)?$/; +const GITLAB_ISSUE = /^\/(.+?)(?:\/-)?\/issues\/(\d+)$/; +const JIRA_ISSUE = /^(.*?)\/browse\/([A-Z][A-Z0-9_]*-\d+)$/i; +const WORK_ITEM = /^(.*?)\/_workitems\/edit\/(\d+)$/; +const SCM_CHANGE = + /(?:^\/[^/]+\/[^/]+\/(?:pulls?|pull-requests|commits?)\/|\/-\/(?:merge_requests|commits?)\/)/; + +function issuePath(orgSlug: string, issueId: string): string { + return `/organizations/${encodeURIComponent(orgSlug)}/issues/${encodeURIComponent(issueId)}/integrations/`; +} + +function parseUrl(value: string): URL { + let url: URL; + try { + url = new URL(value); + } catch { + throw new ValidationError( + "External issue must be an absolute HTTP(S) URL." + ); + } + if ( + !["https:", "http:"].includes(url.protocol) || + url.username || + url.password + ) { + throw new ValidationError( + "External issue must be an HTTP(S) URL without credentials." + ); + } + url.hash = ""; + url.search = ""; + url.pathname = url.pathname.replace(TRAILING_SLASH, ""); + return url; +} + +function integrationUrl(integration: NativeIntegration): URL | undefined { + if (!integration.domainName) { + return; + } + const domain = integration.domainName; + // Older personal Bitbucket installations store only the username. + if (integration.provider.key === "bitbucket" && !domain.includes("/")) { + return parseUrl(`https://bitbucket.org/${domain}`); + } + return parseUrl(domain.includes("://") ? domain : `https://${domain}`); +} + +function parseRepositoryIssue(url: URL): ParsedTarget | undefined { + const match = REPOSITORY_ISSUE.exec(url.pathname); + if (!(match?.[1] && match[2])) { + return; + } + const repo = match[1]; + const number = match[2]; + return { + url: `${url.origin}/${repo}/issues/${number}`, + key: `${repo}#${number}`, + body: { repo, externalIssue: number }, + }; +} + +function parseGitlabIssue(url: URL): ParsedTarget | undefined { + const match = GITLAB_ISSUE.exec(url.pathname); + if (!(match?.[1] && match[2])) { + return; + } + const project = match[1]; + const number = match[2]; + return { + url: `${url.origin}/${project}/-/issues/${number}`, + key: `${url.host}:${project}#${number}`, + body: { externalIssue: `${project}#${number}` }, + }; +} + +function parseJiraIssue(url: URL): ParsedTarget | undefined { + const match = JIRA_ISSUE.exec(url.pathname); + if (!match?.[2]) { + return; + } + const key = match[2].toUpperCase(); + return { + url: `${url.origin}${match[1]}/browse/${key}`, + key, + body: { externalIssue: key }, + }; +} + +function azureAccount(url: URL): string | undefined { + if (url.hostname === "dev.azure.com") { + return url.pathname.split("/").find(Boolean)?.toLowerCase(); + } + if (url.hostname.endsWith(".visualstudio.com")) { + return url.hostname.slice(0, -".visualstudio.com".length); + } +} + +function parseAzureIssue(url: URL, domain: URL): ParsedTarget | undefined { + const account = azureAccount(domain); + const match = WORK_ITEM.exec(url.pathname); + if (!(account && account === azureAccount(url) && match?.[2])) { + return; + } + const key = match[2]; + return { + url: `${domain.origin}${domain.pathname.replace(TRAILING_SLASH, "")}/_workitems/edit/${key}`, + key, + body: { externalIssue: key }, + }; +} + +function parseScopedGitlabIssue( + url: URL, + domain: URL, + domainName: string +): ParsedTarget | undefined { + const target = parseGitlabIssue(url); + const group = domain.pathname.replace(TRAILING_SLASH, ""); + if (group && !url.pathname.startsWith(`${group}/`)) { + return; + } + return target + ? { ...target, key: `${domainName}:${target.body.externalIssue}` } + : undefined; +} + +function parseTarget( + url: URL, + integration: NativeIntegration +): ParsedTarget | undefined { + const domain = integrationUrl(integration); + if (!(domain && integration.domainName)) { + return; + } + const provider = integration.provider.key; + if (provider === "vsts") { + return parseAzureIssue(url, domain); + } + if (domain.host !== url.host) { + return; + } + if (["github", "github_enterprise", "bitbucket"].includes(provider)) { + const target = parseRepositoryIssue(url); + const account = domain.pathname.split("/").find(Boolean); + if ( + account && + target?.body.repo?.split("/")[0]?.toLowerCase() !== account.toLowerCase() + ) { + return; + } + return target; + } + if (provider === "gitlab") { + return parseScopedGitlabIssue(url, domain, integration.domainName); + } + if (provider === "jira" || provider === "jira_server") { + const prefix = domain.pathname.replace(TRAILING_SLASH, ""); + if (prefix && !url.pathname.startsWith(`${prefix}/browse/`)) { + return; + } + return parseJiraIssue(url); + } +} + +async function listFreshPages( + fetchPage: (cursor?: string) => Promise> +): Promise { + const items: T[] = []; + let cursor: string | undefined; + for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { + const response = await fetchPage(cursor); + items.push(...response.data); + cursor = response.nextCursor; + if (!cursor) { + return items; + } + } + throw new ValidationError( + "Too many results to resolve the issue link safely." + ); +} + +async function listIntegrations( + orgSlug: string, + issueId: string +): Promise { + const regionUrl = await resolveOrgRegion(orgSlug); + // The SDK exposes integration detail and mutations, but no issue-integration list. + return listFreshPages(async (cursor) => { + const response = await apiRequestToRegion( + regionUrl, + issuePath(orgSlug, issueId), + { + params: { cursor, per_page: API_MAX_PER_PAGE }, + cache: "no-store", + } + ); + return { + data: response.data, + nextCursor: parseLinkHeader(response.headers.get("Link")).nextCursor, + }; + }); +} + +async function listFreshRepositories( + orgSlug: string +): Promise { + const config = getSdkConfig(await resolveOrgRegion(orgSlug), { + cache: "no-store", + }); + return listFreshPages(async (cursor) => { + // per_page is supported by Sentry's paginator but absent from the SDK query type. + const query = { cursor, per_page: API_MAX_PER_PAGE }; + const result = await listOrganizationRepos({ + ...config, + path: { organization_id_or_slug: orgSlug }, + query, + }); + return unwrapPaginatedResult( + result, + "Failed to list repositories" + ); + }); +} + +function flattenLinks(integrations: NativeIntegration[]): NativeIssueLink[] { + return integrations.flatMap((integration) => + integration.externalIssues.map((link) => ({ + ...link, + id: String(link.id), + integrationId: integration.id, + provider: integration.provider.key, + url: + parseTarget(parseUrl(link.url), integration)?.url ?? + parseUrl(link.url).href, + })) + ); +} + +/** Fetch every link fresh, including links whose provider is no longer supported. */ +export async function listNativeIssueLinks( + orgSlug: string, + issueId: string +): Promise { + return flattenLinks(await listIntegrations(orgSlug, issueId)); +} + +function matchesNativeUrl(link: NativeIssueLink, target: URL): boolean { + const existing = parseUrl(link.url); + if (link.provider === "vsts") { + const issueId = WORK_ITEM.exec(target.pathname)?.[2]; + const account = azureAccount(target); + return Boolean( + issueId && + account && + account === azureAccount(existing) && + issueId === WORK_ITEM.exec(existing.pathname)?.[2] + ); + } + if (existing.host !== target.host) { + return false; + } + if (link.provider === "gitlab") { + const existingMatch = GITLAB_ISSUE.exec(existing.pathname); + const targetMatch = GITLAB_ISSUE.exec(target.pathname); + return Boolean( + existingMatch && + targetMatch && + existingMatch[1] === targetMatch[1] && + existingMatch[2] === targetMatch[2] + ); + } + if (["github", "github_enterprise", "bitbucket"].includes(link.provider)) { + const key = parseRepositoryIssue(target)?.key.toLowerCase(); + return Boolean( + key && key === parseRepositoryIssue(existing)?.key.toLowerCase() + ); + } + if (["jira", "jira_server"].includes(link.provider)) { + const canonical = parseJiraIssue(target)?.url; + return Boolean(canonical && canonical === parseJiraIssue(existing)?.url); + } + return existing.href === target.href; +} + +/** Match local link metadata without contacting the issue tracker. */ +export function findNativeIssueLink( + links: NativeIssueLink[], + url: string, + integrationId?: string +): NativeIssueLink | undefined { + const target = parseUrl(url); + const matches = links.filter( + (link) => + (!integrationId || integrationId === link.integrationId) && + matchesNativeUrl(link, target) + ); + if (matches.length > 1) { + throw new ValidationError( + "This issue is linked through multiple integrations. Specify --integration ." + ); + } + return matches[0]; +} + +/** Prepare a reference using installed integration metadata; performs no mutations. */ +export async function resolveNativeIssueLink(options: { + orgSlug: string; + issueId: string; + url: string; + integrationId?: string; +}): Promise { + const url = parseUrl(options.url); + if (SCM_CHANGE.test(url.pathname)) { + throw new ValidationError( + "External issue linking accepts tracker issues, not commits or pull requests." + ); + } + const integrations = await listIntegrations(options.orgSlug, options.issueId); + let candidates = integrations.flatMap((integration) => { + if ( + integration.status !== "active" || + (options.integrationId && options.integrationId !== integration.id) + ) { + return []; + } + const target = parseTarget(url, integration); + return target ? [{ integration, target }] : []; + }); + if ( + candidates.some(({ integration }) => + ["github", "github_enterprise"].includes(integration.provider.key) + ) + ) { + const repositories = await listFreshRepositories(options.orgSlug); + candidates = candidates.flatMap(({ integration, target }) => { + if (!["github", "github_enterprise"].includes(integration.provider.key)) { + return [{ integration, target }]; + } + const repository = repositories.find( + (repo) => + repo.status === "active" && + repo.integrationId === integration.id && + repo.name.toLowerCase() === target.body.repo?.toLowerCase() + ); + if (!repository) { + return []; + } + // Sentry's repository lookup is case-sensitive; use its registered spelling. + return [ + { + integration, + target: { + ...target, + body: { ...target.body, repo: repository.name }, + key: `${repository.name}#${target.body.externalIssue}`, + url: `${url.origin}/${repository.name}/issues/${target.body.externalIssue}`, + }, + }, + ]; + }); + } + if (candidates.length === 0) { + throw new ValidationError( + "No installed native issue-tracker integration matches this URL. Check --integration, or use --app for a Sentry App." + ); + } + if (candidates.length > 1) { + throw new ValidationError( + `Multiple integrations match this URL. Specify --integration : ${candidates.map(({ integration }) => `${integration.id} (${integration.name})`).join(", ")}` + ); + } + const selected = candidates[0]; + if (!selected) { + throw new ValidationError("No matching integration."); + } + return { + orgSlug: options.orgSlug, + issueId: options.issueId, + regionUrl: await resolveOrgRegion(options.orgSlug), + integrationId: selected.integration.id, + provider: selected.integration.provider.key, + ...selected.target, + existing: findNativeIssueLink( + flattenLinks(integrations), + selected.target.url, + selected.integration.id + ), + }; +} + +/** Link an existing tracker issue, without a comment or automatic mutation retry. */ +export async function linkNativeIssue( + prepared: PreparedNativeIssueLink +): Promise<{ link: NativeIssueLink; changed: boolean }> { + if (prepared.existing) { + return { link: prepared.existing, changed: false }; + } + const result = await updateOrganizationIssueIntegration({ + ...getSdkConfig(prepared.regionUrl, { + retry: false, + cache: "no-store", + }), + path: { + organization_id_or_slug: prepared.orgSlug, + issue_id: prepared.issueId, + integration_id: prepared.integrationId, + }, + body: prepared.body, + }); + const data = unwrapResult( + result, + "Failed to link external issue" + ); + return { + link: { + ...data, + id: String(data.id), + integrationId: String(data.integrationId), + provider: prepared.provider, + }, + changed: true, + }; +} + +/** The DELETE identifier is Sentry's ExternalIssue ID, not the provider key. */ +export async function unlinkNativeIssueLink( + orgSlug: string, + issueId: string, + link: NativeIssueLink +): Promise { + const externalIssue = Number(link.id); + if (!Number.isSafeInteger(externalIssue) || externalIssue <= 0) { + throw new ValidationError( + "External issue link ID must be a safe positive integer." + ); + } + const result = await deleteOrganizationIssueIntegration({ + ...getSdkConfig(await resolveOrgRegion(orgSlug), { + retry: false, + cache: "no-store", + }), + path: { + organization_id_or_slug: orgSlug, + issue_id: issueId, + integration_id: link.integrationId, + }, + query: { externalIssue }, + }); + unwrapResult(result, "Failed to unlink external issue"); +} diff --git a/packages/cli/src/lib/complete.ts b/packages/cli/src/lib/complete.ts index 03bb15bfe4..038d156bab 100644 --- a/packages/cli/src/lib/complete.ts +++ b/packages/cli/src/lib/complete.ts @@ -97,6 +97,8 @@ export const ORG_PROJECT_COMMANDS = new Set([ "issue explain", "issue plan", "issue resolve", + "issue link", + "issue unlink", "issue unresolve", "issue archive", "issue merge", diff --git a/packages/cli/src/lib/formatters/issue-links.ts b/packages/cli/src/lib/formatters/issue-links.ts new file mode 100644 index 0000000000..e033ec0a00 --- /dev/null +++ b/packages/cli/src/lib/formatters/issue-links.ts @@ -0,0 +1,29 @@ +/** Human-readable results for linking and unlinking existing external issues. */ + +import type { ExternalIssueLinkResult } from "../issue-links.js"; +import { renderMarkdown, safeCodeSpan } from "./markdown.js"; + +/** Render the association outcome without implying that either issue was resolved. */ +export function formatIssueLinkResult(result: ExternalIssueLinkResult): string { + const external = safeCodeSpan(result.externalIssue.url); + const issue = safeCodeSpan(`${result.org}/${result.issueId}`); + if (result.dryRun) { + const needsChange = + result.action === "link" ? !result.linked : result.linked; + return renderMarkdown( + needsChange + ? `Would ${result.action} ${external} ${result.action === "link" ? "to" : "from"} ${issue}. (dry run)` + : `Already ${result.linked ? "linked" : "unlinked"}: ${external}. (dry run)` + ); + } + if (!result.changed) { + return renderMarkdown( + `Already ${result.linked ? "linked" : "unlinked"}: ${external}.` + ); + } + return renderMarkdown( + result.linked + ? `Linked ${external} to ${issue}.` + : `Unlinked ${external} from ${issue}. The external issue was not deleted.` + ); +} diff --git a/packages/cli/src/lib/issue-links.ts b/packages/cli/src/lib/issue-links.ts new file mode 100644 index 0000000000..8a61ac7267 --- /dev/null +++ b/packages/cli/src/lib/issue-links.ts @@ -0,0 +1,237 @@ +/** + * Link and unlink existing external issues through Sentry's native integrations + * and Sentry Apps. These operations leave the Sentry issue's status unchanged. + */ + +import { + findAppIssueLink, + linkAppIssue, + listAppIssueLinks, + resolveAppIssueLink, + unlinkAppIssueLink, +} from "./api/issue-app-links.js"; +import { + findNativeIssueLink, + linkNativeIssue, + listNativeIssueLinks, + resolveNativeIssueLink, + unlinkNativeIssueLink, +} from "./api/issue-integrations.js"; +import { ValidationError } from "./errors.js"; +import { resolveOrgRegion } from "./region.js"; +import { invalidateCachedResponsesMatching } from "./response-cache.js"; +import { getApiBaseUrl } from "./sentry-client.js"; + +/** An external resource selected for linking to a Sentry issue. */ +export type ExternalIssueLinkOptions = { + /** Organization containing the Sentry issue. */ + orgSlug: string; + /** Numeric Sentry issue ID. */ + issueId: string; + /** Project context required by some Sentry App searches. */ + projectId?: string; + /** URL of an existing external issue. */ + url: string; + /** Native integration ID, when multiple installations match. */ + integrationId?: string; + /** Sentry App slug; Linear URLs select the Linear app automatically. */ + appSlug?: string; + /** Additional fields required by a Sentry App's link form. */ + fields?: Record; + /** Inspect the operation without submitting a mutation. */ + dryRun?: boolean; +}; + +/** Result shared by human and JSON output for external issue mutations. */ +export type ExternalIssueLinkResult = { + /** Organization containing the Sentry issue. */ + org: string; + /** Numeric Sentry issue ID. */ + issueId: string; + /** Requested operation. */ + action: "link" | "unlink"; + /** Whether the external issue remains linked after the operation. */ + linked: boolean; + /** Whether this invocation changed an association. */ + changed: boolean; + /** True when no mutation was submitted. */ + dryRun?: boolean; + /** Canonical external issue identity when available. */ + externalIssue: { + /** Sentry's internal external-issue record ID, not the tracker key. */ + id?: string; + /** Tracker key or display name. */ + identifier?: string; + /** External issue URL. */ + url: string; + /** Native provider key or Sentry App slug. */ + provider?: string; + }; +}; + +/** Validate the URL and select the native integration or Sentry App workflow. */ +function usesSentryApp(options: ExternalIssueLinkOptions): boolean { + let url: URL; + try { + url = new URL(options.url); + } catch { + throw new ValidationError("--external-issue must be a complete issue URL."); + } + if ( + !["https:", "http:"].includes(url.protocol) || + url.username || + url.password + ) { + throw new ValidationError( + "--external-issue must be an HTTP(S) URL without embedded credentials." + ); + } + const app = Boolean(options.appSlug) || url.hostname === "linear.app"; + if (app && options.integrationId) { + throw new ValidationError( + "--integration selects a native integration. Use --app for a Sentry App." + ); + } + if (!app && options.fields && Object.keys(options.fields).length > 0) { + throw new ValidationError( + "--field requires a Sentry App selected with --app." + ); + } + return app; +} + +/** App callbacks run on the control silo, so invalidate the issue's regional cache too. */ +async function invalidateIssueLinks( + options: ExternalIssueLinkOptions +): Promise { + const regionUrl = await resolveOrgRegion(options.orgSlug); + const base = getApiBaseUrl(); + const issuePath = `/api/0/organizations/${encodeURIComponent(options.orgSlug)}/issues/${encodeURIComponent(options.issueId)}/`; + await Promise.all([ + invalidateCachedResponsesMatching(new URL(issuePath, regionUrl).href), + invalidateCachedResponsesMatching(new URL(issuePath, base).href), + invalidateCachedResponsesMatching( + new URL(`/api/0/issues/${encodeURIComponent(options.issueId)}/`, base) + .href + ), + ]); +} + +/** Associate an existing ticket; a dry run performs only discovery and validation. */ +export async function linkExternalIssue( + options: ExternalIssueLinkOptions +): Promise { + const base = { + org: options.orgSlug, + issueId: options.issueId, + action: "link" as const, + dryRun: options.dryRun, + }; + if (usesSentryApp(options)) { + const prepared = await resolveAppIssueLink(options); + if (options.dryRun) { + return { + ...base, + linked: Boolean(prepared.existing), + changed: false, + externalIssue: { + id: prepared.existing?.id, + url: options.url, + provider: options.appSlug ?? "linear", + }, + }; + } + const { link: appLink, changed: appChanged } = await linkAppIssue(prepared); + if (appChanged) { + await invalidateIssueLinks(options); + } + return { + ...base, + linked: true, + changed: appChanged, + externalIssue: { + id: appLink.id, + identifier: appLink.displayName, + url: appLink.webUrl, + provider: appLink.serviceType, + }, + }; + } + const prepared = await resolveNativeIssueLink(options); + if (options.dryRun) { + return { + ...base, + linked: Boolean(prepared.existing), + changed: false, + externalIssue: { + id: prepared.existing?.id, + identifier: prepared.key, + url: prepared.url, + provider: prepared.provider, + }, + }; + } + const { link, changed } = await linkNativeIssue(prepared); + if (changed) { + await invalidateIssueLinks(options); + } + return { + ...base, + linked: true, + changed, + externalIssue: { + id: link.id, + identifier: link.key, + url: link.url, + provider: link.provider, + }, + }; +} + +/** Remove a stored association without contacting or deleting the remote ticket. */ +export async function unlinkExternalIssue( + options: ExternalIssueLinkOptions +): Promise { + const base = { + org: options.orgSlug, + issueId: options.issueId, + action: "unlink" as const, + dryRun: options.dryRun, + }; + if (usesSentryApp(options)) { + const links = await listAppIssueLinks(options.orgSlug, options.issueId); + const link = findAppIssueLink(links, options.url, options.appSlug); + if (link && !options.dryRun) { + await unlinkAppIssueLink(options.orgSlug, options.issueId, link.id); + await invalidateIssueLinks(options); + } + return { + ...base, + linked: Boolean(link && options.dryRun), + changed: Boolean(link && !options.dryRun), + externalIssue: { + id: link?.id, + identifier: link?.displayName, + url: link?.webUrl ?? options.url, + provider: link?.serviceType ?? options.appSlug ?? "linear", + }, + }; + } + const links = await listNativeIssueLinks(options.orgSlug, options.issueId); + const link = findNativeIssueLink(links, options.url, options.integrationId); + if (link && !options.dryRun) { + await unlinkNativeIssueLink(options.orgSlug, options.issueId, link); + await invalidateIssueLinks(options); + } + return { + ...base, + linked: Boolean(link && options.dryRun), + changed: Boolean(link && !options.dryRun), + externalIssue: { + id: link?.id, + identifier: link?.key, + url: link?.url ?? options.url, + provider: link?.provider, + }, + }; +} diff --git a/packages/cli/src/lib/oauth.ts b/packages/cli/src/lib/oauth.ts index a5ccd798fb..46bfc8b1c9 100644 --- a/packages/cli/src/lib/oauth.ts +++ b/packages/cli/src/lib/oauth.ts @@ -87,6 +87,7 @@ export const OAUTH_SCOPES: readonly string[] = [ "org:read", "event:read", "event:write", + "event:admin", "member:read", "team:read", "team:write", diff --git a/packages/cli/src/lib/scope-recovery.ts b/packages/cli/src/lib/scope-recovery.ts index 395f3f1b79..7c3468303d 100644 --- a/packages/cli/src/lib/scope-recovery.ts +++ b/packages/cli/src/lib/scope-recovery.ts @@ -3,17 +3,22 @@ import { getCurrentAuthScopes } from "./api/auth.js"; import { assertAutoLoginHostTrusted } from "./auto-auth.js"; import { type AuthSource, getAuthConfig } from "./db/auth.js"; import { ApiError, AuthError } from "./errors.js"; -import type { LoginResult } from "./interactive-login.js"; +import type { + InteractiveLoginOptions, + LoginResult, +} from "./interactive-login.js"; import { interactivePromptsAllowed } from "./interactive-prompts.js"; import { logger } from "./logger.js"; import { OAUTH_SCOPES } from "./oauth.js"; -type InteractiveLogin = () => Promise; +type InteractiveLogin = ( + options?: InteractiveLoginOptions +) => Promise; type OAuthScopeState = | { kind: "current" } | { kind: "invalid" } - | { kind: "missing"; scopes: string[] }; + | { kind: "missing"; scopes: string[]; requestedScopes: string[] }; export type ScopeRecoveryRuntime = { assertTrustedHost: () => void; @@ -52,7 +57,11 @@ async function inspectOAuthScopes( const grantedSet = new Set(granted); const missing = OAUTH_SCOPES.filter((scope) => !grantedSet.has(scope)); return missing.length > 0 - ? { kind: "missing", scopes: missing } + ? { + kind: "missing", + scopes: missing, + requestedScopes: [...new Set([...OAUTH_SCOPES, ...granted])], + } : { kind: "current" }; } catch (error) { if ( @@ -120,6 +129,15 @@ async function refreshOAuthScopes( runtime: ScopeRecoveryRuntime ): Promise { if (!(runtime.inputIsTty() && runtime.promptsAllowed())) { + if (state.kind === "missing") { + const scopeArgs = state.requestedScopes + .map((scope) => `--scope ${scope}`) + .join(" "); + runtime.write( + `Your CLI authorization is missing ${state.scopes.join(", ")}.\n` + + `Re-authenticate with: sentry auth refresh ${scopeArgs}\n` + ); + } return false; } @@ -133,7 +151,13 @@ async function refreshOAuthScopes( "Your CLI authorization is no longer valid. Starting authorization...\n\n" ); } - return Boolean(await runInteractiveLogin()); + return Boolean( + await runInteractiveLogin( + state.kind === "missing" + ? { scope: state.requestedScopes.join(" ") } + : undefined + ) + ); } /** Refresh a stored OAuth grant when it lacks any scope requested by this CLI. */ diff --git a/packages/cli/src/lib/sentry-client.ts b/packages/cli/src/lib/sentry-client.ts index bb8b3653fa..655d4dfdb1 100644 --- a/packages/cli/src/lib/sentry-client.ts +++ b/packages/cli/src/lib/sentry-client.ts @@ -65,6 +65,14 @@ const ENDPOINT_TIMEOUT_OVERRIDES: TimeoutOverride[] = [ /** Maximum retry attempts for failed requests */ const MAX_RETRIES = 2; +/** Per-request controls for mutations with external effects and fresh preflights. */ +export type SentryRequestOptions = { + /** False sends exactly one request, without HTTP, network, timeout, or 401 replay. */ + retry?: boolean; + /** Bypass both reads and writes of the local response cache. */ + cache?: "no-store"; +}; + /** Maximum backoff delay between retries in milliseconds */ const MAX_BACKOFF_MS = 10_000; @@ -507,16 +515,17 @@ async function buildAttemptFactory( async function fetchWithRetry( input: Request | string | URL, init: RequestInit | undefined, - method: string, - fullUrl: string + request: { method: string; fullUrl: string; options: SentryRequestOptions } ): Promise { + const { method, fullUrl, options } = request; const { token } = await refreshToken(); const headers = prepareHeaders(input, init, token); const attemptFactory = await buildAttemptFactory(input, init); const timeoutMs = resolveTimeoutMs(fullUrl); + const maxRetries = options.retry === false ? 0 : MAX_RETRIES; - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { - const isLastAttempt = attempt === MAX_RETRIES; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const isLastAttempt = attempt === maxRetries; const { input: attemptInput, init: attemptInit } = attemptFactory(); const result = await executeAttempt({ input: attemptInput, @@ -524,17 +533,20 @@ async function fetchWithRetry( headers, isLastAttempt, timeoutMs, + retry: options.retry, }); if (result.action === "done") { // Use getAuthToken() instead of captured `token` — after a 401 refresh, // handleUnauthorized stores a new token in the DB - cacheResponse( - method, - fullUrl, - authHeaders(getAuthToken()), - result.response - ); + if (options.cache !== "no-store") { + cacheResponse( + method, + fullUrl, + authHeaders(getAuthToken()), + result.response + ); + } await invalidateAfterMutation(method, fullUrl, result.response); return result.response; } @@ -572,10 +584,9 @@ async function fetchWithRetry( * * @returns A fetch-compatible function for use with @sentry/api SDK functions */ -function createAuthenticatedFetch(): ( - input: Request | string | URL, - init?: RequestInit -) => Promise { +function createAuthenticatedFetch( + options: SentryRequestOptions = {} +): (input: Request | string | URL, init?: RequestInit) => Promise { return function authenticatedFetch( input: Request | string | URL, init?: RequestInit @@ -604,11 +615,10 @@ function createAuthenticatedFetch(): ( // Check cache before auth/retry for GET requests. // Uses current token (no refresh) so lookups are fast but Vary-correct. - const cached = await tryCacheHit( - method, - fullUrl, - authHeaders(getAuthToken()) - ); + const cached = + options.cache === "no-store" + ? undefined + : await tryCacheHit(method, fullUrl, authHeaders(getAuthToken())); if (cached) { span.setAttribute("http.response.status_code", cached.status); log.debug( @@ -617,7 +627,14 @@ function createAuthenticatedFetch(): ( return cached; } - const response = await fetchWithRetry(input, init, method, fullUrl); + const requestInit = options.cache + ? { ...init, cache: options.cache } + : init; + const response = await fetchWithRetry(input, requestInit, { + method, + fullUrl, + options, + }); span.setAttribute("http.response.status_code", response.status); if (!response.ok) { span.setStatus({ code: 2, message: `${response.status}` }); @@ -641,6 +658,7 @@ type ExecuteAttemptArgs = { headers: Headers; isLastAttempt: boolean; timeoutMs: number; + retry?: boolean; }; async function executeAttempt({ @@ -649,6 +667,7 @@ async function executeAttempt({ headers, isLastAttempt, timeoutMs, + retry, }: ExecuteAttemptArgs): Promise { try { const response = await fetchWithTimeout({ @@ -658,7 +677,9 @@ async function executeAttempt({ externalSignal: init?.signal, timeoutMs, }); - return handleResponse(response, headers, isLastAttempt); + return retry === false + ? { action: "done", response } + : handleResponse(response, headers, isLastAttempt); } catch (error) { return handleFetchError(error, init?.signal, isLastAttempt); } @@ -714,7 +735,10 @@ export function getControlSiloUrl(): string { * const result = await listOrganizations({ ...config }); * ``` */ -export function getSdkConfig(regionUrl: string) { +export function getSdkConfig( + regionUrl: string, + options: SentryRequestOptions = {} +) { const normalizedBase = regionUrl.endsWith("/") ? regionUrl.slice(0, -1) : regionUrl; @@ -723,7 +747,10 @@ export function getSdkConfig(regionUrl: string) { // SDK functions already include /api/0/ in their URL paths, // so baseUrl should be the plain region URL without /api/0. baseUrl: normalizedBase, - fetch: getAuthenticatedFetch(), + fetch: + options.retry !== undefined || options.cache !== undefined + ? (createAuthenticatedFetch(options) as typeof fetch) + : getAuthenticatedFetch(), throwOnError: false as const, }; } diff --git a/packages/cli/test/commands/issue/link.func.test.ts b/packages/cli/test/commands/issue/link.func.test.ts new file mode 100644 index 0000000000..5d2de0ccd7 --- /dev/null +++ b/packages/cli/test/commands/issue/link.func.test.ts @@ -0,0 +1,225 @@ +/** Tests the issue link command, including its shared output wrapper. */ + +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { linkCommand } from "../../../src/commands/issue/link.js"; +import { resolveIssue } from "../../../src/commands/issue/utils.js"; +import { updateIssueStatus } from "../../../src/lib/api-client.js"; +import { + ApiError, + ContextError, + ValidationError, +} from "../../../src/lib/errors.js"; +import { + type ExternalIssueLinkResult, + linkExternalIssue, +} from "../../../src/lib/issue-links.js"; +import type { SentryIssue } from "../../../src/types/sentry.js"; + +vi.mock("../../../src/commands/issue/utils.js", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../../../src/commands/issue/utils.js") + >()), + resolveIssue: vi.fn(), +})); + +vi.mock("../../../src/lib/api-client.js", async (importOriginal) => ({ + ...(await importOriginal()), + updateIssueStatus: vi.fn(), +})); + +vi.mock("../../../src/lib/issue-links.js", () => ({ + linkExternalIssue: vi.fn(), +})); + +const externalUrl = "https://github.com/example/app/issues/42"; +const defaultFlags = { + "external-issue": externalUrl, + "dry-run": false, + json: false, +}; +const issue = { + id: "123456789", + shortId: "APP-42", + title: "TypeError: boom", + culprit: "handler", + count: "10", + userCount: 3, + firstSeen: "2026-03-01T00:00:00Z", + lastSeen: "2026-04-03T12:00:00Z", + level: "error", + status: "unresolved", + permalink: "https://sentry.io/organizations/test-org/issues/123456789/", + project: { id: "456", slug: "test-project", name: "Test Project" }, +} as SentryIssue; +const linkedResult: ExternalIssueLinkResult = { + org: "test-org", + issueId: "123456789", + action: "link", + linked: true, + changed: true, + externalIssue: { + id: "789", + identifier: "example/app#42", + url: externalUrl, + provider: "github", + }, +}; + +function createMockContext() { + const stdoutWrite = vi.fn((_chunk: string) => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: vi.fn((_chunk: string) => true) }, + cwd: "/tmp/example-project", + }, + output: () => stdoutWrite.mock.calls.map(([chunk]) => chunk).join(""), + }; +} + +describe("issue link", () => { + beforeEach(() => { + vi.mocked(resolveIssue).mockReset(); + vi.mocked(linkExternalIssue).mockReset(); + vi.mocked(updateIssueStatus).mockClear(); + vi.mocked(resolveIssue).mockResolvedValue({ org: "test-org", issue }); + vi.mocked(linkExternalIssue).mockResolvedValue(linkedResult); + }); + + test("forwards resolved organization, issue and project with the integration selector", async () => { + const { context, output } = createMockContext(); + const func = await linkCommand.loader(); + await func.call( + context, + { ...defaultFlags, integration: "99" }, + "test-org/APP-42" + ); + + expect(resolveIssue).toHaveBeenCalledExactlyOnceWith({ + issueArg: "test-org/APP-42", + cwd: "/tmp/example-project", + command: "link", + }); + expect(linkExternalIssue).toHaveBeenCalledExactlyOnceWith({ + orgSlug: "test-org", + issueId: "123456789", + projectId: "456", + url: externalUrl, + integrationId: "99", + appSlug: undefined, + fields: undefined, + dryRun: false, + }); + expect(output()).toContain("Linked"); + expect(output()).toContain(externalUrl); + expect(output()).toContain("test-org/123456789"); + expect(updateIssueStatus).not.toHaveBeenCalled(); + }); + + test("forwards an App selector and parses repeatable fields without losing values", async () => { + const { context } = createMockContext(); + const func = await linkCommand.loader(); + await func.call( + context, + { + ...defaultFlags, + "external-issue": "https://tracker.example/issues/42", + app: "custom-tracker", + field: ["team=team-1", "query=key=value", "optional="], + }, + "APP-42" + ); + + expect(linkExternalIssue).toHaveBeenCalledExactlyOnceWith({ + orgSlug: "test-org", + issueId: "123456789", + projectId: "456", + url: "https://tracker.example/issues/42", + integrationId: undefined, + appSlug: "custom-tracker", + fields: { team: "team-1", query: "key=value", optional: "" }, + dryRun: false, + }); + expect(updateIssueStatus).not.toHaveBeenCalled(); + }); + + test.each([ + ["team"], + ["=team-1"], + ["team=one", "team=two"], + ["__proto__=value"], + ["constructor=value"], + ["prototype=value"], + ])("rejects malformed or ambiguous --field input %j before resolving or writing", async (...fields) => { + const { context, output } = createMockContext(); + const func = await linkCommand.loader(); + + await expect( + func.call( + context, + { ...defaultFlags, app: "custom-tracker", field: fields }, + "APP-42" + ) + ).rejects.toBeInstanceOf(ValidationError); + + expect(resolveIssue).not.toHaveBeenCalled(); + expect(linkExternalIssue).not.toHaveBeenCalled(); + expect(output()).toBe(""); + }); + + test("requires organization context before linking a numeric issue", async () => { + vi.mocked(resolveIssue).mockResolvedValue({ org: undefined, issue }); + const { context } = createMockContext(); + const func = await linkCommand.loader(); + + await expect( + func.call(context, defaultFlags, "123456789") + ).rejects.toBeInstanceOf(ContextError); + expect(linkExternalIssue).not.toHaveBeenCalled(); + }); + + test("renders a dry-run preview while forwarding the no-write flag", async () => { + vi.mocked(linkExternalIssue).mockResolvedValue({ + ...linkedResult, + linked: false, + changed: false, + dryRun: true, + }); + const { context, output } = createMockContext(); + const func = await linkCommand.loader(); + await func.call(context, { ...defaultFlags, "dry-run": true }, "APP-42"); + + expect(linkExternalIssue).toHaveBeenCalledWith( + expect.objectContaining({ dryRun: true }) + ); + expect(output()).toContain("Would link"); + expect(output()).toContain("dry run"); + expect(updateIssueStatus).not.toHaveBeenCalled(); + }); + + test.each([ + true, + false, + ])("emits structured link state with changed=%s in JSON", async (changed) => { + const result = { ...linkedResult, changed }; + vi.mocked(linkExternalIssue).mockResolvedValue(result); + const { context, output } = createMockContext(); + const func = await linkCommand.loader(); + await func.call(context, { ...defaultFlags, json: true }, "APP-42"); + + expect(JSON.parse(output())).toEqual(result); + expect(updateIssueStatus).not.toHaveBeenCalled(); + }); + + test("propagates the original 403 so scope recovery can handle it", async () => { + const error = new ApiError("Permission denied", 403, "Missing event:write"); + vi.mocked(linkExternalIssue).mockRejectedValue(error); + const { context, output } = createMockContext(); + const func = await linkCommand.loader(); + + await expect( + func.call(context, { ...defaultFlags, json: true }, "APP-42") + ).rejects.toBe(error); + expect(output()).toBe(""); + }); +}); diff --git a/packages/cli/test/commands/issue/unlink.func.test.ts b/packages/cli/test/commands/issue/unlink.func.test.ts new file mode 100644 index 0000000000..60af635e87 --- /dev/null +++ b/packages/cli/test/commands/issue/unlink.func.test.ts @@ -0,0 +1,250 @@ +/** Tests the issue unlink command with its real destructive-command guard. */ + +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { unlinkCommand } from "../../../src/commands/issue/unlink.js"; +import { resolveOrgAndIssueId } from "../../../src/commands/issue/utils.js"; +import { updateIssueStatus } from "../../../src/lib/api-client.js"; +import { ApiError } from "../../../src/lib/errors.js"; +import { + type ExternalIssueLinkResult, + unlinkExternalIssue, +} from "../../../src/lib/issue-links.js"; +import { confirmByTyping } from "../../../src/lib/mutate-command.js"; + +const { mockIsatty } = vi.hoisted(() => ({ mockIsatty: vi.fn(() => false) })); + +vi.mock("node:tty", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isatty: mockIsatty, + default: { ...actual, isatty: mockIsatty }, + }; +}); + +vi.mock("../../../src/commands/issue/utils.js", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../../../src/commands/issue/utils.js") + >()), + resolveOrgAndIssueId: vi.fn(), +})); + +vi.mock("../../../src/lib/api-client.js", async (importOriginal) => ({ + ...(await importOriginal()), + updateIssueStatus: vi.fn(), +})); + +vi.mock("../../../src/lib/issue-links.js", () => ({ + unlinkExternalIssue: vi.fn(), +})); + +vi.mock("../../../src/lib/mutate-command.js", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../../../src/lib/mutate-command.js") + >()), + confirmByTyping: vi.fn(), +})); + +const externalUrl = "https://github.com/example/app/issues/42"; +const defaultFlags = { + "external-issue": externalUrl, + "dry-run": false, + yes: false, + force: false, + json: false, +}; +const unlinkedResult: ExternalIssueLinkResult = { + org: "test-org", + issueId: "123456789", + action: "unlink", + linked: false, + changed: true, + externalIssue: { + id: "789", + identifier: "example/app#42", + url: externalUrl, + provider: "github", + }, +}; + +function createMockContext() { + const stdoutWrite = vi.fn((_chunk: string) => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: vi.fn((_chunk: string) => true) }, + cwd: "/tmp/example-project", + }, + output: () => stdoutWrite.mock.calls.map(([chunk]) => chunk).join(""), + }; +} + +describe("issue unlink", () => { + beforeEach(() => { + mockIsatty.mockReset().mockReturnValue(false); + vi.mocked(resolveOrgAndIssueId).mockReset(); + vi.mocked(unlinkExternalIssue).mockReset(); + vi.mocked(confirmByTyping).mockReset().mockResolvedValue(true); + vi.mocked(updateIssueStatus).mockClear(); + vi.mocked(resolveOrgAndIssueId).mockResolvedValue({ + org: "test-org", + issueId: "123456789", + }); + vi.mocked(unlinkExternalIssue).mockResolvedValue(unlinkedResult); + }); + + test.each([ + { + selector: { integration: "99" }, + expected: { integrationId: "99", appSlug: undefined }, + }, + { + selector: { app: "custom-tracker" }, + expected: { integrationId: undefined, appSlug: "custom-tracker" }, + }, + ])("forwards resolved issue context and selector $selector", async ({ + selector, + expected, + }) => { + const { context, output } = createMockContext(); + const func = await unlinkCommand.loader(); + await func.call( + context, + { ...defaultFlags, ...selector, yes: true }, + "test-org/APP-42" + ); + + expect(resolveOrgAndIssueId).toHaveBeenCalledExactlyOnceWith({ + issueArg: "test-org/APP-42", + cwd: "/tmp/example-project", + command: "unlink", + }); + expect(unlinkExternalIssue).toHaveBeenCalledExactlyOnceWith({ + orgSlug: "test-org", + issueId: "123456789", + url: externalUrl, + ...expected, + dryRun: false, + }); + expect(confirmByTyping).not.toHaveBeenCalled(); + expect(output()).toContain("Unlinked"); + expect(output()).toContain("external issue was not deleted"); + expect(updateIssueStatus).not.toHaveBeenCalled(); + }); + + test("refuses non-interactive mutation without explicit confirmation before resolving", async () => { + const { context, output } = createMockContext(); + const func = await unlinkCommand.loader(); + + await expect(func.call(context, defaultFlags, "APP-42")).rejects.toThrow( + "Use --yes or --force to confirm." + ); + + expect(resolveOrgAndIssueId).not.toHaveBeenCalled(); + expect(confirmByTyping).not.toHaveBeenCalled(); + expect(unlinkExternalIssue).not.toHaveBeenCalled(); + expect(output()).toBe(""); + }); + + test.each([ + "yes", + "force", + ] as const)("allows non-interactive --%s without prompting", async (flag) => { + const { context } = createMockContext(); + const func = await unlinkCommand.loader(); + await func.call(context, { ...defaultFlags, [flag]: true }, "APP-42"); + + expect(confirmByTyping).not.toHaveBeenCalled(); + expect(unlinkExternalIssue).toHaveBeenCalledExactlyOnceWith({ + orgSlug: "test-org", + issueId: "123456789", + url: externalUrl, + integrationId: undefined, + appSlug: undefined, + dryRun: false, + }); + }); + + test("confirms the selected issue and external URL before unlinking interactively", async () => { + mockIsatty.mockReturnValue(true); + const { context } = createMockContext(); + const func = await unlinkCommand.loader(); + await func.call(context, defaultFlags, "test-org/APP-42"); + + expect(confirmByTyping).toHaveBeenCalledExactlyOnceWith( + "test-org/APP-42", + `Type 'test-org/APP-42' to unlink ${externalUrl}:` + ); + expect(unlinkExternalIssue).toHaveBeenCalledOnce(); + expect(vi.mocked(confirmByTyping).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(unlinkExternalIssue).mock.invocationCallOrder[0] + ); + }); + + test("cancelling confirmation leaves the association untouched", async () => { + mockIsatty.mockReturnValue(true); + vi.mocked(confirmByTyping).mockResolvedValue(false); + const { context, output } = createMockContext(); + const func = await unlinkCommand.loader(); + await func.call(context, defaultFlags, "APP-42"); + + expect(confirmByTyping).toHaveBeenCalledOnce(); + expect(unlinkExternalIssue).not.toHaveBeenCalled(); + expect(output()).toContain("Cancelled."); + expect(updateIssueStatus).not.toHaveBeenCalled(); + }); + + test("permits --dry-run without a TTY and preserves current link state in JSON", async () => { + const result = { + ...unlinkedResult, + linked: true, + changed: false, + dryRun: true, + }; + vi.mocked(unlinkExternalIssue).mockResolvedValue(result); + const { context, output } = createMockContext(); + const func = await unlinkCommand.loader(); + await func.call( + context, + { ...defaultFlags, "dry-run": true, json: true }, + "APP-42" + ); + + expect(confirmByTyping).not.toHaveBeenCalled(); + expect(unlinkExternalIssue).toHaveBeenCalledWith( + expect.objectContaining({ dryRun: true }) + ); + expect(JSON.parse(output())).toEqual(result); + expect(updateIssueStatus).not.toHaveBeenCalled(); + }); + + test.each([ + true, + false, + ])("emits structured unlink state with changed=%s in JSON", async (changed) => { + const result = { ...unlinkedResult, changed }; + vi.mocked(unlinkExternalIssue).mockResolvedValue(result); + const { context, output } = createMockContext(); + const func = await unlinkCommand.loader(); + await func.call( + context, + { ...defaultFlags, yes: true, json: true }, + "APP-42" + ); + + expect(JSON.parse(output())).toEqual(result); + expect(updateIssueStatus).not.toHaveBeenCalled(); + }); + + test("propagates the original 403 so scope recovery can handle it", async () => { + const error = new ApiError("Permission denied", 403, "Missing event:admin"); + vi.mocked(unlinkExternalIssue).mockRejectedValue(error); + const { context, output } = createMockContext(); + const func = await unlinkCommand.loader(); + + await expect( + func.call(context, { ...defaultFlags, yes: true, json: true }, "APP-42") + ).rejects.toBe(error); + expect(output()).toBe(""); + }); +}); diff --git a/packages/cli/test/lib/api/issue-app-links.test.ts b/packages/cli/test/lib/api/issue-app-links.test.ts new file mode 100644 index 0000000000..d6243b9722 --- /dev/null +++ b/packages/cli/test/lib/api/issue-app-links.test.ts @@ -0,0 +1,447 @@ +/** Contract tests for installed app callbacks, singleton protection, and regional unlinking. */ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + type AppIssueLink, + findAppIssueLink, + linkAppIssue, + listAppIssueLinks, + resolveAppIssueLink, + unlinkAppIssueLink, +} from "../../../src/lib/api/issue-app-links.js"; +import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { setOrgRegion } from "../../../src/lib/db/regions.js"; +import { ApiError, ValidationError } from "../../../src/lib/errors.js"; +import { resetAuthenticatedFetch } from "../../../src/lib/sentry-client.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("issue-app-links-"); + +const ORG = "example-org"; +const ISSUE = "123"; +const URL = "https://linear.app/example/issue/ENG-42/fix-crash"; +const OPTIONS = { orgSlug: ORG, issueId: ISSUE, url: URL, projectId: "77" }; +const LINK: AppIssueLink = { + id: "99", + issueId: ISSUE, + serviceType: "linear", + displayName: "ENG-42", + webUrl: URL, +}; +const INSTALLATION = { + uuid: "install-uuid", + status: "installed", + organization: { slug: ORG }, + app: { uuid: "app-uuid", slug: "linear", sentryAppId: 12 }, +}; +const FORM = { + uri: "/hooks/sentry/issues/link", + required_fields: [ + { name: "issueId", type: "select", uri: "/hooks/sentry/issues/search" }, + ], +}; +const COMPONENT = { + type: "issue-link", + sentryApp: { uuid: "app-uuid", slug: "linear" }, + schema: { link: FORM }, +}; + +let originalFetch: typeof globalThis.fetch; +let calls: Request[]; +let links: AppIssueLink[]; +let choices: [string, string][]; +let form: unknown; +let installation: typeof INSTALLATION; +let actionStatus: number; +let actionLink: AppIssueLink; + +function json(data: unknown, status = 200, headers?: HeadersInit): Response { + return Response.json(data, { status, headers }); +} + +beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + setOrgRegion(ORG, "https://de.sentry.io"); + resetAuthenticatedFetch(); + calls = []; + links = []; + choices = [["linear-uuid", "ENG-42: Fix the crash"]]; + form = FORM; + installation = INSTALLATION; + actionStatus = 200; + actionLink = LINK; + globalThis.fetch = mockFetch(async (input, init) => { + const request = new Request(input, init); + calls.push(request.clone()); + const path = new globalThis.URL(request.url).pathname; + if (path.endsWith("/external-issues/") && request.method === "GET") { + return json(links); + } + if (path.endsWith("/sentry-app-installations/")) { + return json([installation]); + } + if (path.endsWith("/sentry-app-components/")) { + return json([{ ...COMPONENT, schema: { link: form } }]); + } + if (path.endsWith("/external-requests/")) { + return json({ choices }); + } + if (path.endsWith("/external-issue-actions/")) { + return json( + actionStatus === 200 ? actionLink : { detail: "Provider failed" }, + actionStatus + ); + } + if (request.method === "DELETE") { + return new Response(null, { status: 204 }); + } + throw new Error(`Unexpected request: ${request.method} ${request.url}`); + }); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + resetAuthenticatedFetch(); +}); + +function writes(): Request[] { + return calls.filter((request) => request.method !== "GET"); +} + +describe("app issue-link action", () => { + test("resolves Linear key to UUID read-only, then sends the schema URI and fields top-level", async () => { + const prepared = await resolveAppIssueLink(OPTIONS); + expect(writes()).toHaveLength(0); + expect(prepared.fields).toEqual({ issueId: "linear-uuid" }); + const search = calls.find((request) => + request.url.includes("external-requests") + ); + expect(search?.url).toContain( + "https://sentry.io/api/0/sentry-app-installations/install-uuid/" + ); + expect(search?.url).toContain("query=ENG-42"); + expect(search?.url).toContain("projectId=77"); + expect(calls[0]?.url).toContain( + "https://de.sentry.io/api/0/organizations/" + ); + expect(await linkAppIssue(prepared)).toEqual({ changed: true, link: LINK }); + expect(writes()).toHaveLength(1); + expect(await writes()[0]?.json()).toEqual({ + groupId: ISSUE, + action: "link", + uri: FORM.uri, + issueId: "linear-uuid", + }); + expect(calls.every((request) => request.cache === "no-store")).toBe(true); + }); + + test("does not treat a prefix search result as an exact Linear issue", async () => { + choices = [["wrong", "ENG-420: Other issue"]]; + await expect(resolveAppIssueLink(OPTIONS)).rejects.toThrow( + "did not return an exact match" + ); + expect(writes()).toHaveLength(0); + }); + + test("does not select an issue whose title merely mentions the requested key", async () => { + choices = [["wrong", "ENG-99: Follow up on ENG-42"]]; + await expect(resolveAppIssueLink(OPTIONS)).rejects.toThrow( + "did not return an exact match" + ); + expect(writes()).toHaveLength(0); + }); + + test("rejects multiple exact matches rather than selecting the first", async () => { + choices.push(["another-uuid", "ENG-42: Another issue"]); + await expect(resolveAppIssueLink(OPTIONS)).rejects.toThrow( + "multiple exact issue matches" + ); + expect(writes()).toHaveLength(0); + }); + + test("recognizes an existing Linear link even when the URL title changes", async () => { + links = [ + { ...LINK, webUrl: "https://linear.app/example/issue/eng-42/new-title" }, + ]; + const prepared = await resolveAppIssueLink(OPTIONS); + expect(prepared.existing?.id).toBe(LINK.id); + expect(await linkAppIssue(prepared)).toEqual({ + changed: false, + link: links[0], + }); + expect( + calls.some((request) => request.url.includes("sentry-app-components")) + ).toBe(false); + expect(writes()).toHaveLength(0); + }); + + test("refuses to replace another issue linked to the same app", async () => { + links = [ + { ...LINK, webUrl: "https://linear.app/example/issue/ENG-99/other" }, + ]; + await expect(resolveAppIssueLink(OPTIONS)).rejects.toThrow( + "Unlink it before" + ); + expect(writes()).toHaveLength(0); + }); + + test("rechecks the singleton immediately before calling the app", async () => { + const prepared = await resolveAppIssueLink(OPTIONS); + links = [ + { ...LINK, webUrl: "https://linear.app/example/issue/ENG-99/other" }, + ]; + await expect(linkAppIssue(prepared)).rejects.toThrow("Unlink it before"); + expect(writes()).toHaveLength(0); + }); + + test("does not blindly replay a failed app action", async () => { + const prepared = await resolveAppIssueLink(OPTIONS); + actionStatus = 503; + await expect(linkAppIssue(prepared)).rejects.toBeInstanceOf(ApiError); + expect(writes()).toHaveLength(1); + }); + + test("reports a different returned target without retrying or deleting it", async () => { + const prepared = await resolveAppIssueLink(OPTIONS); + actionLink = { + ...LINK, + webUrl: "https://linear.app/example/issue/ENG-99/other", + }; + await expect(linkAppIssue(prepared)).rejects.toThrow( + "different issue after linking" + ); + expect(writes()).toHaveLength(1); + }); + + test("requires an installation in the requested organization", async () => { + installation = { ...INSTALLATION, organization: { slug: "other-org" } }; + await expect(resolveAppIssueLink(OPTIONS)).rejects.toThrow( + "not installed in this organization" + ); + expect(writes()).toHaveLength(0); + }); + + test("finds the installed app on later SDK cursor pages", async () => { + const defaultFetch = globalThis.fetch; + globalThis.fetch = mockFetch(async (input, init) => { + const request = new Request(input, init); + const parsed = new globalThis.URL(request.url); + if (!parsed.pathname.endsWith("/sentry-app-installations/")) { + return defaultFetch(input, init); + } + calls.push(request); + if (parsed.searchParams.get("cursor") === "install-page-2") { + return json([INSTALLATION]); + } + return json([], 200, { + Link: '; rel="next"; results="true"; cursor="install-page-2"', + }); + }); + const prepared = await resolveAppIssueLink(OPTIONS); + expect(prepared.installationUuid).toBe(INSTALLATION.uuid); + const pages = calls.filter((request) => + request.url.includes("/sentry-app-installations/?") + ); + expect(pages).toHaveLength(1); + expect(pages[0]?.url).toBe( + "https://sentry.io/api/0/organizations/example-org/sentry-app-installations/?cursor=install-page-2" + ); + expect(writes()).toHaveLength(0); + }); + + test("propagates SDK installation errors without attempting the callback", async () => { + const defaultFetch = globalThis.fetch; + globalThis.fetch = mockFetch(async (input, init) => { + const request = new Request(input, init); + if (!request.url.includes("/sentry-app-installations/")) { + return defaultFetch(input, init); + } + calls.push(request); + return json({ detail: "Installation access denied" }, 403); + }); + await expect(resolveAppIssueLink(OPTIONS)).rejects.toBeInstanceOf(ApiError); + expect(writes()).toHaveLength(0); + }); + + test("rejects unsupported app link forms instead of using direct registration", async () => { + form = undefined; + await expect(resolveAppIssueLink(OPTIONS)).rejects.toThrow( + "does not expose" + ); + expect(writes()).toHaveLength(0); + }); + + test("does not allow user fields to override the action URI or target", async () => { + await expect( + resolveAppIssueLink({ ...OPTIONS, fields: { uri: "/create" } }) + ).rejects.toThrow("reserved app link field"); + await expect( + resolveAppIssueLink({ ...OPTIONS, fields: { issueId: "different-uuid" } }) + ).rejects.toThrow("conflicts"); + expect(writes()).toHaveLength(0); + }); + + test("resolves dependent choices using validated field values", async () => { + form = { + ...FORM, + required_fields: [ + { ...FORM.required_fields[0], depends_on: ["team"] }, + { + name: "team", + type: "select", + choices: [["team-uuid", "Engineering"]], + }, + ], + }; + const prepared = await resolveAppIssueLink({ + ...OPTIONS, + fields: { team: "Engineering" }, + }); + expect(prepared.fields).toEqual({ + team: "team-uuid", + issueId: "linear-uuid", + }); + const search = calls.find((request) => + request.url.includes("external-requests") + ); + expect( + new globalThis.URL(search?.url ?? "").searchParams.get("dependentData") + ).toBe('{"team":"team-uuid"}'); + }); + + test("reports required fields rather than sending a partial form", async () => { + form = { + ...FORM, + required_fields: [ + ...FORM.required_fields, + { name: "team", type: "text" }, + ], + }; + await expect(resolveAppIssueLink(OPTIONS)).rejects.toThrow( + "--field team=VALUE" + ); + expect(writes()).toHaveLength(0); + }); + + test("accepts numeric labels in the app's static select options", async () => { + form = { + ...FORM, + required_fields: [ + ...FORM.required_fields, + { name: "team", type: "select", options: [["team-uuid", 42]] }, + ], + }; + const prepared = await resolveAppIssueLink({ + ...OPTIONS, + fields: { team: "42" }, + }); + expect(prepared.fields).toEqual({ + team: "team-uuid", + issueId: "linear-uuid", + }); + expect(writes()).toHaveLength(0); + }); +}); + +describe("list and unlink app associations", () => { + test("follows cursor pages and never uses a pagination URL as a request target", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const request = new Request(input, init); + calls.push(request); + if (new globalThis.URL(request.url).searchParams.has("cursor")) { + return json([{ ...LINK, id: "100", serviceType: "another-app" }]); + } + return json([LINK], 200, { + Link: '; rel="next"; results="true"; cursor="next-page"', + }); + }); + expect(await listAppIssueLinks(ORG, ISSUE)).toHaveLength(2); + expect(calls[1]?.url).toContain( + "https://de.sentry.io/api/0/organizations/example-org/issues/123/external-issues/?cursor=next-page" + ); + }); + + test("fails on cursor loops instead of returning incomplete links", async () => { + globalThis.fetch = mockFetch(async () => + json([LINK], 200, { + Link: '; rel="next"; results="true"; cursor="same"', + }) + ); + await expect(listAppIssueLinks(ORG, ISSUE)).rejects.toThrow( + "repeated a cursor" + ); + }); + + test("deletes the group association ID in its region without an app schema", async () => { + await unlinkAppIssueLink(ORG, ISSUE, LINK.id); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe( + "https://de.sentry.io/api/0/organizations/example-org/issues/123/external-issues/99/" + ); + expect(calls[0]?.method).toBe("DELETE"); + }); + + test("rejects relative path segments before issuing an unlink", async () => { + await expect(unlinkAppIssueLink(ORG, ISSUE, "..")).rejects.toThrow( + ValidationError + ); + await expect(unlinkAppIssueLink(ORG, "..", LINK.id)).rejects.toThrow( + ValidationError + ); + expect(calls).toHaveLength(0); + }); + + test("propagates unlink permissions without falling back to installation registration", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + calls.push(new Request(input, init)); + return json({ detail: "Requires event:admin" }, 403); + }); + await expect( + unlinkAppIssueLink(ORG, ISSUE, LINK.id) + ).rejects.toBeInstanceOf(ApiError); + expect(calls).toHaveLength(1); + }); + + test("matches generic URLs and refuses ambiguity across apps", () => { + const link = { + ...LINK, + webUrl: "https://tracker.example/issues/42/", + serviceType: "custom", + }; + expect(findAppIssueLink([link], "https://tracker.example/issues/42")).toBe( + link + ); + expect(() => + findAppIssueLink( + [link, { ...link, serviceType: "other" }], + "https://tracker.example/issues/42" + ) + ).toThrow(ValidationError); + expect( + findAppIssueLink([link], "https://tracker.example/issues/42", "other") + ).toBeUndefined(); + }); + + test("keeps query and fragment identifiers distinct for generic apps", () => { + const link = { + ...LINK, + serviceType: "custom", + webUrl: "https://tracker.example/view?id=1#issue/42", + }; + expect(findAppIssueLink([link], link.webUrl, "custom")).toBe(link); + expect( + findAppIssueLink( + [link], + "https://tracker.example/view?id=2#issue/42", + "custom" + ) + ).toBeUndefined(); + expect( + findAppIssueLink( + [link], + "https://tracker.example/view?id=1#issue/43", + "custom" + ) + ).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/api/issue-integrations.test.ts b/packages/cli/test/lib/api/issue-integrations.test.ts new file mode 100644 index 0000000000..3b9901ec69 --- /dev/null +++ b/packages/cli/test/lib/api/issue-integrations.test.ts @@ -0,0 +1,480 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + findNativeIssueLink, + linkNativeIssue, + listNativeIssueLinks, + type NativeIssueLink, + resolveNativeIssueLink, + unlinkNativeIssueLink, +} from "../../../src/lib/api/issue-integrations.js"; +import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { setOrgRegion } from "../../../src/lib/db/regions.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; + +const REGION = "https://eu.sentry.io"; +const INTEGRATIONS = "/api/0/organizations/test-org/issues/42/integrations/"; +const REPOSITORIES = "/api/0/organizations/test-org/repos/"; +const SOURCE = { orgSlug: "test-org", issueId: "42" }; +const JIRA_URL = "https://tracker.example.com/browse/PROJ-7"; +const LINK: NativeIssueLink = { + id: "1234", + integrationId: "10", + provider: "jira", + key: "PROJ-7", + url: JIRA_URL, + displayName: "PROJ-7", + title: "Example issue", +}; + +function integration( + provider = "jira", + domainName = "tracker.example.com", + id = "10", + externalIssues: NativeIssueLink[] = [] +) { + return { + id, + name: `Example ${provider}`, + domainName, + provider: { key: provider }, + status: "active", + externalIssues, + }; +} + +function json(data: unknown, headers?: HeadersInit): Response { + return Response.json(data, { status: 200, headers }); +} + +function mockApi( + respond: (request: Request) => Response | Promise +): Request[] { + const requests: Request[] = []; + globalThis.fetch = mockFetch(async (input, init) => { + const request = new Request(input, init); + requests.push(request); + return respond(request); + }); + return requests; +} + +describe("native tracker issue links", () => { + useTestConfigDir("native-issue-links-"); + let originalFetch: typeof fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + setAuthToken("test-token", 3600, "test-refresh"); + setOrgRegion(SOURCE.orgSlug, REGION); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test.each([ + { + provider: "jira", + domain: "tracker.example.com", + url: `${JIRA_URL.toLowerCase()}/?source=cli#details`, + canonical: JIRA_URL, + body: { externalIssue: "PROJ-7" }, + }, + { + provider: "jira_server", + domain: "tracker.example.com", + url: "https://tracker.example.com/jira/browse/PROJ-7", + canonical: "https://tracker.example.com/jira/browse/PROJ-7", + body: { externalIssue: "PROJ-7" }, + }, + { + provider: "gitlab", + domain: "gitlab.example.com/group/subgroup", + url: "https://gitlab.example.com/group/subgroup/project/-/issues/7", + canonical: "https://gitlab.example.com/group/subgroup/project/-/issues/7", + body: { externalIssue: "group/subgroup/project#7" }, + }, + { + provider: "bitbucket", + domain: "bitbucket.org/workspace", + url: "https://bitbucket.org/workspace/repo/issues/7/a-title", + canonical: "https://bitbucket.org/workspace/repo/issues/7", + body: { repo: "workspace/repo", externalIssue: "7" }, + }, + { + provider: "bitbucket", + domain: "username", + url: "https://bitbucket.org/username/commits/issues/7", + canonical: "https://bitbucket.org/username/commits/issues/7", + body: { repo: "username/commits", externalIssue: "7" }, + }, + { + provider: "vsts", + domain: "https://example.visualstudio.com", + url: "https://dev.azure.com/example/project/_workitems/edit/7", + canonical: "https://example.visualstudio.com/_workitems/edit/7", + body: { externalIssue: "7" }, + }, + { + provider: "vsts", + domain: "https://dev.azure.com/example", + url: "https://example.visualstudio.com/project/_workitems/edit/7", + canonical: "https://dev.azure.com/example/_workitems/edit/7", + body: { externalIssue: "7" }, + }, + ])("prepares $provider without creating or commenting", async (fixture) => { + const requests = mockApi((request) => { + expect(new URL(request.url).pathname).toBe(INTEGRATIONS); + expect(new URL(request.url).origin).toBe(REGION); + expect(request.cache).toBe("no-store"); + expect(request.method).toBe("GET"); + return json([integration(fixture.provider, fixture.domain)]); + }); + + const prepared = await resolveNativeIssueLink({ + ...SOURCE, + url: fixture.url, + }); + + expect(prepared).toMatchObject({ + ...SOURCE, + regionUrl: REGION, + integrationId: "10", + provider: fixture.provider, + url: fixture.canonical, + body: fixture.body, + }); + expect(prepared.existing).toBeUndefined(); + expect(requests).toHaveLength(1); + }); + + test.each([ + { provider: "github", host: "github.com" }, + { provider: "github_enterprise", host: "github.example.com" }, + ])("matches the registered $provider installation and spelling", async ({ + provider, + host, + }) => { + const requests = mockApi((request) => { + const { pathname } = new URL(request.url); + expect(request.method).toBe("GET"); + if (pathname === INTEGRATIONS) { + return json([ + integration(provider, `${host}/owner`, "10"), + integration(provider, `${host}/owner`, "20"), + ]); + } + expect(pathname).toBe(REPOSITORIES); + return json([ + { name: "Owner/Repo", integrationId: "20", status: "active" }, + ]); + }); + + const prepared = await resolveNativeIssueLink({ + ...SOURCE, + url: `https://${host}/OWNER/repo/issues/7`, + }); + + expect(prepared).toMatchObject({ + integrationId: "20", + body: { repo: "Owner/Repo", externalIssue: "7" }, + url: `https://${host}/Owner/Repo/issues/7`, + }); + expect(requests).toHaveLength(2); + }); + + test("does not select GitHub repositories absent from the installation", async () => { + mockApi((request) => + json( + new URL(request.url).pathname === INTEGRATIONS + ? [integration("github", "github.com/owner")] + : [{ name: "owner/repo", integrationId: "20", status: "active" }] + ) + ); + await expect( + resolveNativeIssueLink({ + ...SOURCE, + url: "https://github.com/owner/repo/issues/7", + }) + ).rejects.toThrow("No installed native"); + }); + + test("preserves repository pagination and provider body fields through the SDK", async () => { + const githubLink = { + ...LINK, + provider: "github", + key: "owner/repo#7", + url: "https://github.com/owner/repo/issues/7", + }; + const requests = mockApi(async (request) => { + const url = new URL(request.url); + expect(url.origin).toBe(REGION); + expect(request.cache).toBe("no-store"); + if (request.method === "PUT") { + expect(url.pathname).toBe(`${INTEGRATIONS}10/`); + expect(await request.json()).toEqual({ + repo: "owner/repo", + externalIssue: "7", + }); + return json(githubLink); + } + expect(request.method).toBe("GET"); + if (url.pathname === INTEGRATIONS) { + return json([integration("github", "github.com/owner")]); + } + expect(url.pathname).toBe(REPOSITORIES); + expect(url.searchParams.get("per_page")).toBe("100"); + if (!url.searchParams.has("cursor")) { + return json([], { + Link: '; rel="next"; results="true"; cursor="second"', + }); + } + expect(url.searchParams.get("cursor")).toBe("second"); + return json([ + { name: "owner/repo", integrationId: "10", status: "active" }, + ]); + }); + + const prepared = await resolveNativeIssueLink({ + ...SOURCE, + url: githubLink.url, + }); + expect(await linkNativeIssue(prepared)).toEqual({ + link: githubLink, + changed: true, + }); + expect(requests).toHaveLength(4); + }); + + test("fetches all integration pages before deciding the link is absent", async () => { + const requests = mockApi((request) => { + const url = new URL(request.url); + expect(url.pathname).toBe(INTEGRATIONS); + expect(url.searchParams.get("per_page")).toBe("100"); + if (!url.searchParams.has("cursor")) { + return json([integration("jira", "other.example.com")], { + Link: '; rel="next"; results="true"; cursor="second"', + }); + } + expect(url.searchParams.get("cursor")).toBe("second"); + return json([ + integration("jira", "tracker.example.com", "20", [ + { ...LINK, integrationId: "20" }, + ]), + ]); + }); + + const prepared = await resolveNativeIssueLink({ ...SOURCE, url: JIRA_URL }); + expect(prepared.existing?.id).toBe(LINK.id); + expect(prepared.integrationId).toBe("20"); + expect(requests).toHaveLength(2); + }); + + test("fresh duplicate preflight prevents PUT", async () => { + const requests = mockApi((request) => { + expect(request.method).toBe("GET"); + return json([integration("jira", "tracker.example.com", "10", [LINK])]); + }); + + const prepared = await resolveNativeIssueLink({ ...SOURCE, url: JIRA_URL }); + expect(await linkNativeIssue(prepared)).toEqual({ + link: LINK, + changed: false, + }); + expect(requests).toHaveLength(1); + }); + + test("PUT sends the existing key without a comment and reads bypass stale cache", async () => { + const requests = mockApi(async (request) => { + expect(new URL(request.url).origin).toBe(REGION); + expect(request.cache).toBe("no-store"); + if (request.method === "PUT") { + expect(new URL(request.url).pathname).toBe(`${INTEGRATIONS}10/`); + expect(await request.json()).toEqual({ externalIssue: "PROJ-7" }); + return json({ ...LINK, id: 1234, integrationId: 10 }); + } + expect(request.method).toBe("GET"); + return json([integration()]); + }); + + await listNativeIssueLinks(SOURCE.orgSlug, SOURCE.issueId); + const prepared = await resolveNativeIssueLink({ ...SOURCE, url: JIRA_URL }); + expect(await linkNativeIssue(prepared)).toEqual({ + link: LINK, + changed: true, + }); + expect(requests.map((request) => request.method)).toEqual([ + "GET", + "GET", + "PUT", + ]); + }); + + test.each([ + "PUT", + "DELETE", + ])("does not retry failed %s mutations", async (method) => { + const requests = mockApi((request) => { + if (request.method === "GET") { + return json([integration()]); + } + expect(request.method).toBe(method); + return new Response(JSON.stringify({ detail: "Temporary error" }), { + status: 503, + }); + }); + const prepared = await resolveNativeIssueLink({ ...SOURCE, url: JIRA_URL }); + const mutation = + method === "PUT" + ? linkNativeIssue(prepared) + : unlinkNativeIssueLink(SOURCE.orgSlug, SOURCE.issueId, LINK); + await expect(mutation).rejects.toThrow(); + expect( + requests.filter((request) => request.method === method) + ).toHaveLength(1); + }); + + test("DELETE uses Sentry's ExternalIssue ID and accepts an empty 204 response", async () => { + const requests = mockApi((request) => { + const url = new URL(request.url); + expect(url.origin).toBe(REGION); + expect(request.cache).toBe("no-store"); + expect(request.method).toBe("DELETE"); + expect(url.pathname).toBe(`${INTEGRATIONS}10/`); + expect(url.searchParams.get("externalIssue")).toBe("1234"); + return new Response(null, { status: 204 }); + }); + await unlinkNativeIssueLink(SOURCE.orgSlug, SOURCE.issueId, LINK); + expect(requests).toHaveLength(1); + }); + + test("rejects an unlink ID that the SDK numeric query cannot represent exactly", async () => { + const requests = mockApi(() => new Response(null, { status: 204 })); + await expect( + unlinkNativeIssueLink(SOURCE.orgSlug, SOURCE.issueId, { + ...LINK, + id: "9007199254740993", + }) + ).rejects.toThrow("safe positive integer"); + expect(requests).toHaveLength(0); + }); + + test("requires integration selection when multiple Jira installations match", async () => { + mockApi(() => + json([integration(), integration("jira", "tracker.example.com", "20")]) + ); + await expect( + resolveNativeIssueLink({ ...SOURCE, url: JIRA_URL }) + ).rejects.toThrow("--integration"); + const prepared = await resolveNativeIssueLink({ + ...SOURCE, + url: JIRA_URL, + integrationId: "20", + }); + expect(prepared.integrationId).toBe("20"); + }); + + test.each([ + { + provider: "gitlab", + domain: "gitlab.example.com/team", + url: "https://gitlab.example.com/another/repo/issues/7", + }, + { + provider: "jira", + domain: "https://tracker.example.com/jira", + url: JIRA_URL, + }, + { + provider: "vsts", + domain: "https://dev.azure.com/example", + url: "https://dev.azure.com/another/project/_workitems/edit/7", + }, + { + provider: "bitbucket", + domain: "bitbucket.org/team", + url: "https://bitbucket.org/another/repo/issues/7", + }, + ])("rejects a URL outside the $provider installation", async ({ + provider, + domain, + url, + }) => { + mockApi(() => json([integration(provider, domain)])); + await expect(resolveNativeIssueLink({ ...SOURCE, url })).rejects.toThrow( + "No installed native" + ); + }); + + test.each([ + "https://github.com/owner/repo/pull/7", + "https://gitlab.com/owner/repo/-/merge_requests/7", + "https://github.com/owner/repo/commit/abcdef", + "https://username:secret@tracker.example.com/browse/PROJ-7", + "javascript:alert(1)", + "PROJ-7", + ])("rejects unsupported input before API calls: %s", async (url) => { + const requests = mockApi(() => json([])); + await expect(resolveNativeIssueLink({ ...SOURCE, url })).rejects.toThrow(); + expect(requests).toHaveLength(0); + }); +}); + +describe("findNativeIssueLink", () => { + test.each([ + { + provider: "jira", + existing: JIRA_URL, + target: `${JIRA_URL.toLowerCase()}/?source=cli#details`, + }, + { + provider: "gitlab", + existing: "https://gitlab.com/group/repo/issues/7", + target: "https://gitlab.com/group/repo/-/issues/7", + }, + { + provider: "github", + existing: "https://github.com/owner/repo/issues/7", + target: "https://github.com/OWNER/Repo/issues/7/", + }, + { + provider: "bitbucket", + existing: "https://bitbucket.org/owner/repo/issues/7/a-title", + target: "https://bitbucket.org/owner/repo/issues/7", + }, + { + provider: "vsts", + existing: "https://example.visualstudio.com/_workitems/edit/7", + target: "https://dev.azure.com/example/project/_workitems/edit/7", + }, + ])("matches $provider using stored metadata alone", ({ + provider, + existing, + target, + }) => { + const link = { ...LINK, provider, url: existing }; + expect(findNativeIssueLink([link], target)).toBe(link); + }); + + test("rejects ambiguous links and accepts an integration selector", () => { + const second = { ...LINK, id: "5678", integrationId: "20" }; + expect(() => findNativeIssueLink([LINK, second], JIRA_URL)).toThrow( + "--integration" + ); + expect(findNativeIssueLink([LINK, second], JIRA_URL, "20")).toBe(second); + }); + + test("never equates invalid URLs just because both lack a parsed issue key", () => { + const link = { + ...LINK, + provider: "github", + url: "https://github.com/owner/repo/pull/7", + }; + expect( + findNativeIssueLink([link], "https://github.com/owner/repo/pull/8") + ).toBeUndefined(); + expect( + findNativeIssueLink([LINK], "https://other.example.com/browse/PROJ-7") + ).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/issue-links.test.ts b/packages/cli/test/lib/issue-links.test.ts new file mode 100644 index 0000000000..0985422631 --- /dev/null +++ b/packages/cli/test/lib/issue-links.test.ts @@ -0,0 +1,245 @@ +/** Exercise provider routing, dry runs and association-only mutation outcomes. */ + +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { + findAppIssueLink, + linkAppIssue, + listAppIssueLinks, + resolveAppIssueLink, + unlinkAppIssueLink, +} from "../../src/lib/api/issue-app-links.js"; +import { + findNativeIssueLink, + linkNativeIssue, + listNativeIssueLinks, + resolveNativeIssueLink, + unlinkNativeIssueLink, +} from "../../src/lib/api/issue-integrations.js"; +import { ApiError } from "../../src/lib/errors.js"; +import { formatIssueLinkResult } from "../../src/lib/formatters/issue-links.js"; +import { + linkExternalIssue, + unlinkExternalIssue, +} from "../../src/lib/issue-links.js"; +import { invalidateCachedResponsesMatching } from "../../src/lib/response-cache.js"; + +vi.mock("../../src/lib/api/issue-app-links.js"); +vi.mock("../../src/lib/api/issue-integrations.js"); +vi.mock("../../src/lib/response-cache.js"); +vi.mock("../../src/lib/region.js", () => ({ + resolveOrgRegion: vi.fn().mockResolvedValue("https://de.sentry.io"), +})); +vi.mock("../../src/lib/sentry-client.js", () => ({ + getApiBaseUrl: () => "https://sentry.io", +})); + +const nativeLink = { + id: "810", + integrationId: "20", + provider: "github", + key: "example/app#42", + displayName: "example/app#42", + url: "https://github.com/example/app/issues/42", +}; +const appLink = { + id: "910", + issueId: "123", + serviceType: "linear", + displayName: "APP-42", + webUrl: "https://linear.app/example/issue/APP-42/fix-error", +}; +const options = { + orgSlug: "example", + issueId: "123", + url: nativeLink.url, +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(resolveNativeIssueLink).mockResolvedValue({ + ...options, + regionUrl: "https://de.sentry.io", + integrationId: nativeLink.integrationId, + provider: nativeLink.provider, + key: nativeLink.key, + body: { repo: "example/app", externalIssue: "42" }, + }); + vi.mocked(linkNativeIssue).mockResolvedValue({ + link: nativeLink, + changed: true, + }); + vi.mocked(listNativeIssueLinks).mockResolvedValue([nativeLink]); + vi.mocked(findNativeIssueLink).mockReturnValue(nativeLink); + vi.mocked(resolveAppIssueLink).mockResolvedValue({ + ...options, + url: appLink.webUrl, + appSlug: "linear", + displayName: appLink.displayName, + installationUuid: "installation", + uri: "/link", + fields: { issueId: "remote-uuid" }, + }); + vi.mocked(linkAppIssue).mockResolvedValue({ link: appLink, changed: true }); + vi.mocked(listAppIssueLinks).mockResolvedValue([appLink]); + vi.mocked(findAppIssueLink).mockReturnValue(appLink); +}); + +describe("external issue associations", () => { + test("native link returns the internal association ID and invalidates issue views", async () => { + const result = await linkExternalIssue(options); + expect(result).toMatchObject({ + action: "link", + changed: true, + linked: true, + externalIssue: { id: "810", identifier: "example/app#42" }, + }); + expect(resolveAppIssueLink).not.toHaveBeenCalled(); + expect(invalidateCachedResponsesMatching).toHaveBeenCalledWith( + "https://de.sentry.io/api/0/organizations/example/issues/123/" + ); + expect(invalidateCachedResponsesMatching).toHaveBeenCalledWith( + "https://sentry.io/api/0/issues/123/" + ); + }); + + test("Linear routes through the app workflow with project context", async () => { + const appOptions = { ...options, url: appLink.webUrl, projectId: "456" }; + const result = await linkExternalIssue(appOptions); + expect(resolveAppIssueLink).toHaveBeenCalledWith(appOptions); + expect(linkNativeIssue).not.toHaveBeenCalled(); + expect(result.externalIssue).toEqual({ + id: appLink.id, + identifier: "APP-42", + url: appLink.webUrl, + provider: "linear", + }); + }); + + test("an explicitly selected app accepts a non-Linear resource URL", async () => { + await linkExternalIssue({ ...options, appSlug: "custom-tracker" }); + expect(resolveAppIssueLink).toHaveBeenCalledWith( + expect.objectContaining({ + appSlug: "custom-tracker", + url: nativeLink.url, + }) + ); + expect(resolveNativeIssueLink).not.toHaveBeenCalled(); + }); + + test.each([ + nativeLink.url, + appLink.webUrl, + ])("dry-run link submits no mutation: %s", async (url) => { + const result = await linkExternalIssue({ ...options, url, dryRun: true }); + expect(result).toMatchObject({ + linked: false, + changed: false, + dryRun: true, + }); + expect(linkNativeIssue).not.toHaveBeenCalled(); + expect(linkAppIssue).not.toHaveBeenCalled(); + expect(invalidateCachedResponsesMatching).not.toHaveBeenCalled(); + expect(formatIssueLinkResult(result)).toContain("Would link"); + }); + + test("already-linked is a successful no-op, with no cache mutation", async () => { + vi.mocked(linkNativeIssue).mockResolvedValue({ + link: nativeLink, + changed: false, + }); + const result = await linkExternalIssue(options); + expect(result).toMatchObject({ linked: true, changed: false }); + expect(formatIssueLinkResult(result)).toContain("Already linked"); + expect(invalidateCachedResponsesMatching).not.toHaveBeenCalled(); + }); + + test("unlink selects a stored native association, without resolving the remote issue", async () => { + const result = await unlinkExternalIssue(options); + expect(unlinkNativeIssueLink).toHaveBeenCalledWith( + "example", + "123", + nativeLink + ); + expect(resolveNativeIssueLink).not.toHaveBeenCalled(); + expect(resolveAppIssueLink).not.toHaveBeenCalled(); + expect(result).toMatchObject({ linked: false, changed: true }); + expect(formatIssueLinkResult(result)).toContain( + "external issue was not deleted" + ); + }); + + test("unlink uses the stored app record ID, without invoking a link workflow", async () => { + const result = await unlinkExternalIssue({ + ...options, + url: appLink.webUrl, + }); + expect(unlinkAppIssueLink).toHaveBeenCalledWith("example", "123", "910"); + expect(resolveAppIssueLink).not.toHaveBeenCalled(); + expect(linkAppIssue).not.toHaveBeenCalled(); + expect(result).toMatchObject({ linked: false, changed: true }); + }); + + test.each([ + nativeLink.url, + appLink.webUrl, + ])("dry-run unlink preserves the link: %s", async (url) => { + const result = await unlinkExternalIssue({ ...options, url, dryRun: true }); + expect(result).toMatchObject({ + linked: true, + changed: false, + dryRun: true, + }); + expect(unlinkNativeIssueLink).not.toHaveBeenCalled(); + expect(unlinkAppIssueLink).not.toHaveBeenCalled(); + expect(formatIssueLinkResult(result)).toContain("Would unlink"); + }); + + test.each([ + nativeLink.url, + appLink.webUrl, + ])("missing association is already unlinked: %s", async (url) => { + vi.mocked(findNativeIssueLink).mockReturnValue(undefined); + vi.mocked(findAppIssueLink).mockReturnValue(undefined); + const result = await unlinkExternalIssue({ ...options, url }); + expect(result).toMatchObject({ linked: false, changed: false }); + expect(unlinkNativeIssueLink).not.toHaveBeenCalled(); + expect(unlinkAppIssueLink).not.toHaveBeenCalled(); + }); + + test("a failed link read is not treated as an empty list", async () => { + const error = new ApiError("Forbidden", 403); + vi.mocked(listNativeIssueLinks).mockRejectedValue(error); + await expect(unlinkExternalIssue(options)).rejects.toBe(error); + expect(unlinkNativeIssueLink).not.toHaveBeenCalled(); + }); + + test("a failed write propagates without claiming success or falling back to another provider", async () => { + const error = new ApiError("Forbidden", 403); + vi.mocked(linkNativeIssue).mockRejectedValue(error); + await expect(linkExternalIssue(options)).rejects.toBe(error); + expect(linkNativeIssue).toHaveBeenCalledTimes(1); + expect(linkAppIssue).not.toHaveBeenCalled(); + expect(invalidateCachedResponsesMatching).not.toHaveBeenCalled(); + }); + + test.each([ + "not-a-url", + "file:///tmp/issue", + "https://user:secret@example.com/issue/42", + ])("invalid targets fail before API calls: %s", async (url) => { + await expect(linkExternalIssue({ ...options, url })).rejects.toThrow(); + expect(resolveNativeIssueLink).not.toHaveBeenCalled(); + expect(resolveAppIssueLink).not.toHaveBeenCalled(); + }); + + test("conflicting native and app selectors fail before API calls", async () => { + await expect( + linkExternalIssue({ + ...options, + appSlug: "linear", + integrationId: "20", + }) + ).rejects.toThrow("--integration"); + expect(resolveAppIssueLink).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/test/lib/oauth.test.ts b/packages/cli/test/lib/oauth.test.ts index 4d32db281f..2e8fbc1a9a 100644 --- a/packages/cli/test/lib/oauth.test.ts +++ b/packages/cli/test/lib/oauth.test.ts @@ -27,6 +27,12 @@ describe("resolveOAuthScopeString", () => { expect(OAUTH_SCOPES).toContain("team:admin"); }); + test("default scopes allow linking and unlinking external issues", () => { + const scopes = resolveOAuthScopeString().split(" "); + expect(scopes).toContain("event:write"); + expect(scopes).toContain("event:admin"); + }); + test("default (no selection) returns the full OAUTH_SCOPES set", () => { expect(resolveOAuthScopeString()).toBe(OAUTH_SCOPES.join(" ")); expect(resolveOAuthScopeString({})).toBe(OAUTH_SCOPES.join(" ")); diff --git a/packages/cli/test/lib/scope-recovery.test.ts b/packages/cli/test/lib/scope-recovery.test.ts index 11ff67e1ef..53b193534a 100644 --- a/packages/cli/test/lib/scope-recovery.test.ts +++ b/packages/cli/test/lib/scope-recovery.test.ts @@ -48,12 +48,44 @@ describe("runWithScopeRecovery", () => { expect(proceed).toHaveBeenCalledTimes(2); expect(testRuntime.getAuthScopes).toHaveBeenCalledOnce(); expect(testRuntime.assertTrustedHost).toHaveBeenCalledOnce(); - expect(login).toHaveBeenCalledWith(); + expect(login).toHaveBeenCalledWith({ scope: OAUTH_SCOPES.join(" ") }); expect(testRuntime.write).toHaveBeenCalledWith( expect.stringContaining("team:admin") ); }); + test("preserves custom grants when recovering issue unlink authorization", async () => { + const error = new ApiError("Forbidden", 403); + const proceed = vi + .fn<(argv: string[]) => Promise>() + .mockRejectedValueOnce(error) + .mockResolvedValueOnce(); + const login = vi.fn().mockResolvedValue({ method: "oauth" }); + const testRuntime = runtime({ + getAuthScopes: vi + .fn() + .mockResolvedValue([ + ...OAUTH_SCOPES.filter((scope) => scope !== "event:admin"), + "org:write", + ]), + }); + + await runWithScopeRecovery( + proceed, + ["issue", "unlink"], + login, + testRuntime + ); + + expect(login).toHaveBeenCalledWith({ + scope: [...OAUTH_SCOPES, "org:write"].join(" "), + }); + expect(proceed).toHaveBeenCalledTimes(2); + expect(testRuntime.write).toHaveBeenCalledWith( + expect.stringContaining("missing event:admin") + ); + }); + test("does not re-authorize a role or policy 403 when the token has every scope", async () => { const error = new ApiError("Forbidden", 403); const proceed = vi.fn().mockRejectedValue(error); @@ -155,38 +187,52 @@ describe("runWithScopeRecovery", () => { test("checks scopes but does not launch OAuth without an interactive TTY", async () => { const error = new ApiError("Forbidden", 403); - const getAuthScopes = vi.fn().mockResolvedValue([]); + const getAuthScopes = vi + .fn() + .mockResolvedValue([ + ...OAUTH_SCOPES.filter((scope) => scope !== "event:admin"), + "org:write", + ]); const login = vi.fn(); + const testRuntime = runtime({ getAuthScopes, inputIsTty: () => false }); await expect( runWithScopeRecovery( vi.fn().mockRejectedValue(error), [], login, - runtime({ getAuthScopes, inputIsTty: () => false }) + testRuntime ) ).rejects.toBe(error); expect(getAuthScopes).toHaveBeenCalledOnce(); expect(login).not.toHaveBeenCalled(); + expect(testRuntime.write).toHaveBeenCalledWith( + "Your CLI authorization is missing event:admin.\n" + + `Re-authenticate with: sentry auth refresh ${[...OAUTH_SCOPES, "org:write"].map((scope) => `--scope ${scope}`).join(" ")}\n` + ); }); test("does not launch OAuth when JSON output disables prompts", async () => { const error = new ApiError("Forbidden", 403); const getAuthScopes = vi.fn().mockResolvedValue([]); const login = vi.fn(); + const testRuntime = runtime({ getAuthScopes, promptsAllowed: () => false }); await expect( runWithScopeRecovery( vi.fn().mockRejectedValue(error), ["--json"], login, - runtime({ getAuthScopes, promptsAllowed: () => false }) + testRuntime ) ).rejects.toBe(error); expect(getAuthScopes).toHaveBeenCalledOnce(); expect(login).not.toHaveBeenCalled(); + expect(testRuntime.write).toHaveBeenCalledWith( + expect.stringContaining("sentry auth refresh --scope project:read") + ); }); test("preserves the original error when scope inspection fails", async () => { diff --git a/packages/cli/test/lib/sdk-positionals.test.ts b/packages/cli/test/lib/sdk-positionals.test.ts index 4b48c4767f..c3073d95bb 100644 --- a/packages/cli/test/lib/sdk-positionals.test.ts +++ b/packages/cli/test/lib/sdk-positionals.test.ts @@ -13,15 +13,15 @@ import { describe, expect, test } from "vitest"; import { createSDKMethods } from "../../src/sdk.generated.js"; -type RecordedCall = { path: string[]; positional: string[] }; +type RecordedCall = { path: string[]; flags: unknown; positional: string[] }; function createRecordingSDK(): { calls: RecordedCall[]; sdk: ReturnType; } { const calls: RecordedCall[] = []; - const invoke = ((path: string[], _flags: unknown, positional: string[]) => { - calls.push({ path, positional }); + const invoke = ((path: string[], flags: unknown, positional: string[]) => { + calls.push({ path, flags, positional }); return Promise.resolve(undefined); }) as Parameters[0]; @@ -29,6 +29,20 @@ function createRecordingSDK(): { } describe("generated SDK positional arguments", () => { + test("issue link forwards repeated form fields as an array", async () => { + const { calls, sdk } = createRecordingSDK(); + await sdk.issue.link({ + issue: "example/APP-42", + externalIssue: "https://linear.app/example/issue/APP-42/title", + field: ["team=engineering", "label=bug"], + }); + expect(calls[0]).toMatchObject({ + path: ["issue", "link"], + positional: ["example/APP-42"], + flags: { field: ["team=engineering", "label=bug"] }, + }); + }); + test("release deploy passes version, environment and name as separate tokens", async () => { const { calls, sdk } = createRecordingSDK(); diff --git a/packages/cli/test/lib/sentry-client.test.ts b/packages/cli/test/lib/sentry-client.test.ts index 2caaf9e330..281245f0a8 100644 --- a/packages/cli/test/lib/sentry-client.test.ts +++ b/packages/cli/test/lib/sentry-client.test.ts @@ -6,6 +6,10 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { setAuthToken } from "../../src/lib/db/auth.js"; import { TimeoutError } from "../../src/lib/errors.js"; +import { + getCachedResponse, + storeCachedResponse, +} from "../../src/lib/response-cache.js"; import { __injectTimeoutOverrideForTests, __resolveRequestTimeoutMsForTests, @@ -35,6 +39,91 @@ function getAuthenticatedFetch(): typeof fetch { return getSdkConfig(REGION_URL).fetch as typeof fetch; } +describe("per-request transport controls", () => { + test("retry false sends a mutation once for transient HTTP and network failures", async () => { + let callCount = 0; + globalThis.fetch = mockFetch(async () => { + callCount += 1; + return new Response("provider unavailable", { status: 503 }); + }); + const fetchOnce = getSdkConfig(REGION_URL, { retry: false }).fetch; + expect( + (await fetchOnce(`${REGION_URL}/api/0/issue-link/`, { method: "POST" })) + .status + ).toBe(503); + expect(callCount).toBe(1); + + const networkError = new TypeError( + "connection reset after request body sent" + ); + globalThis.fetch = mockFetch(async () => { + callCount += 1; + throw networkError; + }); + await expect( + fetchOnce(`${REGION_URL}/api/0/issue-link/`, { method: "POST" }) + ).rejects.toBe(networkError); + expect(callCount).toBe(2); + }); + + test("retry false returns an unauthorized mutation without replaying it", async () => { + let callCount = 0; + globalThis.fetch = mockFetch(async () => { + callCount += 1; + return new Response("unauthorized", { status: 401 }); + }); + const fetchOnce = getSdkConfig(REGION_URL, { retry: false }).fetch; + const response = await fetchOnce(`${REGION_URL}/api/0/issue-link/`, { + method: "POST", + }); + expect(response.status).toBe(401); + expect(callCount).toBe(1); + }); + + test("no-store preflight bypasses both cache lookup and cache storage", async () => { + const url = `${REGION_URL}/api/0/organizations/example/issues/1/external-issues/`; + const headers = { Authorization: "Bearer test-token" }; + await storeCachedResponse( + "GET", + url, + headers, + Response.json( + { source: "cached" }, + { + headers: { + "Cache-Control": "max-age=600", + Date: new Date().toUTCString(), + }, + } + ) + ); + expect( + await (await getCachedResponse("GET", url, headers))?.json() + ).toEqual({ source: "cached" }); + let callCount = 0; + globalThis.fetch = mockFetch(async (_input, init) => { + callCount += 1; + expect(init?.cache).toBe("no-store"); + return Response.json( + { source: "fresh" }, + { + headers: { + "Cache-Control": "max-age=600", + Date: new Date().toUTCString(), + }, + } + ); + }); + const freshFetch = getSdkConfig(REGION_URL, { cache: "no-store" }).fetch; + expect(await (await freshFetch(url)).json()).toEqual({ source: "fresh" }); + expect(await (await freshFetch(url)).json()).toEqual({ source: "fresh" }); + expect(callCount).toBe(2); + expect( + await (await getCachedResponse("GET", url, headers))?.json() + ).toEqual({ source: "cached" }); + }); +}); + describe("fetchWithRetry / buildAttemptFactory", () => { test("retries a POST with a string body without re-consuming the body", async () => { const marker = "__test_string_body__"; From 0b24c9064b27846d929f7757814ef3737f12229b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 8 Sep 2026 20:09:18 +0200 Subject: [PATCH 02/17] fix(issue): validate external link API responses --- packages/cli/src/lib/api/infrastructure.ts | 3 +- packages/cli/src/lib/api/issue-app-links.ts | 15 +-- .../cli/src/lib/api/issue-integrations.ts | 79 ++++++++++-- packages/cli/test/lib/api-client.test.ts | 19 +++ .../test/lib/api/issue-integrations.test.ts | 122 ++++++++++++++++-- 5 files changed, 205 insertions(+), 33 deletions(-) diff --git a/packages/cli/src/lib/api/infrastructure.ts b/packages/cli/src/lib/api/infrastructure.ts index 26b336aa09..2230bf5e80 100644 --- a/packages/cli/src/lib/api/infrastructure.ts +++ b/packages/cli/src/lib/api/infrastructure.ts @@ -21,7 +21,6 @@ import { logger } from "../logger.js"; import { resolveOrgRegion } from "../region.js"; import { getApiBaseUrl, - getDefaultSdkConfig, getSdkConfig, type SentryRequestOptions, } from "../sentry-client.js"; @@ -764,7 +763,7 @@ export async function rawApiRequest( }> { const { method = "GET", body, params, headers: customHeaders = {} } = options; - const config = getDefaultSdkConfig(); + const config = getSdkConfig(getApiBaseUrl(), options); const searchParams = buildSearchParams(params); const normalizedEndpoint = endpoint.startsWith("/") diff --git a/packages/cli/src/lib/api/issue-app-links.ts b/packages/cli/src/lib/api/issue-app-links.ts index 4508bc75ce..d59b10721b 100644 --- a/packages/cli/src/lib/api/issue-app-links.ts +++ b/packages/cli/src/lib/api/issue-app-links.ts @@ -185,6 +185,7 @@ export function findAppIssueLink( return matches[0]; } +/** Fetch a complete, validated collection; partial results cannot safely authorize a link mutation. */ async function listAll( fetchPage: ( cursor: string | undefined @@ -268,6 +269,7 @@ export async function listAppIssueLinks( ); } +/** Preserve the app's single association per Sentry issue; replacing a target requires explicit unlink. */ function checkExisting( links: AppIssueLink[], url: string, @@ -368,13 +370,14 @@ async function getLinkForm( item.type === "issue-link" && item.sentryApp.uuid === installation.app.uuid ); - if (matches.length !== 1 || !matches[0]?.schema.link) { + const component = matches[0]; + const form = component?.schema.link; + if (matches.length !== 1 || !component || !form) { throw new ValidationError( `App ${installation.app.slug} does not expose an unambiguous issue-link form`, "app" ); } - const component = matches[0]; if (component.error) { throw new ApiError( `App ${installation.app.slug} could not prepare its issue-link form`, @@ -382,10 +385,6 @@ async function getLinkForm( JSON.stringify(component.error) ); } - const form = component.schema.link; - if (!form) { - throw new ValidationError("App has no link form", "app"); - } validateUri(form.uri); return form; } @@ -434,9 +433,6 @@ async function getChoices({ schema: ChoicesResponseSchema, } ); - if (!Array.isArray(data.choices)) { - throw new ApiError("App search returned an invalid choices response", 0); - } return data.choices; } @@ -467,6 +463,7 @@ function selectChoice( return choice[0]; } +/** Resolve form dependencies while keeping the target field bound to the requested issue URL. */ async function resolveFields( options: ResolveAppIssueLinkOptions, form: LinkForm, diff --git a/packages/cli/src/lib/api/issue-integrations.ts b/packages/cli/src/lib/api/issue-integrations.ts index 80846ecd0b..7440ad8bf1 100644 --- a/packages/cli/src/lib/api/issue-integrations.ts +++ b/packages/cli/src/lib/api/issue-integrations.ts @@ -2,14 +2,27 @@ import { deleteOrganizationIssueIntegration, type ExternalIssueLinkResponse, - type IntegrationIssueConfigResponse, type LinkExternalIssueRequest, type ListOrganizationReposResponse, listOrganizationRepos, updateOrganizationIssueIntegration, } from "@sentry/api"; - -import { ValidationError } from "../errors.js"; +import { + vExternalIssueLinkResponse, + vIntegrationIssueConfigResponse, + vListOrganizationReposResponse, +} from "@sentry/api/valibot"; +import { + array, + type InferOutput, + nullish, + object, + optional, + pick, + safeParse, + string, +} from "valibot"; +import { ApiError, ValidationError } from "../errors.js"; import { resolveOrgRegion } from "../region.js"; import { getSdkConfig } from "../sentry-client.js"; import { @@ -37,12 +50,30 @@ export type NativeIssueLink = Pick< title?: string; }; -type NativeIntegration = Pick< - IntegrationIssueConfigResponse, - "id" | "name" | "domainName" | "status" | "provider" -> & { - externalIssues: Omit[]; -}; +// The private list serializes link IDs as strings; the SDK mutation uses numbers. +const NativeIntegrationSchema = object({ + ...pick(vIntegrationIssueConfigResponse, [ + "id", + "name", + "domainName", + "status", + "provider", + ]).entries, + externalIssues: array( + object({ + ...pick(vExternalIssueLinkResponse, ["key", "url", "displayName"]) + .entries, + id: string(), + title: nullish(string()), + }) + ), +}); +const NativeIntegrationsSchema = array(NativeIntegrationSchema); +const NativeIssueMutationSchema = object({ + ...vExternalIssueLinkResponse.entries, + title: optional(string()), +}); +type NativeIntegration = InferOutput; /** Read-only resolution result used for previews and a subsequent link mutation. */ export type PreparedNativeIssueLink = { @@ -233,6 +264,11 @@ function parseTarget( } } +/** + * Retrieve every page before choosing an integration or checking existing links. + * Partial results could miss a duplicate or hide an ambiguous match, so reaching + * the safety limit must fail instead of returning the partial list from autoPaginate. + */ async function listFreshPages( fetchPage: (cursor?: string) => Promise> ): Promise { @@ -264,6 +300,7 @@ async function listIntegrations( { params: { cursor, per_page: API_MAX_PER_PAGE }, cache: "no-store", + schema: NativeIntegrationsSchema, } ); return { @@ -287,10 +324,18 @@ async function listFreshRepositories( path: { organization_id_or_slug: orgSlug }, query, }); - return unwrapPaginatedResult( + const page = unwrapPaginatedResult( result, "Failed to list repositories" ); + const parsed = safeParse(vListOrganizationReposResponse, page.data); + if (!parsed.success) { + throw new ApiError( + "Unexpected response format when listing repositories", + 0 + ); + } + return { ...page, data: parsed.output }; }); } @@ -299,6 +344,7 @@ function flattenLinks(integrations: NativeIntegration[]): NativeIssueLink[] { integration.externalIssues.map((link) => ({ ...link, id: String(link.id), + title: link.title ?? undefined, integrationId: integration.id, provider: integration.provider.key, url: @@ -479,10 +525,17 @@ export async function linkNativeIssue( }, body: prepared.body, }); - const data = unwrapResult( - result, - "Failed to link external issue" + const parsed = safeParse( + NativeIssueMutationSchema, + unwrapResult(result, "Failed to link external issue") ); + if (!parsed.success) { + throw new ApiError( + "Unexpected response format after linking; inspect the current links before retrying", + 0 + ); + } + const data = parsed.output; return { link: { ...data, diff --git a/packages/cli/test/lib/api-client.test.ts b/packages/cli/test/lib/api-client.test.ts index a50479197c..dba264bce2 100644 --- a/packages/cli/test/lib/api-client.test.ts +++ b/packages/cli/test/lib/api-client.test.ts @@ -326,6 +326,25 @@ describe("buildSearchParams", () => { }); describe("rawApiRequest", () => { + test("honors per-request retry and cache controls", async () => { + const requests: Request[] = []; + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push(new Request(input, init)); + return Response.json({ detail: "Service unavailable" }, { status: 503 }); + }; + + const result = await rawApiRequest("test/", { + method: "POST", + body: { action: "link" }, + retry: false, + cache: "no-store", + }); + + expect(result.status).toBe(503); + expect(requests).toHaveLength(1); + expect(requests[0]?.cache).toBe("no-store"); + }); + test("sends GET request without body", async () => { const requests: Request[] = []; diff --git a/packages/cli/test/lib/api/issue-integrations.test.ts b/packages/cli/test/lib/api/issue-integrations.test.ts index 3b9901ec69..ec5d609f26 100644 --- a/packages/cli/test/lib/api/issue-integrations.test.ts +++ b/packages/cli/test/lib/api/issue-integrations.test.ts @@ -9,6 +9,7 @@ import { } from "../../../src/lib/api/issue-integrations.js"; import { setAuthToken } from "../../../src/lib/db/auth.js"; import { setOrgRegion } from "../../../src/lib/db/regions.js"; +import { ApiError } from "../../../src/lib/errors.js"; import { mockFetch, useTestConfigDir } from "../../helpers.js"; const REGION = "https://eu.sentry.io"; @@ -36,12 +37,30 @@ function integration( id, name: `Example ${provider}`, domainName, - provider: { key: provider }, + provider: { + key: provider, + slug: provider, + name: `Example ${provider}`, + canAdd: true, + canDisable: false, + features: ["issue-basic"], + aspects: {}, + }, status: "active", externalIssues, }; } +function repository(name: string, integrationId: string) { + return { + id: "100", + name, + integrationId, + status: "active", + dateCreated: "2026-01-01T00:00:00Z", + }; +} + function json(data: unknown, headers?: HeadersInit): Response { return Response.json(data, { status: 200, headers }); } @@ -165,9 +184,7 @@ describe("native tracker issue links", () => { ]); } expect(pathname).toBe(REPOSITORIES); - return json([ - { name: "Owner/Repo", integrationId: "20", status: "active" }, - ]); + return json([repository("Owner/Repo", "20")]); }); const prepared = await resolveNativeIssueLink({ @@ -188,7 +205,7 @@ describe("native tracker issue links", () => { json( new URL(request.url).pathname === INTEGRATIONS ? [integration("github", "github.com/owner")] - : [{ name: "owner/repo", integrationId: "20", status: "active" }] + : [repository("owner/repo", "20")] ) ); await expect( @@ -199,6 +216,48 @@ describe("native tracker issue links", () => { ).rejects.toThrow("No installed native"); }); + test.each([ + { name: "non-array page", data: {} }, + { + name: "missing link array", + data: [{ ...integration(), externalIssues: undefined }], + }, + { + name: "invalid link record", + data: [{ ...integration(), externalIssues: [{}] }], + }, + ])("rejects an invalid integration response: $name", async ({ data }) => { + const requests = mockApi(() => json(data)); + await expect( + resolveNativeIssueLink({ ...SOURCE, url: JIRA_URL }).then(linkNativeIssue) + ).rejects.toBeInstanceOf(ApiError); + expect(requests.map((request) => request.method)).toEqual(["GET"]); + }); + + test.each([ + { name: "empty 204", response: () => new Response(null, { status: 204 }) }, + { name: "non-array page", response: () => json({}) }, + { + name: "invalid repository", + response: () => json([{ ...repository("owner/repo", "10"), name: 42 }]), + }, + ])("rejects an invalid SDK repository response: $name", async ({ + response, + }) => { + const requests = mockApi((request) => + new URL(request.url).pathname === INTEGRATIONS + ? json([integration("github", "github.com/owner")]) + : response() + ); + await expect( + resolveNativeIssueLink({ + ...SOURCE, + url: "https://github.com/owner/repo/issues/7", + }).then(linkNativeIssue) + ).rejects.toBeInstanceOf(ApiError); + expect(requests.map((request) => request.method)).toEqual(["GET", "GET"]); + }); + test("preserves repository pagination and provider body fields through the SDK", async () => { const githubLink = { ...LINK, @@ -216,7 +275,7 @@ describe("native tracker issue links", () => { repo: "owner/repo", externalIssue: "7", }); - return json(githubLink); + return json({ ...githubLink, id: 1234, integrationId: 10 }); } expect(request.method).toBe("GET"); if (url.pathname === INTEGRATIONS) { @@ -230,9 +289,7 @@ describe("native tracker issue links", () => { }); } expect(url.searchParams.get("cursor")).toBe("second"); - return json([ - { name: "owner/repo", integrationId: "10", status: "active" }, - ]); + return json([repository("owner/repo", "10")]); }); const prepared = await resolveNativeIssueLink({ @@ -310,6 +367,25 @@ describe("native tracker issue links", () => { ]); }); + test.each([ + { name: "empty 204", response: () => new Response(null, { status: 204 }) }, + { name: "empty object", response: () => json({}) }, + { name: "invalid numeric IDs", response: () => json(LINK) }, + ])("does not report success for an invalid SDK mutation response: $name", async ({ + response, + }) => { + const requests = mockApi((request) => + request.method === "GET" ? json([integration()]) : response() + ); + const prepared = await resolveNativeIssueLink({ ...SOURCE, url: JIRA_URL }); + const mutation = linkNativeIssue(prepared); + await expect(mutation).rejects.toBeInstanceOf(ApiError); + await expect(mutation).rejects.toThrow( + "inspect the current links before retrying" + ); + expect(requests.map((request) => request.method)).toEqual(["GET", "PUT"]); + }); + test.each([ "PUT", "DELETE", @@ -348,6 +424,34 @@ describe("native tracker issue links", () => { expect(requests).toHaveLength(1); }); + test("lists and unlinks an existing issue whose title is null", async () => { + const requests = mockApi((request) => { + if (request.method === "GET") { + return json([ + { ...integration(), externalIssues: [{ ...LINK, title: null }] }, + ]); + } + expect(request.method).toBe("DELETE"); + expect(new URL(request.url).searchParams.get("externalIssue")).toBe( + "1234" + ); + return new Response(null, { status: 204 }); + }); + const link = findNativeIssueLink( + await listNativeIssueLinks(SOURCE.orgSlug, SOURCE.issueId), + JIRA_URL + ); + expect(link).toEqual({ ...LINK, title: undefined }); + if (!link) { + throw new Error("Expected the existing external issue link"); + } + await unlinkNativeIssueLink(SOURCE.orgSlug, SOURCE.issueId, link); + expect(requests.map((request) => request.method)).toEqual([ + "GET", + "DELETE", + ]); + }); + test("rejects an unlink ID that the SDK numeric query cannot represent exactly", async () => { const requests = mockApi(() => new Response(null, { status: 204 })); await expect( From 578a09b068719554b8cc3ce2641b156252916ddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 8 Sep 2026 20:58:43 +0200 Subject: [PATCH 03/17] fix(issue): link and unlink GitHub pull request references --- apps/cli-docs/src/fragments/commands/issue.md | 14 +- .../skills/sentry-cli/references/issue.md | 6 +- packages/cli/src/commands/issue/link-utils.ts | 2 +- packages/cli/src/commands/issue/link.ts | 3 +- packages/cli/src/commands/issue/unlink.ts | 3 +- .../cli/src/lib/api/issue-integrations.ts | 34 ++-- .../test/lib/api/issue-integrations.test.ts | 151 +++++++++++++++++- 7 files changed, 188 insertions(+), 25 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/issue.md b/apps/cli-docs/src/fragments/commands/issue.md index 37974cf34b..040c81b87f 100644 --- a/apps/cli-docs/src/fragments/commands/issue.md +++ b/apps/cli-docs/src/fragments/commands/issue.md @@ -300,10 +300,11 @@ sentry issue ignore CLI-G5 --until auto ### Link an external issue -Link an existing tracker issue to a Sentry issue: +Link an existing tracker issue or GitHub pull request to a Sentry issue: ```bash sentry issue link FRONT-123 --external-issue https://github.com/example/app/issues/42 +sentry issue link FRONT-123 --external-issue https://github.com/example/app/pull/43 sentry issue link FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 sentry issue link FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error ``` @@ -325,9 +326,13 @@ write. The provider validates the remote issue when the link is submitted. An existing matching link succeeds with `changed: false`. A Sentry App that already links this issue to a different resource must be unlinked first. -This command creates an association only. It does not create a tracker issue, -resolve the Sentry issue, or link a commit or pull request. Existing integration -status-sync settings continue to apply after linking. +GitHub and GitHub Enterprise pull requests are stored as external references. +Their `/pull/NUMBER` and `/issues/NUMBER` URLs identify the same resource for +duplicate detection and unlinking. Linking a PR does not mark it as a fix or +resolve the Sentry issue. + +This command does not create a tracker issue or link a commit. Existing +integration status-sync settings continue to apply after linking. #### Link permissions @@ -343,6 +348,7 @@ Remove an association without deleting either issue: ```bash sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/issues/42 +sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/pull/43 --yes sentry issue unlink my-org/FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 --yes sentry issue unlink FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error --dry-run ``` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md index e1be9ebdef..dde651bea4 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -358,7 +358,7 @@ sentry issue merge cli-k9 cli-15h --into cli-k9 # alias form Link an existing external issue **Flags:** -- `--external-issue - URL of an existing external issue to link or unlink` +- `--external-issue - URL of an existing tracker issue or GitHub pull request` - `--integration - Native integration ID, when multiple installations match` - `--app - Sentry App slug (automatically detected for Linear URLs)` - `-n, --dry-run - Show what would happen without making changes` @@ -368,6 +368,7 @@ Link an existing external issue ```bash sentry issue link FRONT-123 --external-issue https://github.com/example/app/issues/42 +sentry issue link FRONT-123 --external-issue https://github.com/example/app/pull/43 sentry issue link FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 sentry issue link FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error @@ -380,7 +381,7 @@ sentry issue link my-org/FRONT-123 --external-issue https://github.com/example/a Unlink an external issue **Flags:** -- `--external-issue - URL of an existing external issue to link or unlink` +- `--external-issue - URL of an existing tracker issue or GitHub pull request` - `--integration - Native integration ID, when multiple installations match` - `--app - Sentry App slug (automatically detected for Linear URLs)` - `-y, --yes - Skip confirmation prompt` @@ -391,6 +392,7 @@ Unlink an external issue ```bash sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/issues/42 +sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/pull/43 --yes sentry issue unlink my-org/FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 --yes sentry issue unlink FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error --dry-run ``` diff --git a/packages/cli/src/commands/issue/link-utils.ts b/packages/cli/src/commands/issue/link-utils.ts index 5287df6c6d..fd2c5b08ed 100644 --- a/packages/cli/src/commands/issue/link-utils.ts +++ b/packages/cli/src/commands/issue/link-utils.ts @@ -7,7 +7,7 @@ export const EXTERNAL_ISSUE_FLAGS = { "external-issue": { kind: "parsed", parse: String, - brief: "URL of an existing external issue to link or unlink", + brief: "URL of an existing tracker issue or GitHub pull request", }, integration: { kind: "parsed", diff --git a/packages/cli/src/commands/issue/link.ts b/packages/cli/src/commands/issue/link.ts index 02e1a8093e..37392d9ab8 100644 --- a/packages/cli/src/commands/issue/link.ts +++ b/packages/cli/src/commands/issue/link.ts @@ -22,12 +22,13 @@ export const linkCommand = buildCommand({ docs: { brief: "Link an existing external issue", fullDescription: - "Link an existing GitHub, Jira, Linear, or other supported tracker issue.\n" + + "Link an existing tracker issue or GitHub pull request as an external reference.\n" + "The integration must be installed in your Sentry organization.\n" + "This does not create a remote issue or resolve the Sentry issue.\n\n" + "Requires event:write and access to the Sentry project.\n\n" + "Examples:\n" + " sentry issue link FRONT-123 --external-issue https://github.com/example/app/issues/42\n" + + " sentry issue link FRONT-123 --external-issue https://github.com/example/app/pull/43\n" + " sentry issue link my-org/FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42\n" + " sentry issue link FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error\n" + " sentry issue link FRONT-123 --external-issue https://github.com/example/app/issues/42 --dry-run", diff --git a/packages/cli/src/commands/issue/unlink.ts b/packages/cli/src/commands/issue/unlink.ts index 7978d3cdde..b2ff78ec4b 100644 --- a/packages/cli/src/commands/issue/unlink.ts +++ b/packages/cli/src/commands/issue/unlink.ts @@ -25,12 +25,13 @@ export const unlinkCommand = buildDeleteCommand({ docs: { brief: "Unlink an external issue", fullDescription: - "Remove the link between a Sentry issue and an external tracker issue.\n" + + "Remove an external tracker issue or GitHub pull request reference from a Sentry issue.\n" + "This does not delete the external issue or change the Sentry issue's status.\n\n" + "Requires event:admin and access to the Sentry project.\n" + "Your token must include event:admin even if your project role grants it.\n\n" + "Examples:\n" + " sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/issues/42\n" + + " sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/pull/43 --yes\n" + " sentry issue unlink my-org/FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 --yes\n" + " sentry issue unlink FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error --dry-run", }, diff --git a/packages/cli/src/lib/api/issue-integrations.ts b/packages/cli/src/lib/api/issue-integrations.ts index 7440ad8bf1..434358e1f4 100644 --- a/packages/cli/src/lib/api/issue-integrations.ts +++ b/packages/cli/src/lib/api/issue-integrations.ts @@ -101,6 +101,7 @@ type ParsedTarget = Pick; const TRAILING_SLASH = /\/+$/; const REPOSITORY_ISSUE = /^\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/[^/]+)?$/; +const GITHUB_PULL_REQUEST = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/[^/]+)?$/; const GITLAB_ISSUE = /^\/(.+?)(?:\/-)?\/issues\/(\d+)$/; const JIRA_ISSUE = /^(.*?)\/browse\/([A-Z][A-Z0-9_]*-\d+)$/i; const WORK_ITEM = /^(.*?)\/_workitems\/edit\/(\d+)$/; @@ -147,15 +148,22 @@ function integrationUrl(integration: NativeIntegration): URL | undefined { return parseUrl(domain.includes("://") ? domain : `https://${domain}`); } -function parseRepositoryIssue(url: URL): ParsedTarget | undefined { - const match = REPOSITORY_ISSUE.exec(url.pathname); +function parseRepositoryIssue( + url: URL, + provider: string +): ParsedTarget | undefined { + // GitHub exposes PRs through its issue API; both URL forms share repo#number. + const pullRequest = ["github", "github_enterprise"].includes(provider) + ? GITHUB_PULL_REQUEST.exec(url.pathname) + : null; + const match = pullRequest ?? REPOSITORY_ISSUE.exec(url.pathname); if (!(match?.[1] && match[2])) { return; } const repo = match[1]; const number = match[2]; return { - url: `${url.origin}/${repo}/issues/${number}`, + url: `${url.origin}/${repo}/${pullRequest ? "pull" : "issues"}/${number}`, key: `${repo}#${number}`, body: { repo, externalIssue: number }, }; @@ -242,7 +250,7 @@ function parseTarget( return; } if (["github", "github_enterprise", "bitbucket"].includes(provider)) { - const target = parseRepositoryIssue(url); + const target = parseRepositoryIssue(url, provider); const account = domain.pathname.split("/").find(Boolean); if ( account && @@ -388,9 +396,11 @@ function matchesNativeUrl(link: NativeIssueLink, target: URL): boolean { ); } if (["github", "github_enterprise", "bitbucket"].includes(link.provider)) { - const key = parseRepositoryIssue(target)?.key.toLowerCase(); + // Sentry's list response can reconstruct /issues/N for a linked /pull/N. + const key = parseRepositoryIssue(target, link.provider)?.key.toLowerCase(); return Boolean( - key && key === parseRepositoryIssue(existing)?.key.toLowerCase() + key && + key === parseRepositoryIssue(existing, link.provider)?.key.toLowerCase() ); } if (["jira", "jira_server"].includes(link.provider)) { @@ -428,9 +438,12 @@ export async function resolveNativeIssueLink(options: { integrationId?: string; }): Promise { const url = parseUrl(options.url); - if (SCM_CHANGE.test(url.pathname)) { + if ( + SCM_CHANGE.test(url.pathname) && + !GITHUB_PULL_REQUEST.test(url.pathname) + ) { throw new ValidationError( - "External issue linking accepts tracker issues, not commits or pull requests." + "External issue linking supports tracker issues and GitHub pull requests." ); } const integrations = await listIntegrations(options.orgSlug, options.issueId); @@ -471,7 +484,10 @@ export async function resolveNativeIssueLink(options: { ...target, body: { ...target.body, repo: repository.name }, key: `${repository.name}#${target.body.externalIssue}`, - url: `${url.origin}/${repository.name}/issues/${target.body.externalIssue}`, + url: target.url.replace( + `/${target.body.repo}/`, + `/${repository.name}/` + ), }, }, ]; diff --git a/packages/cli/test/lib/api/issue-integrations.test.ts b/packages/cli/test/lib/api/issue-integrations.test.ts index ec5d609f26..8096b237ce 100644 --- a/packages/cli/test/lib/api/issue-integrations.test.ts +++ b/packages/cli/test/lib/api/issue-integrations.test.ts @@ -10,6 +10,10 @@ import { import { setAuthToken } from "../../../src/lib/db/auth.js"; import { setOrgRegion } from "../../../src/lib/db/regions.js"; import { ApiError } from "../../../src/lib/errors.js"; +import { + linkExternalIssue, + unlinkExternalIssue, +} from "../../../src/lib/issue-links.js"; import { mockFetch, useTestConfigDir } from "../../helpers.js"; const REGION = "https://eu.sentry.io"; @@ -168,11 +172,18 @@ describe("native tracker issue links", () => { }); test.each([ - { provider: "github", host: "github.com" }, - { provider: "github_enterprise", host: "github.example.com" }, - ])("matches the registered $provider installation and spelling", async ({ + { provider: "github", host: "github.com", path: "issues" }, + { + provider: "github_enterprise", + host: "github.example.com", + path: "issues", + }, + { provider: "github", host: "github.com", path: "pull" }, + { provider: "github_enterprise", host: "github.example.com", path: "pull" }, + ])("prepares $provider /$path with the registered repository spelling", async ({ provider, host, + path, }) => { const requests = mockApi((request) => { const { pathname } = new URL(request.url); @@ -189,17 +200,125 @@ describe("native tracker issue links", () => { const prepared = await resolveNativeIssueLink({ ...SOURCE, - url: `https://${host}/OWNER/repo/issues/7`, + url: `https://${host}/OWNER/repo/${path}/7`, }); expect(prepared).toMatchObject({ integrationId: "20", body: { repo: "Owner/Repo", externalIssue: "7" }, - url: `https://${host}/Owner/Repo/issues/7`, + url: `https://${host}/Owner/Repo/${path}/7`, }); expect(requests).toHaveLength(2); }); + test.each([ + { provider: "github", host: "github.com" }, + { provider: "github_enterprise", host: "github.example.com" }, + ])("links, recognizes and unlinks a $provider PR across both URL forms", async ({ + provider, + host, + }) => { + const pullUrl = `https://${host}/Owner/Repo/pull/7`; + const issueUrl = `https://${host}/Owner/Repo/issues/7`; + const storedLink = { + ...LINK, + provider, + key: "Owner/Repo#7", + displayName: "Owner/Repo#7", + url: issueUrl, + }; + let linked = false; + const requests = mockApi(async (request) => { + const url = new URL(request.url); + expect(url.origin).toBe(REGION); + if (request.method === "PUT") { + expect(url.pathname).toBe(`${INTEGRATIONS}10/`); + expect(await request.json()).toEqual({ + repo: "Owner/Repo", + externalIssue: "7", + }); + linked = true; + // The mutation uses GitHub's html_url; listing reconstructs /issues/N. + return json({ + ...storedLink, + id: 1234, + integrationId: 10, + url: pullUrl, + }); + } + if (request.method === "DELETE") { + expect(url.pathname).toBe(`${INTEGRATIONS}10/`); + expect(url.searchParams.get("externalIssue")).toBe("1234"); + linked = false; + return new Response(null, { status: 204 }); + } + expect(request.method).toBe("GET"); + if (url.pathname === INTEGRATIONS) { + return json([ + integration( + provider, + `${host}/owner`, + "10", + linked ? [storedLink] : [] + ), + ]); + } + expect(url.pathname).toBe(REPOSITORIES); + return json([repository("Owner/Repo", "10")]); + }); + + const options = { + ...SOURCE, + url: `https://${host}/OWNER/repo/pull/7/files?source=cli#diff`, + }; + expect(await linkExternalIssue(options)).toMatchObject({ + linked: true, + changed: true, + externalIssue: { id: "1234", identifier: "Owner/Repo#7", url: pullUrl }, + }); + for (const url of [pullUrl, issueUrl]) { + expect(await linkExternalIssue({ ...SOURCE, url })).toMatchObject({ + linked: true, + changed: false, + externalIssue: { id: "1234" }, + }); + } + expect( + await unlinkExternalIssue({ ...options, dryRun: true }) + ).toMatchObject({ + linked: true, + changed: false, + dryRun: true, + }); + expect(await unlinkExternalIssue(options)).toMatchObject({ + linked: false, + changed: true, + externalIssue: { id: "1234" }, + }); + expect(await unlinkExternalIssue(options)).toMatchObject({ + linked: false, + changed: false, + }); + expect( + requests + .filter((request) => request.method !== "GET") + .map((request) => request.method) + ).toEqual(["PUT", "DELETE"]); + }); + + test("does not treat a Bitbucket pull request as an issue", async () => { + const requests = mockApi(() => + json([integration("bitbucket", "bitbucket.org/owner")]) + ); + await expect( + resolveNativeIssueLink({ + ...SOURCE, + url: "https://bitbucket.org/owner/repo/pull/7", + }) + ).rejects.toThrow("No installed native"); + expect(requests.every((request) => request.method === "GET")).toBe(true); + }); + test("does not select GitHub repositories absent from the installation", async () => { mockApi((request) => json( @@ -511,7 +630,8 @@ describe("native tracker issue links", () => { }); test.each([ - "https://github.com/owner/repo/pull/7", + "https://github.com/owner/repo/pulls/7", + "https://bitbucket.org/owner/repo/pull-requests/7", "https://gitlab.com/owner/repo/-/merge_requests/7", "https://github.com/owner/repo/commit/abcdef", "https://username:secret@tracker.example.com/browse/PROJ-7", @@ -568,7 +688,7 @@ describe("findNativeIssueLink", () => { expect(findNativeIssueLink([LINK, second], JIRA_URL, "20")).toBe(second); }); - test("never equates invalid URLs just because both lack a parsed issue key", () => { + test("distinguishes GitHub PR numbers, repositories and hosts", () => { const link = { ...LINK, provider: "github", @@ -577,6 +697,23 @@ describe("findNativeIssueLink", () => { expect( findNativeIssueLink([link], "https://github.com/owner/repo/pull/8") ).toBeUndefined(); + expect( + findNativeIssueLink([link], "https://github.com/owner/other/pull/7") + ).toBeUndefined(); + expect( + findNativeIssueLink([link], "https://other.example.com/owner/repo/pull/7") + ).toBeUndefined(); + }); + + test("never equates invalid URLs just because both lack a parsed issue key", () => { + const link = { + ...LINK, + provider: "github", + url: "https://github.com/owner/repo/commit/abc", + }; + expect( + findNativeIssueLink([link], "https://github.com/owner/repo/commit/def") + ).toBeUndefined(); expect( findNativeIssueLink([LINK], "https://other.example.com/browse/PROJ-7") ).toBeUndefined(); From c5fe6b18c6aa6cfc9c22a9b93dc979b77737ea5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Wed, 9 Sep 2026 13:36:21 +0200 Subject: [PATCH 04/17] fix(issue): accept external link URLs as positionals --- apps/cli-docs/src/fragments/commands/issue.md | 20 +++++------ .../sentry-cli/skills/sentry-cli/SKILL.md | 4 +-- .../skills/sentry-cli/references/issue.md | 26 +++++++------- packages/cli/src/commands/issue/link-utils.ts | 19 ++++++++--- packages/cli/src/commands/issue/link.ts | 32 ++++++++++------- packages/cli/src/commands/issue/unlink.ts | 29 ++++++++++------ packages/cli/src/lib/issue-links.ts | 4 +-- .../cli/test/commands/issue/link.func.test.ts | 29 +++++++++++----- .../test/commands/issue/unlink.func.test.ts | 34 +++++++++++++------ .../lib/scanner-flags.integration.test.ts | 34 ++++++++++++++++++- packages/cli/test/lib/sdk-positionals.test.ts | 23 +++++++++++-- 11 files changed, 175 insertions(+), 79 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/issue.md b/apps/cli-docs/src/fragments/commands/issue.md index 040c81b87f..68305c4199 100644 --- a/apps/cli-docs/src/fragments/commands/issue.md +++ b/apps/cli-docs/src/fragments/commands/issue.md @@ -303,10 +303,10 @@ sentry issue ignore CLI-G5 --until auto Link an existing tracker issue or GitHub pull request to a Sentry issue: ```bash -sentry issue link FRONT-123 --external-issue https://github.com/example/app/issues/42 -sentry issue link FRONT-123 --external-issue https://github.com/example/app/pull/43 -sentry issue link FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 -sentry issue link FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error +sentry issue link FRONT-123 https://github.com/example/app/issues/42 +sentry issue link FRONT-123 https://github.com/example/app/pull/43 +sentry issue link FRONT-123 https://example.atlassian.net/browse/APP-42 +sentry issue link FRONT-123 https://linear.app/example/issue/APP-42/fix-error ``` The matching integration must already be installed in the Sentry organization. @@ -317,8 +317,8 @@ Other Sentry Apps require `--app ` and must expose an issue-link form; additional required form values can be supplied with `--field name=value`. ```bash -sentry issue link my-org/FRONT-123 --external-issue https://github.com/example/app/issues/42 --dry-run -sentry issue link my-org/FRONT-123 --external-issue https://github.com/example/app/issues/42 --json +sentry issue link my-org/FRONT-123 https://github.com/example/app/issues/42 --dry-run +sentry issue link my-org/FRONT-123 https://github.com/example/app/issues/42 --json ``` `--dry-run` discovers the integration and prepares the link without submitting a @@ -347,10 +347,10 @@ error. Environment tokens must be updated separately. Remove an association without deleting either issue: ```bash -sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/issues/42 -sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/pull/43 --yes -sentry issue unlink my-org/FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 --yes -sentry issue unlink FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error --dry-run +sentry issue unlink FRONT-123 https://github.com/example/app/issues/42 +sentry issue unlink FRONT-123 https://github.com/example/app/pull/43 --yes +sentry issue unlink my-org/FRONT-123 https://example.atlassian.net/browse/APP-42 --yes +sentry issue unlink FRONT-123 https://linear.app/example/issue/APP-42/fix-error --dry-run ``` Use `--yes` for non-interactive execution. `--dry-run` shows whether the link diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index cd4e3d6954..9987792ed0 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -382,8 +382,8 @@ Manage Sentry issues - `sentry issue unresolve ` — Reopen a resolved issue - `sentry issue archive ` — Archive (ignore) an issue - `sentry issue merge ` — Merge 2+ issues into a single canonical group -- `sentry issue link ` — Link an existing external issue -- `sentry issue unlink ` — Unlink an external issue +- `sentry issue link ` — Link an existing external issue +- `sentry issue unlink ` — Unlink an external issue → Full flags and examples: `references/issue.md` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md index dde651bea4..ef22a643bb 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -353,12 +353,11 @@ sentry issue merge cli-k9 cli-15h --into cli-k9 # alias form # Non-error issue types (performance, info, etc.) cannot be merged ``` -### `sentry issue link ` +### `sentry issue link ` Link an existing external issue **Flags:** -- `--external-issue - URL of an existing tracker issue or GitHub pull request` - `--integration - Native integration ID, when multiple installations match` - `--app - Sentry App slug (automatically detected for Linear URLs)` - `-n, --dry-run - Show what would happen without making changes` @@ -367,21 +366,20 @@ Link an existing external issue **Examples:** ```bash -sentry issue link FRONT-123 --external-issue https://github.com/example/app/issues/42 -sentry issue link FRONT-123 --external-issue https://github.com/example/app/pull/43 -sentry issue link FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 -sentry issue link FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error +sentry issue link FRONT-123 https://github.com/example/app/issues/42 +sentry issue link FRONT-123 https://github.com/example/app/pull/43 +sentry issue link FRONT-123 https://example.atlassian.net/browse/APP-42 +sentry issue link FRONT-123 https://linear.app/example/issue/APP-42/fix-error -sentry issue link my-org/FRONT-123 --external-issue https://github.com/example/app/issues/42 --dry-run -sentry issue link my-org/FRONT-123 --external-issue https://github.com/example/app/issues/42 --json +sentry issue link my-org/FRONT-123 https://github.com/example/app/issues/42 --dry-run +sentry issue link my-org/FRONT-123 https://github.com/example/app/issues/42 --json ``` -### `sentry issue unlink ` +### `sentry issue unlink ` Unlink an external issue **Flags:** -- `--external-issue - URL of an existing tracker issue or GitHub pull request` - `--integration - Native integration ID, when multiple installations match` - `--app - Sentry App slug (automatically detected for Linear URLs)` - `-y, --yes - Skip confirmation prompt` @@ -391,10 +389,10 @@ Unlink an external issue **Examples:** ```bash -sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/issues/42 -sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/pull/43 --yes -sentry issue unlink my-org/FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 --yes -sentry issue unlink FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error --dry-run +sentry issue unlink FRONT-123 https://github.com/example/app/issues/42 +sentry issue unlink FRONT-123 https://github.com/example/app/pull/43 --yes +sentry issue unlink my-org/FRONT-123 https://example.atlassian.net/browse/APP-42 --yes +sentry issue unlink FRONT-123 https://linear.app/example/issue/APP-42/fix-error --dry-run ``` All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/packages/cli/src/commands/issue/link-utils.ts b/packages/cli/src/commands/issue/link-utils.ts index fd2c5b08ed..107bc71ee0 100644 --- a/packages/cli/src/commands/issue/link-utils.ts +++ b/packages/cli/src/commands/issue/link-utils.ts @@ -1,14 +1,23 @@ /** Shared arguments for external issue association commands. */ import { ValidationError } from "../../lib/errors.js"; +import { issueIdPositional } from "./utils.js"; + +/** Required source issue and existing external resource URL for link and unlink. */ +export const EXTERNAL_ISSUE_POSITIONALS = { + kind: "tuple", + parameters: [ + ...issueIdPositional.parameters, + { + placeholder: "url", + parse: String, + brief: "URL of an existing tracker issue or GitHub pull request", + }, + ], +} as const; /** Flags identifying an existing external issue and its Sentry integration. */ export const EXTERNAL_ISSUE_FLAGS = { - "external-issue": { - kind: "parsed", - parse: String, - brief: "URL of an existing tracker issue or GitHub pull request", - }, integration: { kind: "parsed", parse: String, diff --git a/packages/cli/src/commands/issue/link.ts b/packages/cli/src/commands/issue/link.ts index 37392d9ab8..dc00f12e65 100644 --- a/packages/cli/src/commands/issue/link.ts +++ b/packages/cli/src/commands/issue/link.ts @@ -7,11 +7,14 @@ import { formatIssueLinkResult } from "../../lib/formatters/issue-links.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { linkExternalIssue } from "../../lib/issue-links.js"; import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../lib/mutate-command.js"; -import { EXTERNAL_ISSUE_FLAGS, parseIssueLinkFields } from "./link-utils.js"; -import { issueIdPositional, resolveIssue } from "./utils.js"; +import { + EXTERNAL_ISSUE_FLAGS, + EXTERNAL_ISSUE_POSITIONALS, + parseIssueLinkFields, +} from "./link-utils.js"; +import { resolveIssue } from "./utils.js"; type LinkFlags = { - readonly "external-issue": string; readonly integration?: string; readonly app?: string; readonly field?: string[]; @@ -27,15 +30,15 @@ export const linkCommand = buildCommand({ "This does not create a remote issue or resolve the Sentry issue.\n\n" + "Requires event:write and access to the Sentry project.\n\n" + "Examples:\n" + - " sentry issue link FRONT-123 --external-issue https://github.com/example/app/issues/42\n" + - " sentry issue link FRONT-123 --external-issue https://github.com/example/app/pull/43\n" + - " sentry issue link my-org/FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42\n" + - " sentry issue link FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error\n" + - " sentry issue link FRONT-123 --external-issue https://github.com/example/app/issues/42 --dry-run", + " sentry issue link FRONT-123 https://github.com/example/app/issues/42\n" + + " sentry issue link FRONT-123 https://github.com/example/app/pull/43\n" + + " sentry issue link my-org/FRONT-123 https://example.atlassian.net/browse/APP-42\n" + + " sentry issue link FRONT-123 https://linear.app/example/issue/APP-42/fix-error\n" + + " sentry issue link FRONT-123 https://github.com/example/app/issues/42 --dry-run", }, output: { human: formatIssueLinkResult }, parameters: { - positional: issueIdPositional, + positional: EXTERNAL_ISSUE_POSITIONALS, flags: { ...EXTERNAL_ISSUE_FLAGS, "dry-run": DRY_RUN_FLAG, @@ -49,7 +52,12 @@ export const linkCommand = buildCommand({ }, aliases: DRY_RUN_ALIASES, }, - async *func(this: SentryContext, flags: LinkFlags, issueArg: string) { + async *func( + this: SentryContext, + flags: LinkFlags, + issueArg: string, + url: string + ) { const fields = parseIssueLinkFields(flags.field); const { org, issue } = await resolveIssue({ issueArg, @@ -59,14 +67,14 @@ export const linkCommand = buildCommand({ if (!org) { throw new ContextError( "Organization", - "sentry issue link /ISSUE --external-issue " + "sentry issue link /ISSUE " ); } const result = await linkExternalIssue({ orgSlug: org, issueId: issue.id, projectId: issue.project?.id, - url: flags["external-issue"], + url, integrationId: flags.integration, appSlug: flags.app, fields, diff --git a/packages/cli/src/commands/issue/unlink.ts b/packages/cli/src/commands/issue/unlink.ts index b2ff78ec4b..1f3070f4b1 100644 --- a/packages/cli/src/commands/issue/unlink.ts +++ b/packages/cli/src/commands/issue/unlink.ts @@ -9,11 +9,13 @@ import { confirmByTyping, isConfirmationBypassed, } from "../../lib/mutate-command.js"; -import { EXTERNAL_ISSUE_FLAGS } from "./link-utils.js"; -import { issueIdPositional, resolveOrgAndIssueId } from "./utils.js"; +import { + EXTERNAL_ISSUE_FLAGS, + EXTERNAL_ISSUE_POSITIONALS, +} from "./link-utils.js"; +import { resolveOrgAndIssueId } from "./utils.js"; type UnlinkFlags = { - readonly "external-issue": string; readonly integration?: string; readonly app?: string; readonly "dry-run": boolean; @@ -30,17 +32,22 @@ export const unlinkCommand = buildDeleteCommand({ "Requires event:admin and access to the Sentry project.\n" + "Your token must include event:admin even if your project role grants it.\n\n" + "Examples:\n" + - " sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/issues/42\n" + - " sentry issue unlink FRONT-123 --external-issue https://github.com/example/app/pull/43 --yes\n" + - " sentry issue unlink my-org/FRONT-123 --external-issue https://example.atlassian.net/browse/APP-42 --yes\n" + - " sentry issue unlink FRONT-123 --external-issue https://linear.app/example/issue/APP-42/fix-error --dry-run", + " sentry issue unlink FRONT-123 https://github.com/example/app/issues/42\n" + + " sentry issue unlink FRONT-123 https://github.com/example/app/pull/43 --yes\n" + + " sentry issue unlink my-org/FRONT-123 https://example.atlassian.net/browse/APP-42 --yes\n" + + " sentry issue unlink FRONT-123 https://linear.app/example/issue/APP-42/fix-error --dry-run", }, output: { human: formatIssueLinkResult }, parameters: { - positional: issueIdPositional, + positional: EXTERNAL_ISSUE_POSITIONALS, flags: EXTERNAL_ISSUE_FLAGS, }, - async *func(this: SentryContext, flags: UnlinkFlags, issueArg: string) { + async *func( + this: SentryContext, + flags: UnlinkFlags, + issueArg: string, + url: string + ) { const { org, issueId } = await resolveOrgAndIssueId({ issueArg, cwd: this.cwd, @@ -49,7 +56,7 @@ export const unlinkCommand = buildDeleteCommand({ if (!(flags["dry-run"] || isConfirmationBypassed(flags))) { const confirmed = await confirmByTyping( issueArg, - `Type '${issueArg}' to unlink ${flags["external-issue"]}:` + `Type '${issueArg}' to unlink ${url}:` ); if (!confirmed) { return { hint: "Cancelled." }; @@ -58,7 +65,7 @@ export const unlinkCommand = buildDeleteCommand({ const result = await unlinkExternalIssue({ orgSlug: org, issueId, - url: flags["external-issue"], + url, integrationId: flags.integration, appSlug: flags.app, dryRun: flags["dry-run"], diff --git a/packages/cli/src/lib/issue-links.ts b/packages/cli/src/lib/issue-links.ts index 8a61ac7267..f7d857c8a9 100644 --- a/packages/cli/src/lib/issue-links.ts +++ b/packages/cli/src/lib/issue-links.ts @@ -75,7 +75,7 @@ function usesSentryApp(options: ExternalIssueLinkOptions): boolean { try { url = new URL(options.url); } catch { - throw new ValidationError("--external-issue must be a complete issue URL."); + throw new ValidationError("URL must be a complete external issue URL."); } if ( !["https:", "http:"].includes(url.protocol) || @@ -83,7 +83,7 @@ function usesSentryApp(options: ExternalIssueLinkOptions): boolean { url.password ) { throw new ValidationError( - "--external-issue must be an HTTP(S) URL without embedded credentials." + "URL must use HTTP(S) without embedded credentials." ); } const app = Boolean(options.appSlug) || url.hostname === "linear.app"; diff --git a/packages/cli/test/commands/issue/link.func.test.ts b/packages/cli/test/commands/issue/link.func.test.ts index 5d2de0ccd7..72e471956e 100644 --- a/packages/cli/test/commands/issue/link.func.test.ts +++ b/packages/cli/test/commands/issue/link.func.test.ts @@ -33,7 +33,6 @@ vi.mock("../../../src/lib/issue-links.js", () => ({ const externalUrl = "https://github.com/example/app/issues/42"; const defaultFlags = { - "external-issue": externalUrl, "dry-run": false, json: false, }; @@ -92,7 +91,8 @@ describe("issue link", () => { await func.call( context, { ...defaultFlags, integration: "99" }, - "test-org/APP-42" + "test-org/APP-42", + externalUrl ); expect(resolveIssue).toHaveBeenCalledExactlyOnceWith({ @@ -123,11 +123,11 @@ describe("issue link", () => { context, { ...defaultFlags, - "external-issue": "https://tracker.example/issues/42", app: "custom-tracker", field: ["team=team-1", "query=key=value", "optional="], }, - "APP-42" + "APP-42", + "https://tracker.example/issues/42" ); expect(linkExternalIssue).toHaveBeenCalledExactlyOnceWith({ @@ -158,7 +158,8 @@ describe("issue link", () => { func.call( context, { ...defaultFlags, app: "custom-tracker", field: fields }, - "APP-42" + "APP-42", + externalUrl ) ).rejects.toBeInstanceOf(ValidationError); @@ -173,7 +174,7 @@ describe("issue link", () => { const func = await linkCommand.loader(); await expect( - func.call(context, defaultFlags, "123456789") + func.call(context, defaultFlags, "123456789", externalUrl) ).rejects.toBeInstanceOf(ContextError); expect(linkExternalIssue).not.toHaveBeenCalled(); }); @@ -187,7 +188,12 @@ describe("issue link", () => { }); const { context, output } = createMockContext(); const func = await linkCommand.loader(); - await func.call(context, { ...defaultFlags, "dry-run": true }, "APP-42"); + await func.call( + context, + { ...defaultFlags, "dry-run": true }, + "APP-42", + externalUrl + ); expect(linkExternalIssue).toHaveBeenCalledWith( expect.objectContaining({ dryRun: true }) @@ -205,7 +211,12 @@ describe("issue link", () => { vi.mocked(linkExternalIssue).mockResolvedValue(result); const { context, output } = createMockContext(); const func = await linkCommand.loader(); - await func.call(context, { ...defaultFlags, json: true }, "APP-42"); + await func.call( + context, + { ...defaultFlags, json: true }, + "APP-42", + externalUrl + ); expect(JSON.parse(output())).toEqual(result); expect(updateIssueStatus).not.toHaveBeenCalled(); @@ -218,7 +229,7 @@ describe("issue link", () => { const func = await linkCommand.loader(); await expect( - func.call(context, { ...defaultFlags, json: true }, "APP-42") + func.call(context, { ...defaultFlags, json: true }, "APP-42", externalUrl) ).rejects.toBe(error); expect(output()).toBe(""); }); diff --git a/packages/cli/test/commands/issue/unlink.func.test.ts b/packages/cli/test/commands/issue/unlink.func.test.ts index 60af635e87..430160aa18 100644 --- a/packages/cli/test/commands/issue/unlink.func.test.ts +++ b/packages/cli/test/commands/issue/unlink.func.test.ts @@ -47,7 +47,6 @@ vi.mock("../../../src/lib/mutate-command.js", async (importOriginal) => ({ const externalUrl = "https://github.com/example/app/issues/42"; const defaultFlags = { - "external-issue": externalUrl, "dry-run": false, yes: false, force: false, @@ -111,7 +110,8 @@ describe("issue unlink", () => { await func.call( context, { ...defaultFlags, ...selector, yes: true }, - "test-org/APP-42" + "test-org/APP-42", + externalUrl ); expect(resolveOrgAndIssueId).toHaveBeenCalledExactlyOnceWith({ @@ -136,9 +136,9 @@ describe("issue unlink", () => { const { context, output } = createMockContext(); const func = await unlinkCommand.loader(); - await expect(func.call(context, defaultFlags, "APP-42")).rejects.toThrow( - "Use --yes or --force to confirm." - ); + await expect( + func.call(context, defaultFlags, "APP-42", externalUrl) + ).rejects.toThrow("Use --yes or --force to confirm."); expect(resolveOrgAndIssueId).not.toHaveBeenCalled(); expect(confirmByTyping).not.toHaveBeenCalled(); @@ -152,7 +152,12 @@ describe("issue unlink", () => { ] as const)("allows non-interactive --%s without prompting", async (flag) => { const { context } = createMockContext(); const func = await unlinkCommand.loader(); - await func.call(context, { ...defaultFlags, [flag]: true }, "APP-42"); + await func.call( + context, + { ...defaultFlags, [flag]: true }, + "APP-42", + externalUrl + ); expect(confirmByTyping).not.toHaveBeenCalled(); expect(unlinkExternalIssue).toHaveBeenCalledExactlyOnceWith({ @@ -169,7 +174,7 @@ describe("issue unlink", () => { mockIsatty.mockReturnValue(true); const { context } = createMockContext(); const func = await unlinkCommand.loader(); - await func.call(context, defaultFlags, "test-org/APP-42"); + await func.call(context, defaultFlags, "test-org/APP-42", externalUrl); expect(confirmByTyping).toHaveBeenCalledExactlyOnceWith( "test-org/APP-42", @@ -186,7 +191,7 @@ describe("issue unlink", () => { vi.mocked(confirmByTyping).mockResolvedValue(false); const { context, output } = createMockContext(); const func = await unlinkCommand.loader(); - await func.call(context, defaultFlags, "APP-42"); + await func.call(context, defaultFlags, "APP-42", externalUrl); expect(confirmByTyping).toHaveBeenCalledOnce(); expect(unlinkExternalIssue).not.toHaveBeenCalled(); @@ -207,7 +212,8 @@ describe("issue unlink", () => { await func.call( context, { ...defaultFlags, "dry-run": true, json: true }, - "APP-42" + "APP-42", + externalUrl ); expect(confirmByTyping).not.toHaveBeenCalled(); @@ -229,7 +235,8 @@ describe("issue unlink", () => { await func.call( context, { ...defaultFlags, yes: true, json: true }, - "APP-42" + "APP-42", + externalUrl ); expect(JSON.parse(output())).toEqual(result); @@ -243,7 +250,12 @@ describe("issue unlink", () => { const func = await unlinkCommand.loader(); await expect( - func.call(context, { ...defaultFlags, yes: true, json: true }, "APP-42") + func.call( + context, + { ...defaultFlags, yes: true, json: true }, + "APP-42", + externalUrl + ) ).rejects.toBe(error); expect(output()).toBe(""); }); diff --git a/packages/cli/test/lib/scanner-flags.integration.test.ts b/packages/cli/test/lib/scanner-flags.integration.test.ts index 1c19391c17..c481434b11 100644 --- a/packages/cli/test/lib/scanner-flags.integration.test.ts +++ b/packages/cli/test/lib/scanner-flags.integration.test.ts @@ -78,7 +78,11 @@ async function runApp( }; const exitCode = await run(app, args, context); - return { stdout, stderr, exitCode: exitCode ?? 0 }; + return { + stdout, + stderr, + exitCode: Number(context.process.exitCode ?? exitCode ?? 0), + }; } /** @@ -87,6 +91,34 @@ async function runApp( */ const NO_COMMAND_REGISTERED = "No command registered"; +describe("issue link and unlink URL arguments", () => { + test.each([ + "link", + "unlink", + ])("issue %s requires the URL positional", async (command) => { + const result = await runApp(["issue", command, "APP-42"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Expected argument for url"); + }); + + test.each([ + "link", + "unlink", + ])("issue %s rejects the removed external-issue flag", async (command) => { + const url = "https://github.com/example/app/pull/123"; + const result = await runApp([ + "issue", + command, + "APP-42", + url, + "--external-issue", + url, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("external-issue"); + }); +}); + describe("top-level flags on a leaf command (bash-hook, no auth)", () => { // bash-hook runs without auth and emits its script to stdout, so a successful // route + execution is observable regardless of where the global flag sits. diff --git a/packages/cli/test/lib/sdk-positionals.test.ts b/packages/cli/test/lib/sdk-positionals.test.ts index c3073d95bb..10d6bdcdc6 100644 --- a/packages/cli/test/lib/sdk-positionals.test.ts +++ b/packages/cli/test/lib/sdk-positionals.test.ts @@ -33,14 +33,33 @@ describe("generated SDK positional arguments", () => { const { calls, sdk } = createRecordingSDK(); await sdk.issue.link({ issue: "example/APP-42", - externalIssue: "https://linear.app/example/issue/APP-42/title", + url: "https://linear.app/example/issue/APP-42/title", field: ["team=engineering", "label=bug"], }); expect(calls[0]).toMatchObject({ path: ["issue", "link"], - positional: ["example/APP-42"], + positional: [ + "example/APP-42", + "https://linear.app/example/issue/APP-42/title", + ], flags: { field: ["team=engineering", "label=bug"] }, }); + expect(calls[0]?.flags).not.toHaveProperty("external-issue"); + }); + + test("issue unlink forwards the issue and URL as separate positionals", async () => { + const { calls, sdk } = createRecordingSDK(); + await sdk.issue.unlink({ + issue: "example/APP-42", + url: "https://github.com/example/app/pull/123", + yes: true, + }); + expect(calls[0]).toMatchObject({ + path: ["issue", "unlink"], + positional: ["example/APP-42", "https://github.com/example/app/pull/123"], + flags: { yes: true }, + }); + expect(calls[0]?.flags).not.toHaveProperty("external-issue"); }); test("release deploy passes version, environment and name as separate tokens", async () => { From 262c481b91a8165c8c6367db38b954ea4b76243d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Thu, 10 Sep 2026 17:53:21 +0200 Subject: [PATCH 05/17] fix(issue): correct link resolution and unlink scopes --- apps/cli-docs/src/content/docs/self-hosted.md | 2 +- apps/cli-docs/src/fragments/commands/issue.md | 26 +++-- packages/cli/DEVELOPMENT.md | 2 +- packages/cli/src/commands/issue/link.ts | 3 +- packages/cli/src/commands/issue/unlink.ts | 4 +- packages/cli/src/lib/api/issue-app-links.ts | 10 +- .../cli/src/lib/api/issue-integrations.ts | 83 +++++++++---- packages/cli/src/lib/oauth.ts | 1 - .../cli/test/lib/api/issue-app-links.test.ts | 38 ++++++ .../test/lib/api/issue-integrations.test.ts | 110 ++++++++++++++++-- packages/cli/test/lib/oauth.test.ts | 2 +- packages/cli/test/lib/scope-recovery.test.ts | 8 +- 12 files changed, 234 insertions(+), 55 deletions(-) diff --git a/apps/cli-docs/src/content/docs/self-hosted.md b/apps/cli-docs/src/content/docs/self-hosted.md index 04f2b70416..4301ef43ea 100644 --- a/apps/cli-docs/src/content/docs/self-hosted.md +++ b/apps/cli-docs/src/content/docs/self-hosted.md @@ -56,7 +56,7 @@ If your instance is on an older version or you prefer not to create an OAuth app 1. Go to **Settings → Developer Settings → Personal Tokens** in your Sentry instance (or visit `https://sentry.example.com/settings/account/api/auth-tokens/new-token/`) 2. Create a new token with the following scopes: -`project:read`, `project:write`, `project:admin`, `org:read`, `event:read`, `event:write`, `event:admin`, `member:read`, `team:read`, `team:write`, `team:admin`, `alerts:read`, `alerts:write` +`project:read`, `project:write`, `project:admin`, `org:read`, `event:read`, `event:write`, `member:read`, `team:read`, `team:write`, `team:admin`, `alerts:read`, `alerts:write` 3. Pass it to the CLI: diff --git a/apps/cli-docs/src/fragments/commands/issue.md b/apps/cli-docs/src/fragments/commands/issue.md index 68305c4199..f38390645c 100644 --- a/apps/cli-docs/src/fragments/commands/issue.md +++ b/apps/cli-docs/src/fragments/commands/issue.md @@ -312,9 +312,14 @@ sentry issue link FRONT-123 https://linear.app/example/issue/APP-42/fix-error The matching integration must already be installed in the Sentry organization. Native integrations include GitHub, GitHub Enterprise, Jira, Jira Server, GitLab, Bitbucket, and Azure DevOps. Linear uses its installed Sentry App. +GitLab resolves the repository through the integration's repository search; +it must be visible to that installation. Use `--integration ` if more than one native integration matches the URL. Other Sentry Apps require `--app ` and must expose an issue-link form; additional required form values can be supplied with `--field name=value`. +For other Apps, an issue select can be supplied by exact ID or label with +`--field`, for example `--app custom --field task_id=123`. The CLI checks +that the app's link response identifies the requested URL before reporting success. ```bash sentry issue link my-org/FRONT-123 https://github.com/example/app/issues/42 --dry-run @@ -336,9 +341,10 @@ integration status-sync settings continue to apply after linking. #### Link permissions -Linking requires `event:write` and access to the Sentry project. The CLI requests -`event:write` and `event:admin` during OAuth login. If an older OAuth session lacks -the requested scopes, the CLI offers reauthorization after a permission error. +Linking requires `event:write` and access to the Sentry project. Discovering +GitHub/GitLab repositories and Sentry Apps also requires `org:read`. Both scopes +are included in the default OAuth login. If an older OAuth session lacks the +requested scopes, the CLI offers reauthorization after a permission error. In non-interactive mode, follow the `sentry auth refresh` command shown in the error. Environment tokens must be updated separately. @@ -366,11 +372,11 @@ integration links. #### Unlink permissions -Unlink requires **`event:admin` in both the token and your effective project -permissions**. Being a project member does not automatically grant it. The -organization's “Let Members Delete Events” setting and team roles affect whether -you have this permission. +Unlink requires **`event:write` and access to the Sentry project**; `event:admin` +is also accepted. The organization's “Let Members Delete Events” setting does +not restrict unlinking on updated Sentry versions. -New OAuth sessions request this scope. For an older session, follow the -reauthorization guidance shown by the CLI. Granting a token more scopes does not -override the organization's project-access policy. +Older Sentry versions still require `event:admin` in the token and your effective +project permissions. For those installations, request it explicitly alongside +your existing scopes with `sentry auth refresh --scope ...`. Granting a token +more scopes does not override the organization's project-access policy. diff --git a/packages/cli/DEVELOPMENT.md b/packages/cli/DEVELOPMENT.md index adde93e505..4bc3f3a3d5 100644 --- a/packages/cli/DEVELOPMENT.md +++ b/packages/cli/DEVELOPMENT.md @@ -67,7 +67,7 @@ When creating your Sentry OAuth application: - `project:read`, `project:write`, `project:admin` - `org:read` - - `event:read`, `event:write`, `event:admin` + - `event:read`, `event:write` - `member:read` - `team:read`, `team:write`, `team:admin` - `alerts:read`, `alerts:write` diff --git a/packages/cli/src/commands/issue/link.ts b/packages/cli/src/commands/issue/link.ts index dc00f12e65..73e90a1919 100644 --- a/packages/cli/src/commands/issue/link.ts +++ b/packages/cli/src/commands/issue/link.ts @@ -28,7 +28,8 @@ export const linkCommand = buildCommand({ "Link an existing tracker issue or GitHub pull request as an external reference.\n" + "The integration must be installed in your Sentry organization.\n" + "This does not create a remote issue or resolve the Sentry issue.\n\n" + - "Requires event:write and access to the Sentry project.\n\n" + + "Requires event:write and access to the Sentry project.\n" + + "GitHub, GitLab and Sentry Apps also require org:read for discovery.\n\n" + "Examples:\n" + " sentry issue link FRONT-123 https://github.com/example/app/issues/42\n" + " sentry issue link FRONT-123 https://github.com/example/app/pull/43\n" + diff --git a/packages/cli/src/commands/issue/unlink.ts b/packages/cli/src/commands/issue/unlink.ts index 1f3070f4b1..decf8a9964 100644 --- a/packages/cli/src/commands/issue/unlink.ts +++ b/packages/cli/src/commands/issue/unlink.ts @@ -29,8 +29,8 @@ export const unlinkCommand = buildDeleteCommand({ fullDescription: "Remove an external tracker issue or GitHub pull request reference from a Sentry issue.\n" + "This does not delete the external issue or change the Sentry issue's status.\n\n" + - "Requires event:admin and access to the Sentry project.\n" + - "Your token must include event:admin even if your project role grants it.\n\n" + + "Requires event:write and access to the Sentry project.\n" + + "Older Sentry versions may still require event:admin.\n\n" + "Examples:\n" + " sentry issue unlink FRONT-123 https://github.com/example/app/issues/42\n" + " sentry issue unlink FRONT-123 https://github.com/example/app/pull/43 --yes\n" + diff --git a/packages/cli/src/lib/api/issue-app-links.ts b/packages/cli/src/lib/api/issue-app-links.ts index d59b10721b..146b80bf1b 100644 --- a/packages/cli/src/lib/api/issue-app-links.ts +++ b/packages/cli/src/lib/api/issue-app-links.ts @@ -586,7 +586,13 @@ async function resolveFieldValue({ installationUuid: string; }): Promise { const target = parseTarget(options.url); - const query = target.key ?? options.url; + // Generic selects can use provider IDs that cannot be inferred from the URL. + const query = + target.key ?? + (field === targetField && field.type === "select" + ? options.fields?.[field.name] + : undefined) ?? + options.url; const input = field === targetField ? query : String(values[field.name]); let value: string | number = input; if (field.type === "select") { @@ -715,7 +721,7 @@ export async function linkAppIssue( return { link, changed: true }; } -/** Remove only the selected local app association; this existing endpoint requires event:admin. */ +/** Remove only the selected local app association, using event:write or event:admin. */ export async function unlinkAppIssueLink( orgSlug: string, issueId: string, diff --git a/packages/cli/src/lib/api/issue-integrations.ts b/packages/cli/src/lib/api/issue-integrations.ts index 434358e1f4..36f8786c9c 100644 --- a/packages/cli/src/lib/api/issue-integrations.ts +++ b/packages/cli/src/lib/api/issue-integrations.ts @@ -73,6 +73,11 @@ const NativeIssueMutationSchema = object({ ...vExternalIssueLinkResponse.entries, title: optional(string()), }); +const GitlabRepositoriesSchema = object({ + repos: array( + object({ identifier: string(), name: string(), url: nullish(string()) }) + ), +}); type NativeIntegration = InferOutput; /** Read-only resolution result used for previews and a subsequent link mutation. */ @@ -219,19 +224,45 @@ function parseAzureIssue(url: URL, domain: URL): ParsedTarget | undefined { }; } -function parseScopedGitlabIssue( +/** + * Match GitLab's actual project URL to keep deployment prefixes out of project IDs. + * The private picker has no SDK operation and includes unregistered repositories; + * public integration metadata omits the deployment prefix. + */ +async function resolveGitlabIssue( + orgSlug: string, url: URL, - domain: URL, - domainName: string -): ParsedTarget | undefined { - const target = parseGitlabIssue(url); - const group = domain.pathname.replace(TRAILING_SLASH, ""); - if (group && !url.pathname.startsWith(`${group}/`)) { + integration: NativeIntegration +): Promise { + const match = GITLAB_ISSUE.exec(url.pathname); + if ( + integrationUrl(integration)?.host !== url.host || + !match?.[1] || + !match[2] + ) { return; } - return target - ? { ...target, key: `${domainName}:${target.body.externalIssue}` } - : undefined; + const projectUrl = `${url.origin}/${match[1]}`; + const { data } = await apiRequestToRegion( + await resolveOrgRegion(orgSlug), + `/organizations/${encodeURIComponent(orgSlug)}/integrations/${encodeURIComponent(integration.id)}/repos/`, + { + params: { search: match[1].split("/").at(-1) }, + cache: "no-store", + schema: GitlabRepositoriesSchema, + } + ); + const repository = data.repos.find( + (repo) => repo.url && parseUrl(repo.url).href === projectUrl + ); + if (!repository) { + return; + } + return { + url: `${projectUrl}/-/issues/${match[2]}`, + key: `${repository.name}#${match[2]}`, + body: { externalIssue: `${repository.identifier}#${match[2]}` }, + }; } function parseTarget( @@ -261,7 +292,7 @@ function parseTarget( return target; } if (provider === "gitlab") { - return parseScopedGitlabIssue(url, domain, integration.domainName); + return parseGitlabIssue(url); } if (provider === "jira" || provider === "jira_server") { const prefix = domain.pathname.replace(TRAILING_SLASH, ""); @@ -440,23 +471,31 @@ export async function resolveNativeIssueLink(options: { const url = parseUrl(options.url); if ( SCM_CHANGE.test(url.pathname) && - !GITHUB_PULL_REQUEST.test(url.pathname) + !GITHUB_PULL_REQUEST.test(url.pathname) && + !GITLAB_ISSUE.test(url.pathname) ) { throw new ValidationError( "External issue linking supports tracker issues and GitHub pull requests." ); } const integrations = await listIntegrations(options.orgSlug, options.issueId); - let candidates = integrations.flatMap((integration) => { - if ( - integration.status !== "active" || - (options.integrationId && options.integrationId !== integration.id) - ) { - return []; - } - const target = parseTarget(url, integration); - return target ? [{ integration, target }] : []; - }); + let candidates = ( + await Promise.all( + integrations.map(async (integration) => { + if ( + integration.status !== "active" || + (options.integrationId && options.integrationId !== integration.id) + ) { + return []; + } + const target = + integration.provider.key === "gitlab" + ? await resolveGitlabIssue(options.orgSlug, url, integration) + : parseTarget(url, integration); + return target ? [{ integration, target }] : []; + }) + ) + ).flat(); if ( candidates.some(({ integration }) => ["github", "github_enterprise"].includes(integration.provider.key) diff --git a/packages/cli/src/lib/oauth.ts b/packages/cli/src/lib/oauth.ts index 46bfc8b1c9..a5ccd798fb 100644 --- a/packages/cli/src/lib/oauth.ts +++ b/packages/cli/src/lib/oauth.ts @@ -87,7 +87,6 @@ export const OAUTH_SCOPES: readonly string[] = [ "org:read", "event:read", "event:write", - "event:admin", "member:read", "team:read", "team:write", diff --git a/packages/cli/test/lib/api/issue-app-links.test.ts b/packages/cli/test/lib/api/issue-app-links.test.ts index d6243b9722..c258170f1a 100644 --- a/packages/cli/test/lib/api/issue-app-links.test.ts +++ b/packages/cli/test/lib/api/issue-app-links.test.ts @@ -201,6 +201,44 @@ describe("app issue-link action", () => { expect(writes()).toHaveLength(1); }); + test.each([ + "static", + "search", + ])("links an explicit generic issue ID from %s choices", async (source) => { + const url = "https://tracker.example/tasks/123"; + installation = { + ...INSTALLATION, + app: { ...INSTALLATION.app, slug: "custom" }, + }; + choices = [["123", "An Issue"]]; + form = { + uri: "/sentry/tasks/link", + required_fields: [ + { + name: "task_id", + type: "select", + ...(source === "static" + ? { options: choices } + : { uri: "/sentry/tasks" }), + }, + ], + }; + const prepared = await resolveAppIssueLink({ + ...OPTIONS, + url, + appSlug: "custom", + fields: { task_id: "123" }, + }); + expect(prepared.fields).toEqual({ task_id: "123" }); + actionLink = { ...LINK, serviceType: "custom", webUrl: url }; + expect(await linkAppIssue(prepared)).toEqual({ + changed: true, + link: actionLink, + }); + expect(await writes()[0]?.json()).toMatchObject({ task_id: "123" }); + expect(writes()).toHaveLength(1); + }); + test("reports a different returned target without retrying or deleting it", async () => { const prepared = await resolveAppIssueLink(OPTIONS); actionLink = { diff --git a/packages/cli/test/lib/api/issue-integrations.test.ts b/packages/cli/test/lib/api/issue-integrations.test.ts index 8096b237ce..e61c38e14c 100644 --- a/packages/cli/test/lib/api/issue-integrations.test.ts +++ b/packages/cli/test/lib/api/issue-integrations.test.ts @@ -19,6 +19,8 @@ import { mockFetch, useTestConfigDir } from "../../helpers.js"; const REGION = "https://eu.sentry.io"; const INTEGRATIONS = "/api/0/organizations/test-org/issues/42/integrations/"; const REPOSITORIES = "/api/0/organizations/test-org/repos/"; +const GITLAB_REPOSITORIES = + "/api/0/organizations/test-org/integrations/10/repos/"; const SOURCE = { orgSlug: "test-org", issueId: "42" }; const JIRA_URL = "https://tracker.example.com/browse/PROJ-7"; const LINK: NativeIssueLink = { @@ -115,7 +117,34 @@ describe("native tracker issue links", () => { domain: "gitlab.example.com/group/subgroup", url: "https://gitlab.example.com/group/subgroup/project/-/issues/7", canonical: "https://gitlab.example.com/group/subgroup/project/-/issues/7", - body: { externalIssue: "group/subgroup/project#7" }, + repositoryUrl: "https://gitlab.example.com/group/subgroup/project", + body: { externalIssue: "456#7" }, + }, + { + provider: "gitlab", + domain: "gitlab.example.com/group/subgroup", + url: "https://gitlab.example.com/group/subgroup/pull/-/issues/7", + canonical: "https://gitlab.example.com/group/subgroup/pull/-/issues/7", + repositoryUrl: "https://gitlab.example.com/group/subgroup/pull", + body: { externalIssue: "456#7" }, + }, + { + provider: "gitlab", + domain: "gitlab.example.com", + url: "https://gitlab.example.com/gitlab/group/project/issues/7", + canonical: "https://gitlab.example.com/gitlab/group/project/-/issues/7", + repositoryUrl: "https://gitlab.example.com/gitlab/group/project", + body: { externalIssue: "456#7" }, + }, + { + provider: "gitlab", + domain: "gitlab.example.com/group/subgroup", + url: "https://gitlab.example.com/services/gitlab/group/subgroup/project/-/issues/7", + canonical: + "https://gitlab.example.com/services/gitlab/group/subgroup/project/-/issues/7", + repositoryUrl: + "https://gitlab.example.com/services/gitlab/group/subgroup/project", + body: { externalIssue: "456#7" }, }, { provider: "bitbucket", @@ -147,11 +176,30 @@ describe("native tracker issue links", () => { }, ])("prepares $provider without creating or commenting", async (fixture) => { const requests = mockApi((request) => { - expect(new URL(request.url).pathname).toBe(INTEGRATIONS); - expect(new URL(request.url).origin).toBe(REGION); + const url = new URL(request.url); + expect(url.origin).toBe(REGION); expect(request.cache).toBe("no-store"); expect(request.method).toBe("GET"); - return json([integration(fixture.provider, fixture.domain)]); + if (url.pathname === INTEGRATIONS) { + return json([integration(fixture.provider, fixture.domain)]); + } + expect(url.pathname).toBe(GITLAB_REPOSITORIES); + expect(url.searchParams.get("search")).toBe( + fixture.repositoryUrl?.split("/").at(-1) + ); + return json({ + repos: [ + { + identifier: "456", + name: "Group / Project", + url: fixture.repositoryUrl, + isInstalled: false, + externalId: "gitlab.example.com:456", + defaultBranch: null, + }, + ], + searchable: true, + }); }); const prepared = await resolveNativeIssueLink({ @@ -168,7 +216,10 @@ describe("native tracker issue links", () => { body: fixture.body, }); expect(prepared.existing).toBeUndefined(); - expect(requests).toHaveLength(1); + expect(requests).toHaveLength(fixture.provider === "gitlab" ? 2 : 1); + if (fixture.provider === "gitlab") { + expect(prepared.key).toBe("Group / Project#7"); + } }); test.each([ @@ -543,11 +594,31 @@ describe("native tracker issue links", () => { expect(requests).toHaveLength(1); }); - test("lists and unlinks an existing issue whose title is null", async () => { + test.each([ + { + provider: "jira", + domain: "tracker.example.com", + url: JIRA_URL, + canonical: JIRA_URL, + }, + { + provider: "gitlab", + domain: "gitlab.example.com/group", + url: "https://gitlab.example.com/gitlab/group/project/issues/7", + canonical: "https://gitlab.example.com/gitlab/group/project/-/issues/7", + }, + ])("lists and unlinks a stored $provider reference without discovery", async ({ + provider, + domain, + url, + canonical, + }) => { + const storedLink = { ...LINK, provider, url, title: null }; const requests = mockApi((request) => { if (request.method === "GET") { + expect(new URL(request.url).pathname).toBe(INTEGRATIONS); return json([ - { ...integration(), externalIssues: [{ ...LINK, title: null }] }, + { ...integration(provider, domain), externalIssues: [storedLink] }, ]); } expect(request.method).toBe("DELETE"); @@ -558,9 +629,9 @@ describe("native tracker issue links", () => { }); const link = findNativeIssueLink( await listNativeIssueLinks(SOURCE.orgSlug, SOURCE.issueId), - JIRA_URL + url ); - expect(link).toEqual({ ...LINK, title: undefined }); + expect(link).toEqual({ ...storedLink, url: canonical, title: undefined }); if (!link) { throw new Error("Expected the existing external issue link"); } @@ -603,6 +674,11 @@ describe("native tracker issue links", () => { domain: "gitlab.example.com/team", url: "https://gitlab.example.com/another/repo/issues/7", }, + { + provider: "gitlab", + domain: "gitlab.example.com/team", + url: "https://gitlab.example.com/wrong/team/repo/issues/7", + }, { provider: "jira", domain: "https://tracker.example.com/jira", @@ -623,7 +699,21 @@ describe("native tracker issue links", () => { domain, url, }) => { - mockApi(() => json([integration(provider, domain)])); + mockApi((request) => + json( + new URL(request.url).pathname === INTEGRATIONS + ? [integration(provider, domain)] + : { + repos: [ + { + identifier: "456", + name: "Team / Repo", + url: "https://gitlab.example.com/team/repo", + }, + ], + } + ) + ); await expect(resolveNativeIssueLink({ ...SOURCE, url })).rejects.toThrow( "No installed native" ); diff --git a/packages/cli/test/lib/oauth.test.ts b/packages/cli/test/lib/oauth.test.ts index 2e8fbc1a9a..0925766841 100644 --- a/packages/cli/test/lib/oauth.test.ts +++ b/packages/cli/test/lib/oauth.test.ts @@ -30,7 +30,7 @@ describe("resolveOAuthScopeString", () => { test("default scopes allow linking and unlinking external issues", () => { const scopes = resolveOAuthScopeString().split(" "); expect(scopes).toContain("event:write"); - expect(scopes).toContain("event:admin"); + expect(scopes).not.toContain("event:admin"); }); test("default (no selection) returns the full OAUTH_SCOPES set", () => { diff --git a/packages/cli/test/lib/scope-recovery.test.ts b/packages/cli/test/lib/scope-recovery.test.ts index 53b193534a..847d974743 100644 --- a/packages/cli/test/lib/scope-recovery.test.ts +++ b/packages/cli/test/lib/scope-recovery.test.ts @@ -65,7 +65,7 @@ describe("runWithScopeRecovery", () => { getAuthScopes: vi .fn() .mockResolvedValue([ - ...OAUTH_SCOPES.filter((scope) => scope !== "event:admin"), + ...OAUTH_SCOPES.filter((scope) => scope !== "event:write"), "org:write", ]), }); @@ -82,7 +82,7 @@ describe("runWithScopeRecovery", () => { }); expect(proceed).toHaveBeenCalledTimes(2); expect(testRuntime.write).toHaveBeenCalledWith( - expect.stringContaining("missing event:admin") + expect.stringContaining("missing event:write") ); }); @@ -190,7 +190,7 @@ describe("runWithScopeRecovery", () => { const getAuthScopes = vi .fn() .mockResolvedValue([ - ...OAUTH_SCOPES.filter((scope) => scope !== "event:admin"), + ...OAUTH_SCOPES.filter((scope) => scope !== "event:write"), "org:write", ]); const login = vi.fn(); @@ -208,7 +208,7 @@ describe("runWithScopeRecovery", () => { expect(getAuthScopes).toHaveBeenCalledOnce(); expect(login).not.toHaveBeenCalled(); expect(testRuntime.write).toHaveBeenCalledWith( - "Your CLI authorization is missing event:admin.\n" + + "Your CLI authorization is missing event:write.\n" + `Re-authenticate with: sentry auth refresh ${[...OAUTH_SCOPES, "org:write"].map((scope) => `--scope ${scope}`).join(" ")}\n` ); }); From 97fceeceb2f6b2b439ab65b1cad2ea083e526412 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Mon, 14 Sep 2026 09:18:48 +0200 Subject: [PATCH 06/17] refactor(issue): use published linking schemas --- .lore.md | 2 ++ packages/cli/package.json | 2 +- packages/cli/src/lib/api/issue-app-links.ts | 9 ++++----- .../cli/src/lib/api/issue-integrations.ts | 8 +++----- .../test/lib/api/issue-integrations.test.ts | 20 +++++++++++-------- pnpm-lock.yaml | 10 +++++----- 6 files changed, 27 insertions(+), 24 deletions(-) diff --git a/.lore.md b/.lore.md index 479bc4aa02..38577906cd 100644 --- a/.lore.md +++ b/.lore.md @@ -4,6 +4,8 @@ ### Architecture +* **External issue linking works with existing backend APIs**: Keep URL resolution and fresh no-op/replacement checks in the CLI; do not require backend URL parsing, idempotency, or publication of private endpoints. Use `@sentry/api` for published operations and the authenticated HTTP helper for the remaining existing endpoints. SDK 0.305.0 includes native `repo`/`comment`, repository `per_page`, and external-issue `cursor` fields. Mutations disable automatic retries; App preflight checks cannot make concurrent replacements atomic. + * **API output binary and HTTP contracts**: Always preserve strict output-mode and binary-safety contracts. `sentry api` must preserve `rawApiRequest()` status text; empty textual non-2xx bodies must produce an HTTP status/request fallback, and JSON mode must expose `{status, statusText, body}` so empty success and error responses are distinguishable. Binary `Uint8Array` successes bypass formatters and remain raw; binary errors emit only status/content-type/byte-count summaries, never bytes or JSON coercion. Prompts must never block scripted runs or interleave with stdout JSON. diff --git a/packages/cli/package.json b/packages/cli/package.json index 6cc26f91c5..4d32bd3cd6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -91,7 +91,7 @@ "@clack/prompts": "0.11.0", "@hono/node-server": "^2.0.10", "@mastra/client-js": "^1.38.0", - "@sentry/api": "^0.256.0", + "@sentry/api": "^0.305.0", "@sentry/core": "10.63.0", "@sentry/node": "10.65.0", "@sentry/node-core": "10.63.0", diff --git a/packages/cli/src/lib/api/issue-app-links.ts b/packages/cli/src/lib/api/issue-app-links.ts index 146b80bf1b..59fb4f1b95 100644 --- a/packages/cli/src/lib/api/issue-app-links.ts +++ b/packages/cli/src/lib/api/issue-app-links.ts @@ -259,8 +259,7 @@ export async function listAppIssueLinks( 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, + query: { cursor }, }); return unwrapPaginatedResult(result, "Failed to list app issue links"); }, @@ -346,7 +345,7 @@ async function getLinkForm( installation: AppInstallation ): Promise { const endpoint = `/organizations/${encodeURIComponent(orgSlug)}/sentry-app-components/`; - // SDK0.256.0 has no operation for installed app UI components. + // Installed app UI components are not exposed by the SDK. const components = await listAll( async (cursor) => { const { data, headers } = await apiRequestToRegion( @@ -416,7 +415,7 @@ async function getChoices({ } dependentData[name] = values[name]; } - // SDK0.256.0 does not expose app form option searches. + // App form option searches are not exposed by the SDK. const { data } = await apiRequestToRegion<{ choices: Choice[] }>( getControlSiloUrl(), `/sentry-app-installations/${encodeURIComponent(installationUuid)}/external-requests/`, @@ -691,7 +690,7 @@ export async function linkAppIssue( ); } validateUri(prepared.uri); - // SDK0.256.0 only has direct registration, which skips the app's link callback. + // The SDK's direct registration skips the app's link callback. const { data: link } = await apiRequestToRegion( getControlSiloUrl(), `/sentry-app-installations/${encodeURIComponent(prepared.installationUuid)}/external-issue-actions/`, diff --git a/packages/cli/src/lib/api/issue-integrations.ts b/packages/cli/src/lib/api/issue-integrations.ts index 36f8786c9c..6720f7a4d2 100644 --- a/packages/cli/src/lib/api/issue-integrations.ts +++ b/packages/cli/src/lib/api/issue-integrations.ts @@ -96,8 +96,8 @@ export type PreparedNativeIssueLink = { 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 }; + /** Provider-specific issue identifier and repository. */ + body: LinkExternalIssueRequest; /** Reference found during fresh preflight; avoids a duplicate mutation. */ existing?: NativeIssueLink; }; @@ -356,12 +356,10 @@ async function listFreshRepositories( 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, + query: { cursor, per_page: API_MAX_PER_PAGE }, }); const page = unwrapPaginatedResult( result, diff --git a/packages/cli/test/lib/api/issue-integrations.test.ts b/packages/cli/test/lib/api/issue-integrations.test.ts index e61c38e14c..378006938c 100644 --- a/packages/cli/test/lib/api/issue-integrations.test.ts +++ b/packages/cli/test/lib/api/issue-integrations.test.ts @@ -290,12 +290,10 @@ describe("native tracker issue links", () => { }); linked = true; // The mutation uses GitHub's html_url; listing reconstructs /issues/N. - return json({ - ...storedLink, - id: 1234, - integrationId: 10, - url: pullUrl, - }); + return Response.json( + { ...storedLink, id: 1234, integrationId: 10, url: pullUrl }, + { status: 201 } + ); } if (request.method === "DELETE") { expect(url.pathname).toBe(`${INTEGRATIONS}10/`); @@ -445,7 +443,10 @@ describe("native tracker issue links", () => { repo: "owner/repo", externalIssue: "7", }); - return json({ ...githubLink, id: 1234, integrationId: 10 }); + return Response.json( + { ...githubLink, id: 1234, integrationId: 10 }, + { status: 201 } + ); } expect(request.method).toBe("GET"); if (url.pathname === INTEGRATIONS) { @@ -518,7 +519,10 @@ describe("native tracker issue links", () => { 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 }); + return Response.json( + { ...LINK, id: 1234, integrationId: 10 }, + { status: 201 } + ); } expect(request.method).toBe("GET"); return json([integration()]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2da95d59b..0994a05568 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -186,8 +186,8 @@ importers: specifier: ^1.38.0 version: 1.38.0(express@5.2.1)(zod@4.4.3) '@sentry/api': - specifier: ^0.256.0 - version: 0.256.0(valibot@1.4.2(typescript@5.9.3))(zod@4.4.3) + specifier: ^0.305.0 + version: 0.305.0(valibot@1.4.2(typescript@5.9.3))(zod@4.4.3) '@sentry/core': specifier: 10.63.0 version: 10.63.0(patch_hash=e663994979ff877a26ab6d4dea5968fbaee4ccdfdeb85623983535a572678940) @@ -2002,8 +2002,8 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - '@sentry/api@0.256.0': - resolution: {integrity: sha512-6SL/EpZW569eAEAOHossu2bWHXl1ODJsvs1xWro0hbmLieDD2G4FjBIyNj+zPwbHcCpXNfPhpCTLmkLVih9khA==} + '@sentry/api@0.305.0': + resolution: {integrity: sha512-vyK+ytFWsqKNW9USxGcxavK39Yo0NGYRTDXVKoYNcOO4e3Vdc2KizrCNwybOtXYfmyQgGezktAq9QqDdygoxQw==} engines: {node: '>=22'} peerDependencies: valibot: '*' @@ -7295,7 +7295,7 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} - '@sentry/api@0.256.0(valibot@1.4.2(typescript@5.9.3))(zod@4.4.3)': + '@sentry/api@0.305.0(valibot@1.4.2(typescript@5.9.3))(zod@4.4.3)': optionalDependencies: valibot: 1.4.2(typescript@5.9.3) zod: 4.4.3 From e02c2b9b25818d15f4b448106acb9c60a66c523d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Mon, 28 Sep 2026 14:40:15 +0200 Subject: [PATCH 07/17] refactor(issue): delegate external link resolution to backend --- .lore.md | 1 - apps/cli-docs/src/fragments/commands/issue.md | 25 +- packages/cli/package.json | 2 +- .../skills/sentry-cli/references/event.md | 1 + packages/cli/src/commands/issue/link.ts | 2 +- packages/cli/src/lib/api/infrastructure.ts | 13 +- packages/cli/src/lib/api/issue-app-links.ts | 214 ++++--- .../cli/src/lib/api/issue-integrations.ts | 545 ++++++------------ packages/cli/src/lib/issue-links.ts | 2 +- packages/cli/src/lib/scope-recovery.ts | 34 +- packages/cli/test/lib/api-client.test.ts | 19 - .../cli/test/lib/api/issue-app-links.test.ts | 135 ++++- .../test/lib/api/issue-integrations.test.ts | 533 +++++++++-------- packages/cli/test/lib/issue-links.test.ts | 2 - packages/cli/test/lib/scope-recovery.test.ts | 54 +- pnpm-lock.yaml | 10 +- 16 files changed, 743 insertions(+), 849 deletions(-) diff --git a/.lore.md b/.lore.md index 9cfd99d78b..8b539a5159 100644 --- a/.lore.md +++ b/.lore.md @@ -6,7 +6,6 @@ * **Fresh-install login reuses OAuth inside `cli setup --install`**: The curl installer reconnects setup stdin to `/dev/tty` only with terminal stdout/stderr. After installation output, setup reuses `runInteractiveLogin()` for fresh curl installs with TTY stdin/stdout/stderr, enabled prompts, no existing credentials and no environment/process-tree agent. Quiet/JSON, upgrades and `SENTRY_INIT=1` skip first login. The existing auto-login host-trust guard runs before OAuth. Login errors are best-effort; Ctrl+C retains the existing OAuth exit 130, after the binary has been installed. No login child process, launch retry, extra flag or installer-specific auth mode is needed. - * **API output binary and HTTP contracts**: Always preserve strict output-mode and binary-safety contracts. `sentry api` must preserve `rawApiRequest()` status text; empty textual non-2xx bodies must produce an HTTP status/request fallback, and JSON mode must expose `{status, statusText, body}` so empty success and error responses are distinguishable. Binary `Uint8Array` successes bypass formatters and remain raw; binary errors emit only status/content-type/byte-count summaries, never bytes or JSON coercion. Prompts must never block scripted runs or interleave with stdout JSON. diff --git a/apps/cli-docs/src/fragments/commands/issue.md b/apps/cli-docs/src/fragments/commands/issue.md index 593276c30a..7ff85af009 100644 --- a/apps/cli-docs/src/fragments/commands/issue.md +++ b/apps/cli-docs/src/fragments/commands/issue.md @@ -331,16 +331,18 @@ 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. +Linking requires a Sentry version with native issue URL resolution and guarded +Sentry App callbacks; older self-hosted versions may require an upgrade. 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. +Sentry resolves native issue URLs through the selected integration; the remote +issue 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. +`--field`, for example `--app custom --field task_id=123`. Sentry checks +that the app's callback identifies the requested URL before saving the association. ```bash sentry issue link my-org/FRONT-123 https://github.com/example/app/issues/42 --dry-run @@ -351,6 +353,8 @@ sentry issue link my-org/FRONT-123 https://github.com/example/app/issues/42 --js 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. +App callbacks must return the exact supplied URL; use the issue URL copied from +the tracker, including its title suffix. A mismatch fails without saving the link. GitHub and GitHub Enterprise pull requests are stored as external references. Their `/pull/NUMBER` and `/issues/NUMBER` URLs identify the same resource for @@ -363,11 +367,11 @@ 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 +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. +Use `sentry auth refresh` to update an older OAuth grant. Environment tokens must +be updated separately. ### Unlink an external issue @@ -397,7 +401,4 @@ 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. +Granting a token more scopes does not override project-access policy. diff --git a/packages/cli/package.json b/packages/cli/package.json index 4d32bd3cd6..ca68ddef71 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -91,7 +91,7 @@ "@clack/prompts": "0.11.0", "@hono/node-server": "^2.0.10", "@mastra/client-js": "^1.38.0", - "@sentry/api": "^0.305.0", + "@sentry/api": "^0.337.0", "@sentry/core": "10.63.0", "@sentry/node": "10.65.0", "@sentry/node-core": "10.63.0", diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md index 327ee325dc..9cc411d252 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md @@ -62,6 +62,7 @@ View details of one or more events | `resolvedWith` | array | | | `nextEventID` | string \| null | | | `previousEventID` | string \| null | | +| `formatted` | object | | | `trace` | object \| null | Trace context, or null when unavailable | | `attachments` | array | Event attachments; each includes metadata and an absolute authenticated download URL | diff --git a/packages/cli/src/commands/issue/link.ts b/packages/cli/src/commands/issue/link.ts index 73e90a1919..e58408d37f 100644 --- a/packages/cli/src/commands/issue/link.ts +++ b/packages/cli/src/commands/issue/link.ts @@ -29,7 +29,7 @@ export const linkCommand = buildCommand({ "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" + + "Sentry Apps also require org:read for discovery.\n\n" + "Examples:\n" + " sentry issue link FRONT-123 https://github.com/example/app/issues/42\n" + " sentry issue link FRONT-123 https://github.com/example/app/pull/43\n" + diff --git a/packages/cli/src/lib/api/infrastructure.ts b/packages/cli/src/lib/api/infrastructure.ts index 6a82834fb3..0a58de5281 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 = SentryRequestOptions & { +export type ApiRequestOptions = { method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; body?: unknown; /** @@ -525,7 +525,7 @@ export async function apiRequestToRegion( options: ApiRequestOptions = {} ): Promise<{ data: T; headers: Headers }> { const { method = "GET", body, bodyEncoding, params, schema } = options; - const config = getSdkConfig(regionUrl, options); + const config = getSdkConfig(regionUrl); const normalizedEndpoint = endpoint.startsWith("/") ? endpoint.slice(1) @@ -681,7 +681,7 @@ export async function apiRequestToRegionNoContent( options: Omit = {} ): Promise { const { method = "GET", body, params } = options; - const config = getSdkConfig(regionUrl, options); + const config = getSdkConfig(regionUrl); const searchParams = buildSearchParams(params); const normalizedEndpoint = endpoint.startsWith("/") @@ -809,8 +809,9 @@ export async function rawApiRequest( baseUrl, } = options; -// createAuthenticatedFetch enforces origin trust before attaching Authorization. - const config = getSdkConfig(baseUrl ?? getApiBaseUrl(), options); + // Both configs share createAuthenticatedFetch(), whose prepareHeaders() + // enforces isRequestOriginTrusted() before attaching Authorization. + const config = baseUrl ? getSdkConfig(baseUrl) : getDefaultSdkConfig(); const normalizedEndpoint = endpoint.startsWith("/") ? endpoint.slice(1) diff --git a/packages/cli/src/lib/api/issue-app-links.ts b/packages/cli/src/lib/api/issue-app-links.ts index 59fb4f1b95..574e2597d2 100644 --- a/packages/cli/src/lib/api/issue-app-links.ts +++ b/packages/cli/src/lib/api/issue-app-links.ts @@ -4,13 +4,19 @@ */ import { + deleteOrganizationIssueExternalIssue, + executeSentryAppInstallationExternalIssueAction, type GroupExternalIssueResponse, + getSentryAppInstallationExternalRequestOptions, + type ListOrganizationSentryAppComponentsResponse, type ListOrganizationSentryAppInstallationsResponse, listOrganizationIssueExternalIssues, + listOrganizationSentryAppComponents, listOrganizationSentryAppInstallations, } from "@sentry/api"; import { vGroupExternalIssueResponse, + vListOrganizationSentryAppComponentsResponse, vListOrganizationSentryAppInstallationsResponse, } from "@sentry/api/valibot"; import { @@ -27,23 +33,21 @@ import { 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, + unwrapResult, } 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]; +type Component = ListOrganizationSentryAppComponentsResponse[number]; const ChoiceSchema = tuple([ union([string(), number()]), union([string(), number()]), @@ -63,18 +67,10 @@ const LinkFormSchema = object({ 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 = { @@ -161,7 +157,7 @@ function parseTarget(raw: string) { 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() }; + return { url: url.href, identity, key: linear?.[2]?.toUpperCase() }; } /** Match a stored target by URL, ignoring Linear title suffixes; reject ambiguous matches. */ @@ -171,11 +167,18 @@ export function findAppIssueLink( appSlug?: string ): AppIssueLink | undefined { const target = parseTarget(url); - const matches = links.filter( - (link) => - (!appSlug || link.serviceType === appSlug) && - parseTarget(link.webUrl).identity === target.identity - ); + const matches = links.filter((link) => { + if (appSlug && link.serviceType !== appSlug) { + return false; + } + // biome-ignore lint/plugin: Invalid persisted URLs cannot identify the requested target. + try { + return parseTarget(link.webUrl).identity === target.identity; + } catch { + // A malformed stored sibling must not prevent unlinking a valid target. + return false; + } + }); if (matches.length > 1) { throw new ValidationError( "Multiple app links match this URL; specify the app with --app", @@ -345,24 +348,18 @@ async function getLinkForm( installation: AppInstallation ): Promise { const endpoint = `/organizations/${encodeURIComponent(orgSlug)}/sentry-app-components/`; - // Installed app UI components are not exposed by the SDK. + const config = getSdkConfig(getControlSiloUrl(), { cache: "no-store" }); 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, - }; + const result = await listOrganizationSentryAppComponents({ + ...config, + path: { organization_id_or_slug: orgSlug }, + query: { filter: "issue-link", cursor }, + }); + return unwrapPaginatedResult(result, "Failed to list app components"); }, endpoint, - ComponentsSchema + vListOrganizationSentryAppComponentsResponse ); const matches = components.filter( (item) => @@ -370,8 +367,7 @@ async function getLinkForm( item.sentryApp.uuid === installation.app.uuid ); const component = matches[0]; - const form = component?.schema.link; - if (matches.length !== 1 || !component || !form) { + if (matches.length !== 1 || !component) { throw new ValidationError( `App ${installation.app.slug} does not expose an unambiguous issue-link form`, "app" @@ -384,8 +380,16 @@ async function getLinkForm( JSON.stringify(component.error) ); } - validateUri(form.uri); - return form; + // App-defined form schemas are intentionally untyped in the API contract. + const form = safeParse(LinkFormSchema, component.schema.link); + if (!form.success) { + throw new ValidationError( + `App ${installation.app.slug} does not expose a supported issue-link form`, + "app" + ); + } + validateUri(form.output.uri); + return form.output; } async function getChoices({ @@ -405,34 +409,29 @@ async function getChoices({ 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]; - } - // App form option searches are not exposed by the SDK. - 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, - } + const dependentData = Object.fromEntries( + (field.depends_on ?? []).map((name) => [name, values[name]]) + ); + const result = await getSentryAppInstallationExternalRequestOptions({ + ...getSdkConfig(getControlSiloUrl(), { cache: "no-store" }), + path: { uuid: installationUuid }, + query: { + uri: field.uri, + query, + projectId: projectId === undefined ? undefined : Number(projectId), + dependentData: field.depends_on?.length + ? JSON.stringify(dependentData) + : undefined, + }, + }); + const parsed = safeParse( + ChoicesResponseSchema, + unwrapResult(result, "Failed to search app issues") ); - return data.choices; + if (!parsed.success) { + throw new ApiError("App search returned invalid issue choices", 0); + } + return parsed.output.choices; } function selectChoice( @@ -482,8 +481,15 @@ async function resolveFields( ); const field = pending[index]; if (!field) { + const missing = new Set( + pending.flatMap((item) => + (item.depends_on ?? []).filter((name) => values[name] === undefined) + ) + ); throw new ValidationError( - "App link fields have missing or circular dependencies; supply the required --field values", + missing.size + ? `Missing app link fields: ${[...missing].map((name) => `--field ${name}=VALUE`).join(", ")}` + : "App link fields have circular dependencies", "field" ); } @@ -671,53 +677,35 @@ export async function resolveAppIssueLink( }; } -/** Execute one app callback after a fresh singleton check; concurrent server-side replacements remain possible. */ +/** Execute the callback with the backend's atomic no-op and replacement guard. */ 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" - ); + return { link: prepared.existing, changed: false }; } validateUri(prepared.uri); - // The SDK's direct registration 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, - }, + const result = await executeSentryAppInstallationExternalIssueAction({ + ...getSdkConfig(getControlSiloUrl(), { + // A callback can have external effects before a failed association write. 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 }; + }), + path: { uuid: prepared.installationUuid }, + query: { + expectedExternalIssueUrl: parseTarget(prepared.url).url, + }, + body: { + ...prepared.fields, + groupId: prepared.issueId, + action: "link", + uri: prepared.uri, + }, + }); + return { + link: unwrapResult(result, "Failed to link app issue"), + changed: result.response?.status === 201, + }; } /** Remove only the selected local app association, using event:write or event:admin. */ @@ -732,14 +720,14 @@ export async function unlinkAppIssueLink( "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", - } - ); + groupPath(orgSlug, issueId); + const result = await deleteOrganizationIssueExternalIssue({ + ...getSdkConfig(await resolveOrgRegion(orgSlug), { cache: "no-store" }), + path: { + organization_id_or_slug: orgSlug, + issue_id: issueId, + external_issue_id: linkId, + }, + }); + unwrapResult(result, "Failed to unlink app issue"); } diff --git a/packages/cli/src/lib/api/issue-integrations.ts b/packages/cli/src/lib/api/issue-integrations.ts index 6720f7a4d2..2892c3970e 100644 --- a/packages/cli/src/lib/api/issue-integrations.ts +++ b/packages/cli/src/lib/api/issue-integrations.ts @@ -2,35 +2,21 @@ import { deleteOrganizationIssueIntegration, type ExternalIssueLinkResponse, - type LinkExternalIssueRequest, - type ListOrganizationReposResponse, - listOrganizationRepos, + type IssueIntegrationsResponse, + listOrganizationIssueIntegrations, updateOrganizationIssueIntegration, } from "@sentry/api"; import { vExternalIssueLinkResponse, - vIntegrationIssueConfigResponse, - vListOrganizationReposResponse, + vIssueIntegrationsResponse, } from "@sentry/api/valibot"; -import { - array, - type InferOutput, - nullish, - object, - optional, - pick, - safeParse, - string, -} from "valibot"; +import { safeParse } 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"; @@ -50,35 +36,7 @@ export type NativeIssueLink = Pick< 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; +type NativeIntegration = IssueIntegrationsResponse[number]; /** Read-only resolution result used for previews and a subsequent link mutation. */ export type PreparedNativeIssueLink = { @@ -92,113 +50,62 @@ export type PreparedNativeIssueLink = { integrationId: string; /** Native integration provider key. */ provider: string; - /** Canonical tracker issue URL for the preview. */ + /** External issue URL submitted to the backend for provider resolution. */ url: string; - /** Provider issue key for the preview. */ - key: string; - /** Provider-specific issue identifier and repository. */ - body: LinkExternalIssueRequest; /** 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 JIRA_KEY = /^[A-Z][A-Z0-9]*-\d+$/i; +const JIRA_PATH = /^(.*?)\/(browse|issues)\/([A-Z][A-Z0-9]*-\d+)$/i; +const JIRA_PROJECT_PATH = /\/projects\/[^/]+$/; +const JIRA_BOARD_PATH = + /^(.*?)(?:\/jira\/(?:software|servicedesk|core)\/|\/secure\/RapidBoard\.jspa$)/; 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 { + const url = storedUrl(value); + if (!url) { throw new ValidationError( - "External issue must be an absolute HTTP(S) URL." + "External issue must be an absolute HTTP(S) URL without credentials." ); } + return url; +} + +/** Invalid stored URLs must not prevent matching an unrelated valid association. */ +function storedUrl(value: string): URL | undefined { + const url = URL.canParse(value) ? new URL(value) : undefined; if ( - !["https:", "http:"].includes(url.protocol) || + !(url && ["https:", "http:"].includes(url.protocol)) || url.username || url.password ) { - throw new ValidationError( - "External issue must be an HTTP(S) URL without credentials." - ); + return; } 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; + if (!domain) { + return integration.provider.key === "github" + ? storedUrl(`https://github.com/${integration.name}`) + : undefined; + } // Older personal Bitbucket installations store only the username. if (integration.provider.key === "bitbucket" && !domain.includes("/")) { - return parseUrl(`https://bitbucket.org/${domain}`); + return storedUrl(`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 }, - }; + return storedUrl(domain.includes("://") ? domain : `https://${domain}`); } function azureAccount(url: URL): string | undefined { @@ -210,184 +117,177 @@ function azureAccount(url: URL): string | undefined { } } -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 }, - }; +/** Jira copy links can select the issue in a path or board/backlog query. */ +function jiraIssueKey(url: URL): string | undefined { + const selected = url.searchParams + .getAll("selectedIssue") + .find((key) => JIRA_KEY.test(key)); + return (selected ?? JIRA_PATH.exec(url.pathname)?.[3])?.toUpperCase(); } -/** - * 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; +/** Infer contexts only for known Jira UI paths to keep installations distinct. */ +function jiraContextPath(url: URL): string | undefined { + const path = JIRA_PATH.exec(url.pathname); + if (path) { + return path[2] === "issues" + ? path[1]?.replace(JIRA_PROJECT_PATH, "") + : path[1]; + } + return JIRA_BOARD_PATH.exec(url.pathname)?.[1]; +} + +function requireJiraContext(url: URL): string { + const context = jiraContextPath(url); + if (context === undefined) { + throw new ValidationError( + "Cannot identify this Jira URL's installation context. Use the canonical /browse/ URL." + ); } - 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, + return context; +} + +/** Compare URL aliases locally; provider identifiers are resolved by the backend. */ +function issueIdentity(url: URL, provider: string): string | undefined { + switch (provider) { + case "github": + case "github_enterprise": + case "bitbucket": { + const pull = + provider === "bitbucket" + ? null + : GITHUB_PULL_REQUEST.exec(url.pathname); + const match = pull ?? REPOSITORY_ISSUE.exec(url.pathname); + return match + ? `${url.host}/${match[1]?.toLowerCase()}#${match[2]}` + : undefined; } - ); - const repository = data.repos.find( - (repo) => repo.url && parseUrl(repo.url).href === projectUrl - ); - if (!repository) { - return; + case "gitlab": { + const match = GITLAB_ISSUE.exec(url.pathname); + return match ? `${url.host}/${match[1]}#${match[2]}` : undefined; + } + case "jira": + case "jira_server": { + const key = jiraIssueKey(url); + return key ? `${url.host}#${key}` : undefined; + } + case "vsts": { + const account = azureAccount(url); + const match = WORK_ITEM.exec(url.pathname); + return account && match + ? `${account}:${url.port}#${match[2]}` + : undefined; + } + default: + return; } - return { - url: `${projectUrl}/-/issues/${match[2]}`, - key: `${repository.name}#${match[2]}`, - body: { externalIssue: `${repository.identifier}#${match[2]}` }, - }; } -function parseTarget( +function matchesIntegration( url: URL, - integration: NativeIntegration -): ParsedTarget | undefined { + integration: NativeIntegration, + explicitlySelected: boolean +): boolean { + const provider = integration.provider.key; + if (!issueIdentity(url, provider)) { + return false; + } + // Older Enterprise metadata may omit its host. Only an explicit selection + // can delegate host validation to the backend's instance_hostname metadata. + if (provider === "github_enterprise" && !integration.domainName) { + return ( + explicitlySelected && + url.pathname.split("/")[1]?.toLowerCase() === + integration.name.toLowerCase() + ); + } const domain = integrationUrl(integration); - if (!(domain && integration.domainName)) { - return; + if (!domain) { + return false; } - const provider = integration.provider.key; if (provider === "vsts") { - return parseAzureIssue(url, domain); + return azureAccount(domain) === azureAccount(url); } if (domain.host !== url.host) { - return; + return false; } 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); + return ( + !account || + url.pathname.split("/")[1]?.toLowerCase() === account.toLowerCase() + ); } if (provider === "jira" || provider === "jira_server") { + requireJiraContext(url); 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; - } + return ( + !prefix || + url.pathname === prefix || + url.pathname.startsWith(`${prefix}/`) + ); } - throw new ValidationError( - "Too many results to resolve the issue link safely." - ); + // GitLab's public domain omits its deployment prefix. The backend validates + // that prefix and group; multiple installations on the host require a selector. + return provider === "gitlab"; } +/** Read every integration page; partial discovery could hide an ambiguous match. */ 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) => { - const result = await listOrganizationRepos({ + const integrations: NativeIntegration[] = []; + let cursor: string | undefined; + for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { + const result = await listOrganizationIssueIntegrations({ ...config, - path: { organization_id_or_slug: orgSlug }, + path: { organization_id_or_slug: orgSlug, issue_id: issueId }, query: { cursor, per_page: API_MAX_PER_PAGE }, }); - const page = unwrapPaginatedResult( + const response = unwrapPaginatedResult( result, - "Failed to list repositories" + "Failed to list issue integrations" ); - const parsed = safeParse(vListOrganizationReposResponse, page.data); + const parsed = safeParse(vIssueIntegrationsResponse, response.data); if (!parsed.success) { throw new ApiError( - "Unexpected response format when listing repositories", + "Unexpected response format when listing issue integrations", 0 ); } - return { ...page, data: parsed.output }; - }); + integrations.push(...parsed.output); + cursor = response.nextCursor; + if (!cursor) { + return integrations; + } + } + throw new ValidationError( + "Too many results to resolve the issue link safely." + ); } 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, - })) + integration.externalIssues.flatMap((link) => { + const url = storedUrl(link.url); + if (!url) { + return []; + } + return [ + { + id: link.id, + key: link.key, + displayName: link.displayName, + title: link.title ?? undefined, + integrationId: integration.id, + provider: integration.provider.key, + url: url.href, + }, + ]; + }) ); } @@ -400,41 +300,29 @@ export async function listNativeIssueLinks( } 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) { + const existing = storedUrl(link.url); + if (!existing) { 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)) { + if (existing.host !== target.host) { + return false; + } + const key = jiraIssueKey(target); + if (!key) { + return false; + } + const context = jiraContextPath(existing); + const targetContext = requireJiraContext(target); + return ( + context !== undefined && + context === targetContext && + key === jiraIssueKey(existing) ); } - if (["jira", "jira_server"].includes(link.provider)) { - const canonical = parseJiraIssue(target)?.url; - return Boolean(canonical && canonical === parseJiraIssue(existing)?.url); + const identity = issueIdentity(target, link.provider); + if (identity) { + return identity === issueIdentity(existing, link.provider); } return existing.href === target.href; } @@ -477,59 +365,12 @@ export async function resolveNativeIssueLink(options: { ); } 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}/` - ), - }, - }, - ]; - }); - } + const candidates = integrations.filter( + (integration) => + integration.status === "active" && + (!options.integrationId || options.integrationId === integration.id) && + matchesIntegration(url, integration, Boolean(options.integrationId)) + ); 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." @@ -537,7 +378,7 @@ export async function resolveNativeIssueLink(options: { } if (candidates.length > 1) { throw new ValidationError( - `Multiple integrations match this URL. Specify --integration : ${candidates.map(({ integration }) => `${integration.id} (${integration.name})`).join(", ")}` + `Multiple integrations match this URL. Specify --integration : ${candidates.map((integration) => `${integration.id} (${integration.name})`).join(", ")}` ); } const selected = candidates[0]; @@ -548,18 +389,18 @@ export async function resolveNativeIssueLink(options: { orgSlug: options.orgSlug, issueId: options.issueId, regionUrl: await resolveOrgRegion(options.orgSlug), - integrationId: selected.integration.id, - provider: selected.integration.provider.key, - ...selected.target, + integrationId: selected.id, + provider: selected.provider.key, + url: url.href, existing: findNativeIssueLink( flattenLinks(integrations), - selected.target.url, - selected.integration.id + url.href, + selected.id ), }; } -/** Link an existing tracker issue, without a comment or automatic mutation retry. */ +/** Link by URL; the backend resolves provider identifiers and enforces idempotency. */ export async function linkNativeIssue( prepared: PreparedNativeIssueLink ): Promise<{ link: NativeIssueLink; changed: boolean }> { @@ -568,7 +409,6 @@ export async function linkNativeIssue( } const result = await updateOrganizationIssueIntegration({ ...getSdkConfig(prepared.regionUrl, { - retry: false, cache: "no-store", }), path: { @@ -576,10 +416,10 @@ export async function linkNativeIssue( issue_id: prepared.issueId, integration_id: prepared.integrationId, }, - body: prepared.body, + body: { externalIssue: prepared.url }, }); const parsed = safeParse( - NativeIssueMutationSchema, + vExternalIssueLinkResponse, unwrapResult(result, "Failed to link external issue") ); if (!parsed.success) { @@ -596,7 +436,7 @@ export async function linkNativeIssue( integrationId: String(data.integrationId), provider: prepared.provider, }, - changed: true, + changed: result.response.status === 201, }; } @@ -614,7 +454,6 @@ export async function unlinkNativeIssueLink( } const result = await deleteOrganizationIssueIntegration({ ...getSdkConfig(await resolveOrgRegion(orgSlug), { - retry: false, cache: "no-store", }), path: { diff --git a/packages/cli/src/lib/issue-links.ts b/packages/cli/src/lib/issue-links.ts index f7d857c8a9..d5567037db 100644 --- a/packages/cli/src/lib/issue-links.ts +++ b/packages/cli/src/lib/issue-links.ts @@ -165,7 +165,7 @@ export async function linkExternalIssue( changed: false, externalIssue: { id: prepared.existing?.id, - identifier: prepared.key, + identifier: prepared.existing?.key, url: prepared.url, provider: prepared.provider, }, diff --git a/packages/cli/src/lib/scope-recovery.ts b/packages/cli/src/lib/scope-recovery.ts index 7c3468303d..395f3f1b79 100644 --- a/packages/cli/src/lib/scope-recovery.ts +++ b/packages/cli/src/lib/scope-recovery.ts @@ -3,22 +3,17 @@ 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 { - InteractiveLoginOptions, - LoginResult, -} from "./interactive-login.js"; +import type { LoginResult } from "./interactive-login.js"; import { interactivePromptsAllowed } from "./interactive-prompts.js"; import { logger } from "./logger.js"; import { OAUTH_SCOPES } from "./oauth.js"; -type InteractiveLogin = ( - options?: InteractiveLoginOptions -) => Promise; +type InteractiveLogin = () => Promise; type OAuthScopeState = | { kind: "current" } | { kind: "invalid" } - | { kind: "missing"; scopes: string[]; requestedScopes: string[] }; + | { kind: "missing"; scopes: string[] }; export type ScopeRecoveryRuntime = { assertTrustedHost: () => void; @@ -57,11 +52,7 @@ 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, - requestedScopes: [...new Set([...OAUTH_SCOPES, ...granted])], - } + ? { kind: "missing", scopes: missing } : { kind: "current" }; } catch (error) { if ( @@ -129,15 +120,6 @@ 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; } @@ -151,13 +133,7 @@ async function refreshOAuthScopes( "Your CLI authorization is no longer valid. Starting authorization...\n\n" ); } - return Boolean( - await runInteractiveLogin( - state.kind === "missing" - ? { scope: state.requestedScopes.join(" ") } - : undefined - ) - ); + return Boolean(await runInteractiveLogin()); } /** Refresh a stored OAuth grant when it lacks any scope requested by this CLI. */ diff --git a/packages/cli/test/lib/api-client.test.ts b/packages/cli/test/lib/api-client.test.ts index 929847604f..e8130673ba 100644 --- a/packages/cli/test/lib/api-client.test.ts +++ b/packages/cli/test/lib/api-client.test.ts @@ -326,25 +326,6 @@ 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 index c258170f1a..bb4e60ec4a 100644 --- a/packages/cli/test/lib/api/issue-app-links.test.ts +++ b/packages/cli/test/lib/api/issue-app-links.test.ts @@ -40,8 +40,10 @@ const FORM = { ], }; const COMPONENT = { + uuid: "component-uuid", type: "issue-link", - sentryApp: { uuid: "app-uuid", slug: "linear" }, + error: "", + sentryApp: { uuid: "app-uuid", slug: "linear", name: "Linear", avatars: [] }, schema: { link: FORM }, }; @@ -68,7 +70,7 @@ beforeEach(async () => { choices = [["linear-uuid", "ENG-42: Fix the crash"]]; form = FORM; installation = INSTALLATION; - actionStatus = 200; + actionStatus = 201; actionLink = LINK; globalThis.fetch = mockFetch(async (input, init) => { const request = new Request(input, init); @@ -88,7 +90,7 @@ beforeEach(async () => { } if (path.endsWith("/external-issue-actions/")) { return json( - actionStatus === 200 ? actionLink : { detail: "Provider failed" }, + actionStatus < 300 ? actionLink : { detail: "Provider failed" }, actionStatus ); } @@ -126,6 +128,11 @@ describe("app issue-link action", () => { ); expect(await linkAppIssue(prepared)).toEqual({ changed: true, link: LINK }); expect(writes()).toHaveLength(1); + expect( + new globalThis.URL(writes()[0]!.url).searchParams.get( + "expectedExternalIssueUrl" + ) + ).toBe(URL); expect(await writes()[0]?.json()).toEqual({ groupId: ISSUE, action: "link", @@ -133,6 +140,9 @@ describe("app issue-link action", () => { issueId: "linear-uuid", }); expect(calls.every((request) => request.cache === "no-store")).toBe(true); + expect( + calls.filter((request) => request.url.includes("/external-issues/")) + ).toHaveLength(1); }); test("does not treat a prefix search result as an exact Linear issue", async () => { @@ -185,13 +195,21 @@ describe("app issue-link action", () => { expect(writes()).toHaveLength(0); }); - test("rechecks the singleton immediately before calling the app", async () => { + test("uses the backend guard when another association appears after preflight", 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); + actionStatus = 409; + await expect(linkAppIssue(prepared)).rejects.toMatchObject({ status: 409 }); + expect(writes()).toHaveLength(1); + }); + + test("reports a backend no-op when the same association appears after preflight", async () => { + const prepared = await resolveAppIssueLink(OPTIONS); + actionStatus = 200; + expect(await linkAppIssue(prepared)).toEqual({ + changed: false, + link: LINK, + }); + expect(writes()).toHaveLength(1); }); test("does not blindly replay a failed app action", async () => { @@ -239,16 +257,25 @@ describe("app issue-link action", () => { 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", + test("preserves query and fragment identity in the backend URL guard", async () => { + const url = "https://tracker.example/view?id=42#issue"; + installation = { + ...INSTALLATION, + app: { ...INSTALLATION.app, slug: "custom" }, }; - await expect(linkAppIssue(prepared)).rejects.toThrow( - "different issue after linking" - ); - expect(writes()).toHaveLength(1); + form = { uri: "/link", required_fields: [{ name: "url", type: "text" }] }; + actionLink = { ...LINK, serviceType: "custom", webUrl: url }; + const prepared = await resolveAppIssueLink({ + ...OPTIONS, + appSlug: "custom", + url, + }); + await linkAppIssue(prepared); + expect( + new globalThis.URL(writes()[0]!.url).searchParams.get( + "expectedExternalIssueUrl" + ) + ).toBe(url); }); test("requires an installation in the requested organization", async () => { @@ -319,6 +346,26 @@ describe("app issue-link action", () => { expect(writes()).toHaveLength(0); }); + test("keeps the query guard separate from an app field with the same name", async () => { + form = { + ...FORM, + optional_fields: [{ name: "expectedExternalIssueUrl", type: "text" }], + }; + const prepared = await resolveAppIssueLink({ + ...OPTIONS, + fields: { expectedExternalIssueUrl: "provider-field" }, + }); + await linkAppIssue(prepared); + expect( + new globalThis.URL(writes()[0]!.url).searchParams.get( + "expectedExternalIssueUrl" + ) + ).toBe(URL); + expect(await writes()[0]?.json()).toMatchObject({ + expectedExternalIssueUrl: "provider-field", + }); + }); + test("resolves dependent choices using validated field values", async () => { form = { ...FORM, @@ -361,6 +408,40 @@ describe("app issue-link action", () => { expect(writes()).toHaveLength(0); }); + test("names missing dependencies instead of reporting a dependency cycle", async () => { + form = { + ...FORM, + required_fields: [ + { ...FORM.required_fields[0], depends_on: ["team"] }, + { name: "team", type: "text" }, + ], + }; + await expect(resolveAppIssueLink(OPTIONS)).rejects.toThrow( + "Missing app link fields: --field team=VALUE" + ); + expect( + calls.some((request) => request.url.includes("external-requests")) + ).toBe(false); + expect(writes()).toHaveLength(0); + }); + + test("distinguishes actual dependency cycles from missing values", async () => { + form = { + ...FORM, + required_fields: [ + { ...FORM.required_fields[0], depends_on: ["team"] }, + { name: "team", type: "text", depends_on: ["issueId"] }, + ], + }; + await expect( + resolveAppIssueLink({ + ...OPTIONS, + fields: { team: "Engineering", issueId: "ENG-42" }, + }) + ).rejects.toThrow("App link fields have circular dependencies"); + expect(writes()).toHaveLength(0); + }); + test("accepts numeric labels in the app's static select options", async () => { form = { ...FORM, @@ -460,6 +541,24 @@ describe("list and unlink app associations", () => { ).toBeUndefined(); }); + test.each([ + "invalid", + "https://linear.app/example/settings", + "javascript:alert(1)", + ])("ignores malformed stored sibling %s when matching a valid target", (webUrl) => { + const sibling = { ...LINK, id: "100", webUrl }; + expect(findAppIssueLink([sibling, LINK], URL)).toBe(LINK); + expect(() => findAppIssueLink([LINK], webUrl)).toThrow(ValidationError); + }); + + test("still refuses replacement of a corrupt link belonging to the selected app", async () => { + links = [{ ...LINK, webUrl: "invalid" }]; + await expect(resolveAppIssueLink(OPTIONS)).rejects.toThrow( + "Unlink it before" + ); + expect(writes()).toHaveLength(0); + }); + test("keeps query and fragment identifiers distinct for generic apps", () => { const link = { ...LINK, diff --git a/packages/cli/test/lib/api/issue-integrations.test.ts b/packages/cli/test/lib/api/issue-integrations.test.ts index 378006938c..fe455e538d 100644 --- a/packages/cli/test/lib/api/issue-integrations.test.ts +++ b/packages/cli/test/lib/api/issue-integrations.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { findNativeIssueLink, linkNativeIssue, @@ -18,9 +18,6 @@ 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 = { @@ -30,12 +27,11 @@ const LINK: NativeIssueLink = { key: "PROJ-7", url: JIRA_URL, displayName: "PROJ-7", - title: "Example issue", }; function integration( provider = "jira", - domainName = "tracker.example.com", + domainName: string | null = "tracker.example.com", id = "10", externalIssues: NativeIssueLink[] = [] ) { @@ -43,6 +39,11 @@ function integration( id, name: `Example ${provider}`, domainName, + icon: null, + accountType: null, + scopes: null, + outOfDate: null, + missingFeatures: null, provider: { key: provider, slug: provider, @@ -53,17 +54,11 @@ function integration( aspects: {}, }, status: "active", - externalIssues, - }; -} - -function repository(name: string, integrationId: string) { - return { - id: "100", - name, - integrationId, - status: "active", - dateCreated: "2026-01-01T00:00:00Z", + externalIssues: externalIssues.map((link) => ({ + ...link, + title: link.title ?? null, + description: null, + })), }; } @@ -101,40 +96,32 @@ describe("native tracker issue links", () => { { provider: "jira", domain: "tracker.example.com", - url: `${JIRA_URL.toLowerCase()}/?source=cli#details`, + url: JIRA_URL.toLowerCase(), 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", @@ -142,124 +129,76 @@ describe("native tracker issue links", () => { 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" }, + canonical: "https://dev.azure.com/example/project/_workitems/edit/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" }, + canonical: "https://example.visualstudio.com/project/_workitems/edit/7", }, - ])("prepares $provider without creating or commenting", async (fixture) => { - const requests = mockApi((request) => { + ...["github", "github_enterprise"].flatMap((provider) => + ["issues", "pull"].map((path) => ({ + provider, + domain: "github.example.com/owner", + url: `https://github.example.com/OWNER/repo/${path}/7`, + canonical: `https://github.example.com/OWNER/repo/${path}/7`, + })) + ), + ])("links $provider using the URL without repository discovery or a comment", async (fixture) => { + const requests = mockApi(async (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)]); + if (request.method === "PUT") { + expect(url.pathname).toBe(`${INTEGRATIONS}10/`); + expect(await request.json()).toEqual({ + externalIssue: fixture.url, + }); + return Response.json( + { ...LINK, id: 1234, integrationId: 10, url: fixture.canonical }, + { status: 201 } + ); } - 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, - }); + expect(request.method).toBe("GET"); + expect(url.pathname).toBe(INTEGRATIONS); + return json([integration(fixture.provider, fixture.domain)]); }); - const prepared = await resolveNativeIssueLink({ ...SOURCE, url: fixture.url, }); - expect(prepared).toMatchObject({ ...SOURCE, regionUrl: REGION, integrationId: "10", provider: fixture.provider, - url: fixture.canonical, - body: fixture.body, + url: fixture.url, }); 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(1); + expect(await linkNativeIssue(prepared)).toMatchObject({ + changed: true, + link: { url: fixture.canonical }, }); - expect(requests).toHaveLength(2); + expect(requests.map((request) => request.method)).toEqual(["GET", "PUT"]); }); test.each([ @@ -285,8 +224,7 @@ describe("native tracker issue links", () => { if (request.method === "PUT") { expect(url.pathname).toBe(`${INTEGRATIONS}10/`); expect(await request.json()).toEqual({ - repo: "Owner/Repo", - externalIssue: "7", + externalIssue: `https://${host}/OWNER/repo/pull/7/files?source=cli`, }); linked = true; // The mutation uses GitHub's html_url; listing reconstructs /issues/N. @@ -312,8 +250,7 @@ describe("native tracker issue links", () => { ), ]); } - expect(url.pathname).toBe(REPOSITORIES); - return json([repository("Owner/Repo", "10")]); + throw new Error(`Unexpected request: ${request.url}`); }); const options = { @@ -355,6 +292,145 @@ describe("native tracker issue links", () => { ).toEqual(["PUT", "DELETE"]); }); + test("preserves the full query while stripping fragment and trailing slash", async () => { + mockApi(() => json([integration()])); + const prepared = await resolveNativeIssueLink({ + ...SOURCE, + url: `${JIRA_URL}/?source=cli#details`, + }); + expect(prepared.url).toBe(`${JIRA_URL}?source=cli`); + }); + + test.each([ + { + domain: "tracker.example.com", + url: "https://tracker.example.com/projects/PROJ/issues/PROJ-7", + stored: JIRA_URL, + }, + { + domain: "tracker.example.com", + url: "https://tracker.example.com/jira/software/projects/PROJ/boards/1?selectedIssue=PROJ-7&view=detail", + stored: JIRA_URL, + }, + { + domain: "tracker.example.com/jira", + url: "https://tracker.example.com/jira/secure/RapidBoard.jspa?rapidView=1&selectedIssue=PROJ-7", + stored: "https://tracker.example.com/jira/browse/PROJ-7", + }, + ])("forwards Jira copy link $url and matches its stored browse alias", async ({ + domain, + url, + stored, + }) => { + const requests = mockApi(async (request) => { + if (request.method === "GET") + return json([integration("jira_server", domain)]); + expect(await request.json()).toEqual({ externalIssue: url }); + return Response.json( + { ...LINK, id: 1234, integrationId: 10, url: stored }, + { status: 201 } + ); + }); + const prepared = await resolveNativeIssueLink({ ...SOURCE, url }); + expect(await linkNativeIssue(prepared)).toMatchObject({ + changed: true, + link: { url: stored }, + }); + expect( + findNativeIssueLink( + [{ ...LINK, provider: "jira_server", url: stored }], + url + )?.id + ).toBe(LINK.id); + expect(requests).toHaveLength(2); + }); + + test("unknown Jira board contexts require a canonical issue URL before any mutation", async () => { + const requests = mockApi(() => + json([integration("jira_server", "tracker.example.com")]) + ); + const url = + "https://tracker.example.com/jira-archive/board?selectedIssue=PROJ-7"; + await expect(resolveNativeIssueLink({ ...SOURCE, url })).rejects.toThrow( + "canonical /browse/" + ); + expect(() => findNativeIssueLink([LINK], url)).toThrow( + "canonical /browse/" + ); + expect(requests.every((request) => request.method === "GET")).toBe(true); + }); + + test("selects GitHub cloud installations with missing domain metadata by owner", async () => { + mockApi(() => json([{ ...integration("github", null), name: "Owner" }])); + expect( + await resolveNativeIssueLink({ + ...SOURCE, + url: "https://github.com/OWNER/repo/issues/7", + }) + ).toMatchObject({ integrationId: "10" }); + await expect( + resolveNativeIssueLink({ + ...SOURCE, + url: "https://github.com/another/repo/issues/7", + }) + ).rejects.toThrow("No installed native"); + }); + + test("requires an explicit Enterprise installation when its host metadata is missing", async () => { + mockApi(() => + json([{ ...integration("github_enterprise", null), name: "Owner" }]) + ); + const options = { + ...SOURCE, + url: "https://github.example.com/OWNER/repo/pull/7", + }; + await expect(resolveNativeIssueLink(options)).rejects.toThrow( + "--integration" + ); + expect( + await resolveNativeIssueLink({ ...options, integrationId: "10" }) + ).toMatchObject({ integrationId: "10", url: options.url }); + }); + + test("requires a selector for GitLab installations sharing a host", async () => { + mockApi(() => + json([ + integration("gitlab", "gitlab.example.com/group", "10"), + integration("gitlab", "gitlab.example.com/another", "20"), + ]) + ); + const options = { + ...SOURCE, + url: "https://gitlab.example.com/deployment/group/repo/-/issues/7", + }; + await expect(resolveNativeIssueLink(options)).rejects.toThrow( + "Multiple integrations" + ); + expect( + await resolveNativeIssueLink({ ...options, integrationId: "10" }) + ).toMatchObject({ integrationId: "10", url: options.url }); + }); + + test("malformed stored URLs and domains do not block an unrelated valid association", async () => { + const requests = mockApi(() => + json([ + integration("jira", "https://", "20"), + integration("jira", "tracker.example.com", "10", [ + { ...LINK, id: "999", url: "not a URL" }, + LINK, + ]), + ]) + ); + const prepared = await resolveNativeIssueLink({ ...SOURCE, url: JIRA_URL }); + expect(await linkNativeIssue(prepared)).toMatchObject({ + changed: false, + link: LINK, + }); + const links = await listNativeIssueLinks(SOURCE.orgSlug, SOURCE.issueId); + expect(findNativeIssueLink(links, JIRA_URL)?.id).toBe(LINK.id); + expect(requests.every((request) => request.method === "GET")).toBe(true); + }); + test("does not treat a Bitbucket pull request as an issue", async () => { const requests = mockApi(() => json([integration("bitbucket", "bitbucket.org/owner")]) @@ -368,20 +444,22 @@ describe("native tracker issue links", () => { 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")] - ) + test("delegates repository membership and URL validation to the backend", async () => { + const requests = mockApi((request) => + request.method === "GET" + ? json([integration("github", "github.com/owner")]) + : Response.json( + { detail: "Repository is not installed" }, + { status: 400 } + ) ); await expect( resolveNativeIssueLink({ ...SOURCE, url: "https://github.com/owner/repo/issues/7", - }) - ).rejects.toThrow("No installed native"); + }).then(linkNativeIssue) + ).rejects.toBeInstanceOf(ApiError); + expect(requests.map((request) => request.method)).toEqual(["GET", "PUT"]); }); test.each([ @@ -402,78 +480,6 @@ describe("native tracker issue links", () => { 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 Response.json( - { ...githubLink, id: 1234, integrationId: 10 }, - { status: 201 } - ); - } - 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); @@ -518,7 +524,7 @@ describe("native tracker issue links", () => { 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" }); + expect(await request.json()).toEqual({ externalIssue: JIRA_URL }); return Response.json( { ...LINK, id: 1234, integrationId: 10 }, { status: 201 } @@ -563,25 +569,54 @@ describe("native tracker issue links", () => { 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, + ])("retries an idempotent %s mutation", async (method) => { + vi.useFakeTimers(); + try { + let attempts = 0; + const requests = mockApi((request) => { + if (request.method === "GET") return json([integration()]); + expect(request.method).toBe(method); + attempts += 1; + if (attempts === 1) + return Response.json({ detail: "Temporary error" }, { status: 503 }); + return method === "PUT" + ? json({ ...LINK, id: 1234, integrationId: 10 }) + : new Response(null, { status: 204 }); }); - }); + const prepared = await resolveNativeIssueLink({ + ...SOURCE, + url: JIRA_URL, + }); + const mutation = + method === "PUT" + ? linkNativeIssue(prepared) + : unlinkNativeIssueLink(SOURCE.orgSlug, SOURCE.issueId, LINK); + await vi.runAllTimersAsync(); + if (method === "PUT") { + expect(await mutation).toMatchObject({ changed: false }); + } else { + await mutation; + } + expect( + requests.filter((request) => request.method === method) + ).toHaveLength(2); + } finally { + vi.useRealTimers(); + } + }); + + test("a concurrent PUT no-op uses the backend's 200 status", async () => { + mockApi((request) => + request.method === "GET" + ? json([integration()]) + : json({ ...LINK, id: 1234, integrationId: 10 }) + ); 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); + expect(prepared.existing).toBeUndefined(); + expect(await linkNativeIssue(prepared)).toEqual({ + link: LINK, + changed: false, + }); }); test("DELETE uses Sentry's ExternalIssue ID and accepts an empty 204 response", async () => { @@ -617,13 +652,11 @@ describe("native tracker issue links", () => { url, canonical, }) => { - const storedLink = { ...LINK, provider, url, title: null }; + const storedLink = { ...LINK, provider, url }; const requests = mockApi((request) => { if (request.method === "GET") { expect(new URL(request.url).pathname).toBe(INTEGRATIONS); - return json([ - { ...integration(provider, domain), externalIssues: [storedLink] }, - ]); + return json([integration(provider, domain, "10", [storedLink])]); } expect(request.method).toBe("DELETE"); expect(new URL(request.url).searchParams.get("externalIssue")).toBe( @@ -633,9 +666,9 @@ describe("native tracker issue links", () => { }); const link = findNativeIssueLink( await listNativeIssueLinks(SOURCE.orgSlug, SOURCE.issueId), - url + canonical ); - expect(link).toEqual({ ...storedLink, url: canonical, title: undefined }); + expect(link).toEqual({ ...storedLink, title: undefined }); if (!link) { throw new Error("Expected the existing external issue link"); } @@ -673,16 +706,6 @@ describe("native tracker issue links", () => { }); 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", @@ -703,21 +726,7 @@ describe("native tracker issue links", () => { 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", - }, - ], - } - ) - ); + mockApi(() => json([integration(provider, domain)])); await expect(resolveNativeIssueLink({ ...SOURCE, url })).rejects.toThrow( "No installed native" ); @@ -774,6 +783,54 @@ describe("findNativeIssueLink", () => { expect(findNativeIssueLink([link], target)).toBe(link); }); + test.each([ + { + stored: "https://tracker.example.com/browse/PROJ-7", + target: "https://tracker.example.com/jira-archive/browse/PROJ-7", + }, + { + stored: "https://tracker.example.com/jira/browse/PROJ-7", + target: "https://tracker.example.com/jira/archive/browse/PROJ-7", + }, + { + stored: "https://tracker.example.com/browse/PROJ-7", + target: + "https://tracker.example.com/jira-archive/projects/PROJ/issues/PROJ-7", + }, + { + stored: "https://tracker.example.com/browse/PROJ-7", + target: + "https://tracker.example.com/jira-archive/secure/RapidBoard.jspa?selectedIssue=PROJ-7", + }, + { + stored: "https://tracker.example.com/jira/browse/PROJ-7", + target: + "https://tracker.example.com/jira/archive/secure/RapidBoard.jspa?selectedIssue=PROJ-7", + }, + ])("does not match Jira aliases from another context: $target", ({ + stored, + target, + }) => { + expect( + findNativeIssueLink( + [{ ...LINK, provider: "jira_server", url: stored }], + target + ) + ).toBeUndefined(); + }); + + test("a Jira sibling on the same host does not block an Enterprise issue match", () => { + const enterprise = { + ...LINK, + id: "5678", + provider: "github_enterprise", + url: "https://tracker.example.com/owner/repo/issues/7", + }; + expect(findNativeIssueLink([LINK, enterprise], enterprise.url)).toBe( + enterprise + ); + }); + test("rejects ambiguous links and accepts an integration selector", () => { const second = { ...LINK, id: "5678", integrationId: "20" }; expect(() => findNativeIssueLink([LINK, second], JIRA_URL)).toThrow( diff --git a/packages/cli/test/lib/issue-links.test.ts b/packages/cli/test/lib/issue-links.test.ts index 0985422631..659a156097 100644 --- a/packages/cli/test/lib/issue-links.test.ts +++ b/packages/cli/test/lib/issue-links.test.ts @@ -61,8 +61,6 @@ beforeEach(() => { 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, diff --git a/packages/cli/test/lib/scope-recovery.test.ts b/packages/cli/test/lib/scope-recovery.test.ts index 847d974743..11ff67e1ef 100644 --- a/packages/cli/test/lib/scope-recovery.test.ts +++ b/packages/cli/test/lib/scope-recovery.test.ts @@ -48,44 +48,12 @@ describe("runWithScopeRecovery", () => { expect(proceed).toHaveBeenCalledTimes(2); expect(testRuntime.getAuthScopes).toHaveBeenCalledOnce(); expect(testRuntime.assertTrustedHost).toHaveBeenCalledOnce(); - expect(login).toHaveBeenCalledWith({ scope: OAUTH_SCOPES.join(" ") }); + expect(login).toHaveBeenCalledWith(); 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); @@ -187,52 +155,38 @@ 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([ - ...OAUTH_SCOPES.filter((scope) => scope !== "event:write"), - "org:write", - ]); + const getAuthScopes = vi.fn().mockResolvedValue([]); const login = vi.fn(); - const testRuntime = runtime({ getAuthScopes, inputIsTty: () => false }); await expect( runWithScopeRecovery( vi.fn().mockRejectedValue(error), [], login, - testRuntime + runtime({ getAuthScopes, inputIsTty: () => false }) ) ).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, - testRuntime + runtime({ getAuthScopes, promptsAllowed: () => false }) ) ).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/pnpm-lock.yaml b/pnpm-lock.yaml index 0994a05568..8d925f4afe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -186,8 +186,8 @@ importers: specifier: ^1.38.0 version: 1.38.0(express@5.2.1)(zod@4.4.3) '@sentry/api': - specifier: ^0.305.0 - version: 0.305.0(valibot@1.4.2(typescript@5.9.3))(zod@4.4.3) + specifier: ^0.337.0 + version: 0.337.0(valibot@1.4.2(typescript@5.9.3))(zod@4.4.3) '@sentry/core': specifier: 10.63.0 version: 10.63.0(patch_hash=e663994979ff877a26ab6d4dea5968fbaee4ccdfdeb85623983535a572678940) @@ -2002,8 +2002,8 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - '@sentry/api@0.305.0': - resolution: {integrity: sha512-vyK+ytFWsqKNW9USxGcxavK39Yo0NGYRTDXVKoYNcOO4e3Vdc2KizrCNwybOtXYfmyQgGezktAq9QqDdygoxQw==} + '@sentry/api@0.337.0': + resolution: {integrity: sha512-/U7MU4TEhRxrSDWbbTbCIhIp2FOjw18tU9m4oz/PpzIrBKiB8QPk4YDpW5hGeOXZGjsNVQMBg8Vgxzs16PUjMw==} engines: {node: '>=22'} peerDependencies: valibot: '*' @@ -7295,7 +7295,7 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} - '@sentry/api@0.305.0(valibot@1.4.2(typescript@5.9.3))(zod@4.4.3)': + '@sentry/api@0.337.0(valibot@1.4.2(typescript@5.9.3))(zod@4.4.3)': optionalDependencies: valibot: 1.4.2(typescript@5.9.3) zod: 4.4.3 From c2cf13050bfdd898ac34698c91a530e1f2470f79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 29 Sep 2026 07:36:51 +0200 Subject: [PATCH 08/17] fix(issue): defer native link validation and retries to backend --- .../cli/src/lib/api/issue-integrations.ts | 76 +---- .../test/lib/api/issue-integrations.test.ts | 281 ++++++------------ 2 files changed, 108 insertions(+), 249 deletions(-) diff --git a/packages/cli/src/lib/api/issue-integrations.ts b/packages/cli/src/lib/api/issue-integrations.ts index 2892c3970e..1274ad23e8 100644 --- a/packages/cli/src/lib/api/issue-integrations.ts +++ b/packages/cli/src/lib/api/issue-integrations.ts @@ -52,7 +52,7 @@ export type PreparedNativeIssueLink = { provider: string; /** External issue URL submitted to the backend for provider resolution. */ url: string; - /** Reference found during fresh preflight; avoids a duplicate mutation. */ + /** Reference found during preflight, used only to describe a dry run. */ existing?: NativeIssueLink; }; @@ -61,13 +61,9 @@ const REPOSITORY_ISSUE = /^\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/[^/]+)?$/; const GITHUB_PULL_REQUEST = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/[^/]+)?$/; const GITLAB_ISSUE = /^\/(.+?)(?:\/-)?\/issues\/(\d+)$/; const JIRA_KEY = /^[A-Z][A-Z0-9]*-\d+$/i; -const JIRA_PATH = /^(.*?)\/(browse|issues)\/([A-Z][A-Z0-9]*-\d+)$/i; -const JIRA_PROJECT_PATH = /\/projects\/[^/]+$/; -const JIRA_BOARD_PATH = - /^(.*?)(?:\/jira\/(?:software|servicedesk|core)\/|\/secure\/RapidBoard\.jspa$)/; +const JIRA_PATH = /\/(?:browse|issues)\/([A-Z][A-Z0-9]*-\d+)$/i; +const JIRA_CANONICAL_PATH = /^(.*)\/browse\/([^/]+)$/; const WORK_ITEM = /^(.*?)\/_workitems\/edit\/(\d+)$/; -const SCM_CHANGE = - /(?:^\/[^/]+\/[^/]+\/(?:pulls?|pull-requests|commits?)\/|\/-\/(?:merge_requests|commits?)\/)/; function parseUrl(value: string): URL { const url = storedUrl(value); @@ -122,28 +118,7 @@ function jiraIssueKey(url: URL): string | undefined { const selected = url.searchParams .getAll("selectedIssue") .find((key) => JIRA_KEY.test(key)); - return (selected ?? JIRA_PATH.exec(url.pathname)?.[3])?.toUpperCase(); -} - -/** Infer contexts only for known Jira UI paths to keep installations distinct. */ -function jiraContextPath(url: URL): string | undefined { - const path = JIRA_PATH.exec(url.pathname); - if (path) { - return path[2] === "issues" - ? path[1]?.replace(JIRA_PROJECT_PATH, "") - : path[1]; - } - return JIRA_BOARD_PATH.exec(url.pathname)?.[1]; -} - -function requireJiraContext(url: URL): string { - const context = jiraContextPath(url); - if (context === undefined) { - throw new ValidationError( - "Cannot identify this Jira URL's installation context. Use the canonical /browse/ URL." - ); - } - return context; + return (selected ?? JIRA_PATH.exec(url.pathname)?.[1])?.toUpperCase(); } /** Compare URL aliases locally; provider identifiers are resolved by the backend. */ @@ -165,11 +140,6 @@ function issueIdentity(url: URL, provider: string): string | undefined { const match = GITLAB_ISSUE.exec(url.pathname); return match ? `${url.host}/${match[1]}#${match[2]}` : undefined; } - case "jira": - case "jira_server": { - const key = jiraIssueKey(url); - return key ? `${url.host}#${key}` : undefined; - } case "vsts": { const account = azureAccount(url); const match = WORK_ITEM.exec(url.pathname); @@ -188,9 +158,6 @@ function matchesIntegration( explicitlySelected: boolean ): boolean { const provider = integration.provider.key; - if (!issueIdentity(url, provider)) { - return false; - } // Older Enterprise metadata may omit its host. Only an explicit selection // can delegate host validation to the backend's instance_hostname metadata. if (provider === "github_enterprise" && !integration.domainName) { @@ -205,7 +172,8 @@ function matchesIntegration( return false; } if (provider === "vsts") { - return azureAccount(domain) === azureAccount(url); + const account = azureAccount(url); + return Boolean(account) && account === azureAccount(domain); } if (domain.host !== url.host) { return false; @@ -218,7 +186,6 @@ function matchesIntegration( ); } if (provider === "jira" || provider === "jira_server") { - requireJiraContext(url); const prefix = domain.pathname.replace(TRAILING_SLASH, ""); return ( !prefix || @@ -305,19 +272,16 @@ function matchesNativeUrl(link: NativeIssueLink, target: URL): boolean { return false; } if (["jira", "jira_server"].includes(link.provider)) { - if (existing.host !== target.host) { - return false; - } - const key = jiraIssueKey(target); - if (!key) { - return false; - } - const context = jiraContextPath(existing); - const targetContext = requireJiraContext(target); + // Sentry returns /browse/ URLs whose prefix preserves the installation's + // context path, including contexts omitted from integration.domainName. + const canonical = JIRA_CANONICAL_PATH.exec(existing.pathname); + const context = canonical?.[1]; return ( + existing.host === target.host && context !== undefined && - context === targetContext && - key === jiraIssueKey(existing) + (target.pathname === context || + target.pathname.startsWith(`${context}/`)) && + jiraIssueKey(target) === canonical?.[2]?.toUpperCase() ); } const identity = issueIdentity(target, link.provider); @@ -355,15 +319,6 @@ export async function resolveNativeIssueLink(options: { 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); const candidates = integrations.filter( (integration) => @@ -404,9 +359,6 @@ export async function resolveNativeIssueLink(options: { 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, { cache: "no-store", diff --git a/packages/cli/test/lib/api/issue-integrations.test.ts b/packages/cli/test/lib/api/issue-integrations.test.ts index fe455e538d..0910b06cdc 100644 --- a/packages/cli/test/lib/api/issue-integrations.test.ts +++ b/packages/cli/test/lib/api/issue-integrations.test.ts @@ -157,9 +157,9 @@ describe("native tracker issue links", () => { ...["github", "github_enterprise"].flatMap((provider) => ["issues", "pull"].map((path) => ({ provider, - domain: "github.example.com/owner", - url: `https://github.example.com/OWNER/repo/${path}/7`, - canonical: `https://github.example.com/OWNER/repo/${path}/7`, + domain: `${provider === "github" ? "github.com" : "github.example.com"}/owner`, + url: `https://${provider === "github" ? "github.com" : "github.example.com"}/OWNER/repo/${path}/7`, + canonical: `https://${provider === "github" ? "github.com" : "github.example.com"}/OWNER/repo/${path}/7`, })) ), ])("links $provider using the URL without repository discovery or a comment", async (fixture) => { @@ -218,19 +218,22 @@ describe("native tracker issue links", () => { url: issueUrl, }; let linked = false; + const submittedUrls: unknown[] = []; 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({ - externalIssue: `https://${host}/OWNER/repo/pull/7/files?source=cli`, - }); + const body = await request.json(); + expect(Object.keys(body)).toEqual(["externalIssue"]); + submittedUrls.push(body.externalIssue); + const status = linked ? 200 : 201; linked = true; // The mutation uses GitHub's html_url; listing reconstructs /issues/N. return Response.json( { ...storedLink, id: 1234, integrationId: 10, url: pullUrl }, - { status: 201 } + { status } ); } if (request.method === "DELETE") { @@ -289,7 +292,12 @@ describe("native tracker issue links", () => { requests .filter((request) => request.method !== "GET") .map((request) => request.method) - ).toEqual(["PUT", "DELETE"]); + ).toEqual(["PUT", "PUT", "PUT", "DELETE"]); + expect(submittedUrls).toEqual([ + `https://${host}/OWNER/repo/pull/7/files?source=cli`, + pullUrl, + issueUrl, + ]); }); test("preserves the full query while stripping fragment and trailing slash", async () => { @@ -317,6 +325,16 @@ describe("native tracker issue links", () => { url: "https://tracker.example.com/jira/secure/RapidBoard.jspa?rapidView=1&selectedIssue=PROJ-7", stored: "https://tracker.example.com/jira/browse/PROJ-7", }, + { + domain: "tracker.example.com", + url: "https://tracker.example.com/jira-archive/board?selectedIssue=PROJ-7", + stored: JIRA_URL, + }, + { + domain: "tracker.example.com", + url: "https://tracker.example.com/browse/PROJ-1?selectedIssue=invalid&selectedIssue=proj-7&selectedIssue=PROJ-1", + stored: JIRA_URL, + }, ])("forwards Jira copy link $url and matches its stored browse alias", async ({ domain, url, @@ -345,21 +363,6 @@ describe("native tracker issue links", () => { expect(requests).toHaveLength(2); }); - test("unknown Jira board contexts require a canonical issue URL before any mutation", async () => { - const requests = mockApi(() => - json([integration("jira_server", "tracker.example.com")]) - ); - const url = - "https://tracker.example.com/jira-archive/board?selectedIssue=PROJ-7"; - await expect(resolveNativeIssueLink({ ...SOURCE, url })).rejects.toThrow( - "canonical /browse/" - ); - expect(() => findNativeIssueLink([LINK], url)).toThrow( - "canonical /browse/" - ); - expect(requests.every((request) => request.method === "GET")).toBe(true); - }); - test("selects GitHub cloud installations with missing domain metadata by owner", async () => { mockApi(() => json([{ ...integration("github", null), name: "Owner" }])); expect( @@ -412,14 +415,17 @@ describe("native tracker issue links", () => { }); test("malformed stored URLs and domains do not block an unrelated valid association", async () => { - const requests = mockApi(() => - json([ - integration("jira", "https://", "20"), - integration("jira", "tracker.example.com", "10", [ - { ...LINK, id: "999", url: "not a URL" }, - LINK, - ]), - ]) + const requests = mockApi((request) => + request.method === "PUT" + ? json({ ...LINK, id: 1234, integrationId: 10 }) + : json([ + integration("jira", "https://", "20"), + integration("vsts", "unrecognized.example.com", "30"), + integration("jira", "tracker.example.com", "10", [ + { ...LINK, id: "999", url: "not a URL" }, + LINK, + ]), + ]) ); const prepared = await resolveNativeIssueLink({ ...SOURCE, url: JIRA_URL }); expect(await linkNativeIssue(prepared)).toMatchObject({ @@ -428,36 +434,42 @@ describe("native tracker issue links", () => { }); const links = await listNativeIssueLinks(SOURCE.orgSlug, SOURCE.issueId); expect(findNativeIssueLink(links, JIRA_URL)?.id).toBe(LINK.id); - expect(requests.every((request) => request.method === "GET")).toBe(true); - }); - - 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); + expect(requests.map((request) => request.method)).toEqual([ + "GET", + "PUT", + "GET", + ]); }); - test("delegates repository membership and URL validation to the backend", async () => { - const requests = mockApi((request) => - request.method === "GET" - ? json([integration("github", "github.com/owner")]) - : Response.json( - { detail: "Repository is not installed" }, - { status: 400 } - ) - ); + test.each([ + ["github", "github.com/owner", "https://github.com/owner/repo/issues/7"], + [ + "github", + "github.com/owner", + "https://github.com/owner/repo/commit/abcdef", + ], + [ + "bitbucket", + "bitbucket.org/owner", + "https://bitbucket.org/owner/repo/pull-requests/7", + ], + [ + "gitlab", + "gitlab.com/owner", + "https://gitlab.com/owner/repo/-/merge_requests/7", + ], + ])("delegates %s URL and repository validation to the backend: %s %s", async (provider, domain, url) => { + const requests = mockApi(async (request) => { + if (request.method === "GET") + return json([integration(provider, domain)]); + expect(await request.json()).toEqual({ externalIssue: url }); + return Response.json( + { detail: "Invalid provider reference" }, + { status: 400 } + ); + }); await expect( - resolveNativeIssueLink({ - ...SOURCE, - url: "https://github.com/owner/repo/issues/7", - }).then(linkNativeIssue) + resolveNativeIssueLink({ ...SOURCE, url }).then(linkNativeIssue) ).rejects.toBeInstanceOf(ApiError); expect(requests.map((request) => request.method)).toEqual(["GET", "PUT"]); }); @@ -504,47 +516,22 @@ describe("native tracker issue links", () => { 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: JIRA_URL }); - return Response.json( - { ...LINK, id: 1234, integrationId: 10 }, - { status: 201 } - ); - } - expect(request.method).toBe("GET"); - return json([integration()]); - }); - - await listNativeIssueLinks(SOURCE.orgSlug, SOURCE.issueId); + test.each([ + 200, 201, + ])("uses backend HTTP %i even when preflight found a link", async (status) => { + const requests = mockApi((request) => + request.method === "GET" + ? json([integration("jira", "tracker.example.com", "10", [LINK])]) + : Response.json({ ...LINK, id: 1234, integrationId: 10 }, { status }) + ); const prepared = await resolveNativeIssueLink({ ...SOURCE, url: JIRA_URL }); + expect(prepared.existing).toEqual(LINK); + // A concurrent unlink can remove the association after preflight. expect(await linkNativeIssue(prepared)).toEqual({ link: LINK, - changed: true, + changed: status === 201, }); - expect(requests.map((request) => request.method)).toEqual([ - "GET", - "GET", - "PUT", - ]); + expect(requests.map((request) => request.method)).toEqual(["GET", "PUT"]); }); test.each([ @@ -619,66 +606,6 @@ describe("native tracker issue links", () => { }); }); - 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 }; - const requests = mockApi((request) => { - if (request.method === "GET") { - expect(new URL(request.url).pathname).toBe(INTEGRATIONS); - return json([integration(provider, domain, "10", [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), - canonical - ); - expect(link).toEqual({ ...storedLink, 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( @@ -733,10 +660,6 @@ describe("native tracker issue links", () => { }); 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", @@ -784,36 +707,20 @@ describe("findNativeIssueLink", () => { }); test.each([ - { - stored: "https://tracker.example.com/browse/PROJ-7", - target: "https://tracker.example.com/jira-archive/browse/PROJ-7", - }, - { - stored: "https://tracker.example.com/jira/browse/PROJ-7", - target: "https://tracker.example.com/jira/archive/browse/PROJ-7", - }, - { - stored: "https://tracker.example.com/browse/PROJ-7", - target: - "https://tracker.example.com/jira-archive/projects/PROJ/issues/PROJ-7", - }, - { - stored: "https://tracker.example.com/browse/PROJ-7", - target: - "https://tracker.example.com/jira-archive/secure/RapidBoard.jspa?selectedIssue=PROJ-7", - }, - { - stored: "https://tracker.example.com/jira/browse/PROJ-7", - target: - "https://tracker.example.com/jira/archive/secure/RapidBoard.jspa?selectedIssue=PROJ-7", - }, - ])("does not match Jira aliases from another context: $target", ({ - stored, - target, - }) => { + "https://tracker.example.com/jira-archive/browse/PROJ-7", + "https://tracker.example.com/other/projects/PROJ/issues/PROJ-7", + "https://tracker.example.com/other/board?selectedIssue=PROJ-7", + "https://other.example.com/jira/browse/PROJ-7", + ])("does not match Jira aliases outside the stored context: %s", (target) => { expect( findNativeIssueLink( - [{ ...LINK, provider: "jira_server", url: stored }], + [ + { + ...LINK, + provider: "jira_server", + url: "https://tracker.example.com/jira/browse/PROJ-7", + }, + ], target ) ).toBeUndefined(); From e24f709f60ae0bb6e606960b514a7006e9690379 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 29 Sep 2026 07:37:16 +0200 Subject: [PATCH 09/17] fix(issue): prepare safe app link retries and form defaults --- packages/cli/src/lib/api/issue-app-links.ts | 218 ++++++++++++------ .../cli/test/lib/api/issue-app-links.test.ts | 135 +++++++++-- 2 files changed, 261 insertions(+), 92 deletions(-) diff --git a/packages/cli/src/lib/api/issue-app-links.ts b/packages/cli/src/lib/api/issue-app-links.ts index 574e2597d2..287d9b2ef7 100644 --- a/packages/cli/src/lib/api/issue-app-links.ts +++ b/packages/cli/src/lib/api/issue-app-links.ts @@ -67,7 +67,10 @@ const LinkFormSchema = object({ required_fields: optional(array(FieldSchema)), optional_fields: optional(array(FieldSchema)), }); -const ChoicesResponseSchema = object({ choices: array(ChoiceSchema) }); +const ChoicesResponseSchema = object({ + choices: array(ChoiceSchema), + defaultValue: FieldSchema.entries.defaultValue, +}); type Choice = InferOutput; type Field = InferOutput; type LinkForm = InferOutput; @@ -106,7 +109,7 @@ export type PreparedAppIssueLink = { 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 association to the same target, supplying the canonical URL guard. */ existing?: AppIssueLink; }; @@ -123,6 +126,7 @@ const RESERVED_FIELDS = new Set([ ]); const TRAILING_SLASHES = /\/+$/; const CHOICE_LABEL_TOKENS = /[^A-Z0-9-]+/; +const LINEAR_ISSUE_KEY = /^[A-Z][A-Z0-9]*-\d+$/; const URL_FIELD = /url/i; const NUMERIC_ID = /^\d+$/; @@ -401,12 +405,12 @@ async function getChoices({ }: { installationUuid: string; field: Field; - query: string; + query?: string; values: Record; projectId?: string; -}): Promise { +}): Promise> { if (!field.uri) { - return field.choices ?? field.options ?? []; + return { choices: field.choices ?? field.options ?? [] }; } validateUri(field.uri); const dependentData = Object.fromEntries( @@ -431,36 +435,90 @@ async function getChoices({ if (!parsed.success) { throw new ApiError("App search returned invalid issue choices", 0); } - return parsed.output.choices; + return parsed.output; +} + +function choiceLabelKey(label: string | number): string | undefined { + return String(label) + .toUpperCase() + .split(CHOICE_LABEL_TOKENS) + .find((token) => token.length > 0); +} + +/** Reject supplied IDs that identify another Linear issue before invoking its callback. */ +function validateLinearChoice( + choice: Choice, + choices: Choice[], + key: string +): void { + const valueKey = String(choice[0]).toUpperCase(); + const labelKey = choiceLabelKey(choice[1]); + const identified = choices.filter( + ([value, label]) => + String(value).toUpperCase() === key || choiceLabelKey(label) === key + ); + if ( + (LINEAR_ISSUE_KEY.test(valueKey) && valueKey !== key) || + (!LINEAR_ISSUE_KEY.test(valueKey) && + ((identified.length && + !identified.some(([value]) => value === choice[0])) || + (labelKey && LINEAR_ISSUE_KEY.test(labelKey) && labelKey !== key))) + ) { + throw new ValidationError( + "App issue choice conflicts with the requested issue URL", + "field" + ); + } } function selectChoice( choices: Choice[], query: string, - linearKey?: string + linearKey?: string, + supplied?: string ): string | number { + const wanted = supplied ?? query; const matches = choices.filter( ([value, label]) => - String(value) === query || - String(label) === query || + String(value) === wanted || + String(label) === wanted || (linearKey !== undefined && - String(label) - .toUpperCase() - .split(CHOICE_LABEL_TOKENS) - .find((token) => token.length > 0) === linearKey) + choiceLabelKey(label) === linearKey && + (supplied === undefined || + supplied === query || + String(value) === supplied)) ); const choice = matches[0]; if (matches.length !== 1 || !choice) { + const missingMessage = + supplied && linearKey + ? "App issue choice conflicts with the requested issue URL" + : "App search did not return an exact match for the external issue"; throw new ValidationError( matches.length ? "App search returned multiple exact issue matches" - : "App search did not return an exact match for the external issue", + : missingMessage, "url" ); } + if (linearKey) { + validateLinearChoice(choice, choices, linearKey); + } return choice[0]; } +/** Dependencies are required even when their fields are otherwise optional. */ +function addDependencies(pending: Field[], fields: Field[]): void { + for (const field of pending) { + for (const name of field.depends_on ?? []) { + const dependency = fields.find((item) => item.name === name); + if (dependency && !pending.includes(dependency)) { + pending.push(dependency); + } + } + } +} + /** Resolve form dependencies while keeping the target field bound to the requested issue URL. */ async function resolveFields( options: ResolveAppIssueLinkOptions, @@ -472,8 +530,12 @@ async function resolveFields( const values = seedFields(fields, options.fields ?? {}); const targetField = findTargetField(fields, required); const pending = fields.filter( - (field) => field === targetField || values[field.name] !== undefined + (field) => + field === targetField || + required.includes(field) || + values[field.name] !== undefined ); + addDependencies(pending, fields); const resolved = new Set(); while (pending.length) { const index = pending.findIndex((item) => @@ -503,15 +565,6 @@ async function resolveFields( }); 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; } @@ -536,7 +589,9 @@ function seedFields( "field" ); } - values[name] = value; + if (value !== "") { + values[name] = value; + } } for (const field of fields) { if (RESERVED_FIELDS.has(field.name)) { @@ -552,9 +607,10 @@ function seedFields( ); } if ( - values[field.name] === undefined && + supplied[field.name] === undefined && field.defaultValue !== undefined && - field.defaultValue !== null + field.defaultValue !== null && + field.defaultValue !== "" ) { values[field.name] = field.defaultValue; } @@ -577,6 +633,31 @@ function findTargetField(fields: Field[], required: Field[]): Field { return targetField; } +/** Required fields and dependencies need a value; explicit target values must agree. */ +function validateFieldValue( + fieldName: string, + value: string | number | undefined, + query: string | number | undefined, + supplied?: string +): asserts value is string | number { + if ( + supplied !== undefined && + supplied !== String(value) && + supplied !== query + ) { + throw new ValidationError( + `App field ${fieldName} conflicts with the requested issue URL`, + "field" + ); + } + if (value === undefined || value === "") { + throw new ValidationError( + `Missing app link fields: --field ${fieldName}=VALUE`, + "field" + ); + } +} + async function resolveFieldValue({ field, targetField, @@ -590,42 +671,41 @@ async function resolveFieldValue({ options: ResolveAppIssueLinkOptions; installationUuid: string; }): Promise { - const target = parseTarget(options.url); + const isTarget = field === targetField; + const targetKey = isTarget ? parseTarget(options.url).key : undefined; + const supplied = isTarget ? options.fields?.[field.name] : undefined; // 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; + let query = (options.fields?.[field.name] ?? values[field.name])?.toString(); + if (isTarget) { + query = + targetKey ?? + (field.type === "select" ? supplied : undefined) ?? + options.url; + } + let value: string | number | undefined = query; 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)) { + const optionsResponse = await getChoices({ + installationUuid, + field, + query, + values, + projectId: options.projectId, + }); + if (!isTarget) { + value ??= optionsResponse.defaultValue ?? undefined; + } + if (value !== undefined) { + value = selectChoice( + optionsResponse.choices, + String(value), + targetKey, + supplied + ); + } + } else if (isTarget && 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" - ); - } + validateFieldValue(field.name, value, query, supplied); return value; } @@ -653,27 +733,17 @@ export async function resolveAppIssueLink( 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, + displayName: existing?.displayName ?? target.key ?? options.url, installationUuid: installation.uuid, uri: form.uri, fields: await resolveFields(options, form, installation.uuid), + existing, }; } @@ -681,9 +751,6 @@ export async function resolveAppIssueLink( export async function linkAppIssue( prepared: PreparedAppIssueLink ): Promise<{ link: AppIssueLink; changed: boolean }> { - if (prepared.existing) { - return { link: prepared.existing, changed: false }; - } validateUri(prepared.uri); const result = await executeSentryAppInstallationExternalIssueAction({ ...getSdkConfig(getControlSiloUrl(), { @@ -693,7 +760,8 @@ export async function linkAppIssue( }), path: { uuid: prepared.installationUuid }, query: { - expectedExternalIssueUrl: parseTarget(prepared.url).url, + expectedExternalIssueUrl: + prepared.existing?.webUrl ?? parseTarget(prepared.url).url, }, body: { ...prepared.fields, diff --git a/packages/cli/test/lib/api/issue-app-links.test.ts b/packages/cli/test/lib/api/issue-app-links.test.ts index bb4e60ec4a..bfffb8faf6 100644 --- a/packages/cli/test/lib/api/issue-app-links.test.ts +++ b/packages/cli/test/lib/api/issue-app-links.test.ts @@ -145,16 +145,11 @@ describe("app issue-link action", () => { ).toHaveLength(1); }); - 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"]]; + test.each([ + "ENG-420: Other issue", + "ENG-99: Follow up on ENG-42", + ])("rejects a nonmatching Linear label: %s", async (label) => { + choices = [["wrong", label]]; await expect(resolveAppIssueLink(OPTIONS)).rejects.toThrow( "did not return an exact match" ); @@ -169,20 +164,31 @@ describe("app issue-link action", () => { expect(writes()).toHaveLength(0); }); - test("recognizes an existing Linear link even when the URL title changes", async () => { + test.each([ + 200, 201, + ])("guards an existing Linear link with its canonical URL (HTTP %s)", async (status) => { 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(prepared.fields).toEqual({ issueId: "linear-uuid" }); + actionStatus = status; + actionLink = links[0]!; expect(await linkAppIssue(prepared)).toEqual({ - changed: false, + changed: status === 201, link: links[0], }); expect( - calls.some((request) => request.url.includes("sentry-app-components")) - ).toBe(false); - expect(writes()).toHaveLength(0); + new globalThis.URL(writes()[0]!.url).searchParams.get( + "expectedExternalIssueUrl" + ) + ).toBe(links[0]!.webUrl); + expect(await writes()[0]?.json()).toMatchObject({ + uri: FORM.uri, + issueId: "linear-uuid", + }); + expect(writes()).toHaveLength(1); }); test("refuses to replace another issue linked to the same app", async () => { @@ -346,6 +352,47 @@ describe("app issue-link action", () => { expect(writes()).toHaveLength(0); }); + test("uses a supplied ID to disambiguate matching labels without accepting another issue", async () => { + choices = [ + ["linear-uuid", "ENG-42: Fix"], + ["another-uuid", "ENG-42: Fix"], + ["wrong", "ENG-99: Other"], + ]; + const prepared = await resolveAppIssueLink({ + ...OPTIONS, + fields: { issueId: "linear-uuid" }, + }); + expect(prepared.fields).toEqual({ issueId: "linear-uuid" }); + await expect( + resolveAppIssueLink({ + ...OPTIONS, + fields: { issueId: "wrong" }, + }) + ).rejects.toThrow("conflicts"); + expect(writes()).toHaveLength(0); + }); + + test.each([ + "ENG-42", + "ENG-99", + ])("uses Linear choice ID %s before the label's key", async (id) => { + choices = [ + [ + id, + id === "ENG-42" + ? "ENG-99 mentioned in title" + : "ENG-42 misleading label", + ], + ]; + const result = resolveAppIssueLink({ ...OPTIONS, fields: { issueId: id } }); + if (id === "ENG-42") { + expect((await result).fields).toEqual({ issueId: id }); + } else { + await expect(result).rejects.toThrow("conflicts"); + } + expect(writes()).toHaveLength(0); + }); + test("keeps the query guard separate from an app field with the same name", async () => { form = { ...FORM, @@ -394,6 +441,57 @@ describe("app issue-link action", () => { ).toBe('{"team":"team-uuid"}'); }); + test("resolves a required dependency from the App's remote default", async () => { + form = { + ...FORM, + required_fields: [{ ...FORM.required_fields[0], depends_on: ["team"] }], + optional_fields: [{ name: "team", type: "select", uri: "/teams" }], + }; + const defaultFetch = globalThis.fetch; + globalThis.fetch = mockFetch((input, init) => { + const request = new Request(input, init); + const query = new globalThis.URL(request.url).searchParams; + if (query.get("uri") === "/teams") { + calls.push(request); + expect(query.has("query")).toBe(false); + return Promise.resolve( + json({ + choices: [["team-uuid", "Engineering"]], + defaultValue: "team-uuid", + }) + ); + } + return defaultFetch(input, init); + }); + const prepared = await resolveAppIssueLink(OPTIONS); + expect(prepared.fields).toEqual({ + team: "team-uuid", + issueId: "linear-uuid", + }); + const search = calls.find((request) => + new globalThis.URL(request.url).searchParams.has("dependentData") + ); + expect( + new globalThis.URL(search!.url).searchParams.get("dependentData") + ).toBe('{"team":"team-uuid"}'); + }); + + test.each([ + { defaultValue: "", fields: undefined }, + { defaultValue: "preset", fields: { note: "" } }, + ])("omits empty optional fields ($defaultValue)", async ({ + defaultValue, + fields, + }) => { + form = { + ...FORM, + optional_fields: [{ name: "note", type: "text", defaultValue }], + }; + expect((await resolveAppIssueLink({ ...OPTIONS, fields })).fields).toEqual({ + issueId: "linear-uuid", + }); + }); + test("reports required fields rather than sending a partial form", async () => { form = { ...FORM, @@ -408,7 +506,10 @@ describe("app issue-link action", () => { expect(writes()).toHaveLength(0); }); - test("names missing dependencies instead of reporting a dependency cycle", async () => { + test.each([ + undefined, + { team: "" }, + ])("requires missing or empty dependencies (%j)", async (fields) => { form = { ...FORM, required_fields: [ @@ -416,7 +517,7 @@ describe("app issue-link action", () => { { name: "team", type: "text" }, ], }; - await expect(resolveAppIssueLink(OPTIONS)).rejects.toThrow( + await expect(resolveAppIssueLink({ ...OPTIONS, fields })).rejects.toThrow( "Missing app link fields: --field team=VALUE" ); expect( From 177668981c26d70f9a85fda851cccc145f693c2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 29 Sep 2026 08:43:00 +0200 Subject: [PATCH 10/17] refactor(api): align request options with #1649 Keep sentry-client.ts identical to #1649 so merging it leaves no diff here. A final-attempt 401 now returns the response, which makes retry: false a plain single-attempt loop without its own code path. Co-authored-by: Cursor --- packages/cli/src/lib/sentry-client.ts | 14 ++--- packages/cli/test/lib/sentry-client.test.ts | 63 ++++++++++++++++----- 2 files changed, 54 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/lib/sentry-client.ts b/packages/cli/src/lib/sentry-client.ts index 826170f7e4..033b4da863 100644 --- a/packages/cli/src/lib/sentry-client.ts +++ b/packages/cli/src/lib/sentry-client.ts @@ -323,13 +323,15 @@ type AttemptResult = /** * Decide what to do with a successful HTTP response. * Returns 'done' for final responses, 'retry' for retryable errors and 401s. + * The last attempt always returns 'done': a refreshed token there would have + * no attempt left to use it. */ async function handleResponse( response: Response, headers: Headers, isLastAttempt: boolean ): Promise { - if (response.status === 401) { + if (response.status === 401 && !isLastAttempt) { const refreshed = await handleUnauthorized(headers); return refreshed ? { action: "retry" } : { action: "done", response }; } @@ -553,7 +555,6 @@ async function fetchWithRetry( headers, isLastAttempt, timeoutMs, - retry: options.retry, }); if (result.action === "done") { @@ -576,7 +577,7 @@ async function fetchWithRetry( const delay = backoffDelay(attempt); log.debug( - `${method} ${new URL(fullUrl).pathname} → retry ${attempt + 1}/${MAX_RETRIES} after ${delay}ms` + `${method} ${new URL(fullUrl).pathname} → retry ${attempt + 1}/${maxRetries} after ${delay}ms` ); await sleepMs(delay); } @@ -678,7 +679,6 @@ type ExecuteAttemptArgs = { headers: Headers; isLastAttempt: boolean; timeoutMs: number; - retry?: boolean; }; async function executeAttempt({ @@ -687,7 +687,6 @@ async function executeAttempt({ headers, isLastAttempt, timeoutMs, - retry, }: ExecuteAttemptArgs): Promise { try { const response = await fetchWithTimeout({ @@ -697,9 +696,7 @@ async function executeAttempt({ externalSignal: init?.signal, timeoutMs, }); - return retry === false - ? { action: "done", response } - : handleResponse(response, headers, isLastAttempt); + return handleResponse(response, headers, isLastAttempt); } catch (error) { return handleFetchError(error, init?.signal, isLastAttempt); } @@ -747,6 +744,7 @@ export function getControlSiloUrl(): string { * - `throwOnError`: Always false (we handle errors ourselves) * * @param regionUrl - The base URL for the target region (e.g., https://us.sentry.io) + * @param options - Per-request retry and cache controls; omit for the shared fetch * @returns Configuration object to spread into SDK function options * * @example diff --git a/packages/cli/test/lib/sentry-client.test.ts b/packages/cli/test/lib/sentry-client.test.ts index 281245f0a8..7ca2c1f77c 100644 --- a/packages/cli/test/lib/sentry-client.test.ts +++ b/packages/cli/test/lib/sentry-client.test.ts @@ -16,7 +16,12 @@ import { getSdkConfig, resetAuthenticatedFetch, } from "../../src/lib/sentry-client.js"; -import { mockFetch, useTestConfigDir } from "../helpers.js"; +import { + extractFetchUrl, + mockFetch, + useEnvSandbox, + useTestConfigDir, +} from "../helpers.js"; useTestConfigDir("sentry-client-"); @@ -39,6 +44,48 @@ function getAuthenticatedFetch(): typeof fetch { return getSdkConfig(REGION_URL).fetch as typeof fetch; } +describe("401 replay", () => { + useEnvSandbox(["SENTRY_CLIENT_ID"]); + + /** Answer resource requests with `statuses` in order while OAuth refresh succeeds. */ + async function mockRefreshableSession(statuses: number[]): Promise { + process.env.SENTRY_CLIENT_ID = "synthetic-client-id"; + await setAuthToken("stored-token", 3600, "synthetic-refresh-token"); + const urls: string[] = []; + globalThis.fetch = mockFetch(async (input) => { + const url = extractFetchUrl(input); + urls.push(url); + if (url.endsWith("/oauth/token/")) { + return Response.json({ + access_token: "refreshed-token", + token_type: "bearer", + expires_in: 3600, + }); + } + return new Response("{}", { status: statuses.shift() ?? 200 }); + }); + return urls; + } + + test("returns a 401 from the final attempt instead of replaying it", async () => { + const urls = await mockRefreshableSession([503, 503, 401]); + const response = await getAuthenticatedFetch()( + `${REGION_URL}/api/0/organizations/` + ); + expect(response.status).toBe(401); + expect(urls.filter((url) => url.endsWith("/oauth/token/"))).toEqual([]); + }); + + test("retry false returns a 401 without refreshing the token", async () => { + const urls = await mockRefreshableSession([401]); + const url = `${REGION_URL}/api/0/issue-link/`; + const fetchOnce = getSdkConfig(REGION_URL, { retry: false }).fetch; + const response = await fetchOnce(url, { method: "POST" }); + expect(response.status).toBe(401); + expect(urls).toEqual([url]); + }); +}); + describe("per-request transport controls", () => { test("retry false sends a mutation once for transient HTTP and network failures", async () => { let callCount = 0; @@ -66,20 +113,6 @@ describe("per-request transport controls", () => { 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" }; From e837d0a22ebe1a8bf089232c2d6f4acd507c2a8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 29 Sep 2026 12:06:48 +0200 Subject: [PATCH 11/17] test(issue): drop redundant link and unlink tests Remove assertions on code the commands never call, a flag that never shipped, OAuth scope and transport retry behavior covered elsewhere, and duplicated JSON/error wrapper checks. Formatter states move to one table test instead of being asserted from three layers. Co-authored-by: Cursor --- .../cli/test/commands/issue/link.func.test.ts | 38 +----------- .../test/commands/issue/unlink.func.test.ts | 47 -------------- .../test/lib/api/issue-integrations.test.ts | 41 +------------ .../test/lib/formatters/issue-links.test.ts | 61 +++++++++++++++++++ packages/cli/test/lib/issue-links.test.ts | 7 --- packages/cli/test/lib/oauth.test.ts | 6 -- .../lib/scanner-flags.integration.test.ts | 34 +---------- packages/cli/test/lib/sdk-positionals.test.ts | 2 - 8 files changed, 66 insertions(+), 170 deletions(-) create mode 100644 packages/cli/test/lib/formatters/issue-links.test.ts diff --git a/packages/cli/test/commands/issue/link.func.test.ts b/packages/cli/test/commands/issue/link.func.test.ts index 72e471956e..29cb0895e6 100644 --- a/packages/cli/test/commands/issue/link.func.test.ts +++ b/packages/cli/test/commands/issue/link.func.test.ts @@ -3,12 +3,7 @@ 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 { ContextError, ValidationError } from "../../../src/lib/errors.js"; import { type ExternalIssueLinkResult, linkExternalIssue, @@ -22,11 +17,6 @@ vi.mock("../../../src/commands/issue/utils.js", async (importOriginal) => ({ 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(), })); @@ -80,7 +70,6 @@ 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); }); @@ -113,7 +102,6 @@ describe("issue link", () => { 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 () => { @@ -140,7 +128,6 @@ describe("issue link", () => { fields: { team: "team-1", query: "key=value", optional: "" }, dryRun: false, }); - expect(updateIssueStatus).not.toHaveBeenCalled(); }); test.each([ @@ -200,15 +187,9 @@ describe("issue link", () => { ); 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); + test("emits the link result unchanged in JSON", async () => { const { context, output } = createMockContext(); const func = await linkCommand.loader(); await func.call( @@ -218,19 +199,6 @@ describe("issue link", () => { 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(""); + expect(JSON.parse(output())).toEqual(linkedResult); }); }); diff --git a/packages/cli/test/commands/issue/unlink.func.test.ts b/packages/cli/test/commands/issue/unlink.func.test.ts index 430160aa18..7a28c16aef 100644 --- a/packages/cli/test/commands/issue/unlink.func.test.ts +++ b/packages/cli/test/commands/issue/unlink.func.test.ts @@ -3,8 +3,6 @@ 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, @@ -29,11 +27,6 @@ vi.mock("../../../src/commands/issue/utils.js", async (importOriginal) => ({ 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(), })); @@ -84,7 +77,6 @@ describe("issue unlink", () => { 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", @@ -129,7 +121,6 @@ describe("issue unlink", () => { 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 () => { @@ -196,7 +187,6 @@ describe("issue unlink", () => { 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 () => { @@ -221,42 +211,5 @@ describe("issue unlink", () => { 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/issue-integrations.test.ts b/packages/cli/test/lib/api/issue-integrations.test.ts index 0910b06cdc..24a8cf22ad 100644 --- a/packages/cli/test/lib/api/issue-integrations.test.ts +++ b/packages/cli/test/lib/api/issue-integrations.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { findNativeIssueLink, linkNativeIssue, @@ -553,45 +553,6 @@ describe("native tracker issue links", () => { expect(requests.map((request) => request.method)).toEqual(["GET", "PUT"]); }); - test.each([ - "PUT", - "DELETE", - ])("retries an idempotent %s mutation", async (method) => { - vi.useFakeTimers(); - try { - let attempts = 0; - const requests = mockApi((request) => { - if (request.method === "GET") return json([integration()]); - expect(request.method).toBe(method); - attempts += 1; - if (attempts === 1) - return Response.json({ detail: "Temporary error" }, { status: 503 }); - return method === "PUT" - ? json({ ...LINK, id: 1234, integrationId: 10 }) - : new Response(null, { status: 204 }); - }); - const prepared = await resolveNativeIssueLink({ - ...SOURCE, - url: JIRA_URL, - }); - const mutation = - method === "PUT" - ? linkNativeIssue(prepared) - : unlinkNativeIssueLink(SOURCE.orgSlug, SOURCE.issueId, LINK); - await vi.runAllTimersAsync(); - if (method === "PUT") { - expect(await mutation).toMatchObject({ changed: false }); - } else { - await mutation; - } - expect( - requests.filter((request) => request.method === method) - ).toHaveLength(2); - } finally { - vi.useRealTimers(); - } - }); - test("a concurrent PUT no-op uses the backend's 200 status", async () => { mockApi((request) => request.method === "GET" diff --git a/packages/cli/test/lib/formatters/issue-links.test.ts b/packages/cli/test/lib/formatters/issue-links.test.ts new file mode 100644 index 0000000000..950f00e013 --- /dev/null +++ b/packages/cli/test/lib/formatters/issue-links.test.ts @@ -0,0 +1,61 @@ +/** + * Issue link formatter tests. + */ + +import { beforeEach, describe, expect, test } from "vitest"; +import { formatIssueLinkResult } from "../../../src/lib/formatters/issue-links.js"; +import type { ExternalIssueLinkResult } from "../../../src/lib/issue-links.js"; +import { useEnvSandbox } from "../../helpers.js"; + +const URL = "https://github.com/example/app/issues/42"; + +describe("formatIssueLinkResult", () => { + useEnvSandbox(["SENTRY_PLAIN_OUTPUT"]); + + beforeEach(() => { + process.env.SENTRY_PLAIN_OUTPUT = "1"; + }); + + test.each([ + [ + { action: "link", linked: false, changed: false, dryRun: true }, + `Would link ${URL} to test-org/123. (dry run)`, + ], + [ + { action: "link", linked: true, changed: false, dryRun: true }, + `Already linked: ${URL}. (dry run)`, + ], + [ + { action: "link", linked: true, changed: true }, + `Linked ${URL} to test-org/123.`, + ], + [ + { action: "link", linked: true, changed: false }, + `Already linked: ${URL}.`, + ], + [ + { action: "unlink", linked: true, changed: false, dryRun: true }, + `Would unlink ${URL} from test-org/123. (dry run)`, + ], + [ + { action: "unlink", linked: false, changed: false, dryRun: true }, + `Already unlinked: ${URL}. (dry run)`, + ], + [ + { action: "unlink", linked: false, changed: true }, + `Unlinked ${URL} from test-org/123. The external issue was not deleted.`, + ], + [ + { action: "unlink", linked: false, changed: false }, + `Already unlinked: ${URL}.`, + ], + ] as const)("renders %j", (state, expected) => { + const result: ExternalIssueLinkResult = { + org: "test-org", + issueId: "123", + externalIssue: { url: URL }, + ...state, + }; + expect(formatIssueLinkResult(result)).toBe(expected); + }); +}); diff --git a/packages/cli/test/lib/issue-links.test.ts b/packages/cli/test/lib/issue-links.test.ts index 659a156097..985ab69202 100644 --- a/packages/cli/test/lib/issue-links.test.ts +++ b/packages/cli/test/lib/issue-links.test.ts @@ -16,7 +16,6 @@ import { 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, @@ -137,7 +136,6 @@ describe("external issue associations", () => { 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 () => { @@ -147,7 +145,6 @@ describe("external issue associations", () => { }); const result = await linkExternalIssue(options); expect(result).toMatchObject({ linked: true, changed: false }); - expect(formatIssueLinkResult(result)).toContain("Already linked"); expect(invalidateCachedResponsesMatching).not.toHaveBeenCalled(); }); @@ -161,9 +158,6 @@ describe("external issue associations", () => { 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 () => { @@ -189,7 +183,6 @@ describe("external issue associations", () => { }); expect(unlinkNativeIssueLink).not.toHaveBeenCalled(); expect(unlinkAppIssueLink).not.toHaveBeenCalled(); - expect(formatIssueLinkResult(result)).toContain("Would unlink"); }); test.each([ diff --git a/packages/cli/test/lib/oauth.test.ts b/packages/cli/test/lib/oauth.test.ts index 0925766841..4d32db281f 100644 --- a/packages/cli/test/lib/oauth.test.ts +++ b/packages/cli/test/lib/oauth.test.ts @@ -27,12 +27,6 @@ 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 c481434b11..1c19391c17 100644 --- a/packages/cli/test/lib/scanner-flags.integration.test.ts +++ b/packages/cli/test/lib/scanner-flags.integration.test.ts @@ -78,11 +78,7 @@ async function runApp( }; const exitCode = await run(app, args, context); - return { - stdout, - stderr, - exitCode: Number(context.process.exitCode ?? exitCode ?? 0), - }; + return { stdout, stderr, exitCode: exitCode ?? 0 }; } /** @@ -91,34 +87,6 @@ async function runApp( */ const NO_COMMAND_REGISTERED = "No command registered"; -describe("issue link and unlink URL arguments", () => { - test.each([ - "link", - "unlink", - ])("issue %s requires the URL positional", async (command) => { - const result = await runApp(["issue", command, "APP-42"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Expected argument for url"); - }); - - test.each([ - "link", - "unlink", - ])("issue %s rejects the removed external-issue flag", async (command) => { - const url = "https://github.com/example/app/pull/123"; - const result = await runApp([ - "issue", - command, - "APP-42", - url, - "--external-issue", - url, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("external-issue"); - }); -}); - describe("top-level flags on a leaf command (bash-hook, no auth)", () => { // bash-hook runs without auth and emits its script to stdout, so a successful // route + execution is observable regardless of where the global flag sits. diff --git a/packages/cli/test/lib/sdk-positionals.test.ts b/packages/cli/test/lib/sdk-positionals.test.ts index 10d6bdcdc6..ea4b5f1190 100644 --- a/packages/cli/test/lib/sdk-positionals.test.ts +++ b/packages/cli/test/lib/sdk-positionals.test.ts @@ -44,7 +44,6 @@ describe("generated SDK positional arguments", () => { ], 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 () => { @@ -59,7 +58,6 @@ describe("generated SDK positional arguments", () => { positional: ["example/APP-42", "https://github.com/example/app/pull/123"], flags: { yes: true }, }); - expect(calls[0]?.flags).not.toHaveProperty("external-issue"); }); test("release deploy passes version, environment and name as separate tokens", async () => { From 04ca8aa48ff96820a40ee0e415914ea390823efc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 29 Sep 2026 12:09:23 +0200 Subject: [PATCH 12/17] refactor(api): share strict pagination for issue link lookups The four listings that authorize a link mutation each hand-rolled an all-or-nothing page loop with different errors, and only one detected repeated cursors. fetchAllPages validates every page and fails on an invalid page, a repeated cursor or the page limit. Error messages now name the listing, and running out of pages is an ApiError rather than a ValidationError. Co-authored-by: Cursor --- packages/cli/src/lib/api/infrastructure.ts | 44 +++++++++ packages/cli/src/lib/api/issue-app-links.ts | 89 ++++--------------- .../cli/src/lib/api/issue-integrations.ts | 40 +++------ .../cli/test/lib/api/infrastructure.test.ts | 57 ++++++++++++ 4 files changed, 129 insertions(+), 101 deletions(-) diff --git a/packages/cli/src/lib/api/infrastructure.ts b/packages/cli/src/lib/api/infrastructure.ts index 0a58de5281..a6892f107f 100644 --- a/packages/cli/src/lib/api/infrastructure.ts +++ b/packages/cli/src/lib/api/infrastructure.ts @@ -509,6 +509,50 @@ export function paginate( ); } +/** + * Fetch and validate every page of a list endpoint, or fail. + * + * Unlike {@link autoPaginate}, a partial result is an error: use this when a + * missing page could hide the record a mutation depends on. Throws on an + * invalid page, a repeated cursor, or more than {@link MAX_PAGINATION_PAGES}. + * + * @param fetchPage - Fetches one page given a cursor + * @param schema - Validates each page's items + * @param context - Operation for error messages, e.g. "listing issue integrations" + * @returns All validated items, in page order + */ +export async function fetchAllPages( + fetchPage: ( + cursor: string | undefined + ) => Promise>, + schema: GenericSchema, + context: string +): Promise { + const items: T[] = []; + const seen = new Set(); + let cursor: string | undefined; + for (let page = 0; page < MAX_PAGINATION_PAGES; page += 1) { + const { data, nextCursor } = await fetchPage(cursor); + const parsed = safeParse(schema, data); + if (!parsed.success) { + throw new ApiError(`Unexpected response format when ${context}`, 0); + } + items.push(...parsed.output); + if (!nextCursor) { + return items; + } + if (seen.has(nextCursor)) { + throw new ApiError(`Pagination repeated a cursor when ${context}`, 0); + } + seen.add(nextCursor); + cursor = nextCursor; + } + throw new ApiError( + `Pagination exceeded ${MAX_PAGINATION_PAGES} pages when ${context}`, + 0 + ); +} + /** * Make an authenticated request to a specific Sentry region. * Returns both parsed response data and raw headers for pagination support. diff --git a/packages/cli/src/lib/api/issue-app-links.ts b/packages/cli/src/lib/api/issue-app-links.ts index 287d9b2ef7..a512c82196 100644 --- a/packages/cli/src/lib/api/issue-app-links.ts +++ b/packages/cli/src/lib/api/issue-app-links.ts @@ -8,7 +8,6 @@ import { executeSentryAppInstallationExternalIssueAction, type GroupExternalIssueResponse, getSentryAppInstallationExternalRequestOptions, - type ListOrganizationSentryAppComponentsResponse, type ListOrganizationSentryAppInstallationsResponse, listOrganizationIssueExternalIssues, listOrganizationSentryAppComponents, @@ -22,7 +21,6 @@ import { import { array, boolean, - type GenericSchema, type InferOutput, nullish, number, @@ -37,9 +35,9 @@ import { import { ApiError, ValidationError } from "../errors.js"; import { resolveOrgRegion } from "../region.js"; import { getControlSiloUrl, getSdkConfig } from "../sentry-client.js"; +import { isAllDigits } from "../utils.js"; import { - MAX_PAGINATION_PAGES, - type PaginatedResponse, + fetchAllPages, unwrapPaginatedResult, unwrapResult, } from "./infrastructure.js"; @@ -47,7 +45,6 @@ import { /** A stored Sentry App association; id identifies the link, not the remote ticket. */ export type AppIssueLink = GroupExternalIssueResponse[number]; type AppInstallation = ListOrganizationSentryAppInstallationsResponse[number]; -type Component = ListOrganizationSentryAppComponentsResponse[number]; const ChoiceSchema = tuple([ union([string(), number()]), union([string(), number()]), @@ -128,7 +125,6 @@ const TRAILING_SLASHES = /\/+$/; const CHOICE_LABEL_TOKENS = /[^A-Z0-9-]+/; const LINEAR_ISSUE_KEY = /^[A-Z][A-Z0-9]*-\d+$/; const URL_FIELD = /url/i; -const NUMERIC_ID = /^\d+$/; function parseTarget(raw: string) { let url: URL; @@ -192,64 +188,18 @@ export function findAppIssueLink( return matches[0]; } -/** Fetch a complete, validated collection; partial results cannot safely authorize a link mutation. */ -async function listAll( - fetchPage: ( - cursor: string | undefined - ) => 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 { +function requireIssueTarget(orgSlug: string, issueId: string): void { if ( !orgSlug || orgSlug === "." || orgSlug === ".." || - !NUMERIC_ID.test(issueId) + !isAllDigits(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. */ @@ -257,11 +207,11 @@ export async function listAppIssueLinks( orgSlug: string, issueId: string ): Promise { - const endpoint = groupPath(orgSlug, issueId); + requireIssueTarget(orgSlug, issueId); const config = getSdkConfig(await resolveOrgRegion(orgSlug), { cache: "no-store", }); - return listAll( + return fetchAllPages( async (cursor) => { const result = await listOrganizationIssueExternalIssues({ ...config, @@ -270,8 +220,8 @@ export async function listAppIssueLinks( }); return unwrapPaginatedResult(result, "Failed to list app issue links"); }, - endpoint, - vGroupExternalIssueResponse + vGroupExternalIssueResponse, + "listing app issue links" ); } @@ -314,7 +264,7 @@ async function resolveInstallation( appSlug: string ): Promise { const config = getSdkConfig(getControlSiloUrl(), { cache: "no-store" }); - const installations = await listAll( + const installations = await fetchAllPages( async (cursor) => { const result = await listOrganizationSentryAppInstallations({ ...config, @@ -326,8 +276,8 @@ async function resolveInstallation( "Failed to list Sentry App installations" ); }, - `/organizations/${encodeURIComponent(orgSlug)}/sentry-app-installations/`, - vListOrganizationSentryAppInstallationsResponse + vListOrganizationSentryAppInstallationsResponse, + "listing Sentry App installations" ); const matches = installations.filter( (item) => @@ -351,9 +301,8 @@ async function getLinkForm( orgSlug: string, installation: AppInstallation ): Promise { - const endpoint = `/organizations/${encodeURIComponent(orgSlug)}/sentry-app-components/`; const config = getSdkConfig(getControlSiloUrl(), { cache: "no-store" }); - const components = await listAll( + const components = await fetchAllPages( async (cursor) => { const result = await listOrganizationSentryAppComponents({ ...config, @@ -362,8 +311,8 @@ async function getLinkForm( }); return unwrapPaginatedResult(result, "Failed to list app components"); }, - endpoint, - vListOrganizationSentryAppComponentsResponse + vListOrganizationSentryAppComponentsResponse, + "listing Sentry App components" ); const matches = components.filter( (item) => @@ -721,12 +670,6 @@ export async function resolveAppIssueLink( "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, @@ -782,13 +725,13 @@ export async function unlinkAppIssueLink( issueId: string, linkId: string ): Promise { - if (!NUMERIC_ID.test(linkId)) { + if (!isAllDigits(linkId)) { throw new ValidationError( "App unlink requires the numeric association ID", "linkId" ); } - groupPath(orgSlug, issueId); + requireIssueTarget(orgSlug, issueId); const result = await deleteOrganizationIssueExternalIssue({ ...getSdkConfig(await resolveOrgRegion(orgSlug), { cache: "no-store" }), path: { diff --git a/packages/cli/src/lib/api/issue-integrations.ts b/packages/cli/src/lib/api/issue-integrations.ts index 1274ad23e8..ee241c8039 100644 --- a/packages/cli/src/lib/api/issue-integrations.ts +++ b/packages/cli/src/lib/api/issue-integrations.ts @@ -16,7 +16,7 @@ import { resolveOrgRegion } from "../region.js"; import { getSdkConfig } from "../sentry-client.js"; import { API_MAX_PER_PAGE, - MAX_PAGINATION_PAGES, + fetchAllPages, unwrapPaginatedResult, unwrapResult, } from "./infrastructure.js"; @@ -206,33 +206,17 @@ async function listIntegrations( const config = getSdkConfig(await resolveOrgRegion(orgSlug), { cache: "no-store", }); - const integrations: NativeIntegration[] = []; - let cursor: string | undefined; - for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { - const result = await listOrganizationIssueIntegrations({ - ...config, - path: { organization_id_or_slug: orgSlug, issue_id: issueId }, - query: { cursor, per_page: API_MAX_PER_PAGE }, - }); - const response = unwrapPaginatedResult( - result, - "Failed to list issue integrations" - ); - const parsed = safeParse(vIssueIntegrationsResponse, response.data); - if (!parsed.success) { - throw new ApiError( - "Unexpected response format when listing issue integrations", - 0 - ); - } - integrations.push(...parsed.output); - cursor = response.nextCursor; - if (!cursor) { - return integrations; - } - } - throw new ValidationError( - "Too many results to resolve the issue link safely." + return fetchAllPages( + async (cursor) => { + const result = await listOrganizationIssueIntegrations({ + ...config, + path: { organization_id_or_slug: orgSlug, issue_id: issueId }, + query: { cursor, per_page: API_MAX_PER_PAGE }, + }); + return unwrapPaginatedResult(result, "Failed to list issue integrations"); + }, + vIssueIntegrationsResponse, + "listing issue integrations" ); } diff --git a/packages/cli/test/lib/api/infrastructure.test.ts b/packages/cli/test/lib/api/infrastructure.test.ts index acda59ccf2..e1d4187a3d 100644 --- a/packages/cli/test/lib/api/infrastructure.test.ts +++ b/packages/cli/test/lib/api/infrastructure.test.ts @@ -1,7 +1,10 @@ +import { array, number } from "valibot"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { API_MAX_PER_PAGE, + fetchAllPages, isTextualContentType, + MAX_PAGINATION_PAGES, paginate, rawApiRequest, throwApiError, @@ -776,3 +779,57 @@ describe("paginate", () => { ]); }); }); + +describe("fetchAllPages", () => { + const numbers = array(number()); + + test("follows cursors and returns every validated item", async () => { + const fetchPage = vi.fn((cursor: string | undefined) => + Promise.resolve( + cursor ? { data: [3] } : { data: [1, 2], nextCursor: "page-2" } + ) + ); + expect(await fetchAllPages(fetchPage, numbers, "listing numbers")).toEqual([ + 1, 2, 3, + ]); + expect(fetchPage.mock.calls.map(([cursor]) => cursor)).toEqual([ + undefined, + "page-2", + ]); + }); + + test.each([ + { + name: "an invalid page", + page: () => ({ data: ["one"] }), + message: "Unexpected response format when listing numbers", + }, + { + name: "a repeated cursor", + page: () => ({ data: [1], nextCursor: "same" }), + message: "Pagination repeated a cursor when listing numbers", + }, + { + name: "the page limit", + page: (() => { + let page = 0; + return () => { + page += 1; + return { data: [1], nextCursor: String(page) }; + }; + })(), + message: `Pagination exceeded ${MAX_PAGINATION_PAGES} pages when listing numbers`, + }, + ])("fails instead of returning a partial list on $name", async ({ + page, + message, + }) => { + const result = fetchAllPages( + () => Promise.resolve(page()), + numbers, + "listing numbers" + ); + await expect(result).rejects.toBeInstanceOf(ApiError); + await expect(result).rejects.toThrow(message); + }); +}); From 2a678f1ceb5cc3bf3c900515d1585552052afa57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 29 Sep 2026 12:14:12 +0200 Subject: [PATCH 13/17] refactor(issue): validate link URLs once and share one path per action Three modules each checked for an HTTP(S) URL without credentials, with different messages; parseHttpUrl now does that check. Link and unlink had near-identical native and App branches that built their results separately. Each action now has one path: planLink and findStoredLink isolate the backend differences, and results are built in one place. A dry run of an existing App link now reports its identifier, like native links already did. Co-authored-by: Cursor --- packages/cli/src/lib/api/issue-app-links.ts | 23 +- .../cli/src/lib/api/issue-integrations.ts | 12 +- packages/cli/src/lib/issue-links.ts | 257 ++++++++++-------- packages/cli/src/lib/utils.ts | 22 ++ packages/cli/test/lib/issue-links.test.ts | 25 +- packages/cli/test/lib/utils.test.ts | 22 +- 6 files changed, 221 insertions(+), 140 deletions(-) diff --git a/packages/cli/src/lib/api/issue-app-links.ts b/packages/cli/src/lib/api/issue-app-links.ts index a512c82196..5da24cd441 100644 --- a/packages/cli/src/lib/api/issue-app-links.ts +++ b/packages/cli/src/lib/api/issue-app-links.ts @@ -35,7 +35,7 @@ import { import { ApiError, ValidationError } from "../errors.js"; import { resolveOrgRegion } from "../region.js"; import { getControlSiloUrl, getSdkConfig } from "../sentry-client.js"; -import { isAllDigits } from "../utils.js"; +import { isAllDigits, parseHttpUrl } from "../utils.js"; import { fetchAllPages, unwrapPaginatedResult, @@ -98,8 +98,6 @@ export type PreparedAppIssueLink = { 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. */ @@ -127,22 +125,10 @@ const LINEAR_ISSUE_KEY = /^[A-Z][A-Z0-9]*-\d+$/; const URL_FIELD = /url/i; function parseTarget(raw: string) { - let url: URL; - try { - url = new URL(raw); - } catch { + const url = parseHttpUrl(raw); + if (!url) { 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", + "External issue must be an absolute HTTP(S) URL without credentials.", "url" ); } @@ -682,7 +668,6 @@ export async function resolveAppIssueLink( issueId: options.issueId, appSlug, url: options.url, - displayName: existing?.displayName ?? target.key ?? options.url, installationUuid: installation.uuid, uri: form.uri, fields: await resolveFields(options, form, installation.uuid), diff --git a/packages/cli/src/lib/api/issue-integrations.ts b/packages/cli/src/lib/api/issue-integrations.ts index ee241c8039..cd24528154 100644 --- a/packages/cli/src/lib/api/issue-integrations.ts +++ b/packages/cli/src/lib/api/issue-integrations.ts @@ -14,6 +14,7 @@ import { safeParse } from "valibot"; import { ApiError, ValidationError } from "../errors.js"; import { resolveOrgRegion } from "../region.js"; import { getSdkConfig } from "../sentry-client.js"; +import { parseHttpUrl } from "../utils.js"; import { API_MAX_PER_PAGE, fetchAllPages, @@ -69,7 +70,8 @@ function parseUrl(value: string): URL { const url = storedUrl(value); if (!url) { throw new ValidationError( - "External issue must be an absolute HTTP(S) URL without credentials." + "External issue must be an absolute HTTP(S) URL without credentials.", + "url" ); } return url; @@ -77,12 +79,8 @@ function parseUrl(value: string): URL { /** Invalid stored URLs must not prevent matching an unrelated valid association. */ function storedUrl(value: string): URL | undefined { - const url = URL.canParse(value) ? new URL(value) : undefined; - if ( - !(url && ["https:", "http:"].includes(url.protocol)) || - url.username || - url.password - ) { + const url = parseHttpUrl(value); + if (!url) { return; } url.hash = ""; diff --git a/packages/cli/src/lib/issue-links.ts b/packages/cli/src/lib/issue-links.ts index d5567037db..87d14895ba 100644 --- a/packages/cli/src/lib/issue-links.ts +++ b/packages/cli/src/lib/issue-links.ts @@ -4,6 +4,7 @@ */ import { + type AppIssueLink, findAppIssueLink, linkAppIssue, listAppIssueLinks, @@ -14,6 +15,7 @@ import { findNativeIssueLink, linkNativeIssue, listNativeIssueLinks, + type NativeIssueLink, resolveNativeIssueLink, unlinkNativeIssueLink, } from "./api/issue-integrations.js"; @@ -21,6 +23,7 @@ import { ValidationError } from "./errors.js"; import { resolveOrgRegion } from "./region.js"; import { invalidateCachedResponsesMatching } from "./response-cache.js"; import { getApiBaseUrl } from "./sentry-client.js"; +import { parseHttpUrl } from "./utils.js"; /** An external resource selected for linking to a Sentry issue. */ export type ExternalIssueLinkOptions = { @@ -69,35 +72,144 @@ export type ExternalIssueLinkResult = { }; }; -/** 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 - ) { +type ExternalIssueRef = ExternalIssueLinkResult["externalIssue"]; + +/** A link prepared with reads only: its dry-run preview and the write that creates it. */ +type LinkPlan = { + /** Whether preflight found this association already stored. */ + linked: boolean; + /** External issue reported by a dry run. */ + preview: ExternalIssueRef; + /** Submit the link; the backend decides whether it changed anything. */ + submit: () => Promise<{ ref: ExternalIssueRef; changed: boolean }>; +}; + +/** A stored association matching the requested URL. */ +type StoredLink = { + ref: ExternalIssueRef; + /** Delete the association, leaving the remote issue untouched. */ + remove: () => Promise; +}; + +/** Validate the URL and return the Sentry App slug, or undefined for a native integration. */ +function selectSentryApp( + options: ExternalIssueLinkOptions +): string | undefined { + const url = parseHttpUrl(options.url); + if (!url) { throw new ValidationError( - "URL must use HTTP(S) without embedded credentials." + "External issue must be an absolute HTTP(S) URL without credentials.", + "url" ); } - const app = Boolean(options.appSlug) || url.hostname === "linear.app"; - if (app && options.integrationId) { + const appSlug = + options.appSlug || (url.hostname === "linear.app" ? "linear" : undefined); + if (appSlug && 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) { + if (!appSlug && options.fields && Object.keys(options.fields).length > 0) { throw new ValidationError( "--field requires a Sentry App selected with --app." ); } - return app; + return appSlug; +} + +function appRef(link: AppIssueLink): ExternalIssueRef { + return { + id: link.id, + identifier: link.displayName, + url: link.webUrl, + provider: link.serviceType, + }; +} + +function nativeRef(link: NativeIssueLink): ExternalIssueRef { + return { + id: link.id, + identifier: link.key, + url: link.url, + provider: link.provider, + }; +} + +async function planLink( + options: ExternalIssueLinkOptions, + appSlug: string | undefined +): Promise { + if (appSlug) { + const prepared = await resolveAppIssueLink(options); + return { + linked: Boolean(prepared.existing), + preview: { + id: prepared.existing?.id, + identifier: prepared.existing?.displayName, + url: prepared.url, + provider: prepared.appSlug, + }, + submit: async () => { + const { link, changed } = await linkAppIssue(prepared); + return { ref: appRef(link), changed }; + }, + }; + } + const prepared = await resolveNativeIssueLink(options); + return { + linked: Boolean(prepared.existing), + preview: { + id: prepared.existing?.id, + identifier: prepared.existing?.key, + url: prepared.url, + provider: prepared.provider, + }, + submit: async () => { + const { link, changed } = await linkNativeIssue(prepared); + return { ref: nativeRef(link), changed }; + }, + }; +} + +async function findStoredLink( + options: ExternalIssueLinkOptions, + appSlug: string | undefined +): Promise { + const { orgSlug, issueId, url } = options; + if (appSlug) { + const links = await listAppIssueLinks(orgSlug, issueId); + const link = findAppIssueLink(links, url, options.appSlug); + if (!link) { + return; + } + return { + ref: appRef(link), + remove: () => unlinkAppIssueLink(orgSlug, issueId, link.id), + }; + } + const links = await listNativeIssueLinks(orgSlug, issueId); + const link = findNativeIssueLink(links, url, options.integrationId); + if (!link) { + return; + } + return { + ref: nativeRef(link), + remove: () => unlinkNativeIssueLink(orgSlug, issueId, link), + }; +} + +function toResult( + options: ExternalIssueLinkOptions, + action: ExternalIssueLinkResult["action"], + outcome: Pick +): ExternalIssueLinkResult { + return { + org: options.orgSlug, + issueId: options.issueId, + action, + dryRun: options.dryRun, + ...outcome, + }; } /** App callbacks run on the control silo, so invalidate the issue's regional cache too. */ @@ -121,117 +233,38 @@ async function invalidateIssueLinks( 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); + const plan = await planLink(options, selectSentryApp(options)); if (options.dryRun) { - return { - ...base, - linked: Boolean(prepared.existing), + return toResult(options, "link", { + linked: plan.linked, changed: false, - externalIssue: { - id: prepared.existing?.id, - identifier: prepared.existing?.key, - url: prepared.url, - provider: prepared.provider, - }, - }; + externalIssue: plan.preview, + }); } - const { link, changed } = await linkNativeIssue(prepared); + const { ref, changed } = await plan.submit(); if (changed) { await invalidateIssueLinks(options); } - return { - ...base, + return toResult(options, "link", { linked: true, changed, - externalIssue: { - id: link.id, - identifier: link.key, - url: link.url, - provider: link.provider, - }, - }; + externalIssue: ref, + }); } /** 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); + const appSlug = selectSentryApp(options); + const link = await findStoredLink(options, appSlug); if (link && !options.dryRun) { - await unlinkNativeIssueLink(options.orgSlug, options.issueId, link); + await link.remove(); await invalidateIssueLinks(options); } - return { - ...base, + return toResult(options, "unlink", { 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, - }, - }; + externalIssue: link?.ref ?? { url: options.url, provider: appSlug }, + }); } diff --git a/packages/cli/src/lib/utils.ts b/packages/cli/src/lib/utils.ts index 1a96ff755e..554548b5c2 100644 --- a/packages/cli/src/lib/utils.ts +++ b/packages/cli/src/lib/utils.ts @@ -21,6 +21,28 @@ export function isAllDigits(str: string): boolean { return ALL_DIGITS_PATTERN.test(str); } +/** + * Parse an absolute HTTP(S) URL without embedded credentials. + * + * @param value - Untrusted URL string + * @returns The parsed URL, or undefined for malformed or relative input, + * other schemes, and URLs with a username or password + */ +export function parseHttpUrl(value: string): URL | undefined { + if (!URL.canParse(value)) { + return; + } + const url = new URL(value); + if ( + !["http:", "https:"].includes(url.protocol) || + url.username || + url.password + ) { + return; + } + return url; +} + /** * Quote a value for safe use as one POSIX shell argument. * diff --git a/packages/cli/test/lib/issue-links.test.ts b/packages/cli/test/lib/issue-links.test.ts index 985ab69202..8f64e2504b 100644 --- a/packages/cli/test/lib/issue-links.test.ts +++ b/packages/cli/test/lib/issue-links.test.ts @@ -71,7 +71,6 @@ beforeEach(() => { ...options, url: appLink.webUrl, appSlug: "linear", - displayName: appLink.displayName, installationUuid: "installation", uri: "/link", fields: { issueId: "remote-uuid" }, @@ -138,6 +137,30 @@ describe("external issue associations", () => { expect(invalidateCachedResponsesMatching).not.toHaveBeenCalled(); }); + test("dry-run link describes an existing app association", async () => { + vi.mocked(resolveAppIssueLink).mockResolvedValue({ + ...options, + url: appLink.webUrl, + appSlug: "linear", + installationUuid: "installation", + uri: "/link", + fields: { issueId: "remote-uuid" }, + existing: appLink, + }); + const result = await linkExternalIssue({ + ...options, + url: appLink.webUrl, + dryRun: true, + }); + expect(result).toMatchObject({ linked: true, changed: false }); + expect(result.externalIssue).toEqual({ + id: appLink.id, + identifier: appLink.displayName, + url: appLink.webUrl, + provider: "linear", + }); + }); + test("already-linked is a successful no-op, with no cache mutation", async () => { vi.mocked(linkNativeIssue).mockResolvedValue({ link: nativeLink, diff --git a/packages/cli/test/lib/utils.test.ts b/packages/cli/test/lib/utils.test.ts index 70b34182ca..6f78d5df55 100644 --- a/packages/cli/test/lib/utils.test.ts +++ b/packages/cli/test/lib/utils.test.ts @@ -8,7 +8,7 @@ */ import { describe, expect, test } from "vitest"; -import { isAllDigits, slugify } from "../../src/lib/utils.js"; +import { isAllDigits, parseHttpUrl, slugify } from "../../src/lib/utils.js"; describe("slugify", () => { describe("JSDoc examples (canonical alignment)", () => { @@ -111,3 +111,23 @@ describe("isAllDigits", () => { expect(isAllDigits(" 123")).toBe(false); }); }); + +describe("parseHttpUrl", () => { + test.each([ + "https://github.com/example/app/issues/42", + "http://tracker.example.com/browse/PROJ-7?view=detail#comments", + ])("parses %s", (value) => { + expect(parseHttpUrl(value)?.href).toBe(value); + }); + + test.each([ + "not-a-url", + "/relative/path", + "file:///tmp/issue", + "javascript:alert(1)", + "https://user:secret@example.com/issue/42", + "https://token@example.com/issue/42", + ])("rejects %s", (value) => { + expect(parseHttpUrl(value)).toBeUndefined(); + }); +}); From a8ed74acd874e6f0fd0879c3348ca92cc3374e2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 29 Sep 2026 12:14:13 +0200 Subject: [PATCH 14/17] refactor(issue): resolve link context with resolveOrgAndIssueId issue link repeated resolveOrgAndIssueId's organization check only to read the project ID. The helper now returns it, so link and unlink share the resolver and its error hint. Co-authored-by: Cursor --- packages/cli/src/commands/issue/link.ts | 15 ++----- packages/cli/src/commands/issue/utils.ts | 10 +++-- .../cli/test/commands/issue/link.func.test.ts | 44 +++++-------------- 3 files changed, 22 insertions(+), 47 deletions(-) diff --git a/packages/cli/src/commands/issue/link.ts b/packages/cli/src/commands/issue/link.ts index e58408d37f..5f5b753c46 100644 --- a/packages/cli/src/commands/issue/link.ts +++ b/packages/cli/src/commands/issue/link.ts @@ -2,7 +2,6 @@ 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"; @@ -12,7 +11,7 @@ import { EXTERNAL_ISSUE_POSITIONALS, parseIssueLinkFields, } from "./link-utils.js"; -import { resolveIssue } from "./utils.js"; +import { resolveOrgAndIssueId } from "./utils.js"; type LinkFlags = { readonly integration?: string; @@ -60,21 +59,15 @@ export const linkCommand = buildCommand({ url: string ) { const fields = parseIssueLinkFields(flags.field); - const { org, issue } = await resolveIssue({ + const { org, issueId, projectId } = await resolveOrgAndIssueId({ 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, + issueId, + projectId, url, integrationId: flags.integration, appSlug: flags.app, diff --git a/packages/cli/src/commands/issue/utils.ts b/packages/cli/src/commands/issue/utils.ts index 55b7e0ba71..00e43771ca 100644 --- a/packages/cli/src/commands/issue/utils.ts +++ b/packages/cli/src/commands/issue/utils.ts @@ -919,18 +919,22 @@ export async function resolveIssue( * This is a stricter wrapper around resolveIssue that throws if org is undefined. * * @param options - Resolution options - * @returns Object with org slug and numeric issue ID + * @returns Object with org slug, numeric issue ID, and the issue's project ID when known * @throws {ContextError} When organization cannot be resolved */ export async function resolveOrgAndIssueId( options: ResolveIssueOptions -): Promise<{ org: string; issueId: string }> { +): Promise<{ org: string; issueId: string; projectId?: string }> { const result = await resolveIssue(options); if (!result.org) { const commandHint = buildCommandHint(options.command, options.issueArg); throw new ContextError("Organization", commandHint); } - return { org: result.org, issueId: result.issue.id }; + return { + org: result.org, + issueId: result.issue.id, + projectId: result.issue.project?.id, + }; } type PollAutofixOptions = { diff --git a/packages/cli/test/commands/issue/link.func.test.ts b/packages/cli/test/commands/issue/link.func.test.ts index 29cb0895e6..6493b8f89c 100644 --- a/packages/cli/test/commands/issue/link.func.test.ts +++ b/packages/cli/test/commands/issue/link.func.test.ts @@ -2,19 +2,18 @@ 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 { ContextError, ValidationError } from "../../../src/lib/errors.js"; +import { resolveOrgAndIssueId } from "../../../src/commands/issue/utils.js"; +import { 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(), + resolveOrgAndIssueId: vi.fn(), })); vi.mock("../../../src/lib/issue-links.js", () => ({ @@ -26,20 +25,6 @@ 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", @@ -68,9 +53,13 @@ function createMockContext() { describe("issue link", () => { beforeEach(() => { - vi.mocked(resolveIssue).mockReset(); + vi.mocked(resolveOrgAndIssueId).mockReset(); vi.mocked(linkExternalIssue).mockReset(); - vi.mocked(resolveIssue).mockResolvedValue({ org: "test-org", issue }); + vi.mocked(resolveOrgAndIssueId).mockResolvedValue({ + org: "test-org", + issueId: "123456789", + projectId: "456", + }); vi.mocked(linkExternalIssue).mockResolvedValue(linkedResult); }); @@ -84,7 +73,7 @@ describe("issue link", () => { externalUrl ); - expect(resolveIssue).toHaveBeenCalledExactlyOnceWith({ + expect(resolveOrgAndIssueId).toHaveBeenCalledExactlyOnceWith({ issueArg: "test-org/APP-42", cwd: "/tmp/example-project", command: "link", @@ -150,22 +139,11 @@ describe("issue link", () => { ) ).rejects.toBeInstanceOf(ValidationError); - expect(resolveIssue).not.toHaveBeenCalled(); + expect(resolveOrgAndIssueId).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, From 2a60686a9c1d2e7b956c4bdd9b5ae42e17722ba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 29 Sep 2026 17:27:06 +0200 Subject: [PATCH 15/17] test(issue): test native link matching without fetch mocks Installation selection and URL alias matching are pure, but most cases went through mocked fetches for a resolve and a PUT that were identical in every case. selectNativeIntegration is now exported, so both run as table tests. The HTTP contract keeps one test per behavior, and one end-to-end test covers link and unlink through the orchestration layer. Co-authored-by: Cursor --- .../cli/src/lib/api/issue-integrations.ts | 37 +- .../test/lib/api/issue-integrations.test.ts | 903 +++++++----------- 2 files changed, 375 insertions(+), 565 deletions(-) diff --git a/packages/cli/src/lib/api/issue-integrations.ts b/packages/cli/src/lib/api/issue-integrations.ts index cd24528154..2ebe6b779e 100644 --- a/packages/cli/src/lib/api/issue-integrations.ts +++ b/packages/cli/src/lib/api/issue-integrations.ts @@ -293,20 +293,18 @@ export function findNativeIssueLink( 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); - const integrations = await listIntegrations(options.orgSlug, options.issueId); +/** Select the one active installation that can own the URL; ambiguity requires --integration. */ +export function selectNativeIntegration( + integrations: NativeIntegration[], + url: string, + integrationId?: string +): NativeIntegration { + const target = parseUrl(url); const candidates = integrations.filter( (integration) => integration.status === "active" && - (!options.integrationId || options.integrationId === integration.id) && - matchesIntegration(url, integration, Boolean(options.integrationId)) + (!integrationId || integrationId === integration.id) && + matchesIntegration(target, integration, Boolean(integrationId)) ); if (candidates.length === 0) { throw new ValidationError( @@ -322,6 +320,23 @@ export async function resolveNativeIssueLink(options: { if (!selected) { throw new ValidationError("No matching integration."); } + return selected; +} + +/** 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); + const integrations = await listIntegrations(options.orgSlug, options.issueId); + const selected = selectNativeIntegration( + integrations, + url.href, + options.integrationId + ); return { orgSlug: options.orgSlug, issueId: options.issueId, diff --git a/packages/cli/test/lib/api/issue-integrations.test.ts b/packages/cli/test/lib/api/issue-integrations.test.ts index 24a8cf22ad..2f72b68e88 100644 --- a/packages/cli/test/lib/api/issue-integrations.test.ts +++ b/packages/cli/test/lib/api/issue-integrations.test.ts @@ -5,6 +5,7 @@ import { listNativeIssueLinks, type NativeIssueLink, resolveNativeIssueLink, + selectNativeIntegration, unlinkNativeIssueLink, } from "../../../src/lib/api/issue-integrations.js"; import { setAuthToken } from "../../../src/lib/db/auth.js"; @@ -29,15 +30,24 @@ const LINK: NativeIssueLink = { displayName: "PROJ-7", }; -function integration( +type IntegrationFixture = { + provider?: string; + domainName?: string | null; + id?: string; + name?: string; + externalIssues?: NativeIssueLink[]; +}; + +function integration({ provider = "jira", - domainName: string | null = "tracker.example.com", + domainName = "tracker.example.com", id = "10", - externalIssues: NativeIssueLink[] = [] -) { + name = `Example ${provider}`, + externalIssues = [], +}: IntegrationFixture = {}) { return { id, - name: `Example ${provider}`, + name, domainName, icon: null, accountType: null, @@ -66,19 +76,215 @@ 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); +describe("selectNativeIntegration", () => { + function select( + fixture: IntegrationFixture, + url: string, + integrationId?: string + ): string { + return selectNativeIntegration([integration(fixture)], url, integrationId) + .id; + } + + // Paths the backend rejects, such as commits and merge requests, still select + // an installation: validating the issue path is the backend's job. + test.each` + provider | domainName | url + ${"jira"} | ${"tracker.example.com"} | ${JIRA_URL.toLowerCase()} + ${"jira_server"} | ${"tracker.example.com"} | ${"https://tracker.example.com/jira/browse/PROJ-7"} + ${"jira_server"} | ${"tracker.example.com"} | ${"https://tracker.example.com/projects/PROJ/issues/PROJ-7"} + ${"jira_server"} | ${"tracker.example.com"} | ${"https://tracker.example.com/jira/software/projects/PROJ/boards/1?selectedIssue=PROJ-7"} + ${"jira_server"} | ${"tracker.example.com/jira"} | ${"https://tracker.example.com/jira/secure/RapidBoard.jspa?rapidView=1&selectedIssue=PROJ-7"} + ${"gitlab"} | ${"gitlab.example.com/group/subgroup"} | ${"https://gitlab.example.com/group/subgroup/project/-/issues/7"} + ${"gitlab"} | ${"gitlab.example.com"} | ${"https://gitlab.example.com/gitlab/group/project/issues/7"} + ${"gitlab"} | ${"gitlab.example.com/group/subgroup"} | ${"https://gitlab.example.com/services/gitlab/group/subgroup/project/-/issues/7"} + ${"gitlab"} | ${"gitlab.com/owner"} | ${"https://gitlab.com/owner/repo/-/merge_requests/7"} + ${"bitbucket"} | ${"bitbucket.org/workspace"} | ${"https://bitbucket.org/workspace/repo/issues/7/a-title"} + ${"bitbucket"} | ${"username"} | ${"https://bitbucket.org/username/commits/issues/7"} + ${"bitbucket"} | ${"bitbucket.org/owner"} | ${"https://bitbucket.org/owner/repo/pull-requests/7"} + ${"vsts"} | ${"https://example.visualstudio.com"} | ${"https://dev.azure.com/example/project/_workitems/edit/7"} + ${"vsts"} | ${"https://dev.azure.com/example"} | ${"https://example.visualstudio.com/project/_workitems/edit/7"} + ${"github"} | ${"github.com/owner"} | ${"https://github.com/OWNER/repo/issues/7"} + ${"github"} | ${"github.com/owner"} | ${"https://github.com/OWNER/repo/pull/7"} + ${"github"} | ${"github.com/owner"} | ${"https://github.com/owner/repo/commit/abcdef"} + ${"github_enterprise"} | ${"github.example.com/owner"} | ${"https://github.example.com/OWNER/repo/pull/7"} + `( + "selects the $provider installation at $domainName for $url", + ({ provider, domainName, url }) => { + expect(select({ provider, domainName }, url)).toBe("10"); + } + ); + + test.each` + provider | domainName | url + ${"jira"} | ${"https://tracker.example.com/jira"} | ${JIRA_URL} + ${"vsts"} | ${"https://dev.azure.com/example"} | ${"https://dev.azure.com/another/project/_workitems/edit/7"} + ${"bitbucket"} | ${"bitbucket.org/team"} | ${"https://bitbucket.org/another/repo/issues/7"} + `( + "rejects a URL outside the $provider installation at $domainName", + ({ provider, domainName, url }) => { + expect(() => select({ provider, domainName }, url)).toThrow( + "No installed native" + ); + } + ); + + test("selects a GitHub installation without domain metadata by owner", () => { + const github = { provider: "github", domainName: null, name: "Owner" }; + expect(select(github, "https://github.com/OWNER/repo/issues/7")).toBe("10"); + expect(() => + select(github, "https://github.com/another/repo/issues/7") + ).toThrow("No installed native"); + }); + + test("selects an Enterprise installation without host metadata only when explicit", () => { + const enterprise = { + provider: "github_enterprise", + domainName: null, + name: "Owner", + }; + const url = "https://github.example.com/OWNER/repo/pull/7"; + expect(() => select(enterprise, url)).toThrow("--integration"); + expect(select(enterprise, url, "10")).toBe("10"); + }); + + test.each([ + { + name: "Jira", + integrations: [integration(), integration({ id: "20" })], + url: JIRA_URL, + }, + { + name: "GitLab", + integrations: [ + integration({ + provider: "gitlab", + domainName: "gitlab.example.com/group", + }), + integration({ + provider: "gitlab", + domainName: "gitlab.example.com/another", + id: "20", + }), + ], + url: "https://gitlab.example.com/deployment/group/repo/-/issues/7", + }, + ])("requires --integration for $name installations sharing a host", ({ + integrations, + url, + }) => { + expect(() => selectNativeIntegration(integrations, url)).toThrow( + "Multiple integrations" + ); + expect(selectNativeIntegration(integrations, url, "20").id).toBe("20"); + }); + + test("ignores installations with malformed domain metadata", () => { + const integrations = [ + integration({ domainName: "https://", id: "20" }), + integration({ + provider: "vsts", + domainName: "unrecognized.example.com", + id: "30", + }), + integration(), + ]; + expect(selectNativeIntegration(integrations, JIRA_URL).id).toBe("10"); + }); +}); + +describe("findNativeIssueLink", () => { + test.each` + provider | existing | target + ${"jira"} | ${JIRA_URL} | ${`${JIRA_URL.toLowerCase()}/?source=cli#details`} + ${"jira_server"} | ${JIRA_URL} | ${"https://tracker.example.com/projects/PROJ/issues/PROJ-7"} + ${"jira_server"} | ${JIRA_URL} | ${"https://tracker.example.com/jira/software/projects/PROJ/boards/1?selectedIssue=PROJ-7&view=detail"} + ${"jira_server"} | ${"https://tracker.example.com/jira/browse/PROJ-7"} | ${"https://tracker.example.com/jira/secure/RapidBoard.jspa?rapidView=1&selectedIssue=PROJ-7"} + ${"jira"} | ${JIRA_URL} | ${"https://tracker.example.com/browse/PROJ-1?selectedIssue=invalid&selectedIssue=proj-7&selectedIssue=PROJ-1"} + ${"gitlab"} | ${"https://gitlab.com/group/repo/issues/7"} | ${"https://gitlab.com/group/repo/-/issues/7"} + ${"github"} | ${"https://github.com/owner/repo/issues/7"} | ${"https://github.com/OWNER/Repo/issues/7/"} + ${"github"} | ${"https://github.com/owner/repo/issues/7"} | ${"https://github.com/OWNER/repo/pull/7/files?source=cli#diff"} + ${"bitbucket"} | ${"https://bitbucket.org/owner/repo/issues/7/a-title"} | ${"https://bitbucket.org/owner/repo/issues/7"} + ${"vsts"} | ${"https://example.visualstudio.com/_workitems/edit/7"} | ${"https://dev.azure.com/example/project/_workitems/edit/7"} + `( + "matches $provider alias $target using stored metadata alone", + ({ provider, existing, target }) => { + const link = { ...LINK, provider, url: existing }; + expect(findNativeIssueLink([link], target)).toBe(link); + } + ); + + test.each([ + "https://tracker.example.com/jira-archive/browse/PROJ-7", + "https://tracker.example.com/other/projects/PROJ/issues/PROJ-7", + "https://tracker.example.com/other/board?selectedIssue=PROJ-7", + "https://other.example.com/jira/browse/PROJ-7", + ])("does not match Jira aliases outside the stored context: %s", (target) => { + expect( + findNativeIssueLink( + [ + { + ...LINK, + provider: "jira_server", + url: "https://tracker.example.com/jira/browse/PROJ-7", + }, + ], + target + ) + ).toBeUndefined(); + }); + + test("ignores malformed stored siblings and other providers on the same host", () => { + const enterprise = { + ...LINK, + id: "5678", + provider: "github_enterprise", + url: "https://tracker.example.com/owner/repo/issues/7", + }; + const malformed = { ...LINK, id: "999", url: "not a URL" }; + expect(findNativeIssueLink([malformed, LINK], JIRA_URL)).toBe(LINK); + expect(findNativeIssueLink([LINK, enterprise], enterprise.url)).toBe( + enterprise + ); + }); + + 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); }); - return requests; -} -describe("native tracker issue links", () => { + test.each([ + "https://github.com/owner/repo/pull/8", + "https://github.com/owner/other/pull/7", + "https://other.example.com/owner/repo/pull/7", + ])("distinguishes GitHub PR numbers, repositories and hosts: %s", (target) => { + const link = { + ...LINK, + provider: "github", + url: "https://github.com/owner/repo/pull/7", + }; + expect(findNativeIssueLink([link], target)).toBeUndefined(); + }); + + test("never equates URLs just because neither identifies an issue", () => { + 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(); + }); +}); + +describe("native link API", () => { useTestConfigDir("native-issue-links-"); let originalFetch: typeof fetch; @@ -92,421 +298,70 @@ describe("native tracker issue links", () => { globalThis.fetch = originalFetch; }); - test.each([ - { - provider: "jira", - domain: "tracker.example.com", - url: JIRA_URL.toLowerCase(), - canonical: JIRA_URL, - }, - { - 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", - }, - { - 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", - }, - { - 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", - }, - { - 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", - }, - { - 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", - }, - { - provider: "bitbucket", - domain: "bitbucket.org/workspace", - url: "https://bitbucket.org/workspace/repo/issues/7/a-title", - canonical: "https://bitbucket.org/workspace/repo/issues/7", - }, - { - provider: "bitbucket", - domain: "username", - url: "https://bitbucket.org/username/commits/issues/7", - canonical: "https://bitbucket.org/username/commits/issues/7", - }, - { - provider: "vsts", - domain: "https://example.visualstudio.com", - url: "https://dev.azure.com/example/project/_workitems/edit/7", - canonical: "https://dev.azure.com/example/project/_workitems/edit/7", - }, - { - provider: "vsts", - domain: "https://dev.azure.com/example", - url: "https://example.visualstudio.com/project/_workitems/edit/7", - canonical: "https://example.visualstudio.com/project/_workitems/edit/7", - }, - ...["github", "github_enterprise"].flatMap((provider) => - ["issues", "pull"].map((path) => ({ - provider, - domain: `${provider === "github" ? "github.com" : "github.example.com"}/owner`, - url: `https://${provider === "github" ? "github.com" : "github.example.com"}/OWNER/repo/${path}/7`, - canonical: `https://${provider === "github" ? "github.com" : "github.example.com"}/OWNER/repo/${path}/7`, - })) - ), - ])("links $provider using the URL without repository discovery or a comment", async (fixture) => { + 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; + } + + test("resolves fresh in the organization's region and links by the submitted URL", async () => { + const submitted = `${JIRA_URL}?source=cli`; 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({ - externalIssue: fixture.url, - }); + expect(await request.json()).toEqual({ externalIssue: submitted }); return Response.json( - { ...LINK, id: 1234, integrationId: 10, url: fixture.canonical }, + { ...LINK, id: 1234, integrationId: 10 }, { status: 201 } ); } - expect(request.method).toBe("GET"); expect(url.pathname).toBe(INTEGRATIONS); - return json([integration(fixture.provider, fixture.domain)]); + expect(url.searchParams.get("per_page")).toBe("100"); + return json([integration()]); }); + const prepared = await resolveNativeIssueLink({ ...SOURCE, - url: fixture.url, + url: `${JIRA_URL}/?source=cli#details`, }); expect(prepared).toMatchObject({ ...SOURCE, regionUrl: REGION, integrationId: "10", - provider: fixture.provider, - url: fixture.url, + provider: "jira", + url: submitted, }); expect(prepared.existing).toBeUndefined(); - expect(requests).toHaveLength(1); - expect(await linkNativeIssue(prepared)).toMatchObject({ - changed: true, - link: { url: fixture.canonical }, - }); - expect(requests.map((request) => request.method)).toEqual(["GET", "PUT"]); - }); - - 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 submittedUrls: unknown[] = []; - 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/`); - const body = await request.json(); - expect(Object.keys(body)).toEqual(["externalIssue"]); - submittedUrls.push(body.externalIssue); - const status = linked ? 200 : 201; - linked = true; - // The mutation uses GitHub's html_url; listing reconstructs /issues/N. - return Response.json( - { ...storedLink, id: 1234, integrationId: 10, url: pullUrl }, - { status } - ); - } - 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] : [] - ), - ]); - } - throw new Error(`Unexpected request: ${request.url}`); - }); - - 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", "PUT", "PUT", "DELETE"]); - expect(submittedUrls).toEqual([ - `https://${host}/OWNER/repo/pull/7/files?source=cli`, - pullUrl, - issueUrl, - ]); - }); - - test("preserves the full query while stripping fragment and trailing slash", async () => { - mockApi(() => json([integration()])); - const prepared = await resolveNativeIssueLink({ - ...SOURCE, - url: `${JIRA_URL}/?source=cli#details`, - }); - expect(prepared.url).toBe(`${JIRA_URL}?source=cli`); - }); - - test.each([ - { - domain: "tracker.example.com", - url: "https://tracker.example.com/projects/PROJ/issues/PROJ-7", - stored: JIRA_URL, - }, - { - domain: "tracker.example.com", - url: "https://tracker.example.com/jira/software/projects/PROJ/boards/1?selectedIssue=PROJ-7&view=detail", - stored: JIRA_URL, - }, - { - domain: "tracker.example.com/jira", - url: "https://tracker.example.com/jira/secure/RapidBoard.jspa?rapidView=1&selectedIssue=PROJ-7", - stored: "https://tracker.example.com/jira/browse/PROJ-7", - }, - { - domain: "tracker.example.com", - url: "https://tracker.example.com/jira-archive/board?selectedIssue=PROJ-7", - stored: JIRA_URL, - }, - { - domain: "tracker.example.com", - url: "https://tracker.example.com/browse/PROJ-1?selectedIssue=invalid&selectedIssue=proj-7&selectedIssue=PROJ-1", - stored: JIRA_URL, - }, - ])("forwards Jira copy link $url and matches its stored browse alias", async ({ - domain, - url, - stored, - }) => { - const requests = mockApi(async (request) => { - if (request.method === "GET") - return json([integration("jira_server", domain)]); - expect(await request.json()).toEqual({ externalIssue: url }); - return Response.json( - { ...LINK, id: 1234, integrationId: 10, url: stored }, - { status: 201 } - ); - }); - const prepared = await resolveNativeIssueLink({ ...SOURCE, url }); - expect(await linkNativeIssue(prepared)).toMatchObject({ - changed: true, - link: { url: stored }, - }); - expect( - findNativeIssueLink( - [{ ...LINK, provider: "jira_server", url: stored }], - url - )?.id - ).toBe(LINK.id); - expect(requests).toHaveLength(2); - }); - - test("selects GitHub cloud installations with missing domain metadata by owner", async () => { - mockApi(() => json([{ ...integration("github", null), name: "Owner" }])); - expect( - await resolveNativeIssueLink({ - ...SOURCE, - url: "https://github.com/OWNER/repo/issues/7", - }) - ).toMatchObject({ integrationId: "10" }); - await expect( - resolveNativeIssueLink({ - ...SOURCE, - url: "https://github.com/another/repo/issues/7", - }) - ).rejects.toThrow("No installed native"); - }); - - test("requires an explicit Enterprise installation when its host metadata is missing", async () => { - mockApi(() => - json([{ ...integration("github_enterprise", null), name: "Owner" }]) - ); - const options = { - ...SOURCE, - url: "https://github.example.com/OWNER/repo/pull/7", - }; - await expect(resolveNativeIssueLink(options)).rejects.toThrow( - "--integration" - ); - expect( - await resolveNativeIssueLink({ ...options, integrationId: "10" }) - ).toMatchObject({ integrationId: "10", url: options.url }); - }); - - test("requires a selector for GitLab installations sharing a host", async () => { - mockApi(() => - json([ - integration("gitlab", "gitlab.example.com/group", "10"), - integration("gitlab", "gitlab.example.com/another", "20"), - ]) - ); - const options = { - ...SOURCE, - url: "https://gitlab.example.com/deployment/group/repo/-/issues/7", - }; - await expect(resolveNativeIssueLink(options)).rejects.toThrow( - "Multiple integrations" - ); - expect( - await resolveNativeIssueLink({ ...options, integrationId: "10" }) - ).toMatchObject({ integrationId: "10", url: options.url }); - }); - - test("malformed stored URLs and domains do not block an unrelated valid association", async () => { - const requests = mockApi((request) => - request.method === "PUT" - ? json({ ...LINK, id: 1234, integrationId: 10 }) - : json([ - integration("jira", "https://", "20"), - integration("vsts", "unrecognized.example.com", "30"), - integration("jira", "tracker.example.com", "10", [ - { ...LINK, id: "999", url: "not a URL" }, - LINK, - ]), - ]) - ); - const prepared = await resolveNativeIssueLink({ ...SOURCE, url: JIRA_URL }); - expect(await linkNativeIssue(prepared)).toMatchObject({ - changed: false, + expect(await linkNativeIssue(prepared)).toEqual({ link: LINK, + changed: true, }); - const links = await listNativeIssueLinks(SOURCE.orgSlug, SOURCE.issueId); - expect(findNativeIssueLink(links, JIRA_URL)?.id).toBe(LINK.id); - expect(requests.map((request) => request.method)).toEqual([ - "GET", - "PUT", - "GET", - ]); - }); - - test.each([ - ["github", "github.com/owner", "https://github.com/owner/repo/issues/7"], - [ - "github", - "github.com/owner", - "https://github.com/owner/repo/commit/abcdef", - ], - [ - "bitbucket", - "bitbucket.org/owner", - "https://bitbucket.org/owner/repo/pull-requests/7", - ], - [ - "gitlab", - "gitlab.com/owner", - "https://gitlab.com/owner/repo/-/merge_requests/7", - ], - ])("delegates %s URL and repository validation to the backend: %s %s", async (provider, domain, url) => { - const requests = mockApi(async (request) => { - if (request.method === "GET") - return json([integration(provider, domain)]); - expect(await request.json()).toEqual({ externalIssue: url }); - return Response.json( - { detail: "Invalid provider reference" }, - { status: 400 } - ); - }); - await expect( - resolveNativeIssueLink({ ...SOURCE, url }).then(linkNativeIssue) - ).rejects.toBeInstanceOf(ApiError); expect(requests.map((request) => request.method)).toEqual(["GET", "PUT"]); }); - 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("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")], { + return json([integration({ domainName: "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" }, - ]), + integration({ + id: "20", + externalIssues: [{ ...LINK, integrationId: "20" }], + }), ]); }); @@ -519,9 +374,9 @@ describe("native tracker issue links", () => { test.each([ 200, 201, ])("uses backend HTTP %i even when preflight found a link", async (status) => { - const requests = mockApi((request) => + mockApi((request) => request.method === "GET" - ? json([integration("jira", "tracker.example.com", "10", [LINK])]) + ? json([integration({ externalIssues: [LINK] })]) : Response.json({ ...LINK, id: 1234, integrationId: 10 }, { status }) ); const prepared = await resolveNativeIssueLink({ ...SOURCE, url: JIRA_URL }); @@ -531,17 +386,16 @@ describe("native tracker issue links", () => { link: LINK, changed: status === 201, }); - expect(requests.map((request) => request.method)).toEqual(["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 ({ + ])("does not report success for an invalid mutation response: $name", async ({ response, }) => { - const requests = mockApi((request) => + mockApi((request) => request.method === "GET" ? json([integration()]) : response() ); const prepared = await resolveNativeIssueLink({ ...SOURCE, url: JIRA_URL }); @@ -550,21 +404,58 @@ describe("native tracker issue links", () => { await expect(mutation).rejects.toThrow( "inspect the current links before retrying" ); - expect(requests.map((request) => request.method)).toEqual(["GET", "PUT"]); }); - test("a concurrent PUT no-op uses the backend's 200 status", async () => { - mockApi((request) => + test("propagates the backend's rejection of an issue URL", async () => { + const requests = mockApi((request) => request.method === "GET" - ? json([integration()]) - : json({ ...LINK, id: 1234, integrationId: 10 }) + ? json([ + integration({ provider: "github", domainName: "github.com/owner" }), + ]) + : Response.json( + { detail: "Invalid provider reference" }, + { status: 400 } + ) ); - const prepared = await resolveNativeIssueLink({ ...SOURCE, url: JIRA_URL }); - expect(prepared.existing).toBeUndefined(); - expect(await linkNativeIssue(prepared)).toEqual({ - link: LINK, - changed: false, - }); + await expect( + resolveNativeIssueLink({ + ...SOURCE, + url: "https://github.com/owner/repo/commit/abcdef", + }).then(linkNativeIssue) + ).rejects.toBeInstanceOf(ApiError); + expect(requests.map((request) => request.method)).toEqual(["GET", "PUT"]); + }); + + test("rejects an invalid integration page without linking", async () => { + const requests = mockApi(() => + json([{ ...integration(), externalIssues: [{}] }]) + ); + await expect( + resolveNativeIssueLink({ ...SOURCE, url: JIRA_URL }) + ).rejects.toBeInstanceOf(ApiError); + expect(requests.map((request) => request.method)).toEqual(["GET"]); + }); + + test("lists stored links, skipping URLs that cannot identify an issue", async () => { + mockApi(() => + json([ + integration({ + externalIssues: [{ ...LINK, id: "999", url: "not a URL" }, LINK], + }), + ]) + ); + expect(await listNativeIssueLinks(SOURCE.orgSlug, SOURCE.issueId)).toEqual([ + LINK, + ]); + }); + + test("unlinks by Sentry's association ID in the organization's region", async () => { + const requests = mockApi(() => new Response(null, { status: 204 })); + await unlinkNativeIssueLink(SOURCE.orgSlug, SOURCE.issueId, LINK); + const url = new URL(requests[0]?.url ?? ""); + expect(requests.map((request) => request.method)).toEqual(["DELETE"]); + expect(`${url.origin}${url.pathname}`).toBe(`${REGION}${INTEGRATIONS}10/`); + expect(url.searchParams.get("externalIssue")).toBe("1234"); }); test("rejects an unlink ID that the SDK numeric query cannot represent exactly", async () => { @@ -578,48 +469,6 @@ describe("native tracker issue links", () => { 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: "jira", - domain: "https://tracker.example.com/jira", - url: JIRA_URL, - }, - { - provider: "vsts", - domain: "https://dev.azure.com/example", - url: "https://dev.azure.com/another/project/_workitems/edit/7", - }, - { - provider: "bitbucket", - domain: "bitbucket.org/team", - url: "https://bitbucket.org/another/repo/issues/7", - }, - ])("rejects a URL outside the $provider installation", async ({ - provider, - domain, - url, - }) => { - mockApi(() => json([integration(provider, domain)])); - await expect(resolveNativeIssueLink({ ...SOURCE, url })).rejects.toThrow( - "No installed native" - ); - }); - test.each([ "https://username:secret@tracker.example.com/browse/PROJ-7", "javascript:alert(1)", @@ -629,112 +478,58 @@ describe("native tracker issue links", () => { 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.each([ - "https://tracker.example.com/jira-archive/browse/PROJ-7", - "https://tracker.example.com/other/projects/PROJ/issues/PROJ-7", - "https://tracker.example.com/other/board?selectedIssue=PROJ-7", - "https://other.example.com/jira/browse/PROJ-7", - ])("does not match Jira aliases outside the stored context: %s", (target) => { - expect( - findNativeIssueLink( - [ - { - ...LINK, - provider: "jira_server", - url: "https://tracker.example.com/jira/browse/PROJ-7", - }, - ], - target - ) - ).toBeUndefined(); - }); - - test("a Jira sibling on the same host does not block an Enterprise issue match", () => { - const enterprise = { - ...LINK, - id: "5678", - provider: "github_enterprise", - url: "https://tracker.example.com/owner/repo/issues/7", - }; - expect(findNativeIssueLink([LINK, enterprise], enterprise.url)).toBe( - enterprise - ); - }); - - 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 = { + test("links a GitHub PR and unlinks it through its other URL form", async () => { + const pullUrl = "https://github.com/Owner/Repo/pull/7"; + const storedLink = { ...LINK, provider: "github", - url: "https://github.com/owner/repo/pull/7", + key: "Owner/Repo#7", + displayName: "Owner/Repo#7", + url: "https://github.com/Owner/Repo/issues/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(); - }); + let linked = false; + const requests = mockApi((request) => { + if (request.method === "PUT") { + linked = true; + // The mutation returns GitHub's html_url; listing reconstructs /issues/N. + return Response.json( + { ...storedLink, id: 1234, integrationId: 10, url: pullUrl }, + { status: 201 } + ); + } + if (request.method === "DELETE") { + linked = false; + return new Response(null, { status: 204 }); + } + return json([ + integration({ + provider: "github", + domainName: "github.com/owner", + externalIssues: linked ? [storedLink] : [], + }), + ]); + }); - 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", + const options = { + ...SOURCE, + url: "https://github.com/OWNER/repo/pull/7/files?source=cli#diff", }; + expect(await linkExternalIssue(options)).toMatchObject({ + changed: true, + externalIssue: { id: "1234", identifier: "Owner/Repo#7", url: pullUrl }, + }); + expect(await unlinkExternalIssue(options)).toMatchObject({ + changed: true, + externalIssue: { id: "1234" }, + }); + expect(await unlinkExternalIssue(options)).toMatchObject({ + changed: false, + }); expect( - findNativeIssueLink([link], "https://github.com/owner/repo/commit/def") - ).toBeUndefined(); - expect( - findNativeIssueLink([LINK], "https://other.example.com/browse/PROJ-7") - ).toBeUndefined(); + requests + .filter((request) => request.method !== "GET") + .map((request) => request.method) + ).toEqual(["PUT", "DELETE"]); }); }); From 200a3aeae8a816766e661b776e187e1a61f97589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 29 Sep 2026 17:27:06 +0200 Subject: [PATCH 16/17] fix(issue): match GitLab issue paths case-insensitively GitLab resolves group and project paths case-insensitively, so a link stored as /MyOrg/proj/-/issues/7 must match /myorg/proj/-/issues/7 when unlinking. GitHub and Bitbucket paths were already compared in lowercase. Co-authored-by: Cursor --- packages/cli/src/lib/api/issue-integrations.ts | 4 +++- packages/cli/test/lib/api/issue-integrations.test.ts | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/lib/api/issue-integrations.ts b/packages/cli/src/lib/api/issue-integrations.ts index 2ebe6b779e..f393db61b7 100644 --- a/packages/cli/src/lib/api/issue-integrations.ts +++ b/packages/cli/src/lib/api/issue-integrations.ts @@ -136,7 +136,9 @@ function issueIdentity(url: URL, provider: string): string | undefined { } case "gitlab": { const match = GITLAB_ISSUE.exec(url.pathname); - return match ? `${url.host}/${match[1]}#${match[2]}` : undefined; + return match + ? `${url.host}/${match[1]?.toLowerCase()}#${match[2]}` + : undefined; } case "vsts": { const account = azureAccount(url); diff --git a/packages/cli/test/lib/api/issue-integrations.test.ts b/packages/cli/test/lib/api/issue-integrations.test.ts index 2f72b68e88..bfe97dfd2d 100644 --- a/packages/cli/test/lib/api/issue-integrations.test.ts +++ b/packages/cli/test/lib/api/issue-integrations.test.ts @@ -202,6 +202,7 @@ describe("findNativeIssueLink", () => { ${"jira_server"} | ${"https://tracker.example.com/jira/browse/PROJ-7"} | ${"https://tracker.example.com/jira/secure/RapidBoard.jspa?rapidView=1&selectedIssue=PROJ-7"} ${"jira"} | ${JIRA_URL} | ${"https://tracker.example.com/browse/PROJ-1?selectedIssue=invalid&selectedIssue=proj-7&selectedIssue=PROJ-1"} ${"gitlab"} | ${"https://gitlab.com/group/repo/issues/7"} | ${"https://gitlab.com/group/repo/-/issues/7"} + ${"gitlab"} | ${"https://gitlab.com/MyOrg/Repo/-/issues/7"} | ${"https://gitlab.com/myorg/repo/-/issues/7"} ${"github"} | ${"https://github.com/owner/repo/issues/7"} | ${"https://github.com/OWNER/Repo/issues/7/"} ${"github"} | ${"https://github.com/owner/repo/issues/7"} | ${"https://github.com/OWNER/repo/pull/7/files?source=cli#diff"} ${"bitbucket"} | ${"https://bitbucket.org/owner/repo/issues/7/a-title"} | ${"https://bitbucket.org/owner/repo/issues/7"} From 9c5765b581d2f7049fa4028fdd47077688bb3dfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 29 Sep 2026 17:27:06 +0200 Subject: [PATCH 17/17] docs(issue): explain why unlink filters Apps only by an explicit --app Co-authored-by: Cursor --- packages/cli/src/lib/issue-links.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/lib/issue-links.ts b/packages/cli/src/lib/issue-links.ts index 87d14895ba..dc5c9c9b09 100644 --- a/packages/cli/src/lib/issue-links.ts +++ b/packages/cli/src/lib/issue-links.ts @@ -178,6 +178,7 @@ async function findStoredLink( const { orgSlug, issueId, url } = options; if (appSlug) { const links = await listAppIssueLinks(orgSlug, issueId); + // Only an explicit --app narrows the match: another App may store a Linear URL. const link = findAppIssueLink(links, url, options.appSlug); if (!link) { return;