From 81f6e0763918515194a34ed39a13de70bac9dcaf Mon Sep 17 00:00:00 2001 From: Peter Schilling Date: Fri, 17 Jul 2026 14:45:27 -0700 Subject: [PATCH] Add --project to `document update` to re-point a document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `document create` accepts --project but `document update` did not, so a document's project attachment was fixed at creation time — changing it required the web UI. Add --project to update (UUID, slug ID, or name), reusing the same resolveProjectId path as create so resolution and errors stay consistent. The reporter framed this around Linear's web UI supporting multiple project links per document with add/remove semantics (--project X --remove). The API tells a different story: a Document has a single related project (the scalar DocumentUpdateInput.projectId), and create requires exactly one anchor — so a document always has one project and setting merely replaces it. A detach flag was prototyped, but live testing showed the API silently ignores `projectId: null` (returns success, keeps the project), so shipping it would have been a silent no-op; it was dropped rather than lie about detaching. Github-Issue: Fixes #225 Github-Issue-Url: https://github.com/schpet/linear-cli/issues/225 --- src/commands/document/document-update.ts | 20 ++- .../document-update.test.ts.snap | 39 ++++- .../commands/document/document-update.test.ts | 164 ++++++++++++++++++ 3 files changed, 220 insertions(+), 3 deletions(-) diff --git a/src/commands/document/document-update.ts b/src/commands/document/document-update.ts index 227a42f5..e8645bb9 100644 --- a/src/commands/document/document-update.ts +++ b/src/commands/document/document-update.ts @@ -4,6 +4,7 @@ import type { DocumentInlineCommentGuardQuery } from "../../__codegen__/graphql. import { getGraphQLClient } from "../../utils/graphql.ts" import { getEditor } from "../../utils/editor.ts" import { readIdsFromStdin } from "../../utils/bulk.ts" +import { resolveProjectId } from "../../utils/linear.ts" import { CliError, handleError, @@ -178,6 +179,10 @@ export const updateCommand = new Command() "Read new content from file", ) .option("--icon ", "New icon (emoji)") + .option( + "--project ", + "Attach to project (UUID, slug ID, or name)", + ) .option("-e, --edit", "Open current content in $EDITOR for editing") .option( "--force", @@ -185,7 +190,7 @@ export const updateCommand = new Command() ) .action( async ( - { title, content, contentFile, icon, edit, force }, + { title, content, contentFile, icon, project, edit, force }, documentId, ) => { try { @@ -204,6 +209,17 @@ export const updateCommand = new Command() input.icon = icon } + // Set the document's project. A document has a single related project + // (DocumentUpdateInput.projectId), so this replaces any existing one. + // (The API silently ignores projectId: null, so detaching a document + // from its only anchor isn't supported — only re-pointing it.) Resolved + // here alongside the other metadata flags so it participates in the + // stdin auto-read guard below (a project-only update shouldn't slurp + // stdin as content). + if (project != null) { + input.projectId = await resolveProjectId(project) + } + // Resolve content from various sources let finalContent: string | undefined @@ -270,7 +286,7 @@ export const updateCommand = new Command() if (Object.keys(input).length === 0) { throw new ValidationError("No update fields provided", { suggestion: - "Use --title, --content, --content-file, --icon, or --edit.", + "Use --title, --content, --content-file, --icon, --project, or --edit.", }) } diff --git a/test/commands/document/__snapshots__/document-update.test.ts.snap b/test/commands/document/__snapshots__/document-update.test.ts.snap index 58b867ab..64c89e29 100644 --- a/test/commands/document/__snapshots__/document-update.test.ts.snap +++ b/test/commands/document/__snapshots__/document-update.test.ts.snap @@ -16,6 +16,7 @@ Options: -c, --content - New markdown content (inline) -f, --content-file - Read new content from file --icon - New icon (emoji) + --project - Attach to project (UUID, slug ID, or name) -e, --edit - Open current content in \$EDITOR for editing --force - Update content even when document comments may lose inline anchors @@ -83,7 +84,7 @@ stdout: "" stderr: "✗ Failed to update document: No update fields provided - Use --title, --content, --content-file, --icon, or --edit. + Use --title, --content, --content-file, --icon, --project, or --edit. " `; @@ -95,3 +96,39 @@ https://linear.app/test/document/delegation-system-spec-d4b93e3b2695 stderr: "" `; + +snapshot[`Document Update Command - Set Project By UUID 1`] = ` +stdout: +"✓ Updated document: Spec +https://linear.app/test/document/spec-d4b93e3b2695 +" +stderr: +"" +`; + +snapshot[`Document Update Command - Set Project By Name 1`] = ` +stdout: +"✓ Updated document: Spec +https://linear.app/test/document/spec-d4b93e3b2695 +" +stderr: +"" +`; + +snapshot[`Document Update Command - Title And Project 1`] = ` +stdout: +"✓ Updated document: Spec +https://linear.app/test/document/spec-d4b93e3b2695 +" +stderr: +"" +`; + +snapshot[`Document Update Command - Project Not Found 1`] = ` +stdout: +"" +stderr: +"✗ Failed to update document: Project not found: Nope + Pass a project UUID, slug ID (from \`linear project list\`), or exact project name. +" +`; diff --git a/test/commands/document/document-update.test.ts b/test/commands/document/document-update.test.ts index 5ffa4a97..bd14ead5 100644 --- a/test/commands/document/document-update.test.ts +++ b/test/commands/document/document-update.test.ts @@ -516,3 +516,167 @@ await snapshotTest({ } }, }) + +// Regression tests for #225: `document update` can set, change, or clear the +// document's related project (previously only settable at create time). + +const projectDocResponse = { + data: { + documentUpdate: { + success: true, + document: { + id: "doc-1", + slugId: "d4b93e3b2695", + title: "Spec", + url: "https://linear.app/test/document/spec-d4b93e3b2695", + updatedAt: "2026-01-19T10:00:00Z", + }, + }, + }, +} + +// Set the project by UUID — resolveProjectId short-circuits, so the only query +// is the update mutation carrying the resolved projectId. +await snapshotTest({ + name: "Document Update Command - Set Project By UUID", + meta: import.meta, + colors: false, + args: [ + "d4b93e3b2695", + "--project", + "00000000-0000-0000-0000-000000000000", + ], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "UpdateDocument", + variables: { + id: "d4b93e3b2695", + input: { projectId: "00000000-0000-0000-0000-000000000000" }, + }, + response: projectDocResponse, + }, + ]) + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + await updateCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +// Set the project by name — resolveProjectId looks it up, then the update runs. +await snapshotTest({ + name: "Document Update Command - Set Project By Name", + meta: import.meta, + colors: false, + args: ["d4b93e3b2695", "--project", "Tech Debt"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetProjectIdByName", + variables: { name: "Tech Debt" }, + response: { data: { projects: { nodes: [{ id: "proj-uuid" }] } } }, + }, + { + queryName: "UpdateDocument", + variables: { + id: "d4b93e3b2695", + input: { projectId: "proj-uuid" }, + }, + response: projectDocResponse, + }, + ]) + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + await updateCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +// Combining a project change with another field updates both in one mutation. +await snapshotTest({ + name: "Document Update Command - Title And Project", + meta: import.meta, + colors: false, + args: [ + "d4b93e3b2695", + "--title", + "Renamed Spec", + "--project", + "00000000-0000-0000-0000-000000000000", + ], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "UpdateDocument", + variables: { + id: "d4b93e3b2695", + input: { + title: "Renamed Spec", + projectId: "00000000-0000-0000-0000-000000000000", + }, + }, + response: projectDocResponse, + }, + ]) + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + await updateCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +// An unknown project name fails with the standard not-found error and no update. +await snapshotTest({ + name: "Document Update Command - Project Not Found", + meta: import.meta, + colors: false, + args: ["d4b93e3b2695", "--project", "Nope"], + denoArgs: commonDenoArgs, + canFail: true, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetProjectIdByName", + variables: { name: "Nope" }, + response: { data: { projects: { nodes: [] } } }, + }, + { + queryName: "GetProjectIdBySlugId", + variables: { slugId: "Nope" }, + response: { data: { projects: { nodes: [] } } }, + }, + ]) + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + await updateCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +})