diff --git a/images/icons/dark/plus_nav.png b/images/icons/dark/plus_nav.png new file mode 100644 index 0000000..e0b7bb5 Binary files /dev/null and b/images/icons/dark/plus_nav.png differ diff --git a/images/icons/light/plus_nav.png b/images/icons/light/plus_nav.png new file mode 100644 index 0000000..9d594cf Binary files /dev/null and b/images/icons/light/plus_nav.png differ diff --git a/package.json b/package.json index c83cfd5..3c95620 100644 --- a/package.json +++ b/package.json @@ -174,6 +174,14 @@ "light": "images/icons/light/refresh.svg", "dark": "images/icons/dark/refresh.svg" } + }, + { + "command": "jira-plugin.createIssueCommand", + "title": "Create issue", + "icon": { + "light": "images/icons/light/plus_nav.png", + "dark": "images/icons/dark/plus_nav.png" + } } ], "menus": { @@ -210,6 +218,11 @@ "command": "jira-plugin.refresh", "when": "view == jiraExplorer", "group": "navigation" + }, + { + "command": "jira-plugin.createIssueCommand", + "when": "view == jiraExplorer", + "group": "navigation" } ], "view/item/context": [ diff --git a/src/commands/change-issue-assignee.ts b/src/commands/change-issue-assignee.ts index da6a8c9..6010f9d 100644 --- a/src/commands/change-issue-assignee.ts +++ b/src/commands/change-issue-assignee.ts @@ -13,10 +13,10 @@ export class ChangeIssueAssigneeCommand implements Command { let issue = issueItem.issue; // verify if it's the current working issue if (!isWorkingIssue(issue.key)) { - let assignee = await selectAssignee(false, false); + let assignee = await selectAssignee(false, false, true, undefined); if (!!assignee) { // call Jira API - const res = await state.jira.setAssignIssue({ issueKey: issue.key, assignee: assignee }); + const res = await state.jira.setAssignIssue({ issueKey: issue.key, assignee: assignee }); await vscode.commands.executeCommand('jira-plugin.refresh'); } } diff --git a/src/commands/create-issue.model.ts b/src/commands/create-issue.model.ts new file mode 100644 index 0000000..2a38a96 --- /dev/null +++ b/src/commands/create-issue.model.ts @@ -0,0 +1,60 @@ +import { IAssignee, IIssueType, IPriority } from '../http/api.model'; + +// statuses for new issue loop +export const NEW_ISSUE_STATUS = { + STOP: -1, + CONTINUE: 0, + INSERT: 1 +}; + +export interface INewIssue { + type: IIssueType | undefined; + summary: string | undefined; + description: string | undefined; + assignee: IAssignee | string | undefined; + priority: IPriority | undefined; +} + +// items available inside the selector +export const NEW_ISSUE_FIELDS = { + TYPE: { + field: 'type', + label: 'Type * :', + description: 'Select type' + }, + SUMMARY: { + field: 'summary', + label: 'Summary * :', + description: 'Insert summary' + }, + DESCRIPTION: { + field: 'description', + label: 'Description * :', + description: 'Insert description' + }, + ASSIGNEE: { + field: 'assignee', + label: 'Assignee :', + description: 'Select assignee' + }, + PRIORITY: { + field: 'priority', + label: 'Priority :', + description: 'Select priority' + }, + DIVIDER: { + field: 'divider', + label: '--- * required ---', + description: '' + }, + INSERT_ISSUE: { + field: 'insert_issue', + label: 'Insert issue', + description: '' + }, + EXIT: { + field: 'exit', + label: 'Exit', + description: '' + } +}; diff --git a/src/commands/create-issue.ts b/src/commands/create-issue.ts new file mode 100644 index 0000000..ecbcb2f --- /dev/null +++ b/src/commands/create-issue.ts @@ -0,0 +1,201 @@ +import * as vscode from 'vscode'; +import { IssueItem } from '../explorer/item/issue-item'; +import { IAssignee, ICreateIssue, IIssueType, IPriority } from '../http/api.model'; +import { getConfigurationByKey } from '../shared/configuration'; +import { CONFIG, MAX_RESULTS } from '../shared/constants'; +import { selectAssignee, selectIssueType } from '../shared/select-utilities'; +import state, { printErrorMessageInOutput, verifyCurrentProject } from '../state/state'; +import { INewIssue, NEW_ISSUE_FIELDS, NEW_ISSUE_STATUS } from './create-issue.model'; +import { OpenIssueCommand } from './open-issue'; +import { Command } from './shared/command'; + +export class CreateIssueCommand implements Command { + public id = 'jira-plugin.createIssueCommand'; + + public async run(issueItem: IssueItem): Promise { + const project = getConfigurationByKey(CONFIG.WORKING_PROJECT) || ''; + if (verifyCurrentProject(project)) { + try { + // load once the options + const assignees = await state.jira.getAssignees({ project, maxResults: MAX_RESULTS }); + const priorities = await state.jira.getAllPriorities(); + const types = await state.jira.getAllIssueTypes(); + + // instance for keep data + const newIssue: INewIssue = { + type: undefined, + summary: undefined, + description: undefined, + assignee: undefined, + priority: undefined + }; + let status = NEW_ISSUE_STATUS.CONTINUE; + while (status === NEW_ISSUE_STATUS.CONTINUE) { + // genearte/update new issue selector + const createIssuePicks = generateNewIssuePicks(newIssue, priorities && priorities.length > 0); + const selected = await vscode.window.showQuickPick(createIssuePicks, { + placeHolder: `Insert Jira issue`, + matchOnDescription: true + }); + + // manage the selected field + if (!!selected && selected.field !== NEW_ISSUE_FIELDS.DIVIDER.field) { + if (selected.field !== NEW_ISSUE_FIELDS.INSERT_ISSUE.field && selected.field !== NEW_ISSUE_FIELDS.EXIT.field) { + await manageSelectedField(selected, newIssue, types, assignees, priorities); + } else { + if (mandatoryFieldsOk(newIssue)) { + status = selected.field === NEW_ISSUE_FIELDS.INSERT_ISSUE.field ? NEW_ISSUE_STATUS.INSERT : NEW_ISSUE_STATUS.STOP; + } + } + } + } + // insert + if (status === NEW_ISSUE_STATUS.INSERT) { + await insertNewTicket(newIssue); + } else { + // console.log(`Exit`); + } + } catch (err) { + printErrorMessageInOutput(err); + } + } + } +} + +const generateNewIssuePicks = (newIssue: INewIssue, addPriority: boolean) => { + const picks = [ + { + field: NEW_ISSUE_FIELDS.TYPE.field, + label: NEW_ISSUE_FIELDS.TYPE.label, + description: !!newIssue.type ? newIssue.type.name : NEW_ISSUE_FIELDS.TYPE.description + }, + { + field: NEW_ISSUE_FIELDS.SUMMARY.field, + label: NEW_ISSUE_FIELDS.SUMMARY.label, + description: newIssue.summary || NEW_ISSUE_FIELDS.SUMMARY.description + }, + { + field: NEW_ISSUE_FIELDS.DESCRIPTION.field, + label: NEW_ISSUE_FIELDS.DESCRIPTION.label, + description: newIssue.description || NEW_ISSUE_FIELDS.DESCRIPTION.description + }, + { + field: NEW_ISSUE_FIELDS.ASSIGNEE.field, + label: NEW_ISSUE_FIELDS.ASSIGNEE.label, + description: !!newIssue.assignee ? (newIssue.assignee).name : NEW_ISSUE_FIELDS.ASSIGNEE.description + }, + { + field: NEW_ISSUE_FIELDS.DIVIDER.field, + label: NEW_ISSUE_FIELDS.DIVIDER.label, + description: NEW_ISSUE_FIELDS.DIVIDER.description + }, + { + field: NEW_ISSUE_FIELDS.INSERT_ISSUE.field, + label: NEW_ISSUE_FIELDS.INSERT_ISSUE.label, + description: NEW_ISSUE_FIELDS.INSERT_ISSUE.description + }, + { + field: NEW_ISSUE_FIELDS.EXIT.field, + label: NEW_ISSUE_FIELDS.EXIT.label, + description: NEW_ISSUE_FIELDS.EXIT.description + } + ]; + + if (addPriority) { + picks.splice(4, 0, { + field: NEW_ISSUE_FIELDS.PRIORITY.field, + label: NEW_ISSUE_FIELDS.PRIORITY.label, + description: !!newIssue.priority ? newIssue.priority.name : NEW_ISSUE_FIELDS.PRIORITY.description + }); + } + return picks; +}; + +const mandatoryFieldsOk = (newIssue: INewIssue) => { + return !!newIssue.summary && !!newIssue.description && !!newIssue.type; +}; + +const insertNewTicket = async (newIssue: INewIssue) => { + const project = getConfigurationByKey(CONFIG.WORKING_PROJECT); + if (mandatoryFieldsOk(newIssue)) { + let request = { + fields: { + project: { + key: project + }, + summary: newIssue.summary, + description: newIssue.description, + issuetype: { + id: (newIssue.type).id + } + } + } as ICreateIssue; + // adding assignee + if (!!newIssue.assignee) { + request.fields = { + ...request.fields, + assignee: { + key: (newIssue.assignee).key + } + }; + } + // adding priority + if (!!newIssue.priority) { + request.fields = { + ...request.fields, + priority: { + id: newIssue.priority.id + } + }; + } + const createdIssue = await state.jira.createIssue(request); + if (!!createdIssue && !!createdIssue.key) { + // if the response is ok, we will open the created issue + const action = await vscode.window.showInformationMessage('Issue created', 'Open in browser'); + if (action === 'Open in browser') { + new OpenIssueCommand().run(createdIssue.key); + } + } + } +}; + +const manageSelectedField = async ( + selected: any, + newIssue: INewIssue, + types: IIssueType[], + assignees: IAssignee[], + priorities: IPriority[] +) => { + switch (selected.field) { + case NEW_ISSUE_FIELDS.SUMMARY.field: + case NEW_ISSUE_FIELDS.DESCRIPTION.field: + (newIssue)[selected.field] = await vscode.window.showInputBox({ + ignoreFocusOut: true, + placeHolder: '' + }); + break; + case NEW_ISSUE_FIELDS.TYPE.field: + newIssue.type = await selectIssueType(true, types); + break; + case NEW_ISSUE_FIELDS.ASSIGNEE.field: + newIssue.assignee = await selectAssignee(false, false, false, assignees); + break; + case NEW_ISSUE_FIELDS.PRIORITY.field: + { + const priorityPicks = (priorities || []).map((priority: IPriority) => { + return { + pickValue: priority, + label: priority.id, + description: priority.name + }; + }); + const selected = await vscode.window.showQuickPick(priorityPicks, { + matchOnDescription: true, + matchOnDetail: true, + placeHolder: 'Select an issue' + }); + newIssue.priority = selected ? selected.pickValue : undefined; + } + break; + } +}; diff --git a/src/commands/issue-add-comment.ts b/src/commands/issue-add-comment.ts index 6f1fe3f..97399dd 100644 --- a/src/commands/issue-add-comment.ts +++ b/src/commands/issue-add-comment.ts @@ -21,7 +21,7 @@ export class IssueAddCommentCommand implements Command { // ask for assignee if there is one or more [@] in the comment const num = (text.match(new RegExp('[@]', 'g')) || []).length; for (let i = 0; i < num; i++) { - const assignee = await selectAssignee(false, false); + const assignee = await selectAssignee(false, false, true, undefined); if (!!assignee) { text = text.replace('[@]', `[~${assignee}]`); } else { diff --git a/src/extension.ts b/src/extension.ts index 5c18ec9..27313eb 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,8 +1,10 @@ import * as vscode from 'vscode'; import { ChangeIssueAssigneeCommand } from './commands/change-issue-assignee'; import { ChangeIssueStatusCommand } from './commands/change-issue-status'; +import { CreateIssueCommand } from './commands/create-issue'; import { IssueAddCommentCommand } from './commands/issue-add-comment'; import { IssueAddWorklogCommand } from './commands/issue-add-worklog'; +import { OpenGitHubRepoCommand } from './commands/open-github-repo'; import { OpenIssueCommand } from './commands/open-issue'; import { SetWorkingIssueCommand } from './commands/set-working-issue'; import { SetWorkingProjectCommand } from './commands/set-working-project'; @@ -13,7 +15,6 @@ import { IssueLinkProvider } from './shared/document-link-provider'; import { selectIssue } from './shared/select-utilities'; import { StatusBarManager } from './shared/status-bar'; import state, { connectToJira } from './state/state'; -import { OpenGitHubRepoCommand } from './commands/open-github-repo'; let channel: vscode.OutputChannel; @@ -42,15 +43,22 @@ export const activate = async (context: vscode.ExtensionContext): Promise new OpenGitHubRepoCommand(), new OpenIssueCommand(), new SetWorkingIssueCommand(), - new IssueAddWorklogCommand() + new IssueAddWorklogCommand(), + new CreateIssueCommand() ]; // register all commands context.subscriptions.push(vscode.commands.registerCommand('jira-plugin.refresh', () => selectIssue(SEARCH_MODE.REFRESH))); context.subscriptions.push(vscode.commands.registerCommand('jira-plugin.allIssuesCommand', () => selectIssue(SEARCH_MODE.ALL))); - context.subscriptions.push(vscode.commands.registerCommand('jira-plugin.currentSprintCommand', () => selectIssue(SEARCH_MODE.CURRENT_SPRINT))); - context.subscriptions.push(vscode.commands.registerCommand('jira-plugin.myIssuesByStatusCommand', () => selectIssue(SEARCH_MODE.MY_STATUS))); - context.subscriptions.push(vscode.commands.registerCommand('jira-plugin.issuesByStatusAssigneeCommand', () => selectIssue(SEARCH_MODE.STATUS_ASSIGNEE))); + context.subscriptions.push( + vscode.commands.registerCommand('jira-plugin.currentSprintCommand', () => selectIssue(SEARCH_MODE.CURRENT_SPRINT)) + ); + context.subscriptions.push( + vscode.commands.registerCommand('jira-plugin.myIssuesByStatusCommand', () => selectIssue(SEARCH_MODE.MY_STATUS)) + ); + context.subscriptions.push( + vscode.commands.registerCommand('jira-plugin.issuesByStatusAssigneeCommand', () => selectIssue(SEARCH_MODE.STATUS_ASSIGNEE)) + ); context.subscriptions.push(vscode.commands.registerCommand('jira-plugin.issuesByStatusCommand', () => selectIssue(SEARCH_MODE.STATUS))); context.subscriptions.push(vscode.commands.registerCommand('jira-plugin.issueByIdCommand', () => selectIssue(SEARCH_MODE.ID))); context.subscriptions.push(vscode.commands.registerCommand('jira-plugin.issuesBySummaryCommand', () => selectIssue(SEARCH_MODE.SUMMARY))); diff --git a/src/http/api.model.ts b/src/http/api.model.ts index 84891ab..db54a5b 100644 --- a/src/http/api.model.ts +++ b/src/http/api.model.ts @@ -10,6 +10,11 @@ export interface IJira { addNewComment(params: { issueKey: string; comment: IAddComment }): Promise; addWorkLog(params: { issueKey: string; worklog: IAddWorkLog }): Promise; + + getAllIssueTypes(): Promise; + createIssue(params: ICreateIssue): Promise; + + getAllPriorities(): Promise; } export interface IServerInfo { @@ -95,3 +100,38 @@ export interface IAddWorkLog { timeSpentSeconds: number; comment?: string; } + +export interface IIssueType { + id: string; + description: string; + name: string; + subtask: boolean; +} + +export interface ICreateIssue { + fields: { + project: { + key: string; + }; + summary: string; + description: string; + issuetype: { + id: string; + }; + assignee?: { + key: string; + }; + priority?: { + id: string; + }; + }; +} + +export interface IPriority { + description: string; + iconUrl: string; + id: string; + name: string; + self: string; + statusColor: string; +} diff --git a/src/http/api.ts b/src/http/api.ts index 7d91208..6efa8cd 100644 --- a/src/http/api.ts +++ b/src/http/api.ts @@ -7,12 +7,15 @@ import { IAddCommentResponse, IAddWorkLog, IAssignee, + ICreateIssue, IIssues, + IIssueType, IJira, IProject, ISetTransition, IStatus, - ITransitions + ITransitions, + IPriority } from './api.model'; export class Jira implements IJira { @@ -38,7 +41,7 @@ export class Jira implements IJira { basic_auth: { username, password } }); - // custom event + // custom event // solve this issue -> https://github.com/floralvikings/jira-connector/issues/115 const customGetAllProjects = (opts: any, callback: any) => { var options = this.jiraInstance.project.buildRequestOptions(opts, '', 'GET'); @@ -103,4 +106,16 @@ export class Jira implements IJira { async addWorkLog(params: { issueKey: string; worklog: IAddWorkLog }): Promise { return await this.jiraInstance.issue.addWorkLog(params); } + + async getAllIssueTypes(): Promise { + return await this.jiraInstance.issueType.getAllIssueTypes(); + } + + async createIssue(params: ICreateIssue): Promise { + return await this.jiraInstance.issue.createIssue(params); + } + + async getAllPriorities(): Promise { + return await this.jiraInstance.priority.getAllPriorities(); + } } diff --git a/src/shared/select-utilities.ts b/src/shared/select-utilities.ts index b6a2d2c..83ee7d9 100644 --- a/src/shared/select-utilities.ts +++ b/src/shared/select-utilities.ts @@ -1,5 +1,5 @@ import * as vscode from 'vscode'; -import { IAssignee, IIssue } from '../http/api.model'; +import { IAssignee, IIssue, IIssueType } from '../http/api.model'; import BackPick from '../picks/back-pick'; import NoWorkingIssuePick from '../picks/no-working-issue-pick'; import UnassignedAssigneePick from '../picks/unassigned-assignee-pick'; @@ -114,8 +114,11 @@ const getFilterAndJQL = async (mode: string, project: string): Promise `project = ${project} AND status in (${statuses}) AND assignee in (currentUser()) ORDER BY status ASC, updated DESC` ]; } - case SEARCH_MODE.CURRENT_SPRINT :{ - return [`CURRENT SPRINT`, `project = ${project} AND sprint in openSprints() and sprint not in futureSprints() ORDER BY status ASC, updated ASC`]; + case SEARCH_MODE.CURRENT_SPRINT: { + return [ + `CURRENT SPRINT`, + `project = ${project} AND sprint in openSprints() and sprint not in futureSprints() ORDER BY status ASC, updated ASC` + ]; } } return ['', '']; @@ -199,7 +202,10 @@ export const selectChangeWorkingIssue = async (): Promise => // limit case, there is a working issue selected but the user has no more ${filter} issue. i.e: change of status of the working issue if (state.workingIssue.issue.key !== NO_WORKING_ISSUE.key) { const picks = [new NoWorkingIssuePick()]; - const selected = await vscode.window.showQuickPick(picks, { placeHolder: `Your working issue list`, matchOnDescription: true }); + const selected = await vscode.window.showQuickPick(picks, { + placeHolder: `Your working issue list`, + matchOnDescription: true + }); return selected ? selected.pickValue : undefined; } } @@ -213,18 +219,25 @@ export const selectChangeWorkingIssue = async (): Promise => }; // selection for assignees -export const selectAssignee = async (unassigned: boolean, back: boolean): Promise => { +export const selectAssignee = async ( + unassigned: boolean, + back: boolean, + onlyKey: boolean, + preLoadedPicks: IAssignee[] | undefined +): Promise => { try { const project = getConfigurationByKey(CONFIG.WORKING_PROJECT) || ''; if (verifyCurrentProject(project)) { - const assignees = await state.jira.getAssignees({ project, maxResults: MAX_RESULTS }); - const picks = (assignees || []).filter((assignee: IAssignee) => assignee.active === true).map((assignee: IAssignee) => { - return { - pickValue: assignee.key, - label: assignee.key, - description: assignee.displayName - }; - }); + const assignees = preLoadedPicks || (await state.jira.getAssignees({ project, maxResults: MAX_RESULTS })); + const picks = (assignees || []) + .filter((assignee: IAssignee) => assignee.active === true) + .map((assignee: IAssignee) => { + return { + pickValue: onlyKey ? assignee.key : assignee, + label: assignee.key, + description: assignee.displayName + }; + }); if (back) { picks.unshift(new BackPick()); } @@ -289,9 +302,33 @@ const doubleSelection = async ( export const selectStatusAndAssignee = async (): Promise<{ status: string; assignee: string }> => { const project = getConfigurationByKey(CONFIG.WORKING_PROJECT) || ''; if (verifyCurrentProject(project)) { - const { firstChoise, secondChoise } = await doubleSelection(selectStatus, async () => await selectAssignee(true, true)); + const { firstChoise, secondChoise } = await doubleSelection( + selectStatus, + async () => await selectAssignee(true, true, true, undefined) + ); return { status: firstChoise, assignee: secondChoise }; } else { throw new Error(`Working project not correct, please select one valid project. ("Set working project" command)`); } }; + +export const selectIssueType = async (ignoreFocusOut: boolean, preLoadedPicks: IIssueType[]): Promise => { + try { + const types = preLoadedPicks || (await state.jira.getAllIssueTypes()); + const picks = (types || []).map(type => ({ + pickValue: type, + label: type.name, + description: '', + type + })); + const selected = await vscode.window.showQuickPick(picks, { + placeHolder: `Select type`, + matchOnDescription: true, + ignoreFocusOut + }); + return selected ? selected.pickValue : undefined; + } catch (err) { + printErrorMessageInOutput(err); + } + return undefined; +};