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

136 lines
4.5 KiB
TypeScript

import { assertEquals, assertStringIncludes } from "@std/assert"
import { cli } from "../../src/cli.ts"
import { membersCommand } from "../../src/commands/team/team-members.ts"
import { listCommand } from "../../src/commands/user/user-list.ts"
// Cliffy's Command type carries its options in its generic parameters, so a
// concrete command is not assignable to a bare `Command`. Introspecting the
// tree only needs this much of the shape.
interface CommandOption {
flags: string[]
description: string
}
interface IntrospectableCommand {
getName(): string
getDescription(): string
getCommands(): IntrospectableCommand[]
getOptions(hidden?: boolean): CommandOption[]
getOption(name: string): CommandOption | undefined
}
// Declaring one of these means the command takes a body Linear renders as
// Markdown, which is exactly where the mention and collapsible rules apply.
const MARKDOWN_BODY_OPTIONS = [
"--body-file",
"--description-file",
"--content-file",
"--content",
]
// Pinned so that adding a Markdown-bodied command (or a --body-file flag to an
// existing one) forces a deliberate decision about the guidance rather than
// silently shipping a command an agent will misuse.
const EXPECTED_MARKDOWN_COMMANDS = [
"document comment add",
"document create",
"document update",
"initiative comment add",
"initiative-update create",
"issue comment add",
"issue comment update",
"issue create",
"issue update",
"project comment add",
"project create",
"project update",
"project-update create",
]
function findMarkdownCommands(
command: IntrospectableCommand,
path: string[] = [],
): { path: string; command: IntrospectableCommand }[] {
const found: { path: string; command: IntrospectableCommand }[] = []
for (const sub of command.getCommands()) {
const subPath = [...path, sub.getName()]
const flags = sub.getOptions(true).flatMap((option) => option.flags)
if (MARKDOWN_BODY_OPTIONS.some((flag) => flags.includes(flag))) {
found.push({ path: subPath.join(" "), command: sub })
}
found.push(...findMarkdownCommands(sub, subPath))
}
return found
}
Deno.test("markdown help - every Markdown-bodied command is accounted for", () => {
const discovered = findMarkdownCommands(cli).map((entry) => entry.path).sort()
assertEquals(discovered, EXPECTED_MARKDOWN_COMMANDS)
})
// Asserting the substance rather than `includes(MARKDOWN_HINT)`: comparing a
// command's description against the same constant it was built from still
// passes if the shared hint is gutted to a bare "see `linear markdown`".
Deno.test("markdown help - every Markdown-bodied command teaches real mentions", () => {
for (const { path, command } of findMarkdownCommands(cli)) {
const description = command.getDescription()
assertStringIncludes(
description,
"a plain Linear URL creates a mention",
`${path} does not state how mentions are created`,
)
for (const wrongForm of ["`@name`", "`@[Name](id)`", "`[Name](url)`"]) {
assertStringIncludes(
description,
wrongForm,
`${path} does not warn that ${wrongForm} fails to mention anyone`,
)
}
assertStringIncludes(
description,
"linear team members <TEAM> --json",
`${path} does not say how to look a person's URL up`,
)
assertStringIncludes(
description,
"linear markdown",
`${path} does not point at the full reference`,
)
}
})
function jsonOptionDescription(command: IntrospectableCommand): string {
const option = command.getOption("json")
if (option == null) throw new Error("expected a --json option")
return option.description
}
// The `url` field only exists in --json output, so this is where an agent finds
// out what it is for.
Deno.test("markdown help - member listings explain the url field", () => {
assertStringIncludes(
jsonOptionDescription(membersCommand),
"url mentions them when pasted into Markdown",
)
assertStringIncludes(
jsonOptionDescription(listCommand),
"url mentions them when pasted into Markdown",
)
})
// Mentioning someone who is not on the team is usually a mistake, so the
// workspace-wide listing must not read as an equal alternative to the team one.
Deno.test("markdown help - workspace listing keeps the team-first safeguard", () => {
const description = jsonOptionDescription(listCommand)
assertStringIncludes(description, "searches the whole workspace")
assertStringIncludes(description, "prefer `linear team members <TEAM>`")
assertStringIncludes(description, "confirm before mentioning someone outside")
})