diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index 3e932f7cc..3a6d0af62 100644 --- a/apps/cli-docs/src/content/docs/contributing.md +++ b/apps/cli-docs/src/content/docs/contributing.md @@ -64,7 +64,7 @@ cli/ │ │ ├── docs/ # list, query │ │ ├── event/ # list, send, view │ │ ├── feedback/ # list, view -│ │ ├── issue/ # archive, events, explain, list, merge, plan, resolve, unresolve, view +│ │ ├── issue/ # archive, events, explain, link, list, merge, plan, resolve, unlink, unresolve, view │ │ ├── local/ # run, serve │ │ ├── log/ # list, view │ │ ├── monitor/ # list, run diff --git a/apps/cli-docs/src/fragments/commands/issue.md b/apps/cli-docs/src/fragments/commands/issue.md index 531e2c2be..7ff85af00 100644 --- a/apps/cli-docs/src/fragments/commands/issue.md +++ b/apps/cli-docs/src/fragments/commands/issue.md @@ -318,3 +318,87 @@ sentry issue ignore CLI-G5 --until auto | `10users/2hours` | 10 users within 2 hours | | *(omitted)* | Archive forever | ::: + +### Link an external issue + +Link an existing tracker issue or GitHub pull request to a Sentry issue: + +```bash +sentry issue link FRONT-123 https://github.com/example/app/issues/42 +sentry issue link FRONT-123 https://github.com/example/app/pull/43 +sentry issue link FRONT-123 https://example.atlassian.net/browse/APP-42 +sentry issue link FRONT-123 https://linear.app/example/issue/APP-42/fix-error +``` + +The matching integration must already be installed in the Sentry organization. +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. +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`. 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 +sentry issue link my-org/FRONT-123 https://github.com/example/app/issues/42 --json +``` + +`--dry-run` discovers the integration and prepares the link without submitting a +write. The provider validates the remote issue when the link is submitted. +An existing matching link succeeds with `changed: false`. A Sentry App that +already links this issue to a different resource must be unlinked first. +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 +duplicate detection and unlinking. Linking a PR does not mark it as a fix or +resolve the Sentry issue. + +This command does not create a tracker issue or link a commit. Existing +integration status-sync settings continue to apply after linking. + +#### Link permissions + +Linking requires `event:write` and access to the Sentry project. Discovering +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. +Use `sentry auth refresh` to update an older OAuth grant. Environment tokens must +be updated separately. + +### Unlink an external issue + +Remove an association without deleting either issue: + +```bash +sentry issue unlink FRONT-123 https://github.com/example/app/issues/42 +sentry issue unlink FRONT-123 https://github.com/example/app/pull/43 --yes +sentry issue unlink my-org/FRONT-123 https://example.atlassian.net/browse/APP-42 --yes +sentry issue unlink FRONT-123 https://linear.app/example/issue/APP-42/fix-error --dry-run +``` + +Use `--yes` for non-interactive execution. `--dry-run` shows whether the link +exists without removing it. If the association is already absent, the command +succeeds with `changed: false`. + +Unlink matches the URL against stored associations and sends Sentry's internal +link ID to the existing DELETE endpoint. It does not require fetching the ticket +from the remote tracker, so a deleted remote ticket can still be unlinked. +For a custom Sentry App, select it with `--app `; unlink does not require +the app to expose a link form. Use `--integration ` to disambiguate native +integration links. + +#### Unlink permissions + +Unlink requires **`event:write` and access to the Sentry project**; `event:admin` +is also accepted. The organization's “Let Members Delete Events” setting does +not restrict unlinking on updated Sentry versions. + +Granting a token more scopes does not override project-access policy. diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 34b5fde3d..24c8d64ad 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -417,6 +417,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 a6dce81d7..67a3f2f4d 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 @@ -370,4 +370,46 @@ sentry issue merge cli-k9 cli-15h --into cli-k9 # alias form # Non-error issue types (performance, info, etc.) cannot be merged ``` +### `sentry issue link ` + +Link an existing external issue + +**Flags:** +- `--integration - Native integration ID, when multiple installations match` +- `--app - Sentry App slug (automatically detected for Linear URLs)` +- `-n, --dry-run - Show what would happen without making changes` +- `--field ... - Additional Sentry App link form field (name=value, repeatable)` + +**Examples:** + +```bash +sentry issue link FRONT-123 https://github.com/example/app/issues/42 +sentry issue link FRONT-123 https://github.com/example/app/pull/43 +sentry issue link FRONT-123 https://example.atlassian.net/browse/APP-42 +sentry issue link FRONT-123 https://linear.app/example/issue/APP-42/fix-error + +sentry issue link my-org/FRONT-123 https://github.com/example/app/issues/42 --dry-run +sentry issue link my-org/FRONT-123 https://github.com/example/app/issues/42 --json +``` + +### `sentry issue unlink ` + +Unlink an external issue + +**Flags:** +- `--integration - Native integration ID, when multiple installations match` +- `--app - Sentry App slug (automatically detected for Linear URLs)` +- `-y, --yes - Skip confirmation prompt` +- `-f, --force - Force the operation without confirmation` +- `-n, --dry-run - Show what would happen without making changes` + +**Examples:** + +```bash +sentry issue unlink FRONT-123 https://github.com/example/app/issues/42 +sentry issue unlink FRONT-123 https://github.com/example/app/pull/43 --yes +sentry issue unlink my-org/FRONT-123 https://example.atlassian.net/browse/APP-42 --yes +sentry issue unlink FRONT-123 https://linear.app/example/issue/APP-42/fix-error --dry-run +``` + All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/packages/cli/src/commands/issue/index.ts b/packages/cli/src/commands/issue/index.ts index a141c6da0..64538c81d 100644 --- a/packages/cli/src/commands/issue/index.ts +++ b/packages/cli/src/commands/issue/index.ts @@ -2,10 +2,12 @@ import { buildRouteMap } from "../../lib/route-map.js"; import { archiveCommand } from "./archive.js"; import { eventsCommand } from "./events.js"; import { explainCommand } from "./explain.js"; +import { linkCommand } from "./link.js"; import { listCommand } from "./list.js"; import { mergeCommand } from "./merge.js"; import { planCommand } from "./plan.js"; import { resolveCommand } from "./resolve.js"; +import { unlinkCommand } from "./unlink.js"; import { unresolveCommand } from "./unresolve.js"; import { viewCommand } from "./view.js"; @@ -20,6 +22,8 @@ export const issueRoute = buildRouteMap({ unresolve: unresolveCommand, archive: archiveCommand, merge: mergeCommand, + link: linkCommand, + unlink: unlinkCommand, }, // `reopen` is a friendlier synonym for `unresolve`, `ignore` for `archive`. aliases: { reopen: "unresolve", ignore: "archive" }, @@ -37,7 +41,9 @@ export const issueRoute = buildRouteMap({ " resolve Mark an issue as resolved (optionally in a release)\n" + " unresolve Reopen a resolved issue (alias: reopen)\n" + " archive Archive/ignore an issue (alias: ignore)\n" + - " merge Merge 2+ issues into a single group\n\n" + + " merge Merge 2+ issues into a single group\n" + + " link Link an existing external issue\n" + + " unlink Remove an external issue link\n\n" + "Magic selectors (available for view, events, explain, plan, resolve, unresolve, archive):\n" + " @latest Most recent unresolved issue\n" + " @most_frequent Issue with the highest event frequency\n\n" + diff --git a/packages/cli/src/commands/issue/link-utils.ts b/packages/cli/src/commands/issue/link-utils.ts new file mode 100644 index 000000000..107bc71ee --- /dev/null +++ b/packages/cli/src/commands/issue/link-utils.ts @@ -0,0 +1,58 @@ +/** Shared arguments for external issue association commands. */ + +import { ValidationError } from "../../lib/errors.js"; +import { issueIdPositional } from "./utils.js"; + +/** Required source issue and existing external resource URL for link and unlink. */ +export const EXTERNAL_ISSUE_POSITIONALS = { + kind: "tuple", + parameters: [ + ...issueIdPositional.parameters, + { + placeholder: "url", + parse: String, + brief: "URL of an existing tracker issue or GitHub pull request", + }, + ], +} as const; + +/** Flags identifying an existing external issue and its Sentry integration. */ +export const EXTERNAL_ISSUE_FLAGS = { + integration: { + kind: "parsed", + parse: String, + brief: "Native integration ID, when multiple installations match", + optional: true, + }, + app: { + kind: "parsed", + parse: String, + brief: "Sentry App slug (automatically detected for Linear URLs)", + optional: true, + }, +} as const; + +/** Parse repeated App form fields while rejecting ambiguous duplicate keys. */ +export function parseIssueLinkFields( + fields: readonly string[] | undefined +): Record | undefined { + if (!fields?.length) { + return; + } + const result: Record = {}; + for (const field of fields) { + const separator = field.indexOf("="); + const key = field.slice(0, separator); + if ( + separator < 1 || + ["__proto__", "constructor", "prototype"].includes(key) || + Object.hasOwn(result, key) + ) { + throw new ValidationError( + "Each --field must be a unique name=value pair." + ); + } + result[key] = field.slice(separator + 1); + } + return result; +} diff --git a/packages/cli/src/commands/issue/link.ts b/packages/cli/src/commands/issue/link.ts new file mode 100644 index 000000000..5f5b753c4 --- /dev/null +++ b/packages/cli/src/commands/issue/link.ts @@ -0,0 +1,79 @@ +/** Associate an existing tracker issue with a Sentry issue. */ + +import type { SentryContext } from "../../context.js"; +import { buildCommand } from "../../lib/command.js"; +import { formatIssueLinkResult } from "../../lib/formatters/issue-links.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { linkExternalIssue } from "../../lib/issue-links.js"; +import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../lib/mutate-command.js"; +import { + EXTERNAL_ISSUE_FLAGS, + EXTERNAL_ISSUE_POSITIONALS, + parseIssueLinkFields, +} from "./link-utils.js"; +import { resolveOrgAndIssueId } from "./utils.js"; + +type LinkFlags = { + readonly integration?: string; + readonly app?: string; + readonly field?: string[]; + readonly "dry-run": boolean; +}; + +export const linkCommand = buildCommand({ + docs: { + brief: "Link an existing external issue", + fullDescription: + "Link an existing tracker issue or GitHub pull request as an external reference.\n" + + "The integration must be installed in your Sentry organization.\n" + + "This does not create a remote issue or resolve the Sentry issue.\n\n" + + "Requires event:write and access to the Sentry project.\n" + + "Sentry Apps also require org:read for discovery.\n\n" + + "Examples:\n" + + " sentry issue link FRONT-123 https://github.com/example/app/issues/42\n" + + " sentry issue link FRONT-123 https://github.com/example/app/pull/43\n" + + " sentry issue link my-org/FRONT-123 https://example.atlassian.net/browse/APP-42\n" + + " sentry issue link FRONT-123 https://linear.app/example/issue/APP-42/fix-error\n" + + " sentry issue link FRONT-123 https://github.com/example/app/issues/42 --dry-run", + }, + output: { human: formatIssueLinkResult }, + parameters: { + positional: EXTERNAL_ISSUE_POSITIONALS, + flags: { + ...EXTERNAL_ISSUE_FLAGS, + "dry-run": DRY_RUN_FLAG, + field: { + kind: "parsed", + parse: String, + brief: "Additional Sentry App link form field (name=value, repeatable)", + variadic: true, + optional: true, + }, + }, + aliases: DRY_RUN_ALIASES, + }, + async *func( + this: SentryContext, + flags: LinkFlags, + issueArg: string, + url: string + ) { + const fields = parseIssueLinkFields(flags.field); + const { org, issueId, projectId } = await resolveOrgAndIssueId({ + issueArg, + cwd: this.cwd, + command: "link", + }); + const result = await linkExternalIssue({ + orgSlug: org, + issueId, + projectId, + url, + integrationId: flags.integration, + appSlug: flags.app, + fields, + dryRun: flags["dry-run"], + }); + yield new CommandOutput(result); + }, +}); diff --git a/packages/cli/src/commands/issue/unlink.ts b/packages/cli/src/commands/issue/unlink.ts new file mode 100644 index 000000000..decf8a996 --- /dev/null +++ b/packages/cli/src/commands/issue/unlink.ts @@ -0,0 +1,75 @@ +/** Remove an external issue association without deleting the external issue. */ + +import type { SentryContext } from "../../context.js"; +import { formatIssueLinkResult } from "../../lib/formatters/issue-links.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { unlinkExternalIssue } from "../../lib/issue-links.js"; +import { + buildDeleteCommand, + confirmByTyping, + isConfirmationBypassed, +} from "../../lib/mutate-command.js"; +import { + EXTERNAL_ISSUE_FLAGS, + EXTERNAL_ISSUE_POSITIONALS, +} from "./link-utils.js"; +import { resolveOrgAndIssueId } from "./utils.js"; + +type UnlinkFlags = { + readonly integration?: string; + readonly app?: string; + readonly "dry-run": boolean; + readonly yes: boolean; + readonly force: boolean; +}; + +export const unlinkCommand = buildDeleteCommand({ + docs: { + brief: "Unlink an external issue", + fullDescription: + "Remove an external tracker issue or GitHub pull request reference from a Sentry issue.\n" + + "This does not delete the external issue or change the Sentry issue's status.\n\n" + + "Requires event:write and access to the Sentry project.\n" + + "Older Sentry versions may still require event:admin.\n\n" + + "Examples:\n" + + " sentry issue unlink FRONT-123 https://github.com/example/app/issues/42\n" + + " sentry issue unlink FRONT-123 https://github.com/example/app/pull/43 --yes\n" + + " sentry issue unlink my-org/FRONT-123 https://example.atlassian.net/browse/APP-42 --yes\n" + + " sentry issue unlink FRONT-123 https://linear.app/example/issue/APP-42/fix-error --dry-run", + }, + output: { human: formatIssueLinkResult }, + parameters: { + positional: EXTERNAL_ISSUE_POSITIONALS, + flags: EXTERNAL_ISSUE_FLAGS, + }, + async *func( + this: SentryContext, + flags: UnlinkFlags, + issueArg: string, + url: string + ) { + const { org, issueId } = await resolveOrgAndIssueId({ + issueArg, + cwd: this.cwd, + command: "unlink", + }); + if (!(flags["dry-run"] || isConfirmationBypassed(flags))) { + const confirmed = await confirmByTyping( + issueArg, + `Type '${issueArg}' to unlink ${url}:` + ); + if (!confirmed) { + return { hint: "Cancelled." }; + } + } + const result = await unlinkExternalIssue({ + orgSlug: org, + issueId, + url, + integrationId: flags.integration, + appSlug: flags.app, + dryRun: flags["dry-run"], + }); + yield new CommandOutput(result); + }, +}); diff --git a/packages/cli/src/commands/issue/utils.ts b/packages/cli/src/commands/issue/utils.ts index 55b7e0ba7..00e43771c 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/src/lib/api/infrastructure.ts b/packages/cli/src/lib/api/infrastructure.ts index 0a58de528..a6892f107 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 new file mode 100644 index 000000000..5da24cd44 --- /dev/null +++ b/packages/cli/src/lib/api/issue-app-links.ts @@ -0,0 +1,729 @@ +/** + * Link existing tracker issues through installed Sentry Apps' issue-link forms. + * App callbacks and search URIs come only from the installed component schema. + */ + +import { + deleteOrganizationIssueExternalIssue, + executeSentryAppInstallationExternalIssueAction, + type GroupExternalIssueResponse, + getSentryAppInstallationExternalRequestOptions, + type ListOrganizationSentryAppInstallationsResponse, + listOrganizationIssueExternalIssues, + listOrganizationSentryAppComponents, + listOrganizationSentryAppInstallations, +} from "@sentry/api"; +import { + vGroupExternalIssueResponse, + vListOrganizationSentryAppComponentsResponse, + vListOrganizationSentryAppInstallationsResponse, +} from "@sentry/api/valibot"; +import { + array, + boolean, + type InferOutput, + nullish, + number, + object, + optional, + picklist, + safeParse, + string, + tuple, + union, +} from "valibot"; +import { ApiError, ValidationError } from "../errors.js"; +import { resolveOrgRegion } from "../region.js"; +import { getControlSiloUrl, getSdkConfig } from "../sentry-client.js"; +import { isAllDigits, parseHttpUrl } from "../utils.js"; +import { + fetchAllPages, + 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]; +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 ChoicesResponseSchema = object({ + choices: array(ChoiceSchema), + defaultValue: FieldSchema.entries.defaultValue, +}); +type Choice = InferOutput; +type Field = InferOutput; +type LinkForm = 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; + /** 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, supplying the canonical URL guard. */ + 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 LINEAR_ISSUE_KEY = /^[A-Z][A-Z0-9]*-\d+$/; +const URL_FIELD = /url/i; + +function parseTarget(raw: string) { + const url = parseHttpUrl(raw); + if (!url) { + throw new ValidationError( + "External issue must be an absolute 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: url.href, 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) => { + 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", + "app" + ); + } + return matches[0]; +} + +function requireIssueTarget(orgSlug: string, issueId: string): void { + if ( + !orgSlug || + orgSlug === "." || + orgSlug === ".." || + !isAllDigits(issueId) + ) { + throw new ValidationError( + "App links require an organization and numeric Sentry issue ID", + "issueId" + ); + } +} + +/** Retrieve all app associations in the issue's region, bypassing stale cached preflights. */ +export async function listAppIssueLinks( + orgSlug: string, + issueId: string +): Promise { + requireIssueTarget(orgSlug, issueId); + const config = getSdkConfig(await resolveOrgRegion(orgSlug), { + cache: "no-store", + }); + return fetchAllPages( + async (cursor) => { + const result = await listOrganizationIssueExternalIssues({ + ...config, + path: { organization_id_or_slug: orgSlug, issue_id: issueId }, + query: { cursor }, + }); + return unwrapPaginatedResult(result, "Failed to list app issue links"); + }, + vGroupExternalIssueResponse, + "listing app issue links" + ); +} + +/** Preserve the app's single association per Sentry issue; replacing a target requires explicit unlink. */ +function checkExisting( + links: AppIssueLink[], + url: string, + appSlug: string +): AppIssueLink | undefined { + const existing = findAppIssueLink(links, url, appSlug); + if ( + links.some( + (link) => link.serviceType === appSlug && link.id !== existing?.id + ) + ) { + throw new ValidationError( + `This issue already has a different ${appSlug} link. Unlink it before linking another issue.`, + "app" + ); + } + return existing; +} + +function validateUri(uri: unknown): asserts uri is string { + if ( + typeof uri !== "string" || + !uri.startsWith("/") || + uri.startsWith("//") || + uri.includes("\\") + ) { + throw new ValidationError( + "The installed app has an invalid relative action URI", + "app" + ); + } +} + +async function resolveInstallation( + orgSlug: string, + appSlug: string +): Promise { + const config = getSdkConfig(getControlSiloUrl(), { cache: "no-store" }); + const installations = await fetchAllPages( + async (cursor) => { + const result = await listOrganizationSentryAppInstallations({ + ...config, + path: { organization_id_or_slug: orgSlug }, + query: { cursor }, + }); + return unwrapPaginatedResult( + result, + "Failed to list Sentry App installations" + ); + }, + vListOrganizationSentryAppInstallationsResponse, + "listing Sentry App installations" + ); + 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 config = getSdkConfig(getControlSiloUrl(), { cache: "no-store" }); + const components = await fetchAllPages( + async (cursor) => { + const result = await listOrganizationSentryAppComponents({ + ...config, + path: { organization_id_or_slug: orgSlug }, + query: { filter: "issue-link", cursor }, + }); + return unwrapPaginatedResult(result, "Failed to list app components"); + }, + vListOrganizationSentryAppComponentsResponse, + "listing Sentry App components" + ); + const matches = components.filter( + (item) => + item.type === "issue-link" && + item.sentryApp.uuid === installation.app.uuid + ); + const component = matches[0]; + if (matches.length !== 1 || !component) { + throw new ValidationError( + `App ${installation.app.slug} does not expose an unambiguous issue-link form`, + "app" + ); + } + if (component.error) { + throw new ApiError( + `App ${installation.app.slug} could not prepare its issue-link form`, + 0, + JSON.stringify(component.error) + ); + } + // 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({ + installationUuid, + field, + query, + values, + projectId, +}: { + installationUuid: string; + field: Field; + query?: string; + values: Record; + projectId?: string; +}): Promise> { + if (!field.uri) { + return { choices: field.choices ?? field.options ?? [] }; + } + validateUri(field.uri); + 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") + ); + if (!parsed.success) { + throw new ApiError("App search returned invalid issue choices", 0); + } + 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, + supplied?: string +): string | number { + const wanted = supplied ?? query; + const matches = choices.filter( + ([value, label]) => + String(value) === wanted || + String(label) === wanted || + (linearKey !== undefined && + 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" + : 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, + 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 || + required.includes(field) || + values[field.name] !== undefined + ); + addDependencies(pending, fields); + 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) { + const missing = new Set( + pending.flatMap((item) => + (item.depends_on ?? []).filter((name) => values[name] === undefined) + ) + ); + throw new ValidationError( + missing.size + ? `Missing app link fields: ${[...missing].map((name) => `--field ${name}=VALUE`).join(", ")}` + : "App link fields have circular dependencies", + "field" + ); + } + pending.splice(index, 1); + values[field.name] = await resolveFieldValue({ + field, + targetField, + values, + options, + installationUuid, + }); + resolved.add(field.name); + } + 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" + ); + } + if (value !== "") { + 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 ( + supplied[field.name] === undefined && + field.defaultValue !== undefined && + field.defaultValue !== null && + field.defaultValue !== "" + ) { + 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; +} + +/** 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, + values, + options, + installationUuid, +}: { + field: Field; + targetField: Field; + values: Record; + options: ResolveAppIssueLinkOptions; + installationUuid: string; +}): Promise { + 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. + 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") { + 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; + } + validateFieldValue(field.name, value, query, supplied); + 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" + ); + } + const existing = checkExisting( + await listAppIssueLinks(options.orgSlug, options.issueId), + options.url, + appSlug + ); + const installation = await resolveInstallation(options.orgSlug, appSlug); + const form = await getLinkForm(options.orgSlug, installation); + return { + orgSlug: options.orgSlug, + issueId: options.issueId, + appSlug, + url: options.url, + installationUuid: installation.uuid, + uri: form.uri, + fields: await resolveFields(options, form, installation.uuid), + existing, + }; +} + +/** Execute the callback with the backend's atomic no-op and replacement guard. */ +export async function linkAppIssue( + prepared: PreparedAppIssueLink +): Promise<{ link: AppIssueLink; changed: boolean }> { + validateUri(prepared.uri); + const result = await executeSentryAppInstallationExternalIssueAction({ + ...getSdkConfig(getControlSiloUrl(), { + // A callback can have external effects before a failed association write. + retry: false, + cache: "no-store", + }), + path: { uuid: prepared.installationUuid }, + query: { + expectedExternalIssueUrl: + prepared.existing?.webUrl ?? 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. */ +export async function unlinkAppIssueLink( + orgSlug: string, + issueId: string, + linkId: string +): Promise { + if (!isAllDigits(linkId)) { + throw new ValidationError( + "App unlink requires the numeric association ID", + "linkId" + ); + } + requireIssueTarget(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 new file mode 100644 index 000000000..f393db61b --- /dev/null +++ b/packages/cli/src/lib/api/issue-integrations.ts @@ -0,0 +1,418 @@ +/** Existing issue-tracker links through Sentry's native integrations. */ +import { + deleteOrganizationIssueIntegration, + type ExternalIssueLinkResponse, + type IssueIntegrationsResponse, + listOrganizationIssueIntegrations, + updateOrganizationIssueIntegration, +} from "@sentry/api"; +import { + vExternalIssueLinkResponse, + vIssueIntegrationsResponse, +} from "@sentry/api/valibot"; +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, + 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 = IssueIntegrationsResponse[number]; + +/** 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; + /** External issue URL submitted to the backend for provider resolution. */ + url: string; + /** Reference found during preflight, used only to describe a dry run. */ + existing?: NativeIssueLink; +}; + +const TRAILING_SLASH = /\/+$/; +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_CANONICAL_PATH = /^(.*)\/browse\/([^/]+)$/; +const WORK_ITEM = /^(.*?)\/_workitems\/edit\/(\d+)$/; + +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.", + "url" + ); + } + return url; +} + +/** Invalid stored URLs must not prevent matching an unrelated valid association. */ +function storedUrl(value: string): URL | undefined { + const url = parseHttpUrl(value); + if (!url) { + return; + } + url.hash = ""; + url.pathname = url.pathname.replace(TRAILING_SLASH, ""); + return url; +} + +function integrationUrl(integration: NativeIntegration): URL | undefined { + 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 storedUrl(`https://bitbucket.org/${domain}`); + } + return storedUrl(domain.includes("://") ? domain : `https://${domain}`); +} + +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); + } +} + +/** 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)?.[1])?.toUpperCase(); +} + +/** 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; + } + case "gitlab": { + const match = GITLAB_ISSUE.exec(url.pathname); + return match + ? `${url.host}/${match[1]?.toLowerCase()}#${match[2]}` + : 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; + } +} + +function matchesIntegration( + url: URL, + integration: NativeIntegration, + explicitlySelected: boolean +): boolean { + const provider = integration.provider.key; + // 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) { + return false; + } + if (provider === "vsts") { + const account = azureAccount(url); + return Boolean(account) && account === azureAccount(domain); + } + if (domain.host !== url.host) { + return false; + } + if (["github", "github_enterprise", "bitbucket"].includes(provider)) { + const account = domain.pathname.split("/").find(Boolean); + return ( + !account || + url.pathname.split("/")[1]?.toLowerCase() === account.toLowerCase() + ); + } + if (provider === "jira" || provider === "jira_server") { + const prefix = domain.pathname.replace(TRAILING_SLASH, ""); + return ( + !prefix || + url.pathname === prefix || + url.pathname.startsWith(`${prefix}/`) + ); + } + // 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 config = getSdkConfig(await resolveOrgRegion(orgSlug), { + cache: "no-store", + }); + 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" + ); +} + +function flattenLinks(integrations: NativeIntegration[]): NativeIssueLink[] { + return integrations.flatMap((integration) => + 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, + }, + ]; + }) + ); +} + +/** 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 = storedUrl(link.url); + if (!existing) { + return false; + } + if (["jira", "jira_server"].includes(link.provider)) { + // 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 && + (target.pathname === context || + target.pathname.startsWith(`${context}/`)) && + jiraIssueKey(target) === canonical?.[2]?.toUpperCase() + ); + } + const identity = issueIdentity(target, link.provider); + if (identity) { + return identity === issueIdentity(existing, link.provider); + } + 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]; +} + +/** 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" && + (!integrationId || integrationId === integration.id) && + matchesIntegration(target, integration, Boolean(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." + ); + } + 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 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, + regionUrl: await resolveOrgRegion(options.orgSlug), + integrationId: selected.id, + provider: selected.provider.key, + url: url.href, + existing: findNativeIssueLink( + flattenLinks(integrations), + url.href, + selected.id + ), + }; +} + +/** Link by URL; the backend resolves provider identifiers and enforces idempotency. */ +export async function linkNativeIssue( + prepared: PreparedNativeIssueLink +): Promise<{ link: NativeIssueLink; changed: boolean }> { + const result = await updateOrganizationIssueIntegration({ + ...getSdkConfig(prepared.regionUrl, { + cache: "no-store", + }), + path: { + organization_id_or_slug: prepared.orgSlug, + issue_id: prepared.issueId, + integration_id: prepared.integrationId, + }, + body: { externalIssue: prepared.url }, + }); + const parsed = safeParse( + vExternalIssueLinkResponse, + unwrapResult(result, "Failed to link external issue") + ); + if (!parsed.success) { + throw new ApiError( + "Unexpected response format after linking; inspect the current links before retrying", + 0 + ); + } + const data = parsed.output; + return { + link: { + ...data, + id: String(data.id), + integrationId: String(data.integrationId), + provider: prepared.provider, + }, + changed: result.response.status === 201, + }; +} + +/** 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), { + cache: "no-store", + }), + path: { + organization_id_or_slug: orgSlug, + issue_id: issueId, + integration_id: link.integrationId, + }, + query: { externalIssue }, + }); + unwrapResult(result, "Failed to unlink external issue"); +} diff --git a/packages/cli/src/lib/complete.ts b/packages/cli/src/lib/complete.ts index 03bb15bfe..038d156ba 100644 --- a/packages/cli/src/lib/complete.ts +++ b/packages/cli/src/lib/complete.ts @@ -97,6 +97,8 @@ export const ORG_PROJECT_COMMANDS = new Set([ "issue explain", "issue plan", "issue resolve", + "issue link", + "issue unlink", "issue unresolve", "issue archive", "issue merge", diff --git a/packages/cli/src/lib/formatters/issue-links.ts b/packages/cli/src/lib/formatters/issue-links.ts new file mode 100644 index 000000000..e033ec0a0 --- /dev/null +++ b/packages/cli/src/lib/formatters/issue-links.ts @@ -0,0 +1,29 @@ +/** Human-readable results for linking and unlinking existing external issues. */ + +import type { ExternalIssueLinkResult } from "../issue-links.js"; +import { renderMarkdown, safeCodeSpan } from "./markdown.js"; + +/** Render the association outcome without implying that either issue was resolved. */ +export function formatIssueLinkResult(result: ExternalIssueLinkResult): string { + const external = safeCodeSpan(result.externalIssue.url); + const issue = safeCodeSpan(`${result.org}/${result.issueId}`); + if (result.dryRun) { + const needsChange = + result.action === "link" ? !result.linked : result.linked; + return renderMarkdown( + needsChange + ? `Would ${result.action} ${external} ${result.action === "link" ? "to" : "from"} ${issue}. (dry run)` + : `Already ${result.linked ? "linked" : "unlinked"}: ${external}. (dry run)` + ); + } + if (!result.changed) { + return renderMarkdown( + `Already ${result.linked ? "linked" : "unlinked"}: ${external}.` + ); + } + return renderMarkdown( + result.linked + ? `Linked ${external} to ${issue}.` + : `Unlinked ${external} from ${issue}. The external issue was not deleted.` + ); +} diff --git a/packages/cli/src/lib/issue-links.ts b/packages/cli/src/lib/issue-links.ts new file mode 100644 index 000000000..dc5c9c9b0 --- /dev/null +++ b/packages/cli/src/lib/issue-links.ts @@ -0,0 +1,271 @@ +/** + * Link and unlink existing external issues through Sentry's native integrations + * and Sentry Apps. These operations leave the Sentry issue's status unchanged. + */ + +import { + type AppIssueLink, + findAppIssueLink, + linkAppIssue, + listAppIssueLinks, + resolveAppIssueLink, + unlinkAppIssueLink, +} from "./api/issue-app-links.js"; +import { + findNativeIssueLink, + linkNativeIssue, + listNativeIssueLinks, + type NativeIssueLink, + 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"; +import { parseHttpUrl } from "./utils.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; + }; +}; + +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( + "External issue must be an absolute HTTP(S) URL without credentials.", + "url" + ); + } + 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 (!appSlug && options.fields && Object.keys(options.fields).length > 0) { + throw new ValidationError( + "--field requires a Sentry App selected with --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); + // Only an explicit --app narrows the match: another App may store a Linear URL. + 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. */ +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 plan = await planLink(options, selectSentryApp(options)); + if (options.dryRun) { + return toResult(options, "link", { + linked: plan.linked, + changed: false, + externalIssue: plan.preview, + }); + } + const { ref, changed } = await plan.submit(); + if (changed) { + await invalidateIssueLinks(options); + } + return toResult(options, "link", { + linked: true, + changed, + externalIssue: ref, + }); +} + +/** Remove a stored association without contacting or deleting the remote ticket. */ +export async function unlinkExternalIssue( + options: ExternalIssueLinkOptions +): Promise { + const appSlug = selectSentryApp(options); + const link = await findStoredLink(options, appSlug); + if (link && !options.dryRun) { + await link.remove(); + await invalidateIssueLinks(options); + } + return toResult(options, "unlink", { + linked: Boolean(link && options.dryRun), + changed: Boolean(link && !options.dryRun), + externalIssue: link?.ref ?? { url: options.url, provider: appSlug }, + }); +} diff --git a/packages/cli/src/lib/sentry-client.ts b/packages/cli/src/lib/sentry-client.ts index a35c46646..033b4da86 100644 --- a/packages/cli/src/lib/sentry-client.ts +++ b/packages/cli/src/lib/sentry-client.ts @@ -75,6 +75,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; @@ -315,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 }; } @@ -527,16 +537,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, @@ -549,12 +560,14 @@ async function fetchWithRetry( 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; } @@ -564,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); } @@ -592,10 +605,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 @@ -624,11 +636,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( @@ -637,7 +648,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}` }); @@ -726,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 @@ -734,7 +753,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; @@ -743,7 +765,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/src/lib/utils.ts b/packages/cli/src/lib/utils.ts index 1a96ff755..554548b5c 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/commands/issue/link.func.test.ts b/packages/cli/test/commands/issue/link.func.test.ts new file mode 100644 index 000000000..6493b8f89 --- /dev/null +++ b/packages/cli/test/commands/issue/link.func.test.ts @@ -0,0 +1,182 @@ +/** 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 { resolveOrgAndIssueId } from "../../../src/commands/issue/utils.js"; +import { ValidationError } from "../../../src/lib/errors.js"; +import { + type ExternalIssueLinkResult, + linkExternalIssue, +} from "../../../src/lib/issue-links.js"; + +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/issue-links.js", () => ({ + linkExternalIssue: vi.fn(), +})); + +const externalUrl = "https://github.com/example/app/issues/42"; +const defaultFlags = { + "dry-run": false, + json: false, +}; +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(resolveOrgAndIssueId).mockReset(); + vi.mocked(linkExternalIssue).mockReset(); + vi.mocked(resolveOrgAndIssueId).mockResolvedValue({ + org: "test-org", + issueId: "123456789", + projectId: "456", + }); + vi.mocked(linkExternalIssue).mockResolvedValue(linkedResult); + }); + + test("forwards resolved organization, issue and project with the integration selector", async () => { + const { context, output } = createMockContext(); + const func = await linkCommand.loader(); + await func.call( + context, + { ...defaultFlags, integration: "99" }, + "test-org/APP-42", + externalUrl + ); + + expect(resolveOrgAndIssueId).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"); + }); + + test("forwards an App selector and parses repeatable fields without losing values", async () => { + const { context } = createMockContext(); + const func = await linkCommand.loader(); + await func.call( + context, + { + ...defaultFlags, + app: "custom-tracker", + field: ["team=team-1", "query=key=value", "optional="], + }, + "APP-42", + "https://tracker.example/issues/42" + ); + + expect(linkExternalIssue).toHaveBeenCalledExactlyOnceWith({ + orgSlug: "test-org", + issueId: "123456789", + projectId: "456", + url: "https://tracker.example/issues/42", + integrationId: undefined, + appSlug: "custom-tracker", + fields: { team: "team-1", query: "key=value", optional: "" }, + dryRun: false, + }); + }); + + test.each([ + ["team"], + ["=team-1"], + ["team=one", "team=two"], + ["__proto__=value"], + ["constructor=value"], + ["prototype=value"], + ])("rejects malformed or ambiguous --field input %j before resolving or writing", async (...fields) => { + const { context, output } = createMockContext(); + const func = await linkCommand.loader(); + + await expect( + func.call( + context, + { ...defaultFlags, app: "custom-tracker", field: fields }, + "APP-42", + externalUrl + ) + ).rejects.toBeInstanceOf(ValidationError); + + expect(resolveOrgAndIssueId).not.toHaveBeenCalled(); + expect(linkExternalIssue).not.toHaveBeenCalled(); + expect(output()).toBe(""); + }); + + test("renders a dry-run preview while forwarding the no-write flag", async () => { + vi.mocked(linkExternalIssue).mockResolvedValue({ + ...linkedResult, + linked: false, + changed: false, + dryRun: true, + }); + const { context, output } = createMockContext(); + const func = await linkCommand.loader(); + await func.call( + context, + { ...defaultFlags, "dry-run": true }, + "APP-42", + externalUrl + ); + + expect(linkExternalIssue).toHaveBeenCalledWith( + expect.objectContaining({ dryRun: true }) + ); + expect(output()).toContain("Would link"); + expect(output()).toContain("dry run"); + }); + + test("emits the link result unchanged in JSON", async () => { + const { context, output } = createMockContext(); + const func = await linkCommand.loader(); + await func.call( + context, + { ...defaultFlags, json: true }, + "APP-42", + externalUrl + ); + + 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 new file mode 100644 index 000000000..7a28c16ae --- /dev/null +++ b/packages/cli/test/commands/issue/unlink.func.test.ts @@ -0,0 +1,215 @@ +/** 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 { + 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/issue-links.js", () => ({ + unlinkExternalIssue: vi.fn(), +})); + +vi.mock("../../../src/lib/mutate-command.js", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../../../src/lib/mutate-command.js") + >()), + confirmByTyping: vi.fn(), +})); + +const externalUrl = "https://github.com/example/app/issues/42"; +const defaultFlags = { + "dry-run": false, + yes: false, + force: false, + json: false, +}; +const unlinkedResult: ExternalIssueLinkResult = { + org: "test-org", + issueId: "123456789", + action: "unlink", + linked: false, + changed: true, + externalIssue: { + id: "789", + identifier: "example/app#42", + url: externalUrl, + provider: "github", + }, +}; + +function createMockContext() { + const stdoutWrite = vi.fn((_chunk: string) => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: vi.fn((_chunk: string) => true) }, + cwd: "/tmp/example-project", + }, + output: () => stdoutWrite.mock.calls.map(([chunk]) => chunk).join(""), + }; +} + +describe("issue unlink", () => { + beforeEach(() => { + mockIsatty.mockReset().mockReturnValue(false); + vi.mocked(resolveOrgAndIssueId).mockReset(); + vi.mocked(unlinkExternalIssue).mockReset(); + vi.mocked(confirmByTyping).mockReset().mockResolvedValue(true); + vi.mocked(resolveOrgAndIssueId).mockResolvedValue({ + org: "test-org", + issueId: "123456789", + }); + vi.mocked(unlinkExternalIssue).mockResolvedValue(unlinkedResult); + }); + + test.each([ + { + selector: { integration: "99" }, + expected: { integrationId: "99", appSlug: undefined }, + }, + { + selector: { app: "custom-tracker" }, + expected: { integrationId: undefined, appSlug: "custom-tracker" }, + }, + ])("forwards resolved issue context and selector $selector", async ({ + selector, + expected, + }) => { + const { context, output } = createMockContext(); + const func = await unlinkCommand.loader(); + await func.call( + context, + { ...defaultFlags, ...selector, yes: true }, + "test-org/APP-42", + externalUrl + ); + + expect(resolveOrgAndIssueId).toHaveBeenCalledExactlyOnceWith({ + issueArg: "test-org/APP-42", + cwd: "/tmp/example-project", + command: "unlink", + }); + expect(unlinkExternalIssue).toHaveBeenCalledExactlyOnceWith({ + orgSlug: "test-org", + issueId: "123456789", + url: externalUrl, + ...expected, + dryRun: false, + }); + expect(confirmByTyping).not.toHaveBeenCalled(); + expect(output()).toContain("Unlinked"); + expect(output()).toContain("external issue was not deleted"); + }); + + test("refuses non-interactive mutation without explicit confirmation before resolving", async () => { + const { context, output } = createMockContext(); + const func = await unlinkCommand.loader(); + + await expect( + func.call(context, defaultFlags, "APP-42", externalUrl) + ).rejects.toThrow("Use --yes or --force to confirm."); + + expect(resolveOrgAndIssueId).not.toHaveBeenCalled(); + expect(confirmByTyping).not.toHaveBeenCalled(); + expect(unlinkExternalIssue).not.toHaveBeenCalled(); + expect(output()).toBe(""); + }); + + test.each([ + "yes", + "force", + ] as const)("allows non-interactive --%s without prompting", async (flag) => { + const { context } = createMockContext(); + const func = await unlinkCommand.loader(); + await func.call( + context, + { ...defaultFlags, [flag]: true }, + "APP-42", + externalUrl + ); + + expect(confirmByTyping).not.toHaveBeenCalled(); + expect(unlinkExternalIssue).toHaveBeenCalledExactlyOnceWith({ + orgSlug: "test-org", + issueId: "123456789", + url: externalUrl, + integrationId: undefined, + appSlug: undefined, + dryRun: false, + }); + }); + + test("confirms the selected issue and external URL before unlinking interactively", async () => { + mockIsatty.mockReturnValue(true); + const { context } = createMockContext(); + const func = await unlinkCommand.loader(); + await func.call(context, defaultFlags, "test-org/APP-42", externalUrl); + + expect(confirmByTyping).toHaveBeenCalledExactlyOnceWith( + "test-org/APP-42", + `Type 'test-org/APP-42' to unlink ${externalUrl}:` + ); + expect(unlinkExternalIssue).toHaveBeenCalledOnce(); + expect(vi.mocked(confirmByTyping).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(unlinkExternalIssue).mock.invocationCallOrder[0] + ); + }); + + test("cancelling confirmation leaves the association untouched", async () => { + mockIsatty.mockReturnValue(true); + vi.mocked(confirmByTyping).mockResolvedValue(false); + const { context, output } = createMockContext(); + const func = await unlinkCommand.loader(); + await func.call(context, defaultFlags, "APP-42", externalUrl); + + expect(confirmByTyping).toHaveBeenCalledOnce(); + expect(unlinkExternalIssue).not.toHaveBeenCalled(); + expect(output()).toContain("Cancelled."); + }); + + test("permits --dry-run without a TTY and preserves current link state in JSON", async () => { + const result = { + ...unlinkedResult, + linked: true, + changed: false, + dryRun: true, + }; + vi.mocked(unlinkExternalIssue).mockResolvedValue(result); + const { context, output } = createMockContext(); + const func = await unlinkCommand.loader(); + await func.call( + context, + { ...defaultFlags, "dry-run": true, json: true }, + "APP-42", + externalUrl + ); + + expect(confirmByTyping).not.toHaveBeenCalled(); + expect(unlinkExternalIssue).toHaveBeenCalledWith( + expect.objectContaining({ dryRun: true }) + ); + expect(JSON.parse(output())).toEqual(result); + }); +}); diff --git a/packages/cli/test/lib/api/infrastructure.test.ts b/packages/cli/test/lib/api/infrastructure.test.ts index acda59ccf..e1d4187a3 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); + }); +}); diff --git a/packages/cli/test/lib/api/issue-app-links.test.ts b/packages/cli/test/lib/api/issue-app-links.test.ts new file mode 100644 index 000000000..bfffb8faf --- /dev/null +++ b/packages/cli/test/lib/api/issue-app-links.test.ts @@ -0,0 +1,685 @@ +/** 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 = { + uuid: "component-uuid", + type: "issue-link", + error: "", + sentryApp: { uuid: "app-uuid", slug: "linear", name: "Linear", avatars: [] }, + 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 = 201; + 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 < 300 ? 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( + new globalThis.URL(writes()[0]!.url).searchParams.get( + "expectedExternalIssueUrl" + ) + ).toBe(URL); + 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); + expect( + calls.filter((request) => request.url.includes("/external-issues/")) + ).toHaveLength(1); + }); + + 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" + ); + 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.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: status === 201, + link: links[0], + }); + expect( + 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 () => { + 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("uses the backend guard when another association appears after preflight", async () => { + const prepared = await resolveAppIssueLink(OPTIONS); + 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 () => { + const prepared = await resolveAppIssueLink(OPTIONS); + actionStatus = 503; + await expect(linkAppIssue(prepared)).rejects.toBeInstanceOf(ApiError); + expect(writes()).toHaveLength(1); + }); + + test.each([ + "static", + "search", + ])("links an explicit generic issue ID from %s choices", async (source) => { + const url = "https://tracker.example/tasks/123"; + installation = { + ...INSTALLATION, + app: { ...INSTALLATION.app, slug: "custom" }, + }; + choices = [["123", "An Issue"]]; + form = { + uri: "/sentry/tasks/link", + required_fields: [ + { + name: "task_id", + type: "select", + ...(source === "static" + ? { options: choices } + : { uri: "/sentry/tasks" }), + }, + ], + }; + const prepared = await resolveAppIssueLink({ + ...OPTIONS, + url, + appSlug: "custom", + fields: { task_id: "123" }, + }); + expect(prepared.fields).toEqual({ task_id: "123" }); + actionLink = { ...LINK, serviceType: "custom", webUrl: url }; + expect(await linkAppIssue(prepared)).toEqual({ + changed: true, + link: actionLink, + }); + expect(await writes()[0]?.json()).toMatchObject({ task_id: "123" }); + expect(writes()).toHaveLength(1); + }); + + test("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" }, + }; + 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 () => { + 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("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, + 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, + 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("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, + required_fields: [ + ...FORM.required_fields, + { name: "team", type: "text" }, + ], + }; + await expect(resolveAppIssueLink(OPTIONS)).rejects.toThrow( + "--field team=VALUE" + ); + expect(writes()).toHaveLength(0); + }); + + test.each([ + undefined, + { team: "" }, + ])("requires missing or empty dependencies (%j)", async (fields) => { + form = { + ...FORM, + required_fields: [ + { ...FORM.required_fields[0], depends_on: ["team"] }, + { name: "team", type: "text" }, + ], + }; + await expect(resolveAppIssueLink({ ...OPTIONS, fields })).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, + 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.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, + serviceType: "custom", + webUrl: "https://tracker.example/view?id=1#issue/42", + }; + expect(findAppIssueLink([link], link.webUrl, "custom")).toBe(link); + expect( + findAppIssueLink( + [link], + "https://tracker.example/view?id=2#issue/42", + "custom" + ) + ).toBeUndefined(); + expect( + findAppIssueLink( + [link], + "https://tracker.example/view?id=1#issue/43", + "custom" + ) + ).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/api/issue-integrations.test.ts b/packages/cli/test/lib/api/issue-integrations.test.ts new file mode 100644 index 000000000..bfe97dfd2 --- /dev/null +++ b/packages/cli/test/lib/api/issue-integrations.test.ts @@ -0,0 +1,536 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + findNativeIssueLink, + linkNativeIssue, + listNativeIssueLinks, + type NativeIssueLink, + resolveNativeIssueLink, + selectNativeIntegration, + unlinkNativeIssueLink, +} from "../../../src/lib/api/issue-integrations.js"; +import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { setOrgRegion } from "../../../src/lib/db/regions.js"; +import { ApiError } from "../../../src/lib/errors.js"; +import { + linkExternalIssue, + unlinkExternalIssue, +} from "../../../src/lib/issue-links.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; + +const REGION = "https://eu.sentry.io"; +const INTEGRATIONS = "/api/0/organizations/test-org/issues/42/integrations/"; +const 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", +}; + +type IntegrationFixture = { + provider?: string; + domainName?: string | null; + id?: string; + name?: string; + externalIssues?: NativeIssueLink[]; +}; + +function integration({ + provider = "jira", + domainName = "tracker.example.com", + id = "10", + name = `Example ${provider}`, + externalIssues = [], +}: IntegrationFixture = {}) { + return { + id, + name, + domainName, + icon: null, + accountType: null, + scopes: null, + outOfDate: null, + missingFeatures: null, + provider: { + key: provider, + slug: provider, + name: `Example ${provider}`, + canAdd: true, + canDisable: false, + features: ["issue-basic"], + aspects: {}, + }, + status: "active", + externalIssues: externalIssues.map((link) => ({ + ...link, + title: link.title ?? null, + description: null, + })), + }; +} + +function json(data: unknown, headers?: HeadersInit): Response { + return Response.json(data, { status: 200, headers }); +} + +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"} + ${"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"} + ${"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); + }); + + 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; + + beforeEach(() => { + originalFetch = globalThis.fetch; + setAuthToken("test-token", 3600, "test-refresh"); + setOrgRegion(SOURCE.orgSlug, REGION); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + 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: submitted }); + return Response.json( + { ...LINK, id: 1234, integrationId: 10 }, + { status: 201 } + ); + } + expect(url.pathname).toBe(INTEGRATIONS); + expect(url.searchParams.get("per_page")).toBe("100"); + return json([integration()]); + }); + + const prepared = await resolveNativeIssueLink({ + ...SOURCE, + url: `${JIRA_URL}/?source=cli#details`, + }); + expect(prepared).toMatchObject({ + ...SOURCE, + regionUrl: REGION, + integrationId: "10", + provider: "jira", + url: submitted, + }); + expect(prepared.existing).toBeUndefined(); + expect(await linkNativeIssue(prepared)).toEqual({ + link: LINK, + changed: true, + }); + expect(requests.map((request) => request.method)).toEqual(["GET", "PUT"]); + }); + + test("fetches all integration pages before deciding the link is absent", async () => { + const requests = mockApi((request) => { + const url = new URL(request.url); + if (!url.searchParams.has("cursor")) { + return json([integration({ domainName: "other.example.com" })], { + Link: '; rel="next"; results="true"; cursor="second"', + }); + } + expect(url.searchParams.get("cursor")).toBe("second"); + return json([ + integration({ + id: "20", + externalIssues: [{ ...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.each([ + 200, 201, + ])("uses backend HTTP %i even when preflight found a link", async (status) => { + mockApi((request) => + request.method === "GET" + ? json([integration({ externalIssues: [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: status === 201, + }); + }); + + 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 mutation response: $name", async ({ + response, + }) => { + 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" + ); + }); + + test("propagates the backend's rejection of an issue URL", async () => { + const requests = mockApi((request) => + request.method === "GET" + ? json([ + integration({ provider: "github", domainName: "github.com/owner" }), + ]) + : Response.json( + { detail: "Invalid provider reference" }, + { status: 400 } + ) + ); + 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 () => { + 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.each([ + "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); + }); + + 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", + key: "Owner/Repo#7", + displayName: "Owner/Repo#7", + url: "https://github.com/Owner/Repo/issues/7", + }; + 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] : [], + }), + ]); + }); + + 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( + requests + .filter((request) => request.method !== "GET") + .map((request) => request.method) + ).toEqual(["PUT", "DELETE"]); + }); +}); 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 000000000..950f00e01 --- /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 new file mode 100644 index 000000000..8f64e2504 --- /dev/null +++ b/packages/cli/test/lib/issue-links.test.ts @@ -0,0 +1,259 @@ +/** 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 { + 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, + }); + 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", + 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(); + }); + + 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, + changed: false, + }); + const result = await linkExternalIssue(options); + expect(result).toMatchObject({ linked: true, changed: false }); + 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 }); + }); + + 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(); + }); + + 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/sdk-positionals.test.ts b/packages/cli/test/lib/sdk-positionals.test.ts index 4b48c4767..ea4b5f119 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,37 @@ function createRecordingSDK(): { } describe("generated SDK positional arguments", () => { + test("issue link forwards repeated form fields as an array", async () => { + const { calls, sdk } = createRecordingSDK(); + await sdk.issue.link({ + issue: "example/APP-42", + url: "https://linear.app/example/issue/APP-42/title", + field: ["team=engineering", "label=bug"], + }); + expect(calls[0]).toMatchObject({ + path: ["issue", "link"], + positional: [ + "example/APP-42", + "https://linear.app/example/issue/APP-42/title", + ], + flags: { field: ["team=engineering", "label=bug"] }, + }); + }); + + 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 }, + }); + }); + test("release deploy passes version, environment and name as separate tokens", async () => { const { calls, sdk } = createRecordingSDK(); diff --git a/packages/cli/test/lib/sentry-client.test.ts b/packages/cli/test/lib/sentry-client.test.ts index 2caaf9e33..7ca2c1f77 100644 --- a/packages/cli/test/lib/sentry-client.test.ts +++ b/packages/cli/test/lib/sentry-client.test.ts @@ -6,13 +6,22 @@ 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, 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-"); @@ -35,6 +44,119 @@ 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; + 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("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__"; diff --git a/packages/cli/test/lib/utils.test.ts b/packages/cli/test/lib/utils.test.ts index 70b34182c..6f78d5df5 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(); + }); +});