Skip to content

Commit 0c61a96

Browse files
betegoncodex
andcommitted
feat(cli): add issue unlink command
Remove stored external issue associations through @sentry/api, reusing link discovery and URL matching. Preserve standard deletion confirmation, dry-run previews, and successful no-ops when an association is absent. Co-Authored-By: GPT-6 <noreply@openai.com>
1 parent f007ee2 commit 0c61a96

18 files changed

Lines changed: 663 additions & 24 deletions

File tree

‎apps/cli-docs/src/content/docs/contributing.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ toolkit/
6868
│ │ │ ├── dsn/ # list
6969
│ │ │ ├── event/ # list, send, view
7070
│ │ │ ├── feedback/ # list, resolve, spam, unresolve, view
71-
│ │ │ ├── issue/ # archive, events, explain, link, list, merge, plan, resolve, unresolve, view
71+
│ │ │ ├── issue/ # archive, events, explain, link, list, merge, plan, resolve, unlink, unresolve, view
7272
│ │ │ ├── local/ # run, serve
7373
│ │ │ ├── log/ # list, view
7474
│ │ │ ├── monitor/ # list, run

‎apps/cli-docs/src/fragments/commands/issue.md‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -352,13 +352,13 @@ sentry issue link my-org/FRONT-123 https://github.com/example/app/issues/42 --js
352352
`--dry-run` discovers the integration and prepares the link without submitting a
353353
write. The provider validates the remote issue when the link is submitted.
354354
An existing matching link succeeds with `changed: false`. A Sentry App that
355-
already links this issue to a different resource must be unlinked in Sentry first.
355+
already links this issue to a different resource must be unlinked first.
356356
App callbacks must return the exact supplied URL; use the issue URL copied from
357357
the tracker, including its title suffix. A mismatch fails without saving the link.
358358

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

364364
This command does not create a tracker issue or link a commit. Existing
@@ -372,3 +372,33 @@ OAuth login. If an older OAuth session lacks the
372372
requested scopes, the CLI offers reauthorization after a permission error.
373373
Use `sentry auth login` to request the current default scopes. Environment tokens must
374374
be updated separately.
375+
376+
### Unlink an external issue
377+
378+
Remove an association without deleting either issue:
379+
380+
```bash
381+
sentry issue unlink FRONT-123 https://github.com/example/app/issues/42
382+
sentry issue unlink FRONT-123 https://github.com/example/app/pull/43 --yes
383+
sentry issue unlink my-org/FRONT-123 https://example.atlassian.net/browse/APP-42 --yes
384+
sentry issue unlink FRONT-123 https://linear.app/example/issue/APP-42/fix-error --dry-run
385+
```
386+
387+
Use `--yes` for non-interactive execution. `--dry-run` shows whether the link
388+
exists without removing it. If the association is already absent, the command
389+
succeeds with `changed: false`.
390+
391+
Unlink matches the URL against stored associations and sends Sentry's internal
392+
link ID to the existing DELETE endpoint. It does not require fetching the ticket
393+
from the remote tracker, so a deleted remote ticket can still be unlinked.
394+
For a custom Sentry App, select it with `--app <slug>`; unlink does not require
395+
the app to expose a link form. Use `--integration <id>` to disambiguate native
396+
integration links.
397+
398+
#### Unlink permissions
399+
400+
Unlink requires **`event:write` and access to the Sentry project**; `event:admin`
401+
is also accepted. The organization's “Let Members Delete Events” setting does
402+
not restrict unlinking on updated Sentry versions.
403+
404+
Granting a token more scopes does not override project-access policy.

‎packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,7 @@ Manage Sentry issues
418418
- `sentry issue archive <issue>` — Archive (ignore) an issue
419419
- `sentry issue merge <issue...>` — Merge 2+ issues into a single canonical group
420420
- `sentry issue link <issue> <url>` — Link an existing external issue
421+
- `sentry issue unlink <issue> <url>` — Unlink an external issue
421422

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

