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") + } + }, +})