Skip to content

Commit 6bca776

Browse files
betegoncodex
andcommitted
feat(cli): add issue link command
Link existing tracker issues and GitHub pull requests through @sentry/api. Native integrations resolve URLs server-side; installed Sentry Apps use their guarded link forms. Keep unlink in a separate stacked change. Co-Authored-By: GPT-6 <noreply@openai.com>
1 parent 94849b0 commit 6bca776

23 files changed

Lines changed: 3258 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
@@ -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, list, merge, plan, resolve, unresolve, view
71+
│ │ │ ├── issue/ # archive, events, explain, link, list, merge, plan, resolve, unresolve, view
7272
│ │ │ ├── local/ # run, serve
7373
│ │ │ ├── log/ # list, view
7474
│ │ │ ├── monitor/ # list, run

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,3 +318,57 @@ 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 in Sentry 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. 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.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,7 @@ 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
420421

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

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,4 +370,26 @@ 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+
373395
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
@@ -2,6 +2,7 @@ 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";
@@ -20,6 +21,7 @@ export const issueRoute = buildRouteMap({
2021
unresolve: unresolveCommand,
2122
archive: archiveCommand,
2223
merge: mergeCommand,
24+
link: linkCommand,
2325
},
2426
// `reopen` is a friendlier synonym for `unresolve`, `ignore` for `archive`.
2527
aliases: { reopen: "unresolve", ignore: "archive" },
@@ -37,7 +39,8 @@ export const issueRoute = buildRouteMap({
3739
" resolve Mark an issue as resolved (optionally in a release)\n" +
3840
" unresolve Reopen a resolved issue (alias: reopen)\n" +
3941
" archive Archive/ignore an issue (alias: ignore)\n" +
40-
" merge Merge 2+ issues into a single group\n\n" +
42+
" merge Merge 2+ issues into a single group\n" +
43+
" link Link an existing external issue\n\n" +
4144
"Magic selectors (available for view, events, explain, plan, resolve, unresolve, archive):\n" +
4245
" @latest Most recent unresolved issue\n" +
4346
" @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+
/** Arguments for linking external issues. */
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 linking. */
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+
});

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -919,18 +919,22 @@ export async function resolveIssue(
919919
* This is a stricter wrapper around resolveIssue that throws if org is undefined.
920920
*
921921
* @param options - Resolution options
922-
* @returns Object with org slug and numeric issue ID
922+
* @returns Object with org slug, numeric issue ID, and the issue's project ID when known
923923
* @throws {ContextError} When organization cannot be resolved
924924
*/
925925
export async function resolveOrgAndIssueId(
926926
options: ResolveIssueOptions
927-
): Promise<{ org: string; issueId: string }> {
927+
): Promise<{ org: string; issueId: string; projectId?: string }> {
928928
const result = await resolveIssue(options);
929929
if (!result.org) {
930930
const commandHint = buildCommandHint(options.command, options.issueArg);
931931
throw new ContextError("Organization", commandHint);
932932
}
933-
return { org: result.org, issueId: result.issue.id };
933+
return {
934+
org: result.org,
935+
issueId: result.issue.id,
936+
projectId: result.issue.project?.id,
937+
};
934938
}
935939

936940
type PollAutofixOptions = {

‎packages/cli/src/lib/api/infrastructure.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,50 @@ export function paginate<T>(
509509
);
510510
}
511511

512+
/**
513+
* Fetch and validate every page of a list endpoint, or fail.
514+
*
515+
* Unlike {@link autoPaginate}, a partial result is an error: use this when a
516+
* missing page could hide the record a mutation depends on. Throws on an
517+
* invalid page, a repeated cursor, or more than {@link MAX_PAGINATION_PAGES}.
518+
*
519+
* @param fetchPage - Fetches one page given a cursor
520+
* @param schema - Validates each page's items
521+
* @param context - Operation for error messages, e.g. "listing issue integrations"
522+
* @returns All validated items, in page order
523+
*/
524+
export async function fetchAllPages<T>(
525+
fetchPage: (
526+
cursor: string | undefined
527+
) => Promise<PaginatedResponse<unknown>>,
528+
schema: GenericSchema<unknown, T[]>,
529+
context: string
530+
): Promise<T[]> {
531+
const items: T[] = [];
532+
const seen = new Set<string>();
533+
let cursor: string | undefined;
534+
for (let page = 0; page < MAX_PAGINATION_PAGES; page += 1) {
535+
const { data, nextCursor } = await fetchPage(cursor);
536+
const parsed = safeParse(schema, data);
537+
if (!parsed.success) {
538+
throw new ApiError(`Unexpected response format when ${context}`, 0);
539+
}
540+
items.push(...parsed.output);
541+
if (!nextCursor) {
542+
return items;
543+
}
544+
if (seen.has(nextCursor)) {
545+
throw new ApiError(`Pagination repeated a cursor when ${context}`, 0);
546+
}
547+
seen.add(nextCursor);
548+
cursor = nextCursor;
549+
}
550+
throw new ApiError(
551+
`Pagination exceeded ${MAX_PAGINATION_PAGES} pages when ${context}`,
552+
0
553+
);
554+
}
555+
512556
/**
513557
* Make an authenticated request to a specific Sentry region.
514558
* Returns both parsed response data and raw headers for pagination support.

0 commit comments

Comments
 (0)