Files
Peter Schilling 3a00cc7604 Accept team names and IDs, and state names, wherever the CLI takes them
Every command that takes a team now accepts its key, name, or UUID through
one shared resolver (findTeam / resolveTeam / resolveTeams), replacing the
key-only getTeamIdByKey and the ad-hoc uppercasing spread across commands.
One aliased ResolveTeam operation looks up key, name, and (for UUID-shaped
input) id in a single round trip; precedence is key, then id, then name,
applied client-side so a reference that equals one team's key and another
team's name always means the key. Keys stay the canonical downstream form:
filters that matched on team.key still do, with the server's uppercase key,
and callers that need a UUID take it from the same resolved object. An
unknown team now errors with the list of valid keys instead of an empty
result or a raw "Entity not found" from the API.

Only explicit input goes through the resolver. The configured default team
is already a normalized key, and resolving it would add a round trip to
every default-team invocation of the most-used commands for no gain. In
issue create, the interactive substring picker survives only for that
default; an explicit --team that matches nothing errors like everywhere
else.

issue query --state and issue mine --state take a workflow state name or
ID as well as the six type tokens. Names and IDs are resolved within the
queried scope (the team, the teams, or the whole workspace under
--all-teams, where a name matches every team's same-named state), so a
state from another team errors instead of silently matching nothing, and
the error lists the scope's states. Type-only input still sends the same
{ type: { in } } filter with no extra request; a mix of types and names
becomes an or-filter.

The MCP server already describes these parameters as "key, name, or ID"
and "type, name, or ID"; this brings the CLI to parity so an agent does
not need a preliminary team list to translate a name into a key.

Claude-Session: https://claude.ai/code/session_01A9qEGri4p2HZMQSuYsBmub
2026-09-05 07:19:38 -07:00

829 lines
22 KiB
TypeScript

import { snapshotTest } from "@cliffy/testing"
import { updateCommand } from "../../../src/commands/document/document-update.ts"
import { MockLinearServer } from "../../utils/mock_linear_server.ts"
import { commonDenoArgs } from "../../utils/test-helpers.ts"
// Test help output
await snapshotTest({
name: "Document Update Command - Help Text",
meta: import.meta,
colors: false,
args: ["--help"],
denoArgs: commonDenoArgs,
async fn() {
await updateCommand.parse()
},
})
// Test updating document title
await snapshotTest({
name: "Document Update Command - Update Title",
meta: import.meta,
colors: false,
args: ["d4b93e3b2695", "--title", "New Title"],
denoArgs: commonDenoArgs,
async fn() {
const server = new MockLinearServer([
{
queryName: "UpdateDocument",
variables: {
id: "d4b93e3b2695",
input: {
title: "New Title",
},
},
response: {
data: {
documentUpdate: {
success: true,
document: {
id: "doc-1",
slugId: "d4b93e3b2695",
title: "New Title",
url: "https://linear.app/test/document/new-title-d4b93e3b2695",
updatedAt: "2026-01-19T10:00:00Z",
},
},
},
},
},
])
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")
}
},
})
// Test updating document content
await snapshotTest({
name: "Document Update Command - Update Content",
meta: import.meta,
colors: false,
args: ["d4b93e3b2695", "--content", "# Updated Content\n\nNew content here."],
denoArgs: commonDenoArgs,
async fn() {
const server = new MockLinearServer([
{
queryName: "DocumentInlineCommentGuard",
variables: {
id: "d4b93e3b2695",
after: null,
},
response: {
data: {
document: {
id: "doc-1",
title: "Delegation System Spec",
content: "# Current Content",
comments: {
nodes: [],
pageInfo: {
hasNextPage: false,
endCursor: null,
},
},
},
},
},
},
{
queryName: "UpdateDocument",
variables: {
id: "d4b93e3b2695",
input: {
content: "# Updated Content\n\nNew content here.",
},
},
response: {
data: {
documentUpdate: {
success: true,
document: {
id: "doc-1",
slugId: "d4b93e3b2695",
title: "Delegation System Spec",
url:
"https://linear.app/test/document/delegation-system-spec-d4b93e3b2695",
updatedAt: "2026-01-19T10:00:00Z",
},
},
},
},
},
])
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")
}
},
})
// Test content updates allow top-level document comments without inline anchors
await snapshotTest({
name:
"Document Update Command - Allows Content Update With Top Level Comments",
meta: import.meta,
colors: false,
args: ["d4b93e3b2695", "--content", "# Updated Content"],
denoArgs: commonDenoArgs,
async fn() {
const server = new MockLinearServer([
{
queryName: "DocumentInlineCommentGuard",
variables: {
id: "d4b93e3b2695",
after: null,
},
response: {
data: {
document: {
id: "doc-1",
comments: {
nodes: [
{
id: "comment-1",
quotedText: null,
},
],
pageInfo: {
hasNextPage: false,
endCursor: null,
},
},
},
},
},
},
{
queryName: "UpdateDocument",
variables: {
id: "d4b93e3b2695",
input: {
content: "# Updated Content",
},
},
response: {
data: {
documentUpdate: {
success: true,
document: {
id: "doc-1",
slugId: "d4b93e3b2695",
title: "Delegation System Spec",
url:
"https://linear.app/test/document/delegation-system-spec-d4b93e3b2695",
updatedAt: "2026-01-19T10:00:00Z",
},
},
},
},
},
])
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")
}
},
})
// Test content updates refuse to run when inline document comments exist
await snapshotTest({
name: "Document Update Command - Blocks Content Update With Inline Comments",
meta: import.meta,
colors: false,
canFail: true,
args: ["d4b93e3b2695", "--content", "# Updated Content"],
denoArgs: commonDenoArgs,
async fn() {
const server = new MockLinearServer([
{
queryName: "DocumentInlineCommentGuard",
variables: {
id: "d4b93e3b2695",
after: null,
},
response: {
data: {
document: {
id: "doc-1",
title: "Delegation System Spec",
content: "# Current Content",
comments: {
nodes: [
{
id: "comment-1",
quotedText: null,
},
],
pageInfo: {
hasNextPage: true,
endCursor: "cursor-1",
},
},
},
},
},
},
{
queryName: "DocumentInlineCommentGuard",
variables: {
id: "d4b93e3b2695",
after: "cursor-1",
},
response: {
data: {
document: {
id: "doc-1",
comments: {
nodes: [
{
id: "comment-2",
quotedText: "Current Content",
},
],
pageInfo: {
hasNextPage: false,
endCursor: null,
},
},
},
},
},
},
])
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")
}
},
})
// Test --force bypasses the comment guard for intentional content replacement
await snapshotTest({
name: "Document Update Command - Force Content Update With Comments",
meta: import.meta,
colors: false,
args: ["d4b93e3b2695", "--content", "# Updated Content", "--force"],
denoArgs: commonDenoArgs,
async fn() {
const server = new MockLinearServer([
{
queryName: "UpdateDocument",
variables: {
id: "d4b93e3b2695",
input: {
content: "# Updated Content",
},
},
response: {
data: {
documentUpdate: {
success: true,
document: {
id: "doc-1",
slugId: "d4b93e3b2695",
title: "Delegation System Spec",
url:
"https://linear.app/test/document/delegation-system-spec-d4b93e3b2695",
updatedAt: "2026-01-19T10:00:00Z",
},
},
},
},
},
])
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")
}
},
})
// Test updating multiple fields
await snapshotTest({
name: "Document Update Command - Update Multiple Fields",
meta: import.meta,
colors: false,
args: [
"d4b93e3b2695",
"--title",
"Updated Title",
"--content",
"# New Content",
"--icon",
"📝",
],
denoArgs: commonDenoArgs,
async fn() {
const server = new MockLinearServer([
{
queryName: "DocumentInlineCommentGuard",
variables: {
id: "d4b93e3b2695",
after: null,
},
response: {
data: {
document: {
id: "doc-1",
title: "Delegation System Spec",
content: "# Current Content",
comments: {
nodes: [],
pageInfo: {
hasNextPage: false,
endCursor: null,
},
},
},
},
},
},
{
queryName: "UpdateDocument",
variables: {
id: "d4b93e3b2695",
input: {
title: "Updated Title",
content: "# New Content",
icon: "📝",
},
},
response: {
data: {
documentUpdate: {
success: true,
document: {
id: "doc-1",
slugId: "d4b93e3b2695",
title: "Updated Title",
url:
"https://linear.app/test/document/updated-title-d4b93e3b2695",
updatedAt: "2026-01-19T10:00:00Z",
},
},
},
},
},
])
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")
}
},
})
// NOTE: "Document Not Found" test removed - stack traces contain machine-specific paths
// Test no update fields provided
await snapshotTest({
name: "Document Update Command - No Fields Provided",
meta: import.meta,
colors: false,
canFail: true,
args: ["d4b93e3b2695"],
denoArgs: commonDenoArgs,
async fn() {
// Set dummy API key so validation logic is reached (not "api_key not set" error)
Deno.env.set("LINEAR_API_KEY", "dummy-key-for-validation-test")
try {
await updateCommand.parse()
} finally {
Deno.env.delete("LINEAR_API_KEY")
}
},
})
// NOTE: "Permission Error" test removed - stack traces contain machine-specific paths
// A RESOLVED inline comment (closed thread) must NOT block a content update:
// detaching the anchor of a resolved comment loses no live context, so the
// guard should let the update through without --force.
await snapshotTest({
name: "Document Update Command - Resolved Inline Comment Does Not Block",
meta: import.meta,
colors: false,
args: ["d4b93e3b2695", "--content", "# Updated Content"],
denoArgs: commonDenoArgs,
async fn() {
const server = new MockLinearServer([
{
queryName: "DocumentInlineCommentGuard",
variables: { id: "d4b93e3b2695" },
response: {
data: {
document: {
id: "doc-1",
comments: {
nodes: [
{
// Inline (has quotedText) but resolved: must be ignored.
id: "comment-resolved",
quotedText: "Old anchored text",
resolvedAt: "2026-01-15T10:00:00Z",
archivedAt: null,
},
],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
},
},
},
{
queryName: "UpdateDocument",
variables: {
id: "d4b93e3b2695",
input: { content: "# Updated Content" },
},
response: {
data: {
documentUpdate: {
success: true,
document: {
id: "doc-1",
slugId: "d4b93e3b2695",
title: "Delegation System Spec",
url:
"https://linear.app/test/document/delegation-system-spec-d4b93e3b2695",
updatedAt: "2026-01-19T10:00:00Z",
},
},
},
},
},
])
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")
}
},
})
// 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")
}
},
})
// Re-pointing to any of the six target types replaces the current attachment
// (the server clears the old target when a new one is set).
await snapshotTest({
name: "Document Update Command - Repoint To Team",
meta: import.meta,
colors: false,
args: ["d4b93e3b2695", "--team", "eng"],
denoArgs: commonDenoArgs,
async fn() {
const server = new MockLinearServer([
{
queryName: "ResolveTeam",
variables: { reference: "eng" },
response: {
data: {
teams: {
nodes: [{ id: "team-eng-id", key: "ENG", name: "Engineering" }],
},
},
},
},
{
queryName: "UpdateDocument",
variables: {
id: "d4b93e3b2695",
input: { teamId: "team-eng-id" },
},
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")
}
},
})
await snapshotTest({
name: "Document Update Command - Repoint To Issue",
meta: import.meta,
colors: false,
args: ["d4b93e3b2695", "--issue", "TC-123"],
denoArgs: commonDenoArgs,
async fn() {
const server = new MockLinearServer([
{
queryName: "GetIssueForDocumentTarget",
variables: { id: "TC-123" },
response: { data: { issue: { id: "issue-uuid-456" } } },
},
{
queryName: "UpdateDocument",
variables: {
id: "d4b93e3b2695",
input: { issueId: "issue-uuid-456" },
},
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")
}
},
})
await snapshotTest({
name: "Document Update Command - Repoint To Release",
meta: import.meta,
colors: false,
args: ["d4b93e3b2695", "--release", "Summer Launch"],
denoArgs: commonDenoArgs,
async fn() {
const server = new MockLinearServer([
{
queryName: "ResolveReleases",
variables: { input: "Summer Launch", after: null },
response: {
data: {
releases: {
nodes: [
{
id: "release-uuid-1",
name: "Summer Launch",
version: "2026.8",
},
],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
},
},
{
queryName: "UpdateDocument",
variables: {
id: "d4b93e3b2695",
input: { releaseId: "release-uuid-1" },
},
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")
}
},
})
// A document has exactly one attachment target — two target flags must fail
// locally before any request is made (no mock server is running).
await snapshotTest({
name: "Document Update Command - Multiple Targets Error",
meta: import.meta,
colors: false,
canFail: true,
args: ["d4b93e3b2695", "--project", "roadmap", "--release", "Launch"],
denoArgs: commonDenoArgs,
async fn() {
Deno.env.set("LINEAR_API_KEY", "dummy-key-for-validation-test")
try {
await updateCommand.parse()
} finally {
Deno.env.delete("LINEAR_API_KEY")
}
},
})