Skip to content
Merged
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 @@ -68,7 +68,7 @@ toolkit/
│ │ │ ├── dsn/ # list
│ │ │ ├── event/ # list, send, view
│ │ │ ├── feedback/ # list, resolve, spam, unresolve, view
│ │ │ ├── issue/ # archive, events, explain, list, merge, plan, resolve, unresolve, view
│ │ │ ├── issue/ # archive, events, explain, link, list, merge, plan, resolve, unresolve, view
│ │ │ ├── local/ # run, serve
│ │ │ ├── log/ # list, view
│ │ │ ├── monitor/ # list, run
Expand Down
54 changes: 54 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,57 @@ 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 in Sentry 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. 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 login` to request the current default scopes. Environment tokens must
be updated separately.
1 change: 1 addition & 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,7 @@ 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

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -370,4 +370,26 @@ 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
```

All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags.
5 changes: 4 additions & 1 deletion packages/cli/src/commands/issue/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ 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";
Expand All @@ -20,6 +21,7 @@ export const issueRoute = buildRouteMap({
unresolve: unresolveCommand,
archive: archiveCommand,
merge: mergeCommand,
link: linkCommand,
},
// `reopen` is a friendlier synonym for `unresolve`, `ignore` for `archive`.
aliases: { reopen: "unresolve", ignore: "archive" },
Expand All @@ -37,7 +39,8 @@ 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\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 @@
/** Arguments for linking external issues. */

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

/** Required source issue and existing external resource URL for linking. */
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);
},
});
10 changes: 7 additions & 3 deletions packages/cli/src/commands/issue/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -919,18 +919,22 @@ export async function resolveIssue(
* This is a stricter wrapper around resolveIssue that throws if org is undefined.
*
* @param options - Resolution options
* @returns Object with org slug and numeric issue ID
* @returns Object with org slug, numeric issue ID, and the issue's project ID when known
* @throws {ContextError} When organization cannot be resolved
*/
export async function resolveOrgAndIssueId(
options: ResolveIssueOptions
): Promise<{ org: string; issueId: string }> {
): Promise<{ org: string; issueId: string; projectId?: string }> {
const result = await resolveIssue(options);
if (!result.org) {
const commandHint = buildCommandHint(options.command, options.issueArg);
throw new ContextError("Organization", commandHint);
}
return { org: result.org, issueId: result.issue.id };
return {
org: result.org,
issueId: result.issue.id,
projectId: result.issue.project?.id,
};
}

type PollAutofixOptions = {
Expand Down
44 changes: 44 additions & 0 deletions packages/cli/src/lib/api/infrastructure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,50 @@ export function paginate<T>(
);
}

/**
* Fetch and validate every page of a list endpoint, or fail.
*
* Unlike {@link autoPaginate}, a partial result is an error: use this when a
* missing page could hide the record a mutation depends on. Throws on an
* invalid page, a repeated cursor, or more than {@link MAX_PAGINATION_PAGES}.
*
* @param fetchPage - Fetches one page given a cursor
* @param schema - Validates each page's items
* @param context - Operation for error messages, e.g. "listing issue integrations"
* @returns All validated items, in page order
*/
export async function fetchAllPages<T>(
fetchPage: (
cursor: string | undefined
) => Promise<PaginatedResponse<unknown>>,
schema: GenericSchema<unknown, T[]>,
context: string
): Promise<T[]> {
const items: T[] = [];
const seen = new Set<string>();
let cursor: string | undefined;
for (let page = 0; page < MAX_PAGINATION_PAGES; page += 1) {
const { data, nextCursor } = await fetchPage(cursor);
const parsed = safeParse(schema, data);
if (!parsed.success) {
throw new ApiError(`Unexpected response format when ${context}`, 0);
}
items.push(...parsed.output);
if (!nextCursor) {
return items;
}
if (seen.has(nextCursor)) {
throw new ApiError(`Pagination repeated a cursor when ${context}`, 0);
}
seen.add(nextCursor);
cursor = nextCursor;
}
throw new ApiError(
`Pagination exceeded ${MAX_PAGINATION_PAGES} pages when ${context}`,
0
);
}

/**
* Make an authenticated request to a specific Sentry region.
* Returns both parsed response data and raw headers for pagination support.
Expand Down
Loading
Loading