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 @@ -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
83 changes: 83 additions & 0 deletions apps/cli-docs/src/fragments/commands/issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,3 +297,86 @@ 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.
Native integrations include GitHub, GitHub Enterprise, Jira, Jira Server,
GitLab, Bitbucket, and Azure DevOps. Linear uses its installed Sentry App.
GitLab resolves the repository through the integration's repository search;
it 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`. The CLI checks
that the app's link response identifies the requested URL before reporting success.

```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.

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
GitHub/GitLab repositories and 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.
In non-interactive mode, follow the `sentry auth refresh` command shown in the
error. 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.

Older Sentry versions still require `event:admin` in the token and your effective
project permissions. For those installations, request it explicitly alongside
your existing scopes with `sentry auth refresh --scope ...`. Granting a token
more scopes does not override the organization's 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 @@ -382,6 +382,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 @@ -353,4 +353,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.
2 changes: 1 addition & 1 deletion packages/cli/script/generate-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ function extractSdkFlags(command: Command): SdkFlagInfo[] {
flags.push({
name,
kind,
tsType,
tsType: def.variadic ? `Array<${tsType}>` : tsType,
optional,
default: def.default,
brief: def.brief,
Expand Down
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;
}
86 changes: 86 additions & 0 deletions packages/cli/src/commands/issue/link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/** Associate an existing tracker issue with a Sentry issue. */

import type { SentryContext } from "../../context.js";
import { buildCommand } from "../../lib/command.js";
import { ContextError } from "../../lib/errors.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 { resolveIssue } 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" +
"GitHub, GitLab and 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, issue } = await resolveIssue({
issueArg,
cwd: this.cwd,
command: "link",
});
if (!org) {
throw new ContextError(
"Organization",
"sentry issue link <org>/ISSUE <url>"
);
}
const result = await linkExternalIssue({
orgSlug: org,
issueId: issue.id,
projectId: issue.project?.id,
url,
integrationId: flags.integration,
appSlug: flags.app,
fields,
dryRun: flags["dry-run"],
});
yield new CommandOutput(result);
},
});
Loading
Loading