mirror of
https://github.com/schpet/linear-cli.git
synced 2026-09-14 14:26:50 +08:00
d7bba4a670
The CLI could only comment on issues. Documents, projects, and initiatives all take comments in Linear (GitHub issue #230 asked for document comments), so this adds `document comment list|add`, `project comment list|add`, and `initiative comment list|add`, mirroring `issue comment` with the same --body / --body-file conventions and the shared Markdown hint. Every comment `add`, including the issue one, now takes `--reply-to <commentId>`; -p and --parent stay as aliases so existing scripts keep working. The entity-agnostic parts live in src/utils/comments.ts: a typed comment target union feeding one AddComment mutation, strict body handling (an explicitly blank --body or body file is an error, not a fall-through to the prompt), a CommentListFields fragment so the four --json shapes cannot drift, a page collector, and the threaded renderer. Comment lists now fetch every page instead of stopping silently at 50, and their JSON nodes, plus the comments in `issue view --json`, carry quotedText (the passage an inline comment quotes) alongside parent.id. Replies whose root is missing from the result are rendered as replies naming their parent instead of being dropped. API findings, verified live against scratch objects on 2026-09-04: - A reply must carry its entity id as well as parentId; parentId alone is rejected, so every add sends both. - Project comments attach via projectId, but the schema's Project.comments connection does not return them; only the root `comments` query filtered by project does. Initiative has no comments connection at all. Both list commands therefore use the root query and select the entity in the same operation so an unknown UUID is reported as not found rather than as an empty list. - Document comments attach via the document's documentContentId, which is looked up first; `document(id:)` accepts a UUID or slug directly. - Linear rejects a reply to a reply and a cross-entity parent with a user-presentable message, which is surfaced verbatim. Linear's not-found error carries the user-presentable message "Could not find referenced <Type>.", which isNotFoundError never matched, so the existing not-found branches were dead. Matching that wording exposed a `document view` catch block that re-threw instead of reporting; it now goes through handleError like everything else. Claude-Session: https://claude.ai/code/session_01A9qEGri4p2HZMQSuYsBmub
430 lines
14 KiB
TypeScript
430 lines
14 KiB
TypeScript
import { snapshotTest } from "@cliffy/testing"
|
|
import { assertEquals } from "@std/assert"
|
|
import { stub } from "@std/testing/mock"
|
|
import { viewCommand } from "../../../src/commands/document/document-view.ts"
|
|
import { MockLinearServer } from "../../utils/mock_linear_server.ts"
|
|
import { commonDenoArgs } from "../../utils/test-helpers.ts"
|
|
|
|
// Test help output
|
|
await snapshotTest({
|
|
name: "Document View Command - Help Text",
|
|
meta: import.meta,
|
|
colors: false,
|
|
args: ["--help"],
|
|
denoArgs: commonDenoArgs,
|
|
async fn() {
|
|
await viewCommand.parse()
|
|
},
|
|
})
|
|
|
|
// Test viewing a document
|
|
await snapshotTest({
|
|
name: "Document View Command - View Document",
|
|
meta: import.meta,
|
|
colors: false,
|
|
args: ["d4b93e3b2695"],
|
|
denoArgs: commonDenoArgs,
|
|
async fn() {
|
|
const server = new MockLinearServer([
|
|
{
|
|
queryName: "GetDocument",
|
|
variables: { id: "d4b93e3b2695" },
|
|
response: {
|
|
data: {
|
|
document: {
|
|
id: "doc-1",
|
|
title: "Delegation System Spec",
|
|
slugId: "d4b93e3b2695",
|
|
content:
|
|
"# Delegation System\n\nThis document describes the delegation system architecture.\n\n## Overview\n\nThe system supports user-to-user delegations with time-bounded capabilities.\n\n## Implementation\n\n- UCAN-based delegation chains\n- PKH DID format for user identity\n- Session key DIDs for signing",
|
|
url:
|
|
"https://linear.app/test/document/delegation-system-spec-d4b93e3b2695",
|
|
createdAt: "2026-01-15T08:00:00Z",
|
|
updatedAt: "2026-01-18T10:30:00Z",
|
|
creator: { name: "John Doe", email: "john@example.com" },
|
|
project: { name: "TinyCloud SDK", slugId: "tinycloud-sdk" },
|
|
issue: null,
|
|
initiative: null,
|
|
team: null,
|
|
cycle: null,
|
|
release: null,
|
|
comments: {
|
|
nodes: [
|
|
{
|
|
id: "comment-1",
|
|
body: "Can we clarify this?",
|
|
quotedText: "delegation system architecture",
|
|
documentContentId: "document-content-1",
|
|
createdAt: "2026-01-18T11:00:00Z",
|
|
updatedAt: "2026-01-18T11:00:00Z",
|
|
archivedAt: null,
|
|
resolvedAt: null,
|
|
url: "https://linear.app/test/comment/comment-1",
|
|
user: {
|
|
name: "Jane Reviewer",
|
|
email: "jane@example.com",
|
|
},
|
|
parent: null,
|
|
children: {
|
|
nodes: [],
|
|
},
|
|
},
|
|
],
|
|
pageInfo: {
|
|
hasNextPage: false,
|
|
endCursor: "cursor-1",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
])
|
|
|
|
try {
|
|
await server.start()
|
|
Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint())
|
|
Deno.env.set("LINEAR_API_KEY", "Bearer test-token")
|
|
|
|
await viewCommand.parse()
|
|
} finally {
|
|
await server.stop()
|
|
Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT")
|
|
Deno.env.delete("LINEAR_API_KEY")
|
|
}
|
|
},
|
|
})
|
|
|
|
// Test viewing a document with --raw flag
|
|
await snapshotTest({
|
|
name: "Document View Command - Raw Output",
|
|
meta: import.meta,
|
|
colors: false,
|
|
args: ["d4b93e3b2695", "--raw"],
|
|
denoArgs: commonDenoArgs,
|
|
async fn() {
|
|
const server = new MockLinearServer([
|
|
{
|
|
queryName: "GetDocument",
|
|
variables: { id: "d4b93e3b2695" },
|
|
response: {
|
|
data: {
|
|
document: {
|
|
id: "doc-1",
|
|
title: "Delegation System Spec",
|
|
slugId: "d4b93e3b2695",
|
|
content:
|
|
"# Delegation System\n\nThis document describes the delegation system architecture.",
|
|
url:
|
|
"https://linear.app/test/document/delegation-system-spec-d4b93e3b2695",
|
|
createdAt: "2026-01-15T08:00:00Z",
|
|
updatedAt: "2026-01-18T10:30:00Z",
|
|
creator: { name: "John Doe", email: "john@example.com" },
|
|
project: { name: "TinyCloud SDK", slugId: "tinycloud-sdk" },
|
|
issue: null,
|
|
initiative: null,
|
|
team: null,
|
|
cycle: null,
|
|
release: null,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
])
|
|
|
|
try {
|
|
await server.start()
|
|
Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint())
|
|
Deno.env.set("LINEAR_API_KEY", "Bearer test-token")
|
|
|
|
await viewCommand.parse()
|
|
} finally {
|
|
await server.stop()
|
|
Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT")
|
|
Deno.env.delete("LINEAR_API_KEY")
|
|
}
|
|
},
|
|
})
|
|
|
|
// Test JSON output
|
|
await snapshotTest({
|
|
name: "Document View Command - JSON Output",
|
|
meta: import.meta,
|
|
colors: false,
|
|
args: ["d4b93e3b2695", "--json"],
|
|
denoArgs: commonDenoArgs,
|
|
async fn() {
|
|
const server = new MockLinearServer([
|
|
{
|
|
queryName: "GetDocumentWithComments",
|
|
variables: { id: "d4b93e3b2695", commentsAfter: null },
|
|
response: {
|
|
data: {
|
|
document: {
|
|
id: "doc-1",
|
|
title: "Delegation System Spec",
|
|
slugId: "d4b93e3b2695",
|
|
content:
|
|
"# Delegation System\n\nThis document describes the delegation system architecture.",
|
|
url:
|
|
"https://linear.app/test/document/delegation-system-spec-d4b93e3b2695",
|
|
createdAt: "2026-01-15T08:00:00Z",
|
|
updatedAt: "2026-01-18T10:30:00Z",
|
|
creator: { name: "John Doe", email: "john@example.com" },
|
|
project: { name: "TinyCloud SDK", slugId: "tinycloud-sdk" },
|
|
issue: null,
|
|
initiative: null,
|
|
team: null,
|
|
cycle: null,
|
|
release: null,
|
|
comments: {
|
|
nodes: [
|
|
{
|
|
id: "comment-1",
|
|
body: "Can we clarify this?",
|
|
quotedText: "delegation system architecture",
|
|
documentContentId: "document-content-1",
|
|
createdAt: "2026-01-18T11:00:00Z",
|
|
updatedAt: "2026-01-18T11:00:00Z",
|
|
archivedAt: null,
|
|
resolvedAt: null,
|
|
url: "https://linear.app/test/comment/comment-1",
|
|
user: {
|
|
name: "Jane Reviewer",
|
|
email: "jane@example.com",
|
|
},
|
|
parent: null,
|
|
},
|
|
],
|
|
pageInfo: {
|
|
hasNextPage: true,
|
|
endCursor: "cursor-1",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
queryName: "GetDocumentWithComments",
|
|
variables: { id: "d4b93e3b2695", commentsAfter: "cursor-1" },
|
|
response: {
|
|
data: {
|
|
document: {
|
|
id: "doc-1",
|
|
title: "Delegation System Spec",
|
|
slugId: "d4b93e3b2695",
|
|
content:
|
|
"# Delegation System\n\nThis document describes the delegation system architecture.",
|
|
url:
|
|
"https://linear.app/test/document/delegation-system-spec-d4b93e3b2695",
|
|
createdAt: "2026-01-15T08:00:00Z",
|
|
updatedAt: "2026-01-18T10:30:00Z",
|
|
creator: { name: "John Doe", email: "john@example.com" },
|
|
project: { name: "TinyCloud SDK", slugId: "tinycloud-sdk" },
|
|
issue: null,
|
|
initiative: null,
|
|
team: null,
|
|
cycle: null,
|
|
release: null,
|
|
comments: {
|
|
nodes: [
|
|
{
|
|
id: "comment-2",
|
|
body: "Follow-up note",
|
|
quotedText: null,
|
|
documentContentId: "document-content-1",
|
|
createdAt: "2026-01-18T12:00:00Z",
|
|
updatedAt: "2026-01-18T12:00:00Z",
|
|
archivedAt: null,
|
|
resolvedAt: null,
|
|
url: "https://linear.app/test/comment/comment-2",
|
|
user: {
|
|
name: "John Doe",
|
|
email: "john@example.com",
|
|
},
|
|
parent: {
|
|
id: "comment-1",
|
|
},
|
|
},
|
|
],
|
|
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 viewCommand.parse()
|
|
} finally {
|
|
await server.stop()
|
|
Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT")
|
|
Deno.env.delete("LINEAR_API_KEY")
|
|
}
|
|
},
|
|
})
|
|
|
|
// With --no-download, image URLs in the markdown are passed through verbatim.
|
|
// Without --no-download, the raw output would contain a local /tmp path
|
|
// (after fetching from the Linear CDN), so this snapshot exercises the
|
|
// wiring that skips the fetch.
|
|
await snapshotTest({
|
|
name: "Document View Command - No Download Keeps Remote URLs",
|
|
meta: import.meta,
|
|
colors: false,
|
|
args: ["d4b93e3b2695", "--raw", "--no-download"],
|
|
denoArgs: commonDenoArgs,
|
|
async fn() {
|
|
const server = new MockLinearServer([
|
|
{
|
|
queryName: "GetDocument",
|
|
variables: { id: "d4b93e3b2695" },
|
|
response: {
|
|
data: {
|
|
document: {
|
|
id: "doc-1",
|
|
title: "Doc With Image",
|
|
slugId: "d4b93e3b2695",
|
|
content:
|
|
"# Doc\n\n",
|
|
url:
|
|
"https://linear.app/test/document/doc-with-image-d4b93e3b2695",
|
|
createdAt: "2026-01-15T08:00:00Z",
|
|
updatedAt: "2026-01-18T10:30:00Z",
|
|
creator: { name: "John Doe", email: "john@example.com" },
|
|
project: null,
|
|
issue: null,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
])
|
|
|
|
try {
|
|
await server.start()
|
|
Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint())
|
|
Deno.env.set("LINEAR_API_KEY", "Bearer test-token")
|
|
|
|
await viewCommand.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 document attached to issue
|
|
await snapshotTest({
|
|
name: "Document View Command - Document Attached To Issue",
|
|
meta: import.meta,
|
|
colors: false,
|
|
args: ["abc123def456"],
|
|
denoArgs: commonDenoArgs,
|
|
async fn() {
|
|
const server = new MockLinearServer([
|
|
{
|
|
queryName: "GetDocument",
|
|
variables: { id: "abc123def456" },
|
|
response: {
|
|
data: {
|
|
document: {
|
|
id: "doc-3",
|
|
title: "Investigation Notes",
|
|
slugId: "abc123def456",
|
|
content:
|
|
"# Investigation Notes\n\nNotes from investigating TC-123.",
|
|
url:
|
|
"https://linear.app/test/document/investigation-notes-abc123def456",
|
|
createdAt: "2026-01-16T08:00:00Z",
|
|
updatedAt: "2026-01-16T09:00:00Z",
|
|
creator: { name: "Alice Dev", email: "alice@example.com" },
|
|
project: null,
|
|
issue: { identifier: "TC-123", title: "Fix login bug" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
])
|
|
|
|
try {
|
|
await server.start()
|
|
Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint())
|
|
Deno.env.set("LINEAR_API_KEY", "Bearer test-token")
|
|
|
|
await viewCommand.parse()
|
|
} finally {
|
|
await server.stop()
|
|
Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT")
|
|
Deno.env.delete("LINEAR_API_KEY")
|
|
}
|
|
},
|
|
})
|
|
|
|
// Linear reports an unknown document as a GraphQL error whose user-facing
|
|
// message is "Could not find referenced Document." (no "not found" in it).
|
|
// That has to become a clean not-found message naming the reference; the
|
|
// command used to re-throw from its catch block and print a stack trace.
|
|
Deno.test("Document View Command - unknown document is reported as not found", async () => {
|
|
const server = new MockLinearServer([
|
|
{
|
|
queryName: "GetDocument",
|
|
response: {
|
|
errors: [{
|
|
message: "Entity not found: Document",
|
|
extensions: {
|
|
type: "invalid input",
|
|
userError: true,
|
|
userPresentableMessage: "Could not find referenced Document.",
|
|
},
|
|
}],
|
|
},
|
|
},
|
|
])
|
|
await server.start()
|
|
Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint())
|
|
Deno.env.set("LINEAR_API_KEY", "Bearer test-token")
|
|
|
|
const errorLogs: string[] = []
|
|
const errorStub = stub(console, "error", (...args: unknown[]) => {
|
|
errorLogs.push(args.map(String).join(" "))
|
|
})
|
|
const exitStub = stub(Deno, "exit", (_code?: number) => {
|
|
throw new Error("EXIT")
|
|
})
|
|
|
|
let exited = false
|
|
try {
|
|
await viewCommand.parse(["doc-missing", "--raw"])
|
|
} catch (e) {
|
|
if (!(e instanceof Error) || e.message !== "EXIT") throw e
|
|
exited = true
|
|
} finally {
|
|
errorStub.restore()
|
|
exitStub.restore()
|
|
await server.stop()
|
|
Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT")
|
|
Deno.env.delete("LINEAR_API_KEY")
|
|
}
|
|
|
|
assertEquals(exited, true)
|
|
assertEquals(
|
|
errorLogs.some((l) =>
|
|
l.toLowerCase().includes("not found") && l.includes("doc-missing")
|
|
),
|
|
true,
|
|
errorLogs.join("\n"),
|
|
)
|
|
})
|