Skip to content
Open
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
20 changes: 18 additions & 2 deletions src/commands/document/document-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -178,14 +179,18 @@ export const updateCommand = new Command()
"Read new content from file",
)
.option("--icon <icon:string>", "New icon (emoji)")
.option(
"--project <project:string>",
"Attach to project (UUID, slug ID, or name)",
)
.option("-e, --edit", "Open current content in $EDITOR for editing")
.option(
"--force",
"Update content even when document comments may lose inline anchors",
)
.action(
async (
{ title, content, contentFile, icon, edit, force },
{ title, content, contentFile, icon, project, edit, force },
documentId,
) => {
try {
Expand All @@ -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

Expand Down Expand Up @@ -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.",
})
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Options:
-c, --content <content> - New markdown content (inline)
-f, --content-file <path> - Read new content from file
--icon <icon> - New icon (emoji)
--project <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

Expand Down Expand Up @@ -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.
"
`;

Expand All @@ -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.
"
`;
164 changes: 164 additions & 0 deletions test/commands/document/document-update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
},
})
Loading