‎packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,4 +392,24 @@ sentry issue link my-org/FRONT-123 https://github.com/example/app/issues/42 --dr
392392
sentry issue link my-org/FRONT-123 https://github.com/example/app/issues/42 --json
393393
```
394394

395+
### `sentry issue unlink <issue> <url>`
396+
397+
Unlink an external issue
398+
399+
**Flags:**
400+
- `--integration <value> - Native integration ID, when multiple installations match`
401+
- `--app <value> - Sentry App slug (automatically detected for Linear URLs)`
402+
- `-y, --yes - Skip confirmation prompt`
403+
- `-f, --force - Force the operation without confirmation`
404+
- `-n, --dry-run - Show what would happen without making changes`
405+
406+
**Examples:**
407+
408+
```bash
409+
sentry issue unlink FRONT-123 https://github.com/example/app/issues/42
410+
sentry issue unlink FRONT-123 https://github.com/example/app/pull/43 --yes
411+
sentry issue unlink my-org/FRONT-123 https://example.atlassian.net/browse/APP-42 --yes
412+
sentry issue unlink FRONT-123 https://linear.app/example/issue/APP-42/fix-error --dry-run
413+
```
414+
395415
All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags.

‎packages/cli/src/commands/issue/index.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { listCommand } from "./list.js";
77
import { mergeCommand } from "./merge.js";
88
import { planCommand } from "./plan.js";
99
import { resolveCommand } from "./resolve.js";
10+
import { unlinkCommand } from "./unlink.js";
1011
import { unresolveCommand } from "./unresolve.js";
1112
import { viewCommand } from "./view.js";
1213

@@ -22,6 +23,7 @@ export const issueRoute = buildRouteMap({
2223
archive: archiveCommand,
2324
merge: mergeCommand,
2425
link: linkCommand,
26+
unlink: unlinkCommand,
2527
},
2628
// `reopen` is a friendlier synonym for `unresolve`, `ignore` for `archive`.
2729
aliases: { reopen: "unresolve", ignore: "archive" },
@@ -40,7 +42,8 @@ export const issueRoute = buildRouteMap({
4042
" unresolve Reopen a resolved issue (alias: reopen)\n" +
4143
" archive Archive/ignore an issue (alias: ignore)\n" +
4244
" merge Merge 2+ issues into a single group\n" +
43-
" link Link an existing external issue\n\n" +
45+
" link Link an existing external issue\n" +
46+
" unlink Remove an external issue link\n\n" +
4447
"Magic selectors (available for view, events, explain, plan, resolve, unresolve, archive):\n" +
4548
" @latest Most recent unresolved issue\n" +
4649
" @most_frequent Issue with the highest event frequency\n\n" +

‎packages/cli/src/commands/issue/link-utils.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
/** Arguments for linking external issues. */
1+
/** Shared arguments for external issue association commands. */
22

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

6-
/** Required source issue and existing external resource URL for linking. */
6+
/** Required source issue and existing external resource URL for link and unlink. */
77
export const EXTERNAL_ISSUE_POSITIONALS = {
88
kind: "tuple",
99
parameters: [
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/** Remove an external issue association without deleting the external issue. */
2+
3+
import type { SentryContext } from "../../context.js";
4+
import { formatIssueLinkResult } from "../../lib/formatters/issue-links.js";
5+
import { CommandOutput } from "../../lib/formatters/output.js";
6+
import { unlinkExternalIssue } from "../../lib/issue-links.js";
7+
import {
8+
buildDeleteCommand,
9+
confirmByTyping,
10+
isConfirmationBypassed,
11+
} from "../../lib/mutate-command.js";
12+
import {
13+
EXTERNAL_ISSUE_FLAGS,
14+
EXTERNAL_ISSUE_POSITIONALS,
15+
} from "./link-utils.js";
16+
import { resolveOrgAndIssueId } from "./utils.js";
17+
18+
type UnlinkFlags = {
19+
readonly integration?: string;
20+
readonly app?: string;
21+
readonly "dry-run": boolean;
22+
readonly yes: boolean;
23+
readonly force: boolean;
24+
};
25+
26+
export const unlinkCommand = buildDeleteCommand({
27+
docs: {
28+
brief: "Unlink an external issue",
29+
fullDescription:
30+
"Remove an external tracker issue or GitHub pull request reference from a Sentry issue.\n" +
31+
"This does not delete the external issue or change the Sentry issue's status.\n\n" +
32+
"Requires event:write and access to the Sentry project.\n" +
33+
"Older Sentry versions may still require event:admin.\n\n" +
34+
"Examples:\n" +
35+
" sentry issue unlink FRONT-123 https://github.com/example/app/issues/42\n" +
36+
" sentry issue unlink FRONT-123 https://github.com/example/app/pull/43 --yes\n" +
37+
" sentry issue unlink my-org/FRONT-123 https://example.atlassian.net/browse/APP-42 --yes\n" +
38+
" sentry issue unlink FRONT-123 https://linear.app/example/issue/APP-42/fix-error --dry-run",
39+
},
40+
output: { human: formatIssueLinkResult },
41+
parameters: {
42+
positional: EXTERNAL_ISSUE_POSITIONALS,
43+
flags: EXTERNAL_ISSUE_FLAGS,
44+
},
45+
async *func(
46+
this: SentryContext,
47+
flags: UnlinkFlags,
48+
issueArg: string,
49+
url: string
50+
) {
51+
const { org, issueId } = await resolveOrgAndIssueId({
52+
issueArg,
53+
cwd: this.cwd,
54+
command: "unlink",
55+
});
56+
if (!(flags["dry-run"] || isConfirmationBypassed(flags))) {
57+
const confirmed = await confirmByTyping(
58+
issueArg,
59+
`Type '${issueArg}' to unlink ${url}:`
60+
);
61+
if (!confirmed) {
62+
return { hint: "Cancelled." };
63+
}
64+
}
65+
const result = await unlinkExternalIssue({
66+
orgSlug: org,
67+
issueId,
68+
url,
69+
integrationId: flags.integration,
70+
appSlug: flags.app,
71+
dryRun: flags["dry-run"],
72+
});
73+
yield new CommandOutput(result);
74+
},
75+
});

‎packages/cli/src/lib/api/issue-app-links.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55

66
import {
7+
deleteOrganizationIssueExternalIssue,
78
executeSentryAppInstallationExternalIssueAction,
89
type GroupExternalIssueResponse,
910
getSentryAppInstallationExternalRequestOptions,
@@ -694,3 +695,27 @@ export async function linkAppIssue(
694695
changed: result.response?.status === 201,
695696
};
696697
}
698+
699+
/** Remove only the selected local app association, using event:write or event:admin. */
700+
export async function unlinkAppIssueLink(
701+
orgSlug: string,
702+
issueId: string,
703+
linkId: string
704+
): Promise<void> {
705+
if (!isAllDigits(linkId)) {
706+
throw new ValidationError(
707+
"App unlink requires the numeric association ID",
708+
"linkId"
709+
);
710+
}
711+
requireIssueTarget(orgSlug, issueId);
712+
const result = await deleteOrganizationIssueExternalIssue({
713+
...getSdkConfig(await resolveOrgRegion(orgSlug)),
714+
path: {
715+
organization_id_or_slug: orgSlug,
716+
issue_id: issueId,
717+
external_issue_id: linkId,
718+
},
719+
});
720+
unwrapResult(result, "Failed to unlink app issue");
721+
}

‎packages/cli/src/lib/api/issue-integrations.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/** Existing issue-tracker links through Sentry's native integrations. */
22
import {
3+
deleteOrganizationIssueIntegration,
34
type ExternalIssueLinkResponse,
45
type IssueIntegrationsResponse,
56
listOrganizationIssueIntegrations,
@@ -239,6 +240,14 @@ function flattenLinks(integrations: NativeIntegration[]): NativeIssueLink[] {
239240
);
240241
}
241242

243+
/** List every link, including links whose provider is no longer supported. */
244+
export async function listNativeIssueLinks(
245+
orgSlug: string,
246+
issueId: string
247+
): Promise<NativeIssueLink[]> {
248+
return flattenLinks(await listIntegrations(orgSlug, issueId));
249+
}
250+
242251
function matchesNativeUrl(link: NativeIssueLink, target: URL): boolean {
243252
const existing = storedUrl(link.url);
244253
if (!existing) {
@@ -377,3 +386,27 @@ export async function linkNativeIssue(
377386
changed: result.response.status === 201,
378387
};
379388
}
389+
390+
/** The DELETE identifier is Sentry's ExternalIssue ID, not the provider key. */
391+
export async function unlinkNativeIssueLink(
392+
orgSlug: string,
393+
issueId: string,
394+
link: NativeIssueLink
395+
): Promise<void> {
396+
const externalIssue = Number(link.id);
397+
if (!Number.isSafeInteger(externalIssue) || externalIssue <= 0) {
398+
throw new ValidationError(
399+
"External issue link ID must be a safe positive integer."
400+
);
401+
}
402+
const result = await deleteOrganizationIssueIntegration({
403+
...getSdkConfig(await resolveOrgRegion(orgSlug)),
404+
path: {
405+
organization_id_or_slug: orgSlug,
406+
issue_id: issueId,
407+
integration_id: link.integrationId,
408+
},
409+
query: { externalIssue },
410+
});
411+
unwrapResult<void>(result, "Failed to unlink external issue");
412+
}

‎packages/cli/src/lib/complete.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export const ORG_PROJECT_COMMANDS = new Set([
9999
"issue plan",
100100
"issue resolve",
101101
"issue link",
102+
"issue unlink",
102103
"issue unresolve",
103104
"issue archive",
104105
"issue merge",

0 commit comments

Comments
 (0)