diff --git a/src/jira/client/jira-api-client.test.ts b/src/jira/client/jira-api-client.test.ts new file mode 100644 index 000000000..14709902f --- /dev/null +++ b/src/jira/client/jira-api-client.test.ts @@ -0,0 +1,799 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { getLogger } from "config/logger"; +import { JiraClient } from "./jira-api-client"; +import { DatabaseStateCreator } from "test/utils/database-state-creator"; +import { TransformedRepositoryId } from "~/src/transforms/transform-repository-id"; +import { JiraBuildBulkSubmitData, JiraVulnerabilityBulkSubmitData } from "interfaces/jira"; + +describe("JiraClient", () => { + let jiraClient: JiraClient | null; + beforeEach(async () => { + const { installation } = await new DatabaseStateCreator().create(); + jiraClient = installation && await JiraClient.create(installation, undefined, getLogger("test")); + }); + + describe("isAuthorized()", () => { + + it("is true when response is 200", async () => { + jiraNock + .get("/rest/devinfo/0.10/existsByProperties?fakeProperty=1") + .reply(200); + + const isAuthorized = await jiraClient?.isAuthorized(); + expect(isAuthorized).toBe(true); + }); + + it("is false when response is 302", async () => { + jiraNock + .get("/rest/devinfo/0.10/existsByProperties?fakeProperty=1") + .reply(302); + + const isAuthorized = await jiraClient?.isAuthorized(); + expect(isAuthorized).toBe(false); + }); + + it("is false when response is 403", async () => { + jiraNock + .get("/rest/devinfo/0.10/existsByProperties?fakeProperty=1") + .reply(403); + + const isAuthorized = await jiraClient?.isAuthorized(); + expect(isAuthorized).toBe(false); + }); + + it("rethrows non-response errors", async () => { + jiraClient && jest.spyOn(jiraClient.axios, "get").mockImplementation(() => { + throw new Error("boom"); + }); + + await expect(jiraClient?.isAuthorized()).rejects.toThrow("boom"); + }); + }); + + describe("appPropertiesCreate()", () => { + test.each([true, false])("sets up %s", async (value) => { + jiraNock + .put("/rest/atlassian-connect/latest/addons/com.github.integration.test-atlassian-instance/properties/is-configured", { + isConfigured: value + }) + .reply(200); + + expect(await jiraClient?.appPropertiesCreate(value)).toBeDefined(); + }); + }); + + describe("getCloudId()", () => { + it("should return cloudId data", async () => { + jiraNock + .get("/_edge/tenant_info") + .reply(200, "cat"); + + const data = await jiraClient!.getCloudId(); + expect(data).toEqual("cat"); + }); + }); + + describe("appPropertiesGet()", () => { + it("returns data", async () => { + jiraNock + .get("/rest/atlassian-connect/latest/addons/com.github.integration.test-atlassian-instance/properties/is-configured") + .reply(200,{ + isConfigured: true + }); + + expect(jiraClient && (await jiraClient.appPropertiesGet()).data.isConfigured).toBeTruthy(); + }); + }); + + describe("appPropertiesDelete()", () => { + it("deletes data", async () => { + jiraNock + .delete("/rest/atlassian-connect/latest/addons/com.github.integration.test-atlassian-instance/properties/is-configured") + .reply(200); + + expect(await jiraClient?.appPropertiesDelete()).toBeDefined(); + }); + }); + + describe("linkedWorkspace()", () => { + it("linked workspace", async () => { + jiraNock.post("/rest/security/1.0/linkedWorkspaces/bulk", { + "workspaceIds": [123] + }).reply(202); + + const jiraRes = await jiraClient?.linkedWorkspace(123); + expect(jiraRes?.status).toEqual(202); + }); + }); + + describe("deleteWorkspace()", () => { + it("delete workspace", async () => { + jiraNock + .delete("/rest/security/1.0/linkedWorkspaces/bulk?workspaceIds=123") + .reply(202); + + const jiraRes = await jiraClient?.deleteWorkspace(123); + expect(jiraRes?.status).toEqual(202); + }); + }); + + describe("checkAdminPermissions()", () => { + + it("checks admin permissions successfully", async () => { + jiraNock + .post("/rest/api/latest/permissions/check")// TODO PASS BODY AND TEST ITS USED + .reply(200, {}); + + const result = await jiraClient?.checkAdminPermissions("123"); + + expect(result?.status).toBe(200); + }); + + it("handles errors gracefully", async () => { + jiraNock + .post("/rest/api/latest/permissions/check") + .reply(500); + + + await expect(jiraClient?.checkAdminPermissions("123")).rejects.toThrow( + "Error executing Axios Request HTTP 500" + ); + }); + + }); + + describe("getIssue()", () => { + + it("gets issue successfully", async () => { + const response = { choice: "as" }; + + jiraNock + .get(`/rest/api/latest/issue/3?fields=summary`) + .reply(200, response); + + const result = await jiraClient?.getIssue("3"); + + expect(result?.status).toBe(200); + expect(result?.data).toEqual(response); + }); + + it("handles errors gracefully", async () => { + jiraNock + .get("/rest/api/latest/issue/3?fields=summary") + .reply(500); + + await expect(jiraClient?.getIssue("3")).rejects.toThrow( + "Error executing Axios Request HTTP 500" + ); + }); + + it("handles not found", async () => { + jiraNock + .get("/rest/api/latest/issue/invalid_issue_id?fields=summary") + .reply(404); + + await expect(jiraClient?.getIssue("invalid_issue_id")).rejects.toThrow( + "Error executing Axios Request HTTP 404" + ); + }); + + }); + + describe("getAllIssues()", () => { + + const issue3 = { id: 3 }; + const issue7 = { id: 7 }; + + it("gets issues successfully", async () => { + jiraNock + .get(`/rest/api/latest/issue/3?fields=summary`) + .reply(200, { issue3 }); + jiraNock + .get(`/rest/api/latest/issue/7?fields=summary`) + .reply(200, { issue7 }); + + const result = await jiraClient?.getAllIssues(["3", "7"]); + + expect(result?.length).toBe(2); + expect.arrayContaining([ + expect.objectContaining({ "issue3": expect.objectContaining({ "id": 3 }) }), + expect.objectContaining({ "issue7": expect.objectContaining({ "id": 7 }) }) + ]); + }); + + it("handles mixture of failure and success responses", async () => { + jiraNock + .get(`/rest/api/latest/issue/3?fields=summary`) + .reply(200, { issue3 }); + jiraNock + .get(`/rest/api/latest/issue/7?fields=summary`) + .reply(200, { issue7 }); + jiraNock + .get(`/rest/api/latest/issue/9?fields=summary`) + .reply(500); + jiraNock + .get(`/rest/api/latest/issue/fake?fields=summary`) + .reply(404); + + const result = await jiraClient?.getAllIssues(["3", "7", "9", "fake"]); + + expect(result?.length).toBe(2); + + expect.arrayContaining([ + expect.objectContaining({ "issue3": expect.objectContaining({ "id": 3 }) }), + expect.objectContaining({ "issue7": expect.objectContaining({ "id": 7 }) }) + ]); + }); + + }); + + describe("listIssueComments()", () => { + + it("lists issue comments successfully", async () => { + const response = { choice: "as" }; + jiraNock + .get("/rest/api/latest/issue/3/comment?expand=properties") + .reply(200, response); + + const result = await jiraClient?.listIssueComments("3"); + + expect(result?.status).toBe(200); + expect(result?.data).toEqual(response); + }); + + it("handles errors gracefully", async () => { + jiraNock + .get("/rest/api/latest/issue/3/comment?expand=properties") + .reply(500); + + await expect(jiraClient?.listIssueComments("3")).rejects.toThrow( + "Error executing Axios Request HTTP 500" + ); + }); + + it("handles not found", async () => { + jiraNock + .get("/rest/api/latest/issue/invalid_issue_id/comment?expand=properties") + .reply(404); + + await expect(jiraClient?.listIssueComments("invalid_issue_id")).rejects.toThrow( + "Error executing Axios Request HTTP 404" + ); + }); + + }); + + describe("addIssueComment()", () => { + + it("adds issue comment successfully", async () => { + jiraNock + .post("/rest/api/latest/issue/3/comment")// TODO PASS BODY AND TEST ITS USED + .reply(200); + + const result = await jiraClient?.addIssueComment("3", "sick new comment"); + + expect(result?.status).toBe(200); + }); + + it("handles errors gracefully", async () => { + jiraNock + .post("/rest/api/latest/issue/3/comment")// TODO PASS BODY AND TEST ITS USED + .reply(500); + + await expect(jiraClient?.addIssueComment("3", "comment doesnt matter for failure sadpanda")).rejects.toThrow( + "Error executing Axios Request HTTP 500" + ); + }); + + }); + + describe("updateIssueComment()", () => { + + it("update issue comment successfully", async () => { + jiraNock + .put("/rest/api/latest/issue/3/comment/9")// TODO PASS BODY AND TEST ITS USED + .reply(200); + + const result = await jiraClient?.updateIssueComment("3", "9", "sick new comment"); + + expect(result?.status).toBe(200); + }); + + it("handles errors gracefully", async () => { + jiraNock + .put("/rest/api/latest/issue/3/comment/9") // TODO PASS BODY AND TEST ITS USED + .reply(500); + + await expect(jiraClient?.updateIssueComment("3", "9", "comment doesnt matter for failure sadderpanda")).rejects.toThrow( + "Error executing Axios Request HTTP 500" + ); + }); + + }); + + describe("deleteIssueComment()", () => { + + it("delete issue comment successfully", async () => { + jiraNock + .delete("/rest/api/latest/issue/3/comment/9") + .reply(200); + + const result = await jiraClient?.deleteIssueComment("3", "9"); + + expect(result?.status).toBe(200); + }); + + it("handles errors gracefully", async () => { + jiraNock + .delete("/rest/api/latest/issue/3/comment/9") + .reply(500); + + await expect(jiraClient?.deleteIssueComment("3", "9")).rejects.toThrow( + "Error executing Axios Request HTTP 500" + ); + }); + + }); + + describe("listIssueTransistions()", () => { + + it("update issue comment successfully", async () => { + const response = { choice: "as" }; + jiraNock + .get("/rest/api/latest/issue/3/transitions") + .reply(200, response); + + const result = await jiraClient?.listIssueTransistions("3"); + + expect(result?.status).toBe(200); + expect(result?.data).toEqual(response); + }); + + it("handles errors gracefully", async () => { + jiraNock + .get("/rest/api/latest/issue/3/transitions") + .reply(500); + + await expect(jiraClient?.listIssueTransistions("3")).rejects.toThrow( + "Error executing Axios Request HTTP 500" + ); + }); + + }); + + describe("updateIssueTransistions()", () => { + + it("update issue transistion successfully", async () => { + const requestBody = { + transition: { id: "1" } + }; + jiraNock + .post("/rest/api/latest/issue/3/transitions", requestBody) + .reply(200); + + const result = await jiraClient?.updateIssueTransistions("3", "1"); + + expect(result?.status).toBe(200); + }); + + it("handles errors gracefully", async () => { + const requestBody = { + transition: { id: "99999" } + }; + jiraNock + .post("/rest/api/latest/issue/3/transitions", requestBody) + .reply(500); + + await expect(jiraClient?.updateIssueTransistions("3", "99999")).rejects.toThrow( + "Error executing Axios Request HTTP 500" + ); + }); + + }); + + describe("addWorklogForIssue()", () => { + + it("update issue transistion successfully", async () => { + const requestBody = { choice: "as" }; + jiraNock + .post("/rest/api/latest/issue/3/worklog", requestBody) + .reply(200); + + const result = await jiraClient?.addWorklogForIssue("3", requestBody); + + expect(result?.status).toBe(200); + }); + + it("handles errors gracefully", async () => { + const requestBody = { choice: "as" }; + jiraNock + .post("/rest/api/latest/issue/3/worklog", requestBody) + .reply(500); + + await expect(jiraClient?.addWorklogForIssue("3", requestBody)).rejects.toThrow( + "Error executing Axios Request HTTP 500" + ); + }); + + }); + + describe("deleteInstallation()", () => { + + it("update issue transistion successfully", async () => { + jiraNock + .delete("/rest/devinfo/0.10/bulkByProperties?installationId=99") + .reply(200); + + jiraNock + .delete("/rest/builds/0.1/bulkByProperties?gitHubInstallationId=99") + .reply(200); + + jiraNock + .delete("/rest/deployments/0.1/bulkByProperties?gitHubInstallationId=99") + .reply(200); + + const result = await jiraClient?.deleteInstallation(99); + + expect(result).toHaveLength(3); + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ status: 200 }), + expect.objectContaining({ status: 200 }), + expect.objectContaining({ status: 200 }) + ]) + ); + }); + + it("handles errors gracefully", async () => { + jiraNock + .delete("/rest/devinfo/0.10/bulkByProperties?installationId=99") + .reply(200); + + jiraNock + .delete("/rest/builds/0.1/bulkByProperties?gitHubInstallationId=99") + .reply(500); + + jiraNock + .delete("/rest/deployments/0.1/bulkByProperties?gitHubInstallationId=99") + .reply(200); + + await expect(jiraClient?.deleteInstallation(99)).rejects.toThrow( + "Error executing Axios Request HTTP 500" + ); + }); + + }); + + describe("deleteBranch()", () => { + const repositoryId: TransformedRepositoryId = "sweet_repository_id" as TransformedRepositoryId; + + beforeEach(() => { + jest.spyOn(Date, "now").mockImplementation(() => 100); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("delete branch successfully", async () => { + jiraNock + .delete("/rest/devinfo/0.10/repository/sweet_repository_id/branch/def333f4?_updateSequenceId=100") + .reply(200); + + const result = await jiraClient?.deleteBranch(repositoryId, "def333f4"); + + expect(result?.status).toBe(200); + }); + + it("handles errors gracefully", async () => { + jiraNock + .delete("/rest/devinfo/0.10/repository/sweet_repository_id/branch/def333f4?_updateSequenceId=100") + .reply(500); + + await expect(jiraClient?.deleteBranch(repositoryId, "def333f4")).rejects.toThrow( + "Error executing Axios Request HTTP 500" + ); + }); + + }); + + describe("deletePullRequest()", () => { + const repositoryId: TransformedRepositoryId = "sweet_repository_id" as TransformedRepositoryId; + + beforeEach(() => { + jest.spyOn(Date, "now").mockImplementation(() => 100); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("delete branch successfully", async () => { + jiraNock + .delete("/rest/devinfo/0.10/repository/sweet_repository_id/pull_request/88?_updateSequenceId=100") + .reply(200); + + const result = await jiraClient?.deletePullRequest(repositoryId, "88"); + + expect(result?.status).toBe(200); + }); + + it("handles errors gracefully", async () => { + jiraNock + .delete("/rest/devinfo/0.10/repository/sweet_repository_id/pull_request/88?_updateSequenceId=100") + .reply(500); + + await expect(jiraClient?.deletePullRequest(repositoryId, "88")).rejects.toThrow( + "Error executing Axios Request HTTP 500" + ); + }); + + }); + + describe("deleteRepository()", () => { + + beforeEach(() => { + jest.spyOn(Date, "now").mockImplementation(() => 100); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("delete branch successfully", async () => { + jiraNock + .delete("/rest/devinfo/0.10/repository/22?_updateSequenceId=100") + .reply(200); + jiraNock + .delete("/rest/builds/0.1/bulkByProperties?repositoryId=22") + .reply(200); + jiraNock + .delete("/rest/deployments/0.1/bulkByProperties?repositoryId=22") + .reply(200); + + const result = await jiraClient?.deleteRepository(22); + + expect(result).toHaveLength(3); + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ status: 200 }), + expect.objectContaining({ status: 200 }), + expect.objectContaining({ status: 200 }) + ]) + ); + }); + + it("handles errors gracefully", async () => { + jiraNock + .delete("/rest/devinfo/0.10/repository/22?_updateSequenceId=100") + .reply(200); + jiraNock + .delete("/rest/builds/0.1/bulkByProperties?repositoryId=22") + .reply(500); + jiraNock + .delete("/rest/deployments/0.1/bulkByProperties?repositoryId=22") + .reply(200); + + await expect(jiraClient?.deleteRepository(22)).rejects.toThrow( + "Error executing Axios Request HTTP 500" + ); + }); + + }); + + describe("submitBuilds()", () => { + + it("submits builds successfully within issue key limit", async () => { + + jiraNock + .post("/rest/builds/0.1/bulk", { + builds: [{ name: "Build 123" }], + properties: { + gitHubInstallationId: jiraClient?.gitHubInstallationId, // uses the id from the mock installation creator + repositoryId: 123 + }, + providerMetadata: { + product: "product" + }, + preventTransitions: false, + operationType: "NORMAL" + }) + .reply(200); + + const data = { + builds: [{ name: "Build 123" }], + product: "product" + } as unknown as JiraBuildBulkSubmitData; + + const result = await jiraClient?.submitBuilds(data, 123); + + expect(result?.status).toBe(200); + }); + + it("handles errors gracefully", async () => { + jiraNock + .post("/rest/builds/0.1/bulk", { + builds: [{ name: "Build 123" }], + properties: { + gitHubInstallationId: jiraClient?.gitHubInstallationId, + repositoryId: 123 + }, + providerMetadata: { + product: "product" + }, + preventTransitions: false, + operationType: "NORMAL" + }) + .reply(500); + + const data = { + builds: [{ name: "Build 123" }], + product: "product" + } as unknown as JiraBuildBulkSubmitData; + + await expect(jiraClient?.submitBuilds(data, 123)).rejects.toThrow("Error executing Axios Request HTTP 500"); + }); + + }); + + //TODO OTHER DEPLLYNE TEST HERE + + describe("submitRemoteLinks()", () => { + + it("submits remote links successfully", async () => { + + const remoteLinks = [ + { + associations: [ + { + associationType: "issueIdOrKeys", + values: ["VALUE1", "VALUE2", "VALUE3"] + } + ] + } + ]; + + jiraNock + .post("/rest/remotelinks/1.0/bulk", { + remoteLinks, + properties: { + gitHubInstallationId: jiraClient?.gitHubInstallationId + }, + preventTransitions: false, + operationType: "NORMAL" + }) + .reply(200); + + const data = { + remoteLinks + }; + + const result = await jiraClient?.submitRemoteLinks(data); + + expect(result?.status).toBe(200); + }); + + it("handles errors gracefully", async () => { + const remoteLinks = [ + { + associations: [ + { + associationType: "issueIdOrKeys", + values: ["VALUE1", "VALUE2", "VALUE3"] + } + ] + } + ]; + + jiraNock + .post("/rest/remotelinks/1.0/bulk", { + remoteLinks, + properties: { + gitHubInstallationId: jiraClient?.gitHubInstallationId + }, + preventTransitions: false, + operationType: "NORMAL" + }) + .reply(500); + + const data = { + remoteLinks + }; + + await expect(jiraClient?.submitRemoteLinks(data)).rejects.toThrow("Error executing Axios Request HTTP 500"); + }); + + }); + + describe("submitVulnerabilities()", () => { + + it("submits vulnerabilities successfully", async () => { + + const data = + { + vulnerabilities: [{ + id: 1, + displayName: "name", + description: "oh noes" + }] + } as unknown as JiraVulnerabilityBulkSubmitData; + + jiraNock + .post("/rest/security/1.0/bulk", { + vulnerabilities: [{ + id: 1, + displayName: "name", + description: "oh noes" + }], + properties: { + gitHubInstallationId: jiraClient?.gitHubInstallationId + }, + operationType: "NORMAL" + }) + .reply(200); + + const result = await jiraClient?.submitVulnerabilities(data); + + expect(result?.status).toBe(200); + }); + + it("handles errors gracefully", async () => { + const data = + { + vulnerabilities: [{ + id: 1, + displayName: "name", + description: "oh noes" + }] + } as unknown as JiraVulnerabilityBulkSubmitData; + + jiraNock + .post("/rest/security/1.0/bulk", { + vulnerabilities: [{ + id: 1, + displayName: "name", + description: "oh noes" + }], + properties: { + gitHubInstallationId: jiraClient?.gitHubInstallationId + }, + operationType: "NORMAL" + }) + .reply(500); + + await expect(jiraClient?.submitVulnerabilities(data)).rejects.toThrow("Error executing Axios Request HTTP 500"); + }); + + }); + + describe("parseIssueText", () => { + it("parses valid issue text", () => { + const inputText = "This is a valid issue text KEY-123 and CAT-999."; + const expectedKeys = ["KEY-123", "CAT-999"]; + + const result = JiraClient.parseIssueText(inputText); + + expect(result).toEqual(expectedKeys); + }); + + it("returns undefined for empty input text", () => { + const inputText = ""; + + const result = JiraClient.parseIssueText(inputText); + + expect(result).toBeUndefined(); + }); + + it("returns undefined for undefined input text", () => { + const inputText = undefined as unknown as string; + + const result = JiraClient.parseIssueText(inputText); + + expect(result).toBeUndefined(); + }); + + }); + +}); diff --git a/src/jira/client/jira-api-client.ts b/src/jira/client/jira-api-client.ts new file mode 100644 index 000000000..4af93243d --- /dev/null +++ b/src/jira/client/jira-api-client.ts @@ -0,0 +1,475 @@ +import Logger from "bunyan"; +import { getAxiosInstance, JiraClientError } from "./axios"; +import { AxiosInstance, AxiosResponse } from "axios"; +import { Installation } from "models/installation"; +import { Subscription } from "models/subscription"; +import { envVars } from "config/env"; +import { uniq } from "lodash"; +import { jiraIssueKeyParser } from "utils/jira-utils"; +import { TransformedRepositoryId, transformRepositoryId } from "~/src/transforms/transform-repository-id"; +import { getJiraId } from "../util/id"; +import { getCloudOrServerFromGitHubAppId } from "utils/get-cloud-or-server"; +import { getDeploymentDebugInfo, extractDeploymentDataForLoggingPurpose } from "./jira-client-deployment-helper"; +import { + truncateIssueKeys, + getTruncatedIssueKeys, + withinIssueKeyLimit, + updateIssueKeyAssociationValuesFor, + extractAndHashIssueKeysForLoggingPurpose, + safeParseAndHashUnknownIssueKeysForLoggingPurpose, + dedupIssueKeys, + updateIssueKeysFor, + withinIssueKeyAssociationsLimit, + truncate +} from "./jira-client-issue-key-helper"; +import { + JiraBuildBulkSubmitData, + JiraCommit, + JiraDeploymentBulkSubmitData, + JiraIssue, + JiraSubmitOptions, + JiraVulnerabilityBulkSubmitData +} from "interfaces/jira"; + +const issueKeyLimitWarning = "Exceeded issue key reference limit. Some issues may not be linked."; + +export interface DeploymentsResult { + status: number; + rejectedDeployments?: any[]; +} + +export class JiraClient { + axios: AxiosInstance; + logger: Logger; + jiraHost: string; + gitHubInstallationId: number; + gitHubAppId: number | undefined; + + static async create(installation: Installation, gitHubAppId: number | undefined, logger: Logger): Promise { + const jiraClient = new JiraClient(installation.jiraHost, installation.id, gitHubAppId, logger); + await jiraClient.initialize(installation); + return jiraClient; + } + + private async initialize(installation: Installation): Promise { + const secret = await installation.decrypt("encryptedSharedSecret", this.logger); + this.axios = getAxiosInstance(installation.jiraHost, secret, this.logger); + } + + private constructor(jiraHost: string, gitHubInstallationId: number, gitHubAppId: number | undefined, logger: Logger) { + const gitHubProduct = getCloudOrServerFromGitHubAppId(gitHubAppId); + + this.jiraHost = jiraHost; + this.gitHubInstallationId = gitHubInstallationId; + this.gitHubAppId = gitHubAppId; + this.logger = logger.child({ jiraHost, gitHubInstallationId, gitHubAppId, gitHubProduct }); + } + + async isAuthorized(): Promise { + try { + return (await this.axios.get("/rest/devinfo/0.10/existsByProperties?fakeProperty=1")).status === 200; + } catch (error) { + if (!(error instanceof JiraClientError)) { + throw error; + } + return false; + } + } + + async getCloudId(): Promise<{ cloudId: string }> { + return (await this.axios.get("_edge/tenant_info")).data; + } + + async appPropertiesCreate(isConfiguredState: boolean) { + return await this.axios.put(`/rest/atlassian-connect/latest/addons/${envVars.APP_KEY}/properties/is-configured`, { + "isConfigured": isConfiguredState + }); + } + + async appPropertiesGet() { + return await this.axios.get(`/rest/atlassian-connect/latest/addons/${envVars.APP_KEY}/properties/is-configured`); + } + + async appPropertiesDelete() { + return await this.axios.delete(`/rest/atlassian-connect/latest/addons/${envVars.APP_KEY}/properties/is-configured`); + } + + async linkedWorkspace(subscriptionId: number) { + const payload = { + "workspaceIds": [subscriptionId] + }; + return await this.axios.post("/rest/security/1.0/linkedWorkspaces/bulk", payload); + } + + async deleteWorkspace(subscriptionId: number) { + return await this.axios.delete(`/rest/security/1.0/linkedWorkspaces/bulk?workspaceIds=${subscriptionId}`); + } + + async checkAdminPermissions(accountId: string) { + const payload = { + accountId, + globalPermissions: [ + "ADMINISTER" + ] + }; + return await this.axios.post("/rest/api/latest/permissions/check", payload); + } + + // ISSUES + async getIssue(issueId: string, query = { fields: "summary" }): Promise> { + return this.axios.get("/rest/api/latest/issue/{issue_id}", { + params: query, + urlParams: { + issue_id: issueId + } + }); + } + async getAllIssues(issueIds: string[], query?: { fields: string }): Promise { + const responses = await Promise.all | undefined>( + issueIds.map((issueId) => this.getIssue(issueId, query).catch(() => undefined)) + ); + return responses.reduce((acc: JiraIssue[], response) => { + if (response?.status === 200 && !!response?.data) { + acc.push(response.data); + } + return acc; + }, []); + } + + static parseIssueText(text: string): string[] | undefined { + if (!text) return undefined; + return jiraIssueKeyParser(text); + } + + // ISSUE COMMENTS + async listIssueComments(issueId: string) { + return this.axios.get("/rest/api/latest/issue/{issue_id}/comment?expand=properties", { + urlParams: { + issue_id: issueId + } + }); + } + + async addIssueComment(issueId: string, payload: any) { + return this.axios.post("/rest/api/latest/issue/{issue_id}/comment", payload, { + urlParams: { + issue_id: issueId + } + }); + } + + async updateIssueComment(issueId: string, commentId: string, payload: any) { + return this.axios.put("rest/api/latest/issue/{issue_id}/comment/{comment_id}", payload, { + urlParams: { + issue_id: issueId, + comment_id: commentId + } + }); + } + + async deleteIssueComment(issueId: string, commentId: string) { + return this.axios.delete("rest/api/latest/issue/{issue_id}/comment/{comment_id}", { + urlParams: { + issue_id: issueId, + comment_id: commentId + } + }); + } + + // ISSUE TRANSITIONS + async listIssueTransistions(issueId: string) { + return this.axios.get("/rest/api/latest/issue/{issue_id}/transitions", { + urlParams: { + issue_id: issueId + } + }); + } + + async updateIssueTransistions(issueId: string, transitionId: string) { + return this.axios.post("/rest/api/latest/issue/{issue_id}/transitions", { + transition: { + id: transitionId + } + }, { + urlParams: { + issue_id: issueId + } + }); + } + + // ISSUE WORKLOGS + async addWorklogForIssue(issueId: string, payload: any) { + return this.axios.post("/rest/api/latest/issue/{issue_id}/worklog", payload, { + urlParams: { + issue_id: issueId + } + }); + } + + // DELETE INSTALLATION + async deleteInstallation(gitHubInstallationId: string | number) { + return Promise.all([ + // We are sending devinfo events with the property "installationId", so we delete by this property. + this.axios.delete("/rest/devinfo/0.10/bulkByProperties", { + params: { + installationId: gitHubInstallationId + } + }), + // We are sending build events with the property "gitHubInstallationId", so we delete by this property. + this.axios.delete("/rest/builds/0.1/bulkByProperties", { + params: { + gitHubInstallationId + } + }), + // We are sending deployments events with the property "gitHubInstallationId", so we delete by this property. + this.axios.delete("/rest/deployments/0.1/bulkByProperties", { + params: { + gitHubInstallationId + } + }) + ]); + } + + // DEV INFO + async deleteBranch(transformedRepositoryId: TransformedRepositoryId, branchRef: string) { + return this.axios.delete("/rest/devinfo/0.10/repository/{transformedRepositoryId}/branch/{branchJiraId}", + { + params: { + _updateSequenceId: Date.now() + }, + urlParams: { + transformedRepositoryId, + branchJiraId: getJiraId(branchRef) + } + } + ); + } + + async deletePullRequest(transformedRepositoryId: TransformedRepositoryId, pullRequestId: string) { + return this.axios.delete("/rest/devinfo/0.10/repository/{transformedRepositoryId}/pull_request/{pullRequestId}", { + params: { + _updateSequenceId: Date.now() + }, + urlParams: { + transformedRepositoryId, + pullRequestId + } + }); + } + + async deleteRepository(repositoryId: number, gitHubBaseUrl?: string) { + const transformedRepositoryId = transformRepositoryId(repositoryId, gitHubBaseUrl); + return Promise.all([ + this.axios.delete("/rest/devinfo/0.10/repository/{transformedRepositoryId}", { + params: { + _updateSequenceId: Date.now() + }, + urlParams: { + transformedRepositoryId + } + }), + this.axios.delete("/rest/builds/0.1/bulkByProperties", { + params: { + repositoryId + } + }), + this.axios.delete("/rest/deployments/0.1/bulkByProperties", { + params: { + repositoryId + } + }) + ]); + } + + // TODO TEST + async updateRepository(data: any, options?: JiraSubmitOptions) { + dedupIssueKeys(data); + if (!withinIssueKeyLimit(data.commits) || !withinIssueKeyLimit(data.branches) || !withinIssueKeyLimit(data.pullRequests)) { + this.logger.warn({ + truncatedCommitsCount: getTruncatedIssueKeys(data.commits).length, + truncatedBranchesCount: getTruncatedIssueKeys(data.branches).length, + truncatedPRsCount: getTruncatedIssueKeys(data.pullRequests).length + }, issueKeyLimitWarning); + truncateIssueKeys(data); + const subscription = await Subscription.getSingleInstallation( + this.jiraHost, + this.gitHubInstallationId, + this.gitHubAppId + ); + await subscription?.update({ syncWarning: issueKeyLimitWarning }); + } + + return batchedBulkUpdate( + data, + this.axios, + this.gitHubInstallationId, + this.logger, + options + ); + } + + async submitBuilds(data: JiraBuildBulkSubmitData, repositoryId: number, options?: JiraSubmitOptions) { + updateIssueKeysFor(data.builds, uniq); + if (!withinIssueKeyLimit(data.builds)) { + this.logger.warn({ truncatedBuilds: getTruncatedIssueKeys(data.builds) }, issueKeyLimitWarning); + updateIssueKeysFor(data.builds, truncate); + const subscription = await Subscription.getSingleInstallation(this.jiraHost, this.gitHubInstallationId, this.gitHubAppId); + await subscription?.update({ syncWarning: issueKeyLimitWarning }); + } + + const payload = { + builds: data.builds, + properties: { + gitHubInstallationId: this.gitHubInstallationId, + repositoryId + }, + providerMetadata: { + product: data.product + }, + preventTransitions: options?.preventTransitions || false, + operationType: options?.operationType || "NORMAL" + }; + + this.logger.info("Sending builds payload to jira."); + return this.axios.post("/rest/builds/0.1/bulk", payload); + } + + // TODO TEST + async submitDeployments(data: JiraDeploymentBulkSubmitData, repositoryId: number, options?: JiraSubmitOptions): Promise { + updateIssueKeysFor(data.deployments, uniq); + if (!withinIssueKeyLimit(data.deployments)) { + this.logger.warn({ truncatedDeployments: getTruncatedIssueKeys(data.deployments) }, issueKeyLimitWarning); + updateIssueKeysFor(data.deployments, truncate); + const subscription = await Subscription.getSingleInstallation(this.jiraHost, this.gitHubInstallationId, this.gitHubAppId); + await subscription?.update({ syncWarning: issueKeyLimitWarning }); + } + const payload = { + deployments: data.deployments, + properties: { + gitHubInstallationId: this.gitHubInstallationId, + repositoryId + }, + preventTransitions: options?.preventTransitions || false, + operationType: options?.operationType || "NORMAL" + }; + + this.logger.info({ ...extractDeploymentDataForLoggingPurpose(data, this.logger) }, "Sending deployments payload to jira."); + const response: AxiosResponse = await this.axios.post("/rest/deployments/0.1/bulk", payload); + + if ( + response.data?.rejectedDeployments?.length || + response.data?.unknownIssueKeys?.length || + response.data?.unknownAssociations?.length + ) { + this.logger.warn({ + acceptedDeployments: response.data?.acceptedDeployments, + rejectedDeployments: response.data?.rejectedDeployments, + unknownIssueKeys: response.data?.unknownIssueKeys, + unknownAssociations: response.data?.unknownAssociations, + options, + ...getDeploymentDebugInfo(data) + }, "Jira API rejected deployment!"); + } else { + this.logger.info({ + acceptedDeployments: response.data?.acceptedDeployments, + options, + ...getDeploymentDebugInfo(data) + }, "Jira API accepted deployment!"); + } + + return { + status: response.status, + rejectedDeployments: response.data?.rejectedDeployments + }; + } + + async submitRemoteLinks(data, options?: JiraSubmitOptions) { + // Note: RemoteLinks doesn't have an issueKey field and takes in associations instead + updateIssueKeyAssociationValuesFor(data.remoteLinks, uniq); + if (!withinIssueKeyAssociationsLimit(data.remoteLinks)) { + updateIssueKeyAssociationValuesFor(data.remoteLinks, truncate); + const subscription = await Subscription.getSingleInstallation(this.jiraHost, this.gitHubInstallationId, this.gitHubAppId); + await subscription?.update({ syncWarning: issueKeyLimitWarning }); + } + const payload = { + remoteLinks: data.remoteLinks, + properties: { + gitHubInstallationId: this.gitHubInstallationId + }, + preventTransitions: options?.preventTransitions || false, + operationType: options?.operationType || "NORMAL" + }; + this.logger.info("Sending remoteLinks payload to jira."); + return this.axios.post("/rest/remotelinks/1.0/bulk", payload); + } + + async submitVulnerabilities(data: JiraVulnerabilityBulkSubmitData, options?: JiraSubmitOptions): Promise { + const payload = { + vulnerabilities: data.vulnerabilities, + properties: { + gitHubInstallationId: this.gitHubInstallationId + }, + operationType: options?.operationType || "NORMAL" + }; + this.logger.info("Sending vulnerabilities payload to jira."); + return await this.axios.post("/rest/security/1.0/bulk", payload); + } +} + +// TODO MOVE TO new jira-client-commit-helper.ts +const deduplicateCommits = (commits: JiraCommit[] = []): JiraCommit[] => { + const uniqueCommits = commits.reduce((accumulator: JiraCommit[], currentCommit: JiraCommit) => { + if (!accumulator.some((commit) => commit.id === currentCommit.id)) { + accumulator.push(currentCommit); + } + return accumulator; + }, []); + return uniqueCommits; +}; + +// TODO MOVE TO new jira-client-commit-helper.ts +/** + * Splits commits in data payload into chunks of 400 and makes separate requests + * to avoid Jira API limit + */ +const batchedBulkUpdate = async ( + data, + instance: AxiosInstance, + installationId: number | undefined, + logger: Logger, + options?: JiraSubmitOptions +) => { + const dedupedCommits = deduplicateCommits(data.commits); + // Initialize with an empty chunk of commits so we still process the request if there are no commits in the payload + const commitChunks: JiraCommit[][] = []; + do { + commitChunks.push(dedupedCommits.splice(0, 400)); + } while (dedupedCommits.length); + + const batchedUpdates = commitChunks.map(async (commitChunk: JiraCommit[]) => { + if (commitChunk.length) { + data.commits = commitChunk; + } + const body = { + preventTransitions: options?.preventTransitions || false, + operationType: options?.operationType || "NORMAL", + repositories: [data], + properties: { + installationId + } + }; + + logger.info({ + issueKeys: extractAndHashIssueKeysForLoggingPurpose(commitChunk, logger) + }, "Posting to Jira devinfo bulk update api"); + + const response = await instance.post("/rest/devinfo/0.10/bulk", body); + logger.info({ + responseStatus: response.status, + unknownIssueKeys: safeParseAndHashUnknownIssueKeysForLoggingPurpose(response.data, logger) + }, "Jira devinfo bulk update api returned"); + + return response; + }); + return Promise.all(batchedUpdates); +}; diff --git a/src/jira/client/jira-client-issue-key-helper.test.ts b/src/jira/client/jira-client-issue-key-helper.test.ts index ee3a4c29b..82f81a128 100644 --- a/src/jira/client/jira-client-issue-key-helper.test.ts +++ b/src/jira/client/jira-client-issue-key-helper.test.ts @@ -303,7 +303,9 @@ describe("findIssueKeyAssociation", () => { }); }); + it("should return undefined if no 'issueIdOrKeys' association type exists", () => { + const resource: IssueKeyObject = { associations: [ {