Files
Peter Schilling 3a00cc7604 Accept team names and IDs, and state names, wherever the CLI takes them
Every command that takes a team now accepts its key, name, or UUID through
one shared resolver (findTeam / resolveTeam / resolveTeams), replacing the
key-only getTeamIdByKey and the ad-hoc uppercasing spread across commands.
One aliased ResolveTeam operation looks up key, name, and (for UUID-shaped
input) id in a single round trip; precedence is key, then id, then name,
applied client-side so a reference that equals one team's key and another
team's name always means the key. Keys stay the canonical downstream form:
filters that matched on team.key still do, with the server's uppercase key,
and callers that need a UUID take it from the same resolved object. An
unknown team now errors with the list of valid keys instead of an empty
result or a raw "Entity not found" from the API.

Only explicit input goes through the resolver. The configured default team
is already a normalized key, and resolving it would add a round trip to
every default-team invocation of the most-used commands for no gain. In
issue create, the interactive substring picker survives only for that
default; an explicit --team that matches nothing errors like everywhere
else.

issue query --state and issue mine --state take a workflow state name or
ID as well as the six type tokens. Names and IDs are resolved within the
queried scope (the team, the teams, or the whole workspace under
--all-teams, where a name matches every team's same-named state), so a
state from another team errors instead of silently matching nothing, and
the error lists the scope's states. Type-only input still sends the same
{ type: { in } } filter with no extra request; a mix of types and names
becomes an or-filter.

The MCP server already describes these parameters as "key, name, or ID"
and "type, name, or ID"; this brings the CLI to parity so an agent does
not need a preliminary team list to translate a name into a key.

Claude-Session: https://claude.ai/code/session_01A9qEGri4p2HZMQSuYsBmub
2026-09-05 07:19:38 -07:00

529 lines
15 KiB
TypeScript

import { assertEquals } from "@std/assert"
import { snapshotTest } from "@cliffy/testing"
import {
formatDocumentAttachment,
listCommand,
} from "../../../src/commands/document/document-list.ts"
import { MockLinearServer } from "../../utils/mock_linear_server.ts"
import { commonDenoArgs } from "../../utils/test-helpers.ts"
// Test help output
await snapshotTest({
name: "Document List Command - Help Text",
meta: import.meta,
colors: false,
args: ["--help"],
denoArgs: commonDenoArgs,
async fn() {
await listCommand.parse()
},
})
// NOTE: The human-readable table tests for "List All Documents", "Filter By Project",
// and "Filter By Issue" have been removed because they display relative timestamps
// (e.g., "3 days ago") which are inherently non-deterministic. The fakeTime solution
// causes hangs with mock servers (see project-list.test.ts for similar issue).
// Issue filtering is covered below via the --json path, which prints raw timestamps
// and is therefore deterministic. The ATTACHMENT column formatting is covered by
// the formatDocumentAttachment unit tests at the bottom of this file.
// Test JSON output (uses raw timestamps, not relative - deterministic)
await snapshotTest({
name: "Document List Command - JSON Output",
meta: import.meta,
colors: false,
args: ["--json"],
denoArgs: commonDenoArgs,
async fn() {
const server = new MockLinearServer([
{
queryName: "ListDocuments",
variables: { first: 50 },
response: {
data: {
documents: {
nodes: [
{
id: "doc-1",
title: "Delegation System Spec",
slugId: "d4b93e3b2695",
url:
"https://linear.app/test/document/delegation-system-spec-d4b93e3b2695",
updatedAt: "2026-01-18T10:30:00Z",
project: { name: "TinyCloud SDK", slugId: "tinycloud-sdk" },
issue: null,
initiative: null,
team: null,
cycle: null,
release: null,
creator: { name: "John Doe" },
},
{
id: "doc-2",
title: "Team Handbook",
slugId: "b7e81a4c9f12",
url:
"https://linear.app/test/document/team-handbook-b7e81a4c9f12",
updatedAt: "2026-01-19T09:00:00Z",
project: null,
issue: null,
initiative: null,
team: { name: "Engineering", key: "ENG" },
cycle: null,
release: null,
creator: { name: "Jane Smith" },
},
],
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 listCommand.parse()
} finally {
await server.stop()
Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT")
Deno.env.delete("LINEAR_API_KEY")
}
},
})
// Regression test: --issue resolves the identifier to the issue UUID and
// filters on IssueFilter.id. The mock declares the exact request variables, so
// a wrong filter shape matches no mock, falls through to the
// NO_MOCK_CONFIGURED error and fails the test rather than quietly producing
// different output.
await snapshotTest({
name: "Document List Command - Filter By Issue JSON Output",
meta: import.meta,
colors: false,
args: ["--issue", "TC-123", "--json"],
denoArgs: commonDenoArgs,
async fn() {
const server = new MockLinearServer([
{
queryName: "GetIssueForDocumentTarget",
variables: { id: "TC-123" },
response: { data: { issue: { id: "issue-uuid-1" } } },
},
{
queryName: "ListDocuments",
variables: {
filter: { issue: { id: { eq: "issue-uuid-1" } } },
first: 50,
},
response: {
data: {
documents: {
nodes: [
{
id: "doc-2",
title: "Migration Runbook",
slugId: "a1c27f6d8e04",
url:
"https://linear.app/test/document/migration-runbook-a1c27f6d8e04",
updatedAt: "2026-01-20T14:15:00Z",
project: null,
issue: { identifier: "TC-123", title: "Plan the migration" },
initiative: null,
team: null,
cycle: null,
release: null,
creator: { name: "Jane Smith" },
},
],
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 listCommand.parse()
} finally {
await server.stop()
Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT")
Deno.env.delete("LINEAR_API_KEY")
}
},
})
// Regression test: --project accepts a name (not just a slug ID) by resolving
// it to the project UUID and filtering on ProjectFilter.id
await snapshotTest({
name: "Document List Command - Filter By Project Name JSON Output",
meta: import.meta,
colors: false,
args: ["--project", "TinyCloud SDK", "--json"],
denoArgs: commonDenoArgs,
async fn() {
const server = new MockLinearServer([
{
queryName: "GetProjectIdByName",
variables: { name: "TinyCloud SDK" },
response: {
data: { projects: { nodes: [{ id: "project-uuid-9" }] } },
},
},
{
queryName: "ListDocuments",
variables: {
filter: { project: { id: { eq: "project-uuid-9" } } },
first: 50,
},
response: {
data: {
documents: {
nodes: [
{
id: "doc-1",
title: "Delegation System Spec",
slugId: "d4b93e3b2695",
url:
"https://linear.app/test/document/delegation-system-spec-d4b93e3b2695",
updatedAt: "2026-01-18T10:30:00Z",
project: { name: "TinyCloud SDK", slugId: "tinycloud-sdk" },
issue: null,
initiative: null,
team: null,
cycle: null,
release: null,
creator: { name: "John Doe" },
},
],
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 listCommand.parse()
} finally {
await server.stop()
Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT")
Deno.env.delete("LINEAR_API_KEY")
}
},
})
// --team resolves the key so an invalid key errors instead of returning an
// empty list, and filters on TeamFilter.id
await snapshotTest({
name: "Document List Command - Filter By Team JSON Output",
meta: import.meta,
colors: false,
args: ["--team", "eng", "--json"],
denoArgs: commonDenoArgs,
async fn() {
const server = new MockLinearServer([
{
queryName: "ResolveTeam",
variables: { reference: "eng" },
response: {
data: {
teams: {
nodes: [{ id: "team-eng-id", key: "ENG", name: "Engineering" }],
},
},
},
},
{
queryName: "ListDocuments",
variables: {
filter: { team: { id: { eq: "team-eng-id" } } },
first: 50,
},
response: {
data: {
documents: {
nodes: [
{
id: "doc-3",
title: "Team Handbook",
slugId: "b7e81a4c9f12",
url:
"https://linear.app/test/document/team-handbook-b7e81a4c9f12",
updatedAt: "2026-01-19T09:00:00Z",
project: null,
issue: null,
initiative: null,
team: { name: "Engineering", key: "ENG" },
cycle: null,
release: null,
creator: { name: "Jane Smith" },
},
],
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 listCommand.parse()
} finally {
await server.stop()
Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT")
Deno.env.delete("LINEAR_API_KEY")
}
},
})
// --release resolves name/version to the release UUID and filters on
// ReleaseFilter.id
await snapshotTest({
name: "Document List Command - Filter By Release JSON Output",
meta: import.meta,
colors: false,
args: ["--release", "Summer Launch", "--json"],
denoArgs: commonDenoArgs,
async fn() {
const server = new MockLinearServer([
{
queryName: "ResolveReleases",
variables: { input: "Summer Launch", after: null },
response: {
data: {
releases: {
nodes: [
{
id: "release-uuid-1",
name: "Summer Launch",
version: "2026.8",
},
],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
},
},
{
queryName: "ListDocuments",
variables: {
filter: { release: { id: { eq: "release-uuid-1" } } },
first: 50,
},
response: {
data: {
documents: {
nodes: [
{
id: "doc-4",
title: "Release Notes",
slugId: "c9f04b7e2a31",
url:
"https://linear.app/test/document/release-notes-c9f04b7e2a31",
updatedAt: "2026-01-21T16:45:00Z",
project: null,
issue: null,
initiative: null,
team: null,
cycle: null,
release: { name: "Summer Launch", version: "2026.8" },
creator: { name: "John Doe" },
},
],
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 listCommand.parse()
} finally {
await server.stop()
Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT")
Deno.env.delete("LINEAR_API_KEY")
}
},
})
// Two distinct target filters can never match one document — must error
// locally without a server
await snapshotTest({
name: "Document List Command - Multiple Targets Error",
meta: import.meta,
colors: false,
canFail: true,
args: ["--project", "roadmap", "--issue", "TC-123"],
denoArgs: commonDenoArgs,
async fn() {
Deno.env.set("LINEAR_API_KEY", "dummy-key-for-validation-test")
try {
await listCommand.parse()
} finally {
Deno.env.delete("LINEAR_API_KEY")
}
},
})
// Test empty results
await snapshotTest({
name: "Document List Command - Empty Results",
meta: import.meta,
colors: false,
args: [],
denoArgs: commonDenoArgs,
async fn() {
const server = new MockLinearServer([
{
queryName: "ListDocuments",
variables: { first: 50 },
response: {
data: {
documents: {
nodes: [],
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 listCommand.parse()
} finally {
await server.stop()
Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT")
Deno.env.delete("LINEAR_API_KEY")
}
},
})
// The ATTACHMENT column labels the target type, since names come from six
// different namespaces. (Table snapshots are avoided for their relative
// timestamps, so the formatter is tested directly.)
Deno.test("formatDocumentAttachment - labels each target type", () => {
assertEquals(
formatDocumentAttachment({ project: { name: "Roadmap" } }),
"Project: Roadmap",
)
assertEquals(
formatDocumentAttachment({ issue: { identifier: "TC-123" } }),
"Issue: TC-123",
)
assertEquals(
formatDocumentAttachment({ initiative: { name: "Dev Experience" } }),
"Initiative: Dev Experience",
)
assertEquals(
formatDocumentAttachment({ team: { name: "Engineering", key: "ENG" } }),
"Team: Engineering (ENG)",
)
assertEquals(
formatDocumentAttachment({
cycle: { name: "Sprint 8", number: 8, team: { key: "ENG" } },
}),
"Cycle: ENG #8 — Sprint 8",
)
assertEquals(
formatDocumentAttachment({
cycle: { name: null, number: 4, team: { key: "CLI" } },
}),
"Cycle: CLI #4",
)
assertEquals(
formatDocumentAttachment({
release: { name: "Summer Launch", version: "2026.8" },
}),
"Release: Summer Launch (2026.8)",
)
assertEquals(
formatDocumentAttachment({
release: { name: "Summer Launch", version: null },
}),
"Release: Summer Launch",
)
assertEquals(formatDocumentAttachment({}), "-")
})
// A team UUID goes through the resolver's id lookup and lands in the same
// TeamFilter.id filter.
await snapshotTest({
name: "Document List Command - Filter By Team ID JSON Output",
meta: import.meta,
colors: false,
args: ["--team", "11111111-2222-3333-4444-555555555555", "--json"],
denoArgs: commonDenoArgs,
async fn() {
const server = new MockLinearServer([
{
queryName: "ResolveTeam",
variables: {
reference: "11111111-2222-3333-4444-555555555555",
id: "11111111-2222-3333-4444-555555555555",
isUuid: true,
},
response: {
data: {
teams: { nodes: [] },
teamById: {
nodes: [{ id: "team-eng-id", key: "ENG", name: "Engineering" }],
},
},
},
},
{
queryName: "ListDocuments",
variables: {
filter: { team: { id: { eq: "team-eng-id" } } },
first: 50,
},
response: {
data: {
documents: {
nodes: [],
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 listCommand.parse()
} finally {
await server.stop()
Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT")
Deno.env.delete("LINEAR_API_KEY")
}
},
})