From 60f4e2b08e6a0e749ddc1086c4caf4caf374b329 Mon Sep 17 00:00:00 2001 From: Ryan Schumacher Date: Fri, 22 May 2026 05:53:35 -0500 Subject: [PATCH 01/12] fix(cli): accept both UUID and name for --project/--milestone (#221) The CLI was inconsistent about whether --project and --milestone flags accept a UUID, slug, or name depending on which command you called. Several commands silently rejected one form with a misleading "not found" error. - Extend resolveProjectId to accept UUID, slug, or name uniformly. - Add resolveMilestoneId that accepts a UUID directly, or a name when --project is supplied so the milestone lookup can be scoped. - Wire the resolvers into issue create/update/mine/query, milestone create/update/list, and milestone view (which now also accepts an optional --project for name-based lookup). - Remove document create's duplicate local resolver in favor of the shared one. - Update --help to say "UUID, slug ID, or name" everywhere. Closes #221 --- src/commands/document/document-create.ts | 75 +--------- src/commands/issue/issue-create.ts | 33 +++-- src/commands/issue/issue-mine.ts | 29 ++-- src/commands/issue/issue-query.ts | 17 ++- src/commands/issue/issue-update.ts | 42 +++--- src/commands/milestone/milestone-create.ts | 6 +- src/commands/milestone/milestone-list.ts | 6 +- src/commands/milestone/milestone-update.ts | 5 +- src/commands/milestone/milestone-view.ts | 21 ++- src/utils/linear.ts | 90 +++++++----- .../document-create.test.ts.snap | 16 +- .../commands/document/document-create.test.ts | 13 +- .../__snapshots__/issue-create.test.ts.snap | 4 +- .../__snapshots__/issue-list.test.ts.snap | 4 +- .../__snapshots__/issue-mine.test.ts.snap | 4 +- .../__snapshots__/issue-query.test.ts.snap | 4 +- .../__snapshots__/issue-update.test.ts.snap | 53 +++++-- test/commands/issue/issue-update.test.ts | 83 +++++++++++ .../milestone-create.test.ts.snap | 22 ++- .../__snapshots__/milestone-list.test.ts.snap | 6 +- .../milestone-update.test.ts.snap | 12 +- .../__snapshots__/milestone-view.test.ts.snap | 5 +- .../milestone/milestone-create.test.ts | 88 +++++++++-- .../commands/milestone/milestone-list.test.ts | 26 ++-- test/utils/linear.test.ts | 139 +++++++++++++++++- 25 files changed, 564 insertions(+), 239 deletions(-) diff --git a/src/commands/document/document-create.ts b/src/commands/document/document-create.ts index 598f47c6..63af3b0e 100644 --- a/src/commands/document/document-create.ts +++ b/src/commands/document/document-create.ts @@ -2,6 +2,7 @@ import { Command } from "@cliffy/command" import { Input, Select } from "@cliffy/prompt" import { gql } from "../../__codegen__/gql.ts" import { getGraphQLClient } from "../../utils/graphql.ts" +import { resolveProjectId } from "../../utils/linear.ts" import { getEditor, openEditor } from "../../utils/editor.ts" import { readIdsFromStdin } from "../../utils/bulk.ts" import { @@ -43,7 +44,10 @@ export const createCommand = new Command() .option("-t, --title ", "Document title (required)") .option("-c, --content ", "Markdown content (inline)") .option("-f, --content-file ", "Read content from file") - .option("--project ", "Attach to project (slug or ID)") + .option( + "--project ", + "Attach to project (UUID, slug ID, or name)", + ) .option("--issue ", "Attach to issue (identifier like TC-123)") .option("--icon ", "Document icon (emoji)") .option("-i, --interactive", "Interactive mode with prompts") @@ -146,12 +150,7 @@ export const createCommand = new Command() // Resolve project ID if provided let projectId: string | undefined if (project) { - projectId = await resolveProjectId(client, project) - if (!projectId) { - throw new NotFoundError("Project", project, { - suggestion: "Provide a valid project slug or ID.", - }) - } + projectId = await resolveProjectId(project) } // Resolve issue ID if provided @@ -273,15 +272,9 @@ async function promptInteractiveCreate(): Promise<{ if (attachTo === "project") { const projectInput = await Input.prompt({ - message: "Project slug or ID", + message: "Project (UUID, slug ID, or name)", }) - const client = getGraphQLClient() - projectId = await resolveProjectId(client, projectInput) - if (!projectId) { - throw new NotFoundError("Project", projectInput, { - suggestion: "Provide a valid project slug or ID.", - }) - } + projectId = await resolveProjectId(projectInput) } else if (attachTo === "issue") { const issueInput = await Input.prompt({ message: "Issue identifier (e.g., TC-123)", @@ -304,58 +297,6 @@ async function promptInteractiveCreate(): Promise<{ } } -async function resolveProjectId( - // deno-lint-ignore no-explicit-any - client: any, - projectInput: string, -): Promise { - // First try to get by slug/ID directly - const projectQuery = gql(` - query GetProjectForDocument($slugId: String!) { - project(id: $slugId) { - id - name - } - } - `) - - try { - const result = await client.request(projectQuery, { slugId: projectInput }) - if (result.project) { - return result.project.id - } - } catch { - // Project not found by ID, try searching by name - } - - // Search by name - const searchQuery = gql(` - query SearchProjectsForDocument($filter: ProjectFilter) { - projects(filter: $filter, first: 1) { - nodes { - id - name - } - } - } - `) - - try { - const result = await client.request(searchQuery, { - filter: { - name: { containsIgnoreCase: projectInput }, - }, - }) - if (result.projects.nodes.length > 0) { - return result.projects.nodes[0].id - } - } catch { - // Search failed - } - - return undefined -} - async function resolveIssueId( // deno-lint-ignore no-explicit-any client: any, diff --git a/src/commands/issue/issue-create.ts b/src/commands/issue/issue-create.ts index d9f37b29..4a25a2b4 100644 --- a/src/commands/issue/issue-create.ts +++ b/src/commands/issue/issue-create.ts @@ -13,9 +13,10 @@ import { getIssueLabelIdByNameForTeam, getIssueLabelOptionsByNameForTeam, getLabelsForTeam, - getMilestoneIdByName, getProjectIdByName, getProjectOptionsByName, + isLinearUuid, + resolveMilestoneId, getTeamIdByKey, getTeamKey, getWorkflowStateByNameOrType, @@ -490,7 +491,7 @@ export const createCommand = new Command() ) .option( "--project ", - "Name or slug ID of the project with the issue", + "Project for the issue (UUID, slug ID, or name)", ) .option( "-s, --state ", @@ -498,7 +499,7 @@ export const createCommand = new Command() ) .option( "--milestone ", - "Name of the project milestone", + "Project milestone (UUID, or name when --project is set)", ) .option( "--cycle ", @@ -752,19 +753,23 @@ export const createCommand = new Command() let projectMilestoneId: string | undefined if (milestone != null) { - if (projectId == null) { - throw new ValidationError( - "--milestone requires --project to be set", - { - suggestion: - "Use --project to specify which project the milestone belongs to.", - }, + if (isLinearUuid(milestone)) { + projectMilestoneId = milestone + } else { + if (projectId == null) { + throw new ValidationError( + "--milestone requires --project to be set", + { + suggestion: + "Use --project to specify which project the milestone belongs to, or pass a milestone UUID directly.", + }, + ) + } + projectMilestoneId = await resolveMilestoneId( + milestone, + projectId, ) } - projectMilestoneId = await getMilestoneIdByName( - milestone, - projectId, - ) } let cycleId: string | undefined diff --git a/src/commands/issue/issue-mine.ts b/src/commands/issue/issue-mine.ts index e8ee021f..a892deb3 100644 --- a/src/commands/issue/issue-mine.ts +++ b/src/commands/issue/issue-mine.ts @@ -11,8 +11,9 @@ import { import { fetchIssuesForState, getCycleIdByNameOrNumber, - getMilestoneIdByName, getProjectIdByName, + isLinearUuid, + resolveMilestoneId, getProjectOptionsByName, getTeamIdByKey, getTeamKey, @@ -69,7 +70,7 @@ export const mineCommand = new Command() ) .option( "--project ", - "Filter by project name", + "Filter by project (UUID, slug ID, or name)", ) .option( "--project-label ", @@ -81,7 +82,7 @@ export const mineCommand = new Command() ) .option( "--milestone ", - "Filter by project milestone name (requires --project)", + "Filter by project milestone (UUID, or name when --project is set)", ) .option( "-l, --label ", @@ -243,16 +244,20 @@ export const mineCommand = new Command() }, ) } - if (projectId == null) { - throw new ValidationError( - "--milestone requires --project to be set", - { - suggestion: - "Use --project to specify which project the milestone belongs to.", - }, - ) + if (isLinearUuid(milestone)) { + milestoneId = milestone + } else { + if (projectId == null) { + throw new ValidationError( + "--milestone requires --project to be set", + { + suggestion: + "Use --project to specify which project the milestone belongs to, or pass a milestone UUID directly.", + }, + ) + } + milestoneId = await resolveMilestoneId(milestone, projectId) } - milestoneId = await getMilestoneIdByName(milestone, projectId) } const labelNames = labels && labels.length > 0 diff --git a/src/commands/issue/issue-query.ts b/src/commands/issue/issue-query.ts index 5578d7c4..1de20101 100644 --- a/src/commands/issue/issue-query.ts +++ b/src/commands/issue/issue-query.ts @@ -11,8 +11,9 @@ import { import { fetchIssuesForQuery, getCycleIdByNameOrNumber, - getMilestoneIdByName, getProjectIdByName, + isLinearUuid, + resolveMilestoneId, getProjectOptionsByName, getTeamIdByKey, getTeamKey, @@ -77,7 +78,7 @@ export const queryCommand = new Command() ) .option( "--project ", - "Filter by project name", + "Filter by project (UUID, slug ID, or name)", ) .option( "--project-label ", @@ -89,7 +90,7 @@ export const queryCommand = new Command() ) .option( "--milestone ", - "Filter by project milestone name (requires --project)", + "Filter by project milestone (UUID, or name when --project is set)", ) .option( "-l, --label ", @@ -182,12 +183,12 @@ export const queryCommand = new Command() ) } - if (milestone != null && project == null) { + if (milestone != null && project == null && !isLinearUuid(milestone)) { throw new ValidationError( "--milestone requires --project to be set", { suggestion: - "Use --project to specify which project the milestone belongs to.", + "Use --project to specify which project the milestone belongs to, or pass a milestone UUID directly.", }, ) } @@ -295,8 +296,10 @@ export const queryCommand = new Command() } let milestoneId: string | undefined - if (milestone != null && projectId != null) { - milestoneId = await getMilestoneIdByName(milestone, projectId) + if (milestone != null) { + milestoneId = isLinearUuid(milestone) + ? milestone + : await resolveMilestoneId(milestone, projectId) } const labelNames = label && label.length > 0 diff --git a/src/commands/issue/issue-update.ts b/src/commands/issue/issue-update.ts index f86e2e1c..324ba2dd 100644 --- a/src/commands/issue/issue-update.ts +++ b/src/commands/issue/issue-update.ts @@ -8,8 +8,9 @@ import { getIssueIdentifier, getIssueLabelIdByNameForTeam, getIssueProjectId, - getMilestoneIdByName, getProjectIdByName, + isLinearUuid, + resolveMilestoneId, getTeamIdByKey, getWorkflowStateByNameOrType, lookupUserId, @@ -64,7 +65,7 @@ export const updateCommand = new Command() ) .option( "--project ", - "Name or slug ID of the project with the issue", + "Project to assign the issue to (UUID, slug ID, or name)", ) .option( "-s, --state ", @@ -72,7 +73,7 @@ export const updateCommand = new Command() ) .option( "--milestone ", - "Name of the project milestone", + "Project milestone (UUID, or name when --project is set or the issue already has a project)", ) .option( "--cycle ", @@ -196,27 +197,34 @@ export const updateCommand = new Command() if (project !== undefined) { projectId = await getProjectIdByName(project) if (projectId === undefined) { - throw new NotFoundError("Project", project) + throw new NotFoundError("Project", project, { + suggestion: + "Pass a project UUID, slug ID (from `linear project list`), or exact project name.", + }) } } let projectMilestoneId: string | undefined if (milestone != null) { - const milestoneProjectId = projectId ?? - await getIssueProjectId(issueId) - if (milestoneProjectId == null) { - throw new ValidationError( - "--milestone requires --project to be set (issue has no existing project)", - { - suggestion: - "Use --project to specify the project for the milestone.", - }, + if (isLinearUuid(milestone)) { + projectMilestoneId = milestone + } else { + const milestoneProjectId = projectId ?? + await getIssueProjectId(issueId) + if (milestoneProjectId == null) { + throw new ValidationError( + "--milestone requires --project to be set (issue has no existing project)", + { + suggestion: + "Use --project to specify the project for the milestone, or pass a milestone UUID directly.", + }, + ) + } + projectMilestoneId = await resolveMilestoneId( + milestone, + milestoneProjectId, ) } - projectMilestoneId = await getMilestoneIdByName( - milestone, - milestoneProjectId, - ) } let cycleId: string | undefined diff --git a/src/commands/milestone/milestone-create.ts b/src/commands/milestone/milestone-create.ts index f83f1117..188499d2 100644 --- a/src/commands/milestone/milestone-create.ts +++ b/src/commands/milestone/milestone-create.ts @@ -25,7 +25,11 @@ const CreateProjectMilestone = gql(` export const createCommand = new Command() .name("create") .description("Create a new project milestone") - .option("--project ", "Project ID", { required: true }) + .option( + "--project ", + "Project (UUID, slug ID, or name)", + { required: true }, + ) .option("--name ", "Milestone name", { required: true }) .option("--description ", "Milestone description") .option("--target-date ", "Target date (YYYY-MM-DD)") diff --git a/src/commands/milestone/milestone-list.ts b/src/commands/milestone/milestone-list.ts index bf1be4a1..ef6f3009 100644 --- a/src/commands/milestone/milestone-list.ts +++ b/src/commands/milestone/milestone-list.ts @@ -31,7 +31,11 @@ const GetProjectMilestones = gql(` export const listCommand = new Command() .name("list") .description("List milestones for a project") - .option("--project ", "Project ID", { required: true }) + .option( + "--project ", + "Project (UUID, slug ID, or name)", + { required: true }, + ) .action(async ({ project: projectIdOrSlug }) => { const { Spinner } = await import("@std/cli/unstable-spinner") const showSpinner = shouldShowSpinner() diff --git a/src/commands/milestone/milestone-update.ts b/src/commands/milestone/milestone-update.ts index 74b53c59..fe93e13a 100644 --- a/src/commands/milestone/milestone-update.ts +++ b/src/commands/milestone/milestone-update.ts @@ -34,7 +34,10 @@ export const updateCommand = new Command() "--sort-order ", "Sort order relative to other milestones", ) - .option("--project ", "Move to a different project") + .option( + "--project ", + "Move to a different project (UUID, slug ID, or name)", + ) .action( async ( { name, description, targetDate, sortOrder, project: projectIdOrSlug }, diff --git a/src/commands/milestone/milestone-view.ts b/src/commands/milestone/milestone-view.ts index 8aa6e13c..f8b8d334 100644 --- a/src/commands/milestone/milestone-view.ts +++ b/src/commands/milestone/milestone-view.ts @@ -5,6 +5,7 @@ import { getGraphQLClient } from "../../utils/graphql.ts" import { formatRelativeTime } from "../../utils/display.ts" import { shouldShowSpinner } from "../../utils/hyperlink.ts" import { handleError, NotFoundError } from "../../utils/errors.ts" +import { resolveMilestoneId, resolveProjectId } from "../../utils/linear.ts" const GetMilestoneDetails = gql(` query GetMilestoneDetails($id: String!) { @@ -41,14 +42,28 @@ export const viewCommand = new Command() .name("view") .description("View milestone details") .alias("v") - .arguments("") - .action(async (_options, milestoneId) => { + .arguments("") + .option( + "--project ", + "Project for resolving a milestone name (UUID, slug ID, or name)", + ) + .action(async ({ project }, milestoneInput) => { const { Spinner } = await import("@std/cli/unstable-spinner") const showSpinner = shouldShowSpinner() const spinner = showSpinner ? new Spinner() : null spinner?.start() try { + let milestoneId: string + if (project != null) { + const projectId = await resolveProjectId(project) + milestoneId = await resolveMilestoneId(milestoneInput, projectId) + } else { + // Without --project, pass the input through to the API. Linear will + // resolve it if it's a UUID and return null otherwise. + milestoneId = milestoneInput + } + const client = getGraphQLClient() const result = await client.request(GetMilestoneDetails, { id: milestoneId, @@ -57,7 +72,7 @@ export const viewCommand = new Command() const milestone = result.projectMilestone if (!milestone) { - throw new NotFoundError("Milestone", milestoneId) + throw new NotFoundError("Milestone", milestoneInput) } // Build the display diff --git a/src/utils/linear.ts b/src/utils/linear.ts index bfdd2a78..8d8afbc5 100644 --- a/src/utils/linear.ts +++ b/src/utils/linear.ts @@ -1187,11 +1187,26 @@ export async function searchIssuesByTerm( } } +const UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +export function isLinearUuid(value: string): boolean { + return UUID_REGEX.test(value) +} + +/** + * Look up a project ID by UUID, slug ID, or exact name. + * Returns undefined when no project matches. Use [[resolveProjectId]] when + * you want a missing project to throw. + */ export async function getProjectIdByName( - name: string, + input: string, ): Promise { + if (isLinearUuid(input)) return input + const client = getGraphQLClient() - const query = gql(/* GraphQL */ ` + + const nameQuery = gql(/* GraphQL */ ` query GetProjectIdByName($name: String!) { projects(filter: { name: { eq: $name } }) { nodes { @@ -1200,14 +1215,10 @@ export async function getProjectIdByName( } } `) - const data = await client.request(query, { name }) - const projectId = data.projects?.nodes[0]?.id - if (projectId) return projectId + const nameData = await client.request(nameQuery, { name: input }) + const nameMatch = nameData.projects?.nodes[0]?.id + if (nameMatch) return nameMatch - // Fall back to matching by slugId (the 12-char hex string visible in - // `project list` output and Linear URLs). This provides a reliable - // alternative when project names contain special characters that the - // exact-match name filter doesn't handle well. const slugQuery = gql(/* GraphQL */ ` query GetProjectIdBySlugId($slugId: String!) { projects(filter: { slugId: { eq: $slugId } }) { @@ -1217,41 +1228,24 @@ export async function getProjectIdByName( } } `) - const slugData = await client.request(slugQuery, { slugId: name }) + const slugData = await client.request(slugQuery, { slugId: input }) return slugData.projects?.nodes[0]?.id } +/** + * Resolve a project to its UUID. Accepts a UUID, slug ID, or exact name. + * Throws NotFoundError if none match. + */ export async function resolveProjectId( - projectIdOrSlug: string, + input: string, ): Promise { - // If it looks like a full UUID, try to use it directly - if ( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( - projectIdOrSlug, - ) - ) { - return projectIdOrSlug - } - - // Otherwise, treat it as a slug and look it up - const client = getGraphQLClient() - const query = gql(/* GraphQL */ ` - query GetProjectBySlug($slugId: String!) { - projects(filter: { slugId: { eq: $slugId } }) { - nodes { - id - slugId - } - } - } - `) - const data = await client.request(query, { slugId: projectIdOrSlug }) - const projectId = data.projects?.nodes[0]?.id - + const projectId = await getProjectIdByName(input) if (!projectId) { - throw new NotFoundError("Project", projectIdOrSlug) + throw new NotFoundError("Project", input, { + suggestion: + "Pass a project UUID, slug ID (from `linear project list`), or exact project name.", + }) } - return projectId } @@ -1574,6 +1568,28 @@ export async function getIssueProjectId( return data.issue?.project?.id ?? undefined } +/** + * Resolve a milestone to its UUID. Accepts a UUID directly, or a milestone + * name when scoped to a project. Throws when a name is passed without a + * project context. + */ +export async function resolveMilestoneId( + input: string, + projectId?: string, +): Promise { + if (isLinearUuid(input)) return input + if (!projectId) { + throw new ValidationError( + `Cannot resolve milestone "${input}" without --project`, + { + suggestion: + "Pass a milestone UUID, or specify --project so the milestone name can be looked up within that project.", + }, + ) + } + return await getMilestoneIdByName(input, projectId) +} + export async function getMilestoneIdByName( milestoneName: string, projectId: string, diff --git a/test/commands/document/__snapshots__/document-create.test.ts.snap b/test/commands/document/__snapshots__/document-create.test.ts.snap index a8b80a7f..c4dfbdd9 100644 --- a/test/commands/document/__snapshots__/document-create.test.ts.snap +++ b/test/commands/document/__snapshots__/document-create.test.ts.snap @@ -11,14 +11,14 @@ Description: Options: - -h, --help - Show this help. - -t, --title - Document title (required) - -c, --content <content> - Markdown content (inline) - -f, --content-file <path> - Read content from file - --project <project> - Attach to project (slug or ID) - --issue <issue> - Attach to issue (identifier like TC-123) - --icon <icon> - Document icon (emoji) - -i, --interactive - Interactive mode with prompts + -h, --help - Show this help. + -t, --title <title> - Document title (required) + -c, --content <content> - Markdown content (inline) + -f, --content-file <path> - Read content from file + --project <project> - Attach to project (UUID, slug ID, or name) + --issue <issue> - Attach to issue (identifier like TC-123) + --icon <icon> - Document icon (emoji) + -i, --interactive - Interactive mode with prompts " stderr: diff --git a/test/commands/document/document-create.test.ts b/test/commands/document/document-create.test.ts index 885721f4..d1062f45 100644 --- a/test/commands/document/document-create.test.ts +++ b/test/commands/document/document-create.test.ts @@ -79,16 +79,17 @@ await snapshotTest({ denoArgs: commonDenoArgs, async fn() { const server = new MockLinearServer([ - // Mock project resolution query + // Shared project resolver tries name first, then slugId { - queryName: "GetProjectForDocument", + queryName: "GetProjectIdByName", + response: { data: { projects: { nodes: [] } } }, + }, + { + queryName: "GetProjectIdBySlugId", variables: { slugId: "tinycloud-sdk" }, response: { data: { - project: { - id: "project-uuid-123", - name: "TinyCloud SDK", - }, + projects: { nodes: [{ id: "project-uuid-123" }] }, }, }, }, diff --git a/test/commands/issue/__snapshots__/issue-create.test.ts.snap b/test/commands/issue/__snapshots__/issue-create.test.ts.snap index 729a7283..121308d5 100644 --- a/test/commands/issue/__snapshots__/issue-create.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-create.test.ts.snap @@ -22,9 +22,9 @@ Options: --description-file <path> - Read description from a file (preferred for markdown content) -l, --label <label> - Issue label associated with the issue. May be repeated. --team <team> - Team associated with the issue (if not your default team) - --project <project> - Name or slug ID of the project with the issue + --project <project> - Project for the issue (UUID, slug ID, or name) -s, --state <state> - Workflow state for the issue (by name or type) - --milestone <milestone> - Name of the project milestone + --milestone <milestone> - Project milestone (UUID, or name when --project is set) --cycle <cycle> - Cycle name, number, or 'active' --no-use-default-template - Do not use default template for the issue --no-interactive - Disable interactive prompts diff --git a/test/commands/issue/__snapshots__/issue-list.test.ts.snap b/test/commands/issue/__snapshots__/issue-list.test.ts.snap index 610a4096..72684889 100644 --- a/test/commands/issue/__snapshots__/issue-list.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-list.test.ts.snap @@ -17,10 +17,10 @@ Options: --all-states - Show issues from all states --sort <sort> - Sort order (can also be set via LINEAR_ISSUE_SORT) (Values: \\x1b[32m"manual"\\x1b[39m, \\x1b[32m"priority"\\x1b[39m) --team <team> - Team to list issues for (if not your default team) - --project <project> - Filter by project name + --project <project> - Filter by project (UUID, slug ID, or name) --project-label <projectLabel> - Filter by project label name (shows issues from all projects with this label) --cycle <cycle> - Filter by cycle name, number, or 'active' - --milestone <milestone> - Filter by project milestone name (requires --project) + --milestone <milestone> - Filter by project milestone (UUID, or name when --project is set) -l, --label <label> - Filter by label name (can be repeated for multiple labels) --limit <limit> - Maximum number of issues to fetch (default: 50, use 0 for unlimited) (Default: \\x1b[33m50\\x1b[39m) --created-after <date> - Filter issues created after this date (ISO 8601 or YYYY-MM-DD) diff --git a/test/commands/issue/__snapshots__/issue-mine.test.ts.snap b/test/commands/issue/__snapshots__/issue-mine.test.ts.snap index 4512a51c..7274fc0e 100644 --- a/test/commands/issue/__snapshots__/issue-mine.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-mine.test.ts.snap @@ -17,10 +17,10 @@ Options: --all-states - Show issues from all states --sort <sort> - Sort order (can also be set via LINEAR_ISSUE_SORT) (Values: \\x1b[32m"manual"\\x1b[39m, \\x1b[32m"priority"\\x1b[39m) --team <team> - Team to list issues for (if not your default team) - --project <project> - Filter by project name + --project <project> - Filter by project (UUID, slug ID, or name) --project-label <projectLabel> - Filter by project label name (shows issues from all projects with this label) --cycle <cycle> - Filter by cycle name, number, or 'active' - --milestone <milestone> - Filter by project milestone name (requires --project) + --milestone <milestone> - Filter by project milestone (UUID, or name when --project is set) -l, --label <label> - Filter by label name (can be repeated for multiple labels) --limit <limit> - Maximum number of issues to fetch (default: 50, use 0 for unlimited) (Default: \\x1b[33m50\\x1b[39m) --created-after <date> - Filter issues created after this date (ISO 8601 or YYYY-MM-DD) diff --git a/test/commands/issue/__snapshots__/issue-query.test.ts.snap b/test/commands/issue/__snapshots__/issue-query.test.ts.snap index 8adef3b4..190c2932 100644 --- a/test/commands/issue/__snapshots__/issue-query.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-query.test.ts.snap @@ -23,10 +23,10 @@ Options: -A, --all-assignees - Show issues for all assignees (this is the default) -U, --unassigned - Show only unassigned issues --sort <sort> - Sort order: manual or priority (default: priority, not available with --search) (Values: \\x1b[32m"manual"\\x1b[39m, \\x1b[32m"priority"\\x1b[39m) - --project <project> - Filter by project name + --project <project> - Filter by project (UUID, slug ID, or name) --project-label <projectLabel> - Filter by project label name (shows issues from all projects with this label) --cycle <cycle> - Filter by cycle name, number, or 'active' - --milestone <milestone> - Filter by project milestone name (requires --project) + --milestone <milestone> - Filter by project milestone (UUID, or name when --project is set) -l, --label <label> - Filter by label name (can be repeated for multiple labels) --limit <limit> - Maximum number of issues to fetch (default: 50, use 0 for unlimited) (Default: \\x1b[33m50\\x1b[39m) --created-after <date> - Filter issues created after this date (ISO 8601 or YYYY-MM-DD) diff --git a/test/commands/issue/__snapshots__/issue-update.test.ts.snap b/test/commands/issue/__snapshots__/issue-update.test.ts.snap index 67d8f315..cc88b8b9 100644 --- a/test/commands/issue/__snapshots__/issue-update.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-update.test.ts.snap @@ -11,21 +11,22 @@ Description: Options: - -h, --help - Show this help. - -a, --assignee <assignee> - Assign the issue to 'self' or someone (by username or name) - --due-date <dueDate> - Due date of the issue - --parent <parent> - Parent issue (if any) as a team_number code - -p, --priority <priority> - Priority of the issue (1-4, descending priority) - --estimate <estimate> - Points estimate of the issue - -d, --description <description> - Description of the issue - --description-file <path> - Read description from a file (preferred for markdown content) - -l, --label <label> - Issue label associated with the issue. May be repeated. - --team <team> - Team associated with the issue (if not your default team) - --project <project> - Name or slug ID of the project with the issue - -s, --state <state> - Workflow state for the issue (by name or type) - --milestone <milestone> - Name of the project milestone - --cycle <cycle> - Cycle name, number, or 'active' - -t, --title <title> - Title of the issue + -h, --help - Show this help. + -a, --assignee <assignee> - Assign the issue to 'self' or someone (by username or name) + --due-date <dueDate> - Due date of the issue + --parent <parent> - Parent issue (if any) as a team_number code + -p, --priority <priority> - Priority of the issue (1-4, descending priority) + --estimate <estimate> - Points estimate of the issue + -d, --description <description> - Description of the issue + --description-file <path> - Read description from a file (preferred for markdown content) + -l, --label <label> - Issue label associated with the issue. May be repeated. + --team <team> - Team associated with the issue (if not your default team) + --project <project> - Project to assign the issue to (UUID, slug ID, or name) + -s, --state <state> - Workflow state for the issue (by name or type) + --milestone <milestone> - Project milestone (UUID, or name when --project is set or the issue already has + a project) + --cycle <cycle> - Cycle name, number, or 'active' + -t, --title <title> - Title of the issue " stderr: @@ -97,3 +98,25 @@ https://linear.app/test-team/issue/ENG-123/test-issue stderr: "" `; + +snapshot[`Issue Update Command - With Project UUID 1`] = ` +stdout: +"Updating issue ENG-123 + +✓ Updated issue ENG-123: Test Issue +https://linear.app/test-team/issue/ENG-123/test-issue +" +stderr: +"" +`; + +snapshot[`Issue Update Command - With Milestone UUID (no --project required) 1`] = ` +stdout: +"Updating issue ENG-123 + +✓ Updated issue ENG-123: Test Issue +https://linear.app/test-team/issue/ENG-123/test-issue +" +stderr: +"" +`; diff --git a/test/commands/issue/issue-update.test.ts b/test/commands/issue/issue-update.test.ts index b0969d85..e1e87bcd 100644 --- a/test/commands/issue/issue-update.test.ts +++ b/test/commands/issue/issue-update.test.ts @@ -427,3 +427,86 @@ await snapshotTest({ } }, }) + +// --- #221: --project / --milestone accept UUIDs as well as names --- + +const PROJECT_UUID = "11111111-1111-1111-1111-111111111111" +const MILESTONE_UUID = "22222222-2222-2222-2222-222222222222" + +await snapshotTest({ + name: "Issue Update Command - With Project UUID", + meta: import.meta, + colors: false, + args: ["ENG-123", "--project", PROJECT_UUID], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { data: { teams: { nodes: [{ id: "team-eng-id" }] } } }, + }, + { + queryName: "UpdateIssue", + response: { + data: { + issueUpdate: { + success: true, + issue: { + id: "issue-existing-123", + identifier: "ENG-123", + url: "https://linear.app/test-team/issue/ENG-123/test-issue", + title: "Test Issue", + }, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + try { + await updateCommand.parse() + } finally { + await cleanup() + } + }, +}) + +await snapshotTest({ + name: "Issue Update Command - With Milestone UUID (no --project required)", + meta: import.meta, + colors: false, + args: ["ENG-123", "--milestone", MILESTONE_UUID], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { data: { teams: { nodes: [{ id: "team-eng-id" }] } } }, + }, + { + queryName: "UpdateIssue", + response: { + data: { + issueUpdate: { + success: true, + issue: { + id: "issue-existing-123", + identifier: "ENG-123", + url: "https://linear.app/test-team/issue/ENG-123/test-issue", + title: "Test Issue", + }, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + try { + await updateCommand.parse() + } finally { + await cleanup() + } + }, +}) diff --git a/test/commands/milestone/__snapshots__/milestone-create.test.ts.snap b/test/commands/milestone/__snapshots__/milestone-create.test.ts.snap index b63e6a12..bf35bc6c 100644 --- a/test/commands/milestone/__snapshots__/milestone-create.test.ts.snap +++ b/test/commands/milestone/__snapshots__/milestone-create.test.ts.snap @@ -3,7 +3,7 @@ export const snapshot = {}; snapshot[`Milestone Create Command - Help Text 1`] = ` stdout: " -Usage: create --project <projectId> --name <name> +Usage: create --project <project> --name <name> Description: @@ -11,11 +11,11 @@ Description: Options: - -h, --help - Show this help. - --project <projectId> - Project ID (required) - --name <name> - Milestone name (required) - --description <description> - Milestone description - --target-date <date> - Target date (YYYY-MM-DD) + -h, --help - Show this help. + --project <project> - Project (UUID, slug ID, or name) (required) + --name <name> - Milestone name (required) + --description <description> - Milestone description + --target-date <date> - Target date (YYYY-MM-DD) " stderr: @@ -42,3 +42,13 @@ stdout: stderr: "" `; + +snapshot[`Milestone Create Command - Resolves Project by Name 1`] = ` +stdout: +"✓ Created milestone: Y26 Q2 + ID: milestone-new-q2 + Project: Tech Debt +" +stderr: +"" +`; diff --git a/test/commands/milestone/__snapshots__/milestone-list.test.ts.snap b/test/commands/milestone/__snapshots__/milestone-list.test.ts.snap index 5a83351b..11aeead4 100644 --- a/test/commands/milestone/__snapshots__/milestone-list.test.ts.snap +++ b/test/commands/milestone/__snapshots__/milestone-list.test.ts.snap @@ -3,7 +3,7 @@ export const snapshot = {}; snapshot[`Milestone List Command - Help Text 1`] = ` stdout: " -Usage: list --project <projectId> +Usage: list --project <project> Description: @@ -11,8 +11,8 @@ Description: Options: - -h, --help - Show this help. - --project <projectId> - Project ID (required) + -h, --help - Show this help. + --project <project> - Project (UUID, slug ID, or name) (required) " stderr: diff --git a/test/commands/milestone/__snapshots__/milestone-update.test.ts.snap b/test/commands/milestone/__snapshots__/milestone-update.test.ts.snap index c1ea5b00..5c41cd24 100644 --- a/test/commands/milestone/__snapshots__/milestone-update.test.ts.snap +++ b/test/commands/milestone/__snapshots__/milestone-update.test.ts.snap @@ -11,12 +11,12 @@ Description: Options: - -h, --help - Show this help. - --name <name> - Milestone name - --description <description> - Milestone description - --target-date <date> - Target date (YYYY-MM-DD) - --sort-order <value> - Sort order relative to other milestones - --project <projectId> - Move to a different project + -h, --help - Show this help. + --name <name> - Milestone name + --description <description> - Milestone description + --target-date <date> - Target date (YYYY-MM-DD) + --sort-order <value> - Sort order relative to other milestones + --project <project> - Move to a different project (UUID, slug ID, or name) " stderr: diff --git a/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap b/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap index 91d6a222..d5ad7b40 100644 --- a/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap +++ b/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap @@ -3,7 +3,7 @@ export const snapshot = {}; snapshot[`Milestone View Command - Help Text 1`] = ` stdout: " -Usage: view <milestoneId> +Usage: view <milestone> Description: @@ -11,7 +11,8 @@ Description: Options: - -h, --help - Show this help. + -h, --help - Show this help. + --project <project> - Project for resolving a milestone name (UUID, slug ID, or name) " stderr: diff --git a/test/commands/milestone/milestone-create.test.ts b/test/commands/milestone/milestone-create.test.ts index fcb8530c..c4fcad77 100644 --- a/test/commands/milestone/milestone-create.test.ts +++ b/test/commands/milestone/milestone-create.test.ts @@ -34,14 +34,17 @@ await cliffySnapshotTest({ async fn() { const server = new MockLinearServer([ { - queryName: "GetProjectBySlug", + queryName: "GetProjectIdByName", + response: { + data: { projects: { nodes: [] } }, + }, + }, + { + queryName: "GetProjectIdBySlugId", response: { data: { projects: { - nodes: [{ - id: "project-123", - slugId: "project-123", - }], + nodes: [{ id: "project-123" }], }, }, }, @@ -96,14 +99,17 @@ await cliffySnapshotTest({ async fn() { const server = new MockLinearServer([ { - queryName: "GetProjectBySlug", + queryName: "GetProjectIdByName", + response: { + data: { projects: { nodes: [] } }, + }, + }, + { + queryName: "GetProjectIdBySlugId", response: { data: { projects: { - nodes: [{ - id: "project-456", - slugId: "project-456", - }], + nodes: [{ id: "project-456" }], }, }, }, @@ -142,3 +148,65 @@ await cliffySnapshotTest({ } }, }) + +// #221: --project also accepts an exact project name +await cliffySnapshotTest({ + name: "Milestone Create Command - Resolves Project by Name", + meta: import.meta, + colors: false, + args: [ + "--project", + "Tech Debt", + "--name", + "Y26 Q2", + ], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetProjectIdByName", + variables: { name: "Tech Debt" }, + response: { + data: { projects: { nodes: [{ id: "project-tech-debt-uuid" }] } }, + }, + }, + { + queryName: "CreateProjectMilestone", + variables: { + input: { + projectId: "project-tech-debt-uuid", + name: "Y26 Q2", + }, + }, + response: { + data: { + projectMilestoneCreate: { + success: true, + projectMilestone: { + id: "milestone-new-q2", + name: "Y26 Q2", + targetDate: null, + project: { + id: "project-tech-debt-uuid", + name: "Tech Debt", + }, + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await createCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) diff --git a/test/commands/milestone/milestone-list.test.ts b/test/commands/milestone/milestone-list.test.ts index a4cf54bd..4b5084d0 100644 --- a/test/commands/milestone/milestone-list.test.ts +++ b/test/commands/milestone/milestone-list.test.ts @@ -25,15 +25,14 @@ await cliffySnapshotTest({ async fn() { const server = new MockLinearServer([ { - queryName: "GetProjectBySlug", + queryName: "GetProjectIdByName", + response: { data: { projects: { nodes: [] } } }, + }, + { + queryName: "GetProjectIdBySlugId", response: { data: { - projects: { - nodes: [{ - id: "project-123", - slugId: "project-123", - }], - }, + projects: { nodes: [{ id: "project-123" }] }, }, }, }, @@ -109,15 +108,14 @@ await cliffySnapshotTest({ async fn() { const server = new MockLinearServer([ { - queryName: "GetProjectBySlug", + queryName: "GetProjectIdByName", + response: { data: { projects: { nodes: [] } } }, + }, + { + queryName: "GetProjectIdBySlugId", response: { data: { - projects: { - nodes: [{ - id: "project-456", - slugId: "project-456", - }], - }, + projects: { nodes: [{ id: "project-456" }] }, }, }, }, diff --git a/test/utils/linear.test.ts b/test/utils/linear.test.ts index 5db4ae4d..15237840 100644 --- a/test/utils/linear.test.ts +++ b/test/utils/linear.test.ts @@ -1,8 +1,12 @@ -import { assertEquals } from "@std/assert" +import { assertEquals, assertRejects } from "@std/assert" import { getIssueIdentifier, + isLinearUuid, + resolveMilestoneId, + resolveProjectId, searchIssuesByTerm, } from "../../src/utils/linear.ts" +import { NotFoundError, ValidationError } from "../../src/utils/errors.ts" import { setupMockLinearServer } from "../utils/test-helpers.ts" Deno.test("getIssueId - handles full issue identifiers", async () => { @@ -139,3 +143,136 @@ Deno.test("searchIssuesByTerm - without limit fetches a single page", async () = await cleanup() } }) + +const UUID = "00000000-0000-0000-0000-000000000000" + +Deno.test("isLinearUuid - detects UUID format", () => { + assertEquals(isLinearUuid(UUID), true) + assertEquals(isLinearUuid("ABNL-99"), false) + assertEquals(isLinearUuid("F-FOO"), false) + assertEquals(isLinearUuid("project-name with spaces"), false) + assertEquals(isLinearUuid(""), false) +}) + +Deno.test("resolveProjectId - accepts a UUID without an API call", async () => { + const { cleanup } = await setupMockLinearServer([]) + try { + const id = await resolveProjectId(UUID) + assertEquals(id, UUID) + } finally { + await cleanup() + } +}) + +Deno.test("resolveProjectId - resolves by exact name", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetProjectIdByName", + variables: { name: "Tech Debt" }, + response: { + data: { projects: { nodes: [{ id: "proj-name-uuid" }] } }, + }, + }, + ]) + try { + const id = await resolveProjectId("Tech Debt") + assertEquals(id, "proj-name-uuid") + } finally { + await cleanup() + } +}) + +Deno.test("resolveProjectId - falls back to slug ID when name does not match", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetProjectIdByName", + variables: { name: "f-foo" }, + response: { data: { projects: { nodes: [] } } }, + }, + { + queryName: "GetProjectIdBySlugId", + variables: { slugId: "f-foo" }, + response: { + data: { projects: { nodes: [{ id: "proj-slug-uuid" }] } }, + }, + }, + ]) + try { + const id = await resolveProjectId("f-foo") + assertEquals(id, "proj-slug-uuid") + } finally { + await cleanup() + } +}) + +Deno.test("resolveProjectId - throws NotFoundError when nothing matches", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetProjectIdByName", + response: { data: { projects: { nodes: [] } } }, + }, + { + queryName: "GetProjectIdBySlugId", + response: { data: { projects: { nodes: [] } } }, + }, + ]) + try { + await assertRejects( + () => resolveProjectId("nope"), + NotFoundError, + "Project not found: nope", + ) + } finally { + await cleanup() + } +}) + +Deno.test("resolveMilestoneId - accepts UUID directly without a project", async () => { + const { cleanup } = await setupMockLinearServer([]) + try { + const id = await resolveMilestoneId(UUID) + assertEquals(id, UUID) + } finally { + await cleanup() + } +}) + +Deno.test("resolveMilestoneId - resolves a name within the given project", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetProjectMilestonesForLookup", + variables: { projectId: "proj-1" }, + response: { + data: { + project: { + projectMilestones: { + nodes: [ + { id: "ms-1", name: "Y26 Q2" }, + { id: "ms-2", name: "Y26 Q3" }, + ], + }, + }, + }, + }, + }, + ]) + try { + const id = await resolveMilestoneId("Y26 Q2", "proj-1") + assertEquals(id, "ms-1") + } finally { + await cleanup() + } +}) + +Deno.test("resolveMilestoneId - errors when a name is passed without a project", async () => { + const { cleanup } = await setupMockLinearServer([]) + try { + await assertRejects( + () => resolveMilestoneId("Y26 Q2"), + ValidationError, + "Cannot resolve milestone", + ) + } finally { + await cleanup() + } +}) From dc2dca42f11e480d0d72119894314cf3abac6847 Mon Sep 17 00:00:00 2001 From: Theo Gregory <theo@gregory.sh> Date: Wed, 24 Jun 2026 14:56:04 -0500 Subject: [PATCH 02/12] fix(upload): default attachments to private, add --public opt-in Image attachments uploaded via `issue attach` and `issue comment add --attach` were sent with makePublic auto-detected to true for raster images, producing a public.linear.app URL readable by anyone, unauthenticated, with no way to opt out. This silently published screenshots of internal data from private workspaces. Default all uploads to private (uploads.linear.app), matching the Linear web app. Add a --public flag to both commands to opt into a public URL, which is only valid for raster images; requesting it for other types is now an error rather than a silent downgrade. Print a warning whenever an upload lands on a public URL. Also document the attachment commands and their privacy behaviour in the README (previously undocumented) and regenerate the skill reference. Fixes #233 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- README.md | 19 ++++++++++ skills/linear-cli/references/issue.md | 24 +++++++------ src/commands/issue/issue-attach.ts | 12 ++++++- src/commands/issue/issue-comment-add.ts | 13 +++++-- src/utils/upload.ts | 44 ++++++++++++++++++++--- test/utils/upload.test.ts | 47 +++++++++++++++++++++++++ 6 files changed, 141 insertions(+), 18 deletions(-) create mode 100644 test/utils/upload.test.ts diff --git a/README.md b/README.md index 043ee6d2..2f6eaf57 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,25 @@ linear issue comment update <id> # update a comment linear issue commits # show all commits for an issue (jj only) ``` +#### attaching files + +attach files to an issue or comment. uploads are **private** by default (readable only by workspace members), matching the Linear web app. + +```bash +linear issue attach ENG-123 ./screenshot.png # attach a file to an issue +linear issue attach ENG-123 ./doc.pdf -t "Spec" # custom attachment title +linear issue attach ENG-123 ./img.png -c "see this" # add a linked comment +linear issue comment add ENG-123 -a ./screenshot.png # attach a file to a comment +linear issue comment add ENG-123 -a ./a.png -a ./b.png # attach multiple files +``` + +by default attachments are private. pass `--public` to upload raster images (png/jpeg/gif/webp/bmp/tiff) to a public `public.linear.app` URL readable by **anyone, unauthenticated** — useful for sharing outside the workspace, but a warning is printed since it bypasses workspace access controls. non-image files cannot be made public. + +```bash +linear issue attach ENG-123 ./screenshot.png --public # public image URL +linear issue comment add ENG-123 -a ./screenshot.png --public # public image URL +``` + ### team commands ```bash diff --git a/skills/linear-cli/references/issue.md b/skills/linear-cli/references/issue.md index ae23cb0b..4450d654 100644 --- a/skills/linear-cli/references/issue.md +++ b/skills/linear-cli/references/issue.md @@ -388,12 +388,14 @@ Description: Options: - -h, --help - Show this help. - --workspace <slug> - Target workspace (uses credentials) - -b, --body <text> - Comment body text - --body-file <path> - Read comment body from a file (preferred for markdown content) - -p, --parent <id> - Parent comment ID for replies - -a, --attach <filepath> - Attach a file to the comment (can be used multiple times) + -h, --help - Show this help. + --workspace <slug> - Target workspace (uses credentials) + -b, --body <text> - Comment body text + --body-file <path> - Read comment body from a file (preferred for markdown content) + -p, --parent <id> - Parent comment ID for replies + -a, --attach <filepath> - Attach a file to the comment (can be used multiple times) + --public - Upload attached images to a public, unauthenticated URL (default: private, + workspace-members only) ``` ##### delete @@ -457,10 +459,12 @@ Description: Options: - -h, --help - Show this help. - --workspace <slug> - Target workspace (uses credentials) - -t, --title <title> - Custom title for the attachment - -c, --comment <body> - Add a comment body linked to the attachment + -h, --help - Show this help. + --workspace <slug> - Target workspace (uses credentials) + -t, --title <title> - Custom title for the attachment + -c, --comment <body> - Add a comment body linked to the attachment + --public - Upload images to a public, unauthenticated URL (default: private, + workspace-members only) ``` ### link diff --git a/src/commands/issue/issue-attach.ts b/src/commands/issue/issue-attach.ts index 82243054..013b740c 100644 --- a/src/commands/issue/issue-attach.ts +++ b/src/commands/issue/issue-attach.ts @@ -24,8 +24,12 @@ export const attachCommand = new Command() "-c, --comment <body:string>", "Add a comment body linked to the attachment", ) + .option( + "--public", + "Upload images to a public, unauthenticated URL (default: private, workspace-members only)", + ) .action(async (options, issueId, filepath) => { - const { title, comment } = options + const { title, comment, public: makePublic } = options try { const resolvedIdentifier = await getIssueIdentifier(issueId) @@ -56,8 +60,14 @@ export const attachCommand = new Command() // Upload the file const uploadResult = await uploadFile(filepath, { showProgress: shouldShowSpinner(), + makePublic, }) console.log(`✓ Uploaded ${uploadResult.filename}`) + if (uploadResult.public) { + console.warn( + `⚠ Uploaded to a public URL readable by anyone: ${uploadResult.assetUrl}`, + ) + } // Create the attachment const mutation = gql(` diff --git a/src/commands/issue/issue-comment-add.ts b/src/commands/issue/issue-comment-add.ts index da14c50d..8e49b97f 100644 --- a/src/commands/issue/issue-comment-add.ts +++ b/src/commands/issue/issue-comment-add.ts @@ -26,8 +26,12 @@ export const commentAddCommand = new Command() "Attach a file to the comment (can be used multiple times)", { collect: true }, ) + .option( + "--public", + "Upload attached images to a public, unauthenticated URL (default: private, workspace-members only)", + ) .action(async (options, issueId) => { - const { body, bodyFile, parent, attach } = options + const { body, bodyFile, parent, attach, public: makePublic } = options try { // Validate that body and bodyFile are not both provided @@ -80,6 +84,7 @@ export const commentAddCommand = new Command() for (const filepath of attachments) { const result = await uploadFile(filepath, { showProgress: shouldShowSpinner(), + makePublic, }) uploadedFiles.push({ filename: result.filename, @@ -87,6 +92,11 @@ export const commentAddCommand = new Command() isImage: result.contentType.startsWith("image/"), }) console.log(`✓ Uploaded ${result.filename}`) + if (result.public) { + console.warn( + `⚠ Uploaded to a public URL readable by anyone: ${result.assetUrl}`, + ) + } } } @@ -111,7 +121,6 @@ export const commentAddCommand = new Command() contentType: file.isImage ? "image/png" : "application/octet-stream", - size: 0, }) }) diff --git a/src/utils/upload.ts b/src/utils/upload.ts index de92a661..26b67447 100644 --- a/src/utils/upload.ts +++ b/src/utils/upload.ts @@ -111,13 +111,19 @@ export interface UploadResult { size: number /** The MIME type of the file */ contentType: string + /** Whether the file was uploaded to a public, unauthenticated URL */ + public: boolean } /** * Options for file upload */ export interface UploadOptions { - /** Make the file publicly accessible (only works for images, default: auto-detect) */ + /** + * Upload the file to a public, unauthenticated URL. Only supported for raster + * images. Defaults to false (private, workspace-members only) to match the + * Linear web app. + */ makePublic?: boolean /** Show progress indicator */ showProgress?: boolean @@ -139,6 +145,30 @@ function canBePublic(contentType: string): boolean { return publicTypes.includes(contentType) } +/** + * Resolve the effective `makePublic` value for an upload. + * + * Uploads default to private (workspace-members only), matching Linear's web + * app. Public is opt-in and only valid for raster image types — requesting it + * for any other content type is an error rather than a silent downgrade. + */ +export function resolveMakePublic( + contentType: string, + requested?: boolean, +): boolean { + const makePublic = requested ?? false + if (makePublic && !canBePublic(contentType)) { + throw new ValidationError( + `Cannot upload ${contentType || "this file"} to a public URL`, + { + suggestion: + "Linear only allows public uploads for raster images (png, jpeg, gif, webp, bmp, tiff). Remove --public to upload privately.", + }, + ) + } + return makePublic +} + /** * Upload a file to Linear's cloud storage * @@ -177,6 +207,10 @@ export async function uploadFile( const filename = basename(filepath) const contentType = getMimeType(filepath) + // Default to private; public is an explicit opt-in and only valid for images. + // Validate before doing any network work or starting the spinner. + const makePublic = resolveMakePublic(contentType, options.makePublic) + // Step 1: Request signed upload URL const mutation = gql(` mutation FileUpload($contentType: String!, $filename: String!, $size: Int!, $makePublic: Boolean) { @@ -202,9 +236,6 @@ export async function uploadFile( : null spinner?.start() - // Auto-detect makePublic based on file type (only images can be public) - const makePublic = options.makePublic ?? canBePublic(contentType) - try { const data = await client.request(mutation, { contentType, @@ -252,6 +283,7 @@ export async function uploadFile( filename, size, contentType, + public: makePublic, } } catch (error) { spinner?.stop() @@ -302,7 +334,9 @@ export async function validateFilePath(filepath: string): Promise<void> { /** * Format an uploaded file as a markdown link */ -export function formatAsMarkdownLink(result: UploadResult): string { +export function formatAsMarkdownLink( + result: Pick<UploadResult, "filename" | "assetUrl" | "contentType">, +): string { const isImage = result.contentType.startsWith("image/") if (isImage) { return `![${result.filename}](${result.assetUrl})` diff --git a/test/utils/upload.test.ts b/test/utils/upload.test.ts new file mode 100644 index 00000000..7401d38f --- /dev/null +++ b/test/utils/upload.test.ts @@ -0,0 +1,47 @@ +import { assertEquals, assertThrows } from "@std/assert" +import { resolveMakePublic } from "../../src/utils/upload.ts" +import { ValidationError } from "../../src/utils/errors.ts" + +Deno.test("resolveMakePublic - defaults to private when not requested", () => { + assertEquals(resolveMakePublic("image/png"), false) + assertEquals(resolveMakePublic("image/png", undefined), false) +}) + +Deno.test("resolveMakePublic - defaults to private for non-image types", () => { + assertEquals(resolveMakePublic("application/pdf"), false) +}) + +Deno.test("resolveMakePublic - allows public for raster images when requested", () => { + for ( + const type of [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/bmp", + "image/tiff", + ] + ) { + assertEquals(resolveMakePublic(type, true), true) + } +}) + +Deno.test("resolveMakePublic - explicit false stays private even for images", () => { + assertEquals(resolveMakePublic("image/png", false), false) +}) + +Deno.test("resolveMakePublic - rejects public for non-public-capable types", () => { + // SVG is an image but not allowed to be public by Linear + assertThrows( + () => resolveMakePublic("image/svg+xml", true), + ValidationError, + ) + assertThrows( + () => resolveMakePublic("application/pdf", true), + ValidationError, + ) + assertThrows( + () => resolveMakePublic("application/octet-stream", true), + ValidationError, + ) +}) From f5d87b88847ce43989fcda55aefae52b8a11cb85 Mon Sep 17 00:00:00 2001 From: Peter Schilling <code@schpet.com> Date: Sat, 11 Jul 2026 12:31:42 -0700 Subject: [PATCH 03/12] Follow-up to #234: validate --public per-batch before any upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #234 makes attachment uploads private by default and adds a --public opt-in that errors (rather than silently downgrades) for non-image types. Two gaps remained in `issue comment add`, both flagged independently while blind-planning the fix: - The public/type check only ran inside uploadFile, per file, at upload time. With `--public` and multiple attachments, an earlier valid image was uploaded *publicly* before a later non-image threw — a partial, and unwanted-public, upload. Pre-flight resolveMakePublic() for every attachment in the existing existence-validation loop so a mixed batch fails before anything is uploaded. - `--public` with no `--attach` was a silent no-op. Per the repo's "explicit invalid input should error" convention, reject it. Adds command-level tests for both validation paths (they short-circuit before any network call, so no upload mock is needed). --- src/commands/issue/issue-comment-add.ts | 13 ++- test/commands/issue/issue-comment-add.test.ts | 90 +++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/src/commands/issue/issue-comment-add.ts b/src/commands/issue/issue-comment-add.ts index 8e49b97f..e5aadc67 100644 --- a/src/commands/issue/issue-comment-add.ts +++ b/src/commands/issue/issue-comment-add.ts @@ -5,6 +5,8 @@ import { getGraphQLClient } from "../../utils/graphql.ts" import { getIssueIdentifier } from "../../utils/linear.ts" import { formatAsMarkdownLink, + getMimeType, + resolveMakePublic, uploadFile, validateFilePath, } from "../../utils/upload.ts" @@ -68,6 +70,12 @@ export const commentAddCommand = new Command() // Validate and upload attachments first const attachments = attach || [] + if (makePublic && attachments.length === 0) { + throw new ValidationError( + "--public requires at least one --attach", + { suggestion: "Add --attach <file> to upload, or remove --public." }, + ) + } const uploadedFiles: { filename: string assetUrl: string @@ -75,9 +83,12 @@ export const commentAddCommand = new Command() }[] = [] if (attachments.length > 0) { - // Validate all files exist before uploading + // Validate all files exist and, if --public, that every file may be + // uploaded publicly — before uploading any, so a mixed batch cannot + // publish some files before failing on an unsupported one. for (const filepath of attachments) { await validateFilePath(filepath) + resolveMakePublic(getMimeType(filepath), makePublic) } // Upload files diff --git a/test/commands/issue/issue-comment-add.test.ts b/test/commands/issue/issue-comment-add.test.ts index baeb0949..d32bf818 100644 --- a/test/commands/issue/issue-comment-add.test.ts +++ b/test/commands/issue/issue-comment-add.test.ts @@ -1,4 +1,6 @@ import { snapshotTest } from "@cliffy/testing" +import { assertEquals } from "@std/assert" +import { stub } from "@std/testing/mock" import { commentAddCommand } from "../../../src/commands/issue/issue-comment-add.ts" import { commonDenoArgs, @@ -110,3 +112,91 @@ await snapshotTest({ } }, }) + +// Test validation: --public with no attachments is rejected before any work +Deno.test("Issue Comment Add Command - rejects --public without --attach", async () => { + 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") + }) + + try { + await commentAddCommand.parse(["TEST-123", "--body", "hi", "--public"]) + } catch (e) { + // expected: handleError calls the stubbed Deno.exit which throws "EXIT" + if (!(e instanceof Error) || e.message !== "EXIT") throw e + } finally { + errorStub.restore() + exitStub.restore() + } + + assertEquals( + errorLogs.some((l) => + l.includes("--public requires at least one --attach") + ), + true, + ) +}) + +// Test validation: with --public, an unsupported file anywhere in the batch is +// rejected before ANY attachment uploads — an earlier valid image must not be +// published first. The mock server has no FileUpload handler, so if the image +// were uploaded the failure would be an upload error ("Failed to get upload +// URL"), not this validation error. Seeing the validation error proves the +// whole batch was rejected up front, before any network call. +Deno.test("Issue Comment Add Command - rejects --public batch before uploading earlier valid images", async () => { + const imageFile = await Deno.makeTempFile({ suffix: ".png" }) + const textFile = await Deno.makeTempFile({ suffix: ".txt" }) + await Deno.writeTextFile(imageFile, "pretend png") + await Deno.writeTextFile(textFile, "not an image") + + // No FileUpload handler: any actual upload attempt errors instead of hitting + // real Linear, and produces a different message than the validation error. + const { cleanup } = await setupMockLinearServer([]) + + const infoLogs: string[] = [] + const errorLogs: string[] = [] + const logStub = stub(console, "log", (...args: unknown[]) => { + infoLogs.push(args.map(String).join(" ")) + }) + const errorStub = stub(console, "error", (...args: unknown[]) => { + errorLogs.push(args.map(String).join(" ")) + }) + const exitStub = stub(Deno, "exit", (_code?: number) => { + throw new Error("EXIT") + }) + + try { + await commentAddCommand.parse([ + "TEST-123", + "--attach", + imageFile, // valid raster image, listed first + "--attach", + textFile, // unsupported for --public, listed second + "--public", + ]) + } catch (e) { + // expected: validation fails before any network upload + if (!(e instanceof Error) || e.message !== "EXIT") throw e + } finally { + logStub.restore() + errorStub.restore() + exitStub.restore() + await cleanup() + await Deno.remove(imageFile) + await Deno.remove(textFile) + } + + // The batch is rejected with the validation error... + assertEquals( + errorLogs.some((l) => + l.includes("Cannot upload text/plain to a public URL") + ), + true, + ) + // ...and no attachment was uploaded (no "✓ Uploaded" line was printed). + assertEquals(infoLogs.some((l) => l.includes("Uploaded")), false) +}) From ad630c5d69c63dc6638da646e6f13ccd9637b722 Mon Sep 17 00:00:00 2001 From: Bryan <bryandesigning@gmail.com> Date: Sat, 11 Jul 2026 16:13:23 -0400 Subject: [PATCH 04/12] feat(project): support overview content on create (#216) ## Why Linear projects have two text fields: a short description and a longer project overview. The CLI only supported the short description, so projects created from the terminal still needed a manual edit in Linear to add goals, plans, specs, or launch notes. This PR lets users create a project with its overview already filled in, either from inline markdown or a markdown file. That makes `linear project create` more useful for scripted project setup, templates, and project specs kept in a repo. It also exposes a few existing Linear create fields so users can set priority, labels, members, icon, and color when creating the project instead of doing a follow-up edit. ## What changed - `linear project create` now accepts `--content` for inline project overview markdown. - `--content-file` reads the project overview from a markdown file. - `--content` and `--content-file` are mutually exclusive. - Project create can now set priority, labels, members, icon, and color. - The command passes these values through `ProjectCreateInput` using Linear field names. ## Checks - `deno task codegen` - `deno task check` - `deno lint` - `deno task test` --- README.md | 2 + docs/usage.md | 13 + src/commands/project/project-create.ts | 607 +++++++++++------- .../__snapshots__/project-create.test.ts.snap | 61 +- test/commands/project/project-create.test.ts | 274 +++++++- 5 files changed, 709 insertions(+), 248 deletions(-) diff --git a/README.md b/README.md index 2f6eaf57..2d889ab6 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,8 @@ linear team autolinks # configure GitHub repository autolinks for Linear issues ```bash linear project list # list projects linear project view # view project details +linear project create --name "API v2" --team ENG --content-file overview.md +linear project create --name "Mobile launch" --team APP --priority high --label Launch --member jane@example.com ``` ### milestone commands diff --git a/docs/usage.md b/docs/usage.md index d98fefc6..7a19e60d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -238,6 +238,19 @@ linear team autolinks ### projects +#### create a project + +```bash +# Create with a short description and long-form overview markdown +linear project create --name "API v2" --team ENG --description "Short summary" --content "## Overview" + +# Read the project overview body from a markdown file +linear project create --name "API v2" --team ENG --content-file overview.md + +# Create with priority, labels, members, icon, and color +linear project create --name "Mobile launch" --team APP --priority high --label Launch --member jane@example.com --icon rocket --color "#5E6AD2" +``` + #### list projects ```bash diff --git a/src/commands/project/project-create.ts b/src/commands/project/project-create.ts index 5d326f23..b24c9003 100644 --- a/src/commands/project/project-create.ts +++ b/src/commands/project/project-create.ts @@ -1,7 +1,9 @@ import { Command } from "@cliffy/command" import { Input, Select } from "@cliffy/prompt" import { gql } from "../../__codegen__/gql.ts" +import type { ProjectCreateInput } from "../../__codegen__/graphql.ts" import { getGraphQLClient } from "../../utils/graphql.ts" +import type { GraphQLClient } from "graphql-request" import { getAllTeams, getTeamIdByKey, @@ -50,9 +52,37 @@ const AddProjectToInitiative = gql(` } `) +const GetProjectLabelIdByName = gql(` + query GetProjectLabelIdByNameForCreate($name: String!) { + projectLabels(filter: { name: { eqIgnoreCase: $name } }) { + nodes { + id + name + } + } + } +`) + +const PRIORITY_MAPPING: Record<string, number> = { + "none": 0, + "urgent": 1, + "high": 2, + "medium": 3, + "low": 4, +} + +function parsePriority(priority: string): number { + const mapped = PRIORITY_MAPPING[priority.toLowerCase()] + if (mapped == null) { + throw new ValidationError(`Invalid priority: ${priority}`, { + suggestion: "Valid values: none, urgent, high, medium, low", + }) + } + return mapped +} + async function resolveInitiativeId( - // deno-lint-ignore no-explicit-any - client: any, + client: GraphQLClient, idOrSlugOrName: string, ): Promise<string | undefined> { // Try as UUID first @@ -109,11 +139,49 @@ async function resolveInitiativeId( return undefined } +export async function resolveProjectContent( + content: string | undefined, + contentFile: string | undefined, +): Promise<string | undefined> { + if (content != null && contentFile != null) { + throw new ValidationError( + "Cannot specify both --content and --content-file", + ) + } + + if (contentFile == null) { + return content + } + + try { + return await Deno.readTextFile(contentFile) + } catch (error) { + throw new ValidationError(`Failed to read content file: ${contentFile}`, { + suggestion: `Error: ${ + error instanceof Error ? error.message : String(error) + }`, + }) + } +} + +async function lookupProjectLabelId( + client: GraphQLClient, + label: string, +): Promise<string | undefined> { + const result = await client.request(GetProjectLabelIdByName, { name: label }) + return result.projectLabels?.nodes[0]?.id +} + export const createCommand = new Command() .name("create") .description("Create a new Linear project") .option("-n, --name <name:string>", "Project name (required)") .option("-d, --description <description:string>", "Project description") + .option("--content <markdown:string>", "Project overview markdown") + .option( + "--content-file <path:string>", + "Read project overview markdown from a file", + ) .option( "-t, --team <team:string>", "Team key (required, can be repeated for multiple teams)", @@ -132,6 +200,22 @@ export const createCommand = new Command() "--target-date <targetDate:string>", "Target completion date (YYYY-MM-DD)", ) + .option( + "--priority <priority:string>", + "Project priority (none, urgent, high, medium, low)", + ) + .option( + "--label <label:string>", + "Project label associated with the project. May be repeated.", + { collect: true }, + ) + .option( + "--member <user:string>", + "Project member (username, email, display name, or @me). May be repeated.", + { collect: true }, + ) + .option("--icon <icon:string>", "Project icon") + .option("--color <color:string>", "Project color as a HEX string") .option( "--initiative <initiative:string>", "Add to initiative immediately (ID, slug, or name)", @@ -143,281 +227,332 @@ export const createCommand = new Command() .option("-j, --json", "Output created project as JSON") .action( async (options) => { - const { - name: providedName, - description: providedDescription, - team: providedTeams, - lead: providedLead, - status: providedStatus, - startDate: providedStartDate, - targetDate: providedTargetDate, - initiative: providedInitiative, - interactive: interactiveFlag, - json: jsonOutput, - } = options - - const client = getGraphQLClient() - const initiative = providedInitiative - - let name = providedName - let description = providedDescription - let teams = providedTeams || [] - let lead = providedLead - let status = providedStatus - let startDate = providedStartDate - let targetDate = providedTargetDate - - // Determine if we should run in interactive mode - const noFlagsProvided = !name && teams.length === 0 - const isInteractive = (noFlagsProvided || interactiveFlag) && - Deno.stdout.isTerminal() - - if (isInteractive) { - console.log("\nCreate a new project\n") - - // Name (required) - if (!name) { - name = await Input.prompt({ - message: "Project name:", - minLength: 1, - }) - } + try { + const { + name: providedName, + description: providedDescription, + content: providedContent, + contentFile: providedContentFile, + team: providedTeams, + lead: providedLead, + status: providedStatus, + startDate: providedStartDate, + targetDate: providedTargetDate, + priority: providedPriority, + label: providedLabels, + member: providedMembers, + icon: providedIcon, + color: providedColor, + initiative: providedInitiative, + interactive: interactiveFlag, + json: jsonOutput, + } = options + + const content = await resolveProjectContent( + providedContent, + providedContentFile, + ) + const priority = providedPriority != null + ? parsePriority(providedPriority) + : undefined + const client = getGraphQLClient() + const initiative = providedInitiative + + let name = providedName + let description = providedDescription + let teams = providedTeams || [] + let lead = providedLead + let status = providedStatus + let startDate = providedStartDate + let targetDate = providedTargetDate + const labels = providedLabels || [] + const members = providedMembers || [] + + // Determine if we should run in interactive mode + const noFlagsProvided = !name && teams.length === 0 + const isInteractive = (noFlagsProvided || interactiveFlag) && + Deno.stdout.isTerminal() + + if (isInteractive) { + console.log("\nCreate a new project\n") + + // Name (required) + if (!name) { + name = await Input.prompt({ + message: "Project name:", + minLength: 1, + }) + } - // Description (optional) - if (!description) { - description = await Input.prompt({ - message: "Description (optional):", - }) - if (!description) description = undefined - } + // Description (optional) + if (!description) { + description = await Input.prompt({ + message: "Description (optional):", + }) + if (!description) description = undefined + } - // Team selection (required) - if (teams.length === 0) { - const allTeams = await getAllTeams() - const teamOptions = allTeams.map((t) => ({ - name: `${t.name} (${t.key})`, - value: t.key, - })) + // Team selection (required) + if (teams.length === 0) { + const allTeams = await getAllTeams() + const teamOptions = allTeams.map((t) => ({ + name: `${t.name} (${t.key})`, + value: t.key, + })) + + // Try to get default team from config + const defaultTeam = getTeamKey() + const defaultIndex = defaultTeam + ? teamOptions.findIndex((t) => t.value === defaultTeam) + : -1 + + const selectedTeam = await Select.prompt({ + message: "Team:", + options: teamOptions, + default: defaultIndex >= 0 + ? teamOptions[defaultIndex].value + : undefined, + }) + teams = [selectedTeam] + } - // Try to get default team from config - const defaultTeam = getTeamKey() - const defaultIndex = defaultTeam - ? teamOptions.findIndex((t) => t.value === defaultTeam) - : -1 - - const selectedTeam = await Select.prompt({ - message: "Team:", - options: teamOptions, - default: defaultIndex >= 0 - ? teamOptions[defaultIndex].value - : undefined, - }) - teams = [selectedTeam] - } + // Status selection - get actual statuses from API + if (!status) { + const statusResult = await client.request(GetProjectStatuses) + const projectStatuses = statusResult.projectStatuses?.nodes || [] + + if (projectStatuses.length > 0) { + const statusOptions = projectStatuses.map( + (s: { id: string; name: string; type: string }) => ({ + name: s.name, + value: s.type, + }), + ) - // Status selection - get actual statuses from API - if (!status) { - const statusResult = await client.request(GetProjectStatuses) - const projectStatuses = statusResult.projectStatuses?.nodes || [] + // Find default (planned) status + const defaultStatus = statusOptions.find( + (s: { value: string }) => s.value === "planned", + ) + + const selectedStatus = await Select.prompt({ + message: "Status:", + options: statusOptions, + default: defaultStatus?.value || statusOptions[0]?.value, + }) + status = selectedStatus + } + } - if (projectStatuses.length > 0) { - const statusOptions = projectStatuses.map( - (s: { id: string; name: string; type: string }) => ({ - name: s.name, - value: s.type, - }), - ) - - // Find default (planned) status - const defaultStatus = statusOptions.find( - (s: { value: string }) => s.value === "planned", - ) - - const selectedStatus = await Select.prompt({ - message: "Status:", - options: statusOptions, - default: defaultStatus?.value || statusOptions[0]?.value, + // Lead (optional) + if (!lead) { + lead = await Input.prompt({ + message: "Lead (username, email, or @me - press Enter to skip):", }) - status = selectedStatus + if (!lead) lead = undefined } - } - // Lead (optional) - if (!lead) { - lead = await Input.prompt({ - message: "Lead (username, email, or @me - press Enter to skip):", - }) - if (!lead) lead = undefined - } + // Start date (optional) + if (!startDate) { + startDate = await Input.prompt({ + message: "Start date (YYYY-MM-DD - press Enter to skip):", + }) + if (!startDate) startDate = undefined + } - // Start date (optional) - if (!startDate) { - startDate = await Input.prompt({ - message: "Start date (YYYY-MM-DD - press Enter to skip):", - }) - if (!startDate) startDate = undefined + // Target date (optional) + if (!targetDate) { + targetDate = await Input.prompt({ + message: "Target date (YYYY-MM-DD - press Enter to skip):", + }) + if (!targetDate) targetDate = undefined + } } - // Target date (optional) - if (!targetDate) { - targetDate = await Input.prompt({ - message: "Target date (YYYY-MM-DD - press Enter to skip):", + // Validate required fields + if (!name) { + throw new ValidationError("Project name is required", { + suggestion: "Use --name or -n flag to specify a project name.", }) - if (!targetDate) targetDate = undefined } - } - // Validate required fields - if (!name) { - throw new ValidationError("Project name is required", { - suggestion: "Use --name or -n flag to specify a project name.", - }) - } + if (teams.length === 0) { + // Try default team from config + const defaultTeam = getTeamKey() + if (defaultTeam) { + teams = [defaultTeam] + } else { + throw new ValidationError("At least one team is required", { + suggestion: "Use --team or -t flag to specify a team.", + }) + } + } - if (teams.length === 0) { - // Try default team from config - const defaultTeam = getTeamKey() - if (defaultTeam) { - teams = [defaultTeam] - } else { - throw new ValidationError("At least one team is required", { - suggestion: "Use --team or -t flag to specify a team.", - }) + // Resolve team IDs + const teamIds: string[] = [] + for (const teamKey of teams) { + const teamId = await getTeamIdByKey(teamKey.toUpperCase()) + if (!teamId) { + throw new NotFoundError("Team", teamKey) + } + teamIds.push(teamId) } - } - // Resolve team IDs - const teamIds: string[] = [] - for (const teamKey of teams) { - const teamId = await getTeamIdByKey(teamKey.toUpperCase()) - if (!teamId) { - throw new NotFoundError("Team", teamKey) + // Build input - resolve all optional fields first + let leadId: string | undefined + if (lead) { + leadId = await lookupUserId(lead) + if (!leadId) { + throw new NotFoundError("Lead", lead) + } } - teamIds.push(teamId) - } - // Build input - resolve all optional fields first - let leadId: string | undefined - if (lead) { - leadId = await lookupUserId(lead) - if (!leadId) { - throw new NotFoundError("Lead", lead) + let statusId: string | undefined + if (status) { + // Map display value to API type if needed + const statusLower = status.toLowerCase() + const statusTypeMapping: Record<string, string> = { + "planned": "planned", + "in progress": "started", + "started": "started", + "paused": "paused", + "completed": "completed", + "canceled": "canceled", + "backlog": "backlog", + } + const apiStatusType = statusTypeMapping[statusLower] + if (!apiStatusType) { + throw new ValidationError(`Invalid status: ${status}`, { + suggestion: + "Valid values: planned, started, paused, completed, canceled, backlog", + }) + } + + // Look up the actual status ID from the organization's project statuses + const statusResult = await client.request(GetProjectStatuses) + const projectStatuses = statusResult.projectStatuses?.nodes || [] + const matchingStatus = projectStatuses.find( + (s: { type: string }) => s.type === apiStatusType, + ) + if (!matchingStatus) { + throw new NotFoundError("Project status", apiStatusType) + } + statusId = matchingStatus.id } - } - let statusId: string | undefined - if (status) { - // Map display value to API type if needed - const statusLower = status.toLowerCase() - const statusTypeMapping: Record<string, string> = { - "planned": "planned", - "in progress": "started", - "started": "started", - "paused": "paused", - "completed": "completed", - "canceled": "canceled", - "backlog": "backlog", + const labelIds: string[] = [] + for (const label of labels) { + const labelId = await lookupProjectLabelId(client, label) + if (!labelId) { + throw new NotFoundError("Project label", label) + } + labelIds.push(labelId) } - const apiStatusType = statusTypeMapping[statusLower] - if (!apiStatusType) { - throw new ValidationError(`Invalid status: ${status}`, { - suggestion: - "Valid values: planned, started, paused, completed, canceled, backlog", - }) + + const memberIds: string[] = [] + for (const member of members) { + const memberId = await lookupUserId(member) + if (!memberId) { + throw new NotFoundError("User", member) + } + memberIds.push(memberId) } - // Look up the actual status ID from the organization's project statuses - const statusResult = await client.request(GetProjectStatuses) - const projectStatuses = statusResult.projectStatuses?.nodes || [] - const matchingStatus = projectStatuses.find( - (s: { type: string }) => s.type === apiStatusType, - ) - if (!matchingStatus) { - throw new NotFoundError("Project status", apiStatusType) + if (startDate && !/^\d{4}-\d{2}-\d{2}$/.test(startDate)) { + throw new ValidationError("Start date must be in YYYY-MM-DD format") } - statusId = matchingStatus.id - } - if (startDate && !/^\d{4}-\d{2}-\d{2}$/.test(startDate)) { - throw new ValidationError("Start date must be in YYYY-MM-DD format") - } + if (targetDate && !/^\d{4}-\d{2}-\d{2}$/.test(targetDate)) { + throw new ValidationError("Target date must be in YYYY-MM-DD format") + } - if (targetDate && !/^\d{4}-\d{2}-\d{2}$/.test(targetDate)) { - throw new ValidationError("Target date must be in YYYY-MM-DD format") - } + const input: ProjectCreateInput = { + name, + teamIds, + ...(description && { description }), + ...(content != null && { content }), + ...(leadId && { leadId }), + ...(statusId && { statusId }), + ...(startDate && { startDate }), + ...(targetDate && { targetDate }), + ...(priority != null && { priority }), + ...(labelIds.length > 0 && { labelIds }), + ...(memberIds.length > 0 && { memberIds }), + ...(providedIcon != null && { icon: providedIcon }), + ...(providedColor != null && { color: providedColor }), + } - const input = { - name, - teamIds, - ...(description && { description }), - ...(leadId && { leadId }), - ...(statusId && { statusId }), - ...(startDate && { startDate }), - ...(targetDate && { targetDate }), - } + const { Spinner } = await import("@std/cli/unstable-spinner") + const showSpinner = shouldShowSpinner() && !jsonOutput + const spinner = showSpinner ? new Spinner() : null + spinner?.start() - const { Spinner } = await import("@std/cli/unstable-spinner") - const showSpinner = shouldShowSpinner() && !jsonOutput - const spinner = showSpinner ? new Spinner() : null - spinner?.start() + try { + const result = await client.request(CreateProject, { input }) - try { - const result = await client.request(CreateProject, { input }) + if (!result.projectCreate.success) { + spinner?.stop() + throw new CliError("Failed to create project") + } - if (!result.projectCreate.success) { + const project = result.projectCreate.project spinner?.stop() - throw new CliError("Failed to create project") - } - - const project = result.projectCreate.project - spinner?.stop() - if (!project) { - throw new CliError("Failed to create project: no project returned") - } - - // Add to initiative if specified (before JSON output so warnings go to stderr) - if (initiative) { - const initiativeId = await resolveInitiativeId(client, initiative) - if (!initiativeId) { - console.error(`\nWarning: Initiative not found: ${initiative}`) - console.error("Project was created but not added to initiative.") - } else { - try { - const linkResult = await client.request(AddProjectToInitiative, { - input: { - initiativeId, - projectId: project.id, - }, - }) + if (!project) { + throw new CliError("Failed to create project: no project returned") + } - if (linkResult.initiativeToProjectCreate.success && !jsonOutput) { - console.log(`✓ Added to initiative: ${initiative}`) - } else if ( - !linkResult.initiativeToProjectCreate.success - ) { - console.error(`\nWarning: Failed to add project to initiative`) + // Add to initiative if specified (before JSON output so warnings go to stderr) + if (initiative) { + const initiativeId = await resolveInitiativeId(client, initiative) + if (!initiativeId) { + console.error(`\nWarning: Initiative not found: ${initiative}`) + console.error("Project was created but not added to initiative.") + } else { + try { + const linkResult = await client.request( + AddProjectToInitiative, + { + input: { + initiativeId, + projectId: project.id, + }, + }, + ) + + if ( + linkResult.initiativeToProjectCreate.success && !jsonOutput + ) { + console.log(`✓ Added to initiative: ${initiative}`) + } else if ( + !linkResult.initiativeToProjectCreate.success + ) { + console.error( + `\nWarning: Failed to add project to initiative`, + ) + } + } catch (error) { + console.error( + `\nWarning: Failed to add project to initiative:`, + error, + ) } - } catch (error) { - console.error( - `\nWarning: Failed to add project to initiative:`, - error, - ) } } - } - if (jsonOutput) { - console.log(JSON.stringify(result.projectCreate, null, 2)) - } else { - console.log(`✓ Created project: ${project.name}`) - console.log(` Slug: ${project.slugId}`) - if (project.url) { - console.log(` URL: ${project.url}`) + if (jsonOutput) { + console.log(JSON.stringify(result.projectCreate, null, 2)) + } else { + console.log(`✓ Created project: ${project.name}`) + console.log(` Slug: ${project.slugId}`) + if (project.url) { + console.log(` URL: ${project.url}`) + } } + } catch (error) { + spinner?.stop() + handleError(error, "Failed to create project") } } catch (error) { - spinner?.stop() handleError(error, "Failed to create project") } }, diff --git a/test/commands/project/__snapshots__/project-create.test.ts.snap b/test/commands/project/__snapshots__/project-create.test.ts.snap index 59b0c524..639e5932 100644 --- a/test/commands/project/__snapshots__/project-create.test.ts.snap +++ b/test/commands/project/__snapshots__/project-create.test.ts.snap @@ -11,17 +11,24 @@ Description: Options: - -h, --help - Show this help. - -n, --name <name> - Project name (required) - -d, --description <description> - Project description - -t, --team <team> - Team key (required, can be repeated for multiple teams) - -l, --lead <lead> - Project lead (username, email, or @me) - -s, --status <status> - Project status (planned, started, paused, completed, canceled, backlog) - --start-date <startDate> - Start date (YYYY-MM-DD) - --target-date <targetDate> - Target completion date (YYYY-MM-DD) - --initiative <initiative> - Add to initiative immediately (ID, slug, or name) - -i, --interactive - Interactive mode (default if no flags provided) - -j, --json - Output created project as JSON + -h, --help - Show this help. + -n, --name <name> - Project name (required) + -d, --description <description> - Project description + --content <markdown> - Project overview markdown + --content-file <path> - Read project overview markdown from a file + -t, --team <team> - Team key (required, can be repeated for multiple teams) + -l, --lead <lead> - Project lead (username, email, or @me) + -s, --status <status> - Project status (planned, started, paused, completed, canceled, backlog) + --start-date <startDate> - Start date (YYYY-MM-DD) + --target-date <targetDate> - Target completion date (YYYY-MM-DD) + --priority <priority> - Project priority (none, urgent, high, medium, low) + --label <label> - Project label associated with the project. May be repeated. + --member <user> - Project member (username, email, display name, or @me). May be repeated. + --icon <icon> - Project icon + --color <color> - Project color as a HEX string + --initiative <initiative> - Add to initiative immediately (ID, slug, or name) + -i, --interactive - Interactive mode (default if no flags provided) + -j, --json - Output created project as JSON " stderr: @@ -43,3 +50,35 @@ stdout: stderr: "" `; + +snapshot[`Project Create Command - With Content And Create Fields 1`] = ` +stdout: +'{ + "success": true, + "project": { + "id": "550e8400-e29b-41d4-a716-446655440010", + "slugId": "detailed-project", + "name": "Detailed Project", + "url": "https://linear.app/test/project/detailed-project" + } +} +' +stderr: +"" +`; + +snapshot[`Project Create Command - With Content File 1`] = ` +stdout: +'{ + "success": true, + "project": { + "id": "550e8400-e29b-41d4-a716-446655440011", + "slugId": "file-content-project", + "name": "File Content Project", + "url": "https://linear.app/test/project/file-content-project" + } +} +' +stderr: +"" +`; diff --git a/test/commands/project/project-create.test.ts b/test/commands/project/project-create.test.ts index 73b1d895..2532c1bc 100644 --- a/test/commands/project/project-create.test.ts +++ b/test/commands/project/project-create.test.ts @@ -1,5 +1,10 @@ import { snapshotTest as cliffySnapshotTest } from "@cliffy/testing" -import { createCommand } from "../../../src/commands/project/project-create.ts" +import { assertRejects } from "@std/assert" +import { + createCommand, + resolveProjectContent, +} from "../../../src/commands/project/project-create.ts" +import { ValidationError } from "../../../src/utils/errors.ts" import { commonDenoArgs } from "../../utils/test-helpers.ts" import { MockLinearServer } from "../../utils/mock_linear_server.ts" @@ -72,3 +77,270 @@ await cliffySnapshotTest({ } }, }) + +// Test project create with overview content and GraphQL-backed fields +await cliffySnapshotTest({ + name: "Project Create Command - With Content And Create Fields", + meta: import.meta, + colors: false, + args: [ + "--name", + "Detailed Project", + "--team", + "ENG", + "--description", + "Short project description", + "--content", + "## Overview\nShip the new project experience.", + "--lead", + "lead@example.com", + "--start-date", + "2026-06-01", + "--target-date", + "2026-09-30", + "--priority", + "high", + "--label", + "Frontend", + "--label", + "Backend", + "--member", + "jane@example.com", + "--member", + "@me", + "--icon", + "rocket", + "--color", + "#5E6AD2", + "--json", + ], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-123" }], + }, + }, + }, + }, + { + queryName: "LookupUser", + variables: { input: "lead@example.com" }, + response: { + data: { + users: { + nodes: [{ + id: "user-lead-123", + email: "lead@example.com", + displayName: "Project Lead", + name: "lead", + }], + }, + }, + }, + }, + { + queryName: "GetProjectLabelIdByNameForCreate", + variables: { name: "Frontend" }, + response: { + data: { + projectLabels: { + nodes: [{ id: "project-label-frontend", name: "Frontend" }], + }, + }, + }, + }, + { + queryName: "GetProjectLabelIdByNameForCreate", + variables: { name: "Backend" }, + response: { + data: { + projectLabels: { + nodes: [{ id: "project-label-backend", name: "Backend" }], + }, + }, + }, + }, + { + queryName: "LookupUser", + variables: { input: "jane@example.com" }, + response: { + data: { + users: { + nodes: [{ + id: "user-jane-123", + email: "jane@example.com", + displayName: "Jane Developer", + name: "jane", + }], + }, + }, + }, + }, + { + queryName: "GetViewerId", + variables: {}, + response: { + data: { + viewer: { + id: "user-self-123", + }, + }, + }, + }, + { + queryName: "CreateProject", + variables: { + input: { + name: "Detailed Project", + teamIds: ["team-eng-123"], + description: "Short project description", + content: "## Overview\nShip the new project experience.", + leadId: "user-lead-123", + startDate: "2026-06-01", + targetDate: "2026-09-30", + priority: 2, + labelIds: ["project-label-frontend", "project-label-backend"], + memberIds: ["user-jane-123", "user-self-123"], + icon: "rocket", + color: "#5E6AD2", + }, + }, + response: { + data: { + projectCreate: { + success: true, + project: { + id: "550e8400-e29b-41d4-a716-446655440010", + slugId: "detailed-project", + name: "Detailed Project", + url: "https://linear.app/test/project/detailed-project", + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await createCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +// Test project create with content read from a file +await cliffySnapshotTest({ + name: "Project Create Command - With Content File", + meta: import.meta, + colors: false, + args: [ + "--name", + "File Content Project", + "--team", + "ENG", + "--content-file", + "placeholder-replaced-in-test.md", + "--json", + ], + denoArgs: commonDenoArgs, + async fn() { + const overviewPath = await Deno.makeTempFile({ + prefix: "linear-project-overview-", + suffix: ".md", + }) + + const server = new MockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-123" }], + }, + }, + }, + }, + { + queryName: "CreateProject", + variables: { + input: { + name: "File Content Project", + teamIds: ["team-eng-123"], + content: + "# Project Overview\n\nThis overview came from a markdown file.\n", + }, + }, + response: { + data: { + projectCreate: { + success: true, + project: { + id: "550e8400-e29b-41d4-a716-446655440011", + slugId: "file-content-project", + name: "File Content Project", + url: "https://linear.app/test/project/file-content-project", + }, + }, + }, + }, + }, + ]) + + let contentFileArgIndex = -1 + let originalContentFileArg: string | undefined + + try { + await Deno.writeTextFile( + overviewPath, + "# Project Overview\n\nThis overview came from a markdown file.\n", + ) + contentFileArgIndex = Deno.args.indexOf( + "placeholder-replaced-in-test.md", + ) + if (contentFileArgIndex === -1) { + throw new Error("Expected content file placeholder argument") + } + originalContentFileArg = Deno.args[contentFileArgIndex] + Deno.args[contentFileArgIndex] = overviewPath + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await createCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + if (contentFileArgIndex !== -1 && originalContentFileArg != null) { + Deno.args[contentFileArgIndex] = originalContentFileArg + } + await Deno.remove(overviewPath) + } + }, +}) + +Deno.test("resolveProjectContent rejects mutually exclusive content inputs", async () => { + await assertRejects( + () => + resolveProjectContent( + "Inline overview", + "overview.md", + ), + ValidationError, + "Cannot specify both --content and --content-file", + ) +}) From 43a629029f0564c7461c54a4f2611bcaf6be7f27 Mon Sep 17 00:00:00 2001 From: Peter Schilling <code@schpet.com> Date: Sat, 11 Jul 2026 13:04:54 -0700 Subject: [PATCH 05/12] Follow-up to #216: add error-path tests for project create fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #216 adds --content/--content-file plus priority/label/member/icon/color to `linear project create`, with happy-path snapshot tests and a mutual-exclusion unit test. This adds the missing failure-path coverage that both blind-planning passes called for: - invalid --priority is rejected with a ValidationError before any network call - an unknown --label surfaces a NotFoundError - an unknown --member surfaces a NotFoundError These use a stubbed Deno.exit and capture stderr (mirroring the validation tests in issue-query.test.ts), and run against a mock Linear server so they never touch the real API. No product code changes — the contributor's feature is correct as written; this only locks in the error behavior. --- test/commands/project/project-create.test.ts | 155 ++++++++++++++++++- 1 file changed, 154 insertions(+), 1 deletion(-) diff --git a/test/commands/project/project-create.test.ts b/test/commands/project/project-create.test.ts index 2532c1bc..851f08b3 100644 --- a/test/commands/project/project-create.test.ts +++ b/test/commands/project/project-create.test.ts @@ -1,5 +1,6 @@ import { snapshotTest as cliffySnapshotTest } from "@cliffy/testing" -import { assertRejects } from "@std/assert" +import { assertEquals, assertRejects } from "@std/assert" +import { stub } from "@std/testing/mock" import { createCommand, resolveProjectContent, @@ -344,3 +345,155 @@ Deno.test("resolveProjectContent rejects mutually exclusive content inputs", asy "Cannot specify both --content and --content-file", ) }) + +// Error-path coverage for the new create fields. These use a plain Deno.test with +// a stubbed Deno.exit (handleError calls Deno.exit) and capture stderr, mirroring +// the validation-error tests in issue-query.test.ts. + +// Invalid --priority is rejected before any network call. +Deno.test("Project Create Command - rejects an invalid priority", async () => { + 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 createCommand.parse([ + "--name", + "Proj", + "--team", + "ENG", + "--priority", + "highest", + ]) + } catch (e) { + if (!(e instanceof Error) || e.message !== "EXIT") throw e + exited = true + } finally { + errorStub.restore() + exitStub.restore() + } + + // handleError ran and called Deno.exit (never returns normally)... + assertEquals(exited, true) + // ...with the priority validation message. + assertEquals( + errorLogs.some((l) => l.includes("Invalid priority: highest")), + true, + ) +}) + +// An unknown --label is reported as a NotFoundError. +Deno.test("Project Create Command - rejects an unknown project label", async () => { + const server = new MockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { data: { teams: { nodes: [{ id: "team-eng-123" }] } } }, + }, + { + queryName: "GetProjectLabelIdByNameForCreate", + variables: { name: "Nonexistent" }, + response: { data: { projectLabels: { nodes: [] } } }, + }, + ]) + + 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 server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + await createCommand.parse([ + "--name", + "Proj", + "--team", + "ENG", + "--label", + "Nonexistent", + ]) + } catch (e) { + if (!(e instanceof Error) || e.message !== "EXIT") throw e + exited = true + } finally { + errorStub.restore() + exitStub.restore() + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + + assertEquals(exited, true) + // Full NotFoundError message — a mock mismatch could not produce this exact text. + assertEquals( + errorLogs.some((l) => l.includes("Project label not found: Nonexistent")), + true, + ) +}) + +// An unknown --member is reported as a NotFoundError. +Deno.test("Project Create Command - rejects an unknown member", async () => { + const server = new MockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { data: { teams: { nodes: [{ id: "team-eng-123" }] } } }, + }, + { + queryName: "LookupUser", + variables: { input: "ghostuser" }, + response: { data: { users: { nodes: [] } } }, + }, + ]) + + 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 server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + await createCommand.parse([ + "--name", + "Proj", + "--team", + "ENG", + "--member", + "ghostuser", + ]) + } catch (e) { + if (!(e instanceof Error) || e.message !== "EXIT") throw e + exited = true + } finally { + errorStub.restore() + exitStub.restore() + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + + assertEquals(exited, true) + // Full NotFoundError message — a mock-mismatch error would echo the variable + // "ghostuser" but never this exact "User not found:" text. + assertEquals( + errorLogs.some((l) => l.includes("User not found: ghostuser")), + true, + ) +}) From 701a395bc637d67af5b4aa27abb5c4a013693feb Mon Sep 17 00:00:00 2001 From: Ryan Schumacher <j.r.schumacher@gmail.com> Date: Fri, 22 May 2026 05:53:44 -0500 Subject: [PATCH 06/12] fix(milestone): surface truncation in view, add --all flag (#222) milestone view silently capped its issues list at 10 items. A user with 12 issues would see only 10 and could miss issues entirely when iterating over the visible output in a script. The cap was also undocumented in --help. - Request first: 50 issues with pageInfo on the connection. - Add --all to paginate through every page when needed. - Show a prominent truncation footer pointing at --all and at `linear issue query --milestone X --json` for full programmatic access. - Document the default cap in --help. Closes #222 --- src/commands/milestone/milestone-view.ts | 96 +++++++--- .../__snapshots__/milestone-view.test.ts.snap | 122 +++++++++++- .../commands/milestone/milestone-view.test.ts | 174 ++++++++++++++++-- 3 files changed, 349 insertions(+), 43 deletions(-) diff --git a/src/commands/milestone/milestone-view.ts b/src/commands/milestone/milestone-view.ts index 8aa6e13c..5ff3fb8a 100644 --- a/src/commands/milestone/milestone-view.ts +++ b/src/commands/milestone/milestone-view.ts @@ -6,8 +6,11 @@ import { formatRelativeTime } from "../../utils/display.ts" import { shouldShowSpinner } from "../../utils/hyperlink.ts" import { handleError, NotFoundError } from "../../utils/errors.ts" +const PAGE_SIZE = 50 +const LIST_PREVIEW = 10 + const GetMilestoneDetails = gql(` - query GetMilestoneDetails($id: String!) { + query GetMilestoneDetails($id: String!, $first: Int!, $after: String) { projectMilestone(id: $id) { id name @@ -22,7 +25,7 @@ const GetMilestoneDetails = gql(` slugId url } - issues { + issues(first: $first, after: $after) { nodes { id identifier @@ -32,6 +35,10 @@ const GetMilestoneDetails = gql(` type } } + pageInfo { + hasNextPage + endCursor + } } } } @@ -39,10 +46,19 @@ const GetMilestoneDetails = gql(` export const viewCommand = new Command() .name("view") - .description("View milestone details") + .description( + "View milestone details. By default lists the first " + + LIST_PREVIEW + + " attached issues from the first page of " + PAGE_SIZE + + "; use --all to paginate the full set.", + ) .alias("v") .arguments("<milestoneId:string>") - .action(async (_options, milestoneId) => { + .option( + "--all", + "Fetch and list every issue attached to the milestone (paginates the Linear API).", + ) + .action(async ({ all }, milestoneId) => { const { Spinner } = await import("@std/cli/unstable-spinner") const showSpinner = shouldShowSpinner() const spinner = showSpinner ? new Spinner() : null @@ -50,24 +66,43 @@ export const viewCommand = new Command() try { const client = getGraphQLClient() - const result = await client.request(GetMilestoneDetails, { + const firstPage = await client.request(GetMilestoneDetails, { id: milestoneId, + first: PAGE_SIZE, }) - spinner?.stop() - const milestone = result.projectMilestone + const milestone = firstPage.projectMilestone if (!milestone) { + spinner?.stop() throw new NotFoundError("Milestone", milestoneId) } - // Build the display + const issues = [...milestone.issues.nodes] + let pageInfo = milestone.issues.pageInfo + + if (all) { + while (pageInfo.hasNextPage && pageInfo.endCursor) { + const nextPage = await client.request(GetMilestoneDetails, { + id: milestoneId, + first: PAGE_SIZE, + after: pageInfo.endCursor, + }) + const next = nextPage.projectMilestone + if (next == null) break + issues.push(...next.issues.nodes) + pageInfo = next.issues.pageInfo + } + } + + spinner?.stop() + + const truncated = !all && pageInfo.hasNextPage + const lines: string[] = [] - // Title lines.push(`# ${milestone.name}`) lines.push("") - // Basic info lines.push(`**ID:** ${milestone.id}`) if (milestone.targetDate) { lines.push(`**Target Date:** ${milestone.targetDate}`) @@ -75,7 +110,6 @@ export const viewCommand = new Command() lines.push(`**Target Date:** Not set`) } - // Project info lines.push( `**Project:** ${milestone.project.name} (${milestone.project.slugId})`, ) @@ -85,7 +119,6 @@ export const viewCommand = new Command() lines.push(`**Created:** ${formatRelativeTime(milestone.createdAt)}`) lines.push(`**Updated:** ${formatRelativeTime(milestone.updatedAt)}`) - // Description if (milestone.description) { lines.push("") lines.push("## Description") @@ -93,13 +126,12 @@ export const viewCommand = new Command() lines.push(milestone.description) } - // Issue summary - if (milestone.issues.nodes.length > 0) { + if (issues.length > 0) { lines.push("") lines.push("## Issues") lines.push("") - const issuesByState = milestone.issues.nodes.reduce( + const issuesByState = issues.reduce( (acc: Record<string, number>, issue) => { const stateType = issue.state.type if (!acc[stateType]) acc[stateType] = 0 @@ -109,7 +141,7 @@ export const viewCommand = new Command() {} as Record<string, number>, ) - const total = milestone.issues.nodes.length + const fetched = issues.length const completed = issuesByState.completed || 0 const started = issuesByState.started || 0 const unstarted = issuesByState.unstarted || 0 @@ -117,7 +149,13 @@ export const viewCommand = new Command() const backlog = issuesByState.backlog || 0 const triage = issuesByState.triage || 0 - lines.push(`**Total Issues:** ${total}`) + if (truncated) { + lines.push( + `**Issues fetched:** ${fetched} (milestone has more — use \`--all\` for full counts)`, + ) + } else { + lines.push(`**Total Issues:** ${fetched}`) + } if (completed > 0) lines.push(`**Completed:** ${completed}`) if (started > 0) lines.push(`**In Progress:** ${started}`) if (unstarted > 0) lines.push(`**To Do:** ${unstarted}`) @@ -125,21 +163,29 @@ export const viewCommand = new Command() if (triage > 0) lines.push(`**Triage:** ${triage}`) if (canceled > 0) lines.push(`**Canceled:** ${canceled}`) - // List first 10 issues + const listed = all ? issues : issues.slice(0, LIST_PREVIEW) lines.push("") - lines.push("**Recent Issues:**") + lines.push(all ? "**All Issues:**" : "**Recent Issues:**") lines.push("") - milestone.issues.nodes.slice(0, 10).forEach((issue) => { + listed.forEach((issue) => { lines.push( `- ${issue.identifier}: ${issue.title} (${issue.state.name})`, ) }) - if (milestone.issues.nodes.length > 10) { - lines.push("") - lines.push( - `_...and ${milestone.issues.nodes.length - 10} more issues_`, - ) + if (!all) { + const hiddenLoaded = Math.max(fetched - LIST_PREVIEW, 0) + if (truncated) { + lines.push("") + lines.push( + `_Showing ${Math.min(LIST_PREVIEW, fetched)} of ${fetched}+ issues — the milestone contains more than ${PAGE_SIZE}. Re-run with \`--all\` or use \`linear issue query --milestone ${milestone.id} --json\` for the full list._`, + ) + } else if (hiddenLoaded > 0) { + lines.push("") + lines.push( + `_...and ${hiddenLoaded} more issue${hiddenLoaded === 1 ? "" : "s"}. Re-run with \`--all\` or use \`linear issue query --milestone ${milestone.id} --json\` to see them all._`, + ) + } } } else { lines.push("") diff --git a/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap b/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap index 91d6a222..b5b45f7e 100644 --- a/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap +++ b/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap @@ -7,11 +7,12 @@ Usage: view <milestoneId> Description: - View milestone details + View milestone details. By default lists the first 10 attached issues from the first page of 50; use --all to paginate the full set. Options: - -h, --help - Show this help. + -h, --help - Show this help. + --all - Fetch and list every issue attached to the milestone (paginates the Linear API). " stderr: @@ -69,7 +70,7 @@ stderr: "" `; -snapshot[`Milestone View Command - Many Issues 1`] = ` +snapshot[`Milestone View Command - Many Issues (default lists 10) 1`] = ` stdout: "# Big Release @@ -105,7 +106,120 @@ Major product release with many features - PROD-9: Feature 9 (In Progress) - PROD-10: Feature 10 (In Progress) -_...and 5 more issues_ +_...and 5 more issues. Re-run with \`--all\` or use \`linear issue query --milestone milestone-456 --json\` to see them all._ +" +stderr: +"" +`; + +snapshot[`Milestone View Command - Truncated (more pages available) 1`] = ` +stdout: +"# Huge Milestone + +**ID:** milestone-trunc +**Target Date:** Not set +**Project:** P (p) +**Project URL:** https://linear.app/test/project/p + +**Created:** 12/31/2019 +**Updated:** 1/1/2020 + +## Issues + +**Issues fetched:** 50 (milestone has more — use \`--all\` for full counts) +**To Do:** 50 + +**Recent Issues:** + +- BIG-1: Issue 1 (Todo) +- BIG-2: Issue 2 (Todo) +- BIG-3: Issue 3 (Todo) +- BIG-4: Issue 4 (Todo) +- BIG-5: Issue 5 (Todo) +- BIG-6: Issue 6 (Todo) +- BIG-7: Issue 7 (Todo) +- BIG-8: Issue 8 (Todo) +- BIG-9: Issue 9 (Todo) +- BIG-10: Issue 10 (Todo) + +_Showing 10 of 50+ issues — the milestone contains more than 50. Re-run with \`--all\` or use \`linear issue query --milestone milestone-trunc --json\` for the full list._ +" +stderr: +"" +`; + +snapshot[`Milestone View Command - --all paginates 1`] = ` +stdout: +"# Huge Milestone + +**ID:** milestone-trunc +**Target Date:** Not set +**Project:** P (p) +**Project URL:** https://linear.app/test/project/p + +**Created:** 12/31/2019 +**Updated:** 1/1/2020 + +## Issues + +**Total Issues:** 52 +**Completed:** 2 +**To Do:** 50 + +**All Issues:** + +- BIG-1: Issue 1 (Todo) +- BIG-2: Issue 2 (Todo) +- BIG-3: Issue 3 (Todo) +- BIG-4: Issue 4 (Todo) +- BIG-5: Issue 5 (Todo) +- BIG-6: Issue 6 (Todo) +- BIG-7: Issue 7 (Todo) +- BIG-8: Issue 8 (Todo) +- BIG-9: Issue 9 (Todo) +- BIG-10: Issue 10 (Todo) +- BIG-11: Issue 11 (Todo) +- BIG-12: Issue 12 (Todo) +- BIG-13: Issue 13 (Todo) +- BIG-14: Issue 14 (Todo) +- BIG-15: Issue 15 (Todo) +- BIG-16: Issue 16 (Todo) +- BIG-17: Issue 17 (Todo) +- BIG-18: Issue 18 (Todo) +- BIG-19: Issue 19 (Todo) +- BIG-20: Issue 20 (Todo) +- BIG-21: Issue 21 (Todo) +- BIG-22: Issue 22 (Todo) +- BIG-23: Issue 23 (Todo) +- BIG-24: Issue 24 (Todo) +- BIG-25: Issue 25 (Todo) +- BIG-26: Issue 26 (Todo) +- BIG-27: Issue 27 (Todo) +- BIG-28: Issue 28 (Todo) +- BIG-29: Issue 29 (Todo) +- BIG-30: Issue 30 (Todo) +- BIG-31: Issue 31 (Todo) +- BIG-32: Issue 32 (Todo) +- BIG-33: Issue 33 (Todo) +- BIG-34: Issue 34 (Todo) +- BIG-35: Issue 35 (Todo) +- BIG-36: Issue 36 (Todo) +- BIG-37: Issue 37 (Todo) +- BIG-38: Issue 38 (Todo) +- BIG-39: Issue 39 (Todo) +- BIG-40: Issue 40 (Todo) +- BIG-41: Issue 41 (Todo) +- BIG-42: Issue 42 (Todo) +- BIG-43: Issue 43 (Todo) +- BIG-44: Issue 44 (Todo) +- BIG-45: Issue 45 (Todo) +- BIG-46: Issue 46 (Todo) +- BIG-47: Issue 47 (Todo) +- BIG-48: Issue 48 (Todo) +- BIG-49: Issue 49 (Todo) +- BIG-50: Issue 50 (Todo) +- BIG-51: Issue 51 (Done) +- BIG-52: Issue 52 (Done) " stderr: "" diff --git a/test/commands/milestone/milestone-view.test.ts b/test/commands/milestone/milestone-view.test.ts index ead71000..4347297b 100644 --- a/test/commands/milestone/milestone-view.test.ts +++ b/test/commands/milestone/milestone-view.test.ts @@ -48,30 +48,22 @@ await snapshotTest({ id: "issue-1", identifier: "ENG-123", title: "Implement authentication", - state: { - name: "In Progress", - type: "started", - }, + state: { name: "In Progress", type: "started" }, }, { id: "issue-2", identifier: "ENG-124", title: "Setup database", - state: { - name: "Done", - type: "completed", - }, + state: { name: "Done", type: "completed" }, }, { id: "issue-3", identifier: "ENG-125", title: "Create API endpoints", - state: { - name: "Todo", - type: "unstarted", - }, + state: { name: "Todo", type: "unstarted" }, }, ], + pageInfo: { hasNextPage: false, endCursor: null }, }, }, }, @@ -122,6 +114,7 @@ await snapshotTest({ }, issues: { nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, }, }, }, @@ -143,9 +136,9 @@ await snapshotTest({ }, }) -// Test with many issues (>10) +// Test default behavior with 15 fetched issues (single page, list slice to 10). await snapshotTest({ - name: "Milestone View Command - Many Issues", + name: "Milestone View Command - Many Issues (default lists 10)", meta: import.meta, colors: false, args: ["milestone-456"], @@ -184,6 +177,159 @@ await snapshotTest({ : "unstarted", }, })), + 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 viewCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +// Test that hasNextPage = true triggers the explicit truncation footer +// (this is the core regression: silent capping when the API has more pages). +await snapshotTest({ + name: "Milestone View Command - Truncated (more pages available)", + meta: import.meta, + colors: false, + args: ["milestone-trunc"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetMilestoneDetails", + variables: { id: "milestone-trunc", first: 50 }, + response: { + data: { + projectMilestone: { + id: "milestone-trunc", + name: "Huge Milestone", + description: null, + targetDate: null, + sortOrder: 1, + createdAt: "2020-01-01T00:00:00Z", + updatedAt: "2020-01-02T00:00:00Z", + project: { + id: "project-1", + name: "P", + slugId: "p", + url: "https://linear.app/test/project/p", + }, + issues: { + nodes: Array.from({ length: 50 }, (_, i) => ({ + id: `id-${i}`, + identifier: `BIG-${i + 1}`, + title: `Issue ${i + 1}`, + state: { name: "Todo", type: "unstarted" }, + })), + pageInfo: { hasNextPage: true, endCursor: "cursor-1" }, + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await viewCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +// Test --all paginates through subsequent pages. The more-specific (with `after`) +// mock is listed first so the mock matcher's subset semantics route page 2 correctly. +await snapshotTest({ + name: "Milestone View Command - --all paginates", + meta: import.meta, + colors: false, + args: ["milestone-trunc", "--all"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetMilestoneDetails", + variables: { + id: "milestone-trunc", + first: 50, + after: "cursor-1", + }, + response: { + data: { + projectMilestone: { + id: "milestone-trunc", + name: "Huge Milestone", + description: null, + targetDate: null, + sortOrder: 1, + createdAt: "2020-01-01T00:00:00Z", + updatedAt: "2020-01-02T00:00:00Z", + project: { + id: "project-1", + name: "P", + slugId: "p", + url: "https://linear.app/test/project/p", + }, + issues: { + nodes: Array.from({ length: 2 }, (_, i) => ({ + id: `id-${50 + i}`, + identifier: `BIG-${51 + i}`, + title: `Issue ${51 + i}`, + state: { name: "Done", type: "completed" }, + })), + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + }, + { + queryName: "GetMilestoneDetails", + variables: { id: "milestone-trunc", first: 50 }, + response: { + data: { + projectMilestone: { + id: "milestone-trunc", + name: "Huge Milestone", + description: null, + targetDate: null, + sortOrder: 1, + createdAt: "2020-01-01T00:00:00Z", + updatedAt: "2020-01-02T00:00:00Z", + project: { + id: "project-1", + name: "P", + slugId: "p", + url: "https://linear.app/test/project/p", + }, + issues: { + nodes: Array.from({ length: 50 }, (_, i) => ({ + id: `id-${i}`, + identifier: `BIG-${i + 1}`, + title: `Issue ${i + 1}`, + state: { name: "Todo", type: "unstarted" }, + })), + pageInfo: { hasNextPage: true, endCursor: "cursor-1" }, }, }, }, From 27e10af35f090100f7a8b0924194ce5865df18bf Mon Sep 17 00:00:00 2001 From: Peter Schilling <code@schpet.com> Date: Sat, 11 Jul 2026 13:31:49 -0700 Subject: [PATCH 07/12] Follow-up to #228: harden --all pagination and fix TZ-flaky snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #228 fixes milestone view silently capping its issue list, adding --all to paginate. Two corrections: 1. Fail loudly on inconsistent --all pagination. The loop guarded on `while (pageInfo.hasNextPage && pageInfo.endCursor)` and `if (next == null) break`, so if Linear advertised another page but returned no cursor (or the milestone vanished mid-pagination), --all would stop early and return a *partial* list with no error — reintroducing the exact silent-truncation bug this command exists to fix. Now throws CliError / NotFoundError instead, with a regression test. 2. Fix timezone-flaky snapshots. The new "Truncated" and "--all paginates" tests used midnight-UTC timestamps (2020-01-01T00:00:00Z) whose snapshots were generated in a US timezone, so `formatRelativeTime`'s toLocaleDateString fallback rendered 12/31/2019 there but 1/1/2020 under CI's UTC — failing CI (fork CI never ran, so it slipped through). Moved the fixtures to noon UTC so they render the same date in every timezone; verified passing under TZ=UTC and TZ=America/Los_Angeles. --- src/commands/milestone/milestone-view.ts | 28 ++++-- .../__snapshots__/milestone-view.test.ts.snap | 8 +- .../commands/milestone/milestone-view.test.ts | 88 +++++++++++++++++-- 3 files changed, 109 insertions(+), 15 deletions(-) diff --git a/src/commands/milestone/milestone-view.ts b/src/commands/milestone/milestone-view.ts index 5ff3fb8a..36788b4b 100644 --- a/src/commands/milestone/milestone-view.ts +++ b/src/commands/milestone/milestone-view.ts @@ -4,7 +4,7 @@ import { gql } from "../../__codegen__/gql.ts" import { getGraphQLClient } from "../../utils/graphql.ts" import { formatRelativeTime } from "../../utils/display.ts" import { shouldShowSpinner } from "../../utils/hyperlink.ts" -import { handleError, NotFoundError } from "../../utils/errors.ts" +import { CliError, handleError, NotFoundError } from "../../utils/errors.ts" const PAGE_SIZE = 50 const LIST_PREVIEW = 10 @@ -81,14 +81,28 @@ export const viewCommand = new Command() let pageInfo = milestone.issues.pageInfo if (all) { - while (pageInfo.hasNextPage && pageInfo.endCursor) { + // Paginate the full set. Fail loudly on inconsistent pagination rather + // than silently returning a partial list — silently dropping issues is + // the exact bug --all exists to prevent. + while (pageInfo.hasNextPage) { + if (!pageInfo.endCursor) { + throw new CliError( + "Linear reported more issues but returned no pagination cursor", + { + suggestion: + `Retry, or use \`linear issue query --milestone ${milestone.id} --json\` for the full list.`, + }, + ) + } const nextPage = await client.request(GetMilestoneDetails, { id: milestoneId, first: PAGE_SIZE, after: pageInfo.endCursor, }) const next = nextPage.projectMilestone - if (next == null) break + if (next == null) { + throw new NotFoundError("Milestone", milestoneId) + } issues.push(...next.issues.nodes) pageInfo = next.issues.pageInfo } @@ -178,12 +192,16 @@ export const viewCommand = new Command() if (truncated) { lines.push("") lines.push( - `_Showing ${Math.min(LIST_PREVIEW, fetched)} of ${fetched}+ issues — the milestone contains more than ${PAGE_SIZE}. Re-run with \`--all\` or use \`linear issue query --milestone ${milestone.id} --json\` for the full list._`, + `_Showing ${ + Math.min(LIST_PREVIEW, fetched) + } of ${fetched}+ issues — the milestone contains more than ${PAGE_SIZE}. Re-run with \`--all\` or use \`linear issue query --milestone ${milestone.id} --json\` for the full list._`, ) } else if (hiddenLoaded > 0) { lines.push("") lines.push( - `_...and ${hiddenLoaded} more issue${hiddenLoaded === 1 ? "" : "s"}. Re-run with \`--all\` or use \`linear issue query --milestone ${milestone.id} --json\` to see them all._`, + `_...and ${hiddenLoaded} more issue${ + hiddenLoaded === 1 ? "" : "s" + }. Re-run with \`--all\` or use \`linear issue query --milestone ${milestone.id} --json\` to see them all._`, ) } } diff --git a/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap b/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap index b5b45f7e..fe785f5a 100644 --- a/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap +++ b/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap @@ -121,8 +121,8 @@ stdout: **Project:** P (p) **Project URL:** https://linear.app/test/project/p -**Created:** 12/31/2019 -**Updated:** 1/1/2020 +**Created:** 1/1/2020 +**Updated:** 1/2/2020 ## Issues @@ -157,8 +157,8 @@ stdout: **Project:** P (p) **Project URL:** https://linear.app/test/project/p -**Created:** 12/31/2019 -**Updated:** 1/1/2020 +**Created:** 1/1/2020 +**Updated:** 1/2/2020 ## Issues diff --git a/test/commands/milestone/milestone-view.test.ts b/test/commands/milestone/milestone-view.test.ts index 4347297b..4b6cb901 100644 --- a/test/commands/milestone/milestone-view.test.ts +++ b/test/commands/milestone/milestone-view.test.ts @@ -1,4 +1,6 @@ import { snapshotTest } from "@cliffy/testing" +import { assertEquals } from "@std/assert" +import { stub } from "@std/testing/mock" import { viewCommand } from "../../../src/commands/milestone/milestone-view.ts" import { commonDenoArgs } from "../../utils/test-helpers.ts" import { MockLinearServer } from "../../utils/mock_linear_server.ts" @@ -220,8 +222,8 @@ await snapshotTest({ description: null, targetDate: null, sortOrder: 1, - createdAt: "2020-01-01T00:00:00Z", - updatedAt: "2020-01-02T00:00:00Z", + createdAt: "2020-01-01T12:00:00Z", + updatedAt: "2020-01-02T12:00:00Z", project: { id: "project-1", name: "P", @@ -282,8 +284,8 @@ await snapshotTest({ description: null, targetDate: null, sortOrder: 1, - createdAt: "2020-01-01T00:00:00Z", - updatedAt: "2020-01-02T00:00:00Z", + createdAt: "2020-01-01T12:00:00Z", + updatedAt: "2020-01-02T12:00:00Z", project: { id: "project-1", name: "P", @@ -314,8 +316,8 @@ await snapshotTest({ description: null, targetDate: null, sortOrder: 1, - createdAt: "2020-01-01T00:00:00Z", - updatedAt: "2020-01-02T00:00:00Z", + createdAt: "2020-01-01T12:00:00Z", + updatedAt: "2020-01-02T12:00:00Z", project: { id: "project-1", name: "P", @@ -350,3 +352,77 @@ await snapshotTest({ } }, }) + +// --all must fail loudly, not silently truncate, when Linear advertises another +// page but returns no cursor to fetch it. (Regression guard for the exact bug +// this command fixes: silently dropping issues.) +Deno.test("Milestone View Command - --all errors on inconsistent pagination", async () => { + const server = new MockLinearServer([ + { + queryName: "GetMilestoneDetails", + variables: { id: "milestone-bad", first: 50 }, + response: { + data: { + projectMilestone: { + id: "milestone-bad", + name: "Broken Pagination", + description: null, + targetDate: null, + sortOrder: 1, + createdAt: "2020-01-01T12:00:00Z", + updatedAt: "2020-01-02T12:00:00Z", + project: { + id: "project-1", + name: "P", + slugId: "p", + url: "https://linear.app/test/project/p", + }, + issues: { + nodes: Array.from({ length: 50 }, (_, i) => ({ + id: `id-${i}`, + identifier: `BAD-${i + 1}`, + title: `Issue ${i + 1}`, + state: { name: "Done", type: "completed" }, + })), + // hasNextPage true but no cursor: continuing is impossible. + pageInfo: { hasNextPage: true, endCursor: null }, + }, + }, + }, + }, + }, + ]) + + 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 server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + await viewCommand.parse(["milestone-bad", "--all"]) + } catch (e) { + if (!(e instanceof Error) || e.message !== "EXIT") throw e + exited = true + } finally { + errorStub.restore() + exitStub.restore() + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + + assertEquals(exited, true) + assertEquals( + errorLogs.some((l) => + l.includes("more issues but returned no pagination cursor") + ), + true, + ) +}) From 9756aafe19cb81223563015448ca1a89bbb04fc8 Mon Sep 17 00:00:00 2001 From: Joseph <31323641+josephyooo@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:58:36 -0400 Subject: [PATCH 08/12] Guard document updates when inline comments exist (#235) ## Summary This makes Linear document comments visible to agents/automation and prevents `linear document update` from silently orphaning inline comment anchors. - include paginated document comments in `linear document view <id> --json` - guard Markdown content updates when active inline document comments are present - allow top-level document comments to pass through, since they do not carry losable inline anchors - add `--force` to opt back into the existing replacement behavior when the caller intentionally accepts the risk ## Background / related work Closes #230, which reports that document comments are currently unreachable from the CLI and omitted from `document view --json`. Related: - #219 added richer `document view` behavior for downloaded inline images; this PR keeps normal/raw rendered views lean and only expands `--json` with comment metadata. - #121 added the raw `linear api` GraphQL escape hatch. That is useful for inspection, but it does not make `documentUpdate(content)` safe for inline document comments. ## API limitation This is intentionally a safety guard plus read fix, not a comment-preserving writer. From the schema used by this CLI: - `Document.comments(...)` exposes document comments and `Comment.quotedText`, which is enough to detect inline comments. - `DocumentUpdateInput` only accepts Markdown `content` for document body writes; it does not accept `contentState`/YJS/ProseMirror data or comment anchor metadata. - `CommentCreateInput`/`CommentUpdateInput` expose `quotedText`, but live testing showed raw GraphQL `commentUpdate(quotedText: ...)` returns success without recreating an inline anchor. Writing `<linear-comment>`-style tags through `documentUpdate(content)` stores literal text, not an anchor. So the CLI cannot preserve or restore inline anchors through the public GraphQL write path it uses. The best safe behavior here is to make comments visible and convert silent data loss into an explicit stop. `--force` remains the old replacement behavior behind an intentional flag. ## Behavior `linear document view <id> --json` now returns a flattened, paginated `comments.nodes` list with fields useful to agents: - `id`, `body`, `quotedText`, `documentContentId` - timestamps / archive / resolution metadata - `url`, `user`, and `parent.id` - final `pageInfo` `linear document update <id> --content...` now scans active document comments page-by-page. It blocks only when it finds a comment with `quotedText`, i.e. an inline comment anchor that can be detached by replacing Markdown content. Top-level document comments with `quotedText: null` do not block. ## Testing - `deno task validate` - `deno test --allow-all --quiet` Full suite result locally: `338 passed`, `0 failed`, `5 ignored`. --- README.md | 5 +- skills/linear-cli/references/document.md | 26 +- src/commands/document/document-update.ts | 113 +++++++- src/commands/document/document-view.ts | 88 +++++- .../document-update.test.ts.snap | 40 ++- .../__snapshots__/document-view.test.ts.snap | 44 ++- .../commands/document/document-update.test.ts | 251 ++++++++++++++++++ test/commands/document/document-view.test.ts | 103 ++++++- 8 files changed, 635 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 2d889ab6..75e18f76 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,7 @@ linear document list --json # output as JSON linear document view <slug> # view document rendered in terminal linear document view <slug> --raw # output raw markdown (for piping) linear document view <slug> --web # open in browser -linear document view <slug> --json # output as JSON +linear document view <slug> --json # output as JSON, including document comments # create a document linear document create --title "My Doc" --content "# Hello" # inline content @@ -234,6 +234,7 @@ cat spec.md | linear document create --title "Spec" # from std linear document update <slug> --title "New Title" # update title linear document update <slug> --content-file ./updated.md # update content linear document update <slug> --edit # open in $EDITOR +linear document update <slug> --content-file ./updated.md --force # bypass comment-anchor guard # delete a document linear document delete <slug> # soft delete (move to trash) @@ -241,6 +242,8 @@ linear document delete <slug> --permanent # permanent delete linear document delete --bulk <slug1> <slug2> # bulk delete ``` +content updates are refused by default when a document has active inline Linear comments, because replacing markdown can detach or hide those anchors. top-level document comments do not block updates. review the inline comment first, then rerun with `--force` if you intentionally want to replace the content anyway. + ### other commands ```bash diff --git a/skills/linear-cli/references/document.md b/skills/linear-cli/references/document.md index 6d6c7e08..3facef40 100644 --- a/skills/linear-cli/references/document.md +++ b/skills/linear-cli/references/document.md @@ -61,11 +61,12 @@ Description: Options: - -h, --help - Show this help. - --workspace <slug> - Target workspace (uses credentials) - --raw - Output raw markdown without rendering - -w, --web - Open document in browser - --json - Output full document as JSON + -h, --help - Show this help. + --workspace <slug> - Target workspace (uses credentials) + --raw - Output raw markdown without rendering + -w, --web - Open document in browser + --json - Output full document as JSON + --no-download - Keep remote URLs instead of downloading files ``` ### create @@ -105,13 +106,14 @@ Description: Options: - -h, --help - Show this help. - --workspace <slug> - Target workspace (uses credentials) - -t, --title <title> - New title for the document - -c, --content <content> - New markdown content (inline) - -f, --content-file <path> - Read new content from file - --icon <icon> - New icon (emoji) - -e, --edit - Open current content in $EDITOR for editing + -h, --help - Show this help. + --workspace <slug> - Target workspace (uses credentials) + -t, --title <title> - New title for the document + -c, --content <content> - New markdown content (inline) + -f, --content-file <path> - Read new content from file + --icon <icon> - New icon (emoji) + -e, --edit - Open current content in $EDITOR for editing + --force - Update content even when document comments may lose inline anchors ``` ### delete diff --git a/src/commands/document/document-update.ts b/src/commands/document/document-update.ts index 4dcceabd..7773884a 100644 --- a/src/commands/document/document-update.ts +++ b/src/commands/document/document-update.ts @@ -10,6 +10,83 @@ import { ValidationError, } from "../../utils/errors.ts" +const GetDocumentForEdit = gql(` + query GetDocumentForEdit($id: String!) { + document(id: $id) { + id + title + content + } + } +`) + +const DocumentInlineCommentGuard = gql(` + query DocumentInlineCommentGuard($id: String!, $after: String) { + document(id: $id) { + id + comments(first: 50, after: $after, orderBy: createdAt) { + nodes { + id + quotedText + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +`) + +interface DocumentInlineComment { + id: string + quotedText?: string | null +} + +interface DocumentInlineCommentGuardResponse { + document?: { + comments: { + nodes: DocumentInlineComment[] + pageInfo: { + hasNextPage: boolean + endCursor?: string | null + } + } + } | null +} + +async function getFirstInlineDocumentComment( + client: ReturnType<typeof getGraphQLClient>, + documentId: string, +) { + let after: string | null | undefined = null + + while (true) { + const documentData: DocumentInlineCommentGuardResponse = await client + .request(DocumentInlineCommentGuard, { + id: documentId, + after, + }) + + if (!documentData?.document) { + throw new NotFoundError("Document", documentId) + } + + const comments = documentData.document.comments.nodes + const inlineComment = comments.find((comment) => comment.quotedText) + if (inlineComment) { + return inlineComment + } + + const pageInfo = documentData.document.comments.pageInfo + if (!pageInfo.hasNextPage) { + return undefined + } + + after = pageInfo.endCursor + } +} + /** * Open editor with initial content and return the edited content */ @@ -108,9 +185,13 @@ export const updateCommand = new Command() ) .option("--icon <icon:string>", "New icon (emoji)") .option("-e, --edit", "Open current content in $EDITOR for editing") + .option( + "--force", + "Update content even when document comments may lose inline anchors", + ) .action( async ( - { title, content, contentFile, icon, edit }, + { title, content, contentFile, icon, edit, force }, documentId, ) => { try { @@ -152,17 +233,7 @@ export const updateCommand = new Command() } } else if (edit) { // Edit mode: fetch current content and open in editor - const getDocumentQuery = gql(` - query GetDocumentForEdit($id: String!) { - document(id: $id) { - id - title - content - } - } - `) - - const documentData = await client.request(getDocumentQuery, { + const documentData = await client.request(GetDocumentForEdit, { id: documentId, }) @@ -209,6 +280,24 @@ export const updateCommand = new Command() }) } + if (input.content !== undefined && !force) { + const comment = await getFirstInlineDocumentComment( + client, + documentId, + ) + + if (comment) { + throw new ValidationError( + "Refusing to update document content because this document has inline comments.", + { + suggestion: + `Updating Markdown content can detach or hide Linear document comments. ` + + `First review comment ${comment.id} quoting "${comment.quotedText}", then rerun with --force if you accept that risk.`, + }, + ) + } + } + // Execute the update const updateMutation = gql(` mutation UpdateDocument($id: String!, $input: DocumentUpdateInput!) { diff --git a/src/commands/document/document-view.ts b/src/commands/document/document-view.ts index bfc43868..6779e24d 100644 --- a/src/commands/document/document-view.ts +++ b/src/commands/document/document-view.ts @@ -43,6 +43,90 @@ const GetDocument = gql(` } `) +const GetDocumentWithComments = gql(` + query GetDocumentWithComments($id: String!, $commentsAfter: String) { + document(id: $id) { + id + title + slugId + content + url + createdAt + updatedAt + creator { + name + email + } + project { + name + slugId + } + issue { + identifier + title + } + comments(first: 50, after: $commentsAfter, orderBy: createdAt) { + nodes { + id + body + quotedText + documentContentId + createdAt + updatedAt + archivedAt + resolvedAt + url + user { + name + email + } + parent { + id + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +`) + +async function getDocumentWithAllComments( + client: ReturnType<typeof getGraphQLClient>, + id: string, +) { + const firstResult = await client.request(GetDocumentWithComments, { + id, + commentsAfter: null, + }) + + if (!firstResult.document) { + return undefined + } + + const document = firstResult.document + let commentsAfter = document.comments.pageInfo.endCursor + + while (document.comments.pageInfo.hasNextPage) { + const nextResult = await client.request(GetDocumentWithComments, { + id, + commentsAfter, + }) + + if (!nextResult.document) { + return undefined + } + + document.comments.nodes.push(...nextResult.document.comments.nodes) + document.comments.pageInfo = nextResult.document.comments.pageInfo + commentsAfter = nextResult.document.comments.pageInfo.endCursor + } + + return document +} + export const viewCommand = new Command() .name("view") .description("View a document's content") @@ -60,7 +144,9 @@ export const viewCommand = new Command() try { const client = getGraphQLClient() - const result = await client.request(GetDocument, { id }) + const result = json + ? { document: await getDocumentWithAllComments(client, id) } + : await client.request(GetDocument, { id }) spinner?.stop() const document = result.document diff --git a/test/commands/document/__snapshots__/document-update.test.ts.snap b/test/commands/document/__snapshots__/document-update.test.ts.snap index f22a5096..b4549882 100644 --- a/test/commands/document/__snapshots__/document-update.test.ts.snap +++ b/test/commands/document/__snapshots__/document-update.test.ts.snap @@ -11,12 +11,13 @@ Description: Options: - -h, --help - Show this help. - -t, --title <title> - New title for the document - -c, --content <content> - New markdown content (inline) - -f, --content-file <path> - Read new content from file - --icon <icon> - New icon (emoji) - -e, --edit - Open current content in \$EDITOR for editing + -h, --help - Show this help. + -t, --title <title> - New title for the document + -c, --content <content> - New markdown content (inline) + -f, --content-file <path> - Read new content from file + --icon <icon> - New icon (emoji) + -e, --edit - Open current content in \$EDITOR for editing + --force - Update content even when document comments may lose inline anchors " stderr: @@ -41,6 +42,33 @@ stderr: "" `; +snapshot[`Document Update Command - Allows Content Update With Top Level Comments 1`] = ` +stdout: +"✓ Updated document: Delegation System Spec +https://linear.app/test/document/delegation-system-spec-d4b93e3b2695 +" +stderr: +"" +`; + +snapshot[`Document Update Command - Blocks Content Update With Inline Comments 1`] = ` +stdout: +"" +stderr: +'✗ Failed to update document: Refusing to update document content because this document has inline comments. + Updating Markdown content can detach or hide Linear document comments. First review comment comment-2 quoting "Current Content", then rerun with --force if you accept that risk. +' +`; + +snapshot[`Document Update Command - Force Content Update With Comments 1`] = ` +stdout: +"✓ Updated document: Delegation System Spec +https://linear.app/test/document/delegation-system-spec-d4b93e3b2695 +" +stderr: +"" +`; + snapshot[`Document Update Command - Update Multiple Fields 1`] = ` stdout: "✓ Updated document: Updated Title diff --git a/test/commands/document/__snapshots__/document-view.test.ts.snap b/test/commands/document/__snapshots__/document-view.test.ts.snap index 123ca86e..0fbff8b8 100644 --- a/test/commands/document/__snapshots__/document-view.test.ts.snap +++ b/test/commands/document/__snapshots__/document-view.test.ts.snap @@ -70,7 +70,49 @@ stdout: "name": "TinyCloud SDK", "slugId": "tinycloud-sdk" }, - "issue": null + "issue": null, + "comments": { + "nodes": [ + { + "id": "comment-1", + "body": "Can we clarify this?", + "quotedText": "delegation system architecture", + "documentContentId": "document-content-1", + "createdAt": "2026-01-18T11:00:00Z", + "updatedAt": "2026-01-18T11:00:00Z", + "archivedAt": null, + "resolvedAt": null, + "url": "https://linear.app/test/comment/comment-1", + "user": { + "name": "Jane Reviewer", + "email": "jane@example.com" + }, + "parent": null + }, + { + "id": "comment-2", + "body": "Follow-up note", + "quotedText": null, + "documentContentId": "document-content-1", + "createdAt": "2026-01-18T12:00:00Z", + "updatedAt": "2026-01-18T12:00:00Z", + "archivedAt": null, + "resolvedAt": null, + "url": "https://linear.app/test/comment/comment-2", + "user": { + "name": "John Doe", + "email": "john@example.com" + }, + "parent": { + "id": "comment-1" + } + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } + } } ' stderr: diff --git a/test/commands/document/document-update.test.ts b/test/commands/document/document-update.test.ts index 969f2074..1806f963 100644 --- a/test/commands/document/document-update.test.ts +++ b/test/commands/document/document-update.test.ts @@ -72,6 +72,29 @@ await snapshotTest({ denoArgs: commonDenoArgs, async fn() { const server = new MockLinearServer([ + { + queryName: "DocumentInlineCommentGuard", + variables: { + id: "d4b93e3b2695", + after: null, + }, + response: { + data: { + document: { + id: "doc-1", + title: "Delegation System Spec", + content: "# Current Content", + comments: { + nodes: [], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, + }, + }, + }, + }, { queryName: "UpdateDocument", variables: { @@ -112,6 +135,211 @@ await snapshotTest({ }, }) +// Test content updates allow top-level document comments without inline anchors +await snapshotTest({ + name: + "Document Update Command - Allows Content Update With Top Level Comments", + meta: import.meta, + colors: false, + args: ["d4b93e3b2695", "--content", "# Updated Content"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "DocumentInlineCommentGuard", + variables: { + id: "d4b93e3b2695", + after: null, + }, + response: { + data: { + document: { + id: "doc-1", + comments: { + nodes: [ + { + id: "comment-1", + quotedText: null, + }, + ], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, + }, + }, + }, + }, + { + queryName: "UpdateDocument", + variables: { + id: "d4b93e3b2695", + input: { + content: "# Updated Content", + }, + }, + response: { + data: { + documentUpdate: { + success: true, + document: { + id: "doc-1", + slugId: "d4b93e3b2695", + title: "Delegation System Spec", + url: + "https://linear.app/test/document/delegation-system-spec-d4b93e3b2695", + updatedAt: "2026-01-19T10:00:00Z", + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await updateCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +// Test content updates refuse to run when inline document comments exist +await snapshotTest({ + name: "Document Update Command - Blocks Content Update With Inline Comments", + meta: import.meta, + colors: false, + canFail: true, + args: ["d4b93e3b2695", "--content", "# Updated Content"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "DocumentInlineCommentGuard", + variables: { + id: "d4b93e3b2695", + after: null, + }, + response: { + data: { + document: { + id: "doc-1", + title: "Delegation System Spec", + content: "# Current Content", + comments: { + nodes: [ + { + id: "comment-1", + quotedText: null, + }, + ], + pageInfo: { + hasNextPage: true, + endCursor: "cursor-1", + }, + }, + }, + }, + }, + }, + { + queryName: "DocumentInlineCommentGuard", + variables: { + id: "d4b93e3b2695", + after: "cursor-1", + }, + response: { + data: { + document: { + id: "doc-1", + comments: { + nodes: [ + { + id: "comment-2", + quotedText: "Current Content", + }, + ], + 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 updateCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +// Test --force bypasses the comment guard for intentional content replacement +await snapshotTest({ + name: "Document Update Command - Force Content Update With Comments", + meta: import.meta, + colors: false, + args: ["d4b93e3b2695", "--content", "# Updated Content", "--force"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "UpdateDocument", + variables: { + id: "d4b93e3b2695", + input: { + content: "# Updated Content", + }, + }, + response: { + data: { + documentUpdate: { + success: true, + document: { + id: "doc-1", + slugId: "d4b93e3b2695", + title: "Delegation System Spec", + url: + "https://linear.app/test/document/delegation-system-spec-d4b93e3b2695", + updatedAt: "2026-01-19T10:00:00Z", + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await updateCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + // Test updating multiple fields await snapshotTest({ name: "Document Update Command - Update Multiple Fields", @@ -129,6 +357,29 @@ await snapshotTest({ denoArgs: commonDenoArgs, async fn() { const server = new MockLinearServer([ + { + queryName: "DocumentInlineCommentGuard", + variables: { + id: "d4b93e3b2695", + after: null, + }, + response: { + data: { + document: { + id: "doc-1", + title: "Delegation System Spec", + content: "# Current Content", + comments: { + nodes: [], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, + }, + }, + }, + }, { queryName: "UpdateDocument", variables: { diff --git a/test/commands/document/document-view.test.ts b/test/commands/document/document-view.test.ts index a2df16f3..d7022ad0 100644 --- a/test/commands/document/document-view.test.ts +++ b/test/commands/document/document-view.test.ts @@ -42,6 +42,33 @@ await snapshotTest({ creator: { name: "John Doe", email: "john@example.com" }, project: { name: "TinyCloud SDK", slugId: "tinycloud-sdk" }, issue: null, + comments: { + nodes: [ + { + id: "comment-1", + body: "Can we clarify this?", + quotedText: "delegation system architecture", + documentContentId: "document-content-1", + createdAt: "2026-01-18T11:00:00Z", + updatedAt: "2026-01-18T11:00:00Z", + archivedAt: null, + resolvedAt: null, + url: "https://linear.app/test/comment/comment-1", + user: { + name: "Jane Reviewer", + email: "jane@example.com", + }, + parent: null, + children: { + nodes: [], + }, + }, + ], + pageInfo: { + hasNextPage: false, + endCursor: "cursor-1", + }, + }, }, }, }, @@ -119,8 +146,54 @@ await snapshotTest({ async fn() { const server = new MockLinearServer([ { - queryName: "GetDocument", - variables: { id: "d4b93e3b2695" }, + queryName: "GetDocumentWithComments", + variables: { id: "d4b93e3b2695", commentsAfter: null }, + response: { + data: { + document: { + id: "doc-1", + title: "Delegation System Spec", + slugId: "d4b93e3b2695", + content: + "# Delegation System\n\nThis document describes the delegation system architecture.", + url: + "https://linear.app/test/document/delegation-system-spec-d4b93e3b2695", + createdAt: "2026-01-15T08:00:00Z", + updatedAt: "2026-01-18T10:30:00Z", + creator: { name: "John Doe", email: "john@example.com" }, + project: { name: "TinyCloud SDK", slugId: "tinycloud-sdk" }, + issue: null, + comments: { + nodes: [ + { + id: "comment-1", + body: "Can we clarify this?", + quotedText: "delegation system architecture", + documentContentId: "document-content-1", + createdAt: "2026-01-18T11:00:00Z", + updatedAt: "2026-01-18T11:00:00Z", + archivedAt: null, + resolvedAt: null, + url: "https://linear.app/test/comment/comment-1", + user: { + name: "Jane Reviewer", + email: "jane@example.com", + }, + parent: null, + }, + ], + pageInfo: { + hasNextPage: true, + endCursor: "cursor-1", + }, + }, + }, + }, + }, + }, + { + queryName: "GetDocumentWithComments", + variables: { id: "d4b93e3b2695", commentsAfter: "cursor-1" }, response: { data: { document: { @@ -136,6 +209,32 @@ await snapshotTest({ creator: { name: "John Doe", email: "john@example.com" }, project: { name: "TinyCloud SDK", slugId: "tinycloud-sdk" }, issue: null, + comments: { + nodes: [ + { + id: "comment-2", + body: "Follow-up note", + quotedText: null, + documentContentId: "document-content-1", + createdAt: "2026-01-18T12:00:00Z", + updatedAt: "2026-01-18T12:00:00Z", + archivedAt: null, + resolvedAt: null, + url: "https://linear.app/test/comment/comment-2", + user: { + name: "John Doe", + email: "john@example.com", + }, + parent: { + id: "comment-1", + }, + }, + ], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, }, }, }, From dec4ee59665635c2a6589ca2aa9f0f79510f7fe4 Mon Sep 17 00:00:00 2001 From: Peter Schilling <code@schpet.com> Date: Sat, 11 Jul 2026 13:52:55 -0700 Subject: [PATCH 09/12] Follow-up to #235: exclude resolved inline comments from the update guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #235 makes `document update` refuse a content replacement when the document has active inline comments (whose anchors the replacement could orphan), with --force to override, and exposes comments in `document view --json`. Two corrections to the guard in document-update.ts: - Exclude resolved/archived inline comments. The guard query only selected `quotedText`, so it blocked on ANY inline comment — including resolved (closed) threads whose anchor detaching loses no live context. It now also selects `resolvedAt`/`archivedAt` and ignores comments that are resolved or archived, so a closed thread no longer forces users to pass --force. - Drop the hand-written `DocumentInlineComment*` interfaces in favor of the codegen-inferred type (`DocumentInlineCommentGuardQuery`), per the repo's no-`any`/typed-GraphQL convention. The annotation also breaks the circular result-type inference that the reused `after` cursor variable introduces (the reason the hand-written interface existed). Adds a test that a resolved inline comment lets the update proceed without --force. --- src/commands/document/document-update.ts | 48 ++++++------- .../document-update.test.ts.snap | 9 +++ .../commands/document/document-update.test.ts | 72 +++++++++++++++++++ 3 files changed, 102 insertions(+), 27 deletions(-) diff --git a/src/commands/document/document-update.ts b/src/commands/document/document-update.ts index 7773884a..227a42f5 100644 --- a/src/commands/document/document-update.ts +++ b/src/commands/document/document-update.ts @@ -1,5 +1,6 @@ import { Command } from "@cliffy/command" import { gql } from "../../__codegen__/gql.ts" +import type { DocumentInlineCommentGuardQuery } from "../../__codegen__/graphql.ts" import { getGraphQLClient } from "../../utils/graphql.ts" import { getEditor } from "../../utils/editor.ts" import { readIdsFromStdin } from "../../utils/bulk.ts" @@ -28,6 +29,8 @@ const DocumentInlineCommentGuard = gql(` nodes { id quotedText + resolvedAt + archivedAt } pageInfo { hasNextPage @@ -38,42 +41,33 @@ const DocumentInlineCommentGuard = gql(` } `) -interface DocumentInlineComment { - id: string - quotedText?: string | null -} - -interface DocumentInlineCommentGuardResponse { - document?: { - comments: { - nodes: DocumentInlineComment[] - pageInfo: { - hasNextPage: boolean - endCursor?: string | null - } - } - } | null -} - -async function getFirstInlineDocumentComment( +// An inline comment (quotedText != null) still anchored to live text — i.e. not +// resolved and not archived — is the only kind a content replacement can +// meaningfully orphan. Resolved/archived threads are closed, so detaching their +// anchor loses nothing and must not block the update. +async function getFirstActiveInlineComment( client: ReturnType<typeof getGraphQLClient>, documentId: string, ) { let after: string | null | undefined = null while (true) { - const documentData: DocumentInlineCommentGuardResponse = await client - .request(DocumentInlineCommentGuard, { - id: documentId, - after, - }) + // Annotate with the codegen type: reusing `after` across iterations would + // otherwise make the request's result type circular (self-referential). + const documentData: DocumentInlineCommentGuardQuery = await client.request( + DocumentInlineCommentGuard, + { id: documentId, after }, + ) - if (!documentData?.document) { + if (!documentData.document) { throw new NotFoundError("Document", documentId) } - const comments = documentData.document.comments.nodes - const inlineComment = comments.find((comment) => comment.quotedText) + const inlineComment = documentData.document.comments.nodes.find( + (comment) => + comment.quotedText != null && comment.resolvedAt == null && + comment.archivedAt == null, + ) if (inlineComment) { return inlineComment } @@ -281,7 +275,7 @@ export const updateCommand = new Command() } if (input.content !== undefined && !force) { - const comment = await getFirstInlineDocumentComment( + const comment = await getFirstActiveInlineComment( client, documentId, ) diff --git a/test/commands/document/__snapshots__/document-update.test.ts.snap b/test/commands/document/__snapshots__/document-update.test.ts.snap index b4549882..58b867ab 100644 --- a/test/commands/document/__snapshots__/document-update.test.ts.snap +++ b/test/commands/document/__snapshots__/document-update.test.ts.snap @@ -86,3 +86,12 @@ stderr: Use --title, --content, --content-file, --icon, or --edit. " `; + +snapshot[`Document Update Command - Resolved Inline Comment Does Not Block 1`] = ` +stdout: +"✓ Updated document: Delegation System Spec +https://linear.app/test/document/delegation-system-spec-d4b93e3b2695 +" +stderr: +"" +`; diff --git a/test/commands/document/document-update.test.ts b/test/commands/document/document-update.test.ts index 1806f963..5ffa4a97 100644 --- a/test/commands/document/document-update.test.ts +++ b/test/commands/document/document-update.test.ts @@ -444,3 +444,75 @@ await snapshotTest({ }) // NOTE: "Permission Error" test removed - stack traces contain machine-specific paths + +// A RESOLVED inline comment (closed thread) must NOT block a content update: +// detaching the anchor of a resolved comment loses no live context, so the +// guard should let the update through without --force. +await snapshotTest({ + name: "Document Update Command - Resolved Inline Comment Does Not Block", + meta: import.meta, + colors: false, + args: ["d4b93e3b2695", "--content", "# Updated Content"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "DocumentInlineCommentGuard", + variables: { id: "d4b93e3b2695" }, + response: { + data: { + document: { + id: "doc-1", + comments: { + nodes: [ + { + // Inline (has quotedText) but resolved: must be ignored. + id: "comment-resolved", + quotedText: "Old anchored text", + resolvedAt: "2026-01-15T10:00:00Z", + archivedAt: null, + }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + }, + { + queryName: "UpdateDocument", + variables: { + id: "d4b93e3b2695", + input: { content: "# Updated Content" }, + }, + response: { + data: { + documentUpdate: { + success: true, + document: { + id: "doc-1", + slugId: "d4b93e3b2695", + title: "Delegation System Spec", + url: + "https://linear.app/test/document/delegation-system-spec-d4b93e3b2695", + updatedAt: "2026-01-19T10:00:00Z", + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await updateCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) From 9e0874847ef81dea22cf79edcb4e524dafe967f8 Mon Sep 17 00:00:00 2001 From: Magnus Buvarp <magnus.buvarp@gmail.com> Date: Sat, 11 Jul 2026 23:11:26 +0200 Subject: [PATCH 10/12] Improve issue create project selection (#208) ## Summary This improves `linear issue create` in three related areas: - allow `--project` to keep issue creation interactive, the same way `--parent` already does - add optional project selection in interactive issue creation - add configurable default self-assignment behavior for created issues Before this change, `linear issue create --project "Dashboard"` skipped interactive mode and failed with "Title is required when not using interactive mode". With this PR, `--project` is treated as an interactive-safe input, so users can preselect a project without also needing `--title`. Project prompting in interactive mode remains opt-in. `issue_create_ask_project` defaults to `false`, so existing interactive behavior is unchanged unless users explicitly enable the new prompt. ## Changes - allow interactive issue creation when the only flags are `--project`, `--parent`, or both - add a team-scoped project picker during interactive issue creation - gate the direct project prompt behind a new config option: `issue_create_ask_project` - keep `issue_create_ask_project = false` as the default, so project selection stays out of the main interactive flow unless enabled - when `issue_create_ask_project = false`, expose `Project` through the existing "Add more fields" flow instead - continue inheriting the parent issue's project when `--parent` is used without an explicit `--project` - allow explicit `--project` together with `--parent` and defer any invalid combination checks to the Linear API - add a new config option, `issue_create_assign_self`, with these modes: - `auto` (default): respect Linear's `autoAssignToSelf` setting - `always`: default-assign created issues to self - `never`: never default-assign created issues - update docs for the new interactive behavior and config options ## Notes - `issue_create_ask_project` defaults to `false`, so users who do not set it will keep the previous interactive flow - project selection only shows projects available to the selected team - the interactive project picker is not shown when a parent issue is present unless the project was explicitly provided - explicit `--assignee`, the interactive assignee flow, and `--start` still override the default assignment behavior ## Testing - `deno task codegen` - `deno task check` - `deno lint` - `deno fmt` - `deno task generate-skill-docs` - `deno test --allow-all --quiet test/config.test.ts` - `deno test --allow-all --quiet test/commands/issue/issue-create.test.ts` --- README.md | 16 +- docs/authentication.md | 2 + src/commands/issue/issue-create.ts | 291 ++++-- src/config.ts | 2 + src/utils/linear.ts | 48 + test/commands/issue/issue-create.test.ts | 1098 ++++++++++++++++++++++ test/config.test.ts | 32 + 7 files changed, 1387 insertions(+), 102 deletions(-) diff --git a/README.md b/README.md index 75e18f76..d5faa859 100644 --- a/README.md +++ b/README.md @@ -257,13 +257,15 @@ linear completions # generate shell completions the CLI supports configuration via environment variables or a `.linear.toml` config file. environment variables take precedence over config file values. -| option | env var | toml key | example | description | -| --------------- | ------------------------ | ----------------- | -------------------------- | ------------------------------------- | -| Team ID | `LINEAR_TEAM_ID` | `team_id` | `"ENG"` | default team for operations | -| Workspace | `LINEAR_WORKSPACE` | `workspace` | `"mycompany"` | workspace slug for web/app URLs | -| Issue sort | `LINEAR_ISSUE_SORT` | `issue_sort` | `"priority"` or `"manual"` | how to sort issue lists | -| VCS | `LINEAR_VCS` | `vcs` | `"git"` or `"jj"` | version control system (default: git) | -| Download images | `LINEAR_DOWNLOAD_IMAGES` | `download_images` | `true` or `false` | download images when viewing issues | +| option | env var | toml key | example | description | +| --------------- | --------------------------------- | -------------------------- | ---------------------------------- | ----------------------------------------------------- | +| Team ID | `LINEAR_TEAM_ID` | `team_id` | `"ENG"` | default team for operations | +| Workspace | `LINEAR_WORKSPACE` | `workspace` | `"mycompany"` | workspace slug for web/app URLs | +| Issue sort | `LINEAR_ISSUE_SORT` | `issue_sort` | `"priority"` or `"manual"` | how to sort issue lists | +| Ask project | `LINEAR_ISSUE_CREATE_ASK_PROJECT` | `issue_create_ask_project` | `true` or `false` | ask for a project during interactive `issue create` | +| Assign self | `LINEAR_ISSUE_CREATE_ASSIGN_SELF` | `issue_create_assign_self` | `"always"`, `"auto"`, or `"never"` | control default self-assignment during issue creation | +| VCS | `LINEAR_VCS` | `vcs` | `"git"` or `"jj"` | version control system (default: git) | +| Download images | `LINEAR_DOWNLOAD_IMAGES` | `download_images` | `true` or `false` | download images when viewing issues | the config file can be placed at (checked in order, first found is used): diff --git a/docs/authentication.md b/docs/authentication.md index c5869a7d..89227e9d 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -118,6 +118,8 @@ you can also set the API key in a project's `.linear.toml`: api_key = "lin_api_..." workspace = "acme" team_id = "ENG" +issue_create_assign_self = "always" +issue_create_ask_project = true ``` this is useful for project-specific credentials but less secure than stored credentials since it may be committed to version control. diff --git a/src/commands/issue/issue-create.ts b/src/commands/issue/issue-create.ts index d9f37b29..73866f70 100644 --- a/src/commands/issue/issue-create.ts +++ b/src/commands/issue/issue-create.ts @@ -1,6 +1,7 @@ import { Command } from "@cliffy/command" import { Checkbox, Input, Select } from "@cliffy/prompt" import { gql } from "../../__codegen__/gql.ts" +import { getOption } from "../../config.ts" import { getGraphQLClient } from "../../utils/graphql.ts" import { getEditor, openEditor } from "../../utils/editor.ts" import { getPriorityDisplay } from "../../utils/display.ts" @@ -16,6 +17,7 @@ import { getMilestoneIdByName, getProjectIdByName, getProjectOptionsByName, + getProjectsForTeam, getTeamIdByKey, getTeamKey, getWorkflowStateByNameOrType, @@ -34,6 +36,12 @@ import { } from "../../utils/errors.ts" type IssueLabel = { id: string; name: string; color: string } +type ProjectOption = { id: string; name: string } +type IssueCreatePreloadedData = { + states?: WorkflowState[] + labels?: IssueLabel[] + projects?: ProjectOption[] +} type AdditionalField = { key: string @@ -41,13 +49,130 @@ type AdditionalField = { handler: ( teamKey: string, teamId: string, - preloaded?: { - states?: WorkflowState[] - labels?: IssueLabel[] - }, + preloaded?: IssueCreatePreloadedData, ) => Promise<string | number | string[] | undefined> } +function getIssueCreateAssignSelfMode(): "always" | "auto" | "never" { + return getOption("issue_create_assign_self") ?? "auto" +} + +function shouldAskProjectDuringInteractiveCreate(): boolean { + return getOption("issue_create_ask_project") === true +} + +async function getLinearAutoAssignToSelf(): Promise<boolean> { + const client = getGraphQLClient() + const userSettingsQuery = gql(` + query GetUserSettings { + userSettings { + autoAssignToSelf + } + } + `) + const result = await client.request(userSettingsQuery) + return result.userSettings.autoAssignToSelf +} + +async function shouldAssignSelfByDefaultForInteractiveCreate(): Promise< + boolean +> { + const mode = getIssueCreateAssignSelfMode() + if (mode === "always") { + return true + } + if (mode === "never") { + return false + } + return await getLinearAutoAssignToSelf() +} + +function shouldAssignSelfByDefaultForFlagCreate(): boolean { + return getIssueCreateAssignSelfMode() === "always" +} + +async function promptProjectSelection( + teamKey: string, + preloadedProjects?: ProjectOption[], +): Promise<string | undefined> { + const projects = preloadedProjects ?? await getProjectsForTeam(teamKey) + if (projects.length === 0) { + return undefined + } + + const noProjectValue = "__none__" + const selectedProjectId = await Select.prompt({ + message: "Which project should this issue belong to?", + search: true, + searchLabel: "Search projects", + options: [ + { name: "No project", value: noProjectValue }, + ...projects.map((project) => ({ + name: project.name, + value: project.id, + })), + ], + default: noProjectValue, + }) + + if (selectedProjectId === noProjectValue) { + return undefined + } + + return selectedProjectId +} + +async function resolveProjectIdForCreate( + project: string, + interactive: boolean, +): Promise<string> { + let projectId = await getProjectIdByName(project) + if (projectId == null && interactive) { + const projectIds = await getProjectOptionsByName(project) + projectId = await selectOption("Project", project, projectIds) + } + if (projectId == null) { + throw new NotFoundError("Project", project) + } + return projectId +} + +async function resolveParentIssueForCreate( + parentIdentifier?: string, +): Promise<{ + parentId?: string + parentData: { + title: string + identifier: string + projectId: string | null + } | null +}> { + let parentId: string | undefined + let parentData: { + title: string + identifier: string + projectId: string | null + } | null = null + + if (parentIdentifier) { + const parentIdentifierResolved = await getIssueIdentifier(parentIdentifier) + if (!parentIdentifierResolved) { + throw new ValidationError( + `Could not resolve parent issue identifier: ${parentIdentifier}`, + ) + } + + parentId = await getIssueId(parentIdentifierResolved) + if (!parentId) { + throw new NotFoundError("Parent issue", parentIdentifierResolved) + } + + parentData = await fetchParentIssueData(parentId) + } + + return { parentId, parentData } +} + const ADDITIONAL_FIELDS: AdditionalField[] = [ { key: "workflow_state", @@ -55,10 +180,7 @@ const ADDITIONAL_FIELDS: AdditionalField[] = [ handler: async ( teamKey: string, _teamId: string, - preloaded?: { - states?: WorkflowState[] - labels?: IssueLabel[] - }, + preloaded?: IssueCreatePreloadedData, ) => { const states = preloaded?.states ?? await getWorkflowStates(teamKey) if (states.length === 0) return undefined @@ -113,10 +235,7 @@ const ADDITIONAL_FIELDS: AdditionalField[] = [ handler: async ( teamKey: string, _teamId: string, - preloaded?: { - states?: WorkflowState[] - labels?: IssueLabel[] - }, + preloaded?: IssueCreatePreloadedData, ) => { const labels = preloaded?.labels ?? await getLabelsForTeam(teamKey) if (labels.length === 0) return [] @@ -144,6 +263,17 @@ const ADDITIONAL_FIELDS: AdditionalField[] = [ return isNaN(parsed) ? undefined : parsed }, }, + { + key: "project", + label: "Project", + handler: async ( + teamKey: string, + _teamId: string, + preloaded?: IssueCreatePreloadedData, + ) => { + return await promptProjectSelection(teamKey, preloaded?.projects) + }, + }, ] async function promptAdditionalFields( @@ -151,6 +281,7 @@ async function promptAdditionalFields( teamId: string, states: WorkflowState[], labels: IssueLabel[], + includeProject: boolean, autoAssignToSelf: boolean, ): Promise<{ assigneeId?: string @@ -158,6 +289,7 @@ async function promptAdditionalFields( estimate?: number labelIds: string[] stateId?: string + projectId?: string }> { // Build options that display defaults in parentheses for workflow state and assignee let defaultStateName: string | null = null @@ -166,7 +298,9 @@ async function promptAdditionalFields( states[0] defaultStateName = defaultState.name } - const additionalFieldOptions = ADDITIONAL_FIELDS.map((field) => { + const additionalFieldOptions = ADDITIONAL_FIELDS.filter((field) => + includeProject || field.key !== "project" + ).map((field) => { let name = field.label if (field.key === "workflow_state" && defaultStateName) { name = `${field.label} (${defaultStateName})` @@ -186,8 +320,9 @@ async function promptAdditionalFields( let estimate: number | undefined let labelIds: string[] = [] let stateId: string | undefined + let projectId: string | undefined - // Set assignee default based on user settings + // Set assignee default based on configuration if (autoAssignToSelf) { assigneeId = await lookupUserId("self") } @@ -196,9 +331,13 @@ async function promptAdditionalFields( for (const fieldKey of selectedFields) { const field = ADDITIONAL_FIELDS.find((f) => f.key === fieldKey) if (field) { + const projects = includeProject && fieldKey === "project" + ? await getProjectsForTeam(teamKey) + : undefined const value = await field.handler(teamKey, teamId, { states, labels, + projects, }) switch (fieldKey) { @@ -217,6 +356,9 @@ async function promptAdditionalFields( case "estimate": estimate = value as number | undefined break + case "project": + projectId = value as string | undefined + break } } } @@ -227,10 +369,12 @@ async function promptAdditionalFields( estimate, labelIds, stateId, + projectId, } } async function promptInteractiveIssueCreation( + initialProjectId?: string, parentId?: string, parentData?: { title: string @@ -250,20 +394,8 @@ async function promptInteractiveIssueCreation( parentId?: string projectId?: string | null }> { - // Start user settings and team resolution in background while asking for title - const userSettingsPromise = (async () => { - const client = getGraphQLClient() - const userSettingsQuery = gql(` - query GetUserSettings { - userSettings { - autoAssignToSelf - } - } - `) - const result = await client.request(userSettingsQuery) - return result.userSettings.autoAssignToSelf - })() - + const autoAssignToSelfPromise = + shouldAssignSelfByDefaultForInteractiveCreate() const teamResolutionPromise = (async () => { const defaultTeamKey = getTeamKey() if (defaultTeamKey) { @@ -295,9 +427,10 @@ async function promptInteractiveIssueCreation( minLength: 1, }) - // Await team resolution and user settings + // Await team resolution const teamResult = await teamResolutionPromise - const autoAssignToSelf = await userSettingsPromise + const autoAssignToSelf = await autoAssignToSelfPromise + const askProject = shouldAskProjectDuringInteractiveCreate() let teamId: string let teamKey: string @@ -332,6 +465,9 @@ async function promptInteractiveIssueCreation( // Preload team-scoped data (do not await yet) const workflowStatesPromise = getWorkflowStates(teamKey) const labelsPromise = getLabelsForTeam(teamKey) + const projectsPromise = (askProject && !parentData && !initialProjectId) + ? getProjectsForTeam(teamKey) + : Promise.resolve(undefined) // Description prompt const editorName = await getEditor() @@ -366,6 +502,12 @@ async function promptInteractiveIssueCreation( finalDescription = description.trim() } + let projectId = initialProjectId + const projects = await projectsPromise + if (!parentData && !initialProjectId && askProject) { + projectId = await promptProjectSelection(teamKey, projects) + } + // Now await the preloaded data and resolve default state const states = await workflowStatesPromise const labels = await labelsPromise @@ -391,7 +533,7 @@ async function promptInteractiveIssueCreation( let labelIds: string[] = [] let stateId: string | undefined - // Set assignee default based on user settings + // Set assignee default based on configuration if (autoAssignToSelf) { assigneeId = await lookupUserId("self") } @@ -407,6 +549,7 @@ async function promptInteractiveIssueCreation( teamId, states, labels, + !askProject && !parentData && !initialProjectId, autoAssignToSelf, ) @@ -416,6 +559,7 @@ async function promptInteractiveIssueCreation( estimate = additionalFieldsResult.estimate labelIds = additionalFieldsResult.labelIds stateId = additionalFieldsResult.stateId + projectId = additionalFieldsResult.projectId ?? projectId } // Ask about starting work (always show this) @@ -440,7 +584,7 @@ async function promptInteractiveIssueCreation( stateId, start, parentId, - projectId: parentData?.projectId || null, + projectId: projectId ?? parentData?.projectId ?? null, } } @@ -558,40 +702,24 @@ export const createCommand = new Command() } } - // If no flags are provided (or only parent is provided), use interactive mode - const noFlagsProvided = !title && !assignee && !dueDate && + // If no creation flags are provided beyond project/parent, use interactive mode. + const onlyInteractiveSeedFlagsProvided = !title && !assignee && + !dueDate && priority === undefined && estimate === undefined && !finalDescription && (!labels || labels.length === 0) && - !team && !project && !state && !milestone && !cycle && !start + !team && !state && !milestone && !cycle && !start - if (noFlagsProvided && interactive) { + if (onlyInteractiveSeedFlagsProvided && interactive) { try { - // Convert parent identifier if provided and fetch parent data - let parentId: string | undefined - let parentData: { - title: string - identifier: string - projectId: string | null - } | null = null - if (parentIdentifier) { - const parentIdentifierResolved = await getIssueIdentifier( - parentIdentifier, - ) - if (!parentIdentifierResolved) { - throw new ValidationError( - `Could not resolve parent issue identifier: ${parentIdentifier}`, - ) - } - parentId = await getIssueId(parentIdentifierResolved) - if (!parentId) { - throw new NotFoundError("Parent issue", parentIdentifierResolved) - } - - // Fetch parent issue data including project - parentData = await fetchParentIssueData(parentId) - } + const { parentId, parentData } = await resolveParentIssueForCreate( + parentIdentifier, + ) + const explicitProjectId = project == null + ? undefined + : await resolveProjectIdForCreate(project, interactive) const interactiveData = await promptInteractiveIssueCreation( + explicitProjectId, parentId, parentData, ) @@ -658,7 +786,7 @@ export const createCommand = new Command() "Title is required when not using interactive mode", { suggestion: - "Use --title or run without any flags (or only --parent) for interactive mode.", + "Use --title or run without any flags (or only --parent/--project) for interactive mode.", }, ) } @@ -708,6 +836,9 @@ export const createCommand = new Command() } let assigneeId = undefined + if (shouldAssignSelfByDefaultForFlagCreate()) { + assigneeId = await lookupUserId("self") + } if (assignee) { assigneeId = await lookupUserId(assignee) @@ -738,16 +869,7 @@ export const createCommand = new Command() } let projectId: string | undefined = undefined if (project !== undefined) { - projectId = await getProjectIdByName(project) - if (projectId === undefined && interactive) { - const projectIds = await getProjectOptionsByName(project) - spinner?.stop() - projectId = await selectOption("Project", project, projectIds) - spinner?.start() - } - if (projectId === undefined) { - throw new NotFoundError("Project", project) - } + projectId = await resolveProjectIdForCreate(project, interactive) } let projectMilestoneId: string | undefined @@ -774,30 +896,9 @@ export const createCommand = new Command() // Date validation done at graphql level - // Convert parent identifier if provided and fetch parent data - let parentId: string | undefined - let parentData: { - title: string - identifier: string - projectId: string | null - } | null = null - if (parentIdentifier) { - const parentIdentifierResolved = await getIssueIdentifier( - parentIdentifier, - ) - if (!parentIdentifierResolved) { - throw new ValidationError( - `Could not resolve parent issue identifier: ${parentIdentifier}`, - ) - } - parentId = await getIssueId(parentIdentifierResolved) - if (!parentId) { - throw new NotFoundError("Parent issue", parentIdentifierResolved) - } - - // Fetch parent issue data including project - parentData = await fetchParentIssueData(parentId) - } + const { parentId, parentData } = await resolveParentIssueForCreate( + parentIdentifier, + ) const input = { title, diff --git a/src/config.ts b/src/config.ts index ce51a422..3e7da784 100644 --- a/src/config.ts +++ b/src/config.ts @@ -137,6 +137,8 @@ const OptionsSchema = v.object({ api_key: v.optional(v.string()), workspace: v.optional(v.string()), issue_sort: v.optional(v.picklist(["manual", "priority"])), + issue_create_ask_project: v.optional(BooleanLike), + issue_create_assign_self: v.optional(v.picklist(["always", "auto", "never"])), vcs: v.optional(v.picklist(["git", "jj"])), download_images: v.optional(BooleanLike), hyperlink_format: v.optional(v.string()), diff --git a/src/utils/linear.ts b/src/utils/linear.ts index bfdd2a78..c3073ccb 100644 --- a/src/utils/linear.ts +++ b/src/utils/linear.ts @@ -6,6 +6,7 @@ import type { GetIssueDetailsWithCommentsQuery, GetIssuesForQueryQuery, GetIssuesForStateQuery, + GetProjectsForTeamQuery, GetTeamMembersQuery, IssueFilter, IssueSortInput, @@ -1274,6 +1275,53 @@ export async function getProjectOptionsByName( return Object.fromEntries(qResults.map((t) => [t.id, t.name])) } +export async function getProjectsForTeam( + teamKey: string, +): Promise<Array<{ id: string; name: string }>> { + const client = getGraphQLClient() + const query = gql(/* GraphQL */ ` + query GetProjectsForTeam( + $filter: ProjectFilter + $first: Int + $after: String + ) { + projects(filter: $filter, first: $first, after: $after) { + nodes { + id + name + } + pageInfo { + hasNextPage + endCursor + } + } + } + `) + + const projects: Array<{ id: string; name: string }> = [] + let hasNextPage = true + let after: string | null | undefined = undefined + + while (hasNextPage) { + const data: GetProjectsForTeamQuery = await client.request(query, { + filter: { + accessibleTeams: { some: { key: { eq: teamKey } } }, + }, + first: 100, + after, + }) + + const connection = data.projects + projects.push(...(connection?.nodes || [])) + hasNextPage = connection?.pageInfo?.hasNextPage || false + after = connection?.pageInfo?.endCursor + } + + return projects.sort((a, b) => + a.name.toLowerCase().localeCompare(b.name.toLowerCase()) + ) +} + export async function getTeamIdByKey( team: string, ): Promise<string | undefined> { diff --git a/test/commands/issue/issue-create.test.ts b/test/commands/issue/issue-create.test.ts index 17811fb6..fb1bba11 100644 --- a/test/commands/issue/issue-create.test.ts +++ b/test/commands/issue/issue-create.test.ts @@ -1,4 +1,7 @@ import { snapshotTest } from "@cliffy/testing" +import { assertEquals, assertStringIncludes } from "@std/assert" +import { Checkbox, Input, Select } from "@cliffy/prompt" +import { stub } from "@std/testing/mock" import { createCommand } from "../../../src/commands/issue/issue-create.ts" import { commonDenoArgs, @@ -423,3 +426,1098 @@ await snapshotTest({ } }, }) + +Deno.test("Issue Create Command - Explicit Project Still Uses Interactive Mode", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetUserSettings", + response: { + data: { + userSettings: { + autoAssignToSelf: false, + }, + }, + }, + }, + { + queryName: "GetProjectIdByName", + variables: { name: "Dashboard" }, + response: { + data: { + projects: { + nodes: [{ id: "project-123" }], + }, + }, + }, + }, + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + { + queryName: "GetWorkflowStates", + response: { + data: { + team: { + states: { + nodes: [], + }, + }, + }, + }, + }, + { + queryName: "GetLabelsForTeam", + response: { + data: { + team: { + labels: { + nodes: [], + }, + }, + }, + }, + }, + { + queryName: "CreateIssue", + variables: { + input: { + title: "Create dashboard issue", + labelIds: [], + teamId: "team-eng-id", + projectId: "project-123", + useDefaultTemplate: true, + }, + }, + response: { + data: { + issueCreate: { + success: true, + issue: { + id: "issue-interactive-project", + identifier: "ENG-901", + url: + "https://linear.app/test-team/issue/ENG-901/create-dashboard-issue", + team: { + key: "ENG", + }, + }, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + const terminalStub = stub( + Object.getPrototypeOf(Deno.stdout), + "isTerminal", + () => true, + ) + const inputStub = stub( + Input, + "prompt", + (options: string | { message: string }) => { + const message = typeof options === "string" ? options : options.message + if (message === "What's the title of your issue?") { + return Promise.resolve("Create dashboard issue") + } + if (message.startsWith("Description")) { + return Promise.resolve("") + } + throw new Error(`Unexpected Input.prompt call: ${message}`) + }, + ) + let selectCallCount = 0 + const selectStub = stub(Select, "prompt", (options: { message: string }) => { + selectCallCount += 1 + if (options.message === "What's next?") { + return Promise.resolve("submit") + } + if ( + options.message === + "Start working on this issue now? (creates branch and updates status)" + ) { + return Promise.resolve(false) + } + throw new Error(`Unexpected Select.prompt call: ${options.message}`) + }) + + try { + await createCommand.parse(["--project", "Dashboard"]) + assertEquals(selectCallCount, 2) + } finally { + selectStub.restore() + inputStub.restore() + terminalStub.restore() + await cleanup() + } +}) + +Deno.test("Issue Create Command - Interactive Project Prompt Uses Team Projects", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetUserSettings", + response: { + data: { + userSettings: { + autoAssignToSelf: false, + }, + }, + }, + }, + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + { + queryName: "GetProjectsForTeam", + response: { + data: { + projects: { + nodes: [{ id: "project-456", name: "Dashboard" }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + { + queryName: "GetWorkflowStates", + response: { + data: { + team: { + states: { + nodes: [], + }, + }, + }, + }, + }, + { + queryName: "GetLabelsForTeam", + response: { + data: { + team: { + labels: { + nodes: [], + }, + }, + }, + }, + }, + { + queryName: "CreateIssue", + variables: { + input: { + title: "Issue with prompted project", + labelIds: [], + teamId: "team-eng-id", + projectId: "project-456", + useDefaultTemplate: true, + }, + }, + response: { + data: { + issueCreate: { + success: true, + issue: { + id: "issue-project-prompt", + identifier: "ENG-902", + url: + "https://linear.app/test-team/issue/ENG-902/issue-with-prompted-project", + team: { + key: "ENG", + }, + }, + }, + }, + }, + }, + ], { + LINEAR_TEAM_ID: "ENG", + LINEAR_ISSUE_CREATE_ASK_PROJECT: "true", + }) + + const terminalStub = stub( + Object.getPrototypeOf(Deno.stdout), + "isTerminal", + () => true, + ) + const inputStub = stub( + Input, + "prompt", + (options: string | { message: string }) => { + const message = typeof options === "string" ? options : options.message + if (message === "What's the title of your issue?") { + return Promise.resolve("Issue with prompted project") + } + if (message.startsWith("Description")) { + return Promise.resolve("") + } + throw new Error(`Unexpected Input.prompt call: ${message}`) + }, + ) + const selectStub = stub(Select, "prompt", (options: { message: string }) => { + if (options.message === "Which project should this issue belong to?") { + return Promise.resolve("project-456") + } + if (options.message === "What's next?") { + return Promise.resolve("submit") + } + if ( + options.message === + "Start working on this issue now? (creates branch and updates status)" + ) { + return Promise.resolve(false) + } + throw new Error(`Unexpected Select.prompt call: ${options.message}`) + }) + + try { + await createCommand.parse([]) + } finally { + selectStub.restore() + inputStub.restore() + terminalStub.restore() + await cleanup() + } +}) + +Deno.test("Issue Create Command - Additional Fields Can Set Project", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetUserSettings", + response: { + data: { + userSettings: { + autoAssignToSelf: false, + }, + }, + }, + }, + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + { + queryName: "GetWorkflowStates", + response: { + data: { + team: { + states: { + nodes: [], + }, + }, + }, + }, + }, + { + queryName: "GetLabelsForTeam", + response: { + data: { + team: { + labels: { + nodes: [], + }, + }, + }, + }, + }, + { + queryName: "GetProjectsForTeam", + response: { + data: { + projects: { + nodes: [{ id: "project-789", name: "Dashboard" }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + { + queryName: "CreateIssue", + variables: { + input: { + title: "Issue from more fields", + labelIds: [], + teamId: "team-eng-id", + projectId: "project-789", + useDefaultTemplate: true, + }, + }, + response: { + data: { + issueCreate: { + success: true, + issue: { + id: "issue-more-fields", + identifier: "ENG-903", + url: + "https://linear.app/test-team/issue/ENG-903/issue-from-more-fields", + team: { + key: "ENG", + }, + }, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + const terminalStub = stub( + Object.getPrototypeOf(Deno.stdout), + "isTerminal", + () => true, + ) + const inputStub = stub( + Input, + "prompt", + (options: string | { message: string }) => { + const message = typeof options === "string" ? options : options.message + if (message === "What's the title of your issue?") { + return Promise.resolve("Issue from more fields") + } + if (message.startsWith("Description")) { + return Promise.resolve("") + } + throw new Error(`Unexpected Input.prompt call: ${message}`) + }, + ) + const checkboxStub = stub( + Checkbox, + "prompt", + (options: { message: string }) => { + if (options.message === "Select additional fields to configure") { + return Promise.resolve(["project"]) + } + throw new Error(`Unexpected Checkbox.prompt call: ${options.message}`) + }, + ) + const selectStub = stub(Select, "prompt", (options: { message: string }) => { + if (options.message === "What's next?") { + return Promise.resolve("more_fields") + } + if (options.message === "Which project should this issue belong to?") { + return Promise.resolve("project-789") + } + if ( + options.message === + "Start working on this issue now? (creates branch and updates status)" + ) { + return Promise.resolve(false) + } + throw new Error(`Unexpected Select.prompt call: ${options.message}`) + }) + + try { + await createCommand.parse([]) + } finally { + selectStub.restore() + checkboxStub.restore() + inputStub.restore() + terminalStub.restore() + await cleanup() + } +}) + +Deno.test("Issue Create Command - Inherits Parent Project When Project Not Set", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + { + queryName: "GetIssueId", + variables: { id: "ENG-123" }, + response: { + data: { + issue: { + id: "parent-1", + }, + }, + }, + }, + { + queryName: "GetParentIssueData", + variables: { id: "parent-1" }, + response: { + data: { + issue: { + title: "Parent issue", + identifier: "ENG-123", + project: { + id: "project-parent", + }, + }, + }, + }, + }, + { + queryName: "CreateIssue", + variables: { + input: { + title: "Child issue", + parentId: "parent-1", + labelIds: [], + teamId: "team-eng-id", + projectId: "project-parent", + useDefaultTemplate: true, + }, + }, + response: { + data: { + issueCreate: { + success: true, + issue: { + id: "child-issue", + identifier: "ENG-904", + url: "https://linear.app/test-team/issue/ENG-904/child-issue", + team: { + key: "ENG", + }, + }, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + try { + await createCommand.parse([ + "--title", + "Child issue", + "--team", + "ENG", + "--parent", + "ENG-123", + "--no-interactive", + ]) + } finally { + await cleanup() + } +}) + +Deno.test("Issue Create Command - Explicit Project Overrides Parent Project", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + { + queryName: "GetProjectIdByName", + variables: { name: "Dashboard" }, + response: { + data: { + projects: { + nodes: [{ id: "project-dashboard" }], + }, + }, + }, + }, + { + queryName: "GetIssueId", + variables: { id: "ENG-123" }, + response: { + data: { + issue: { + id: "parent-1", + }, + }, + }, + }, + { + queryName: "GetParentIssueData", + variables: { id: "parent-1" }, + response: { + data: { + issue: { + title: "Parent issue", + identifier: "ENG-123", + project: { + id: "project-parent", + }, + }, + }, + }, + }, + { + queryName: "CreateIssue", + variables: { + input: { + title: "Child issue override", + parentId: "parent-1", + labelIds: [], + teamId: "team-eng-id", + projectId: "project-dashboard", + useDefaultTemplate: true, + }, + }, + response: { + data: { + issueCreate: { + success: true, + issue: { + id: "child-issue-override", + identifier: "ENG-905", + url: + "https://linear.app/test-team/issue/ENG-905/child-issue-override", + team: { + key: "ENG", + }, + }, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + try { + await createCommand.parse([ + "--title", + "Child issue override", + "--team", + "ENG", + "--parent", + "ENG-123", + "--project", + "Dashboard", + "--no-interactive", + ]) + } finally { + await cleanup() + } +}) + +Deno.test("Issue Create Command - Invalid Parent Project Combination Surfaces Backend Error", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + { + queryName: "GetProjectIdByName", + variables: { name: "Dashboard" }, + response: { + data: { + projects: { + nodes: [{ id: "project-dashboard" }], + }, + }, + }, + }, + { + queryName: "GetIssueId", + variables: { id: "ENG-123" }, + response: { + data: { + issue: { + id: "parent-1", + }, + }, + }, + }, + { + queryName: "GetParentIssueData", + variables: { id: "parent-1" }, + response: { + data: { + issue: { + title: "Parent issue", + identifier: "ENG-123", + project: { + id: "project-parent", + }, + }, + }, + }, + }, + { + queryName: "CreateIssue", + response: { + errors: [{ + message: "Parent issue and project are incompatible", + }], + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + const errors: string[] = [] + const errorStub = stub(console, "error", (...args: unknown[]) => { + errors.push(args.map(String).join(" ")) + }) + const exitStub = stub(Deno, "exit", (_code?: number) => { + throw new Error("DENO_EXIT") + }) + + try { + let thrown: Error | undefined + try { + await createCommand.parse([ + "--title", + "Child issue override", + "--team", + "ENG", + "--parent", + "ENG-123", + "--project", + "Dashboard", + "--no-interactive", + ]) + } catch (error) { + thrown = error as Error + } + + assertEquals(thrown?.message, "DENO_EXIT") + assertStringIncludes( + errors.join("\n"), + "Parent issue and project are incompatible", + ) + } finally { + exitStub.restore() + errorStub.restore() + await cleanup() + } +}) + +Deno.test("Issue Create Command - Config Can Assign Self By Default", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + { + queryName: "GetViewerId", + response: { + data: { + viewer: { + id: "user-self-123", + }, + }, + }, + }, + { + queryName: "CreateIssue", + variables: { + input: { + title: "Assigned to self", + assigneeId: "user-self-123", + labelIds: [], + teamId: "team-eng-id", + useDefaultTemplate: true, + }, + }, + response: { + data: { + issueCreate: { + success: true, + issue: { + id: "issue-self-default", + identifier: "ENG-906", + url: + "https://linear.app/test-team/issue/ENG-906/assigned-to-self", + team: { + key: "ENG", + }, + }, + }, + }, + }, + }, + ], { + LINEAR_TEAM_ID: "ENG", + LINEAR_ISSUE_CREATE_ASSIGN_SELF: "always", + }) + + try { + await createCommand.parse([ + "--title", + "Assigned to self", + "--team", + "ENG", + "--no-interactive", + ]) + } finally { + await cleanup() + } +}) + +Deno.test("Issue Create Command - Auto Assign Mode Respects Linear User Setting In Interactive Create", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetUserSettings", + response: { + data: { + userSettings: { + autoAssignToSelf: true, + }, + }, + }, + }, + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + { + queryName: "GetWorkflowStates", + response: { + data: { + team: { + states: { + nodes: [], + }, + }, + }, + }, + }, + { + queryName: "GetLabelsForTeam", + response: { + data: { + team: { + labels: { + nodes: [], + }, + }, + }, + }, + }, + { + queryName: "GetViewerId", + response: { + data: { + viewer: { + id: "user-self-123", + }, + }, + }, + }, + { + queryName: "CreateIssue", + response: { + data: { + issueCreate: { + success: true, + issue: { + id: "issue-auto-assign", + identifier: "ENG-906A", + url: "https://linear.app/test-team/issue/ENG-906A/auto-assign", + team: { + key: "ENG", + }, + }, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + const terminalStub = stub( + Object.getPrototypeOf(Deno.stdout), + "isTerminal", + () => true, + ) + const inputStub = stub( + Input, + "prompt", + (options: string | { message: string }) => { + const message = typeof options === "string" ? options : options.message + if (message === "What's the title of your issue?") { + return Promise.resolve("Auto assign from Linear settings") + } + if (message.startsWith("Description")) { + return Promise.resolve("") + } + throw new Error(`Unexpected Input.prompt call: ${message}`) + }, + ) + const selectStub = stub(Select, "prompt", (options: { message: string }) => { + if (options.message === "What's next?") { + return Promise.resolve("submit") + } + if ( + options.message === + "Start working on this issue now? (creates branch and updates status)" + ) { + return Promise.resolve(false) + } + throw new Error(`Unexpected Select.prompt call: ${options.message}`) + }) + + try { + await createCommand.parse([]) + } finally { + selectStub.restore() + inputStub.restore() + terminalStub.restore() + await cleanup() + } +}) + +Deno.test("Issue Create Command - Explicit Assignee Overrides Config Self Assignment", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + { + queryName: "GetViewerId", + response: { + data: { + viewer: { + id: "user-self-123", + }, + }, + }, + }, + { + queryName: "LookupUser", + variables: { input: "Jane Developer" }, + response: { + data: { + users: { + nodes: [{ + id: "user-jane-456", + displayName: "Jane Developer", + email: "jane@example.com", + name: "Jane Developer", + }], + }, + }, + }, + }, + { + queryName: "CreateIssue", + variables: { + input: { + title: "Assigned explicitly", + assigneeId: "user-jane-456", + labelIds: [], + teamId: "team-eng-id", + useDefaultTemplate: true, + }, + }, + response: { + data: { + issueCreate: { + success: true, + issue: { + id: "issue-explicit-assignee", + identifier: "ENG-907", + url: + "https://linear.app/test-team/issue/ENG-907/assigned-explicitly", + team: { + key: "ENG", + }, + }, + }, + }, + }, + }, + ], { + LINEAR_TEAM_ID: "ENG", + LINEAR_ISSUE_CREATE_ASSIGN_SELF: "always", + }) + + try { + await createCommand.parse([ + "--title", + "Assigned explicitly", + "--team", + "ENG", + "--assignee", + "Jane Developer", + "--no-interactive", + ]) + } finally { + await cleanup() + } +}) + +Deno.test("Issue Create Command - Interactive Assignee Can Override Config Self Assignment", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + { + queryName: "GetWorkflowStates", + response: { + data: { + team: { + states: { + nodes: [], + }, + }, + }, + }, + }, + { + queryName: "GetLabelsForTeam", + response: { + data: { + team: { + labels: { + nodes: [], + }, + }, + }, + }, + }, + { + queryName: "GetViewerId", + response: { + data: { + viewer: { + id: "user-self-123", + }, + }, + }, + }, + { + queryName: "CreateIssue", + response: { + data: { + issueCreate: { + success: true, + issue: { + id: "issue-interactive-assignee-override", + identifier: "ENG-908", + url: + "https://linear.app/test-team/issue/ENG-908/interactive-assignee-override", + team: { + key: "ENG", + }, + }, + }, + }, + }, + }, + ], { + LINEAR_TEAM_ID: "ENG", + LINEAR_ISSUE_CREATE_ASSIGN_SELF: "always", + }) + + const terminalStub = stub( + Object.getPrototypeOf(Deno.stdout), + "isTerminal", + () => true, + ) + const inputStub = stub( + Input, + "prompt", + (options: string | { message: string }) => { + const message = typeof options === "string" ? options : options.message + if (message === "What's the title of your issue?") { + return Promise.resolve("Interactive assignee override") + } + if (message.startsWith("Description")) { + return Promise.resolve("") + } + throw new Error(`Unexpected Input.prompt call: ${message}`) + }, + ) + const checkboxStub = stub( + Checkbox, + "prompt", + (options: { message: string }) => { + if (options.message === "Select additional fields to configure") { + return Promise.resolve(["assignee"]) + } + throw new Error(`Unexpected Checkbox.prompt call: ${options.message}`) + }, + ) + const selectStub = stub(Select, "prompt", (options: { message: string }) => { + if (options.message === "What's next?") { + return Promise.resolve("more_fields") + } + if (options.message === "Assign this issue to yourself?") { + return Promise.resolve(false) + } + if ( + options.message === + "Start working on this issue now? (creates branch and updates status)" + ) { + return Promise.resolve(false) + } + throw new Error(`Unexpected Select.prompt call: ${options.message}`) + }) + + try { + await createCommand.parse([]) + } finally { + selectStub.restore() + checkboxStub.restore() + inputStub.restore() + terminalStub.restore() + await cleanup() + } +}) diff --git a/test/config.test.ts b/test/config.test.ts index dd031d74..6831ca3a 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -54,6 +54,38 @@ Deno.test("getOption - download_images returns undefined for unrecognized string assertEquals(result, undefined) }) +Deno.test("getOption - issue_create_ask_project returns boolean for truthy strings", () => { + const truthyValues = ["true", "yes", "1", "on", "t"] + + for (const value of truthyValues) { + const result = getOption("issue_create_ask_project", value) + assertEquals(result, true, `Expected "${value}" to coerce to true`) + } +}) + +Deno.test("getOption - issue_create_ask_project returns boolean for falsy strings", () => { + const falsyValues = ["false", "no", "0", "off", "f"] + + for (const value of falsyValues) { + const result = getOption("issue_create_ask_project", value) + assertEquals(result, false, `Expected "${value}" to coerce to false`) + } +}) + +Deno.test("getOption - issue_create_assign_self accepts valid mode values", () => { + const validValues = ["always", "auto", "never"] as const + + for (const value of validValues) { + const result = getOption("issue_create_assign_self", value) + assertEquals(result, value) + } +}) + +Deno.test("getOption - issue_create_assign_self rejects invalid mode values", () => { + const result = getOption("issue_create_assign_self", "true") + assertEquals(result, undefined) +}) + Deno.test("getOption - environment variables take precedence over config file", async () => { // Create a temp directory with a config file const tempDir = await Deno.makeTempDir() From 96f0e04de256cc2068488e3ab21a93480c133656 Mon Sep 17 00:00:00 2001 From: Rengar Lee <Rengarlee@163.com> Date: Sun, 12 Jul 2026 05:24:53 +0800 Subject: [PATCH 11/12] feat: add labels to issue view --json output (#170) Add `labels` field to the JSON output of `linear issue view --json`. Each label includes `id`, `name`, and `color`. ## Example ```json { "identifier": "P-51", "labels": [ { "id": "031de1e8-...", "name": "Work", "color": "#4cb782" } ] } --------- Co-authored-by: Hyunsu Lim <hyunsu.lim01@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Luc Leray <luc.leray@gmail.com> Co-authored-by: c-99-e <268417377+c-99-e@users.noreply.github.com> Co-authored-by: Al Johri <al.johri@gmail.com> Co-authored-by: Mihai Chiorean <mihai.v.chiorean@gmail.com> Co-authored-by: Peter Schilling <code@schpet.com> Co-authored-by: Mihai Chiorean <mihai-chiorean@users.noreply.github.com> Co-authored-by: Jeffrey Holm <jeff.holm@scale.com> Co-authored-by: Paymahn Moghadasian <paymahn1@gmail.com> Co-authored-by: Evan Jacobson <evanjacobson3@gmail.com> Co-authored-by: schpetbot <bot@schpet.com> Co-authored-by: Alex Broekhof <alex@quilt.com> Co-authored-by: Theo Gregory <theo@gregory.sh> Co-authored-by: Bryan <bryandesigning@gmail.com> Co-authored-by: Ryan Schumacher <j.r.schumacher@gmail.com> Co-authored-by: Joseph <31323641+josephyooo@users.noreply.github.com> Co-authored-by: Magnus Buvarp <magnus.buvarp@gmail.com> --- src/utils/linear.ts | 14 ++++ .../__snapshots__/issue-view.test.ts.snap | 39 ++++++++++ test/commands/issue/issue-view.test.ts | 73 +++++++++++++++++++ 3 files changed, 126 insertions(+) diff --git a/src/utils/linear.ts b/src/utils/linear.ts index c3073ccb..1538f815 100644 --- a/src/utils/linear.ts +++ b/src/utils/linear.ts @@ -234,6 +234,13 @@ const issueDetailsWithCommentsQuery = gql(/* GraphQL */ ` name number } + labels(first: 50) { + nodes { + id + name + color + } + } parent { identifier title @@ -329,6 +336,13 @@ const issueDetailsQuery = gql(/* GraphQL */ ` name number } + labels(first: 50) { + nodes { + id + name + color + } + } parent { identifier title diff --git a/test/commands/issue/__snapshots__/issue-view.test.ts.snap b/test/commands/issue/__snapshots__/issue-view.test.ts.snap index fd1cf77a..726d5fb4 100644 --- a/test/commands/issue/__snapshots__/issue-view.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-view.test.ts.snap @@ -152,6 +152,45 @@ stderr: "" `; +snapshot[`Issue View Command - JSON Output With Labels 1`] = ` +stdout: +'{ + "identifier": "TEST-123", + "title": "Fix authentication bug in login flow", + "description": "Users are experiencing issues logging in when their session expires.", + "url": "https://linear.app/test-team/issue/TEST-123/fix-authentication-bug-in-login-flow", + "branchName": "fix/test-123-auth-bug", + "state": { + "name": "In Progress", + "color": "#f87462" + }, + "parent": null, + "children": { + "nodes": [] + }, + "attachments": { + "nodes": [] + }, + "labels": { + "nodes": [ + { + "id": "label-1", + "name": "bug", + "color": "#e5484d" + }, + { + "id": "label-2", + "name": "priority:high", + "color": "#f5a524" + } + ] + } +} +' +stderr: +"" +`; + snapshot[`Issue View Command - JSON Output With Comments 1`] = ` stdout: \`{ diff --git a/test/commands/issue/issue-view.test.ts b/test/commands/issue/issue-view.test.ts index 9225a3d8..9766f708 100644 --- a/test/commands/issue/issue-view.test.ts +++ b/test/commands/issue/issue-view.test.ts @@ -66,6 +66,9 @@ await snapshotTest({ attachments: { nodes: [], }, + labels: { + nodes: [], + }, }, }, }, @@ -126,6 +129,9 @@ await snapshotTest({ attachments: { nodes: [], }, + labels: { + nodes: [], + }, }, }, }, @@ -447,6 +453,73 @@ await snapshotTest({ }, }) +// Test JSON output with labels +await snapshotTest({ + name: "Issue View Command - JSON Output With Labels", + meta: import.meta, + colors: false, + args: ["TEST-123", "--json", "--no-comments"], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssueDetails", + variables: { id: "TEST-123" }, + response: { + data: { + issue: { + identifier: "TEST-123", + title: "Fix authentication bug in login flow", + description: + "Users are experiencing issues logging in when their session expires.", + url: + "https://linear.app/test-team/issue/TEST-123/fix-authentication-bug-in-login-flow", + branchName: "fix/test-123-auth-bug", + state: { + name: "In Progress", + color: "#f87462", + }, + parent: null, + children: { + nodes: [], + }, + attachments: { + nodes: [], + }, + labels: { + nodes: [ + { + id: "label-1", + name: "bug", + color: "#e5484d", + }, + { + id: "label-2", + name: "priority:high", + color: "#f5a524", + }, + ], + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await viewCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + // Test JSON output with comments await snapshotTest({ name: "Issue View Command - JSON Output With Comments", From 95097b8849193fd639fa69f0d2ab8d09fc3124ce Mon Sep 17 00:00:00 2001 From: Peter Schilling <code@schpet.com> Date: Sat, 11 Jul 2026 14:21:54 -0700 Subject: [PATCH 12/12] Follow-up to #170: cover labels on the with-comments query path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #170 adds `labels` to `issue view --json` by selecting them in both GetIssueDetails and GetIssueDetailsWithComments. Its dedicated labels-with-content test only exercises the --no-comments (GetIssueDetails) path; the with-comments path was covered only with empty labels. Add a --json (with comments) test carrying real labels so both fetchIssueDetailsRaw code paths are verified to surface labels — the consistency both were meant to guarantee. --- .../__snapshots__/issue-view.test.ts.snap | 61 +++++++++++++ test/commands/issue/issue-view.test.ts | 91 +++++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/test/commands/issue/__snapshots__/issue-view.test.ts.snap b/test/commands/issue/__snapshots__/issue-view.test.ts.snap index 726d5fb4..99455e78 100644 --- a/test/commands/issue/__snapshots__/issue-view.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-view.test.ts.snap @@ -484,3 +484,64 @@ stdout: stderr: "" `; + +snapshot[`Issue View Command - JSON Output With Labels And Comments 1`] = ` +stdout: +'{ + "identifier": "TEST-123", + "title": "Fix authentication bug in login flow", + "description": "Users are experiencing issues logging in when their session expires.", + "url": "https://linear.app/test-team/issue/TEST-123/fix-authentication-bug-in-login-flow", + "branchName": "fix/test-123-auth-bug", + "state": { + "name": "In Progress", + "color": "#f87462" + }, + "assignee": { + "name": "jane.smith", + "displayName": "Jane Smith" + }, + "priority": 2, + "project": null, + "projectMilestone": null, + "parent": null, + "children": { + "nodes": [] + }, + "labels": { + "nodes": [ + { + "id": "label-1", + "name": "bug", + "color": "#e5484d" + }, + { + "id": "label-2", + "name": "priority:high", + "color": "#f5a524" + } + ] + }, + "comments": { + "nodes": [ + { + "id": "comment-1", + "body": "Reproduced on staging.", + "createdAt": "2024-01-15T10:30:00Z", + "user": { + "name": "john.doe", + "displayName": "John Doe" + }, + "externalUser": null, + "parent": null + } + ] + }, + "attachments": { + "nodes": [] + } +} +' +stderr: +"" +`; diff --git a/test/commands/issue/issue-view.test.ts b/test/commands/issue/issue-view.test.ts index 9766f708..ea9fccf3 100644 --- a/test/commands/issue/issue-view.test.ts +++ b/test/commands/issue/issue-view.test.ts @@ -1258,3 +1258,94 @@ await snapshotTest({ } }, }) + +// Labels must also surface via the with-comments query path (GetIssueDetailsWithComments), +// not only the --no-comments path. Complements the existing "JSON Output With Labels" +// (which exercises GetIssueDetails) so both fetchIssueDetailsRaw code paths are covered. +await snapshotTest({ + name: "Issue View Command - JSON Output With Labels And Comments", + meta: import.meta, + colors: false, + args: ["TEST-123", "--json"], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssueDetailsWithComments", + variables: { id: "TEST-123" }, + response: { + data: { + issue: { + identifier: "TEST-123", + title: "Fix authentication bug in login flow", + description: + "Users are experiencing issues logging in when their session expires.", + url: + "https://linear.app/test-team/issue/TEST-123/fix-authentication-bug-in-login-flow", + branchName: "fix/test-123-auth-bug", + state: { + name: "In Progress", + color: "#f87462", + }, + assignee: { + name: "jane.smith", + displayName: "Jane Smith", + }, + priority: 2, + project: null, + projectMilestone: null, + parent: null, + children: { + nodes: [], + }, + labels: { + nodes: [ + { + id: "label-1", + name: "bug", + color: "#e5484d", + }, + { + id: "label-2", + name: "priority:high", + color: "#f5a524", + }, + ], + }, + comments: { + nodes: [ + { + id: "comment-1", + body: "Reproduced on staging.", + createdAt: "2024-01-15T10:30:00Z", + user: { + name: "john.doe", + displayName: "John Doe", + }, + externalUser: null, + parent: null, + }, + ], + }, + attachments: { + nodes: [], + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await viewCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +})