diff --git a/src/commands/document/document-create.ts b/src/commands/document/document-create.ts index 598f47c6..63af3b0e 100644 --- a/src/commands/document/document-create.ts +++ b/src/commands/document/document-create.ts @@ -2,6 +2,7 @@ import { Command } from "@cliffy/command" import { Input, Select } from "@cliffy/prompt" import { gql } from "../../__codegen__/gql.ts" import { getGraphQLClient } from "../../utils/graphql.ts" +import { resolveProjectId } from "../../utils/linear.ts" import { getEditor, openEditor } from "../../utils/editor.ts" import { readIdsFromStdin } from "../../utils/bulk.ts" import { @@ -43,7 +44,10 @@ export const createCommand = new Command() .option("-t, --title ", "Document title (required)") .option("-c, --content ", "Markdown content (inline)") .option("-f, --content-file ", "Read content from file") - .option("--project ", "Attach to project (slug or ID)") + .option( + "--project ", + "Attach to project (UUID, slug ID, or name)", + ) .option("--issue ", "Attach to issue (identifier like TC-123)") .option("--icon ", "Document icon (emoji)") .option("-i, --interactive", "Interactive mode with prompts") @@ -146,12 +150,7 @@ export const createCommand = new Command() // Resolve project ID if provided let projectId: string | undefined if (project) { - projectId = await resolveProjectId(client, project) - if (!projectId) { - throw new NotFoundError("Project", project, { - suggestion: "Provide a valid project slug or ID.", - }) - } + projectId = await resolveProjectId(project) } // Resolve issue ID if provided @@ -273,15 +272,9 @@ async function promptInteractiveCreate(): Promise<{ if (attachTo === "project") { const projectInput = await Input.prompt({ - message: "Project slug or ID", + message: "Project (UUID, slug ID, or name)", }) - const client = getGraphQLClient() - projectId = await resolveProjectId(client, projectInput) - if (!projectId) { - throw new NotFoundError("Project", projectInput, { - suggestion: "Provide a valid project slug or ID.", - }) - } + projectId = await resolveProjectId(projectInput) } else if (attachTo === "issue") { const issueInput = await Input.prompt({ message: "Issue identifier (e.g., TC-123)", @@ -304,58 +297,6 @@ async function promptInteractiveCreate(): Promise<{ } } -async function resolveProjectId( - // deno-lint-ignore no-explicit-any - client: any, - projectInput: string, -): Promise { - // First try to get by slug/ID directly - const projectQuery = gql(` - query GetProjectForDocument($slugId: String!) { - project(id: $slugId) { - id - name - } - } - `) - - try { - const result = await client.request(projectQuery, { slugId: projectInput }) - if (result.project) { - return result.project.id - } - } catch { - // Project not found by ID, try searching by name - } - - // Search by name - const searchQuery = gql(` - query SearchProjectsForDocument($filter: ProjectFilter) { - projects(filter: $filter, first: 1) { - nodes { - id - name - } - } - } - `) - - try { - const result = await client.request(searchQuery, { - filter: { - name: { containsIgnoreCase: projectInput }, - }, - }) - if (result.projects.nodes.length > 0) { - return result.projects.nodes[0].id - } - } catch { - // Search failed - } - - return undefined -} - async function resolveIssueId( // deno-lint-ignore no-explicit-any client: any, diff --git a/src/commands/issue/issue-create.ts b/src/commands/issue/issue-create.ts index d9f37b29..4a25a2b4 100644 --- a/src/commands/issue/issue-create.ts +++ b/src/commands/issue/issue-create.ts @@ -13,9 +13,10 @@ import { getIssueLabelIdByNameForTeam, getIssueLabelOptionsByNameForTeam, getLabelsForTeam, - getMilestoneIdByName, getProjectIdByName, getProjectOptionsByName, + isLinearUuid, + resolveMilestoneId, getTeamIdByKey, getTeamKey, getWorkflowStateByNameOrType, @@ -490,7 +491,7 @@ export const createCommand = new Command() ) .option( "--project ", - "Name or slug ID of the project with the issue", + "Project for the issue (UUID, slug ID, or name)", ) .option( "-s, --state ", @@ -498,7 +499,7 @@ export const createCommand = new Command() ) .option( "--milestone ", - "Name of the project milestone", + "Project milestone (UUID, or name when --project is set)", ) .option( "--cycle ", @@ -752,19 +753,23 @@ export const createCommand = new Command() let projectMilestoneId: string | undefined if (milestone != null) { - if (projectId == null) { - throw new ValidationError( - "--milestone requires --project to be set", - { - suggestion: - "Use --project to specify which project the milestone belongs to.", - }, + if (isLinearUuid(milestone)) { + projectMilestoneId = milestone + } else { + if (projectId == null) { + throw new ValidationError( + "--milestone requires --project to be set", + { + suggestion: + "Use --project to specify which project the milestone belongs to, or pass a milestone UUID directly.", + }, + ) + } + projectMilestoneId = await resolveMilestoneId( + milestone, + projectId, ) } - projectMilestoneId = await getMilestoneIdByName( - milestone, - projectId, - ) } let cycleId: string | undefined diff --git a/src/commands/issue/issue-mine.ts b/src/commands/issue/issue-mine.ts index e8ee021f..a892deb3 100644 --- a/src/commands/issue/issue-mine.ts +++ b/src/commands/issue/issue-mine.ts @@ -11,8 +11,9 @@ import { import { fetchIssuesForState, getCycleIdByNameOrNumber, - getMilestoneIdByName, getProjectIdByName, + isLinearUuid, + resolveMilestoneId, getProjectOptionsByName, getTeamIdByKey, getTeamKey, @@ -69,7 +70,7 @@ export const mineCommand = new Command() ) .option( "--project ", - "Filter by project name", + "Filter by project (UUID, slug ID, or name)", ) .option( "--project-label ", @@ -81,7 +82,7 @@ export const mineCommand = new Command() ) .option( "--milestone ", - "Filter by project milestone name (requires --project)", + "Filter by project milestone (UUID, or name when --project is set)", ) .option( "-l, --label ", @@ -243,16 +244,20 @@ export const mineCommand = new Command() }, ) } - if (projectId == null) { - throw new ValidationError( - "--milestone requires --project to be set", - { - suggestion: - "Use --project to specify which project the milestone belongs to.", - }, - ) + if (isLinearUuid(milestone)) { + milestoneId = milestone + } else { + if (projectId == null) { + throw new ValidationError( + "--milestone requires --project to be set", + { + suggestion: + "Use --project to specify which project the milestone belongs to, or pass a milestone UUID directly.", + }, + ) + } + milestoneId = await resolveMilestoneId(milestone, projectId) } - milestoneId = await getMilestoneIdByName(milestone, projectId) } const labelNames = labels && labels.length > 0 diff --git a/src/commands/issue/issue-query.ts b/src/commands/issue/issue-query.ts index 5578d7c4..1de20101 100644 --- a/src/commands/issue/issue-query.ts +++ b/src/commands/issue/issue-query.ts @@ -11,8 +11,9 @@ import { import { fetchIssuesForQuery, getCycleIdByNameOrNumber, - getMilestoneIdByName, getProjectIdByName, + isLinearUuid, + resolveMilestoneId, getProjectOptionsByName, getTeamIdByKey, getTeamKey, @@ -77,7 +78,7 @@ export const queryCommand = new Command() ) .option( "--project ", - "Filter by project name", + "Filter by project (UUID, slug ID, or name)", ) .option( "--project-label ", @@ -89,7 +90,7 @@ export const queryCommand = new Command() ) .option( "--milestone ", - "Filter by project milestone name (requires --project)", + "Filter by project milestone (UUID, or name when --project is set)", ) .option( "-l, --label ", @@ -182,12 +183,12 @@ export const queryCommand = new Command() ) } - if (milestone != null && project == null) { + if (milestone != null && project == null && !isLinearUuid(milestone)) { throw new ValidationError( "--milestone requires --project to be set", { suggestion: - "Use --project to specify which project the milestone belongs to.", + "Use --project to specify which project the milestone belongs to, or pass a milestone UUID directly.", }, ) } @@ -295,8 +296,10 @@ export const queryCommand = new Command() } let milestoneId: string | undefined - if (milestone != null && projectId != null) { - milestoneId = await getMilestoneIdByName(milestone, projectId) + if (milestone != null) { + milestoneId = isLinearUuid(milestone) + ? milestone + : await resolveMilestoneId(milestone, projectId) } const labelNames = label && label.length > 0 diff --git a/src/commands/issue/issue-update.ts b/src/commands/issue/issue-update.ts index f86e2e1c..324ba2dd 100644 --- a/src/commands/issue/issue-update.ts +++ b/src/commands/issue/issue-update.ts @@ -8,8 +8,9 @@ import { getIssueIdentifier, getIssueLabelIdByNameForTeam, getIssueProjectId, - getMilestoneIdByName, getProjectIdByName, + isLinearUuid, + resolveMilestoneId, getTeamIdByKey, getWorkflowStateByNameOrType, lookupUserId, @@ -64,7 +65,7 @@ export const updateCommand = new Command() ) .option( "--project ", - "Name or slug ID of the project with the issue", + "Project to assign the issue to (UUID, slug ID, or name)", ) .option( "-s, --state ", @@ -72,7 +73,7 @@ export const updateCommand = new Command() ) .option( "--milestone ", - "Name of the project milestone", + "Project milestone (UUID, or name when --project is set or the issue already has a project)", ) .option( "--cycle ", @@ -196,27 +197,34 @@ export const updateCommand = new Command() if (project !== undefined) { projectId = await getProjectIdByName(project) if (projectId === undefined) { - throw new NotFoundError("Project", project) + throw new NotFoundError("Project", project, { + suggestion: + "Pass a project UUID, slug ID (from `linear project list`), or exact project name.", + }) } } let projectMilestoneId: string | undefined if (milestone != null) { - const milestoneProjectId = projectId ?? - await getIssueProjectId(issueId) - if (milestoneProjectId == null) { - throw new ValidationError( - "--milestone requires --project to be set (issue has no existing project)", - { - suggestion: - "Use --project to specify the project for the milestone.", - }, + if (isLinearUuid(milestone)) { + projectMilestoneId = milestone + } else { + const milestoneProjectId = projectId ?? + await getIssueProjectId(issueId) + if (milestoneProjectId == null) { + throw new ValidationError( + "--milestone requires --project to be set (issue has no existing project)", + { + suggestion: + "Use --project to specify the project for the milestone, or pass a milestone UUID directly.", + }, + ) + } + projectMilestoneId = await resolveMilestoneId( + milestone, + milestoneProjectId, ) } - projectMilestoneId = await getMilestoneIdByName( - milestone, - milestoneProjectId, - ) } let cycleId: string | undefined diff --git a/src/commands/milestone/milestone-create.ts b/src/commands/milestone/milestone-create.ts index f83f1117..188499d2 100644 --- a/src/commands/milestone/milestone-create.ts +++ b/src/commands/milestone/milestone-create.ts @@ -25,7 +25,11 @@ const CreateProjectMilestone = gql(` export const createCommand = new Command() .name("create") .description("Create a new project milestone") - .option("--project ", "Project ID", { required: true }) + .option( + "--project ", + "Project (UUID, slug ID, or name)", + { required: true }, + ) .option("--name ", "Milestone name", { required: true }) .option("--description ", "Milestone description") .option("--target-date ", "Target date (YYYY-MM-DD)") diff --git a/src/commands/milestone/milestone-list.ts b/src/commands/milestone/milestone-list.ts index bf1be4a1..ef6f3009 100644 --- a/src/commands/milestone/milestone-list.ts +++ b/src/commands/milestone/milestone-list.ts @@ -31,7 +31,11 @@ const GetProjectMilestones = gql(` export const listCommand = new Command() .name("list") .description("List milestones for a project") - .option("--project ", "Project ID", { required: true }) + .option( + "--project ", + "Project (UUID, slug ID, or name)", + { required: true }, + ) .action(async ({ project: projectIdOrSlug }) => { const { Spinner } = await import("@std/cli/unstable-spinner") const showSpinner = shouldShowSpinner() diff --git a/src/commands/milestone/milestone-update.ts b/src/commands/milestone/milestone-update.ts index 74b53c59..fe93e13a 100644 --- a/src/commands/milestone/milestone-update.ts +++ b/src/commands/milestone/milestone-update.ts @@ -34,7 +34,10 @@ export const updateCommand = new Command() "--sort-order ", "Sort order relative to other milestones", ) - .option("--project ", "Move to a different project") + .option( + "--project ", + "Move to a different project (UUID, slug ID, or name)", + ) .action( async ( { name, description, targetDate, sortOrder, project: projectIdOrSlug }, diff --git a/src/commands/milestone/milestone-view.ts b/src/commands/milestone/milestone-view.ts index 8aa6e13c..f8b8d334 100644 --- a/src/commands/milestone/milestone-view.ts +++ b/src/commands/milestone/milestone-view.ts @@ -5,6 +5,7 @@ import { getGraphQLClient } from "../../utils/graphql.ts" import { formatRelativeTime } from "../../utils/display.ts" import { shouldShowSpinner } from "../../utils/hyperlink.ts" import { handleError, NotFoundError } from "../../utils/errors.ts" +import { resolveMilestoneId, resolveProjectId } from "../../utils/linear.ts" const GetMilestoneDetails = gql(` query GetMilestoneDetails($id: String!) { @@ -41,14 +42,28 @@ export const viewCommand = new Command() .name("view") .description("View milestone details") .alias("v") - .arguments("") - .action(async (_options, milestoneId) => { + .arguments("") + .option( + "--project ", + "Project for resolving a milestone name (UUID, slug ID, or name)", + ) + .action(async ({ project }, milestoneInput) => { const { Spinner } = await import("@std/cli/unstable-spinner") const showSpinner = shouldShowSpinner() const spinner = showSpinner ? new Spinner() : null spinner?.start() try { + let milestoneId: string + if (project != null) { + const projectId = await resolveProjectId(project) + milestoneId = await resolveMilestoneId(milestoneInput, projectId) + } else { + // Without --project, pass the input through to the API. Linear will + // resolve it if it's a UUID and return null otherwise. + milestoneId = milestoneInput + } + const client = getGraphQLClient() const result = await client.request(GetMilestoneDetails, { id: milestoneId, @@ -57,7 +72,7 @@ export const viewCommand = new Command() const milestone = result.projectMilestone if (!milestone) { - throw new NotFoundError("Milestone", milestoneId) + throw new NotFoundError("Milestone", milestoneInput) } // Build the display diff --git a/src/utils/linear.ts b/src/utils/linear.ts index bfdd2a78..8d8afbc5 100644 --- a/src/utils/linear.ts +++ b/src/utils/linear.ts @@ -1187,11 +1187,26 @@ export async function searchIssuesByTerm( } } +const UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +export function isLinearUuid(value: string): boolean { + return UUID_REGEX.test(value) +} + +/** + * Look up a project ID by UUID, slug ID, or exact name. + * Returns undefined when no project matches. Use [[resolveProjectId]] when + * you want a missing project to throw. + */ export async function getProjectIdByName( - name: string, + input: string, ): Promise { + if (isLinearUuid(input)) return input + const client = getGraphQLClient() - const query = gql(/* GraphQL */ ` + + const nameQuery = gql(/* GraphQL */ ` query GetProjectIdByName($name: String!) { projects(filter: { name: { eq: $name } }) { nodes { @@ -1200,14 +1215,10 @@ export async function getProjectIdByName( } } `) - const data = await client.request(query, { name }) - const projectId = data.projects?.nodes[0]?.id - if (projectId) return projectId + const nameData = await client.request(nameQuery, { name: input }) + const nameMatch = nameData.projects?.nodes[0]?.id + if (nameMatch) return nameMatch - // Fall back to matching by slugId (the 12-char hex string visible in - // `project list` output and Linear URLs). This provides a reliable - // alternative when project names contain special characters that the - // exact-match name filter doesn't handle well. const slugQuery = gql(/* GraphQL */ ` query GetProjectIdBySlugId($slugId: String!) { projects(filter: { slugId: { eq: $slugId } }) { @@ -1217,41 +1228,24 @@ export async function getProjectIdByName( } } `) - const slugData = await client.request(slugQuery, { slugId: name }) + const slugData = await client.request(slugQuery, { slugId: input }) return slugData.projects?.nodes[0]?.id } +/** + * Resolve a project to its UUID. Accepts a UUID, slug ID, or exact name. + * Throws NotFoundError if none match. + */ export async function resolveProjectId( - projectIdOrSlug: string, + input: string, ): Promise { - // If it looks like a full UUID, try to use it directly - if ( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( - projectIdOrSlug, - ) - ) { - return projectIdOrSlug - } - - // Otherwise, treat it as a slug and look it up - const client = getGraphQLClient() - const query = gql(/* GraphQL */ ` - query GetProjectBySlug($slugId: String!) { - projects(filter: { slugId: { eq: $slugId } }) { - nodes { - id - slugId - } - } - } - `) - const data = await client.request(query, { slugId: projectIdOrSlug }) - const projectId = data.projects?.nodes[0]?.id - + const projectId = await getProjectIdByName(input) if (!projectId) { - throw new NotFoundError("Project", projectIdOrSlug) + throw new NotFoundError("Project", input, { + suggestion: + "Pass a project UUID, slug ID (from `linear project list`), or exact project name.", + }) } - return projectId } @@ -1574,6 +1568,28 @@ export async function getIssueProjectId( return data.issue?.project?.id ?? undefined } +/** + * Resolve a milestone to its UUID. Accepts a UUID directly, or a milestone + * name when scoped to a project. Throws when a name is passed without a + * project context. + */ +export async function resolveMilestoneId( + input: string, + projectId?: string, +): Promise { + if (isLinearUuid(input)) return input + if (!projectId) { + throw new ValidationError( + `Cannot resolve milestone "${input}" without --project`, + { + suggestion: + "Pass a milestone UUID, or specify --project so the milestone name can be looked up within that project.", + }, + ) + } + return await getMilestoneIdByName(input, projectId) +} + export async function getMilestoneIdByName( milestoneName: string, projectId: string, diff --git a/test/commands/document/__snapshots__/document-create.test.ts.snap b/test/commands/document/__snapshots__/document-create.test.ts.snap index a8b80a7f..c4dfbdd9 100644 --- a/test/commands/document/__snapshots__/document-create.test.ts.snap +++ b/test/commands/document/__snapshots__/document-create.test.ts.snap @@ -11,14 +11,14 @@ Description: Options: - -h, --help - Show this help. - -t, --title - Document title (required) - -c, --content <content> - Markdown content (inline) - -f, --content-file <path> - Read content from file - --project <project> - Attach to project (slug or ID) - --issue <issue> - Attach to issue (identifier like TC-123) - --icon <icon> - Document icon (emoji) - -i, --interactive - Interactive mode with prompts + -h, --help - Show this help. + -t, --title <title> - Document title (required) + -c, --content <content> - Markdown content (inline) + -f, --content-file <path> - Read content from file + --project <project> - Attach to project (UUID, slug ID, or name) + --issue <issue> - Attach to issue (identifier like TC-123) + --icon <icon> - Document icon (emoji) + -i, --interactive - Interactive mode with prompts " stderr: diff --git a/test/commands/document/document-create.test.ts b/test/commands/document/document-create.test.ts index 885721f4..d1062f45 100644 --- a/test/commands/document/document-create.test.ts +++ b/test/commands/document/document-create.test.ts @@ -79,16 +79,17 @@ await snapshotTest({ denoArgs: commonDenoArgs, async fn() { const server = new MockLinearServer([ - // Mock project resolution query + // Shared project resolver tries name first, then slugId { - queryName: "GetProjectForDocument", + queryName: "GetProjectIdByName", + response: { data: { projects: { nodes: [] } } }, + }, + { + queryName: "GetProjectIdBySlugId", variables: { slugId: "tinycloud-sdk" }, response: { data: { - project: { - id: "project-uuid-123", - name: "TinyCloud SDK", - }, + projects: { nodes: [{ id: "project-uuid-123" }] }, }, }, }, diff --git a/test/commands/issue/__snapshots__/issue-create.test.ts.snap b/test/commands/issue/__snapshots__/issue-create.test.ts.snap index 729a7283..121308d5 100644 --- a/test/commands/issue/__snapshots__/issue-create.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-create.test.ts.snap @@ -22,9 +22,9 @@ Options: --description-file <path> - Read description from a file (preferred for markdown content) -l, --label <label> - Issue label associated with the issue. May be repeated. --team <team> - Team associated with the issue (if not your default team) - --project <project> - Name or slug ID of the project with the issue + --project <project> - Project for the issue (UUID, slug ID, or name) -s, --state <state> - Workflow state for the issue (by name or type) - --milestone <milestone> - Name of the project milestone + --milestone <milestone> - Project milestone (UUID, or name when --project is set) --cycle <cycle> - Cycle name, number, or 'active' --no-use-default-template - Do not use default template for the issue --no-interactive - Disable interactive prompts diff --git a/test/commands/issue/__snapshots__/issue-list.test.ts.snap b/test/commands/issue/__snapshots__/issue-list.test.ts.snap index 610a4096..72684889 100644 --- a/test/commands/issue/__snapshots__/issue-list.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-list.test.ts.snap @@ -17,10 +17,10 @@ Options: --all-states - Show issues from all states --sort <sort> - Sort order (can also be set via LINEAR_ISSUE_SORT) (Values: \\x1b[32m"manual"\\x1b[39m, \\x1b[32m"priority"\\x1b[39m) --team <team> - Team to list issues for (if not your default team) - --project <project> - Filter by project name + --project <project> - Filter by project (UUID, slug ID, or name) --project-label <projectLabel> - Filter by project label name (shows issues from all projects with this label) --cycle <cycle> - Filter by cycle name, number, or 'active' - --milestone <milestone> - Filter by project milestone name (requires --project) + --milestone <milestone> - Filter by project milestone (UUID, or name when --project is set) -l, --label <label> - Filter by label name (can be repeated for multiple labels) --limit <limit> - Maximum number of issues to fetch (default: 50, use 0 for unlimited) (Default: \\x1b[33m50\\x1b[39m) --created-after <date> - Filter issues created after this date (ISO 8601 or YYYY-MM-DD) diff --git a/test/commands/issue/__snapshots__/issue-mine.test.ts.snap b/test/commands/issue/__snapshots__/issue-mine.test.ts.snap index 4512a51c..7274fc0e 100644 --- a/test/commands/issue/__snapshots__/issue-mine.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-mine.test.ts.snap @@ -17,10 +17,10 @@ Options: --all-states - Show issues from all states --sort <sort> - Sort order (can also be set via LINEAR_ISSUE_SORT) (Values: \\x1b[32m"manual"\\x1b[39m, \\x1b[32m"priority"\\x1b[39m) --team <team> - Team to list issues for (if not your default team) - --project <project> - Filter by project name + --project <project> - Filter by project (UUID, slug ID, or name) --project-label <projectLabel> - Filter by project label name (shows issues from all projects with this label) --cycle <cycle> - Filter by cycle name, number, or 'active' - --milestone <milestone> - Filter by project milestone name (requires --project) + --milestone <milestone> - Filter by project milestone (UUID, or name when --project is set) -l, --label <label> - Filter by label name (can be repeated for multiple labels) --limit <limit> - Maximum number of issues to fetch (default: 50, use 0 for unlimited) (Default: \\x1b[33m50\\x1b[39m) --created-after <date> - Filter issues created after this date (ISO 8601 or YYYY-MM-DD) diff --git a/test/commands/issue/__snapshots__/issue-query.test.ts.snap b/test/commands/issue/__snapshots__/issue-query.test.ts.snap index 8adef3b4..190c2932 100644 --- a/test/commands/issue/__snapshots__/issue-query.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-query.test.ts.snap @@ -23,10 +23,10 @@ Options: -A, --all-assignees - Show issues for all assignees (this is the default) -U, --unassigned - Show only unassigned issues --sort <sort> - Sort order: manual or priority (default: priority, not available with --search) (Values: \\x1b[32m"manual"\\x1b[39m, \\x1b[32m"priority"\\x1b[39m) - --project <project> - Filter by project name + --project <project> - Filter by project (UUID, slug ID, or name) --project-label <projectLabel> - Filter by project label name (shows issues from all projects with this label) --cycle <cycle> - Filter by cycle name, number, or 'active' - --milestone <milestone> - Filter by project milestone name (requires --project) + --milestone <milestone> - Filter by project milestone (UUID, or name when --project is set) -l, --label <label> - Filter by label name (can be repeated for multiple labels) --limit <limit> - Maximum number of issues to fetch (default: 50, use 0 for unlimited) (Default: \\x1b[33m50\\x1b[39m) --created-after <date> - Filter issues created after this date (ISO 8601 or YYYY-MM-DD) diff --git a/test/commands/issue/__snapshots__/issue-update.test.ts.snap b/test/commands/issue/__snapshots__/issue-update.test.ts.snap index 67d8f315..cc88b8b9 100644 --- a/test/commands/issue/__snapshots__/issue-update.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-update.test.ts.snap @@ -11,21 +11,22 @@ Description: Options: - -h, --help - Show this help. - -a, --assignee <assignee> - Assign the issue to 'self' or someone (by username or name) - --due-date <dueDate> - Due date of the issue - --parent <parent> - Parent issue (if any) as a team_number code - -p, --priority <priority> - Priority of the issue (1-4, descending priority) - --estimate <estimate> - Points estimate of the issue - -d, --description <description> - Description of the issue - --description-file <path> - Read description from a file (preferred for markdown content) - -l, --label <label> - Issue label associated with the issue. May be repeated. - --team <team> - Team associated with the issue (if not your default team) - --project <project> - Name or slug ID of the project with the issue - -s, --state <state> - Workflow state for the issue (by name or type) - --milestone <milestone> - Name of the project milestone - --cycle <cycle> - Cycle name, number, or 'active' - -t, --title <title> - Title of the issue + -h, --help - Show this help. + -a, --assignee <assignee> - Assign the issue to 'self' or someone (by username or name) + --due-date <dueDate> - Due date of the issue + --parent <parent> - Parent issue (if any) as a team_number code + -p, --priority <priority> - Priority of the issue (1-4, descending priority) + --estimate <estimate> - Points estimate of the issue + -d, --description <description> - Description of the issue + --description-file <path> - Read description from a file (preferred for markdown content) + -l, --label <label> - Issue label associated with the issue. May be repeated. + --team <team> - Team associated with the issue (if not your default team) + --project <project> - Project to assign the issue to (UUID, slug ID, or name) + -s, --state <state> - Workflow state for the issue (by name or type) + --milestone <milestone> - Project milestone (UUID, or name when --project is set or the issue already has + a project) + --cycle <cycle> - Cycle name, number, or 'active' + -t, --title <title> - Title of the issue " stderr: @@ -97,3 +98,25 @@ https://linear.app/test-team/issue/ENG-123/test-issue stderr: "" `; + +snapshot[`Issue Update Command - With Project UUID 1`] = ` +stdout: +"Updating issue ENG-123 + +✓ Updated issue ENG-123: Test Issue +https://linear.app/test-team/issue/ENG-123/test-issue +" +stderr: +"" +`; + +snapshot[`Issue Update Command - With Milestone UUID (no --project required) 1`] = ` +stdout: +"Updating issue ENG-123 + +✓ Updated issue ENG-123: Test Issue +https://linear.app/test-team/issue/ENG-123/test-issue +" +stderr: +"" +`; diff --git a/test/commands/issue/issue-update.test.ts b/test/commands/issue/issue-update.test.ts index b0969d85..e1e87bcd 100644 --- a/test/commands/issue/issue-update.test.ts +++ b/test/commands/issue/issue-update.test.ts @@ -427,3 +427,86 @@ await snapshotTest({ } }, }) + +// --- #221: --project / --milestone accept UUIDs as well as names --- + +const PROJECT_UUID = "11111111-1111-1111-1111-111111111111" +const MILESTONE_UUID = "22222222-2222-2222-2222-222222222222" + +await snapshotTest({ + name: "Issue Update Command - With Project UUID", + meta: import.meta, + colors: false, + args: ["ENG-123", "--project", PROJECT_UUID], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { data: { teams: { nodes: [{ id: "team-eng-id" }] } } }, + }, + { + queryName: "UpdateIssue", + response: { + data: { + issueUpdate: { + success: true, + issue: { + id: "issue-existing-123", + identifier: "ENG-123", + url: "https://linear.app/test-team/issue/ENG-123/test-issue", + title: "Test Issue", + }, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + try { + await updateCommand.parse() + } finally { + await cleanup() + } + }, +}) + +await snapshotTest({ + name: "Issue Update Command - With Milestone UUID (no --project required)", + meta: import.meta, + colors: false, + args: ["ENG-123", "--milestone", MILESTONE_UUID], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { data: { teams: { nodes: [{ id: "team-eng-id" }] } } }, + }, + { + queryName: "UpdateIssue", + response: { + data: { + issueUpdate: { + success: true, + issue: { + id: "issue-existing-123", + identifier: "ENG-123", + url: "https://linear.app/test-team/issue/ENG-123/test-issue", + title: "Test Issue", + }, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + try { + await updateCommand.parse() + } finally { + await cleanup() + } + }, +}) diff --git a/test/commands/milestone/__snapshots__/milestone-create.test.ts.snap b/test/commands/milestone/__snapshots__/milestone-create.test.ts.snap index b63e6a12..bf35bc6c 100644 --- a/test/commands/milestone/__snapshots__/milestone-create.test.ts.snap +++ b/test/commands/milestone/__snapshots__/milestone-create.test.ts.snap @@ -3,7 +3,7 @@ export const snapshot = {}; snapshot[`Milestone Create Command - Help Text 1`] = ` stdout: " -Usage: create --project <projectId> --name <name> +Usage: create --project <project> --name <name> Description: @@ -11,11 +11,11 @@ Description: Options: - -h, --help - Show this help. - --project <projectId> - Project ID (required) - --name <name> - Milestone name (required) - --description <description> - Milestone description - --target-date <date> - Target date (YYYY-MM-DD) + -h, --help - Show this help. + --project <project> - Project (UUID, slug ID, or name) (required) + --name <name> - Milestone name (required) + --description <description> - Milestone description + --target-date <date> - Target date (YYYY-MM-DD) " stderr: @@ -42,3 +42,13 @@ stdout: stderr: "" `; + +snapshot[`Milestone Create Command - Resolves Project by Name 1`] = ` +stdout: +"✓ Created milestone: Y26 Q2 + ID: milestone-new-q2 + Project: Tech Debt +" +stderr: +"" +`; diff --git a/test/commands/milestone/__snapshots__/milestone-list.test.ts.snap b/test/commands/milestone/__snapshots__/milestone-list.test.ts.snap index 5a83351b..11aeead4 100644 --- a/test/commands/milestone/__snapshots__/milestone-list.test.ts.snap +++ b/test/commands/milestone/__snapshots__/milestone-list.test.ts.snap @@ -3,7 +3,7 @@ export const snapshot = {}; snapshot[`Milestone List Command - Help Text 1`] = ` stdout: " -Usage: list --project <projectId> +Usage: list --project <project> Description: @@ -11,8 +11,8 @@ Description: Options: - -h, --help - Show this help. - --project <projectId> - Project ID (required) + -h, --help - Show this help. + --project <project> - Project (UUID, slug ID, or name) (required) " stderr: diff --git a/test/commands/milestone/__snapshots__/milestone-update.test.ts.snap b/test/commands/milestone/__snapshots__/milestone-update.test.ts.snap index c1ea5b00..5c41cd24 100644 --- a/test/commands/milestone/__snapshots__/milestone-update.test.ts.snap +++ b/test/commands/milestone/__snapshots__/milestone-update.test.ts.snap @@ -11,12 +11,12 @@ Description: Options: - -h, --help - Show this help. - --name <name> - Milestone name - --description <description> - Milestone description - --target-date <date> - Target date (YYYY-MM-DD) - --sort-order <value> - Sort order relative to other milestones - --project <projectId> - Move to a different project + -h, --help - Show this help. + --name <name> - Milestone name + --description <description> - Milestone description + --target-date <date> - Target date (YYYY-MM-DD) + --sort-order <value> - Sort order relative to other milestones + --project <project> - Move to a different project (UUID, slug ID, or name) " stderr: diff --git a/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap b/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap index 91d6a222..d5ad7b40 100644 --- a/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap +++ b/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap @@ -3,7 +3,7 @@ export const snapshot = {}; snapshot[`Milestone View Command - Help Text 1`] = ` stdout: " -Usage: view <milestoneId> +Usage: view <milestone> Description: @@ -11,7 +11,8 @@ Description: Options: - -h, --help - Show this help. + -h, --help - Show this help. + --project <project> - Project for resolving a milestone name (UUID, slug ID, or name) " stderr: diff --git a/test/commands/milestone/milestone-create.test.ts b/test/commands/milestone/milestone-create.test.ts index fcb8530c..c4fcad77 100644 --- a/test/commands/milestone/milestone-create.test.ts +++ b/test/commands/milestone/milestone-create.test.ts @@ -34,14 +34,17 @@ await cliffySnapshotTest({ async fn() { const server = new MockLinearServer([ { - queryName: "GetProjectBySlug", + queryName: "GetProjectIdByName", + response: { + data: { projects: { nodes: [] } }, + }, + }, + { + queryName: "GetProjectIdBySlugId", response: { data: { projects: { - nodes: [{ - id: "project-123", - slugId: "project-123", - }], + nodes: [{ id: "project-123" }], }, }, }, @@ -96,14 +99,17 @@ await cliffySnapshotTest({ async fn() { const server = new MockLinearServer([ { - queryName: "GetProjectBySlug", + queryName: "GetProjectIdByName", + response: { + data: { projects: { nodes: [] } }, + }, + }, + { + queryName: "GetProjectIdBySlugId", response: { data: { projects: { - nodes: [{ - id: "project-456", - slugId: "project-456", - }], + nodes: [{ id: "project-456" }], }, }, }, @@ -142,3 +148,65 @@ await cliffySnapshotTest({ } }, }) + +// #221: --project also accepts an exact project name +await cliffySnapshotTest({ + name: "Milestone Create Command - Resolves Project by Name", + meta: import.meta, + colors: false, + args: [ + "--project", + "Tech Debt", + "--name", + "Y26 Q2", + ], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetProjectIdByName", + variables: { name: "Tech Debt" }, + response: { + data: { projects: { nodes: [{ id: "project-tech-debt-uuid" }] } }, + }, + }, + { + queryName: "CreateProjectMilestone", + variables: { + input: { + projectId: "project-tech-debt-uuid", + name: "Y26 Q2", + }, + }, + response: { + data: { + projectMilestoneCreate: { + success: true, + projectMilestone: { + id: "milestone-new-q2", + name: "Y26 Q2", + targetDate: null, + project: { + id: "project-tech-debt-uuid", + name: "Tech Debt", + }, + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await createCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) diff --git a/test/commands/milestone/milestone-list.test.ts b/test/commands/milestone/milestone-list.test.ts index a4cf54bd..4b5084d0 100644 --- a/test/commands/milestone/milestone-list.test.ts +++ b/test/commands/milestone/milestone-list.test.ts @@ -25,15 +25,14 @@ await cliffySnapshotTest({ async fn() { const server = new MockLinearServer([ { - queryName: "GetProjectBySlug", + queryName: "GetProjectIdByName", + response: { data: { projects: { nodes: [] } } }, + }, + { + queryName: "GetProjectIdBySlugId", response: { data: { - projects: { - nodes: [{ - id: "project-123", - slugId: "project-123", - }], - }, + projects: { nodes: [{ id: "project-123" }] }, }, }, }, @@ -109,15 +108,14 @@ await cliffySnapshotTest({ async fn() { const server = new MockLinearServer([ { - queryName: "GetProjectBySlug", + queryName: "GetProjectIdByName", + response: { data: { projects: { nodes: [] } } }, + }, + { + queryName: "GetProjectIdBySlugId", response: { data: { - projects: { - nodes: [{ - id: "project-456", - slugId: "project-456", - }], - }, + projects: { nodes: [{ id: "project-456" }] }, }, }, }, diff --git a/test/utils/linear.test.ts b/test/utils/linear.test.ts index 5db4ae4d..15237840 100644 --- a/test/utils/linear.test.ts +++ b/test/utils/linear.test.ts @@ -1,8 +1,12 @@ -import { assertEquals } from "@std/assert" +import { assertEquals, assertRejects } from "@std/assert" import { getIssueIdentifier, + isLinearUuid, + resolveMilestoneId, + resolveProjectId, searchIssuesByTerm, } from "../../src/utils/linear.ts" +import { NotFoundError, ValidationError } from "../../src/utils/errors.ts" import { setupMockLinearServer } from "../utils/test-helpers.ts" Deno.test("getIssueId - handles full issue identifiers", async () => { @@ -139,3 +143,136 @@ Deno.test("searchIssuesByTerm - without limit fetches a single page", async () = await cleanup() } }) + +const UUID = "00000000-0000-0000-0000-000000000000" + +Deno.test("isLinearUuid - detects UUID format", () => { + assertEquals(isLinearUuid(UUID), true) + assertEquals(isLinearUuid("ABNL-99"), false) + assertEquals(isLinearUuid("F-FOO"), false) + assertEquals(isLinearUuid("project-name with spaces"), false) + assertEquals(isLinearUuid(""), false) +}) + +Deno.test("resolveProjectId - accepts a UUID without an API call", async () => { + const { cleanup } = await setupMockLinearServer([]) + try { + const id = await resolveProjectId(UUID) + assertEquals(id, UUID) + } finally { + await cleanup() + } +}) + +Deno.test("resolveProjectId - resolves by exact name", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetProjectIdByName", + variables: { name: "Tech Debt" }, + response: { + data: { projects: { nodes: [{ id: "proj-name-uuid" }] } }, + }, + }, + ]) + try { + const id = await resolveProjectId("Tech Debt") + assertEquals(id, "proj-name-uuid") + } finally { + await cleanup() + } +}) + +Deno.test("resolveProjectId - falls back to slug ID when name does not match", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetProjectIdByName", + variables: { name: "f-foo" }, + response: { data: { projects: { nodes: [] } } }, + }, + { + queryName: "GetProjectIdBySlugId", + variables: { slugId: "f-foo" }, + response: { + data: { projects: { nodes: [{ id: "proj-slug-uuid" }] } }, + }, + }, + ]) + try { + const id = await resolveProjectId("f-foo") + assertEquals(id, "proj-slug-uuid") + } finally { + await cleanup() + } +}) + +Deno.test("resolveProjectId - throws NotFoundError when nothing matches", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetProjectIdByName", + response: { data: { projects: { nodes: [] } } }, + }, + { + queryName: "GetProjectIdBySlugId", + response: { data: { projects: { nodes: [] } } }, + }, + ]) + try { + await assertRejects( + () => resolveProjectId("nope"), + NotFoundError, + "Project not found: nope", + ) + } finally { + await cleanup() + } +}) + +Deno.test("resolveMilestoneId - accepts UUID directly without a project", async () => { + const { cleanup } = await setupMockLinearServer([]) + try { + const id = await resolveMilestoneId(UUID) + assertEquals(id, UUID) + } finally { + await cleanup() + } +}) + +Deno.test("resolveMilestoneId - resolves a name within the given project", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetProjectMilestonesForLookup", + variables: { projectId: "proj-1" }, + response: { + data: { + project: { + projectMilestones: { + nodes: [ + { id: "ms-1", name: "Y26 Q2" }, + { id: "ms-2", name: "Y26 Q3" }, + ], + }, + }, + }, + }, + }, + ]) + try { + const id = await resolveMilestoneId("Y26 Q2", "proj-1") + assertEquals(id, "ms-1") + } finally { + await cleanup() + } +}) + +Deno.test("resolveMilestoneId - errors when a name is passed without a project", async () => { + const { cleanup } = await setupMockLinearServer([]) + try { + await assertRejects( + () => resolveMilestoneId("Y26 Q2"), + ValidationError, + "Cannot resolve milestone", + ) + } finally { + await cleanup() + } +})