From d9729084275a19c8d507f5b6c6b64fa060d75a1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= <78490564+maximilianruesch@users.noreply.github.com> Date: Tue, 21 Nov 2023 21:46:16 +0100 Subject: [PATCH 1/4] Migrate jira cloud provider to axios (#63) --- doc/architecture-decisions/ADR-011.md | 19 + .../jira-cloud-provider/JiraCloudProvider.ts | 1434 ++++++----------- package.json | 1 + yarn.lock | 19 + 4 files changed, 533 insertions(+), 940 deletions(-) create mode 100644 doc/architecture-decisions/ADR-011.md diff --git a/doc/architecture-decisions/ADR-011.md b/doc/architecture-decisions/ADR-011.md new file mode 100644 index 00000000..96b5fd05 --- /dev/null +++ b/doc/architecture-decisions/ADR-011.md @@ -0,0 +1,19 @@ +# ADR 11: Axios for calls to the Jira API + +## Status + +accepted + +## Context + +We want to be able to configure the headers, base URL and other parameters once for Jira API calls, instead of each time we make a call as previous with the `fetch` and `cross-fetch` packages. + +## Decision + +We are now using [Axios](https://axios-http.com/docs/intro), a package that enables creation of REST clients and enables us to configure defaults for each call. +Additionally, we are able to define error handlers for specific HTTP error calls, enabling default error handling for e.g. 401 responses. + +## Consequences + +Every call to the Jira Server API (except for authorization calls to e.g. fetch a token) should be made via an Axios instance. +This also means rewriting the error handling in each call, finding common errors and enabling default handling for them. diff --git a/electron/providers/jira-cloud-provider/JiraCloudProvider.ts b/electron/providers/jira-cloud-provider/JiraCloudProvider.ts index 3d65ba79..c9d4cb21 100644 --- a/electron/providers/jira-cloud-provider/JiraCloudProvider.ts +++ b/electron/providers/jira-cloud-provider/JiraCloudProvider.ts @@ -1,4 +1,5 @@ /* eslint-disable class-methods-use-this */ +import axios, { AxiosError, AxiosResponse, isAxiosError } from "axios"; import { dateTimeFormat, Issue, @@ -31,6 +32,55 @@ export class JiraCloudProvider implements IProvider { private reversedCustomFields = new Map() + private constructRestBasedClient(basePath: string, version: string) { + const instance = axios.create({ + baseURL: `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/${basePath}/${version}`, + headers: { + Accept: "application/json", + Authorization: `Bearer ${this.accessToken}`, + "Content-Type": "application/json", + }, + }) + + const recreateAxiosError = (originalError: AxiosError, message: string) => new AxiosError( + message, + originalError.code, + originalError.config, + originalError.request, + originalError.response + ) + + instance.interceptors.response.use( + (response) => response, + (error) => { + if (isAxiosError(error) && error.response) { + const statusCode = error.response.status + if (statusCode === 400) { + return Promise.reject(recreateAxiosError(error, `Invalid request: ${JSON.stringify(error.response.data)}`)) + } if (statusCode === 401) { + return Promise.reject(recreateAxiosError(error, `User not authenticated: ${JSON.stringify(error.response.data)}`)) + } if (error.response.status === 403) { + return Promise.reject(recreateAxiosError(error, `User does not have a valid licence: ${JSON.stringify(error.response.data)}`)) + } if (error.response.status === 429) { + return Promise.reject(recreateAxiosError(error, `Rate limit exceeded: ${JSON.stringify(error.response.data)}`)) + } + } + + return Promise.reject(error) + } + ) + + return instance + } + + private getRestApiClient(version: number) { + return this.constructRestBasedClient('api', version.toString()); + } + + private getAgileRestApiClient(version: string) { + return this.constructRestBasedClient('agile', version); + } + offsetDate(date: Date) { if (!date) { return date @@ -116,28 +166,14 @@ export class JiraCloudProvider implements IProvider { async mapCustomFields(): Promise { return new Promise((resolve, reject) => { - fetch( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/api/3/field`, - { - headers: { - Accept: "application/json", - Authorization: `Bearer ${this.accessToken}`, - }, - } - ) - .then(async (data) => { - const fetchedFields = await data.json() - if (data.status === 200) { - fetchedFields.forEach((field: { name: string; id: string }) => { - this.customFields.set(field.name, field.id) - this.reversedCustomFields.set(field.id, field.name) - }) - resolve() - } else if (data.status === 401) { - reject(new Error(`User not authenticated: ${fetchedFields}`)) - } else { - reject(new Error(`Unknown error: ${fetchedFields}`)) - } + this.getRestApiClient(3) + .get('/field') + .then(async (response) => { + response.data.forEach((field: { name: string; id: string }) => { + this.customFields.set(field.name, field.id) + this.reversedCustomFields.set(field.id, field.name) + }) + resolve() }) .catch((error) => { reject(new Error(`Error creating issue: ${error}`)) @@ -147,118 +183,77 @@ export class JiraCloudProvider implements IProvider { async getProjects(): Promise { return new Promise((resolve, reject) => { - fetch( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/api/3/project/search?expand=description,lead,issueTypes,url,projectKeys,permissions,insight`, - { - headers: { - Accept: "application/json", - Authorization: `Bearer ${this.accessToken}`, - }, - } - ) + this.getRestApiClient(3) + .get('/project/search?expand=description,lead,issueTypes,url,projectKeys,permissions,insight') .then(async (response) => { - const data = await response.json() - if (response.status === 200) { - const projects = data.values.map((project: JiraProject) => ({ - key: project.key, - name: project.name, - id: project.id, - lead: project.lead.displayName, - type: project.projectTypeKey, - })) - resolve(projects) - } else if (response.status === 400) { - reject(new Error(`Invalid request: ${data}`)) - } else if (response.status === 401) { - reject(new Error(`User not authenticated: ${data}`)) - } else if (response.status === 404) { - reject( - new Error( - `No projects matching the search criteria were found: ${data}` - ) - ) - } else { - reject(new Error(`Unknown error: ${data}`)) - } + const projects = response.data.values.map((project: JiraProject) => ({ + key: project.key, + name: project.name, + id: project.id, + lead: project.lead.displayName, + type: project.projectTypeKey, + })) + resolve(projects) }) .catch((error) => { - reject(new Error(`Error getting projects: ${error}`)) + let specificError = error + if (error.response) { + if (error.response.status === 404) { + specificError = new Error(`No projects matching the search criteria were found: ${error.response.data}`) + } + } + + reject(new Error(`Error getting projects: ${specificError}`)) }) }) } async getIssueTypesByProject(projectIdOrKey: string): Promise { return new Promise((resolve, reject) => { - fetch( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/api/2/project/${projectIdOrKey}/statuses`, - { - headers: { - Accept: "application/json", - Authorization: `Bearer ${this.accessToken}`, - }, - } - ) + this.getRestApiClient(2) + .get(`/project/${projectIdOrKey}/statuses`) .then(async (response) => { - if (response.status === 200) { - const issueTypes: JiraIssueType[] = await response.json() - resolve(issueTypes as IssueType[]) - } else if (response.status === 401) { - reject( - new Error(`User not authenticated: ${await response.json()}`) - ) - } else if (response.status === 404) { - reject( - new Error( - `The project was not found or the user does not have permission to view it: ${await response.json()}` + const issueTypes: JiraIssueType[] = response.data + resolve(issueTypes as IssueType[]) + }) + .catch((error) => { + let specificError = error + if (error.response) { + if (error.response.status === 404) { + specificError = new Error( + `The project was not found or the user does not have permission to view it: ${error.response.data}` ) - ) - } else { - reject(new Error(`Unknown error: ${await response.json()}`)) + } } + + reject(new Error(`Error in fetching the issue types: ${specificError}`)) }) - .catch((error) => - reject(new Error(`Error in fetching the issue types: ${error}`)) - ) }) } async getIssueTypesWithFieldsMap(): Promise<{ [key: string]: string[] }> { return new Promise((resolve, reject) => { - fetch( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/api/3/issue/createmeta?expand=projects.issuetypes.fields`, - { - headers: { - Accept: "application/json", - Authorization: `Bearer ${this.accessToken}`, - }, - } - ) + this.getRestApiClient(3) + .get('/issue/createmeta?expand=projects.issuetypes.fields') .then(async (response) => { - const metadata = await response.json() - if (response.status === 200) { - const issueTypeToFieldsMap: { [key: string]: string[] } = {} - metadata.projects.forEach( - (project: { + const issueTypeToFieldsMap: { [key: string]: string[] } = {} + response.data.projects.forEach( + (project: { + id: string + issuetypes: { + fields: {} id: string - issuetypes: { - fields: {} - id: string - }[] - }) => { - project.issuetypes.forEach((issuetype) => { - const fieldKeys = Object.keys(issuetype.fields) - issueTypeToFieldsMap[issuetype.id] = fieldKeys.map( - (fieldKey) => this.reversedCustomFields.get(fieldKey)! - ) - }) - } - ) - resolve(issueTypeToFieldsMap) - } else if (response.status === 401) { - reject(new Error(`User not authenticated: ${metadata}`)) - } else { - reject(new Error(`Unknown error: ${metadata}`)) - } + }[] + }) => { + project.issuetypes.forEach((issueType) => { + const fieldKeys = Object.keys(issueType.fields) + issueTypeToFieldsMap[issueType.id] = fieldKeys.map( + (fieldKey) => this.reversedCustomFields.get(fieldKey)! + ) + }) + } + ) + resolve(issueTypeToFieldsMap) }) .catch((error) => reject(new Error(`Error in fetching the issue types map: ${error}`)) @@ -268,27 +263,13 @@ export class JiraCloudProvider implements IProvider { async getEditableIssueFields(issueIdOrKey: string): Promise { return new Promise((resolve, reject) => { - fetch( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/api/3/issue/${issueIdOrKey}/editmeta`, - { - headers: { - Accept: "application/json", - Authorization: `Bearer ${this.accessToken}`, - }, - } - ) + this.getRestApiClient(3) + .get(`/issue/${issueIdOrKey}/editmeta`) .then(async (response) => { - const metadata = await response.json() - if (response.status === 200) { - const fieldKeys = Object.keys(metadata.fields).map( - (fieldKey) => this.reversedCustomFields.get(fieldKey)! - ) - resolve(fieldKeys) - } else if (response.status === 401) { - reject(new Error(`User not authenticated: ${metadata}`)) - } else { - reject(new Error(`Unknown error: ${metadata}`)) - } + const fieldKeys = Object.keys(response.data.fields).map( + (fieldKey) => this.reversedCustomFields.get(fieldKey)! + ) + resolve(fieldKeys) }) .catch((error) => reject(new Error(`Error in fetching the issue types map: ${error}`)) @@ -298,71 +279,30 @@ export class JiraCloudProvider implements IProvider { async getAssignableUsersByProject(projectIdOrKey: string): Promise { return new Promise((resolve, reject) => { - fetch( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/api/3/user/assignable/search?project=${projectIdOrKey}`, - { - headers: { - Accept: "application/json", - Authorization: `Bearer ${this.accessToken}`, - }, - } - ) + this.getRestApiClient(3) + .get(`/user/assignable/search?project=${projectIdOrKey}`) .then(async (response) => { - if (response.status === 200) { - const users: User[] = await response.json() - resolve(users as User[]) - } else if (response.status === 400) { - reject( - new Error(`Some infos are missing: ${await response.json()}`) - ) - } else if (response.status === 401) { - reject( - new Error(`User not authenticated: ${await response.json()}`) - ) - } else if (response.status === 404) { - reject( - new Error( - `Project, issue, or transition were not found: ${await response.json()}` - ) - ) - } else if (response.status === 429) { - reject(new Error(`Rate limit exceeded: ${await response.json()}`)) - } else { - reject(new Error(`Unknown error: ${await response.json()}`)) + resolve(response.data as User[]) + }) + .catch((error) => { + let specificError = error + if (error.response) { + if (error.response.status === 404) { + specificError = new Error(`Project, issue, or transition were not found: ${error.response.data}`) + } } + + reject(new Error(`Error in fetching the assignable users for the project ${projectIdOrKey}: ${specificError}`)) }) - .catch((error) => - reject( - new Error( - `Error in fetching the assignable users for the project ${projectIdOrKey}: ${error}` - ) - ) - ) }) } async getCurrentUser(): Promise { return new Promise((resolve, reject) => { - fetch( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/api/3/myself`, - { - headers: { - Accept: "application/json", - Authorization: `Bearer ${this.accessToken}`, - }, - } - ) + this.getRestApiClient(3) + .get('/myself') .then(async (response) => { - if (response.status === 200) { - const user: User = await response.json() - resolve(user as User) - } else if (response.status === 401) { - reject( - new Error(`User not authenticated: ${await response.json()}`) - ) - } else { - reject(new Error(`Unknown error: ${await response.json()}`)) - } + resolve(response.data as User) }) .catch((error) => reject(new Error(`Error in fetching the current user: ${error}`)) @@ -372,64 +312,35 @@ export class JiraCloudProvider implements IProvider { async getIssueReporter(issueIdOrKey: string): Promise { return new Promise((resolve, reject) => { - fetch( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/api/3/issue/${issueIdOrKey}?fields=reporter`, - { - headers: { - Accept: "application/json", - Authorization: `Bearer ${this.accessToken}`, - }, - } - ) + this.getRestApiClient(3) + .get(`/issue/${issueIdOrKey}?fields=reporter`) .then(async (response) => { - const user = await response.json() - if (response.status === 200) { - resolve(user.fields.reporter as User) - } else if (response.status === 401) { - reject(new Error(`User not authenticated: ${user}`)) - } else if (response.status === 404) { - reject( - new Error( - `The issue was not found or the user does not have permission to view it: ${user}` + resolve(response.data.fields.reporter as User) + }) + .catch((error) => { + let specificError = error + if (error.response) { + if (error.response.status === 404) { + specificError = new Error( + `The issue was not found or the user does not have permission to view it: ${error.response.data}` ) - ) - } else { - reject(new Error(`Unknown error: ${user}`)) + } } + + reject(new Error(`Error in fetching the issue reporter: ${specificError}`)) }) - .catch((error) => - reject(new Error(`Error in fetching the current user: ${error}`)) - ) }) } async getBoardIds(project: string): Promise { return new Promise((resolve, reject) => { - fetch( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/agile/1.0/board?projectKeyOrId=${project}`, - { - headers: { - Accept: "application/json", - Authorization: `Bearer ${this.accessToken}`, - }, - } - ) + this.getAgileRestApiClient('1.0') + .get(`/board?projectKeyOrId=${project}`) .then(async (response) => { - const data = await response.json() - if (response.status === 200) { - const boardIds: number[] = data.values.map( - (element: { id: number; name: string }) => element.id - ) - resolve(boardIds) - } else if (response.status === 400) { - reject(new Error(`Invalid request: ${data}`)) - } else if (response.status === 401) { - reject(new Error(`User not authenticated: ${data}`)) - } else if (response.status === 403) { - reject(new Error(`User does not have a valid licence: ${data}`)) - } else { - reject(new Error(`Unknown error: ${data}`)) - } + const boardIds: number[] = response.data.values.map( + (element: { id: number; name: string }) => element.id + ) + resolve(boardIds) }) .catch((error) => { reject(new Error(`Error getting projects: ${error}`)) @@ -439,86 +350,69 @@ export class JiraCloudProvider implements IProvider { async getSprints(boardId: number): Promise { return new Promise((resolve, reject) => { - fetch( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/agile/1.0/board/${boardId}/sprint`, - { - headers: { - Accept: "application/json", - Authorization: `Bearer ${this.accessToken}`, - }, - } - ) + this.getAgileRestApiClient('1.0') + .get(`/board/${boardId}/sprint`) .then(async (response) => { - const data = await response.json() - if (response.status === 200) { - const sprints: Sprint[] = data.values - .filter( - (element: { state: string }) => element.state !== "closed" - ) - .map((element: JiraSprint) => { - const sDate = new Date(element.startDate) - const startDate = Number.isNaN(sDate.getTime()) - ? "Invalid Date" - : dateTimeFormat.format(sDate) - const eDate = new Date(element.endDate) - const endDate = Number.isNaN(eDate.getTime()) - ? "Invalid Date" - : dateTimeFormat.format(eDate) - return { - id: element.id, - name: element.name, - state: element.state, - startDate, - endDate, - } - }) - resolve(sprints) - } else if (response.status === 400) { - reject(new Error(`Invalid request: ${data}`)) - } else if (response.status === 401) { - reject(new Error(`User not authenticated: ${data}`)) - } else if (response.status === 403) { - reject(new Error(`User does not have a valid licence: ${data}`)) - } else if (response.status === 404) { - reject( - new Error( - `The board does not exist or the user does not have permission to view it: ${data}` - ) + const sprints: Sprint[] = response.data.values + .filter( + (element: { state: string }) => element.state !== "closed" ) - } else { - reject(new Error(`Unknown error: ${data}`)) - } + .map((element: JiraSprint) => { + const sDate = new Date(element.startDate) + const startDate = Number.isNaN(sDate.getTime()) + ? "Invalid Date" + : dateTimeFormat.format(sDate) + const eDate = new Date(element.endDate) + const endDate = Number.isNaN(eDate.getTime()) + ? "Invalid Date" + : dateTimeFormat.format(eDate) + return { + id: element.id, + name: element.name, + state: element.state, + startDate, + endDate, + } + }) + resolve(sprints) }) .catch((error) => { - reject(new Error(`Error fetching the sprints: ${error}`)) + let specificError = error + if (error.response) { + if (error.response.status === 404) { + specificError = new Error( + `The board does not exist or the user does not have permission to view it: ${error.response.data}` + ) + } + } + + reject(new Error(`Error fetching the sprints: ${specificError}`)) }) }) } async getIssuesByProject(project: string): Promise { return new Promise((resolve, reject) => { - this.fetchIssues( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/api/3/search?jql=project=${project}&maxResults=10000` - ) + this.getRestApiClient(3) + .get(`/search?jql=project=${project}&maxResults=10000`) .then(async (response) => { - resolve(response) + resolve(this.fetchIssues(response)) }) .catch((error) => { - reject(new Error(`Error fetching issues by project: ${error}`)) + reject(new Error(`Error fetching issues by project: ${this.handleFetchIssuesError(error)}`)) }) }) } async getIssuesBySprint(sprintId: number): Promise { return new Promise((resolve, reject) => { - this.fetchIssues( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/agile/1.0/sprint/${sprintId}/issue` - ) + this.getAgileRestApiClient('1.0') + .get(`/sprint/${sprintId}/issue`) .then(async (response) => { - resolve(response) + resolve(this.fetchIssues(response)) }) .catch((error) => { - reject(new Error(`Error fetching issues by sprint: ${error}`)) + reject(new Error(`Error fetching issues by sprint: ${this.handleFetchIssuesError(error)}`)) }) }) } @@ -528,80 +422,63 @@ export class JiraCloudProvider implements IProvider { boardId: number ): Promise { return new Promise((resolve, reject) => { - this.fetchIssues( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/agile/1.0/board/${boardId}/backlog?jql=project=${project}&maxResults=500` - ) + this.getAgileRestApiClient('1.0') + .get(`/board/${boardId}/backlog?jql=project=${project}&maxResults=500`) .then(async (response) => { - resolve(response) + resolve(this.fetchIssues(response)) }) .catch((error) => { - reject(new Error(`Error fetching issues by project: ${error}`)) + reject(new Error(`Error fetching issues by project: ${this.handleFetchIssuesError(error)}`)) }) }) } - async fetchIssues(url: string): Promise { + async fetchIssues(response: AxiosResponse): Promise { const rankCustomField = this.customFields.get("Rank") || "" - return new Promise((resolve, reject) => { - fetch(url, { - headers: { - Accept: "application/json", - Authorization: `Bearer ${this.accessToken}`, - }, - }) - .then(async (response) => { - const data = await response.json() - if (response.status === 200) { - const issues: Promise = Promise.all( - data.issues.map(async (element: JiraIssue) => ({ - issueKey: element.key, - summary: element.fields.summary, - creator: element.fields.creator.displayName, - status: element.fields.status.name, - type: element.fields.issuetype.name, - storyPointsEstimate: await this.getIssueStoryPointsEstimate( - element.key - ), - epic: element.fields.parent?.fields.summary, - labels: element.fields.labels, - assignee: { - displayName: element.fields.assignee?.displayName, - avatarUrls: element.fields.assignee?.avatarUrls, - }, - rank: element.fields[rankCustomField], - description: element.fields.description, - subtasks: element.fields.subtasks, - created: element.fields.created, - updated: element.fields.updated, - comment: element.fields.comment, - projectId: element.fields.project.id, - sprint: element.fields.sprint, - attachments: element.fields.attachment, - })) - ) - resolve(issues) - } else if (response.status === 400) { - reject(new Error(`Invalid request: ${data}`)) - } else if (response.status === 401) { - reject(new Error(`User not authenticated: ${data}`)) - } else if (response.status === 403) { - reject(new Error(`User does not have a valid licence: ${data}`)) - } else if (response.status === 404) { - reject( - new Error( - `The board does not exist or the user does not have permission to view it: ${data}` - ) - ) - } else { - reject(new Error(`Unknown error: ${data}`)) - } - }) - .catch((error) => { - reject(new Error(`Error fetching issues: ${error}`)) - }) + return new Promise((resolve) => { + const issues: Promise = Promise.all( + response.data.issues.map(async (element: JiraIssue) => ({ + issueKey: element.key, + summary: element.fields.summary, + creator: element.fields.creator.displayName, + status: element.fields.status.name, + type: element.fields.issuetype.name, + storyPointsEstimate: await this.getIssueStoryPointsEstimate(element.key), + epic: element.fields.parent?.fields.summary, + labels: element.fields.labels, + assignee: { + displayName: element.fields.assignee?.displayName, + avatarUrls: element.fields.assignee?.avatarUrls, + }, + rank: element.fields[rankCustomField], + description: element.fields.description, + subtasks: element.fields.subtasks, + created: element.fields.created, + updated: element.fields.updated, + comment: element.fields.comment, + projectId: element.fields.project.id, + sprint: element.fields.sprint, + attachments: element.fields.attachment, + })) + ) + resolve(issues) }) } + handleFetchIssuesError(error: AxiosError): Error { + if (!error.response) { + return error; + } + + if (error.response.status === 404) { + return new Error( + `The board does not exist or the user does not have permission to view it: ${error.response.data}` + ) + } + + return error; + } + async moveIssueToSprintAndRank( sprint: number, issue: string, @@ -610,106 +487,59 @@ export class JiraCloudProvider implements IProvider { ): Promise { return new Promise((resolve, reject) => { const rankCustomField = this.customFields.get("Rank") - const body = { - rankCustomFieldId: rankCustomField!.match(/_(\d+)/)![1], - issues: [issue], - ...(rankAfter ? { rankAfterIssue: rankAfter } : {}), - ...(rankBefore ? { rankBeforeIssue: rankBefore } : {}), - } - fetch( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/agile/1.0/sprint/${sprint}/issue`, - { - method: "POST", - headers: { - Accept: "application/json", - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - } - ) - .then(async (response) => { - if (response.status === 204) { - resolve() - } else if (response.status === 400) { - reject(new Error(`Invalid request: ${await response.json()}`)) - } else if (response.status === 401) { - reject( - new Error(`User not authenticated: ${await response.json()}`) - ) - } else if (response.status === 403) { - reject( - new Error( - `User does not have a valid licence or permissions to assign issues: ${await response.json()}` + this.getAgileRestApiClient('1.0') + .post( + `/sprint/${sprint}/issue`, + { + rankCustomFieldId: rankCustomField!.match(/_(\d+)/)![1], + issues: [issue], + ...(rankAfter ? { rankAfterIssue: rankAfter } : {}), + ...(rankBefore ? { rankBeforeIssue: rankBefore } : {}), + } + ) + .then(async () => { resolve() }) + .catch((error) => { + let specificError = error + if (error.response) { + if (error.response.status === 403) { + specificError = new Error( + `User does not have a valid licence or permissions to assign issues: ${error.response.data}` ) - ) - } else if (response.status === 404) { - reject( - new Error( - `The sprint does not exist or the user does not have permission to view it: ${await response.json()}` + } else if (error.response.status === 404) { + specificError = new Error( + `The board does not exist or the user does not have permission to view it: ${error.response.data}` ) - ) - } else { - reject(new Error(`Unknown error: ${await response.json()}`)) + } } - }) - .catch((error) => { - reject( - new Error( - `Error in moving this issue to the Sprint with id ${sprint}: ${error}` - ) - ) + + reject(new Error(`Error in moving this issue to the Sprint with id ${sprint}: ${specificError}`)) }) }) } async moveIssueToBacklog(issue: string): Promise { return new Promise((resolve, reject) => { - fetch( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/agile/1.0/backlog/issue`, - { - method: "POST", - headers: { - Accept: "application/json", - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, - body: `{ - "issues": [ - "${issue}" - ] - }`, - } - ) - .then(async (response) => { - if (response.status === 204) { - resolve() - } else if (response.status === 400) { - reject(new Error(`Invalid request: ${await response.json()}`)) - } else if (response.status === 401) { - reject( - new Error(`User not authenticated: ${await response.json()}`) - ) - } else if (response.status === 403) { - reject( - new Error( - `User does not have a valid licence or permissions to assign issues: ${await response.json()}` + this.getAgileRestApiClient('1.0') + .post( + '/backlog/issue', + { issues: [issue] } + ) + .then(async () => { resolve() }) + .catch((error) => { + let specificError = error + if (error.response) { + if (error.response.status === 403) { + specificError = new Error( + `User does not have a valid licence or permissions to assign issues: ${error.response.data}` ) - ) - } else if (response.status === 404) { - reject( - new Error( - `The board does not exist or the user does not have permission to view it: ${await response.json()}` + } else if (error.response.status === 404) { + specificError = new Error( + `The board does not exist or the user does not have permission to view it: ${error.response.data}` ) - ) - } else { - reject(new Error(`Unknown error: ${await response.json()}`)) + } } - }) - .catch((error) => { - reject( - new Error(`Error in moving this issue to the backlog: ${error}`) - ) + + reject(new Error(`Error in moving this issue to the backlog: ${specificError}`)) }) }) } @@ -737,18 +567,8 @@ export class JiraCloudProvider implements IProvider { body.rankAfterIssue = rankAfter } - fetch( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/agile/1.0/issue/rank`, - { - method: "PUT", - headers: { - Accept: "application/json", - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - } - ) + this.getAgileRestApiClient('1.0') + .put('/issue/rank', body) .then(async (response) => { if (response.status === 204) { resolve() @@ -756,67 +576,44 @@ export class JiraCloudProvider implements IProvider { // Returns the list of issues with status of rank operation. // see documentation: https://developer.atlassian.com/cloud/jira/software/rest/api-group-issue/#api-rest-agile-1-0-issue-rank-put-responses resolve() - } else if (response.status === 400) { - reject(new Error(`Invalid request: ${await response.json()}`)) - } else if (response.status === 401) { - reject( - new Error(`User not authenticated: ${await response.json()}`) - ) - } else if (response.status === 403) { - reject( - new Error( - `User does not have a valid licence or permissions to rank issues: ${await response.json()}` - ) - ) - } else { - reject(new Error(`Unknown error: ${await response.json()}`)) } }) .catch((error) => { - reject( - new Error(`Error in moving this issue to the backlog: ${error}`) - ) + let specificError = error + if (error.response) { + if (error.response.status === 403) { + specificError = new Error( + `User does not have a valid licence or permissions to rank issues: ${error.response.data}` + ) + } + } + + reject(new Error(`Error in ranking this issue in the backlog: ${specificError}`)) }) }) } async getIssueStoryPointsEstimate(issue: string): Promise { return new Promise((resolve, reject) => { - fetch( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/api/3/issue/${issue}`, - { - headers: { - Accept: "application/json", - Authorization: `Bearer ${this.accessToken}`, - }, - } - ) + this.getRestApiClient(3) + .get(`/issue/${issue}`) .then(async (response) => { - const data = await response.json() - if (response.status === 200) { - const customField = this.customFields.get("Story point estimate") - const points: number = data.fields[customField!] - - resolve(points) - } else if (response.status === 401) { - reject(new Error(`User not authenticated: ${data}`)) - } else if (response.status === 404) { - reject( - new Error( - `The issue was not found or the user does not have permission to view it: ${data}` + const customField = this.customFields.get("Story point estimate") + const points: number = response.data.fields[customField!] + resolve(points) + }) + .catch((error) => { + let specificError = error + if (error.response) { + if (error.response.status === 404) { + specificError = new Error( + `The issue was not found or the user does not have permission to view it: ${error.response.data}` ) - ) - } else { - reject(new Error(`Unknown error: ${data}`)) + } } + + reject(new Error(`Error in getting the story points for issue: ${issue}: ${specificError}`)) }) - .catch((error) => - reject( - new Error( - `Error in getting the story points for issue: ${issue}: ${error}` - ) - ) - ) }) } @@ -840,16 +637,10 @@ export class JiraCloudProvider implements IProvider { const offsetDueDate = this.offsetDate(dueDate) return new Promise((resolve, reject) => { - fetch( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/api/3/issue`, - { - method: "POST", - headers: { - Accept: "application/json", - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ + this.getRestApiClient(3) + .post( + `/issue`, + { fields: { summary, parent: { key: epic }, @@ -890,35 +681,28 @@ export class JiraCloudProvider implements IProvider { }), ...(storyPointsEstimate && { [this.customFields.get("Story point estimate")!]: - storyPointsEstimate, + storyPointsEstimate, }), // ...(files && { // [this.customFields.get("Attachment")!]: files, // }), }, - }), - } - ) - .then(async (data) => { - const createdIssue = await data.json() - if (data.status === 201) { - resolve(JSON.stringify(createdIssue.key)) - this.setTransition(createdIssue.id, status) - } - if (data.status === 400) { - reject(new Error(createdIssue)) - } - if (data.status === 401) { - reject(new Error("User not authenticated")) - } - if (data.status === 403) { - reject( - new Error("The user does not have the necessary permissions") - ) } + ) + .then(async (response) => { + const createdIssue = response.data + resolve(JSON.stringify(createdIssue.key)) + this.setTransition(createdIssue.id, status) }) .catch((error) => { - reject(new Error(`Error creating issue: ${error}`)) + let specificError = error + if (error.response) { + if (error.response.status === 404) { + specificError = new Error("The user does not have the necessary permissions") + } + } + + reject(new Error(`Error creating issue: ${specificError}`)) }) }) } @@ -945,16 +729,10 @@ export class JiraCloudProvider implements IProvider { const offsetDueDate = this.offsetDate(dueDate) return new Promise((resolve, reject) => { - fetch( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/api/3/issue/${issueIdOrKey}`, - { - method: "PUT", - headers: { - Accept: "application/json", - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ + this.getRestApiClient(3) + .put( + `/issue/${issueIdOrKey}`, + { fields: { ...(summary && { summary, @@ -1011,152 +789,85 @@ export class JiraCloudProvider implements IProvider { }), ...(storyPointsEstimate !== undefined && { [this.customFields.get("Story point estimate")!]: - storyPointsEstimate, + storyPointsEstimate, }), }, - }), - } - ) - .then(async (data) => { - if (data.status === 204) { - resolve() - } - if (data.status === 400) { - reject( - new Error( - "400 Error: consult the atlassian rest api v3 under Edit issue for information" - ) - ) - } - if (data.status === 401) { - reject(new Error("User not authenticated")) } - if (data.status === 403) { - reject( - new Error("The user does not have the necessary permissions") - ) - } - if (data.status === 404) { - reject( - new Error( + ) + .then(async () => { resolve() }) + .catch((error) => { + let specificError = error + if (error.response) { + if (error.response.status === 404) { + specificError = new Error( "The issue was not found or the user does not have the necessary permissions" ) - ) + } } - }) - .catch(async (error) => { - reject(new Error(`Error creating issue: ${error}`)) + + reject(new Error(`Error creating issue: ${specificError}`)) }) }) } async setTransition(issueKey: string, status: string): Promise { const transitions = new Map() - const transitonResponse = await fetch( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/api/3/issue/${issueKey}/transitions`, - { - headers: { - Accept: "application/json", - Authorization: `Bearer ${this.accessToken}`, - }, - } + const transitionResponse = await this.getRestApiClient(3).get( + `/issue/${issueKey}/transitions`, ) - const data = await transitonResponse.json() + const {data} = transitionResponse data.transitions.forEach((field: { name: string; id: string }) => { transitions.set(field.name, field.id) }) const transitionId = +transitions.get(status)! - fetch( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/api/3/issue/${issueKey}/transitions`, - { - method: "POST", - headers: { - Accept: "application/json", - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ transition: { id: transitionId } }), - } + this.getRestApiClient(3).post( + `/issue/${issueKey}/transitions`, + { transition: { id: transitionId } } ) } async getEpicsByProject(projectIdOrKey: string): Promise { return new Promise((resolve, reject) => { - fetch( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/api/3/search?jql=issuetype = Epic AND project = ${projectIdOrKey}`, - { - headers: { - Accept: "application/json", - Authorization: `Bearer ${this.accessToken}`, - }, - } - ) + this.getRestApiClient(3) + .get(`search?jql=issuetype = Epic AND project = ${projectIdOrKey}`) .then(async (response) => { - const epicData = await response.json() - if (response.status === 200) { - const epics: Promise = Promise.all( - epicData.issues.map(async (element: JiraIssue) => ({ - issueKey: element.key, - summary: element.fields.summary, - labels: element.fields.labels, - assignee: { - displayName: element.fields.assignee?.displayName, - avatarUrls: element.fields.assignee?.avatarUrls, - }, - })) - ) - resolve(epics) - } else if (response.status === 400) { - reject(new Error(`Invalid request: ${epicData}`)) - } else if (response.status === 401) { - reject(new Error(`User not authenticated: ${epicData}`)) - } else if (response.status === 403) { - reject(new Error(`User does not have a valid licence: ${epicData}`)) - } else if (response.status === 404) { - reject( - new Error( - `The board does not exist or the user does not have permission to view it: ${epicData}` + const epics: Promise = Promise.all( + response.data.issues.map(async (element: JiraIssue) => ({ + issueKey: element.key, + summary: element.fields.summary, + labels: element.fields.labels, + assignee: { + displayName: element.fields.assignee?.displayName, + avatarUrls: element.fields.assignee?.avatarUrls, + }, + })) + ) + resolve(epics) + }) + .catch((error) => { + let specificError = error + if (error.response) { + if (error.response.status === 404) { + specificError = new Error( + `The board does not exist or the user does not have permission to view it: ${error.response.data}` ) - ) - } else { - reject(new Error(`Unknown error: ${epicData}`)) + } } + + reject(new Error(`Error in fetching the epics for the project ${projectIdOrKey}: ${specificError}`)) }) - .catch((error) => - reject( - new Error( - `Error in fetching the epics for the project ${projectIdOrKey}: ${error}` - ) - ) - ) }) } async getLabels(): Promise { return new Promise((resolve, reject) => { - fetch( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/api/3/label`, - { - headers: { - Accept: "application/json", - Authorization: `Bearer ${this.accessToken}`, - }, - } - ) + this.getRestApiClient(3) + .get('/label') .then(async (response) => { - const labelData = await response.json() - if (response.status === 200) { - const labels: Promise = labelData.values - - resolve(labels) - } else if (response.status === 401) { - reject(new Error(`User not authenticated: ${labelData}`)) - } else { - reject(new Error(`Unknown error: ${labelData}`)) - } + resolve(response.data.values) }) .catch((error) => reject(new Error(`Error in fetching the labels: ${error}`)) @@ -1168,27 +879,11 @@ export class JiraCloudProvider implements IProvider { // WARNING: currently (15.03.2023) GET /rest/api/3/priority is deprecated // and GET /rest/api/3/priority/search is experimental return new Promise((resolve, reject) => { - fetch( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/api/3/priority/search`, - { - headers: { - Accept: "application/json", - Authorization: `Bearer ${this.accessToken}`, - }, - } - ) + this.getRestApiClient(3) + .get('/priority/search') .then(async (response) => { - if (response.status === 200) { - const priorityData: JiraPriority = await response.json() - const priorities: Priority[] = priorityData.values - resolve(priorities) - } else if (response.status === 401) { - reject( - new Error(`User not authenticated: ${await response.json()}`) - ) - } else { - reject(new Error(`Unknown error: ${await response.json()}`)) - } + const priorityData: JiraPriority = response.data + resolve(priorityData.values) }) .catch((error) => reject(new Error(`Error in fetching the labels: ${error}`)) @@ -1200,60 +895,38 @@ export class JiraCloudProvider implements IProvider { issueIdOrKey: string, commentText: string ): Promise { - const bodyData = `{ - "body": { - "content": [ - { - "content": [ - { - "text": "${commentText.replace(/\n/g, " ")}", - "type": "text" - } - ], - "type": "paragraph" - } - ], - "type": "doc", - "version": 1 - }}` - return new Promise((resolve, reject) => { - fetch( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/api/3/issue/${issueIdOrKey}/comment`, - { - method: "POST", - headers: { - Accept: "application/json", - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, - body: bodyData, - } - ) - .then(async (data) => { - if (data.status === 201) { - resolve() - } - if (data.status === 400) { - reject(new Error("Invalid api request")) - } - if (data.status === 401) { - reject(new Error("User not authenticated")) + this.getRestApiClient(3) + .post( + `/issue/${issueIdOrKey}/comment`, + { + body: { + content: [ + { + content: [ + { + text: commentText.replace(/\n/g, " "), + type: "text" + } + ], + type: "paragraph" + } + ], + type: "doc", + version: 1 + } } - if (data.status === 404) { - reject( - new Error( - "The issue was not found or the user does not have the necessary permissions" - ) - ) + ) + .then(async () => { resolve() }) + .catch((error) => { + let specificError = error + if (error.response) { + if (error.response.status === 404) { + specificError = new Error("The issue was not found or the user does not have the necessary permissions") + } } - }) - .catch(async (error) => { - reject( - new Error( - `Error adding a comment to the issue ${issueIdOrKey}: ${error}` - ) - ) + + reject(new Error(`Error adding a comment to the issue ${issueIdOrKey}: ${specificError}`)) }) }) } @@ -1263,165 +936,86 @@ export class JiraCloudProvider implements IProvider { commentId: string, commentText: string ): Promise { - const bodyData = `{ - "body": { - "content": [ - { - "content": [ - { - "text": "${commentText.replace(/\n/g, " ")}", - "type": "text" - } - ], - "type": "paragraph" - } - ], - "type": "doc", - "version": 1 - }}` - return new Promise((resolve, reject) => { - fetch( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/api/3/issue/${issueIdOrKey}/comment/${commentId}`, - { - method: "PUT", - headers: { - Accept: "application/json", - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, - body: bodyData, - } - ) - .then(async (data) => { - if (data.status === 200) { - resolve() - } - if (data.status === 400) { - reject( - new Error( - "The user does not have permission to edit the comment or the request is invalid" - ) - ) - } - if (data.status === 401) { - reject(new Error("User not authenticated")) + this.getRestApiClient(3) + .put( + `/issue/${issueIdOrKey}/comment/${commentId}`, + { + body: { + content: [ + { + content: [ + { + text: commentText.replace(/\n/g, " "), + type: "text" + } + ], + type: "paragraph" + } + ], + type: "doc", + version: 1 + } } - if (data.status === 404) { - reject( - new Error( - "The issue was not found or the user does not have the necessary permissions" - ) - ) + ) + .then(async () => { resolve() }) + .catch((error) => { + let specificError = error + if (error.response) { + if (error.response.status === 400) { + specificError = new Error("The user does not have permission to edit the comment or the request is invalid") + } else if (error.response.status === 404) { + specificError = new Error("The issue was not found or the user does not have the necessary permissions") + } } - }) - .catch(async (error) => { - reject( - new Error( - `Error editing the comment in issue ${issueIdOrKey}: ${error}` - ) - ) + + reject(new Error(`Error editing the comment in issue ${issueIdOrKey}: ${specificError}`)) }) }) } deleteIssueComment(issueIdOrKey: string, commentId: string): Promise { return new Promise((resolve, reject) => { - fetch( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/api/3/issue/${issueIdOrKey}/comment/${commentId}`, - { - method: "DELETE", - headers: { - Accept: "application/json", - Authorization: `Bearer ${this.accessToken}`, - }, - } - ) - .then(async (data) => { - if (data.status === 204) { - resolve() - } - if (data.status === 400) { - reject( - new Error( - "The user does not have permission to delete the comment" - ) - ) - } - if (data.status === 401) { - reject(new Error("User not authenticated")) - } - if (data.status === 404) { - reject( - new Error( - "The issue was not found or the user does not have the necessary permissions" - ) - ) - } - if (data.status === 405) { - reject( - new Error("An anonymous call has been made to the operation") - ) + this.getRestApiClient(3) + .delete(`/issue/${issueIdOrKey}/comment/${commentId}`) + .then(async () => { resolve() }) + .catch((error) => { + let specificError = error + if (error.response) { + if (error.response.status === 400) { + specificError = new Error("The user does not have permission to delete the comment") + } else if (error.response.status === 404) { + specificError = new Error("The issue was not found or the user does not have the necessary permissions") + } else if (error.response.status === 405) { + specificError = new Error("An anonymous call has been made to the operation") + } } - }) - .catch(async (error) => { - reject( - new Error( - `Error editing the comment in issue ${issueIdOrKey}: ${error}` - ) - ) + + reject(new Error(`Error deleting the comment in issue ${issueIdOrKey}: ${specificError}`)) }) }) } deleteIssue(issueIdOrKey: string): Promise { return new Promise((resolve, reject) => { - fetch( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/api/2/issue/${issueIdOrKey}`, - { - method: "DELETE", - headers: { - Accept: "application/json", - Authorization: `Bearer ${this.accessToken}`, - }, - } - ) - .then(async (data) => { - if (data.status === 204) { - resolve() - } - if (data.status === 400) { - reject( - new Error( - "The issue has subtasks and deleteSubtasks is not set to true" - ) - ) - } - if (data.status === 401) { - reject(new Error("User not authenticated")) - } - if (data.status === 403) { - reject( - new Error("The user does not have permission to delete the issue") - ) - } - if (data.status === 404) { - reject( - new Error( - "The issue was not found or the user does not have the necessary permissions" - ) - ) - } - if (data.status === 405) { - reject( - new Error("An anonymous call has been made to the operation") - ) + this.getRestApiClient(2) + .delete(`/issue/${issueIdOrKey}`) + .then(async () => { resolve() }) + .catch((error) => { + let specificError = error + if (error.response) { + if (error.response.status === 400) { + specificError = new Error("The issue has subtasks and deleteSubtasks is not set to true") + } else if (error.response.status === 403) { + specificError = new Error("The user does not have permission to delete the issue") + } else if (error.response.status === 404) { + specificError = new Error("The issue was not found or the user does not have the necessary permissions") + } else if (error.response.status === 405) { + specificError = new Error("An anonymous call has been made to the operation") + } } - }) - .catch(async (error) => { - reject( - new Error(`Error deleting the subtask ${issueIdOrKey}: ${error}`) - ) + + reject(new Error(`Error deleting the subtask ${issueIdOrKey}: ${specificError}`)) }) }) } @@ -1433,16 +1027,10 @@ export class JiraCloudProvider implements IProvider { subtaskIssueTypeId: string ): Promise<{ id: string; key: string }> { return new Promise((resolve, reject) => { - fetch( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/api/3/issue/`, - { - method: "POST", - headers: { - Accept: "application/json", - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ + this.getRestApiClient(3) + .post( + '/issue', + { fields: { summary: subtaskSummary, issuetype: { @@ -1455,27 +1043,11 @@ export class JiraCloudProvider implements IProvider { id: projectId, }, }, - }), - } - ) - .then(async (data) => { - if (data.status === 201) { - const createdSubtask: { id: string; key: string } = - await data.json() - resolve(createdSubtask) - } else if (data.status === 400) { - reject(new Error(`Invalid request: ${await data.json()}`)) - } else if (data.status === 401) { - reject(new Error(`User not authenticated: ${await data.json()}`)) - } else if (data.status === 403) { - reject( - new Error( - `User does not have a valid licence: ${await data.json()}` - ) - ) - } else { - reject(new Error(`Unknown error: ${await data.json()}`)) } + ) + .then(async (response) => { + const createdSubtask: { id: string; key: string } = response.data + resolve(createdSubtask) }) .catch((error) => { reject(new Error(`Error creating subtask: ${error}`)) @@ -1486,9 +1058,11 @@ export class JiraCloudProvider implements IProvider { getResource(): Promise { return new Promise((resolve, reject) => { if (this.accessToken !== undefined) { + // IMPROVE expose API client instead of resource + const {defaults} = this.getRestApiClient(3) const result: Resource = { - baseUrl: `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/api/3/`, - authorization: `Bearer ${this.accessToken}`, + baseUrl: defaults.baseURL ?? '', + authorization: defaults.headers.Authorization as string, } resolve(result) } else { @@ -1508,16 +1082,10 @@ export class JiraCloudProvider implements IProvider { const offsetEndDate = this.offsetDate(endDate) return new Promise((resolve, reject) => { - fetch( - `https://api.atlassian.com/ex/jira/${this.cloudID}/rest/agile/1.0/sprint`, - { - method: "POST", - headers: { - Accept: "application/json", - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ + this.getAgileRestApiClient('1.0') + .post( + '/sprint', + { name, originBoardId, ...(offsetStartDate && { @@ -1527,34 +1095,20 @@ export class JiraCloudProvider implements IProvider { endDate: offsetEndDate, }), ...(goal && { goal }), - }), - } - ) - .then(async (data) => { - if (data.status === 201) { - resolve() } - if (data.status === 400) { - reject(new Error("Invalid request")) - } - if (data.status === 401) { - reject(new Error("User not authenticated")) - } - if (data.status === 403) { - reject( - new Error("The user does not have the necessary permissions") - ) - } - if (data.status === 404) { - reject( - new Error( - "The Board does not exists or the user does not have the necessary permissions to view it" - ) - ) - } - }) + ) + .then(async () => { resolve() }) .catch((error) => { - reject(new Error(`Error creating sprint: ${error}`)) + let specificError = error + if (error.response) { + if (error.response.status === 403) { + specificError = new Error("The user does not have the necessary permissions") + } else if (error.response.status === 404) { + specificError = new Error("The Board does not exist or the user does not have the necessary permissions to view it") + } + } + + reject(new Error(`Error creating sprint: ${specificError}`)) }) }) } diff --git a/package.json b/package.json index fa4b0f52..2d64d99f 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@tanstack/react-query-devtools": "^4.23.0", "@types/file-saver": "^2.0.5", "@types/react-beautiful-dnd": "^13.1.3", + "axios": "^1.6.1", "cross-fetch": "^3.1.5", "dayjs": "^1.11.7", "dotenv": "^16.0.3", diff --git a/yarn.lock b/yarn.lock index 67344ac0..5ed5e3dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2416,6 +2416,15 @@ axe-core@^4.6.2: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.8.2.tgz#2f6f3cde40935825cf4465e3c1c9e77b240ff6ae" integrity sha512-/dlp0fxyM3R8YW7MFzaHWXrf4zzbr0vaYb23VBFCl83R7nWNPg/yaQw2Dc8jzCMmDVLhSdzH8MjrsuIUuvX+6g== +axios@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.1.tgz#76550d644bf0a2d469a01f9244db6753208397d7" + integrity sha512-vfBmhDpKafglh0EldBEbVuoe7DyAavGSLWhuSm5ZSEKQnHhBf0xAAwybbNH1IkrJNGnS/VG4I5yxig1pCEXE4g== + dependencies: + follow-redirects "^1.15.0" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + axobject-query@^3.1.1: version "3.2.1" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-3.2.1.tgz#39c378a6e3b06ca679f29138151e45b2b32da62a" @@ -4275,6 +4284,11 @@ flora-colossus@^2.0.0: debug "^4.3.4" fs-extra "^10.1.0" +follow-redirects@^1.15.0: + version "1.15.3" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.3.tgz#fe2f3ef2690afce7e82ed0b44db08165b207123a" + integrity sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q== + for-each@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" @@ -6892,6 +6906,11 @@ proxy-addr@^2.0.7, proxy-addr@~2.0.7: forwarded "0.2.0" ipaddr.js "1.9.1" +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + psl@^1.1.33: version "1.9.0" resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7" From a97fee60f8c7fd4a39661bf19293cc24fd7ba731 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= <78490564+maximilianruesch@users.noreply.github.com> Date: Tue, 21 Nov 2023 22:08:52 +0100 Subject: [PATCH 2/4] Migrate Jira server to axios (#64) --- .../JiraServerProvider.ts | 414 ++++++++---------- 1 file changed, 191 insertions(+), 223 deletions(-) diff --git a/electron/providers/jira-server-provider/JiraServerProvider.ts b/electron/providers/jira-server-provider/JiraServerProvider.ts index a45a3a14..6cd77268 100644 --- a/electron/providers/jira-server-provider/JiraServerProvider.ts +++ b/electron/providers/jira-server-provider/JiraServerProvider.ts @@ -1,5 +1,5 @@ /* eslint-disable class-methods-use-this */ -import fetch from "cross-fetch" +import axios, {AxiosError, AxiosResponse, isAxiosError} from "axios"; import { dateTimeFormat, Issue, @@ -11,13 +11,8 @@ import { SprintCreate, User, } from "../../../types" -import { - JiraIssue, - JiraIssueType, - JiraProject, - JiraSprint, -} from "../../../types/jira" -import { IProvider } from "../base-provider" +import {JiraIssue, JiraIssueType, JiraProject, JiraSprint,} from "../../../types/jira" +import {IProvider} from "../base-provider" export class JiraServerProvider implements IProvider { private loginOptions = { @@ -28,12 +23,65 @@ export class JiraServerProvider implements IProvider { private customFields = new Map() - getAuthHeader() { + private getAuthHeader() { return `Basic ${Buffer.from( `${this.loginOptions.username}:${this.loginOptions.password}` ).toString("base64")}` } + private constructRestBasedClient(apiName: string, version: string) { + const instance = axios.create({ + baseURL: `${this.loginOptions.url}/rest/${apiName}/${version}`, + headers: { + Accept: "application/json", + Authorization: this.getAuthHeader(), + "Content-Type": "application/json", + }, + }) + + const recreateAxiosError = (originalError: AxiosError, message: string) => new AxiosError( + message, + originalError.code, + originalError.config, + originalError.request, + originalError.response + ) + + instance.interceptors.response.use( + (response) => response, + (error) => { + if (isAxiosError(error) && error.response) { + const statusCode = error.response.status + if (statusCode === 400) { + return Promise.reject(recreateAxiosError(error, `Invalid request: ${JSON.stringify(error.response.data)}`)) + } if (statusCode === 401) { + return Promise.reject(recreateAxiosError(error, `User not authenticated: ${JSON.stringify(error.response.data)}`)) + } if (error.response.status === 403) { + return Promise.reject(recreateAxiosError(error, `User does not have a valid licence: ${JSON.stringify(error.response.data)}`)) + } if (error.response.status === 429) { + return Promise.reject(recreateAxiosError(error, `Rate limit exceeded: ${JSON.stringify(error.response.data)}`)) + } + } + + return Promise.reject(error) + } + ) + + return instance + } + + private getRestApiClient(version: number) { + return this.constructRestBasedClient('api', version.toString()); + } + + private getAuthRestApiClient(version: number) { + return this.constructRestBasedClient('auth', version.toString()); + } + + private getAgileRestApiClient(version: string) { + return this.constructRestBasedClient('agile', version); + } + async login({ basicLoginOptions, }: { @@ -53,166 +101,133 @@ export class JiraServerProvider implements IProvider { async isLoggedIn(): Promise { return new Promise((resolve, reject) => { - fetch(`${this.loginOptions.url}/rest/auth/1/session`, { - method: "GET", - headers: { - Accept: "application/json", - Authorization: this.getAuthHeader(), - }, - }) - .then((response) => { - if (response.status === 200) resolve() - if (response.status === 401) { - reject(new Error("Wrong Username or Password")) - } - if (response.status === 404) { - reject(new Error("Wrong URL")) + this.getAuthRestApiClient(1) + .get('/session') + .then(() => { resolve() }) + .catch((error) => { + if (isAxiosError(error) && error.response) { + if (error.response.status === 401) { + return Promise.reject(new Error("Wrong Username or Password")) + } if (error.response.status === 404) { + return Promise.reject(new Error("Wrong URL")) + } } + + return Promise.reject(error) }) - .catch((err) => { - if (err.name === "FetchError") reject(new Error("Wrong URL")) + .catch((error) => { + reject(new Error(`Error in checking login status: ${error}`)) }) }) } async logout(): Promise { return new Promise((resolve, reject) => { - fetch(`${this.loginOptions.url}/rest/auth/1/session`, { - method: "DELETE", - headers: { - Authorization: this.getAuthHeader(), - }, - }).then((res) => { - if (res.status === 204) { - resolve() - } - if (res.status === 401) { - reject(new Error("user not authenticated")) - } - }) + this.getAuthRestApiClient(1) + .delete('/session') + .then(() => { resolve() }) + .catch((error) => { + reject(new Error(`Error in logging out: ${error}`)) + }) }) } async mapCustomFields(): Promise { - const response = await fetch(`${this.loginOptions.url}/rest/api/2/field`, { - method: "GET", - headers: { - Accept: "application/json", - Authorization: this.getAuthHeader(), - }, - }) - const data = await response.json() - data.forEach((field: { name: string; id: string }) => { - this.customFields.set(field.name, field.id) + return new Promise((resolve, reject) => { + this.getRestApiClient(2) + .get('/field') + .then((response) => { + response.data.forEach((field: { name: string; id: string }) => { + this.customFields.set(field.name, field.id) + }) + resolve() + }) + .catch((error) => { + reject(new Error(`Error in mapping custom fields: ${error}`)) + }) }) } async getProjects(): Promise { - const response = await fetch( - `${this.loginOptions.url}/rest/api/2/project?expand=lead,description`, - { - method: "GET", - headers: { - Accept: "application/json", - Authorization: this.getAuthHeader(), - }, - } - ) - if (response.ok) { - const data = await response.json() - const projects = data.map((project: JiraProject) => ({ - key: project.key, - name: project.name, - id: project.id, - lead: project.lead.displayName, - type: project.projectTypeKey, - })) - return projects - } - return Promise.reject(new Error(response.statusText)) + return new Promise((resolve) => { + this.getRestApiClient(2) + .get('/project?expand=lead,description') + .then((response) => { + const projects = response.data.map((project: JiraProject) => ({ + key: project.key, + name: project.name, + id: project.id, + lead: project.lead.displayName, + type: project.projectTypeKey, + })) + resolve(projects) + }) + }) } async getIssueTypesByProject(projectIdOrKey: string): Promise { return new Promise((resolve, reject) => { - fetch( - `${this.loginOptions.url}/rest/api/2/project/${projectIdOrKey}/statuses`, - { - headers: { - Accept: "application/json", - Authorization: this.getAuthHeader(), - }, - } - ) + this.getRestApiClient(2) + .get(`/project/${projectIdOrKey}/statuses`) .then(async (response) => { - const issueTypes: JiraIssueType[] = await response.json() + const issueTypes: JiraIssueType[] = response.data resolve(issueTypes as IssueType[]) }) - .catch((error) => - reject(new Error(`Error in fetching the issue types: ${error}`)) - ) + .catch((error) => reject(new Error(`Error in fetching the issue types: ${error}`))) }) } async getBoardIds(project: string): Promise { - const response = await fetch( - `${this.loginOptions.url}/rest/agile/1.0/board?projectKeyOrId=${project}`, - { - method: "GET", - headers: { - Accept: "application/json", - Authorization: this.getAuthHeader(), - }, - } - ) - - const data = await response.json() - - const boardIds: number[] = data.values.map( - (element: { id: number; name: string }) => element.id - ) - return boardIds + return new Promise((resolve, reject) => { + this.getAgileRestApiClient('1.0') + .get(`/board?projectKeyOrId=${project}`) + .then(async (response) => { + const boardIds: number[] = response.data.values.map( + (element: { id: number; name: string }) => element.id + ) + resolve(boardIds) + }) + .catch((error) => reject(new Error(`Error in fetching the boards: ${error}`))) + }) } async getSprints(boardId: number): Promise { - const response = await fetch( - `${this.loginOptions.url}/rest/agile/1.0/board/${boardId}/sprint`, - { - method: "GET", - headers: { - Accept: "application/json", - Authorization: this.getAuthHeader(), - }, - } - ) - - const data = await response.json() - - const sprints: Sprint[] = data.values - .filter((element: { state: string }) => element.state !== "closed") - .map((element: JiraSprint) => { - const sDate = new Date(element.startDate) - const startDate = Number.isNaN(sDate.getTime()) - ? "Invalid Date" - : dateTimeFormat.format(sDate) - const eDate = new Date(element.endDate) - const endDate = Number.isNaN(eDate.getTime()) - ? "Invalid Date" - : dateTimeFormat.format(eDate) - return { - id: element.id, - name: element.name, - state: element.state, - startDate, - endDate, - } - }) - return sprints + return new Promise((resolve, reject) => { + this.getAgileRestApiClient('1.0') + .get(`/board/${boardId}/sprint`) + .then(async (response) => { + const sprints: Sprint[] = response.data.values + .filter((element: { state: string }) => element.state !== "closed") + .map((element: JiraSprint) => { + const sDate = new Date(element.startDate) + const startDate = Number.isNaN(sDate.getTime()) + ? "Invalid Date" + : dateTimeFormat.format(sDate) + const eDate = new Date(element.endDate) + const endDate = Number.isNaN(eDate.getTime()) + ? "Invalid Date" + : dateTimeFormat.format(eDate) + return { + id: element.id, + name: element.name, + state: element.state, + startDate, + endDate, + } + }) + resolve(sprints) + }) + .catch((error) => reject(new Error(`Error in fetching the boards: ${error}`))) + }) } async getIssuesByProject(project: string, boardId: number): Promise { - return this.fetchIssues( - `${this.loginOptions.url}/rest/agile/1.0/board/${boardId}/issue?jql=project=${project}&maxResults=10000` - ) + return new Promise((resolve, reject) => { + this.getAgileRestApiClient('1.0') + .get(`/board/${boardId}/issue?jql=project=${project}&maxResults=10000`) + .then((response) => resolve(this.fetchIssues(response))) + .catch((error) => reject(new Error(`Error in fetching issues: ${error}`))) + }) } async getIssuesBySprintAndProject( @@ -220,34 +235,31 @@ export class JiraServerProvider implements IProvider { project: string, boardId: number ): Promise { - return this.fetchIssues( - `${this.loginOptions.url}/rest/agile/1.0/board/${boardId}/sprint/${sprintId}/issue?jql=project=${project}` - ) + return new Promise((resolve, reject) => { + this.getAgileRestApiClient('1.0') + .get(`/board/${boardId}/sprint/${sprintId}/issue?jql=project=${project}`) + .then((response) => resolve(this.fetchIssues(response))) + .catch((error) => reject(new Error(`Error in fetching issues: ${error}`))) + }) } async getBacklogIssuesByProjectAndBoard( project: string, boardId: number ): Promise { - return this.fetchIssues( - `${this.loginOptions.url}/rest/agile/1.0/board/${boardId}/backlog?jql=sprint is EMPTY AND project=${project}` - ) + return new Promise((resolve, reject) => { + this.getAgileRestApiClient('1.0') + .get(`/board/${boardId}/backlog?jql=sprint is EMPTY AND project=${project}`) + .then((response) => resolve(this.fetchIssues(response))) + .catch((error) => reject(new Error(`Error in fetching issues: ${error}`))) + }) } - async fetchIssues(url: string): Promise { + async fetchIssues(response: AxiosResponse): Promise { const rankCustomField = this.customFields.get("Rank") - const response = await fetch(url, { - method: "GET", - headers: { - Accept: "application/json", - Authorization: this.getAuthHeader(), - }, - }) - const data = await response.json() - - const issues: Promise = Promise.all( - data.issues.map(async (element: JiraIssue) => ({ + return Promise.all( + response.data.issues.map(async (element: JiraIssue) => ({ issueKey: element.key, summary: element.fields.summary, creator: element.fields.creator.name, @@ -265,7 +277,6 @@ export class JiraServerProvider implements IProvider { rank: element.fields[rankCustomField!], })) ) - return issues } async moveIssueToSprintAndRank( @@ -276,53 +287,33 @@ export class JiraServerProvider implements IProvider { ): Promise { return new Promise((resolve, reject) => { const rankCustomField = this.customFields.get("Rank") - const body = { - rankCustomFieldId: rankCustomField!.match(/_(\d+)/)![1], - issues: [issue], - ...(rankAfter && { rankAfterIssue: rankAfter }), - ...(rankBefore && { rankBeforeIssue: rankBefore }), - } - fetch(`${this.loginOptions.url}/rest/agile/1.0/sprint/${sprint}/issue`, { - method: "POST", - headers: { - Accept: "application/json", - Authorization: this.getAuthHeader(), - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }) + this.getAgileRestApiClient('1.0') + .post( + `/sprint/${sprint}/issue`, + { + rankCustomFieldId: rankCustomField!.match(/_(\d+)/)![1], + issues: [issue], + ...(rankAfter && { rankAfterIssue: rankAfter }), + ...(rankBefore && { rankBeforeIssue: rankBefore }), + } + ) .then(() => resolve()) - .catch((error) => { - reject( - new Error( - `Error in moving this issue to the Sprint with id ${sprint}: ${error}` - ) - ) + reject(new Error(`Error in moving this issue to the Sprint with id ${sprint}: ${error}`)) }) }) } async moveIssueToBacklog(issue: string): Promise { return new Promise((resolve, reject) => { - fetch(`${this.loginOptions.url}/rest/agile/1.0/backlog/issue`, { - method: "POST", - headers: { - Accept: "application/json", - Authorization: this.getAuthHeader(), - "Content-Type": "application/json", - }, - body: `{ - "issues": [ - "${issue}" - ] - }`, - }) + this.getAgileRestApiClient('1.0') + .post( + '/backlog/issue', + { issues: [issue] } + ) .then(() => resolve()) .catch((error) => - reject( - new Error(`Error in moving this issue to the Backlog: ${error}`) - ) + reject(new Error(`Error in moving this issue to the Backlog: ${error}`)) ) }) } @@ -348,50 +339,27 @@ export class JiraServerProvider implements IProvider { } else if (rankAfter) { body.rankAfterIssue = rankAfter } - fetch(`http://${this.loginOptions.url}/rest/agile/1.0/issue/rank`, { - method: "PUT", - headers: { - Accept: "application/json", - Authorization: this.getAuthHeader(), - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }) - .then(() => { - resolve() - }) - + this.getAgileRestApiClient('1.0') + .put('/issue/rank', body) + .then(() => resolve()) .catch((error) => - reject( - new Error(`Error in moving this issue to the Backlog: ${error}`) - ) + reject(new Error(`Error in moving this issue to the Backlog: ${error}`)) ) }) } async getIssueStoryPointsEstimate(issue: string): Promise { return new Promise((resolve, reject) => { - fetch(`${this.loginOptions.url}/rest/api/2/issue/${issue}`, { - method: "GET", - headers: { - Accept: "application/json", - Authorization: this.getAuthHeader(), - }, - }) + this.getRestApiClient(2) + .get(`/issue/${issue}`) .then(async (response) => { - const data = await response.json() const customField = this.customFields.get("Story Points") - const points: number = data.fields[customField!] + const points: number = response.data.fields[customField!] resolve(points) - return points }) .catch((error) => - reject( - new Error( - `Error in getting the story points for issue: ${issue}: ${error}` - ) - ) + reject(new Error(`Error in getting the story points for issue: ${issue}: ${error}`)) ) }) } From de6812cea1338a8a7acdc652738f7d723a013438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= <78490564+maximilianruesch@users.noreply.github.com> Date: Tue, 21 Nov 2023 22:43:54 +0100 Subject: [PATCH 3/4] Migrate user functionality in server (#65) --- .../JiraServerProvider.ts | 35 +++++++++++++++---- .../CreateIssue/CreateIssueModal.tsx | 8 +++-- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/electron/providers/jira-server-provider/JiraServerProvider.ts b/electron/providers/jira-server-provider/JiraServerProvider.ts index 6cd77268..81723e58 100644 --- a/electron/providers/jira-server-provider/JiraServerProvider.ts +++ b/electron/providers/jira-server-provider/JiraServerProvider.ts @@ -363,21 +363,44 @@ export class JiraServerProvider implements IProvider { ) }) } - /* eslint-disable @typescript-eslint/no-unused-vars */ getAssignableUsersByProject(projectIdOrKey: string): Promise { - throw new Error("Method not implemented for Jira Server") + return new Promise((resolve, reject) => { + this.getRestApiClient(2) + .get(`/user/assignable/search?project=${projectIdOrKey}`) + .then(async (response) => { + resolve(response.data as User[]) + }) + .catch((error) => { + if (error.response) { + if (error.response.status === 404) { + return Promise.reject(Error(`Project was not found: ${error.response.data}`)) + } + } + + return Promise.reject(error) + }) + .catch((error) => { + reject(new Error(`Error in fetching the assignable users for the project ${projectIdOrKey}: ${error}`)) + }) + }) } - createIssue(issue: Issue): Promise { - throw new Error("Method not implemented for Jira Server") + getCurrentUser(): Promise { + return new Promise((resolve, reject) => { + this.getRestApiClient(2) + .get('/myself') + .then(async (response) => resolve(response.data as User)) + .catch((error) => reject(new Error(`Error in the current user: ${error}`))) + }) } + /* eslint-disable @typescript-eslint/no-unused-vars */ - getEpicsByProject(projectIdOrKey: string): Promise { + createIssue(issue: Issue): Promise { throw new Error("Method not implemented for Jira Server") } - getCurrentUser(): Promise { + getEpicsByProject(projectIdOrKey: string): Promise { throw new Error("Method not implemented for Jira Server") } diff --git a/src/components/CreateIssue/CreateIssueModal.tsx b/src/components/CreateIssue/CreateIssueModal.tsx index 78ed00b8..076980f8 100644 --- a/src/components/CreateIssue/CreateIssueModal.tsx +++ b/src/components/CreateIssue/CreateIssueModal.tsx @@ -83,8 +83,12 @@ export function CreateIssueModal({ const { data: assignableUsers } = useQuery({ queryKey: ["assignableUsers", form.getInputProps("projectId").value], - queryFn: () => - getAssignableUsersByProject(form.getInputProps("projectId").value!), + queryFn: () => { + const relevantProject = projects + .find((project) => project.id === form.getInputProps("projectId").value!)! + + return getAssignableUsersByProject(relevantProject.key) + }, enabled: !!projects && !!form.getInputProps("projectId").value, }) From b0a36f7aa89c3742daf962ec9d660562c72f9fa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= <78490564+maximilianruesch@users.noreply.github.com> Date: Tue, 21 Nov 2023 22:51:42 +0100 Subject: [PATCH 4/4] Migrate issue functionality in server (#66) --- .../JiraServerProvider.ts | 247 ++++++++++++++++-- 1 file changed, 232 insertions(+), 15 deletions(-) diff --git a/electron/providers/jira-server-provider/JiraServerProvider.ts b/electron/providers/jira-server-provider/JiraServerProvider.ts index 81723e58..4789b98f 100644 --- a/electron/providers/jira-server-provider/JiraServerProvider.ts +++ b/electron/providers/jira-server-provider/JiraServerProvider.ts @@ -23,6 +23,8 @@ export class JiraServerProvider implements IProvider { private customFields = new Map() + private reversedCustomFields = new Map() + private getAuthHeader() { return `Basic ${Buffer.from( `${this.loginOptions.username}:${this.loginOptions.password}` @@ -95,7 +97,7 @@ export class JiraServerProvider implements IProvider { this.loginOptions.username = basicLoginOptions.username this.loginOptions.password = basicLoginOptions.password - // await this.mapCustomFields() + await this.mapCustomFields() return this.isLoggedIn() } @@ -139,6 +141,7 @@ export class JiraServerProvider implements IProvider { .then((response) => { response.data.forEach((field: { name: string; id: string }) => { this.customFields.set(field.name, field.id) + this.reversedCustomFields.set(field.id, field.name) }) resolve() }) @@ -262,19 +265,25 @@ export class JiraServerProvider implements IProvider { response.data.issues.map(async (element: JiraIssue) => ({ issueKey: element.key, summary: element.fields.summary, - creator: element.fields.creator.name, + creator: element.fields.creator.displayName, status: element.fields.status.name, type: element.fields.issuetype.name, - storyPointsEstimate: await this.getIssueStoryPointsEstimate( - element.key - ), - epic: element.fields.epic?.name, + storyPointsEstimate: await this.getIssueStoryPointsEstimate(element.key), + epic: element.fields.parent?.fields.summary, labels: element.fields.labels, assignee: { displayName: element.fields.assignee?.displayName, avatarUrls: element.fields.assignee?.avatarUrls, }, rank: element.fields[rankCustomField!], + description: element.fields.description, + subtasks: element.fields.subtasks, + created: element.fields.created, + updated: element.fields.updated, + comment: element.fields.comment, + projectId: element.fields.project.id, + sprint: element.fields.sprint, + attachments: element.fields.attachment, })) ) } @@ -405,7 +414,16 @@ export class JiraServerProvider implements IProvider { } getIssuesBySprint(sprintId: number): Promise { - throw new Error("Method not implemented for Jira Server") + return new Promise((resolve, reject) => { + this.getAgileRestApiClient('1.0') + .get(`/sprint/${sprintId}/issue`) + .then(async (response) => { + resolve(this.fetchIssues(response)) + }) + .catch((error) => { + reject(new Error(`Error fetching issues by sprint ${sprintId}: ${error}`)) + }) + }) } getLabels(): Promise { @@ -413,11 +431,46 @@ export class JiraServerProvider implements IProvider { } getPriorities(): Promise { - throw new Error("Method not implemented for Jira Server") + return new Promise((resolve, reject) => { + this.getRestApiClient(2) + .get('/priority') + .then((response) => { + const priorityData: Priority[] = response.data + resolve(priorityData) + }) + .catch((error) => + reject(new Error(`Error in fetching priorities: ${error}`)) + ) + }) } getIssueTypesWithFieldsMap(): Promise<{ [key: string]: string[] }> { - throw new Error("Method not implemented for Jira Server") + return new Promise((resolve) => { + // IMPROVE: This is barely scalable + this.getProjects() + .then(async (projects) => { + const issueTypeToFieldsMap: { [key: string]: string[] } = {} + await Promise.all(projects.map((project) => + // IMPROVE: This call currently only supports 50 issue types + this.getRestApiClient(2) + .get(`/issue/createmeta/${project.id}/issuetypes`) + .then(async (response) => { + await Promise.all(response.data.values.map((issueType: { id: string }) => + // IMPROVE: This call currently only supports 50 issue types + this.getRestApiClient(2) + .get(`/issue/createmeta/${project.id}/issuetypes/${issueType.id}`) + .then((issueTypesResponse) => { + issueTypeToFieldsMap[issueType.id] = issueTypesResponse.data.values.map( + (issueTypeField: { fieldId: string }) => this.reversedCustomFields.get(issueTypeField.fieldId)! + ) + }) + )) + }) + )) + + return resolve(issueTypeToFieldsMap) + }) + }) } getResource(): Promise { @@ -429,7 +482,25 @@ export class JiraServerProvider implements IProvider { } deleteIssue(issueIdOrKey: string): Promise { - throw new Error("Method not implemented for Jira Server") + return new Promise((resolve, reject) => { + this.getRestApiClient(2) + .delete(`/issue/${issueIdOrKey}?deleteSubtasks`) + .then(async () => { resolve() }) + .catch((error) => { + let specificError = error + if (error.response) { + if (error.response.status === 403) { + specificError = new Error("The user does not have permission to delete the issue") + } else if (error.response.status === 404) { + specificError = new Error("The issue was not found or the user does not have the necessary permissions") + } else if (error.response.status === 405) { + specificError = new Error("An anonymous call has been made to the operation") + } + } + + reject(new Error(`Error deleting the issue ${issueIdOrKey}: ${specificError}`)) + }) + }) } createSubtask( @@ -441,20 +512,157 @@ export class JiraServerProvider implements IProvider { throw new Error("Method not implemented for Jira Server") } - editIssue(issue: Issue, issueIdOrKey: string): Promise { - throw new Error("Method not implemented for Jira Server") + editIssue( + { + summary, + type, + projectId, + reporter, + assignee, + sprint, + storyPointsEstimate, + description, + epic, + startDate, + dueDate, + labels, + priority, + }: Issue, + issueIdOrKey: string + ): Promise { + const offsetStartDate = this.offsetDate(startDate) + const offsetDueDate = this.offsetDate(dueDate) + + return new Promise((resolve, reject) => { + this.getRestApiClient(2) + .put( + `/issue/${issueIdOrKey}`, + { + fields: { + ...(summary && { + summary, + }), + ...(epic && { + parent: { key: epic }, + }), + ...(type && { + issuetype: { id: type }, + }), + ...(projectId && { + project: { + id: projectId, + }, + }), + ...(reporter && { + reporter: { + id: reporter, + }, + }), + ...(priority && priority.id && { priority }), + ...(assignee && + assignee.id && { + assignee, + }), + ...(description && { + description + }), + ...(labels && { + labels, + }), + ...(offsetStartDate && { + [this.customFields.get("Start date")!]: offsetStartDate, + }), + ...(offsetDueDate && { + [this.customFields.get("Due date")!]: offsetDueDate, + }), + ...(sprint && { + [this.customFields.get("Sprint")!]: sprint.id, + }), + ...(storyPointsEstimate !== undefined && { + [this.customFields.get("Story point estimate")!]: + storyPointsEstimate, + }), + }, + } + ) + .then(async () => { resolve() }) + .catch((error) => { + let specificError = error + if (error.response) { + if (error.response.status === 404) { + specificError = new Error( + "The issue was not found or the user does not have the necessary permissions" + ) + } + } + + reject(new Error(`Error creating issue: ${specificError}`)) + }) + }) } setTransition(issueIdOrKey: string, targetStatus: string): Promise { - throw new Error("Method not implemented for Jira Server") + return new Promise((resolve, reject) => { + this.getRestApiClient(2) + .get(`/issue/${issueIdOrKey}/transitions`) + .then((response) => { + const transitions = new Map() + response.data.transitions.forEach((field: { name: string; id: string }) => { + transitions.set(field.name, field.id) + }) + + const transitionId = +transitions.get(targetStatus)! + + return this + .getRestApiClient(2) + .post( + `/issue/${issueIdOrKey}/transitions`, + { transition: { id: transitionId } } + ) + }) + .then(() => resolve()) + .catch((error) => { + reject(new Error(`Error setting transition: ${error}`)) + }) + }) } getEditableIssueFields(issueIdOrKey: string): Promise { - throw new Error("Method not implemented for Jira Server") + return new Promise((resolve, reject) => { + this.getRestApiClient(2) + .get(`/issue/${issueIdOrKey}/editmeta`) + .then(async (response) => { + const fieldKeys = Object.keys(response.data.fields).map( + (fieldKey) => this.reversedCustomFields.get(fieldKey)! + ) + resolve(fieldKeys) + }) + .catch((error) => + reject(new Error(`Error in fetching the issue types map: ${error}`)) + ) + }) } getIssueReporter(issueIdOrKey: string): Promise { - throw new Error("Method not implemented for Jira Server") + return new Promise((resolve, reject) => { + this.getRestApiClient(2) + .get(`/issue/${issueIdOrKey}?fields=reporter`) + .then(async (response) => { + resolve(response.data.fields.reporter as User) + }) + .catch((error) => { + let specificError = error + if (error.response) { + if (error.response.status === 404) { + specificError = new Error( + `The issue was not found or the user does not have permission to view it: ${error.response.data}` + ) + } + } + + reject(new Error(`Error in fetching the issue reporter: ${specificError}`)) + }) + }) } addCommentToIssue(issueIdOrKey: string, commentText: string): Promise { @@ -479,4 +687,13 @@ export class JiraServerProvider implements IProvider { }): Promise { throw new Error("Method not implemented for Jira Server") } + + offsetDate(date: Date) { + if (!date) { + return date + } + const convertedDate = new Date(date) + const timezoneOffset = convertedDate.getTimezoneOffset() + return new Date(convertedDate.getTime() - timezoneOffset * 60 * 1000) + } }