Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7aff74b
feat(issue): link and unlink external tracker issues
betegon Sep 8, 2026
0b24c90
fix(issue): validate external link API responses
betegon Sep 8, 2026
2132805
chore: merge main into issue link branch
betegon Sep 8, 2026
578a09b
fix(issue): link and unlink GitHub pull request references
betegon Sep 8, 2026
c5fe6b1
fix(issue): accept external link URLs as positionals
betegon Sep 9, 2026
262c481
fix(issue): correct link resolution and unlink scopes
betegon Sep 10, 2026
95c863c
chore: merge main into issue link branch
betegon Sep 14, 2026
97fceec
refactor(issue): use published linking schemas
betegon Sep 14, 2026
5b1dca8
chore: merge main into issue link branch
betegon Sep 28, 2026
e02c2b9
refactor(issue): delegate external link resolution to backend
betegon Sep 28, 2026
c0e7a22
Merge remote-tracking branch 'origin/main' into bt/issue-link-unlink
betegon Sep 29, 2026
c2cf130
fix(issue): defer native link validation and retries to backend
betegon Sep 29, 2026
e24f709
fix(issue): prepare safe app link retries and form defaults
betegon Sep 29, 2026
1776689
refactor(api): align request options with #1649
betegon Sep 29, 2026
385657e
Merge remote-tracking branch 'origin/main' into bt/issue-link-unlink
betegon Sep 29, 2026
e837d0a
test(issue): drop redundant link and unlink tests
betegon Sep 29, 2026
04ca8aa
refactor(api): share strict pagination for issue link lookups
betegon Sep 29, 2026
2a678f1
refactor(issue): validate link URLs once and share one path per action
betegon Sep 29, 2026
a8ed74a
refactor(issue): resolve link context with resolveOrgAndIssueId
betegon Sep 29, 2026
2a60686
test(issue): test native link matching without fetch mocks
betegon Sep 29, 2026
200a3ae
fix(issue): match GitLab issue paths case-insensitively
betegon Sep 29, 2026
9c5765b
docs(issue): explain why unlink filters Apps only by an explicit --app
betegon Sep 29, 2026
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 @@ -64,7 +64,7 @@ cli/
│ │ ├── docs/ # list, query
│ │ ├── event/ # list, send, view
│ │ ├── feedback/ # list, view
│ │ ├── issue/ # archive, events, explain, list, merge, plan, resolve, unresolve, view
│ │ ├── issue/ # archive, events, explain, link, list, merge, plan, resolve, unlink, unresolve, view
│ │ ├── local/ # run, serve
│ │ ├── log/ # list, view
│ │ ├── monitor/ # list, run
Expand Down
84 changes: 84 additions & 0 deletions apps/cli-docs/src/fragments/commands/issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,3 +318,87 @@ sentry issue ignore CLI-G5 --until auto
| `10users/2hours` | 10 users within 2 hours |
| *(omitted)* | Archive forever |
:::

### Link an external issue

Link an existing tracker issue or GitHub pull request to a Sentry issue:

```bash
sentry issue link FRONT-123 https://github.com/example/app/issues/42
sentry issue link FRONT-123 https://github.com/example/app/pull/43
sentry issue link FRONT-123 https://example.atlassian.net/browse/APP-42
sentry issue link FRONT-123 https://linear.app/example/issue/APP-42/fix-error
```

The matching integration must already be installed in the Sentry organization.
Linking requires a Sentry version with native issue URL resolution and guarded
Sentry App callbacks; older self-hosted versions may require an upgrade.
Native integrations include GitHub, GitHub Enterprise, Jira, Jira Server,
GitLab, Bitbucket, and Azure DevOps. Linear uses its installed Sentry App.
Sentry resolves native issue URLs through the selected integration; the remote
issue must be visible to that installation.
Use `--integration <id>` if more than one native integration matches the URL.
Other Sentry Apps require `--app <slug>` and must expose an issue-link form;
additional required form values can be supplied with `--field name=value`.
For other Apps, an issue select can be supplied by exact ID or label with
`--field`, for example `--app custom --field task_id=123`. Sentry checks
that the app's callback identifies the requested URL before saving the association.

```bash
sentry issue link my-org/FRONT-123 https://github.com/example/app/issues/42 --dry-run
sentry issue link my-org/FRONT-123 https://github.com/example/app/issues/42 --json
```

`--dry-run` discovers the integration and prepares the link without submitting a
write. The provider validates the remote issue when the link is submitted.
An existing matching link succeeds with `changed: false`. A Sentry App that
already links this issue to a different resource must be unlinked first.
App callbacks must return the exact supplied URL; use the issue URL copied from
the tracker, including its title suffix. A mismatch fails without saving the link.

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

This command does not create a tracker issue or link a commit. Existing
integration status-sync settings continue to apply after linking.

#### Link permissions

