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
166 lines
4.8 KiB
TypeScript
166 lines
4.8 KiB
TypeScript
import { assertEquals, assertRejects } from "@std/assert"
|
|
import { stripAnsiCode } from "@std/fmt/colors"
|
|
import { stub } from "@std/testing/mock"
|
|
import {
|
|
buildCommentCreateInput,
|
|
collectCommentPages,
|
|
type CommentPageInfo,
|
|
renderCommentThreads,
|
|
resolveCommentBody,
|
|
} from "../../src/utils/comments.ts"
|
|
import { CliError, ValidationError } from "../../src/utils/errors.ts"
|
|
|
|
// Linear requires exactly one owning entity even on a reply, so the builder
|
|
// must always emit the target key next to parentId.
|
|
Deno.test("buildCommentCreateInput pairs the target with parentId on a reply", () => {
|
|
assertEquals(
|
|
buildCommentCreateInput(
|
|
{ kind: "document", documentContentId: "content-1" },
|
|
{ body: "hi", parentId: "root-1" },
|
|
),
|
|
{ body: "hi", parentId: "root-1", documentContentId: "content-1" },
|
|
)
|
|
assertEquals(
|
|
buildCommentCreateInput(
|
|
{ kind: "initiative", initiativeId: "init-1" },
|
|
{ body: "hi" },
|
|
),
|
|
{ body: "hi", initiativeId: "init-1" },
|
|
)
|
|
})
|
|
|
|
Deno.test("resolveCommentBody rejects --body together with --body-file", async () => {
|
|
await assertRejects(
|
|
() => resolveCommentBody({ body: "a", bodyFile: "b.md" }),
|
|
ValidationError,
|
|
"Cannot specify both",
|
|
)
|
|
})
|
|
|
|
// Explicit input that is blank is an error, never a fall-through to the prompt.
|
|
Deno.test("resolveCommentBody rejects a whitespace-only --body", async () => {
|
|
await assertRejects(
|
|
() => resolveCommentBody({ body: " \n" }),
|
|
ValidationError,
|
|
"cannot be empty",
|
|
)
|
|
})
|
|
|
|
Deno.test("resolveCommentBody rejects an empty body file", async () => {
|
|
const file = await Deno.makeTempFile({ suffix: ".md" })
|
|
try {
|
|
await Deno.writeTextFile(file, "\n\n")
|
|
await assertRejects(
|
|
() => resolveCommentBody({ bodyFile: file }),
|
|
ValidationError,
|
|
"Body file is empty",
|
|
)
|
|
} finally {
|
|
await Deno.remove(file)
|
|
}
|
|
})
|
|
|
|
Deno.test("resolveCommentBody wraps an unreadable body file", async () => {
|
|
await assertRejects(
|
|
() => resolveCommentBody({ bodyFile: "/nonexistent/comment.md" }),
|
|
ValidationError,
|
|
"Failed to read body file",
|
|
)
|
|
})
|
|
|
|
Deno.test("resolveCommentBody returns undefined when neither flag is given", async () => {
|
|
assertEquals(await resolveCommentBody({}), undefined)
|
|
})
|
|
|
|
Deno.test("collectCommentPages follows cursors and keeps the last pageInfo", async () => {
|
|
const requested: (string | null)[] = []
|
|
const result = await collectCommentPages<string, CommentPageInfo>((after) => {
|
|
requested.push(after)
|
|
if (after == null) {
|
|
return Promise.resolve({
|
|
nodes: ["a", "b"],
|
|
pageInfo: { hasNextPage: true, endCursor: "cursor-1" },
|
|
})
|
|
}
|
|
return Promise.resolve({
|
|
nodes: ["c"],
|
|
pageInfo: { hasNextPage: false, endCursor: "cursor-2" },
|
|
})
|
|
})
|
|
|
|
assertEquals(requested, [null, "cursor-1"])
|
|
assertEquals(result, {
|
|
nodes: ["a", "b", "c"],
|
|
pageInfo: { hasNextPage: false, endCursor: "cursor-2" },
|
|
})
|
|
})
|
|
|
|
Deno.test("collectCommentPages refuses a next page without a cursor", async () => {
|
|
await assertRejects(
|
|
() =>
|
|
collectCommentPages(() =>
|
|
Promise.resolve({
|
|
nodes: ["a"],
|
|
pageInfo: { hasNextPage: true, endCursor: null },
|
|
})
|
|
),
|
|
CliError,
|
|
"usable cursor",
|
|
)
|
|
})
|
|
|
|
// A server that keeps handing back the same cursor must not spin forever.
|
|
Deno.test("collectCommentPages refuses a repeated cursor", async () => {
|
|
let calls = 0
|
|
await assertRejects(
|
|
() =>
|
|
collectCommentPages(() => {
|
|
calls++
|
|
return Promise.resolve({
|
|
nodes: ["a"],
|
|
pageInfo: { hasNextPage: true, endCursor: "same" },
|
|
})
|
|
}),
|
|
CliError,
|
|
"usable cursor",
|
|
)
|
|
assertEquals(calls, 2)
|
|
})
|
|
|
|
// A reply whose root is missing from the list (deleted, or paged out) used to
|
|
// vanish from the rendered output entirely.
|
|
Deno.test("renderCommentThreads keeps a reply whose parent is absent", () => {
|
|
const lines: string[] = []
|
|
// The renderer bolds the author, so strip ANSI before matching text: CI
|
|
// runs without NO_COLOR and the escape codes would split "@Ada replied".
|
|
const logStub = stub(console, "log", (...args: unknown[]) => {
|
|
lines.push(stripAnsiCode(args.map(String).join(" ")))
|
|
})
|
|
try {
|
|
renderCommentThreads(
|
|
[
|
|
{
|
|
id: "reply-1",
|
|
body: "still here",
|
|
createdAt: "2024-01-15T10:30:00Z",
|
|
user: { name: "ada", displayName: "Ada" },
|
|
parent: { id: "gone-root" },
|
|
},
|
|
],
|
|
{ emptyMessage: "none" },
|
|
)
|
|
} finally {
|
|
logStub.restore()
|
|
}
|
|
|
|
assertEquals(
|
|
lines.some((line) =>
|
|
line.includes("@Ada replied to [gone-root]") && line.includes("[reply-1]")
|
|
),
|
|
true,
|
|
)
|
|
assertEquals(lines.some((line) => line.includes("still here")), true)
|
|
// It is not misrepresented as a root comment.
|
|
assertEquals(lines.some((line) => line.includes("commented")), false)
|
|
})
|