diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index a82bdd82a..d50f47fd8 100644 --- a/apps/cli-docs/src/content/docs/contributing.md +++ b/apps/cli-docs/src/content/docs/contributing.md @@ -68,7 +68,7 @@ toolkit/ │ │ │ ├── dsn/ # list │ │ │ ├── event/ # list, send, view │ │ │ ├── feedback/ # list, resolve, spam, unresolve, view -│ │ │ ├── issue/ # archive, events, explain, list, merge, plan, resolve, unresolve, view +│ │ │ ├── issue/ # archive, events, explain, link, list, merge, plan, resolve, 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..9cff62d1c 100644 --- a/apps/cli-docs/src/fragments/commands/issue.md +++ b/apps/cli-docs/src/fragments/commands/issue.md @@ -318,3 +318,57 @@ 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 in Sentry 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. 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 login` to request the current default scopes. Environment tokens must +be updated separately. 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 30fceeb72..771c666c2 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,7 @@ 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 → 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 865f8906d..ccda75288 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,26 @@ 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 +``` + 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..2bf86f007 100644 --- a/packages/cli/src/commands/issue/index.ts +++ b/packages/cli/src/commands/issue/index.ts @@ -2,6 +2,7 @@ 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"; @@ -20,6 +21,7 @@ export const issueRoute = buildRouteMap({ unresolve: unresolveCommand, archive: archiveCommand, merge: mergeCommand, + link: linkCommand, }, // `reopen` is a friendlier synonym for `unresolve`, `ignore` for `archive`. aliases: { reopen: "unresolve", ignore: "archive" }, @@ -37,7 +39,8 @@ 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\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..35f1f04d7 --- /dev/null +++ b/packages/cli/src/commands/issue/link-utils.ts @@ -0,0 +1,58 @@ +/** Arguments for linking external issues. */ + +import { ValidationError } from "../../lib/errors.js"; +import { issueIdPositional } from "./utils.js"; + +/** Required source issue and existing external resource URL for linking. */ +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/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..4378dac84 --- /dev/null +++ b/packages/cli/src/lib/api/issue-app-links.ts @@ -0,0 +1,696 @@ +/** + * Link existing tracker issues through installed Sentry Apps' issue-link forms. + * App callbacks and search URIs come only from the installed component schema. + */ + +import { + 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 selected by the caller. */ + 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 { 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 matching 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. */ +export async function listAppIssueLinks( + orgSlug: string, + issueId: string +): Promise { + requireIssueTarget(orgSlug, issueId); + const config = getSdkConfig(await resolveOrgRegion(orgSlug)); + 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()); + 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()); + 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()), + 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, + targetKey: string | undefined +): 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, + targetKey: field === targetField ? targetKey : undefined, + 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, + targetKey, + values, + options, + installationUuid, +}: { + field: Field; + targetField: Field; + targetKey: string | undefined; + values: Record; + options: ResolveAppIssueLinkOptions; + installationUuid: string; +}): Promise { + const isTarget = field === targetField; + const supplied = isTarget + ? options.fields?.[field.name] || undefined + : undefined; + // Generic targets can use provider IDs that cannot be inferred from the URL. + const suppliedTarget = + field.type === "select" || !URL_FIELD.test(field.name) + ? supplied + : undefined; + let query = (options.fields?.[field.name] ?? values[field.name])?.toString(); + if (isTarget) { + query = targetKey ?? suppliedTarget ?? 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 { key } = parseTarget(options.url); + const { appSlug } = options; + 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, key), + 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 }> { + const result = await executeSentryAppInstallationExternalIssueAction({ + ...getSdkConfig(getControlSiloUrl()), + path: { uuid: prepared.installationUuid }, + query: { + expectedExternalIssueUrl: prepared.existing?.webUrl ?? prepared.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, + }; +} 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..fae54ffb4 --- /dev/null +++ b/packages/cli/src/lib/api/issue-integrations.ts @@ -0,0 +1,379 @@ +/** Existing issue-tracker links through Sentry's native integrations. */ +import { + 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. */ + 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)); + 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, + }, + ]; + }) + ); +} + +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), + 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, + }; +} diff --git a/packages/cli/src/lib/complete.ts b/packages/cli/src/lib/complete.ts index af455962d..873ab8ae2 100644 --- a/packages/cli/src/lib/complete.ts +++ b/packages/cli/src/lib/complete.ts @@ -98,6 +98,7 @@ export const ORG_PROJECT_COMMANDS = new Set([ "issue explain", "issue plan", "issue resolve", + "issue link", "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..f56f556fa --- /dev/null +++ b/packages/cli/src/lib/formatters/issue-links.ts @@ -0,0 +1,22 @@ +/** Human-readable results for linking 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) { + return renderMarkdown( + result.linked + ? `Already linked: ${external}. (dry run)` + : `Would link ${external} to ${issue}. (dry run)` + ); + } + return renderMarkdown( + result.changed + ? `Linked ${external} to ${issue}.` + : `Already linked: ${external}.` + ); +} diff --git a/packages/cli/src/lib/issue-links.ts b/packages/cli/src/lib/issue-links.ts new file mode 100644 index 000000000..1fccada1f --- /dev/null +++ b/packages/cli/src/lib/issue-links.ts @@ -0,0 +1,218 @@ +/** + * Link existing external issues through Sentry's native integrations + * and Sentry Apps. These operations leave the Sentry issue's status unchanged. + */ + +import { + type AppIssueLink, + linkAppIssue, + resolveAppIssueLink, +} from "./api/issue-app-links.js"; +import { + linkNativeIssue, + type NativeIssueLink, + resolveNativeIssueLink, +} 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"; + /** 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 }>; +}; + +/** 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.appSlug === "") { + throw new ValidationError( + "Specify --app for this external issue URL", + "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, appSlug }); + 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 }; + }, + }; +} + +function toResult( + options: ExternalIssueLinkOptions, + outcome: Pick +): ExternalIssueLinkResult { + return { + org: options.orgSlug, + issueId: options.issueId, + action: "link", + 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, { + linked: plan.linked, + changed: false, + externalIssue: plan.preview, + }); + } + const { ref, changed } = await plan.submit(); + if (changed) { + await invalidateIssueLinks(options); + } + return toResult(options, { + linked: true, + changed, + externalIssue: ref, + }); +} 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/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..0d4e2d991 --- /dev/null +++ b/packages/cli/test/lib/api/issue-app-links.test.ts @@ -0,0 +1,705 @@ +/** Contract tests for installed app callbacks, singleton protection, and regional discovery. */ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + type AppIssueLink, + findAppIssueLink, + linkAppIssue, + listAppIssueLinks, + resolveAppIssueLink, +} 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", + appSlug: "linear", +}; +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 + ); + } + 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.each([ + undefined, + { issueId: "" }, + ])("resolves Linear key to UUID with target fields %j, then sends the schema URI and fields top-level", async (fields) => { + const prepared = await resolveAppIssueLink({ ...OPTIONS, fields }); + 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.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.each([ + { type: "select", options: [["123", "An Issue"]] }, + { type: "select", uri: "/sentry/tasks" }, + { type: "text" }, + { type: "textarea" }, + ])("links an explicit generic issue ID using %j", async (field) => { + 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", + ...field, + }, + ], + }; + 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( + new globalThis.URL(writes()[0]!.url).searchParams.get( + "expectedExternalIssueUrl" + ) + ).toBe(url); + expect(writes()).toHaveLength(1); + }); + + test.each([ + { + type: "text", + name: "issueId", + url: URL, + value: "ENG-99", + appSlug: "linear", + }, + { + type: "textarea", + name: "url", + url: "https://tracker.example/tasks/123", + value: "https://tracker.example/tasks/456", + appSlug: "custom", + }, + ])("rejects a conflicting $type target", async ({ + type, + name, + url, + value, + appSlug, + }) => { + installation = { + ...INSTALLATION, + app: { ...INSTALLATION.app, slug: appSlug }, + }; + form = { uri: "/link", required_fields: [{ name, type }] }; + await expect( + resolveAppIssueLink({ + ...OPTIONS, + url, + appSlug, + fields: { [name]: value }, + }) + ).rejects.toThrow("conflicts"); + expect(writes()).toHaveLength(0); + }); + + test.each([ + { url: "https://tracker.example/view?id=42#issue" }, + { url: "https://TRACKER.example/tasks/42" }, + { url: "https://tracker.example:443/tasks/42" }, + { url: "https://tracker.example" }, + { url: "https://tracker.example/tasks/42#some text" }, + { url: "https://tracker.example/tasks/42", fields: { url: "" } }, + ])("preserves the requested URL in the form and backend guard: %j", async ({ + url, + fields, + }) => { + 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, + fields, + }); + await linkAppIssue(prepared); + expect(await writes()[0]!.json()).toMatchObject({ url }); + 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 match 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("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..19ea49bb0 --- /dev/null +++ b/packages/cli/test/lib/api/issue-integrations.test.ts @@ -0,0 +1,496 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + findNativeIssueLink, + linkNativeIssue, + type NativeIssueLink, + resolveNativeIssueLink, + selectNativeIntegration, +} 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 } 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 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); + 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("finds an existing link despite malformed stored sibling URLs", async () => { + mockApi(() => + json([ + integration({ + externalIssues: [{ ...LINK, id: "999", url: "not a URL" }, LINK], + }), + ]) + ); + const prepared = await resolveNativeIssueLink({ ...SOURCE, url: JIRA_URL }); + expect(prepared.existing).toEqual(LINK); + }); + + 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 returns its canonical URL", 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", + }; + const requests = mockApi((request) => { + if (request.method === "PUT") { + // The mutation returns GitHub's html_url; listing reconstructs /issues/N. + return Response.json( + { ...storedLink, id: 1234, integrationId: 10, url: pullUrl }, + { status: 201 } + ); + } + return json([ + integration({ + provider: "github", + domainName: "github.com/owner", + externalIssues: [], + }), + ]); + }); + + 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( + requests + .filter((request) => request.method !== "GET") + .map((request) => request.method) + ).toEqual(["PUT"]); + }); +}); 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..6b6ffd532 --- /dev/null +++ b/packages/cli/test/lib/formatters/issue-links.test.ts @@ -0,0 +1,45 @@ +/** + * 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}.`, + ], + ] 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..34582fb64 --- /dev/null +++ b/packages/cli/test/lib/issue-links.test.ts @@ -0,0 +1,193 @@ +/** Exercise provider routing, dry runs and association-only mutation outcomes. */ + +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { + linkAppIssue, + resolveAppIssueLink, +} from "../../src/lib/api/issue-app-links.js"; +import { + linkNativeIssue, + resolveNativeIssueLink, +} from "../../src/lib/api/issue-integrations.js"; +import { ApiError } from "../../src/lib/errors.js"; +import { linkExternalIssue } 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(resolveAppIssueLink).mockResolvedValue({ + ...options, + url: appLink.webUrl, + appSlug: "linear", + installationUuid: "installation", + uri: "/link", + fields: { issueId: "remote-uuid" }, + }); + vi.mocked(linkAppIssue).mockResolvedValue({ link: appLink, changed: true }); +}); + +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, + appSlug: "linear", + }); + 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("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..87b694994 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,23 @@ 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("release deploy passes version, environment and name as separate tokens", async () => { const { calls, sdk } = createRecordingSDK(); 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(); + }); +});