Linking requires `event:write` and access to the Sentry project. Discovering
Sentry Apps also requires `org:read`. Both scopes are included in the default
OAuth login. If an older OAuth session lacks the
requested scopes, the CLI offers reauthorization after a permission error.
Use `sentry auth refresh` to update an older OAuth grant. Environment tokens must
be updated separately.

### Unlink an external issue

Remove an association without deleting either issue:

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

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

Unlink matches the URL against stored associations and sends Sentry's internal
link ID to the existing DELETE endpoint. It does not require fetching the ticket
from the remote tracker, so a deleted remote ticket can still be unlinked.
For a custom Sentry App, select it with `--app <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.
2 changes: 2 additions & 0 deletions packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,8 @@ Manage Sentry issues
- `sentry issue unresolve <issue>` — Reopen a resolved issue
- `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 @@ -370,4 +370,46 @@ sentry issue merge cli-k9 cli-15h --into cli-k9 # alias form
# Non-error issue types (performance, info, etc.) cannot be merged
```

### `sentry issue link <issue> <url>`

Link an existing external issue

**Flags:**
- `--integration <value> - Native integration ID, when multiple installations match`
- `--app <value> - Sentry App slug (automatically detected for Linear URLs)`
- `-n, --dry-run - Show what would happen without making changes`
- `--field <value>... - Additional Sentry App link form field (name=value, repeatable)`

**Examples:**

```bash
sentry issue link FRONT-123 https://github.com/example/app/issues/42
sentry issue link FRONT-123 https://github.com/example/app/pull/43
sentry issue link FRONT-123 https://example.atlassian.net/browse/APP-42
sentry issue link FRONT-123 https://linear.app/example/issue/APP-42/fix-error

sentry issue link my-org/FRONT-123 https://github.com/example/app/issues/42 --dry-run
sentry issue link my-org/FRONT-123 https://github.com/example/app/issues/42 --json
```

### `sentry issue unlink <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.
8 changes: 7 additions & 1 deletion packages/cli/src/commands/issue/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { buildRouteMap } from "../../lib/route-map.js";
import { archiveCommand } from "./archive.js";
import { eventsCommand } from "./events.js";
import { explainCommand } from "./explain.js";
import { linkCommand } from "./link.js";
import { listCommand } from "./list.js";
import { mergeCommand } from "./merge.js";
import { planCommand } from "./plan.js";
import { resolveCommand } from "./resolve.js";
import { unlinkCommand } from "./unlink.js";
import { unresolveCommand } from "./unresolve.js";
import { viewCommand } from "./view.js";

Expand All @@ -20,6 +22,8 @@ export const issueRoute = buildRouteMap({
unresolve: unresolveCommand,
archive: archiveCommand,
merge: mergeCommand,
link: linkCommand,
unlink: unlinkCommand,
},
// `reopen` is a friendlier synonym for `unresolve`, `ignore` for `archive`.
aliases: { reopen: "unresolve", ignore: "archive" },
Expand All @@ -37,7 +41,9 @@ export const issueRoute = buildRouteMap({
" resolve Mark an issue as resolved (optionally in a release)\n" +
" unresolve Reopen a resolved issue (alias: reopen)\n" +
" archive Archive/ignore an issue (alias: ignore)\n" +
" merge Merge 2+ issues into a single group\n\n" +
" merge Merge 2+ issues into a single group\n" +
" link Link an existing external issue\n" +
" unlink Remove an external issue link\n\n" +
"Magic selectors (available for view, events, explain, plan, resolve, unresolve, archive):\n" +
" @latest Most recent unresolved issue\n" +
" @most_frequent Issue with the highest event frequency\n\n" +
Expand Down
58 changes: 58 additions & 0 deletions packages/cli/src/commands/issue/link-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/** Shared arguments for external issue association commands. */

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

/** Required source issue and existing external resource URL for link and unlink. */
export const EXTERNAL_ISSUE_POSITIONALS = {
kind: "tuple",
parameters: [
...issueIdPositional.parameters,
{
placeholder: "url",
parse: String,
brief: "URL of an existing tracker issue or GitHub pull request",
},
],
} as const;

/** Flags identifying an existing external issue and its Sentry integration. */
export const EXTERNAL_ISSUE_FLAGS = {
integration: {
kind: "parsed",
parse: String,
brief: "Native integration ID, when multiple installations match",
optional: true,
},
app: {
kind: "parsed",
parse: String,
brief: "Sentry App slug (automatically detected for Linear URLs)",
optional: true,
},
} as const;

/** Parse repeated App form fields while rejecting ambiguous duplicate keys. */
export function parseIssueLinkFields(
fields: readonly string[] | undefined
): Record<string, string> | undefined {
if (!fields?.length) {
return;
}
const result: Record<string, string> = {};
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;
}
79 changes: 79 additions & 0 deletions packages/cli/src/commands/issue/link.ts
Original file line number Diff line number Diff line change
@@ -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);
},
});
Loading
Loading