Files
Peter Schilling d7bba4a670 Add document, project, and initiative comments with threaded replies
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
2026-09-05 07:22:36 -07:00

238 lines
6.3 KiB
TypeScript

import { snapshotTest } from "@cliffy/testing"
import { assertEquals } from "@std/assert"
import { stub } from "@std/testing/mock"
import { commentListCommand } from "../../../src/commands/document/document-comment-list.ts"
import {
commonDenoArgs,
setupMockLinearServer,
} from "../../utils/test-helpers.ts"
// A document thread: a top-level comment with a reply, and an inline comment
// anchored to a passage (quotedText), which shows the quoted passage.
const documentComments = {
nodes: [
{
id: "comment-uuid-1",
body: "Should this section mention the rollout plan?",
quotedText: null,
createdAt: "2024-01-15T10:30:00Z",
updatedAt: "2024-01-15T10:30:00Z",
editedAt: null,
url: "https://linear.app/team/document/spec-abc123#comment-uuid-1",
user: { id: "user-uuid-1", name: "ada", displayName: "Ada Lovelace" },
externalUser: null,
botActor: null,
parent: null,
},
{
id: "comment-uuid-2",
body: "Yes, adding it now.",
quotedText: null,
createdAt: "2024-01-15T11:00:00Z",
updatedAt: "2024-01-15T11:00:00Z",
editedAt: null,
url: "https://linear.app/team/document/spec-abc123#comment-uuid-2",
user: { id: "user-uuid-2", name: "grace", displayName: "Grace Hopper" },
externalUser: null,
botActor: null,
parent: { id: "comment-uuid-1" },
},
{
id: "comment-uuid-3",
body: "This number is out of date.",
quotedText: "handles 500 requests per second",
createdAt: "2024-01-15T12:30:00Z",
updatedAt: "2024-01-15T12:30:00Z",
editedAt: null,
url: "https://linear.app/team/document/spec-abc123#comment-uuid-3",
user: { id: "user-uuid-1", name: "ada", displayName: "Ada Lovelace" },
externalUser: null,
botActor: null,
parent: null,
},
],
pageInfo: { hasNextPage: false, endCursor: "comment-uuid-3" },
}
await snapshotTest({
name: "Document Comment List Command - Threads With Inline Comment",
meta: import.meta,
colors: false,
args: ["spec-abc123"],
denoArgs: commonDenoArgs,
async fn() {
const { cleanup } = await setupMockLinearServer([
{
queryName: "GetDocumentComments",
variables: { id: "spec-abc123", after: null },
response: {
data: { document: { id: "doc-uuid-1", comments: documentComments } },
},
},
])
try {
await commentListCommand.parse()
} finally {
await cleanup()
}
},
})
await snapshotTest({
name: "Document Comment List Command - JSON Output",
meta: import.meta,
colors: false,
args: ["spec-abc123", "--json"],
denoArgs: commonDenoArgs,
async fn() {
const { cleanup } = await setupMockLinearServer([
{
queryName: "GetDocumentComments",
// The selection must carry the inline anchor and the parent link.
queryIncludes: "quotedText",
variables: { id: "spec-abc123", after: null },
response: {
data: { document: { id: "doc-uuid-1", comments: documentComments } },
},
},
])
try {
await commentListCommand.parse()
} finally {
await cleanup()
}
},
})
await snapshotTest({
name: "Document Comment List Command - No Comments",
meta: import.meta,
colors: false,
args: ["spec-abc123"],
denoArgs: commonDenoArgs,
async fn() {
const { cleanup } = await setupMockLinearServer([
{
queryName: "GetDocumentComments",
response: {
data: {
document: {
id: "doc-uuid-1",
comments: {
nodes: [],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
},
},
},
])
try {
await commentListCommand.parse()
} finally {
await cleanup()
}
},
})
// The mock server answers the first matching handler and never consumes it,
// so each page is pinned to its cursor.
await snapshotTest({
name: "Document Comment List Command - JSON Output Follows Pagination",
meta: import.meta,
colors: false,
args: ["spec-abc123", "--json"],
denoArgs: commonDenoArgs,
async fn() {
const [first, second, third] = documentComments.nodes
const { cleanup } = await setupMockLinearServer([
{
queryName: "GetDocumentComments",
variables: { id: "spec-abc123", after: null },
response: {
data: {
document: {
id: "doc-uuid-1",
comments: {
nodes: [first, second],
pageInfo: { hasNextPage: true, endCursor: "cursor-1" },
},
},
},
},
},
{
queryName: "GetDocumentComments",
variables: { id: "spec-abc123", after: "cursor-1" },
response: {
data: {
document: {
id: "doc-uuid-1",
comments: {
nodes: [third],
pageInfo: { hasNextPage: false, endCursor: "cursor-2" },
},
},
},
},
},
])
try {
await commentListCommand.parse()
} finally {
await cleanup()
}
},
})
Deno.test("Document Comment List Command - unknown document is reported as not found", async () => {
const { cleanup } = await setupMockLinearServer([
{
queryName: "GetDocumentComments",
response: {
errors: [{
message: "Entity not found: Document",
extensions: {
type: "invalid input",
userError: true,
userPresentableMessage: "Could not find referenced Document.",
},
}],
},
},
])
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 commentListCommand.parse(["doc-missing"])
} catch (e) {
if (!(e instanceof Error) || e.message !== "EXIT") throw e
exited = true
} finally {
errorStub.restore()
exitStub.restore()
await cleanup()
}
assertEquals(exited, true)
assertEquals(
errorLogs.some((l) =>
l.toLowerCase().includes("not found") && l.includes("doc-missing")
),
true,
errorLogs.join("\n"),
)
})