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
301 lines
7.1 KiB
TypeScript
301 lines
7.1 KiB
TypeScript
import { snapshotTest } from "@cliffy/testing"
|
|
import { assertEquals } from "@std/assert"
|
|
import { stub } from "@std/testing/mock"
|
|
import { commentAddCommand } from "../../../src/commands/document/document-comment-add.ts"
|
|
import {
|
|
commonDenoArgs,
|
|
setupMockLinearServer,
|
|
} from "../../utils/test-helpers.ts"
|
|
|
|
const documentTarget = {
|
|
queryName: "GetDocumentCommentTarget",
|
|
variables: { id: "spec-abc123" },
|
|
response: {
|
|
data: {
|
|
document: {
|
|
id: "doc-uuid-1",
|
|
title: "API Spec",
|
|
documentContentId: "content-uuid-1",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// A document comment hangs off the document's content record, so the mutation
|
|
// must carry documentContentId, not the document id the user typed.
|
|
await snapshotTest({
|
|
name: "Document Comment Add Command - With Body Flag",
|
|
meta: import.meta,
|
|
colors: false,
|
|
args: ["spec-abc123", "--body", "Looks good to me"],
|
|
denoArgs: commonDenoArgs,
|
|
async fn() {
|
|
const { cleanup } = await setupMockLinearServer([
|
|
documentTarget,
|
|
{
|
|
queryName: "AddComment",
|
|
variables: {
|
|
input: {
|
|
body: "Looks good to me",
|
|
documentContentId: "content-uuid-1",
|
|
},
|
|
},
|
|
response: {
|
|
data: {
|
|
commentCreate: {
|
|
success: true,
|
|
comment: {
|
|
id: "comment-uuid-1",
|
|
url:
|
|
"https://linear.app/team/document/spec-abc123#comment-uuid-1",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
])
|
|
|
|
try {
|
|
await commentAddCommand.parse()
|
|
} finally {
|
|
await cleanup()
|
|
}
|
|
},
|
|
})
|
|
|
|
// A reply still names the document content: Linear rejects a bare parentId.
|
|
await snapshotTest({
|
|
name: "Document Comment Add Command - With Reply To Flag",
|
|
meta: import.meta,
|
|
colors: false,
|
|
args: [
|
|
"spec-abc123",
|
|
"--body",
|
|
"Agreed",
|
|
"--reply-to",
|
|
"comment-uuid-1",
|
|
],
|
|
denoArgs: commonDenoArgs,
|
|
async fn() {
|
|
const { cleanup } = await setupMockLinearServer([
|
|
documentTarget,
|
|
{
|
|
queryName: "AddComment",
|
|
variables: {
|
|
input: {
|
|
body: "Agreed",
|
|
documentContentId: "content-uuid-1",
|
|
parentId: "comment-uuid-1",
|
|
},
|
|
},
|
|
response: {
|
|
data: {
|
|
commentCreate: {
|
|
success: true,
|
|
comment: {
|
|
id: "comment-uuid-2",
|
|
url:
|
|
"https://linear.app/team/document/spec-abc123#comment-uuid-2",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
])
|
|
|
|
try {
|
|
await commentAddCommand.parse()
|
|
} finally {
|
|
await cleanup()
|
|
}
|
|
},
|
|
})
|
|
|
|
await snapshotTest({
|
|
name: "Document Comment Add Command - With Body File",
|
|
meta: import.meta,
|
|
colors: false,
|
|
args: ["spec-abc123", "--body-file", "__BODY_FILE__"],
|
|
denoArgs: commonDenoArgs,
|
|
async fn() {
|
|
const bodyFile = await Deno.makeTempFile({ suffix: ".md" })
|
|
await Deno.writeTextFile(bodyFile, "## From a file\n\nWith **markdown**.\n")
|
|
const { cleanup } = await setupMockLinearServer([
|
|
documentTarget,
|
|
{
|
|
queryName: "AddComment",
|
|
variables: {
|
|
input: {
|
|
body: "## From a file\n\nWith **markdown**.\n",
|
|
documentContentId: "content-uuid-1",
|
|
},
|
|
},
|
|
response: {
|
|
data: {
|
|
commentCreate: {
|
|
success: true,
|
|
comment: {
|
|
id: "comment-uuid-3",
|
|
url:
|
|
"https://linear.app/team/document/spec-abc123#comment-uuid-3",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
])
|
|
|
|
try {
|
|
await commentAddCommand.parse([
|
|
"spec-abc123",
|
|
"--body-file",
|
|
bodyFile,
|
|
])
|
|
} finally {
|
|
await cleanup()
|
|
await Deno.remove(bodyFile)
|
|
}
|
|
},
|
|
})
|
|
|
|
await snapshotTest({
|
|
name: "Document Comment Add Command - Help",
|
|
meta: import.meta,
|
|
colors: false,
|
|
args: ["--help"],
|
|
denoArgs: commonDenoArgs,
|
|
async fn() {
|
|
await commentAddCommand.parse()
|
|
},
|
|
})
|
|
|
|
function captureFailure() {
|
|
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")
|
|
})
|
|
return {
|
|
errorLogs,
|
|
restore() {
|
|
errorStub.restore()
|
|
exitStub.restore()
|
|
},
|
|
}
|
|
}
|
|
|
|
async function expectExit(run: () => Promise<unknown>): Promise<boolean> {
|
|
try {
|
|
await run()
|
|
return false
|
|
} catch (e) {
|
|
if (!(e instanceof Error) || e.message !== "EXIT") throw e
|
|
return true
|
|
}
|
|
}
|
|
|
|
Deno.test("Document Comment Add Command - rejects --body with --body-file before any request", async () => {
|
|
// No handlers: any request would fail with a different message.
|
|
const { cleanup } = await setupMockLinearServer([])
|
|
const failure = captureFailure()
|
|
let exited = false
|
|
try {
|
|
exited = await expectExit(() =>
|
|
commentAddCommand.parse([
|
|
"spec-abc123",
|
|
"--body",
|
|
"x",
|
|
"--body-file",
|
|
"y.md",
|
|
])
|
|
)
|
|
} finally {
|
|
failure.restore()
|
|
await cleanup()
|
|
}
|
|
|
|
assertEquals(exited, true)
|
|
assertEquals(
|
|
failure.errorLogs.some((l) =>
|
|
l.includes("Cannot specify both --body and --body-file")
|
|
),
|
|
true,
|
|
failure.errorLogs.join("\n"),
|
|
)
|
|
})
|
|
|
|
Deno.test("Document Comment Add Command - unknown document is reported as not found", async () => {
|
|
const { cleanup } = await setupMockLinearServer([
|
|
{
|
|
queryName: "GetDocumentCommentTarget",
|
|
response: {
|
|
errors: [{
|
|
message: "Entity not found: Document",
|
|
extensions: {
|
|
type: "invalid input",
|
|
userError: true,
|
|
userPresentableMessage: "Could not find referenced Document.",
|
|
},
|
|
}],
|
|
},
|
|
},
|
|
])
|
|
const failure = captureFailure()
|
|
let exited = false
|
|
try {
|
|
exited = await expectExit(() =>
|
|
commentAddCommand.parse(["doc-missing", "--body", "x"])
|
|
)
|
|
} finally {
|
|
failure.restore()
|
|
await cleanup()
|
|
}
|
|
|
|
assertEquals(exited, true)
|
|
assertEquals(
|
|
failure.errorLogs.some((l) =>
|
|
l.toLowerCase().includes("not found") && l.includes("doc-missing")
|
|
),
|
|
true,
|
|
failure.errorLogs.join("\n"),
|
|
)
|
|
})
|
|
|
|
Deno.test("Document Comment Add Command - refuses a document without a content record", async () => {
|
|
const { cleanup } = await setupMockLinearServer([
|
|
{
|
|
queryName: "GetDocumentCommentTarget",
|
|
response: {
|
|
data: {
|
|
document: {
|
|
id: "doc-uuid-2",
|
|
title: "Empty Doc",
|
|
documentContentId: null,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
])
|
|
const failure = captureFailure()
|
|
let exited = false
|
|
try {
|
|
exited = await expectExit(() =>
|
|
commentAddCommand.parse(["empty-doc", "--body", "x"])
|
|
)
|
|
} finally {
|
|
failure.restore()
|
|
await cleanup()
|
|
}
|
|
|
|
assertEquals(exited, true)
|
|
assertEquals(
|
|
failure.errorLogs.some((l) =>
|
|
l.includes('Document "Empty Doc" has no content record')
|
|
),
|
|
true,
|
|
failure.errorLogs.join("\n"),
|
|
)
|
|
})
|