diff --git a/main.ts b/main.ts index 394967f5..b9e53151 100644 --- a/main.ts +++ b/main.ts @@ -194,29 +194,384 @@ function isValidLinearId(id: string): boolean { return /^[A-Za-z0-9]+-[1-9][0-9]*$/.test(id); } -async function getIssueId(providedId?: string): Promise { - if (providedId) { - if (!isValidLinearId(providedId)) { - console.error(`Invalid Linear issue ID format: ${providedId}`); - console.error("Issue IDs should look like: ABC-123"); - Deno.exit(1); - } +async function getProjectUidByName( + name: string, +): Promise { + const data = await fetchGraphQL( + `query match($name: String!) { + projects(filter: {name: {eq: $name}}) {nodes{id}} + }`, + { name }, + ); + return data.data.projects?.nodes[0]?.id; +} + +async function getProjectUidOptionsByName( + name: string, +): Promise> { + const data = await fetchGraphQL( + `query match($name: String!) { + projects(filter: {name: {containsIgnoreCase: $name}}) {nodes{id, name}} + }`, + { name }, + ); + const qResults: { id: string; name: string }[] = data.data.projects?.nodes || + []; + return Object.fromEntries(qResults.map((t) => [t.id, t.name])); +} + +async function getIssueIdByTitle( + title: string, +): Promise { + const data = await fetchGraphQL( + `query match($title: String!) { + issues(filter: {title: {eq: $title}}) {nodes{identifier}} + }`, + { title }, + ); + return data.data.issues?.nodes[0]?.identifier; +} + +async function getIssueUidByIdentifier( + identifier: string, +): Promise { + const data = await fetchGraphQL( + `query match($identifier: String!) { + issues(filter: {identifier: {eq: $identifier}}) {nodes{id}} + }`, + { identifier }, + ); + return data.data.issues?.nodes[0]?.id; +} + +async function getIssueUidOptionsByTitle( + title: string, +): Promise> { + const data = await fetchGraphQL( + `query match($title: String!) { + issues(filter: {title: {containsIgnoreCase: $title}}) {nodes{id, identifier, title}} + }`, + { title }, + ); + const qResults: { id: string; identifier: string; title: string }[] = + data.data.issues?.nodes || []; + return Object.fromEntries( + qResults.map((t) => [t.id, `${t.identifier}: ${t.title}`]), + ); +} + +async function getIssueId( + providedId?: string, + allowByTitle = false, +): Promise { + if (providedId && isValidLinearId(providedId)) { return providedId.toUpperCase(); } - const branch = await getCurrentBranch(); - if (!branch) return null; - const match = branch.match(/[a-zA-Z0-9]+-[1-9][0-9]*/i); - return match ? match[0].toUpperCase() : null; + if (providedId === undefined) { + // look in branch + const branch = await getCurrentBranch(); + if (!branch) return undefined; + const match = branch.match(/[a-zA-Z0-9]+-[1-9][0-9]*/i); + if (match) { + return match[0].toUpperCase(); + } + } + if (allowByTitle && providedId) { + const issueId = await getIssueIdByTitle(providedId); + if (issueId) { + return issueId; + } + } } -async function getTeamId(): Promise { +async function getTeamId(): Promise { const teamId = getOption("team_id"); if (teamId) { return teamId.toUpperCase(); } const dir = await getRepoDir(); const match = dir.match(/^[a-zA-Z0-9]+/); - return match ? match[0].toUpperCase() : null; + return match ? match[0].toUpperCase() : undefined; +} + +async function getTeamUidByKey( + team: string, +): Promise { + const data = await fetchGraphQL( + `query match($team: String!) { + teams(filter: {key: {eq: $team}}) {nodes{id}} + }`, + { team }, + ); + return data.data.teams?.nodes[0]?.id; +} + +async function getTeamUidByName( + team: string, +): Promise { + const data = await fetchGraphQL( + `query match($team: String!) { + teams(filter: {name: {eq: $team}}) {nodes{id}} + }`, + { team }, + ); + return data.data.teams?.nodes[0]?.id; +} + +async function getTeamUid( + team: string | undefined = undefined, + tryDisplayName: boolean = true, + tryKey: boolean = true, +): Promise { + team = team || await getTeamId() || undefined; + if (team === undefined) return undefined; + if (tryKey) { + const teamId = await getTeamUidByKey(team); + if (teamId) return teamId; + } + if (tryDisplayName) { + const teamId = await getTeamUidByName(team); + if (teamId) return teamId; + } +} + +async function getTeamUidOptionsByKey( + team: string, +): Promise> { + const data = await fetchGraphQL( + `query match($team: String!) { + teams(filter: {key: {containsIgnoreCase: $team}}) {nodes{id, key}} + }`, + { team }, + ); + const qResults: { id: string; key: string }[] = data.data.teams?.nodes || []; + return Object.fromEntries(qResults.map((t) => [t.id, t.key])); +} + +async function getTeamUidOptionsByName( + team: string, +): Promise> { + const data = await fetchGraphQL( + `query match($team: String!) { + teams(filter: {name: {containsIgnoreCase: $team}}) {nodes{id, key, name}} + }`, + { team }, + ); + const qResults: { id: string; name: string; key: string }[] = + data.data.teams?.nodes || []; + return Object.fromEntries(qResults.map((t) => [t.id, `${t.key}: ${t.name}`])); +} + +async function getTeamUidOptions( + team: string, + tryName: boolean = true, + tryKey: boolean = true, +): Promise> { + let results: Record = {}; + if (tryKey) { + results = await getTeamUidOptionsByKey(team); + } + if (tryName) { + results = { + ...results, + ...await getTeamUidOptionsByName(team), + }; + } + return results; +} + +async function getUserUidByDisplayName( + username: string, +): Promise { + const data = await fetchGraphQL( + `query match($username: String!) { + users(filter: {displayName: {eq: $username}}) {nodes{id}} + }`, + { username }, + ); + return data.data.users?.nodes[0]?.id; +} + +async function getUserUidOptionsByDisplayName( + name: string, +): Promise> { + const data = await fetchGraphQL( + `query match($name: String!) { + users(filter: {displayName: {containsIgnoreCase: $name}}) {nodes{id, displayName}} + }`, + { name }, + ); + const qResults: { id: string; displayName: string }[] = + data.data.users?.nodes || []; + return Object.fromEntries(qResults.map((t) => [t.id, t.displayName])); +} + +async function getUserUidOptionsByName( + name: string, +): Promise> { + const data = await fetchGraphQL( + `query match($name: String!) { + users(filter: {name: {containsIgnoreCase: $name}}) {nodes{id, name}} + }`, + { name }, + ); + const qResults: { id: string; name: string }[] = data.data.users?.nodes || []; + return Object.fromEntries(qResults.map((t) => [t.id, t.name])); +} + +async function getUserUidOptions( + name: string, + tryDisplayName: boolean = true, + tryName: boolean = true, +): Promise> { + let results: Record = {}; + if (tryDisplayName) { + results = await getUserUidOptionsByDisplayName(name); + } + if (tryName) { + results = { + ...results, + ...await getUserUidOptionsByName(name), + }; + } + return results; +} + +async function getUserId( + username: string | undefined, +): Promise { + if (username === undefined || username === "self") { + const data = await fetchGraphQL( + `query viewerId { + viewer {id} + }`, + {}, + ); + return data.data.viewer.id; + } else { + return await getUserUidByDisplayName(username); + } +} + +async function getIssueLabelUidByName( + name: string, +): Promise { + const data = await fetchGraphQL( + `query match($name: String!) { + issueLabels(filter: {name: {eq: $name}}) {nodes{id}} + }`, + { name }, + ); + return data.data.issueLabels?.nodes[0]?.id; +} + +async function getIssueLabelUidOptionsByName( + name: string, +): Promise> { + const data = await fetchGraphQL( + `query match($name: String!) { + issueLabels(filter: {name: {containsIgnoreCase: $name}}) {nodes{id, name}} + }`, + { name }, + ); + const qResults: { id: string; name: string }[] = + data.data.issueLabels?.nodes || []; + return Object.fromEntries(qResults.map((t) => [t.id, t.name])); +} + +async function selectOption( + dataName: string, + originalValue: string, + options: Record, +): Promise { + const NO = Object(); + const keys = Object.keys(options); + if (keys.length === 0) { + return undefined; + } else if (keys.length === 1) { + const key = keys[0]; + const result = await Select.prompt({ + message: `${dataName} named ${originalValue} does not exist, but ${ + options[key] + } exists. Is this what you meant?`, + options: [ + { name: "yes", value: key }, + { name: "no", value: NO }, + ], + }); + return result === NO ? undefined : result; + } else { + const result = await Select.prompt({ + message: + `${dataName} with ${originalValue} does not exist, but the following exist. Is any of these what you meant?`, + options: [ + ...Object.entries(options).map(([value, name]: [string, string]) => ({ + name, + value, + })), + { name: "none of the above", value: NO }, + ], + }); + return result === NO ? undefined : result; + } +} + +async function doStartIssue(issueId: string, teamId: string) { + const { branchName } = await fetchIssueDetails(issueId, true); + + // Check if branch exists + if (await branchExists(branchName)) { + const answer = await Select.prompt({ + message: + `Branch ${branchName} already exists. What would you like to do?`, + options: [ + { name: "Switch to existing branch", value: "switch" }, + { name: "Create new branch with suffix", value: "create" }, + ], + }); + + if (answer === "switch") { + const process = new Deno.Command("git", { + args: ["checkout", branchName], + }); + await process.output(); + console.log(`✓ Switched to '${branchName}'`); + } else { + // Find next available suffix + let suffix = 1; + let newBranch = `${branchName}-${suffix}`; + while (await branchExists(newBranch)) { + suffix++; + newBranch = `${branchName}-${suffix}`; + } + + const process = new Deno.Command("git", { + args: ["checkout", "-b", newBranch], + }); + await process.output(); + console.log(`✓ Created and switched to branch '${newBranch}'`); + } + } else { + // Create and checkout the branch + const process = new Deno.Command("git", { + args: ["checkout", "-b", branchName], + }); + await process.output(); + console.log(`✓ Created and switched to branch '${branchName}'`); + } + + // Update issue state + try { + const state = await getStartedState(teamId); + if (!issueId) { + console.error("No issue ID resolved"); + Deno.exit(1); + } + await updateIssueState(issueId, state.id); + console.log(`✓ Issue state updated to '${state.name}'`); + } catch (error) { + console.error("Failed to update issue state:", error); + } } export async function fetchGraphQL( @@ -531,7 +886,8 @@ const issueCommand = new Command() .alias("p") .arguments("[issueId:string]") .option("--no-color", "Disable colored output") - .action(async ({ color }, issueId) => { + .option("--no-interactive", "Disable interactive prompts") + .action(async ({ color, interactive }, issueId) => { console.error( "Warning: 'linear issue print' is deprecated and will be removed in a future release.", ); @@ -544,7 +900,7 @@ const issueCommand = new Command() Deno.exit(1); } - const showSpinner = color && Deno.stdout.isTerminal(); + const showSpinner = color && interactive && Deno.stdout.isTerminal(); const { title, description } = await fetchIssueDetails( resolvedId, showSpinner, @@ -612,7 +968,7 @@ const issueCommand = new Command() try { const result = await fetchIssuesForState(teamId, state); - const issues = result.data.issues.nodes; + const issues = result.data.issues?.nodes || []; if (issues.length === 0) { console.log("No unstarted issues found."); @@ -792,14 +1148,13 @@ const issueCommand = new Command() .command("start", "Start working on an issue") .arguments("[issueId:string]") .action(async (_, issueId) => { + const teamId = await getTeamId(); + if (!teamId) { + console.error("Could not determine team ID"); + Deno.exit(1); + } let resolvedId = await getIssueId(issueId); if (!resolvedId) { - const teamId = await getTeamId(); - if (!teamId) { - console.error("Could not determine team ID"); - Deno.exit(1); - } - try { const result = await fetchIssuesForState(teamId, "unstarted"); const issues = result.data.issues.nodes; @@ -827,71 +1182,11 @@ const issueCommand = new Command() } } - const teamId = await getTeamId(); - if (!teamId) { - console.error("Could not determine team ID"); - Deno.exit(1); - } - if (!resolvedId) { console.error("No issue ID resolved"); Deno.exit(1); } - const { branchName } = await fetchIssueDetails(resolvedId, true); - - // Check if branch exists - if (await branchExists(branchName)) { - const answer = await Select.prompt({ - message: - `Branch ${branchName} already exists. What would you like to do?`, - options: [ - { name: "Switch to existing branch", value: "switch" }, - { name: "Create new branch with suffix", value: "create" }, - ], - }); - - if (answer === "switch") { - const process = new Deno.Command("git", { - args: ["checkout", branchName], - }); - await process.output(); - console.log(`✓ Switched to '${branchName}'`); - } else { - // Find next available suffix - let suffix = 1; - let newBranch = `${branchName}-${suffix}`; - while (await branchExists(newBranch)) { - suffix++; - newBranch = `${branchName}-${suffix}`; - } - - const process = new Deno.Command("git", { - args: ["checkout", "-b", newBranch], - }); - await process.output(); - console.log(`✓ Created and switched to branch '${newBranch}'`); - } - } else { - // Create and checkout the branch - const process = new Deno.Command("git", { - args: ["checkout", "-b", branchName], - }); - await process.output(); - console.log(`✓ Created and switched to branch '${branchName}'`); - } - - // Update issue state - try { - const state = await getStartedState(teamId); - if (!resolvedId) { - console.error("No issue ID resolved"); - Deno.exit(1); - } - await updateIssueState(resolvedId, state.id); - console.log(`✓ Issue state updated to '${state.name}'`); - } catch (error) { - console.error("Failed to update issue state:", error); - } + await doStartIssue(resolvedId, teamId); }) .command("view", "View issue details (default) or open in browser/app") .alias("v") @@ -985,7 +1280,230 @@ const issueCommand = new Command() console.error("Failed to create pull request"); Deno.exit(1); } - }); + }) + .command("create", "Create a linear issue") + .alias("cr") + .option( + "-s, --start", + "Start the issue after creation", + ) + .option( + "-a, --assignee ", + "Assign the issue to 'self' or someone (by username or name)", + ) + .option( + "-d, --dueDate ", + "Due date of the issue", + ) + .option( + "-p, --parent ", + "Parent issue (if any) as a team_number code", + ) + .option( + "--dry-run", + "Dry run: do not create the issue, print the mutation", + ) + .option( + "--priority ", + "Priority of the issue (1-4, descending priority)", + ) + .option( + "--estimate ", + "Points estimate of the issue", + ) + .option( + "-l, --labels [labels...:string]", + "Issue labels associated with the issue. May be repeated.", + ) + .option( + "--team ", + "Team associated with the issue (if not your default team)", + ) + .option( + "--project ", + "Name of the project with the issue", + ) + .option( + "--no-use-default-template", + "Do not use default template for the issue", + ) + .option("--no-color", "Disable colored output") + .option("--no-interactive", "Disable interactive prompts") + .arguments("[title:string]") + .action( + async ( + { + start, + assignee, + dueDate, + useDefaultTemplate, + parent, + priority, + estimate, + labels, + team, + project, + dryRun, + color, + interactive, + }, + title, + ) => { + interactive = interactive && Deno.stdout.isTerminal(); + const showSpinner = color && interactive; + const spinner = showSpinner ? new Spinner() : null; + spinner?.start(); + try { + team = (team === undefined) + ? (await getTeamId() || undefined) + : team.toUpperCase(); + let teamUid: string | undefined = undefined; + if (team !== undefined) { + teamUid = await getTeamUid(team); + if (interactive && !teamUid) { + const teamUids = await getTeamUidOptions(team); + spinner?.stop(); + teamUid = await selectOption("Team", team, teamUids); + spinner?.start(); + } + } + if (!teamUid) { + console.error(`Could not determine team ID for team ${team}`); + Deno.exit(1); + } + if (start && assignee === undefined) { + assignee = "self"; + } + if (start && assignee !== undefined && assignee !== "self") { + console.error("Cannot use --start and a non-self --assignee"); + } + let assigneeId = await getUserId(assignee); + if (!assigneeId && assignee !== undefined) { + if (interactive) { + const assigneeIds = await getUserUidOptions(assignee); + spinner?.stop(); + assigneeId = await selectOption("User", assignee, assigneeIds); + spinner?.start(); + } + if (!assigneeId) { + console.error( + `Could not determine user ID for assignee ${assignee}`, + ); + Deno.exit(1); + } + } + const labelIds: string[] = []; + if (labels !== undefined && labels !== true && labels.length > 0) { + // sequential in case of questions + for (const label of labels) { + let labelId = await getIssueLabelUidByName(label); + if (!labelId && interactive) { + const labelIds = await getIssueLabelUidOptionsByName(label); + spinner?.stop(); + labelId = await selectOption("Issue label", label, labelIds); + spinner?.start(); + } + if (!labelId) { + console.error( + `Could not determine ID for issue label ${label}`, + ); + Deno.exit(1); + } + labelIds.push(labelId); + } + } + let projectId: string | undefined = undefined; + if (project !== undefined) { + projectId = await getProjectUidByName(project); + if (projectId === undefined && interactive) { + const projectIds = await getProjectUidOptionsByName(project); + spinner?.stop(); + projectId = await selectOption("Project", project, projectIds); + spinner?.start(); + } + if (projectId === undefined) { + console.error(`Could not determine ID for project ${project}`); + Deno.exit(1); + } + } + let parentUid: string | undefined = undefined; + if (parent !== undefined) { + const parentId = await getIssueId(parent, true); + if (parentId) { + parentUid = await getIssueUidByIdentifier(parentId); + } + if (parentId === undefined && interactive) { + const parentUids = await getIssueUidOptionsByTitle(parent); + spinner?.stop(); + parentUid = await selectOption("Parent issue", parent, parentUids); + spinner?.start(); + } + if (parentUid === undefined) { + console.error(`Could not determine ID for issue ${parent}`); + Deno.exit(1); + } + } + // Date validation done at graphql level + + const query = ` + mutation createIssue($title: String!, $assigneeId: String, $dueDate: TimelessDate, $parentId: String, $priority: Int, $estimate: Int, $labelIds: [String!], $teamId: String!, $projectId: String, $useDefaultTemplate: Boolean) { + issueCreate(input: { + title: $title + assigneeId: $assigneeId + dueDate: $dueDate + parentId: $parentId + priority: $priority + estimate: $estimate + labelIds: $labelIds + teamId: $teamId + projectId: $projectId + useDefaultTemplate: $useDefaultTemplate + }) { + success + issue { id, team { key } } + } + } + `; + const input = { + title, + assigneeId, + dueDate, + parentId: parentUid, + priority, + estimate, + labelIds, + teamId: teamUid, + projectId, + useDefaultTemplate, + }; + if (dryRun) { + spinner?.stop(); + console.log(input); + return; + } + const data = await fetchGraphQL( + `mutation createIssue($input: IssueCreateInput!) { + issueCreate(input: $input) { + success + issue { id, team { key } } + }}`, + { input }, + ); + if (!data.data.issueCreate.success) { + throw "query failed"; + } + const issueId = data.data.issueCreate.issue.id; + if (start) { + await doStartIssue(issueId, data.data.issueCreate.issue.team.key); + } + spinner?.stop(); + } catch (error) { + spinner?.stop(); + console.error("✗ Failed to create issue", error); + Deno.exit(1); + } + }, + ); await new Command() .name("linear")