From 7ee40b664d10e83be4fd89126004b9294f93afbe Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 26 Jun 2026 19:11:46 +0000 Subject: [PATCH] fix(issue): accept multi-line issue identifiers by keeping first line (CLI-1G1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseIssueArg only ran .trim() on the raw argument, which strips leading and trailing whitespace but leaves internal newlines intact. When an identifier arrived with an embedded newline — command substitution that captured extra output, an appended note, or several newline-separated IDs — the surviving newline reached validateResourceId and threw a cryptic "Invalid issue identifier: contains a newline" ValidationError (CLI-1G1, 116+ affected users on 0.38.0). An issue identifier is always a single line, so take the first non-blank line instead. Unlike `sentry api` (CLI-FR), which rejoins wrapped URLs, identifiers are atomic tokens — joining lines would produce garbage. Splitting on newlines does not affect project display names with spaces (#1116) because newlines are control chars that are rejected regardless. --- src/lib/arg-parsing.ts | 21 ++++++++++++++++-- test/lib/arg-parsing.test.ts | 43 ++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index a0e6c9273..8d92daf8f 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -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. * @@ -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( diff --git a/test/lib/arg-parsing.test.ts b/test/lib/arg-parsing.test.ts index c16dc0b4c..43096c7a6 100644 --- a/test/lib/arg-parsing.test.ts +++ b/test/lib/arg-parsing.test.ts @@ -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", () => {