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
21 changes: 19 additions & 2 deletions src/lib/arg-parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ function looksLikeDisplayName(input: string): boolean {
*/
const ISSUE_SHORT_ID_PATTERN = /^[A-Z][A-Z0-9]*(-[A-Z][A-Z0-9]*)*-[A-Z0-9]+$/;

/** Splits a string into lines on LF or CRLF boundaries. */
const LINE_SPLIT_PATTERN = /\r?\n/;

/**
* Check if a string looks like a Sentry issue short ID.
*
Expand Down Expand Up @@ -1084,8 +1087,22 @@ export function parseSlashSeparatedArg(
}

export function parseIssueArg(arg: string): ParsedIssueArg {
// Trim whitespace — agents may pass trailing newlines (CLI-16M)
const input = arg.trim();
// Take the first non-blank line. A bare `.trim()` only strips leading and
// trailing whitespace, so multi-line input (command substitution that
// captured extra output, an identifier with an appended note, or several
// newline-separated IDs) left an *internal* newline that reached
// validateResourceId and threw a cryptic "contains a newline" error
// (CLI-1G1, 116+ users). An issue identifier is always a single line, so the
// first non-blank line is the intended value. Unlike `sentry api`, which
// rejoins wrapped URLs (CLI-FR), identifiers are atomic tokens — joining
// lines would produce garbage, so we keep only the first line.
// Splitting on `\n` (a control char) never breaks project display names with
// spaces (#1116), since those are rejected as control chars anyway.
const input =
arg
.split(LINE_SPLIT_PATTERN)
.map((line) => line.trim())
.find((line) => line.length > 0) ?? "";

if (!input) {
throw new ValidationError(
Expand Down
43 changes: 43 additions & 0 deletions test/lib/arg-parsing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,49 @@ describe("parseIssueArg", () => {
});
});

// Multi-line input (CLI-1G1): agents and shells pass identifiers with
// internal newlines (command substitution capturing extra output, an
// appended note, or several newline-separated IDs). A bare trim() leaves the
// internal newline, which previously threw a cryptic "contains a newline"
// ValidationError. We keep the first non-blank line instead.
describe("multi-line and whitespace input (CLI-1G1)", () => {
const expected = {
type: "explicit",
org: "sentry",
project: "cli",
suffix: "G",
};

test("strips a trailing newline (CLI-16M regression)", () => {
expect(parseIssueArg("sentry/cli-G\n")).toEqual(expected);
});

test("skips leading blank lines", () => {
expect(parseIssueArg("\n\n sentry/cli-G")).toEqual(expected);
});

test("keeps the first line when a note is appended after a newline", () => {
expect(parseIssueArg("sentry/cli-G\nthe auth-token error")).toEqual(
expected
);
});

test("keeps the first identifier when several are newline-separated", () => {
expect(parseIssueArg("sentry/cli-G\nsentry/cli-H")).toEqual(expected);
});

test("handles CRLF line endings", () => {
expect(parseIssueArg("sentry/cli-G\r\ntrailing line")).toEqual(expected);
});

test("throws a clear error when input is only blank lines", () => {
expect(() => parseIssueArg("\n \n\t\n")).toThrow(ValidationError);
expect(() => parseIssueArg("\n \n\t\n")).toThrow(
/empty after trimming/
);
});
});

// Error cases - verify specific error messages
describe("error cases", () => {
test("org/-suffix throws error", () => {
Expand Down
Loading