Skip to content

Commit b6116fe

Browse files
betegoncodex
andcommitted
feat(cli): add issue link and unlink commands
Link existing tracker issues and GitHub pull requests through @sentry/api, using backend URL resolution for native integrations and installed link forms for Sentry Apps. Preserve dry-run output, confirmation, canonical App guards, and the CLI's normal retry and cache policies. Port the issue-linking feature from getsentry/cli#1559 to Toolkit without the per-request transport controls proposed separately in #1367. Co-Authored-By: GPT-6 <noreply@openai.com>
1 parent 1e48c7f commit b6116fe

25 files changed

Lines changed: 3899 additions & 9 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
@@ -67,7 +67,7 @@ toolkit/
6767
│ │ │ ├── docs/ # list, query
6868
│ │ │ ├── event/ # list, send, view
6969
│ │ │ ├── feedback/ # list, resolve, unresolve, view
70-
│ │ │ ├── issue/ # archive, events, explain, list, merge, plan, resolve, unresolve, view
70+
│ │ │ ├── issue/ # archive, events, explain, link, list, merge, plan, resolve, unlink, unresolve, view
7171
│ │ │ ├── local/ # run, serve
7272
│ │ │ ├── log/ # list, view
7373
│ │ │ ├── monitor/ # list, run

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

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,3 +318,87 @@ sentry issue ignore CLI-G5 --until auto
318318
| `10users/2hours` | 10 users within 2 hours |
319319
| *(omitted)* | Archive forever |
320320
:::
321+
322+
### Link an external issue
323+
324+
Link an existing tracker issue or GitHub pull request to a Sentry issue:
325+
326+
```bash
327+
sentry issue link FRONT-123 https://github.com/example/app/issues/42
328+
sentry issue link FRONT-123 https://github.com/example/app/pull/43
329+
sentry issue link FRONT-123 https://example.atlassian.net/browse/APP-42
330+
sentry issue link FRONT-123 https://linear.app/example/issue/APP-42/fix-error
331+
```
332+
333+
The matching integration must already be installed in the Sentry organization.
334+
Linking requires a Sentry version with native issue URL resolution and guarded
335+
Sentry App callbacks; older self-hosted versions may require an upgrade.
336+
Native integrations include GitHub, GitHub Enterprise, Jira, Jira Server,
337+
GitLab, Bitbucket, and Azure DevOps. Linear uses its installed Sentry App.
338+
Sentry resolves native issue URLs through the selected integration; the remote
339+
issue must be visible to that installation.
340+
Use `--integration <id>` if more than one native integration matches the URL.
341+
Other Sentry Apps require `--app <slug>` and must expose an issue-link form;
342+
additional required form values can be supplied with `--field name=value`.
343+
For other Apps, an issue select can be supplied by exact ID or label with
344+
`--field`, for example `--app custom --field task_id=123`. Sentry checks
345+
that the app's callback identifies the requested URL before saving the association.
346+
347+
```bash
348+
sentry issue link my-org/FRONT-123 https://github.com/example/app/issues/42 --dry-run
349+
sentry issue link my-org/FRONT-123 https://github.com/example/app/issues/42 --json
350+
```
351+
352+
`--dry-run` discovers the integration and prepares the link without submitting a
353+
write. The provider validates the remote issue when the link is submitted.
354+
An existing matching link succeeds with `changed: false`. A Sentry App that
355+
already links this issue to a different resource must be unlinked first.
356+
App callbacks must return the exact supplied URL; use the issue URL copied from
357+
the tracker, including its title suffix. A mismatch fails without saving the link.
358+
359+
GitHub and GitHub Enterprise pull requests are stored as external references.
360+
Their `/pull/NUMBER` and `/issues/NUMBER` URLs identify the same resource for
361+
duplicate detection and unlinking. Linking a PR does not mark it as a fix or
362+
resolve the Sentry issue.
363+
364+
This command does not create a tracker issue or link a commit. Existing
365+
integration status-sync settings continue to apply after linking.
366+
367+
#### Link permissions
368+
369+
Linking requires `event:write` and access to the Sentry project. Discovering
370+
Sentry Apps also requires `org:read`. Both scopes are included in the default
371+
OAuth login. If an older OAuth session lacks the
372+
requested scopes, the CLI offers reauthorization after a permission error.
373+
Use `sentry auth login` to request the current default scopes. Environment tokens must
374+
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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,8 @@ Manage Sentry issues
417417
- `sentry issue unresolve <issue>` — Reopen a resolved issue
418418
- `sentry issue archive <issue>` — Archive (ignore) an issue
419419
- `sentry issue merge <issue...>` — Merge 2+ issues into a single canonical group
420+
- `sentry issue link <issue> <url>` — Link an existing external issue
421+
- `sentry issue unlink <issue> <url>` — Unlink an external issue
420422

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

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,4 +370,46 @@ sentry issue merge cli-k9 cli-15h --into cli-k9 # alias form
370370
# Non-error issue types (performance, info, etc.) cannot be merged
371371
```
372372

373+
### `sentry issue link <issue> <url>`
374+
375+
Link an existing external issue
376+
377+
**Flags:**
378+
- `--integration <value> - Native integration ID, when multiple installations match`
379+
- `--app <value> - Sentry App slug (automatically detected for Linear URLs)`
380+
- `-n, --dry-run - Show what would happen without making changes`
381+
- `--field <value>... - Additional Sentry App link form field (name=value, repeatable)`
382+
383+
**Examples:**
384+
385+
```bash
386+
sentry issue link FRONT-123 https://github.com/example/app/issues/42
387+
sentry issue link FRONT-123 https://github.com/example/app/pull/43
388+
sentry issue link FRONT-123 https://example.atlassian.net/browse/APP-42
389+
sentry issue link FRONT-123 https://linear.app/example/issue/APP-42/fix-error
390+
391+
sentry issue link my-org/FRONT-123 https://github.com/example/app/issues/42 --dry-run
392+
sentry issue link my-org/FRONT-123 https://github.com/example/app/issues/42 --json
393+
```
394+
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+
373415
All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags.

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import { buildRouteMap } from "../../lib/route-map.js";
22
import { archiveCommand } from "./archive.js";
33
import { eventsCommand } from "./events.js";
44
import { explainCommand } from "./explain.js";
5+
import { linkCommand } from "./link.js";
56
import { listCommand } from "./list.js";
67
import { mergeCommand } from "./merge.js";
78
import { planCommand } from "./plan.js";
89
import { resolveCommand } from "./resolve.js";
10+
import { unlinkCommand } from "./unlink.js";
911
import { unresolveCommand } from "./unresolve.js";
1012
import { viewCommand } from "./view.js";
1113

@@ -20,6 +22,8 @@ export const issueRoute = buildRouteMap({
2022
unresolve: unresolveCommand,
2123
archive: archiveCommand,
2224
merge: mergeCommand,
25+
link: linkCommand,
26+
unlink: unlinkCommand,
2327
},
2428
// `reopen` is a friendlier synonym for `unresolve`, `ignore` for `archive`.
2529
aliases: { reopen: "unresolve", ignore: "archive" },
@@ -37,7 +41,9 @@ export const issueRoute = buildRouteMap({
3741
" resolve Mark an issue as resolved (optionally in a release)\n" +
3842
" unresolve Reopen a resolved issue (alias: reopen)\n" +
3943
" archive Archive/ignore an issue (alias: ignore)\n" +
40-
" merge Merge 2+ issues into a single group\n\n" +
44+
" merge Merge 2+ issues into a single group\n" +
45+
" link Link an existing external issue\n" +
46+
" unlink Remove an external issue link\n\n" +
4147
"Magic selectors (available for view, events, explain, plan, resolve, unresolve, archive):\n" +
4248
" @latest Most recent unresolved issue\n" +
4349
" @most_frequent Issue with the highest event frequency\n\n" +
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/** Shared arguments for external issue association commands. */
2+
3+
import { ValidationError } from "../../lib/errors.js";
4+
import { issueIdPositional } from "./utils.js";
5+
6+
/** Required source issue and existing external resource URL for link and unlink. */
7+
export const EXTERNAL_ISSUE_POSITIONALS = {
8+
kind: "tuple",
9+
parameters: [
10+
...issueIdPositional.parameters,
11+
{
12+
placeholder: "url",
13+
parse: String,
14+
brief: "URL of an existing tracker issue or GitHub pull request",
15+
},
16+
],
17+
} as const;
18+
19+
/** Flags identifying an existing external issue and its Sentry integration. */
20+
export const EXTERNAL_ISSUE_FLAGS = {
21+
integration: {
22+
kind: "parsed",
23+
parse: String,
24+
brief: "Native integration ID, when multiple installations match",
25+
optional: true,
26+
},
27+
app: {
28+
kind: "parsed",
29+
parse: String,
30+
brief: "Sentry App slug (automatically detected for Linear URLs)",
31+
optional: true,
32+
},
33+
} as const;
34+
35+
/** Parse repeated App form fields while rejecting ambiguous duplicate keys. */
36+
export function parseIssueLinkFields(
37+
fields: readonly string[] | undefined
38+
): Record<string, string> | undefined {
39+
if (!fields?.length) {
40+
return;
41+
}
42+
const result: Record<string, string> = {};
43+
for (const field of fields) {
44+
const separator = field.indexOf("=");
45+
const key = field.slice(0, separator);
46+
if (
47+
separator < 1 ||
48+
["__proto__", "constructor", "prototype"].includes(key) ||
49+
Object.hasOwn(result, key)
50+
) {
51+
throw new ValidationError(
52+
"Each --field must be a unique name=value pair."
53+
);
54+
}
55+
result[key] = field.slice(separator + 1);
56+
}
57+
return result;
58+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/** Associate an existing tracker issue with a Sentry issue. */
2+
3+
import type { SentryContext } from "../../context.js";
4+
import { buildCommand } from "../../lib/command.js";
5+
import { formatIssueLinkResult } from "../../lib/formatters/issue-links.js";
6+
import { CommandOutput } from "../../lib/formatters/output.js";
7+
import { linkExternalIssue } from "../../lib/issue-links.js";
8+
import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../lib/mutate-command.js";
9+
import {
10+
EXTERNAL_ISSUE_FLAGS,
11+
EXTERNAL_ISSUE_POSITIONALS,
12+
parseIssueLinkFields,
13+
} from "./link-utils.js";
14+
import { resolveOrgAndIssueId } from "./utils.js";
15+
16+
type LinkFlags = {
17+
readonly integration?: string;
18+
readonly app?: string;
19+
readonly field?: string[];
20+
readonly "dry-run": boolean;
21+
};
22+
23+
export const linkCommand = buildCommand({
24+
docs: {
25+
brief: "Link an existing external issue",
26+
fullDescription:
27+
"Link an existing tracker issue or GitHub pull request as an external reference.\n" +
28+
"The integration must be installed in your Sentry organization.\n" +
29+
"This does not create a remote issue or resolve the Sentry issue.\n\n" +
30+
"Requires event:write and access to the Sentry project.\n" +
31+
"Sentry Apps also require org:read for discovery.\n\n" +
32+
"Examples:\n" +
33+
" sentry issue link FRONT-123 https://github.com/example/app/issues/42\n" +
34+
" sentry issue link FRONT-123 https://github.com/example/app/pull/43\n" +
35+
" sentry issue link my-org/FRONT-123 https://example.atlassian.net/browse/APP-42\n" +
36+
" sentry issue link FRONT-123 https://linear.app/example/issue/APP-42/fix-error\n" +
37+
" sentry issue link FRONT-123 https://github.com/example/app/issues/42 --dry-run",
38+
},
39+
output: { human: formatIssueLinkResult },
40+
parameters: {
41+
positional: EXTERNAL_ISSUE_POSITIONALS,
42+
flags: {
43+
...EXTERNAL_ISSUE_FLAGS,
44+
"dry-run": DRY_RUN_FLAG,
45+
field: {
46+
kind: "parsed",
47+
parse: String,
48+
brief: "Additional Sentry App link form field (name=value, repeatable)",
49+
variadic: true,
50+
optional: true,
51+
},
52+
},
53+
aliases: DRY_RUN_ALIASES,
54+
},
55+
async *func(
56+
this: SentryContext,
57+
flags: LinkFlags,
58+
issueArg: string,
59+
url: string
60+
) {
61+
const fields = parseIssueLinkFields(flags.field);
62+
const { org, issueId, projectId } = await resolveOrgAndIssueId({
63+
issueArg,
64+
cwd: this.cwd,
65+
command: "link",
66+
});
67+
const result = await linkExternalIssue({
68+
orgSlug: org,
69+
issueId,
70+
projectId,
71+
url,
72+
integrationId: flags.integration,
73+
appSlug: flags.app,
74+
fields,
75+
dryRun: flags["dry-run"],
76+
});
77+
yield new CommandOutput(result);
78+
},
79+
});

0 commit comments

Comments
 (0)