From 9254e3ca3c593bf6fb19c90e57be1b13f5739076 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 2 Oct 2026 17:00:48 +0200 Subject: [PATCH 1/5] feat(cli): add issue link command Link existing tracker issues and GitHub pull requests through @sentry/api. Native integrations resolve URLs server-side; installed Sentry Apps use their guarded link forms. Keep unlink in a separate stacked change. Co-Authored-By: GPT-6 --- .../cli-docs/src/content/docs/contributing.md | 2 +- apps/cli-docs/src/fragments/commands/issue.md | 54 ++ .../sentry-cli/skills/sentry-cli/SKILL.md | 1 + .../skills/sentry-cli/references/issue.md | 22 + packages/cli/src/commands/issue/index.ts | 5 +- packages/cli/src/commands/issue/link-utils.ts | 58 ++ packages/cli/src/commands/issue/link.ts | 79 ++ packages/cli/src/commands/issue/utils.ts | 10 +- packages/cli/src/lib/api/infrastructure.ts | 44 ++ packages/cli/src/lib/api/issue-app-links.ts | 698 ++++++++++++++++++ .../cli/src/lib/api/issue-integrations.ts | 379 ++++++++++ packages/cli/src/lib/complete.ts | 1 + .../cli/src/lib/formatters/issue-links.ts | 22 + packages/cli/src/lib/issue-links.ts | 212 ++++++ packages/cli/src/lib/utils.ts | 22 + .../cli/test/commands/issue/link.func.test.ts | 182 +++++ .../cli/test/lib/api/infrastructure.test.ts | 57 ++ .../cli/test/lib/api/issue-app-links.test.ts | 643 ++++++++++++++++ .../test/lib/api/issue-integrations.test.ts | 496 +++++++++++++ .../test/lib/formatters/issue-links.test.ts | 45 ++ packages/cli/test/lib/issue-links.test.ts | 190 +++++ packages/cli/test/lib/sdk-positionals.test.ts | 23 +- packages/cli/test/lib/utils.test.ts | 22 +- 23 files changed, 3258 insertions(+), 9 deletions(-) create mode 100644 packages/cli/src/commands/issue/link-utils.ts create mode 100644 packages/cli/src/commands/issue/link.ts create mode 100644 packages/cli/src/lib/api/issue-app-links.ts create mode 100644 packages/cli/src/lib/api/issue-integrations.ts create mode 100644 packages/cli/src/lib/formatters/issue-links.ts create mode 100644 packages/cli/src/lib/issue-links.ts create mode 100644 packages/cli/test/commands/issue/link.func.test.ts create mode 100644 packages/cli/test/lib/api/issue-app-links.test.ts create mode 100644 packages/cli/test/lib/api/issue-integrations.test.ts create mode 100644 packages/cli/test/lib/formatters/issue-links.test.ts create mode 100644 packages/cli/test/lib/issue-links.test.ts diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index 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..3fd3e1b3f --- /dev/null +++ b/packages/cli/src/lib/api/issue-app-links.ts @@ -0,0 +1,698 @@ +/** + * 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; defaults to linear for a linear.app issue URL. */ + appSlug?: string; + /** Sentry project ID, forwarded to app searches that need project context. */ + projectId?: string; + /** Additional form values keyed by names from the installed link schema. */ + fields?: Record; +}; + +/** Read-only preflight result. Pass to linkAppIssue to execute the app action. */ +export type PreparedAppIssueLink = { + /** Organization and numeric Sentry issue being linked. */ + orgSlug: string; + /** Numeric Sentry group ID. */ + issueId: string; + /** Installed app slug and requested external URL for display/dry-run. */ + appSlug: string; + /** Requested external resource URL. */ + url: string; + /** UUID selected from this organization's installed apps. */ + installationUuid: string; + /** Link action URI supplied by the installed app schema. */ + uri: string; + /** Validated form fields, sent at the top level of the action request. */ + fields: Record; + /** Existing association to the same target, supplying the canonical URL guard. */ + existing?: AppIssueLink; +}; + +const LINEAR_ISSUE_PATH = /^\/([^/]+)\/issue\/([a-z][a-z0-9]*-\d+)(?:\/|$)/i; +const TARGET_FIELD = + /^(issue_?id|issue|external_?issue|external_?id|issue_?url|url)$/i; +const RESERVED_FIELDS = new Set([ + "groupId", + "action", + "uri", + "__proto__", + "constructor", + "prototype", +]); +const TRAILING_SLASHES = /\/+$/; +const CHOICE_LABEL_TOKENS = /[^A-Z0-9-]+/; +const LINEAR_ISSUE_KEY = /^[A-Z][A-Z0-9]*-\d+$/; +const URL_FIELD = /url/i; + +function parseTarget(raw: string) { + const url = parseHttpUrl(raw); + if (!url) { + throw new ValidationError( + "External issue must be an absolute HTTP(S) URL without credentials.", + "url" + ); + } + const linear = + url.hostname === "linear.app" ? LINEAR_ISSUE_PATH.exec(url.pathname) : null; + if (url.hostname === "linear.app" && !linear) { + throw new ValidationError( + "Expected a Linear issue URL containing /issue/TEAM-123", + "url" + ); + } + const identity = linear + ? `linear.app/${linear[1]?.toLowerCase()}/${linear[2]?.toUpperCase()}` + : `${url.origin}${url.pathname.replace(TRAILING_SLASHES, "")}${url.search}${url.hash}`; + return { url: url.href, identity, key: linear?.[2]?.toUpperCase() }; +} + +/** Match a stored target by URL, ignoring Linear title suffixes; reject ambiguous matches. */ +export function findAppIssueLink( + links: AppIssueLink[], + url: string, + appSlug?: string +): AppIssueLink | undefined { + const target = parseTarget(url); + const matches = links.filter((link) => { + if (appSlug && link.serviceType !== appSlug) { + return false; + } + // biome-ignore lint/plugin: Invalid persisted URLs cannot identify the requested target. + try { + return parseTarget(link.webUrl).identity === target.identity; + } catch { + // A malformed stored sibling must not prevent 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 +): Promise> { + const required = form.required_fields ?? []; + const fields = [...required, ...(form.optional_fields ?? [])]; + const values = seedFields(fields, options.fields ?? {}); + const targetField = findTargetField(fields, required); + const pending = fields.filter( + (field) => + field === targetField || + required.includes(field) || + values[field.name] !== undefined + ); + addDependencies(pending, fields); + const resolved = new Set(); + while (pending.length) { + const index = pending.findIndex((item) => + (item.depends_on ?? []).every((name) => resolved.has(name)) + ); + const field = pending[index]; + if (!field) { + const missing = new Set( + pending.flatMap((item) => + (item.depends_on ?? []).filter((name) => values[name] === undefined) + ) + ); + throw new ValidationError( + missing.size + ? `Missing app link fields: ${[...missing].map((name) => `--field ${name}=VALUE`).join(", ")}` + : "App link fields have circular dependencies", + "field" + ); + } + pending.splice(index, 1); + values[field.name] = await resolveFieldValue({ + field, + targetField, + values, + options, + installationUuid, + }); + resolved.add(field.name); + } + return values; +} + +function seedFields( + fields: Field[], + supplied: Record +): Record { + const values: Record = {}; + if (new Set(fields.map((field) => field.name)).size !== fields.length) { + throw new ValidationError( + "App link schema contains duplicate field names", + "app" + ); + } + for (const [name, value] of Object.entries(supplied)) { + if ( + RESERVED_FIELDS.has(name) || + !fields.some((field) => field.name === name) + ) { + throw new ValidationError( + `Unknown or reserved app link field: ${name}`, + "field" + ); + } + if (value !== "") { + values[name] = value; + } + } + for (const field of fields) { + if (RESERVED_FIELDS.has(field.name)) { + throw new ValidationError( + `App link schema uses reserved field ${field.name}`, + "app" + ); + } + if (field.multiple) { + throw new ValidationError( + `App link field ${field.name} requires multiple values and is not supported`, + "field" + ); + } + if ( + supplied[field.name] === undefined && + field.defaultValue !== undefined && + field.defaultValue !== null && + field.defaultValue !== "" + ) { + values[field.name] = field.defaultValue; + } + } + return values; +} + +function findTargetField(fields: Field[], required: Field[]): Field { + const candidates = fields.filter((field) => TARGET_FIELD.test(field.name)); + let targetField = candidates.length === 1 ? candidates[0] : undefined; + if (candidates.length === 0 && required.length === 1) { + targetField = required[0]; + } + if (!targetField) { + throw new ValidationError( + "Cannot identify one external issue field in the app link schema", + "app" + ); + } + return targetField; +} + +/** Required fields and dependencies need a value; explicit target values must agree. */ +function validateFieldValue( + fieldName: string, + value: string | number | undefined, + query: string | number | undefined, + supplied?: string +): asserts value is string | number { + if ( + supplied !== undefined && + supplied !== String(value) && + supplied !== query + ) { + throw new ValidationError( + `App field ${fieldName} conflicts with the requested issue URL`, + "field" + ); + } + if (value === undefined || value === "") { + throw new ValidationError( + `Missing app link fields: --field ${fieldName}=VALUE`, + "field" + ); + } +} + +async function resolveFieldValue({ + field, + targetField, + values, + options, + installationUuid, +}: { + field: Field; + targetField: Field; + values: Record; + options: ResolveAppIssueLinkOptions; + installationUuid: string; +}): Promise { + const isTarget = field === targetField; + const targetKey = isTarget ? parseTarget(options.url).key : undefined; + const supplied = isTarget ? options.fields?.[field.name] : undefined; + // Generic selects can use provider IDs that cannot be inferred from the URL. + let query = (options.fields?.[field.name] ?? values[field.name])?.toString(); + if (isTarget) { + query = + targetKey ?? + (field.type === "select" ? supplied : undefined) ?? + options.url; + } + let value: string | number | undefined = query; + if (field.type === "select") { + const optionsResponse = await getChoices({ + installationUuid, + field, + query, + values, + projectId: options.projectId, + }); + if (!isTarget) { + value ??= optionsResponse.defaultValue ?? undefined; + } + if (value !== undefined) { + value = selectChoice( + optionsResponse.choices, + String(value), + targetKey, + supplied + ); + } + } else if (isTarget && URL_FIELD.test(field.name)) { + value = options.url; + } + validateFieldValue(field.name, value, query, supplied); + return value; +} + +/** Resolve the installed app and form using reads only; never register a local-only fallback. */ +export async function resolveAppIssueLink( + options: ResolveAppIssueLinkOptions +): Promise { + const target = parseTarget(options.url); + const appSlug = options.appSlug ?? (target.key ? "linear" : undefined); + if (!appSlug) { + throw new ValidationError( + "Specify --app for this external issue URL", + "app" + ); + } + const existing = checkExisting( + await listAppIssueLinks(options.orgSlug, options.issueId), + options.url, + appSlug + ); + const installation = await resolveInstallation(options.orgSlug, appSlug); + const form = await getLinkForm(options.orgSlug, installation); + return { + orgSlug: options.orgSlug, + issueId: options.issueId, + appSlug, + url: options.url, + installationUuid: installation.uuid, + uri: form.uri, + fields: await resolveFields(options, form, installation.uuid), + existing, + }; +} + +/** Execute the callback with the backend's atomic no-op and replacement guard. */ +export async function linkAppIssue( + prepared: PreparedAppIssueLink +): Promise<{ link: AppIssueLink; changed: boolean }> { + validateUri(prepared.uri); + const result = await executeSentryAppInstallationExternalIssueAction({ + ...getSdkConfig(getControlSiloUrl()), + path: { uuid: prepared.installationUuid }, + query: { + expectedExternalIssueUrl: + prepared.existing?.webUrl ?? parseTarget(prepared.url).url, + }, + body: { + ...prepared.fields, + groupId: prepared.issueId, + action: "link", + uri: prepared.uri, + }, + }); + return { + link: unwrapResult(result, "Failed to link app issue"), + changed: result.response?.status === 201, + }; +} 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..0be09bd37 --- /dev/null +++ b/packages/cli/src/lib/issue-links.ts @@ -0,0 +1,212 @@ +/** + * 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.fields && Object.keys(options.fields).length > 0) { + throw new ValidationError( + "--field requires a Sentry App selected with --app." + ); + } + return appSlug; +} + +function appRef(link: AppIssueLink): ExternalIssueRef { + return { + id: link.id, + identifier: link.displayName, + url: link.webUrl, + provider: link.serviceType, + }; +} + +function nativeRef(link: NativeIssueLink): ExternalIssueRef { + return { + id: link.id, + identifier: link.key, + url: link.url, + provider: link.provider, + }; +} + +async function planLink( + options: ExternalIssueLinkOptions, + appSlug: string | undefined +): Promise { + if (appSlug) { + const prepared = await resolveAppIssueLink(options); + return { + linked: Boolean(prepared.existing), + preview: { + id: prepared.existing?.id, + identifier: prepared.existing?.displayName, + url: prepared.url, + provider: prepared.appSlug, + }, + submit: async () => { + const { link, changed } = await linkAppIssue(prepared); + return { ref: appRef(link), changed }; + }, + }; + } + const prepared = await resolveNativeIssueLink(options); + return { + linked: Boolean(prepared.existing), + preview: { + id: prepared.existing?.id, + identifier: prepared.existing?.key, + url: prepared.url, + provider: prepared.provider, + }, + submit: async () => { + const { link, changed } = await linkNativeIssue(prepared); + return { ref: nativeRef(link), changed }; + }, + }; +} + +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..f23d85fb2 --- /dev/null +++ b/packages/cli/test/lib/api/issue-app-links.test.ts @@ -0,0 +1,643 @@ +/** 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" }; +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("resolves Linear key to UUID read-only, then sends the schema URI and fields top-level", async () => { + const prepared = await resolveAppIssueLink(OPTIONS); + expect(writes()).toHaveLength(0); + expect(prepared.fields).toEqual({ issueId: "linear-uuid" }); + const search = calls.find((request) => + request.url.includes("external-requests") + ); + expect(search?.url).toContain( + "https://sentry.io/api/0/sentry-app-installations/install-uuid/" + ); + expect(search?.url).toContain("query=ENG-42"); + expect(search?.url).toContain("projectId=77"); + expect(calls[0]?.url).toContain( + "https://de.sentry.io/api/0/organizations/" + ); + expect(await linkAppIssue(prepared)).toEqual({ changed: true, link: LINK }); + expect(writes()).toHaveLength(1); + expect( + new globalThis.URL(writes()[0]!.url).searchParams.get( + "expectedExternalIssueUrl" + ) + ).toBe(URL); + expect(await writes()[0]?.json()).toEqual({ + groupId: ISSUE, + action: "link", + uri: FORM.uri, + issueId: "linear-uuid", + }); + expect( + calls.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([ + "static", + "search", + ])("links an explicit generic issue ID from %s choices", async (source) => { + const url = "https://tracker.example/tasks/123"; + installation = { + ...INSTALLATION, + app: { ...INSTALLATION.app, slug: "custom" }, + }; + choices = [["123", "An Issue"]]; + form = { + uri: "/sentry/tasks/link", + required_fields: [ + { + name: "task_id", + type: "select", + ...(source === "static" + ? { options: choices } + : { uri: "/sentry/tasks" }), + }, + ], + }; + const prepared = await resolveAppIssueLink({ + ...OPTIONS, + url, + appSlug: "custom", + fields: { task_id: "123" }, + }); + expect(prepared.fields).toEqual({ task_id: "123" }); + actionLink = { ...LINK, serviceType: "custom", webUrl: url }; + expect(await linkAppIssue(prepared)).toEqual({ + changed: true, + link: actionLink, + }); + expect(await writes()[0]?.json()).toMatchObject({ task_id: "123" }); + expect(writes()).toHaveLength(1); + }); + + test("preserves query and fragment identity in the backend URL guard", async () => { + const url = "https://tracker.example/view?id=42#issue"; + installation = { + ...INSTALLATION, + app: { ...INSTALLATION.app, slug: "custom" }, + }; + form = { uri: "/link", required_fields: [{ name: "url", type: "text" }] }; + actionLink = { ...LINK, serviceType: "custom", webUrl: url }; + const prepared = await resolveAppIssueLink({ + ...OPTIONS, + appSlug: "custom", + url, + }); + await linkAppIssue(prepared); + expect( + new globalThis.URL(writes()[0]!.url).searchParams.get( + "expectedExternalIssueUrl" + ) + ).toBe(url); + }); + + test("requires an installation in the requested organization", async () => { + installation = { ...INSTALLATION, organization: { slug: "other-org" } }; + await expect(resolveAppIssueLink(OPTIONS)).rejects.toThrow( + "not installed in this organization" + ); + expect(writes()).toHaveLength(0); + }); + + test("finds the installed app on later SDK cursor pages", async () => { + const defaultFetch = globalThis.fetch; + globalThis.fetch = mockFetch(async (input, init) => { + const request = new Request(input, init); + const parsed = new globalThis.URL(request.url); + if (!parsed.pathname.endsWith("/sentry-app-installations/")) { + return defaultFetch(input, init); + } + calls.push(request); + if (parsed.searchParams.get("cursor") === "install-page-2") { + return json([INSTALLATION]); + } + return json([], 200, { + Link: '; rel="next"; results="true"; cursor="install-page-2"', + }); + }); + const prepared = await resolveAppIssueLink(OPTIONS); + expect(prepared.installationUuid).toBe(INSTALLATION.uuid); + const pages = calls.filter((request) => + request.url.includes("/sentry-app-installations/?") + ); + expect(pages).toHaveLength(1); + expect(pages[0]?.url).toBe( + "https://sentry.io/api/0/organizations/example-org/sentry-app-installations/?cursor=install-page-2" + ); + expect(writes()).toHaveLength(0); + }); + + test("propagates SDK installation errors without attempting the callback", async () => { + const defaultFetch = globalThis.fetch; + globalThis.fetch = mockFetch(async (input, init) => { + const request = new Request(input, init); + if (!request.url.includes("/sentry-app-installations/")) { + return defaultFetch(input, init); + } + calls.push(request); + return json({ detail: "Installation access denied" }, 403); + }); + await expect(resolveAppIssueLink(OPTIONS)).rejects.toBeInstanceOf(ApiError); + expect(writes()).toHaveLength(0); + }); + + test("rejects unsupported app link forms instead of using direct registration", async () => { + form = undefined; + await expect(resolveAppIssueLink(OPTIONS)).rejects.toThrow( + "does not expose" + ); + expect(writes()).toHaveLength(0); + }); + + test("does not allow user fields to override the action URI or target", async () => { + await expect( + resolveAppIssueLink({ ...OPTIONS, fields: { uri: "/create" } }) + ).rejects.toThrow("reserved app link field"); + await expect( + resolveAppIssueLink({ ...OPTIONS, fields: { issueId: "different-uuid" } }) + ).rejects.toThrow("conflicts"); + expect(writes()).toHaveLength(0); + }); + + test("uses a supplied ID to disambiguate matching labels without accepting another issue", async () => { + choices = [ + ["linear-uuid", "ENG-42: Fix"], + ["another-uuid", "ENG-42: Fix"], + ["wrong", "ENG-99: Other"], + ]; + const prepared = await resolveAppIssueLink({ + ...OPTIONS, + fields: { issueId: "linear-uuid" }, + }); + expect(prepared.fields).toEqual({ issueId: "linear-uuid" }); + await expect( + resolveAppIssueLink({ + ...OPTIONS, + fields: { issueId: "wrong" }, + }) + ).rejects.toThrow("conflicts"); + expect(writes()).toHaveLength(0); + }); + + test.each([ + "ENG-42", + "ENG-99", + ])("uses Linear choice ID %s before the label's key", async (id) => { + choices = [ + [ + id, + id === "ENG-42" + ? "ENG-99 mentioned in title" + : "ENG-42 misleading label", + ], + ]; + const result = resolveAppIssueLink({ ...OPTIONS, fields: { issueId: id } }); + if (id === "ENG-42") { + expect((await result).fields).toEqual({ issueId: id }); + } else { + await expect(result).rejects.toThrow("conflicts"); + } + expect(writes()).toHaveLength(0); + }); + + test("keeps the query guard separate from an app field with the same name", async () => { + form = { + ...FORM, + optional_fields: [{ name: "expectedExternalIssueUrl", type: "text" }], + }; + const prepared = await resolveAppIssueLink({ + ...OPTIONS, + fields: { expectedExternalIssueUrl: "provider-field" }, + }); + await linkAppIssue(prepared); + expect( + new globalThis.URL(writes()[0]!.url).searchParams.get( + "expectedExternalIssueUrl" + ) + ).toBe(URL); + expect(await writes()[0]?.json()).toMatchObject({ + expectedExternalIssueUrl: "provider-field", + }); + }); + + test("resolves dependent choices using validated field values", async () => { + form = { + ...FORM, + required_fields: [ + { ...FORM.required_fields[0], depends_on: ["team"] }, + { + name: "team", + type: "select", + choices: [["team-uuid", "Engineering"]], + }, + ], + }; + const prepared = await resolveAppIssueLink({ + ...OPTIONS, + fields: { team: "Engineering" }, + }); + expect(prepared.fields).toEqual({ + team: "team-uuid", + issueId: "linear-uuid", + }); + const search = calls.find((request) => + request.url.includes("external-requests") + ); + expect( + new globalThis.URL(search?.url ?? "").searchParams.get("dependentData") + ).toBe('{"team":"team-uuid"}'); + }); + + test("resolves a required dependency from the App's remote default", async () => { + form = { + ...FORM, + required_fields: [{ ...FORM.required_fields[0], depends_on: ["team"] }], + optional_fields: [{ name: "team", type: "select", uri: "/teams" }], + }; + const defaultFetch = globalThis.fetch; + globalThis.fetch = mockFetch((input, init) => { + const request = new Request(input, init); + const query = new globalThis.URL(request.url).searchParams; + if (query.get("uri") === "/teams") { + calls.push(request); + expect(query.has("query")).toBe(false); + return Promise.resolve( + json({ + choices: [["team-uuid", "Engineering"]], + defaultValue: "team-uuid", + }) + ); + } + return defaultFetch(input, init); + }); + const prepared = await resolveAppIssueLink(OPTIONS); + expect(prepared.fields).toEqual({ + team: "team-uuid", + issueId: "linear-uuid", + }); + const search = calls.find((request) => + new globalThis.URL(request.url).searchParams.has("dependentData") + ); + expect( + new globalThis.URL(search!.url).searchParams.get("dependentData") + ).toBe('{"team":"team-uuid"}'); + }); + + test.each([ + { defaultValue: "", fields: undefined }, + { defaultValue: "preset", fields: { note: "" } }, + ])("omits empty optional fields ($defaultValue)", async ({ + defaultValue, + fields, + }) => { + form = { + ...FORM, + optional_fields: [{ name: "note", type: "text", defaultValue }], + }; + expect((await resolveAppIssueLink({ ...OPTIONS, fields })).fields).toEqual({ + issueId: "linear-uuid", + }); + }); + + test("reports required fields rather than sending a partial form", async () => { + form = { + ...FORM, + required_fields: [ + ...FORM.required_fields, + { name: "team", type: "text" }, + ], + }; + await expect(resolveAppIssueLink(OPTIONS)).rejects.toThrow( + "--field team=VALUE" + ); + expect(writes()).toHaveLength(0); + }); + + test.each([ + undefined, + { team: "" }, + ])("requires missing or empty dependencies (%j)", async (fields) => { + form = { + ...FORM, + required_fields: [ + { ...FORM.required_fields[0], depends_on: ["team"] }, + { name: "team", type: "text" }, + ], + }; + await expect(resolveAppIssueLink({ ...OPTIONS, fields })).rejects.toThrow( + "Missing app link fields: --field team=VALUE" + ); + expect( + calls.some((request) => request.url.includes("external-requests")) + ).toBe(false); + expect(writes()).toHaveLength(0); + }); + + test("distinguishes actual dependency cycles from missing values", async () => { + form = { + ...FORM, + required_fields: [ + { ...FORM.required_fields[0], depends_on: ["team"] }, + { name: "team", type: "text", depends_on: ["issueId"] }, + ], + }; + await expect( + resolveAppIssueLink({ + ...OPTIONS, + fields: { team: "Engineering", issueId: "ENG-42" }, + }) + ).rejects.toThrow("App link fields have circular dependencies"); + expect(writes()).toHaveLength(0); + }); + + test("accepts numeric labels in the app's static select options", async () => { + form = { + ...FORM, + required_fields: [ + ...FORM.required_fields, + { name: "team", type: "select", options: [["team-uuid", 42]] }, + ], + }; + const prepared = await resolveAppIssueLink({ + ...OPTIONS, + fields: { team: "42" }, + }); + expect(prepared.fields).toEqual({ + team: "team-uuid", + issueId: "linear-uuid", + }); + expect(writes()).toHaveLength(0); + }); +}); + +describe("list and 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..47ca75a5c --- /dev/null +++ b/packages/cli/test/lib/issue-links.test.ts @@ -0,0 +1,190 @@ +/** 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); + 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(); + }); +}); From effec4aba47e4a05f3cb97d2041837a09fb62b7d Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 2 Oct 2026 19:52:58 +0200 Subject: [PATCH 2/5] fix(cli): preserve app issue URL in link guard Use the same requested URL in the app form and the backend guard, without URL.href normalization changing its spelling. Keep using the stored canonical URL when the association already exists. Co-Authored-By: GPT-6 --- packages/cli/src/lib/api/issue-app-links.ts | 5 ++--- packages/cli/test/lib/api/issue-app-links.test.ts | 10 ++++++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/lib/api/issue-app-links.ts b/packages/cli/src/lib/api/issue-app-links.ts index 3fd3e1b3f..a37ea8f26 100644 --- a/packages/cli/src/lib/api/issue-app-links.ts +++ b/packages/cli/src/lib/api/issue-app-links.ts @@ -142,7 +142,7 @@ function parseTarget(raw: string) { const identity = linear ? `linear.app/${linear[1]?.toLowerCase()}/${linear[2]?.toUpperCase()}` : `${url.origin}${url.pathname.replace(TRAILING_SLASHES, "")}${url.search}${url.hash}`; - return { url: url.href, identity, key: linear?.[2]?.toUpperCase() }; + return { identity, key: linear?.[2]?.toUpperCase() }; } /** Match a stored target by URL, ignoring Linear title suffixes; reject ambiguous matches. */ @@ -681,8 +681,7 @@ export async function linkAppIssue( ...getSdkConfig(getControlSiloUrl()), path: { uuid: prepared.installationUuid }, query: { - expectedExternalIssueUrl: - prepared.existing?.webUrl ?? parseTarget(prepared.url).url, + expectedExternalIssueUrl: prepared.existing?.webUrl ?? prepared.url, }, body: { ...prepared.fields, diff --git a/packages/cli/test/lib/api/issue-app-links.test.ts b/packages/cli/test/lib/api/issue-app-links.test.ts index f23d85fb2..e6d55d143 100644 --- a/packages/cli/test/lib/api/issue-app-links.test.ts +++ b/packages/cli/test/lib/api/issue-app-links.test.ts @@ -251,8 +251,13 @@ describe("app issue-link action", () => { expect(writes()).toHaveLength(1); }); - test("preserves query and fragment identity in the backend URL guard", async () => { - const url = "https://tracker.example/view?id=42#issue"; + test.each([ + "https://tracker.example/view?id=42#issue", + "https://TRACKER.example/tasks/42", + "https://tracker.example:443/tasks/42", + "https://tracker.example", + "https://tracker.example/tasks/42#some text", + ])("preserves the requested URL in the form and backend guard: %s", async (url) => { installation = { ...INSTALLATION, app: { ...INSTALLATION.app, slug: "custom" }, @@ -265,6 +270,7 @@ describe("app issue-link action", () => { url, }); await linkAppIssue(prepared); + expect(await writes()[0]!.json()).toMatchObject({ url }); expect( new globalThis.URL(writes()[0]!.url).searchParams.get( "expectedExternalIssueUrl" From 3321a959cca712ef69fd65f218987b444b9a50cd Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 5 Oct 2026 11:59:43 +0200 Subject: [PATCH 3/5] fix(cli): accept app issue IDs in text fields Co-Authored-By: GPT-6 --- packages/cli/src/lib/api/issue-app-links.ts | 11 ++-- .../cli/test/lib/api/issue-app-links.test.ts | 56 ++++++++++++++++--- 2 files changed, 55 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/lib/api/issue-app-links.ts b/packages/cli/src/lib/api/issue-app-links.ts index a37ea8f26..36da9f9b4 100644 --- a/packages/cli/src/lib/api/issue-app-links.ts +++ b/packages/cli/src/lib/api/issue-app-links.ts @@ -606,13 +606,14 @@ async function resolveFieldValue({ const isTarget = field === targetField; const targetKey = isTarget ? parseTarget(options.url).key : undefined; const supplied = isTarget ? options.fields?.[field.name] : undefined; - // Generic selects can use provider IDs that cannot be inferred from the URL. + // 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 ?? - (field.type === "select" ? supplied : undefined) ?? - options.url; + query = targetKey ?? suppliedTarget ?? options.url; } let value: string | number | undefined = query; if (field.type === "select") { diff --git a/packages/cli/test/lib/api/issue-app-links.test.ts b/packages/cli/test/lib/api/issue-app-links.test.ts index e6d55d143..932fa3758 100644 --- a/packages/cli/test/lib/api/issue-app-links.test.ts +++ b/packages/cli/test/lib/api/issue-app-links.test.ts @@ -214,9 +214,11 @@ describe("app issue-link action", () => { }); test.each([ - "static", - "search", - ])("links an explicit generic issue ID from %s choices", async (source) => { + { 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, @@ -228,10 +230,7 @@ describe("app issue-link action", () => { required_fields: [ { name: "task_id", - type: "select", - ...(source === "static" - ? { options: choices } - : { uri: "/sentry/tasks" }), + ...field, }, ], }; @@ -248,9 +247,52 @@ describe("app issue-link action", () => { 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([ "https://tracker.example/view?id=42#issue", "https://TRACKER.example/tasks/42", From 8656afe841a545039a53ad18910ef5dd43704ed2 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 5 Oct 2026 13:15:32 +0200 Subject: [PATCH 4/5] refactor(cli): centralize issue link app selection Co-Authored-By: GPT-6 --- packages/cli/src/lib/api/issue-app-links.ts | 24 ++++++++----------- packages/cli/src/lib/issue-links.ts | 8 ++++++- .../cli/test/lib/api/issue-app-links.test.ts | 8 ++++++- packages/cli/test/lib/issue-links.test.ts | 5 +++- 4 files changed, 28 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/lib/api/issue-app-links.ts b/packages/cli/src/lib/api/issue-app-links.ts index 36da9f9b4..67a4eca81 100644 --- a/packages/cli/src/lib/api/issue-app-links.ts +++ b/packages/cli/src/lib/api/issue-app-links.ts @@ -79,8 +79,8 @@ export type ResolveAppIssueLinkOptions = { issueId: string; /** Existing external resource URL. */ url: string; - /** Installed app slug; defaults to linear for a linear.app issue URL. */ - appSlug?: 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. */ @@ -455,7 +455,8 @@ function addDependencies(pending: Field[], fields: Field[]): void { async function resolveFields( options: ResolveAppIssueLinkOptions, form: LinkForm, - installationUuid: string + installationUuid: string, + targetKey: string | undefined ): Promise> { const required = form.required_fields ?? []; const fields = [...required, ...(form.optional_fields ?? [])]; @@ -491,6 +492,7 @@ async function resolveFields( values[field.name] = await resolveFieldValue({ field, targetField, + targetKey: field === targetField ? targetKey : undefined, values, options, installationUuid, @@ -593,18 +595,19 @@ function validateFieldValue( 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 targetKey = isTarget ? parseTarget(options.url).key : undefined; const supplied = isTarget ? options.fields?.[field.name] : undefined; // Generic targets can use provider IDs that cannot be inferred from the URL. const suppliedTarget = @@ -646,14 +649,8 @@ async function resolveFieldValue({ export async function resolveAppIssueLink( options: ResolveAppIssueLinkOptions ): Promise { - const target = parseTarget(options.url); - const appSlug = options.appSlug ?? (target.key ? "linear" : undefined); - if (!appSlug) { - throw new ValidationError( - "Specify --app for this external issue URL", - "app" - ); - } + const { key } = parseTarget(options.url); + const { appSlug } = options; const existing = checkExisting( await listAppIssueLinks(options.orgSlug, options.issueId), options.url, @@ -668,7 +665,7 @@ export async function resolveAppIssueLink( url: options.url, installationUuid: installation.uuid, uri: form.uri, - fields: await resolveFields(options, form, installation.uuid), + fields: await resolveFields(options, form, installation.uuid, key), existing, }; } @@ -677,7 +674,6 @@ export async function resolveAppIssueLink( export async function linkAppIssue( prepared: PreparedAppIssueLink ): Promise<{ link: AppIssueLink; changed: boolean }> { - validateUri(prepared.uri); const result = await executeSentryAppInstallationExternalIssueAction({ ...getSdkConfig(getControlSiloUrl()), path: { uuid: prepared.installationUuid }, diff --git a/packages/cli/src/lib/issue-links.ts b/packages/cli/src/lib/issue-links.ts index 0be09bd37..1fccada1f 100644 --- a/packages/cli/src/lib/issue-links.ts +++ b/packages/cli/src/lib/issue-links.ts @@ -96,6 +96,12 @@ function selectSentryApp( "--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." @@ -127,7 +133,7 @@ async function planLink( appSlug: string | undefined ): Promise { if (appSlug) { - const prepared = await resolveAppIssueLink(options); + const prepared = await resolveAppIssueLink({ ...options, appSlug }); return { linked: Boolean(prepared.existing), preview: { diff --git a/packages/cli/test/lib/api/issue-app-links.test.ts b/packages/cli/test/lib/api/issue-app-links.test.ts index 932fa3758..26ba65f9a 100644 --- a/packages/cli/test/lib/api/issue-app-links.test.ts +++ b/packages/cli/test/lib/api/issue-app-links.test.ts @@ -18,7 +18,13 @@ useTestConfigDir("issue-app-links-"); const ORG = "example-org"; const ISSUE = "123"; const URL = "https://linear.app/example/issue/ENG-42/fix-crash"; -const OPTIONS = { orgSlug: ORG, issueId: ISSUE, url: URL, projectId: "77" }; +const OPTIONS = { + orgSlug: ORG, + issueId: ISSUE, + url: URL, + projectId: "77", + appSlug: "linear", +}; const LINK: AppIssueLink = { id: "99", issueId: ISSUE, diff --git a/packages/cli/test/lib/issue-links.test.ts b/packages/cli/test/lib/issue-links.test.ts index 47ca75a5c..34582fb64 100644 --- a/packages/cli/test/lib/issue-links.test.ts +++ b/packages/cli/test/lib/issue-links.test.ts @@ -88,7 +88,10 @@ describe("external issue associations", () => { test("Linear routes through the app workflow with project context", async () => { const appOptions = { ...options, url: appLink.webUrl, projectId: "456" }; const result = await linkExternalIssue(appOptions); - expect(resolveAppIssueLink).toHaveBeenCalledWith(appOptions); + expect(resolveAppIssueLink).toHaveBeenCalledWith({ + ...appOptions, + appSlug: "linear", + }); expect(linkNativeIssue).not.toHaveBeenCalled(); expect(result.externalIssue).toEqual({ id: appLink.id, From f007ee2560b6ac1eeaa6eeeb22a21043abff57a0 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 5 Oct 2026 15:23:01 +0200 Subject: [PATCH 5/5] fix(cli): ignore empty issue link target overrides Co-Authored-By: GPT-6 --- packages/cli/src/lib/api/issue-app-links.ts | 4 +++- .../cli/test/lib/api/issue-app-links.test.ts | 24 ++++++++++++------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/lib/api/issue-app-links.ts b/packages/cli/src/lib/api/issue-app-links.ts index 67a4eca81..4378dac84 100644 --- a/packages/cli/src/lib/api/issue-app-links.ts +++ b/packages/cli/src/lib/api/issue-app-links.ts @@ -608,7 +608,9 @@ async function resolveFieldValue({ installationUuid: string; }): Promise { const isTarget = field === targetField; - const supplied = isTarget ? options.fields?.[field.name] : undefined; + 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) diff --git a/packages/cli/test/lib/api/issue-app-links.test.ts b/packages/cli/test/lib/api/issue-app-links.test.ts index 26ba65f9a..0d4e2d991 100644 --- a/packages/cli/test/lib/api/issue-app-links.test.ts +++ b/packages/cli/test/lib/api/issue-app-links.test.ts @@ -113,8 +113,11 @@ function writes(): Request[] { } describe("app issue-link action", () => { - test("resolves Linear key to UUID read-only, then sends the schema URI and fields top-level", async () => { - const prepared = await resolveAppIssueLink(OPTIONS); + 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) => @@ -300,12 +303,16 @@ describe("app issue-link action", () => { }); test.each([ - "https://tracker.example/view?id=42#issue", - "https://TRACKER.example/tasks/42", - "https://tracker.example:443/tasks/42", - "https://tracker.example", - "https://tracker.example/tasks/42#some text", - ])("preserves the requested URL in the form and backend guard: %s", async (url) => { + { 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" }, @@ -316,6 +323,7 @@ describe("app issue-link action", () => { ...OPTIONS, appSlug: "custom", url, + fields, }); await linkAppIssue(prepared); expect(await writes()[0]!.json()).toMatchObject({ url });