Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/cli-docs/src/content/docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ toolkit/
│ │ │ ├── dsn/ # list
│ │ │ ├── event/ # list, send, view
│ │ │ ├── feedback/ # list, resolve, spam, unresolve, view
│ │ │ ├── issue/ # archive, events, explain, link, list, merge, plan, resolve, unresolve, view
│ │ │ ├── issue/ # archive, events, explain, link, list, merge, plan, resolve, unlink, unresolve, view
│ │ │ ├── local/ # run, serve
│ │ │ ├── log/ # list, view
│ │ │ ├── monitor/ # list, run
Expand Down
34 changes: 32 additions & 2 deletions apps/cli-docs/src/fragments/commands/issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,13 +352,13 @@ sentry issue link my-org/FRONT-123 https://github.com/example/app/issues/42 --js
`--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.
already links this issue to a different resource must be unlinked first.
App callbacks must return the exact supplied URL; use the issue URL copied from
the tracker, including its title suffix. A mismatch fails without saving the link.

GitHub and GitHub Enterprise pull requests are stored as external references.
Their `/pull/NUMBER` and `/issues/NUMBER` URLs identify the same resource for
duplicate detection. Linking a PR does not mark it as a fix or
duplicate detection and unlinking. Linking a PR does not mark it as a fix or
resolve the Sentry issue.

This command does not create a tracker issue or link a commit. Existing
Expand All @@ -372,3 +372,33 @@ 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.

### Unlink an external issue

Remove an association without deleting either issue:

```bash
sentry issue unlink FRONT-123 https://github.com/example/app/issues/42
sentry issue unlink FRONT-123 https://github.com/example/app/pull/43 --yes
sentry issue unlink my-org/FRONT-123 https://example.atlassian.net/browse/APP-42 --yes
sentry issue unlink FRONT-123 https://linear.app/example/issue/APP-42/fix-error --dry-run
```

Use `--yes` for non-interactive execution. `--dry-run` shows whether the link
exists without removing it. If the association is already absent, the command
succeeds with `changed: false`.

Unlink matches the URL against stored associations and sends Sentry's internal
link ID to the existing DELETE endpoint. It does not require fetching the ticket
from the remote tracker, so a deleted remote ticket can still be unlinked.
For a custom Sentry App, select it with `--app <slug>`; unlink does not require
the app to expose a link form. Use `--integration <id>` to disambiguate native
integration links.

#### Unlink permissions

Unlink requires **`event:write` and access to the Sentry project**; `event:admin`
is also accepted. The organization's “Let Members Delete Events” setting does
not restrict unlinking on updated Sentry versions.

Granting a token more scopes does not override project-access policy.
1 change: 1 addition & 0 deletions packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ Manage Sentry issues
- `sentry issue archive <issue>` — Archive (ignore) an issue
- `sentry issue merge <issue...>` — Merge 2+ issues into a single canonical group
- `sentry issue link <issue> <url>` — Link an existing external issue
- `sentry issue unlink <issue> <url>` — Unlink an external issue

→ Full flags and examples: `references/issue.md`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -392,4 +392,24 @@ sentry issue link my-org/FRONT-123 https://github.com/example/app/issues/42 --dr
sentry issue link my-org/FRONT-123 https://github.com/example/app/issues/42 --json
```

### `sentry issue unlink <issue> <url>`

Unlink an external issue

**Flags:**
- `--integration <value> - Native integration ID, when multiple installations match`
- `--app <value> - Sentry App slug (automatically detected for Linear URLs)`
- `-y, --yes - Skip confirmation prompt`
- `-f, --force - Force the operation without confirmation`
- `-n, --dry-run - Show what would happen without making changes`

**Examples:**

```bash
sentry issue unlink FRONT-123 https://github.com/example/app/issues/42
sentry issue unlink FRONT-123 https://github.com/example/app/pull/43 --yes
sentry issue unlink my-org/FRONT-123 https://example.atlassian.net/browse/APP-42 --yes
sentry issue unlink FRONT-123 https://linear.app/example/issue/APP-42/fix-error --dry-run
```

All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags.
5 changes: 4 additions & 1 deletion packages/cli/src/commands/issue/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { listCommand } from "./list.js";
import { mergeCommand } from "./merge.js";
import { planCommand } from "./plan.js";
import { resolveCommand } from "./resolve.js";
import { unlinkCommand } from "./unlink.js";
import { unresolveCommand } from "./unresolve.js";
import { viewCommand } from "./view.js";

