From eae365650bb4a395a8ec822e0ba8212b7d621241 Mon Sep 17 00:00:00 2001 From: waelwindows Date: Tue, 8 Sep 2026 15:05:46 +0300 Subject: [PATCH 1/2] fix(documents): require --project or --team when creating a document The Linear API rejects documentCreate mutations that lack both a project and team scope, but the CLI previously forwarded the call with undefined values and surfaced only the opaque server error "Argument Validation Error". Validate the presence of at least one scope flag before the GraphQL request and return a clear, actionable error message. Closes the gap where `linearis documents create --title X` would fail with an unhelpful server-side error instead of a local validation message explaining that a document must belong to a project or team. --- src/commands/documents.ts | 6 ++++++ tests/unit/commands/documents.test.ts | 29 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/commands/documents.ts b/src/commands/documents.ts index 55f8bd43..0ca3dbc0 100644 --- a/src/commands/documents.ts +++ b/src/commands/documents.ts @@ -190,6 +190,12 @@ export function setupDocumentsCommands(program: Command): void { "cannot be combined with --issue", ); } + if (!options.project && !options.team) { + throw invalidParameterError( + "--project|--team", + "a document must belong to at least one project or team", + ); + } const issueIdentifier = options.issue ?? options.attachTo; const rootOpts = getRootOpts(command); diff --git a/tests/unit/commands/documents.test.ts b/tests/unit/commands/documents.test.ts index 06508f32..a008d512 100644 --- a/tests/unit/commands/documents.test.ts +++ b/tests/unit/commands/documents.test.ts @@ -153,6 +153,8 @@ describe("documents create", () => { "create", "--title", "Runbook", + "--team", + "ENG", "--issue", "ENG-42", ]); @@ -193,6 +195,33 @@ describe("documents create", () => { ); }); + it("rejects creating without --project or --team", async () => { + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never); + vi.spyOn(console, "error").mockImplementation(() => {}); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "documents", + "create", + "--title", + "Runbook", + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining( + "Invalid --project|--team: a document must belong to at least one project or team", + ), + ); + expect(createDocument).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + + exitSpy.mockRestore(); + }); + it("rejects combining --issue and --attach-to before creating", async () => { const exitSpy = vi .spyOn(process, "exit") From ba6cff119501c38bfa87853210aabb969b727a05 Mon Sep 17 00:00:00 2001 From: Wael Sulais Date: Fri, 11 Sep 2026 17:57:23 +0300 Subject: [PATCH 2/2] fix(documents): validate exactly one document scope --- src/commands/documents.ts | 15 +++-- tests/unit/commands/documents.test.ts | 89 ++++++++++++++++++--------- 2 files changed, 68 insertions(+), 36 deletions(-) diff --git a/src/commands/documents.ts b/src/commands/documents.ts index 0ca3dbc0..ae544a21 100644 --- a/src/commands/documents.ts +++ b/src/commands/documents.ts @@ -80,8 +80,8 @@ export const DOCUMENTS_META: DomainMeta = { name: "documents", summary: "long-form markdown docs attached to projects or issues", context: [ - "a document is a markdown page. it can belong to a project and/or be", - "attached to an issue. documents support icons and colors.", + "a document is a markdown page. it must belong to exactly one project,", + "team, or issue. documents support icons and colors.", ].join("\n"), arguments: { document: "document identifier (UUID)", @@ -190,14 +190,17 @@ export function setupDocumentsCommands(program: Command): void { "cannot be combined with --issue", ); } - if (!options.project && !options.team) { + const issueIdentifier = options.issue ?? options.attachTo; + const scopes = [options.project, options.team, issueIdentifier].filter( + Boolean, + ); + if (scopes.length !== 1) { throw invalidParameterError( - "--project|--team", - "a document must belong to at least one project or team", + "--project|--team|--issue", + "a document needs exactly one scope: --project, --team, or --issue", ); } - const issueIdentifier = options.issue ?? options.attachTo; const rootOpts = getRootOpts(command); const ctx = createContext(rootOpts); diff --git a/tests/unit/commands/documents.test.ts b/tests/unit/commands/documents.test.ts index a008d512..63b16520 100644 --- a/tests/unit/commands/documents.test.ts +++ b/tests/unit/commands/documents.test.ts @@ -59,7 +59,6 @@ vi.mock("../../../src/services/document-service.js", async (importOriginal) => { import { setupDocumentsCommands } from "../../../src/commands/documents.js"; import { resolveIssueId } from "../../../src/resolvers/issue-resolver.js"; -import { resolveTeamId } from "../../../src/resolvers/team-resolver.js"; import { listAttachments } from "../../../src/services/attachment-service.js"; import { createDocument, @@ -153,8 +152,6 @@ describe("documents create", () => { "create", "--title", "Runbook", - "--team", - "ENG", "--issue", "ENG-42", ]); @@ -169,7 +166,12 @@ describe("documents create", () => { ); }); - it("accepts --attach-to as an alias for --issue", async () => { + it("rejects creating without a document scope", async () => { + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never); + vi.spyOn(console, "error").mockImplementation(() => {}); + const program = createProgram(); await program.parseAsync([ "node", @@ -178,29 +180,20 @@ describe("documents create", () => { "create", "--title", "Runbook", - "--team", - "ENG", - "--attach-to", - "ENG-42", ]); - expect(resolveTeamId).toHaveBeenCalledWith(expect.anything(), "ENG"); - expect(resolveIssueId).toHaveBeenCalledWith(expect.anything(), "ENG-42"); - expect(createDocument).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - teamId: "resolved-team-uuid", - issueId: "resolved-issue-uuid", - }), + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining( + "Invalid --project|--team|--issue: a document needs exactly one scope: --project, --team, or --issue", + ), ); - }); + expect(createDocument).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); - it("rejects creating without --project or --team", async () => { - const exitSpy = vi - .spyOn(process, "exit") - .mockImplementation(() => undefined as never); - vi.spyOn(console, "error").mockImplementation(() => {}); + exitSpy.mockRestore(); + }); + it("accepts --attach-to as an alias for --issue", async () => { const program = createProgram(); await program.parseAsync([ "node", @@ -209,19 +202,55 @@ describe("documents create", () => { "create", "--title", "Runbook", + "--attach-to", + "ENG-42", ]); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining( - "Invalid --project|--team: a document must belong to at least one project or team", - ), + expect(resolveIssueId).toHaveBeenCalledWith(expect.anything(), "ENG-42"); + expect(createDocument).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + issueId: "resolved-issue-uuid", + }), ); - expect(createDocument).not.toHaveBeenCalled(); - expect(exitSpy).toHaveBeenCalledWith(1); - - exitSpy.mockRestore(); }); + it.each([ + ["--project", "PROJ", "--team", "ENG"], + ["--project", "PROJ", "--issue", "ENG-42"], + ["--team", "ENG", "--issue", "ENG-42"], + ])( + "rejects combining %s and %s", + async (firstFlag, firstValue, secondFlag, secondValue) => { + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never); + vi.spyOn(console, "error").mockImplementation(() => {}); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "documents", + "create", + "--title", + "Runbook", + firstFlag, + firstValue, + secondFlag, + secondValue, + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("a document needs exactly one scope"), + ); + expect(createDocument).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + + exitSpy.mockRestore(); + }, + ); + it("rejects combining --issue and --attach-to before creating", async () => { const exitSpy = vi .spyOn(process, "exit")