diff --git a/README.md b/README.md index 043ee6d2..0e4ad6cd 100644 --- a/README.md +++ b/README.md @@ -233,13 +233,15 @@ linear completions # generate shell completions the CLI supports configuration via environment variables or a `.linear.toml` config file. environment variables take precedence over config file values. -| option | env var | toml key | example | description | -| --------------- | ------------------------ | ----------------- | -------------------------- | ------------------------------------- | -| Team ID | `LINEAR_TEAM_ID` | `team_id` | `"ENG"` | default team for operations | -| Workspace | `LINEAR_WORKSPACE` | `workspace` | `"mycompany"` | workspace slug for web/app URLs | -| Issue sort | `LINEAR_ISSUE_SORT` | `issue_sort` | `"priority"` or `"manual"` | how to sort issue lists | -| VCS | `LINEAR_VCS` | `vcs` | `"git"` or `"jj"` | version control system (default: git) | -| Download images | `LINEAR_DOWNLOAD_IMAGES` | `download_images` | `true` or `false` | download images when viewing issues | +| option | env var | toml key | example | description | +| --------------- | --------------------------------- | -------------------------- | ---------------------------------- | ----------------------------------------------------- | +| Team ID | `LINEAR_TEAM_ID` | `team_id` | `"ENG"` | default team for operations | +| Workspace | `LINEAR_WORKSPACE` | `workspace` | `"mycompany"` | workspace slug for web/app URLs | +| Issue sort | `LINEAR_ISSUE_SORT` | `issue_sort` | `"priority"` or `"manual"` | how to sort issue lists | +| Ask project | `LINEAR_ISSUE_CREATE_ASK_PROJECT` | `issue_create_ask_project` | `true` or `false` | ask for a project during interactive `issue create` | +| Assign self | `LINEAR_ISSUE_CREATE_ASSIGN_SELF` | `issue_create_assign_self` | `"always"`, `"auto"`, or `"never"` | control default self-assignment during issue creation | +| VCS | `LINEAR_VCS` | `vcs` | `"git"` or `"jj"` | version control system (default: git) | +| Download images | `LINEAR_DOWNLOAD_IMAGES` | `download_images` | `true` or `false` | download images when viewing issues | the config file can be placed at (checked in order, first found is used): diff --git a/docs/authentication.md b/docs/authentication.md index c5869a7d..89227e9d 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -118,6 +118,8 @@ you can also set the API key in a project's `.linear.toml`: api_key = "lin_api_..." workspace = "acme" team_id = "ENG" +issue_create_assign_self = "always" +issue_create_ask_project = true ``` this is useful for project-specific credentials but less secure than stored credentials since it may be committed to version control. diff --git a/src/commands/issue/issue-create.ts b/src/commands/issue/issue-create.ts index d9f37b29..73866f70 100644 --- a/src/commands/issue/issue-create.ts +++ b/src/commands/issue/issue-create.ts @@ -1,6 +1,7 @@ import { Command } from "@cliffy/command" import { Checkbox, Input, Select } from "@cliffy/prompt" import { gql } from "../../__codegen__/gql.ts" +import { getOption } from "../../config.ts" import { getGraphQLClient } from "../../utils/graphql.ts" import { getEditor, openEditor } from "../../utils/editor.ts" import { getPriorityDisplay } from "../../utils/display.ts" @@ -16,6 +17,7 @@ import { getMilestoneIdByName, getProjectIdByName, getProjectOptionsByName, + getProjectsForTeam, getTeamIdByKey, getTeamKey, getWorkflowStateByNameOrType, @@ -34,6 +36,12 @@ import { } from "../../utils/errors.ts" type IssueLabel = { id: string; name: string; color: string } +type ProjectOption = { id: string; name: string } +type IssueCreatePreloadedData = { + states?: WorkflowState[] + labels?: IssueLabel[] + projects?: ProjectOption[] +} type AdditionalField = { key: string @@ -41,13 +49,130 @@ type AdditionalField = { handler: ( teamKey: string, teamId: string, - preloaded?: { - states?: WorkflowState[] - labels?: IssueLabel[] - }, + preloaded?: IssueCreatePreloadedData, ) => Promise } +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 { + 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 { + 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 { + let projectId = await getProjectIdByName(project) + if (projectId == null && interactive) { + const projectIds = await getProjectOptionsByName(project) + projectId = await selectOption("Project", project, projectIds) + } + if (projectId == null) { + throw new NotFoundError("Project", project) + } + return projectId +} + +async function resolveParentIssueForCreate( + parentIdentifier?: string, +): Promise<{ + parentId?: string + parentData: { + title: string + identifier: string + projectId: string | null + } | null +}> { + let parentId: string | undefined + let parentData: { + title: string + identifier: string + projectId: string | null + } | null = null + + if (parentIdentifier) { + const parentIdentifierResolved = await getIssueIdentifier(parentIdentifier) + if (!parentIdentifierResolved) { + throw new ValidationError( + `Could not resolve parent issue identifier: ${parentIdentifier}`, + ) + } + + parentId = await getIssueId(parentIdentifierResolved) + if (!parentId) { + throw new NotFoundError("Parent issue", parentIdentifierResolved) + } + + parentData = await fetchParentIssueData(parentId) + } + + return { parentId, parentData } +} + const ADDITIONAL_FIELDS: AdditionalField[] = [ { key: "workflow_state", @@ -55,10 +180,7 @@ const ADDITIONAL_FIELDS: AdditionalField[] = [ handler: async ( teamKey: string, _teamId: string, - preloaded?: { - states?: WorkflowState[] - labels?: IssueLabel[] - }, + preloaded?: IssueCreatePreloadedData, ) => { const states = preloaded?.states ?? await getWorkflowStates(teamKey) if (states.length === 0) return undefined @@ -113,10 +235,7 @@ const ADDITIONAL_FIELDS: AdditionalField[] = [ handler: async ( teamKey: string, _teamId: string, - preloaded?: { - states?: WorkflowState[] - labels?: IssueLabel[] - }, + preloaded?: IssueCreatePreloadedData, ) => { const labels = preloaded?.labels ?? await getLabelsForTeam(teamKey) if (labels.length === 0) return [] @@ -144,6 +263,17 @@ const ADDITIONAL_FIELDS: AdditionalField[] = [ return isNaN(parsed) ? undefined : parsed }, }, + { + key: "project", + label: "Project", + handler: async ( + teamKey: string, + _teamId: string, + preloaded?: IssueCreatePreloadedData, + ) => { + return await promptProjectSelection(teamKey, preloaded?.projects) + }, + }, ] async function promptAdditionalFields( @@ -151,6 +281,7 @@ async function promptAdditionalFields( teamId: string, states: WorkflowState[], labels: IssueLabel[], + includeProject: boolean, autoAssignToSelf: boolean, ): Promise<{ assigneeId?: string @@ -158,6 +289,7 @@ async function promptAdditionalFields( estimate?: number labelIds: string[] stateId?: string + projectId?: string }> { // Build options that display defaults in parentheses for workflow state and assignee let defaultStateName: string | null = null @@ -166,7 +298,9 @@ async function promptAdditionalFields( states[0] defaultStateName = defaultState.name } - const additionalFieldOptions = ADDITIONAL_FIELDS.map((field) => { + const additionalFieldOptions = ADDITIONAL_FIELDS.filter((field) => + includeProject || field.key !== "project" + ).map((field) => { let name = field.label if (field.key === "workflow_state" && defaultStateName) { name = `${field.label} (${defaultStateName})` @@ -186,8 +320,9 @@ async function promptAdditionalFields( let estimate: number | undefined let labelIds: string[] = [] let stateId: string | undefined + let projectId: string | undefined - // Set assignee default based on user settings + // Set assignee default based on configuration if (autoAssignToSelf) { assigneeId = await lookupUserId("self") } @@ -196,9 +331,13 @@ async function promptAdditionalFields( for (const fieldKey of selectedFields) { const field = ADDITIONAL_FIELDS.find((f) => f.key === fieldKey) if (field) { + const projects = includeProject && fieldKey === "project" + ? await getProjectsForTeam(teamKey) + : undefined const value = await field.handler(teamKey, teamId, { states, labels, + projects, }) switch (fieldKey) { @@ -217,6 +356,9 @@ async function promptAdditionalFields( case "estimate": estimate = value as number | undefined break + case "project": + projectId = value as string | undefined + break } } } @@ -227,10 +369,12 @@ async function promptAdditionalFields( estimate, labelIds, stateId, + projectId, } } async function promptInteractiveIssueCreation( + initialProjectId?: string, parentId?: string, parentData?: { title: string @@ -250,20 +394,8 @@ async function promptInteractiveIssueCreation( parentId?: string projectId?: string | null }> { - // Start user settings and team resolution in background while asking for title - const userSettingsPromise = (async () => { - const client = getGraphQLClient() - const userSettingsQuery = gql(` - query GetUserSettings { - userSettings { - autoAssignToSelf - } - } - `) - const result = await client.request(userSettingsQuery) - return result.userSettings.autoAssignToSelf - })() - + const autoAssignToSelfPromise = + shouldAssignSelfByDefaultForInteractiveCreate() const teamResolutionPromise = (async () => { const defaultTeamKey = getTeamKey() if (defaultTeamKey) { @@ -295,9 +427,10 @@ async function promptInteractiveIssueCreation( minLength: 1, }) - // Await team resolution and user settings + // Await team resolution const teamResult = await teamResolutionPromise - const autoAssignToSelf = await userSettingsPromise + const autoAssignToSelf = await autoAssignToSelfPromise + const askProject = shouldAskProjectDuringInteractiveCreate() let teamId: string let teamKey: string @@ -332,6 +465,9 @@ async function promptInteractiveIssueCreation( // Preload team-scoped data (do not await yet) const workflowStatesPromise = getWorkflowStates(teamKey) const labelsPromise = getLabelsForTeam(teamKey) + const projectsPromise = (askProject && !parentData && !initialProjectId) + ? getProjectsForTeam(teamKey) + : Promise.resolve(undefined) // Description prompt const editorName = await getEditor() @@ -366,6 +502,12 @@ async function promptInteractiveIssueCreation( finalDescription = description.trim() } + let projectId = initialProjectId + const projects = await projectsPromise + if (!parentData && !initialProjectId && askProject) { + projectId = await promptProjectSelection(teamKey, projects) + } + // Now await the preloaded data and resolve default state const states = await workflowStatesPromise const labels = await labelsPromise @@ -391,7 +533,7 @@ async function promptInteractiveIssueCreation( let labelIds: string[] = [] let stateId: string | undefined - // Set assignee default based on user settings + // Set assignee default based on configuration if (autoAssignToSelf) { assigneeId = await lookupUserId("self") } @@ -407,6 +549,7 @@ async function promptInteractiveIssueCreation( teamId, states, labels, + !askProject && !parentData && !initialProjectId, autoAssignToSelf, ) @@ -416,6 +559,7 @@ async function promptInteractiveIssueCreation( estimate = additionalFieldsResult.estimate labelIds = additionalFieldsResult.labelIds stateId = additionalFieldsResult.stateId + projectId = additionalFieldsResult.projectId ?? projectId } // Ask about starting work (always show this) @@ -440,7 +584,7 @@ async function promptInteractiveIssueCreation( stateId, start, parentId, - projectId: parentData?.projectId || null, + projectId: projectId ?? parentData?.projectId ?? null, } } @@ -558,40 +702,24 @@ export const createCommand = new Command() } } - // If no flags are provided (or only parent is provided), use interactive mode - const noFlagsProvided = !title && !assignee && !dueDate && + // If no creation flags are provided beyond project/parent, use interactive mode. + const onlyInteractiveSeedFlagsProvided = !title && !assignee && + !dueDate && priority === undefined && estimate === undefined && !finalDescription && (!labels || labels.length === 0) && - !team && !project && !state && !milestone && !cycle && !start + !team && !state && !milestone && !cycle && !start - if (noFlagsProvided && interactive) { + if (onlyInteractiveSeedFlagsProvided && interactive) { try { - // Convert parent identifier if provided and fetch parent data - let parentId: string | undefined - let parentData: { - title: string - identifier: string - projectId: string | null - } | null = null - if (parentIdentifier) { - const parentIdentifierResolved = await getIssueIdentifier( - parentIdentifier, - ) - if (!parentIdentifierResolved) { - throw new ValidationError( - `Could not resolve parent issue identifier: ${parentIdentifier}`, - ) - } - parentId = await getIssueId(parentIdentifierResolved) - if (!parentId) { - throw new NotFoundError("Parent issue", parentIdentifierResolved) - } - - // Fetch parent issue data including project - parentData = await fetchParentIssueData(parentId) - } + const { parentId, parentData } = await resolveParentIssueForCreate( + parentIdentifier, + ) + const explicitProjectId = project == null + ? undefined + : await resolveProjectIdForCreate(project, interactive) const interactiveData = await promptInteractiveIssueCreation( + explicitProjectId, parentId, parentData, ) @@ -658,7 +786,7 @@ export const createCommand = new Command() "Title is required when not using interactive mode", { suggestion: - "Use --title or run without any flags (or only --parent) for interactive mode.", + "Use --title or run without any flags (or only --parent/--project) for interactive mode.", }, ) } @@ -708,6 +836,9 @@ export const createCommand = new Command() } let assigneeId = undefined + if (shouldAssignSelfByDefaultForFlagCreate()) { + assigneeId = await lookupUserId("self") + } if (assignee) { assigneeId = await lookupUserId(assignee) @@ -738,16 +869,7 @@ export const createCommand = new Command() } let projectId: string | undefined = undefined if (project !== undefined) { - projectId = await getProjectIdByName(project) - if (projectId === undefined && interactive) { - const projectIds = await getProjectOptionsByName(project) - spinner?.stop() - projectId = await selectOption("Project", project, projectIds) - spinner?.start() - } - if (projectId === undefined) { - throw new NotFoundError("Project", project) - } + projectId = await resolveProjectIdForCreate(project, interactive) } let projectMilestoneId: string | undefined @@ -774,30 +896,9 @@ export const createCommand = new Command() // Date validation done at graphql level - // Convert parent identifier if provided and fetch parent data - let parentId: string | undefined - let parentData: { - title: string - identifier: string - projectId: string | null - } | null = null - if (parentIdentifier) { - const parentIdentifierResolved = await getIssueIdentifier( - parentIdentifier, - ) - if (!parentIdentifierResolved) { - throw new ValidationError( - `Could not resolve parent issue identifier: ${parentIdentifier}`, - ) - } - parentId = await getIssueId(parentIdentifierResolved) - if (!parentId) { - throw new NotFoundError("Parent issue", parentIdentifierResolved) - } - - // Fetch parent issue data including project - parentData = await fetchParentIssueData(parentId) - } + const { parentId, parentData } = await resolveParentIssueForCreate( + parentIdentifier, + ) const input = { title, diff --git a/src/config.ts b/src/config.ts index ce51a422..3e7da784 100644 --- a/src/config.ts +++ b/src/config.ts @@ -137,6 +137,8 @@ const OptionsSchema = v.object({ api_key: v.optional(v.string()), workspace: v.optional(v.string()), issue_sort: v.optional(v.picklist(["manual", "priority"])), + issue_create_ask_project: v.optional(BooleanLike), + issue_create_assign_self: v.optional(v.picklist(["always", "auto", "never"])), vcs: v.optional(v.picklist(["git", "jj"])), download_images: v.optional(BooleanLike), hyperlink_format: v.optional(v.string()), diff --git a/src/utils/linear.ts b/src/utils/linear.ts index 4b373cd0..9adb2184 100644 --- a/src/utils/linear.ts +++ b/src/utils/linear.ts @@ -6,6 +6,7 @@ import type { GetIssueDetailsWithCommentsQuery, GetIssuesForQueryQuery, GetIssuesForStateQuery, + GetProjectsForTeamQuery, GetTeamMembersQuery, IssueFilter, IssueSortInput, @@ -1214,6 +1215,53 @@ export async function getProjectOptionsByName( return Object.fromEntries(qResults.map((t) => [t.id, t.name])) } +export async function getProjectsForTeam( + teamKey: string, +): Promise> { + 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 { diff --git a/test/commands/issue/issue-create.test.ts b/test/commands/issue/issue-create.test.ts index 17811fb6..fb1bba11 100644 --- a/test/commands/issue/issue-create.test.ts +++ b/test/commands/issue/issue-create.test.ts @@ -1,4 +1,7 @@ import { snapshotTest } from "@cliffy/testing" +import { assertEquals, assertStringIncludes } from "@std/assert" +import { Checkbox, Input, Select } from "@cliffy/prompt" +import { stub } from "@std/testing/mock" import { createCommand } from "../../../src/commands/issue/issue-create.ts" import { commonDenoArgs, @@ -423,3 +426,1098 @@ await snapshotTest({ } }, }) + +Deno.test("Issue Create Command - Explicit Project Still Uses Interactive Mode", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetUserSettings", + response: { + data: { + userSettings: { + autoAssignToSelf: false, + }, + }, + }, + }, + { + queryName: "GetProjectIdByName", + variables: { name: "Dashboard" }, + response: { + data: { + projects: { + nodes: [{ id: "project-123" }], + }, + }, + }, + }, + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + { + queryName: "GetWorkflowStates", + response: { + data: { + team: { + states: { + nodes: [], + }, + }, + }, + }, + }, + { + queryName: "GetLabelsForTeam", + response: { + data: { + team: { + labels: { + nodes: [], + }, + }, + }, + }, + }, + { + queryName: "CreateIssue", + variables: { + input: { + title: "Create dashboard issue", + labelIds: [], + teamId: "team-eng-id", + projectId: "project-123", + useDefaultTemplate: true, + }, + }, + response: { + data: { + issueCreate: { + success: true, + issue: { + id: "issue-interactive-project", + identifier: "ENG-901", + url: + "https://linear.app/test-team/issue/ENG-901/create-dashboard-issue", + team: { + key: "ENG", + }, + }, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + const terminalStub = stub( + Object.getPrototypeOf(Deno.stdout), + "isTerminal", + () => true, + ) + const inputStub = stub( + Input, + "prompt", + (options: string | { message: string }) => { + const message = typeof options === "string" ? options : options.message + if (message === "What's the title of your issue?") { + return Promise.resolve("Create dashboard issue") + } + if (message.startsWith("Description")) { + return Promise.resolve("") + } + throw new Error(`Unexpected Input.prompt call: ${message}`) + }, + ) + let selectCallCount = 0 + const selectStub = stub(Select, "prompt", (options: { message: string }) => { + selectCallCount += 1 + if (options.message === "What's next?") { + return Promise.resolve("submit") + } + if ( + options.message === + "Start working on this issue now? (creates branch and updates status)" + ) { + return Promise.resolve(false) + } + throw new Error(`Unexpected Select.prompt call: ${options.message}`) + }) + + try { + await createCommand.parse(["--project", "Dashboard"]) + assertEquals(selectCallCount, 2) + } finally { + selectStub.restore() + inputStub.restore() + terminalStub.restore() + await cleanup() + } +}) + +Deno.test("Issue Create Command - Interactive Project Prompt Uses Team Projects", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetUserSettings", + response: { + data: { + userSettings: { + autoAssignToSelf: false, + }, + }, + }, + }, + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + { + queryName: "GetProjectsForTeam", + response: { + data: { + projects: { + nodes: [{ id: "project-456", name: "Dashboard" }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + { + queryName: "GetWorkflowStates", + response: { + data: { + team: { + states: { + nodes: [], + }, + }, + }, + }, + }, + { + queryName: "GetLabelsForTeam", + response: { + data: { + team: { + labels: { + nodes: [], + }, + }, + }, + }, + }, + { + queryName: "CreateIssue", + variables: { + input: { + title: "Issue with prompted project", + labelIds: [], + teamId: "team-eng-id", + projectId: "project-456", + useDefaultTemplate: true, + }, + }, + response: { + data: { + issueCreate: { + success: true, + issue: { + id: "issue-project-prompt", + identifier: "ENG-902", + url: + "https://linear.app/test-team/issue/ENG-902/issue-with-prompted-project", + team: { + key: "ENG", + }, + }, + }, + }, + }, + }, + ], { + LINEAR_TEAM_ID: "ENG", + LINEAR_ISSUE_CREATE_ASK_PROJECT: "true", + }) + + const terminalStub = stub( + Object.getPrototypeOf(Deno.stdout), + "isTerminal", + () => true, + ) + const inputStub = stub( + Input, + "prompt", + (options: string | { message: string }) => { + const message = typeof options === "string" ? options : options.message + if (message === "What's the title of your issue?") { + return Promise.resolve("Issue with prompted project") + } + if (message.startsWith("Description")) { + return Promise.resolve("") + } + throw new Error(`Unexpected Input.prompt call: ${message}`) + }, + ) + const selectStub = stub(Select, "prompt", (options: { message: string }) => { + if (options.message === "Which project should this issue belong to?") { + return Promise.resolve("project-456") + } + if (options.message === "What's next?") { + return Promise.resolve("submit") + } + if ( + options.message === + "Start working on this issue now? (creates branch and updates status)" + ) { + return Promise.resolve(false) + } + throw new Error(`Unexpected Select.prompt call: ${options.message}`) + }) + + try { + await createCommand.parse([]) + } finally { + selectStub.restore() + inputStub.restore() + terminalStub.restore() + await cleanup() + } +}) + +Deno.test("Issue Create Command - Additional Fields Can Set Project", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetUserSettings", + response: { + data: { + userSettings: { + autoAssignToSelf: false, + }, + }, + }, + }, + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + { + queryName: "GetWorkflowStates", + response: { + data: { + team: { + states: { + nodes: [], + }, + }, + }, + }, + }, + { + queryName: "GetLabelsForTeam", + response: { + data: { + team: { + labels: { + nodes: [], + }, + }, + }, + }, + }, + { + queryName: "GetProjectsForTeam", + response: { + data: { + projects: { + nodes: [{ id: "project-789", name: "Dashboard" }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + { + queryName: "CreateIssue", + variables: { + input: { + title: "Issue from more fields", + labelIds: [], + teamId: "team-eng-id", + projectId: "project-789", + useDefaultTemplate: true, + }, + }, + response: { + data: { + issueCreate: { + success: true, + issue: { + id: "issue-more-fields", + identifier: "ENG-903", + url: + "https://linear.app/test-team/issue/ENG-903/issue-from-more-fields", + team: { + key: "ENG", + }, + }, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + const terminalStub = stub( + Object.getPrototypeOf(Deno.stdout), + "isTerminal", + () => true, + ) + const inputStub = stub( + Input, + "prompt", + (options: string | { message: string }) => { + const message = typeof options === "string" ? options : options.message + if (message === "What's the title of your issue?") { + return Promise.resolve("Issue from more fields") + } + if (message.startsWith("Description")) { + return Promise.resolve("") + } + throw new Error(`Unexpected Input.prompt call: ${message}`) + }, + ) + const checkboxStub = stub( + Checkbox, + "prompt", + (options: { message: string }) => { + if (options.message === "Select additional fields to configure") { + return Promise.resolve(["project"]) + } + throw new Error(`Unexpected Checkbox.prompt call: ${options.message}`) + }, + ) + const selectStub = stub(Select, "prompt", (options: { message: string }) => { + if (options.message === "What's next?") { + return Promise.resolve("more_fields") + } + if (options.message === "Which project should this issue belong to?") { + return Promise.resolve("project-789") + } + if ( + options.message === + "Start working on this issue now? (creates branch and updates status)" + ) { + return Promise.resolve(false) + } + throw new Error(`Unexpected Select.prompt call: ${options.message}`) + }) + + try { + await createCommand.parse([]) + } finally { + selectStub.restore() + checkboxStub.restore() + inputStub.restore() + terminalStub.restore() + await cleanup() + } +}) + +Deno.test("Issue Create Command - Inherits Parent Project When Project Not Set", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + { + queryName: "GetIssueId", + variables: { id: "ENG-123" }, + response: { + data: { + issue: { + id: "parent-1", + }, + }, + }, + }, + { + queryName: "GetParentIssueData", + variables: { id: "parent-1" }, + response: { + data: { + issue: { + title: "Parent issue", + identifier: "ENG-123", + project: { + id: "project-parent", + }, + }, + }, + }, + }, + { + queryName: "CreateIssue", + variables: { + input: { + title: "Child issue", + parentId: "parent-1", + labelIds: [], + teamId: "team-eng-id", + projectId: "project-parent", + useDefaultTemplate: true, + }, + }, + response: { + data: { + issueCreate: { + success: true, + issue: { + id: "child-issue", + identifier: "ENG-904", + url: "https://linear.app/test-team/issue/ENG-904/child-issue", + team: { + key: "ENG", + }, + }, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + try { + await createCommand.parse([ + "--title", + "Child issue", + "--team", + "ENG", + "--parent", + "ENG-123", + "--no-interactive", + ]) + } finally { + await cleanup() + } +}) + +Deno.test("Issue Create Command - Explicit Project Overrides Parent Project", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + { + queryName: "GetProjectIdByName", + variables: { name: "Dashboard" }, + response: { + data: { + projects: { + nodes: [{ id: "project-dashboard" }], + }, + }, + }, + }, + { + queryName: "GetIssueId", + variables: { id: "ENG-123" }, + response: { + data: { + issue: { + id: "parent-1", + }, + }, + }, + }, + { + queryName: "GetParentIssueData", + variables: { id: "parent-1" }, + response: { + data: { + issue: { + title: "Parent issue", + identifier: "ENG-123", + project: { + id: "project-parent", + }, + }, + }, + }, + }, + { + queryName: "CreateIssue", + variables: { + input: { + title: "Child issue override", + parentId: "parent-1", + labelIds: [], + teamId: "team-eng-id", + projectId: "project-dashboard", + useDefaultTemplate: true, + }, + }, + response: { + data: { + issueCreate: { + success: true, + issue: { + id: "child-issue-override", + identifier: "ENG-905", + url: + "https://linear.app/test-team/issue/ENG-905/child-issue-override", + team: { + key: "ENG", + }, + }, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + try { + await createCommand.parse([ + "--title", + "Child issue override", + "--team", + "ENG", + "--parent", + "ENG-123", + "--project", + "Dashboard", + "--no-interactive", + ]) + } finally { + await cleanup() + } +}) + +Deno.test("Issue Create Command - Invalid Parent Project Combination Surfaces Backend Error", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + { + queryName: "GetProjectIdByName", + variables: { name: "Dashboard" }, + response: { + data: { + projects: { + nodes: [{ id: "project-dashboard" }], + }, + }, + }, + }, + { + queryName: "GetIssueId", + variables: { id: "ENG-123" }, + response: { + data: { + issue: { + id: "parent-1", + }, + }, + }, + }, + { + queryName: "GetParentIssueData", + variables: { id: "parent-1" }, + response: { + data: { + issue: { + title: "Parent issue", + identifier: "ENG-123", + project: { + id: "project-parent", + }, + }, + }, + }, + }, + { + queryName: "CreateIssue", + response: { + errors: [{ + message: "Parent issue and project are incompatible", + }], + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + const errors: string[] = [] + const errorStub = stub(console, "error", (...args: unknown[]) => { + errors.push(args.map(String).join(" ")) + }) + const exitStub = stub(Deno, "exit", (_code?: number) => { + throw new Error("DENO_EXIT") + }) + + try { + let thrown: Error | undefined + try { + await createCommand.parse([ + "--title", + "Child issue override", + "--team", + "ENG", + "--parent", + "ENG-123", + "--project", + "Dashboard", + "--no-interactive", + ]) + } catch (error) { + thrown = error as Error + } + + assertEquals(thrown?.message, "DENO_EXIT") + assertStringIncludes( + errors.join("\n"), + "Parent issue and project are incompatible", + ) + } finally { + exitStub.restore() + errorStub.restore() + await cleanup() + } +}) + +Deno.test("Issue Create Command - Config Can Assign Self By Default", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + { + queryName: "GetViewerId", + response: { + data: { + viewer: { + id: "user-self-123", + }, + }, + }, + }, + { + queryName: "CreateIssue", + variables: { + input: { + title: "Assigned to self", + assigneeId: "user-self-123", + labelIds: [], + teamId: "team-eng-id", + useDefaultTemplate: true, + }, + }, + response: { + data: { + issueCreate: { + success: true, + issue: { + id: "issue-self-default", + identifier: "ENG-906", + url: + "https://linear.app/test-team/issue/ENG-906/assigned-to-self", + team: { + key: "ENG", + }, + }, + }, + }, + }, + }, + ], { + LINEAR_TEAM_ID: "ENG", + LINEAR_ISSUE_CREATE_ASSIGN_SELF: "always", + }) + + try { + await createCommand.parse([ + "--title", + "Assigned to self", + "--team", + "ENG", + "--no-interactive", + ]) + } finally { + await cleanup() + } +}) + +Deno.test("Issue Create Command - Auto Assign Mode Respects Linear User Setting In Interactive Create", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetUserSettings", + response: { + data: { + userSettings: { + autoAssignToSelf: true, + }, + }, + }, + }, + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + { + queryName: "GetWorkflowStates", + response: { + data: { + team: { + states: { + nodes: [], + }, + }, + }, + }, + }, + { + queryName: "GetLabelsForTeam", + response: { + data: { + team: { + labels: { + nodes: [], + }, + }, + }, + }, + }, + { + queryName: "GetViewerId", + response: { + data: { + viewer: { + id: "user-self-123", + }, + }, + }, + }, + { + queryName: "CreateIssue", + response: { + data: { + issueCreate: { + success: true, + issue: { + id: "issue-auto-assign", + identifier: "ENG-906A", + url: "https://linear.app/test-team/issue/ENG-906A/auto-assign", + team: { + key: "ENG", + }, + }, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + const terminalStub = stub( + Object.getPrototypeOf(Deno.stdout), + "isTerminal", + () => true, + ) + const inputStub = stub( + Input, + "prompt", + (options: string | { message: string }) => { + const message = typeof options === "string" ? options : options.message + if (message === "What's the title of your issue?") { + return Promise.resolve("Auto assign from Linear settings") + } + if (message.startsWith("Description")) { + return Promise.resolve("") + } + throw new Error(`Unexpected Input.prompt call: ${message}`) + }, + ) + const selectStub = stub(Select, "prompt", (options: { message: string }) => { + if (options.message === "What's next?") { + return Promise.resolve("submit") + } + if ( + options.message === + "Start working on this issue now? (creates branch and updates status)" + ) { + return Promise.resolve(false) + } + throw new Error(`Unexpected Select.prompt call: ${options.message}`) + }) + + try { + await createCommand.parse([]) + } finally { + selectStub.restore() + inputStub.restore() + terminalStub.restore() + await cleanup() + } +}) + +Deno.test("Issue Create Command - Explicit Assignee Overrides Config Self Assignment", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + { + queryName: "GetViewerId", + response: { + data: { + viewer: { + id: "user-self-123", + }, + }, + }, + }, + { + queryName: "LookupUser", + variables: { input: "Jane Developer" }, + response: { + data: { + users: { + nodes: [{ + id: "user-jane-456", + displayName: "Jane Developer", + email: "jane@example.com", + name: "Jane Developer", + }], + }, + }, + }, + }, + { + queryName: "CreateIssue", + variables: { + input: { + title: "Assigned explicitly", + assigneeId: "user-jane-456", + labelIds: [], + teamId: "team-eng-id", + useDefaultTemplate: true, + }, + }, + response: { + data: { + issueCreate: { + success: true, + issue: { + id: "issue-explicit-assignee", + identifier: "ENG-907", + url: + "https://linear.app/test-team/issue/ENG-907/assigned-explicitly", + team: { + key: "ENG", + }, + }, + }, + }, + }, + }, + ], { + LINEAR_TEAM_ID: "ENG", + LINEAR_ISSUE_CREATE_ASSIGN_SELF: "always", + }) + + try { + await createCommand.parse([ + "--title", + "Assigned explicitly", + "--team", + "ENG", + "--assignee", + "Jane Developer", + "--no-interactive", + ]) + } finally { + await cleanup() + } +}) + +Deno.test("Issue Create Command - Interactive Assignee Can Override Config Self Assignment", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + { + queryName: "GetWorkflowStates", + response: { + data: { + team: { + states: { + nodes: [], + }, + }, + }, + }, + }, + { + queryName: "GetLabelsForTeam", + response: { + data: { + team: { + labels: { + nodes: [], + }, + }, + }, + }, + }, + { + queryName: "GetViewerId", + response: { + data: { + viewer: { + id: "user-self-123", + }, + }, + }, + }, + { + queryName: "CreateIssue", + response: { + data: { + issueCreate: { + success: true, + issue: { + id: "issue-interactive-assignee-override", + identifier: "ENG-908", + url: + "https://linear.app/test-team/issue/ENG-908/interactive-assignee-override", + team: { + key: "ENG", + }, + }, + }, + }, + }, + }, + ], { + LINEAR_TEAM_ID: "ENG", + LINEAR_ISSUE_CREATE_ASSIGN_SELF: "always", + }) + + const terminalStub = stub( + Object.getPrototypeOf(Deno.stdout), + "isTerminal", + () => true, + ) + const inputStub = stub( + Input, + "prompt", + (options: string | { message: string }) => { + const message = typeof options === "string" ? options : options.message + if (message === "What's the title of your issue?") { + return Promise.resolve("Interactive assignee override") + } + if (message.startsWith("Description")) { + return Promise.resolve("") + } + throw new Error(`Unexpected Input.prompt call: ${message}`) + }, + ) + const checkboxStub = stub( + Checkbox, + "prompt", + (options: { message: string }) => { + if (options.message === "Select additional fields to configure") { + return Promise.resolve(["assignee"]) + } + throw new Error(`Unexpected Checkbox.prompt call: ${options.message}`) + }, + ) + const selectStub = stub(Select, "prompt", (options: { message: string }) => { + if (options.message === "What's next?") { + return Promise.resolve("more_fields") + } + if (options.message === "Assign this issue to yourself?") { + return Promise.resolve(false) + } + if ( + options.message === + "Start working on this issue now? (creates branch and updates status)" + ) { + return Promise.resolve(false) + } + throw new Error(`Unexpected Select.prompt call: ${options.message}`) + }) + + try { + await createCommand.parse([]) + } finally { + selectStub.restore() + checkboxStub.restore() + inputStub.restore() + terminalStub.restore() + await cleanup() + } +}) diff --git a/test/config.test.ts b/test/config.test.ts index dd031d74..6831ca3a 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -54,6 +54,38 @@ Deno.test("getOption - download_images returns undefined for unrecognized string assertEquals(result, undefined) }) +Deno.test("getOption - issue_create_ask_project returns boolean for truthy strings", () => { + const truthyValues = ["true", "yes", "1", "on", "t"] + + for (const value of truthyValues) { + const result = getOption("issue_create_ask_project", value) + assertEquals(result, true, `Expected "${value}" to coerce to true`) + } +}) + +Deno.test("getOption - issue_create_ask_project returns boolean for falsy strings", () => { + const falsyValues = ["false", "no", "0", "off", "f"] + + for (const value of falsyValues) { + const result = getOption("issue_create_ask_project", value) + assertEquals(result, false, `Expected "${value}" to coerce to false`) + } +}) + +Deno.test("getOption - issue_create_assign_self accepts valid mode values", () => { + const validValues = ["always", "auto", "never"] as const + + for (const value of validValues) { + const result = getOption("issue_create_assign_self", value) + assertEquals(result, value) + } +}) + +Deno.test("getOption - issue_create_assign_self rejects invalid mode values", () => { + const result = getOption("issue_create_assign_self", "true") + assertEquals(result, undefined) +}) + Deno.test("getOption - environment variables take precedence over config file", async () => { // Create a temp directory with a config file const tempDir = await Deno.makeTempDir()