Expand All @@ -22,6 +23,7 @@ export const issueRoute = buildRouteMap({
archive: archiveCommand,
merge: mergeCommand,
link: linkCommand,
unlink: unlinkCommand,
},
// `reopen` is a friendlier synonym for `unresolve`, `ignore` for `archive`.
aliases: { reopen: "unresolve", ignore: "archive" },
Expand All @@ -40,7 +42,8 @@ export const issueRoute = buildRouteMap({
" 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" +
" link Link an existing external issue\n\n" +
" link Link an existing external issue\n" +
" unlink Remove an external issue link\n\n" +
"Magic selectors (available for view, events, explain, plan, resolve, unresolve, archive):\n" +
" @latest Most recent unresolved issue\n" +
" @most_frequent Issue with the highest event frequency\n\n" +
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/issue/link-utils.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/** Arguments for linking external issues. */
/** Shared arguments for external issue association commands. */

import { ValidationError } from "../../lib/errors.js";
import { issueIdPositional } from "./utils.js";

/** Required source issue and existing external resource URL for linking. */
/** Required source issue and existing external resource URL for link and unlink. */
export const EXTERNAL_ISSUE_POSITIONALS = {
kind: "tuple",
parameters: [
Expand Down
75 changes: 75 additions & 0 deletions packages/cli/src/commands/issue/unlink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/** Remove an external issue association without deleting the external issue. */

import type { SentryContext } from "../../context.js";
import { formatIssueLinkResult } from "../../lib/formatters/issue-links.js";
import { CommandOutput } from "../../lib/formatters/output.js";
import { unlinkExternalIssue } from "../../lib/issue-links.js";
import {
buildDeleteCommand,
confirmByTyping,
isConfirmationBypassed,
} from "../../lib/mutate-command.js";
import {
EXTERNAL_ISSUE_FLAGS,
EXTERNAL_ISSUE_POSITIONALS,
} from "./link-utils.js";
import { resolveOrgAndIssueId } from "./utils.js";

type UnlinkFlags = {
readonly integration?: string;
readonly app?: string;
readonly "dry-run": boolean;
readonly yes: boolean;
readonly force: boolean;
};

export const unlinkCommand = buildDeleteCommand({
docs: {
brief: "Unlink an external issue",
fullDescription:
"Remove an external tracker issue or GitHub pull request reference from a Sentry issue.\n" +
"This does not delete the external issue or change the Sentry issue's status.\n\n" +
"Requires event:write and access to the Sentry project.\n" +
"Older Sentry versions may still require event:admin.\n\n" +
"Examples:\n" +
" sentry issue unlink FRONT-123 https://github.com/example/app/issues/42\n" +
" sentry issue unlink FRONT-123 https://github.com/example/app/pull/43 --yes\n" +
" sentry issue unlink my-org/FRONT-123 https://example.atlassian.net/browse/APP-42 --yes\n" +
" sentry issue unlink FRONT-123 https://linear.app/example/issue/APP-42/fix-error --dry-run",
},
output: { human: formatIssueLinkResult },
parameters: {
positional: EXTERNAL_ISSUE_POSITIONALS,
flags: EXTERNAL_ISSUE_FLAGS,
},
async *func(
this: SentryContext,
flags: UnlinkFlags,
issueArg: string,
url: string
) {
const { org, issueId } = await resolveOrgAndIssueId({
issueArg,
cwd: this.cwd,
command: "unlink",
});
if (!(flags["dry-run"] || isConfirmationBypassed(flags))) {
const confirmed = await confirmByTyping(
issueArg,
`Type '${issueArg}' to unlink ${url}:`
);
if (!confirmed) {
return { hint: "Cancelled." };
}
}
const result = await unlinkExternalIssue({
orgSlug: org,
issueId,
url,
integrationId: flags.integration,
appSlug: flags.app,
dryRun: flags["dry-run"],
});
yield new CommandOutput(result);
},
});
25 changes: 25 additions & 0 deletions packages/cli/src/lib/api/issue-app-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import {
deleteOrganizationIssueExternalIssue,
executeSentryAppInstallationExternalIssueAction,
type GroupExternalIssueResponse,
getSentryAppInstallationExternalRequestOptions,
Expand Down Expand Up @@ -694,3 +695,27 @@ export async function linkAppIssue(
changed: result.response?.status === 201,
};
}

/** Remove only the selected local app association, using event:write or event:admin. */
export async function unlinkAppIssueLink(
orgSlug: string,
issueId: string,
linkId: string
): Promise<void> {
if (!isAllDigits(linkId)) {
throw new ValidationError(
"App unlink requires the numeric association ID",
"linkId"
);
}
requireIssueTarget(orgSlug, issueId);
const result = await deleteOrganizationIssueExternalIssue({
...getSdkConfig(await resolveOrgRegion(orgSlug)),
path: {
organization_id_or_slug: orgSlug,
issue_id: issueId,
external_issue_id: linkId,
},
});
unwrapResult(result, "Failed to unlink app issue");
}
33 changes: 33 additions & 0 deletions packages/cli/src/lib/api/issue-integrations.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/** Existing issue-tracker links through Sentry's native integrations. */
import {
deleteOrganizationIssueIntegration,
type ExternalIssueLinkResponse,
type IssueIntegrationsResponse,
listOrganizationIssueIntegrations,
Expand Down Expand Up @@ -239,6 +240,14 @@ function flattenLinks(integrations: NativeIntegration[]): NativeIssueLink[] {
);
}

/** List every link, including links whose provider is no longer supported. */
export async function listNativeIssueLinks(
orgSlug: string,
issueId: string
): Promise<NativeIssueLink[]> {
return flattenLinks(await listIntegrations(orgSlug, issueId));
}

function matchesNativeUrl(link: NativeIssueLink, target: URL): boolean {
const existing = storedUrl(link.url);
if (!existing) {
Expand Down Expand Up @@ -377,3 +386,27 @@ export async function linkNativeIssue(
changed: result.response.status === 201,
};
}

/** The DELETE identifier is Sentry's ExternalIssue ID, not the provider key. */
export async function unlinkNativeIssueLink(
orgSlug: string,
issueId: string,
link: NativeIssueLink
): Promise<void> {
const externalIssue = Number(link.id);
if (!Number.isSafeInteger(externalIssue) || externalIssue <= 0) {
throw new ValidationError(
"External issue link ID must be a safe positive integer."
);
}
const result = await deleteOrganizationIssueIntegration({
...getSdkConfig(await resolveOrgRegion(orgSlug)),
path: {
organization_id_or_slug: orgSlug,
issue_id: issueId,
integration_id: link.integrationId,
},
query: { externalIssue },
});
unwrapResult<void>(result, "Failed to unlink external issue");
}
1 change: 1 addition & 0 deletions packages/cli/src/lib/complete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export const ORG_PROJECT_COMMANDS = new Set([
"issue plan",
"issue resolve",
"issue link",
"issue unlink",
"issue unresolve",
"issue archive",
"issue merge",
Expand Down
19 changes: 13 additions & 6 deletions packages/cli/src/lib/formatters/issue-links.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/** Human-readable results for linking existing external issues. */
/** Human-readable results for linking and unlinking existing external issues. */

import type { ExternalIssueLinkResult } from "../issue-links.js";
import { renderMarkdown, safeCodeSpan } from "./markdown.js";
Expand All @@ -8,15 +8,22 @@ export function formatIssueLinkResult(result: ExternalIssueLinkResult): string {
const external = safeCodeSpan(result.externalIssue.url);
const issue = safeCodeSpan(`${result.org}/${result.issueId}`);
if (result.dryRun) {
const needsChange =
result.action === "link" ? !result.linked : result.linked;
return renderMarkdown(
result.linked
? `Already linked: ${external}. (dry run)`
: `Would link ${external} to ${issue}. (dry run)`
needsChange
? `Would ${result.action} ${external} ${result.action === "link" ? "to" : "from"} ${issue}. (dry run)`
: `Already ${result.linked ? "linked" : "unlinked"}: ${external}. (dry run)`
);
}
if (!result.changed) {
return renderMarkdown(
`Already ${result.linked ? "linked" : "unlinked"}: ${external}.`
);
}
return renderMarkdown(
result.changed
result.linked
? `Linked ${external} to ${issue}.`
: `Already linked: ${external}.`
: `Unlinked ${external} from ${issue}. The external issue was not deleted.`
);
}
Loading
Loading