diff --git a/README.md b/README.md index 043ee6d2..d5faa859 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,25 @@ linear issue comment update # 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 @@ -167,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 @@ -200,7 +221,7 @@ linear document list --json # output as JSON linear document view # view document rendered in terminal linear document view --raw # output raw markdown (for piping) linear document view --web # open in browser -linear document view --json # output as JSON +linear document view --json # output as JSON, including document comments # create a document linear document create --title "My Doc" --content "# Hello" # inline content @@ -213,6 +234,7 @@ cat spec.md | linear document create --title "Spec" # from std linear document update --title "New Title" # update title linear document update --content-file ./updated.md # update content linear document update --edit # open in $EDITOR +linear document update --content-file ./updated.md --force # bypass comment-anchor guard # delete a document linear document delete # soft delete (move to trash) @@ -220,6 +242,8 @@ linear document delete --permanent # permanent delete linear document delete --bulk # 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 @@ -233,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/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/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 - 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 - 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 - Target workspace (uses credentials) - -t, --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/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/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 <title:string>", "Document title (required)") .option("-c, --content <content:string>", "Markdown content (inline)") .option("-f, --content-file <path:string>", "Read content from file") - .option("--project <project:string>", "Attach to project (slug or ID)") + .option( + "--project <project:string>", + "Attach to project (UUID, slug ID, or name)", + ) .option("--issue <issue:string>", "Attach to issue (identifier like TC-123)") .option("--icon <icon:string>", "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<string | undefined> { - // 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/document/document-update.ts b/src/commands/document/document-update.ts index 4dcceabd..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" @@ -10,6 +11,76 @@ 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 + resolvedAt + archivedAt + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +`) + +// 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) { + // 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) { + throw new NotFoundError("Document", documentId) + } + + const inlineComment = documentData.document.comments.nodes.find( + (comment) => + comment.quotedText != null && comment.resolvedAt == null && + comment.archivedAt == null, + ) + 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 +179,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 +227,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 +274,24 @@ export const updateCommand = new Command() }) } + if (input.content !== undefined && !force) { + const comment = await getFirstActiveInlineComment( + 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/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..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" @@ -26,8 +28,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 @@ -64,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 @@ -71,15 +83,19 @@ 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 for (const filepath of attachments) { const result = await uploadFile(filepath, { showProgress: shouldShowSpinner(), + makePublic, }) uploadedFiles.push({ filename: result.filename, @@ -87,6 +103,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 +132,6 @@ export const commentAddCommand = new Command() contentType: file.isImage ? "image/png" : "application/octet-stream", - size: 0, }) }) diff --git a/src/commands/issue/issue-create.ts b/src/commands/issue/issue-create.ts index d9f37b29..ec0bcec0 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" @@ -13,14 +14,16 @@ import { getIssueLabelIdByNameForTeam, getIssueLabelOptionsByNameForTeam, getLabelsForTeam, - getMilestoneIdByName, getProjectIdByName, getProjectOptionsByName, + getProjectsForTeam, getTeamIdByKey, getTeamKey, getWorkflowStateByNameOrType, getWorkflowStates, + isLinearUuid, lookupUserId, + resolveMilestoneId, searchTeamsByKeySubstring, selectOption, type WorkflowState, @@ -34,6 +37,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 +50,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 +181,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 +236,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 +264,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 +282,7 @@ async function promptAdditionalFields( teamId: string, states: WorkflowState[], labels: IssueLabel[], + includeProject: boolean, autoAssignToSelf: boolean, ): Promise<{ assigneeId?: string @@ -158,6 +290,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 +299,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 +321,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 +332,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 +357,9 @@ async function promptAdditionalFields( case "estimate": estimate = value as number | undefined break + case "project": + projectId = value as string | undefined + break } } } @@ -227,10 +370,12 @@ async function promptAdditionalFields( estimate, labelIds, stateId, + projectId, } } async function promptInteractiveIssueCreation( + initialProjectId?: string, parentId?: string, parentData?: { title: string @@ -250,20 +395,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 +428,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 +466,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 +503,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 +534,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 +550,7 @@ async function promptInteractiveIssueCreation( teamId, states, labels, + !askProject && !parentData && !initialProjectId, autoAssignToSelf, ) @@ -416,6 +560,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 +585,7 @@ async function promptInteractiveIssueCreation( stateId, start, parentId, - projectId: parentData?.projectId || null, + projectId: projectId ?? parentData?.projectId ?? null, } } @@ -490,7 +635,7 @@ export const createCommand = new Command() ) .option( "--project <project:string>", - "Name or slug ID of the project with the issue", + "Project for the issue (UUID, slug ID, or name)", ) .option( "-s, --state <state:string>", @@ -498,7 +643,7 @@ export const createCommand = new Command() ) .option( "--milestone <milestone:string>", - "Name of the project milestone", + "Project milestone (UUID, or name when --project is set)", ) .option( "--cycle <cycle:string>", @@ -558,40 +703,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 +787,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 +837,9 @@ export const createCommand = new Command() } let assigneeId = undefined + if (shouldAssignSelfByDefaultForFlagCreate()) { + assigneeId = await lookupUserId("self") + } if (assignee) { assigneeId = await lookupUserId(assignee) @@ -738,33 +870,28 @@ 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 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 @@ -774,30 +901,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/commands/issue/issue-mine.ts b/src/commands/issue/issue-mine.ts index e8ee021f..06a5dfeb 100644 --- a/src/commands/issue/issue-mine.ts +++ b/src/commands/issue/issue-mine.ts @@ -11,12 +11,13 @@ import { import { fetchIssuesForState, getCycleIdByNameOrNumber, - getMilestoneIdByName, getProjectIdByName, getProjectOptionsByName, getTeamIdByKey, getTeamKey, isIssueBlocked, + isLinearUuid, + resolveMilestoneId, selectOption, } from "../../utils/linear.ts" import { openTeamAssigneeView } from "../../utils/actions.ts" @@ -69,7 +70,7 @@ export const mineCommand = new Command() ) .option( "--project <project:string>", - "Filter by project name", + "Filter by project (UUID, slug ID, or name)", ) .option( "--project-label <projectLabel:string>", @@ -81,7 +82,7 @@ export const mineCommand = new Command() ) .option( "--milestone <milestone:string>", - "Filter by project milestone name (requires --project)", + "Filter by project milestone (UUID, or name when --project is set)", ) .option( "-l, --label <label:string>", @@ -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..108164a9 100644 --- a/src/commands/issue/issue-query.ts +++ b/src/commands/issue/issue-query.ts @@ -11,12 +11,13 @@ import { import { fetchIssuesForQuery, getCycleIdByNameOrNumber, - getMilestoneIdByName, getProjectIdByName, getProjectOptionsByName, getTeamIdByKey, getTeamKey, isIssueBlocked, + isLinearUuid, + resolveMilestoneId, searchIssuesByTerm, selectOption, } from "../../utils/linear.ts" @@ -77,7 +78,7 @@ export const queryCommand = new Command() ) .option( "--project <project:string>", - "Filter by project name", + "Filter by project (UUID, slug ID, or name)", ) .option( "--project-label <projectLabel:string>", @@ -89,7 +90,7 @@ export const queryCommand = new Command() ) .option( "--milestone <milestone:string>", - "Filter by project milestone name (requires --project)", + "Filter by project milestone (UUID, or name when --project is set)", ) .option( "-l, --label <label:string>", @@ -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..86a60199 100644 --- a/src/commands/issue/issue-update.ts +++ b/src/commands/issue/issue-update.ts @@ -8,11 +8,12 @@ import { getIssueIdentifier, getIssueLabelIdByNameForTeam, getIssueProjectId, - getMilestoneIdByName, getProjectIdByName, getTeamIdByKey, getWorkflowStateByNameOrType, + isLinearUuid, lookupUserId, + resolveMilestoneId, } from "../../utils/linear.ts" import { CliError, @@ -64,7 +65,7 @@ export const updateCommand = new Command() ) .option( "--project <project:string>", - "Name or slug ID of the project with the issue", + "Project to assign the issue to (UUID, slug ID, or name)", ) .option( "-s, --state <state:string>", @@ -72,7 +73,7 @@ export const updateCommand = new Command() ) .option( "--milestone <milestone:string>", - "Name of the project milestone", + "Project milestone (UUID, or name when --project is set or the issue already has a project)", ) .option( "--cycle <cycle:string>", @@ -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 <projectId:string>", "Project ID", { required: true }) + .option( + "--project <project:string>", + "Project (UUID, slug ID, or name)", + { required: true }, + ) .option("--name <name:string>", "Milestone name", { required: true }) .option("--description <description:string>", "Milestone description") .option("--target-date <date:string>", "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 <projectId:string>", "Project ID", { required: true }) + .option( + "--project <project:string>", + "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 <value:number>", "Sort order relative to other milestones", ) - .option("--project <projectId:string>", "Move to a different project") + .option( + "--project <project:string>", + "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..47cc1dde 100644 --- a/src/commands/milestone/milestone-view.ts +++ b/src/commands/milestone/milestone-view.ts @@ -4,10 +4,14 @@ 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" +import { resolveMilestoneId, resolveProjectId } from "../../utils/linear.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 +26,7 @@ const GetMilestoneDetails = gql(` slugId url } - issues { + issues(first: $first, after: $after) { nodes { id identifier @@ -32,6 +36,10 @@ const GetMilestoneDetails = gql(` type } } + pageInfo { + hasNextPage + endCursor + } } } } @@ -39,35 +47,91 @@ 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) => { + .arguments("<milestone:string>") + .option( + "--all", + "Fetch and list every issue attached to the milestone (paginates the Linear API).", + ) + .option( + "--project <project:string>", + "Project for resolving a milestone name (UUID, slug ID, or name)", + ) + .action(async ({ all, 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, { + const firstPage = await client.request(GetMilestoneDetails, { id: milestoneId, + first: PAGE_SIZE, }) - spinner?.stop() - const milestone = result.projectMilestone + const milestone = firstPage.projectMilestone if (!milestone) { - throw new NotFoundError("Milestone", milestoneId) + spinner?.stop() + throw new NotFoundError("Milestone", milestoneInput) + } + + const issues = [...milestone.issues.nodes] + let pageInfo = milestone.issues.pageInfo + + if (all) { + // 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) { + throw new NotFoundError("Milestone", milestoneInput) + } + issues.push(...next.issues.nodes) + pageInfo = next.issues.pageInfo + } } - // Build the display + 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 +139,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 +148,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 +155,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 +170,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 +178,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 +192,33 @@ 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/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/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..665b3f4b 100644 --- a/src/utils/linear.ts +++ b/src/utils/linear.ts @@ -6,6 +6,7 @@ import type { GetIssueDetailsWithCommentsQuery, GetIssuesForQueryQuery, GetIssuesForStateQuery, + GetProjectsForTeamQuery, GetTeamMembersQuery, IssueFilter, IssueSortInput, @@ -233,6 +234,13 @@ const issueDetailsWithCommentsQuery = gql(/* GraphQL */ ` name number } + labels(first: 50) { + nodes { + id + name + color + } + } parent { identifier title @@ -328,6 +336,13 @@ const issueDetailsQuery = gql(/* GraphQL */ ` name number } + labels(first: 50) { + nodes { + id + name + color + } + } parent { identifier title @@ -1187,11 +1202,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<string | undefined> { + 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 +1230,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 +1243,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<string> { - // 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 } @@ -1274,6 +1283,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> { @@ -1574,6 +1630,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<string> { + 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/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/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 <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/__snapshots__/document-update.test.ts.snap b/test/commands/document/__snapshots__/document-update.test.ts.snap index f22a5096..58b867ab 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 @@ -58,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/__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-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/document/document-update.test.ts b/test/commands/document/document-update.test.ts index 969f2074..5ffa4a97 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: { @@ -193,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") + } + }, +}) 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, + }, + }, }, }, }, 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/__snapshots__/issue-view.test.ts.snap b/test/commands/issue/__snapshots__/issue-view.test.ts.snap index fd1cf77a..99455e78 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: \`{ @@ -445,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-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) +}) 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/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/issue/issue-view.test.ts b/test/commands/issue/issue-view.test.ts index 9225a3d8..ea9fccf3 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", @@ -1185,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") + } + }, +}) 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..24a409d6 100644 --- a/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap +++ b/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap @@ -3,15 +3,17 @@ export const snapshot = {}; snapshot[`Milestone View Command - Help Text 1`] = ` stdout: " -Usage: view <milestoneId> +Usage: view <milestone> 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). + --project <project> - Project for resolving a milestone name (UUID, slug ID, or name) " stderr: @@ -69,7 +71,7 @@ stderr: "" `; -snapshot[`Milestone View Command - Many Issues 1`] = ` +snapshot[`Milestone View Command - Many Issues (default lists 10) 1`] = ` stdout: "# Big Release @@ -105,7 +107,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:** 1/1/2020 +**Updated:** 1/2/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:** 1/1/2020 +**Updated:** 1/2/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-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/commands/milestone/milestone-view.test.ts b/test/commands/milestone/milestone-view.test.ts index ead71000..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" @@ -48,30 +50,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 +116,7 @@ await snapshotTest({ }, issues: { nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, }, }, }, @@ -143,9 +138,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 +179,7 @@ await snapshotTest({ : "unstarted", }, })), + pageInfo: { hasNextPage: false, endCursor: null }, }, }, }, @@ -204,3 +200,229 @@ await snapshotTest({ } }, }) + +// 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-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: `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-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: 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-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: `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") + } + }, +}) + +// --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, + ) +}) 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..851f08b3 100644 --- a/test/commands/project/project-create.test.ts +++ b/test/commands/project/project-create.test.ts @@ -1,5 +1,11 @@ import { snapshotTest as cliffySnapshotTest } from "@cliffy/testing" -import { createCommand } from "../../../src/commands/project/project-create.ts" +import { assertEquals, assertRejects } from "@std/assert" +import { stub } from "@std/testing/mock" +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 +78,422 @@ 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", + ) +}) + +// 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, + ) +}) 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() 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() + } +}) 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, + ) +})