From fa6a92d1d40de3b7f0f30d343d24200effe6ad36 Mon Sep 17 00:00:00 2001 From: FIRST_NAME LAST_NAME Date: Thu, 31 Aug 2023 10:11:25 +1200 Subject: [PATCH 01/10] started merging jiraclient --- src/jira/client/axios.ts | 2 +- src/jira/client/jira-client-new.ts | 688 +++++++++++++++++++++++++++++ 2 files changed, 689 insertions(+), 1 deletion(-) create mode 100644 src/jira/client/jira-client-new.ts diff --git a/src/jira/client/axios.ts b/src/jira/client/axios.ts index 43cd20c436..ad3c25e4dc 100644 --- a/src/jira/client/axios.ts +++ b/src/jira/client/axios.ts @@ -219,7 +219,7 @@ export const getAxiosInstance = ( }); // *** IMPORTANT: Interceptors are executed in reverse order. *** - // the last one specified is the first to executed. + // the last one specified is the first to be executed. instance.interceptors.request.use(logRequest(logger)); instance.interceptors.request.use(setRequestStartTime); diff --git a/src/jira/client/jira-client-new.ts b/src/jira/client/jira-client-new.ts new file mode 100644 index 0000000000..a6aef72bfa --- /dev/null +++ b/src/jira/client/jira-client-new.ts @@ -0,0 +1,688 @@ +/* eslint-disable @typescript-eslint/no-explicit-any + */ +/* +import { Installation } from "models/installation"; +// import { Subscription } from "models/subscription"; +import { getAxiosInstance } from "./axios"; +import { getJiraId } from "../util/id"; +import { AxiosInstance, AxiosResponse } from "axios"; +import Logger from "bunyan"; +// import { createHashWithSharedSecret } from "utils/encryption"; + +import { + JiraAssociation, + JiraBuildBulkSubmitData, + JiraCommit, + JiraDeploymentBulkSubmitData, + JiraIssue, + JiraRemoteLink, + JiraSubmitOptions, + JiraVulnerabilityBulkSubmitData +} from "interfaces/jira"; +import { getLogger } from "config/logger"; +import { jiraIssueKeyParser } from "utils/jira-utils"; +import { uniq } from "lodash"; +import { getCloudOrServerFromGitHubAppId } from "utils/get-cloud-or-server"; +import { TransformedRepositoryId, transformRepositoryId } from "~/src/transforms/transform-repository-id"; +// import { getDeploymentDebugInfo } from "./jira-client-deployment-helper"; +// import { +// GetSecretScanningAlertRequestParams, +// SecretScanningAlertResponseItem +// } from "~/src/github/client/github-client.types"; + +// Max number of issue keys we can pass to the Jira API +export const ISSUE_KEY_API_LIMIT = 500; +// @ts-ignore +const issueKeyLimitWarning = "Exceeded issue key reference limit. Some issues may not be linked."; + +export interface DeploymentsResult { + status: number; + rejectedDeployments?: any[]; +} + +export interface JiraClient { + baseURL: string; + issues: { + get: (issueId: string, query?: { fields: string }) => Promise>; + getAll: (issueIds: string[], query?: { fields: string }) => Promise; + parse: (text: string) => string[] | undefined; + comments: { + list: (issue_id: string) => any; + addForIssue: (issue_id: string, payload: any) => any; + updateForIssue: (issue_id: string, comment_id: string, payload: any) => any; + deleteForIssue: (issue_id: string, comment_id: string) => any; + }; + transitions: { + getForIssue: (issue_id: string) => any; + updateForIssue: (issue_id: string, transition_id: string) => any; + }; + worklogs: { + addForIssue: (issue_id: string, payload: any) => any; + }; + }; + devinfo: { + branch: { + delete: (transformedRepositoryId: TransformedRepositoryId, branchRef: string) => any; + }; + installation: { + delete: (gitHubInstallationId: string | number) => Promise; + }; + pullRequest: { + delete: (transformedRepositoryId: TransformedRepositoryId, pullRequestId: string) => any; + }; + repository: { + delete: (repositoryId: number, gitHubBaseUrl?: string) => Promise; + update: (data: any, options?: JiraSubmitOptions) => any; + }, + }, + workflow: { + submit: (data: JiraBuildBulkSubmitData, repositoryId: number, options?: JiraSubmitOptions) => Promise; + }, + deployment: { + submit: ( + data: JiraDeploymentBulkSubmitData, + repositoryId: number, + options?: JiraSubmitOptions + ) => Promise; + }, + remoteLink: { + submit: (data: any, options?: JiraSubmitOptions) => Promise; + }, + security: { + submitVulnerabilities: (data: JiraVulnerabilityBulkSubmitData, options?: JiraSubmitOptions) => Promise; + } +} + +// TODO: need to type jiraClient ASAP +export const getJiraClient = async ( + jiraHost: string, + gitHubInstallationId: number, + gitHubAppId: number | undefined, + log: Logger = getLogger("jira-client") +): Promise => { + const gitHubProduct = getCloudOrServerFromGitHubAppId(gitHubAppId); + const logger = log.child({ jiraHost, gitHubInstallationId, gitHubProduct }); + const installation = await Installation.getForHost(jiraHost); + + if (!installation) { + logger.warn("Cannot initialize Jira Client, Installation doesn't exist."); + return undefined; + } + // @ts-ignore + const instance = getAxiosInstance( + installation.jiraHost, + await installation.decrypt("encryptedSharedSecret", logger), + logger + ); + + // @ts-ignore + class JiraClientNew { + axios: AxiosInstance; + baseURL: string; + gitHubProduct: "server" | "cloud"; + + + // TODO, is this the best name? + static async getNewClient(jiraHost: string, log: Logger) { + const jiraClient = new JiraClientNew(); + jiraClient.baseURL = jiraHost; + jiraClient.gitHubProduct = getCloudOrServerFromGitHubAppId(gitHubAppId); + const installation = await Installation.getForHost(jiraHost); + + if (!installation) { + logger.warn("Cannot initialize Jira Client, Installation doesn't exist."); + return undefined; + } + + jiraClient.axios = getAxiosInstance( + installation.jiraHost, + await installation.decrypt("encryptedSharedSecret", log), + log + ); + return jiraClient; + } + + constructor() { + } + + public async getIssue(issueId: string, query = { fields: "summary" }): Promise> { + return await this.axios.get("/rest/api/latest/issue/{issue_id}", { + params: query, + urlParams: { + issue_id: issueId + } + }); + }; + + // ISSUES + public async getAllIssues(issueIds: string[], query?: { fields: string }): Promise { + const responses = await Promise.all | undefined>( + issueIds.map((issueId) => this.getIssue(issueId, query) + // Ignore any errors + .catch(() => undefined)) + ); + return responses.reduce((acc: JiraIssue[], response) => { + if (response?.status === 200 && !!response?.data) { + acc.push(response.data); + } + return acc; + }, []); + } + + public async parseIssues(issueIds: string[], query?: { fields: string }): Promise { + const responses = await Promise.all | undefined>( + issueIds.map((issueId) => this.getIssue(issueId, query) + .catch(() => undefined)) // Ignore any errors + ); + return responses.reduce((acc: JiraIssue[], response) => { + if (response?.status === 200 && !!response?.data) { + acc.push(response.data); + } + return acc; + }, []); + } + + public parseIssue(text: string): string[] | undefined { + if (!text) return undefined; + return jiraIssueKeyParser(text); + } + + public async listIssueComments(issue_id: string) { + this.axios.get("/rest/api/latest/issue/{issue_id}/comment?expand=properties", { + urlParams: { + issue_id + } + }); + } + + public async addIssueComment(issue_id: string, payload) { + this.axios.post("/rest/api/latest/issue/{issue_id}/comment", payload, { + urlParams: { + issue_id + } + }); + } + + public async updateIssueComment(issue_id: string, comment_id: string, payload) { + this.axios.put("rest/api/latest/issue/{issue_id}/comment/{comment_id}", payload, { + urlParams: { + issue_id, + comment_id + } + }); + } + + public async deleteIssueComment(issue_id: string, comment_id: string) { + this.axios.delete("rest/api/latest/issue/{issue_id}/comment/{comment_id}", { + urlParams: { + issue_id, + comment_id + } + }); + } + + public async getIssueTransitions(issue_id: string) { + this.axios.get("/rest/api/latest/issue/{issue_id}/transitions", { + urlParams: { + issue_id + } + }); + } + + public async updateIssueTransistion(issue_id: string, transition_id: string) { + this.axios.post("/rest/api/latest/issue/{issue_id}/transitions", { + transition: { + id: transition_id + }, + urlParams: { + issue_id + } + }) + } + + + public async deleteBranch(transformedRepositoryId: TransformedRepositoryId, branchRef: string) { + this.axios.delete("/rest/devinfo/0.10/repository/{transformedRepositoryId}/branch/{branchJiraId}", { + params: { + _updateSequenceId: Date.now() + }, + urlParams: { + transformedRepositoryId, + branchJiraId: getJiraId(branchRef) + } + }) + } + + } + +// devinfo: { +// branch: { +// delete: => +// +// }, +// // Add methods for handling installationId properties that exist in Jira +// installation: { +// delete: async (gitHubInstallationId: string | number) => +// Promise.all([ +// +// // We are sending devinfo events with the property "installationId", so we delete by this property. +// instance.delete( +// "/rest/devinfo/0.10/bulkByProperties", +// { +// params: { +// installationId: gitHubInstallationId +// } +// } +// ), +// +// // We are sending build events with the property "gitHubInstallationId", so we delete by this property. +// instance.delete( +// "/rest/builds/0.1/bulkByProperties", +// { +// params: { +// gitHubInstallationId +// } +// } +// ), +// +// // We are sending deployments events with the property "gitHubInstallationId", so we delete by this property. +// instance.delete( +// "/rest/deployments/0.1/bulkByProperties", +// { +// params: { +// gitHubInstallationId +// } +// } +// ) +// ]) +// }, +// pullRequest: { +// delete: (transformedRepositoryId: TransformedRepositoryId, pullRequestId: string) => +// instance.delete( +// "/rest/devinfo/0.10/repository/{transformedRepositoryId}/pull_request/{pullRequestId}", +// { +// params: { +// _updateSequenceId: Date.now() +// }, +// urlParams: { +// transformedRepositoryId, +// pullRequestId +// } +// } +// ) +// }, +// repository: { +// delete: async (repositoryId: number, gitHubBaseUrl?: string) => { +// const transformedRepositoryId = transformRepositoryId(repositoryId, gitHubBaseUrl); +// return Promise.all([ +// // We are sending devinfo events with the property "transformedRepositoryId", so we delete by this property. +// instance.delete("/rest/devinfo/0.10/repository/{transformedRepositoryId}", +// { +// params: { +// _updateSequenceId: Date.now() +// }, +// urlParams: { +// transformedRepositoryId +// } +// } +// ), +// +// // We are sending build events with the property "repositoryId", so we delete by this property. +// instance.delete( +// "/rest/builds/0.1/bulkByProperties", +// { +// params: { +// repositoryId +// } +// } +// ), +// +// // We are sending deployments events with the property "repositoryId", so we delete by this property. +// instance.delete( +// "/rest/deployments/0.1/bulkByProperties", +// { +// params: { +// repositoryId +// } +// } +// ) +// ]); +// }, +// update: async (data, options?: JiraSubmitOptions) => { +// dedupIssueKeys(data); +// if ( +// !withinIssueKeyLimit(data.commits) || +// !withinIssueKeyLimit(data.branches) || +// !withinIssueKeyLimit(data.pullRequests) +// ) { +// 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( +// jiraHost, +// gitHubInstallationId, +// gitHubAppId +// ); +// await subscription?.update({ syncWarning: issueKeyLimitWarning }); +// } +// +// return batchedBulkUpdate( +// data, +// instance, +// gitHubInstallationId, +// logger, +// options +// ); +// } +// } +// }, +// workflow: { +// submit: async (data: JiraBuildBulkSubmitData, repositoryId: number, options?: JiraSubmitOptions) => { +// updateIssueKeysFor(data.builds, uniq); +// if (!withinIssueKeyLimit(data.builds)) { +// logger.warn({ +// truncatedBuilds: getTruncatedIssuekeys(data.builds) +// }, issueKeyLimitWarning); +// updateIssueKeysFor(data.builds, truncate); +// const subscription = await Subscription.getSingleInstallation(jiraHost, gitHubInstallationId, gitHubAppId); +// await subscription?.update({ syncWarning: issueKeyLimitWarning }); +// } +// +// const payload = { +// builds: data.builds, +// properties: { +// gitHubInstallationId, +// repositoryId +// }, +// providerMetadata: { +// product: data.product +// }, +// preventTransitions: options?.preventTransitions || false, +// operationType: options?.operationType || "NORMAL" +// }; +// +// logger?.info({ gitHubProduct }, "Sending builds payload to jira."); +// return await instance.post("/rest/builds/0.1/bulk", payload); +// } +// }, +// deployment: { +// submit: async (data: JiraDeploymentBulkSubmitData, repositoryId: number, options?: JiraSubmitOptions): Promise => { +// updateIssueKeysFor(data.deployments, uniq); +// if (!withinIssueKeyLimit(data.deployments)) { +// logger.warn({ +// truncatedDeployments: getTruncatedIssuekeys(data.deployments) +// }, issueKeyLimitWarning); +// updateIssueKeysFor(data.deployments, truncate); +// const subscription = await Subscription.getSingleInstallation(jiraHost, gitHubInstallationId, gitHubAppId); +// await subscription?.update({ syncWarning: issueKeyLimitWarning }); +// } +// const payload = { +// deployments: data.deployments, +// properties: { +// gitHubInstallationId, +// repositoryId +// }, +// preventTransitions: options?.preventTransitions || false, +// operationType: options?.operationType || "NORMAL" +// }; +// +// logger?.info({ gitHubProduct, ...extractDeploymentDataForLoggingPurpose(data, logger) }, "Sending deployments payload to jira."); +// const response: AxiosResponse = await instance.post("/rest/deployments/0.1/bulk", payload); +// +// if ( +// response.data?.rejectedDeployments?.length || +// response.data?.unknownIssueKeys?.length || +// response.data?.unknownAssociations?.length +// ) { +// 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 { +// logger.info({ +// acceptedDeployments: response.data?.acceptedDeployments, +// options, +// ...getDeploymentDebugInfo(data) +// }, "Jira API accepted deployment!"); +// } +// +// return { +// status: response.status, +// rejectedDeployments: response.data?.rejectedDeployments +// }; +// } +// }, +// remoteLink: { +// submit: async (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(jiraHost, gitHubInstallationId, gitHubAppId); +// await subscription?.update({ syncWarning: issueKeyLimitWarning }); +// } +// const payload = { +// remoteLinks: data.remoteLinks, +// properties: { +// gitHubInstallationId +// }, +// preventTransitions: options?.preventTransitions || false, +// operationType: options?.operationType || "NORMAL" +// }; +// logger.info("Sending remoteLinks payload to jira."); +// await instance.post("/rest/remotelinks/1.0/bulk", payload); +// } +// }, +// security: { +// submitVulnerabilities: async (data, options?: JiraSubmitOptions): Promise => { +// const payload = { +// vulnerabilities: data.vulnerabilities, +// properties: { +// gitHubInstallationId +// }, +// operationType: options?.operationType || "NORMAL" +// }; +// logger.info("Sending vulnerabilities payload to jira."); +// return await instance.post("/rest/security/1.0/bulk", payload); +// } +// } +// }; +// +// return client; +// }; + + + + // Splits commits in data payload into chunks of 400 and makes separate requests + // to avoid Jira API limit + + // @ts-ignore + const batchedBulkUpdate = async ( + data, + instance: AxiosInstance, + installationId: number | undefined, + logger: Logger, + options?: JiraSubmitOptions + ) => { + const dedupedCommits = dedupCommits(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 + } + }; + + // @ts-ignore + logger.info({ + // @ts-ignore + 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, + // @ts-ignore + unknownIssueKeys: safeParseAndHashUnknownIssueKeysForLoggingPurpose(response.data, logger) + }, "Jira devinfo bulk update api returned"); + + return response; + }); + return Promise.all(batchedUpdates); + }; + + const findIssueKeyAssociation = (resource: IssueKeyObject): JiraAssociation | undefined => + resource.associations?.find(a => a.associationType == "issueIdOrKeys"); + + + //Returns if the max length of the issue + //key field is within the limit + + // @ts-ignore + const withinIssueKeyLimit = (resources: IssueKeyObject[]): boolean => { + if (!resources) return true; + const issueKeyCounts = resources.map((r) => r.issueKeys?.length || findIssueKeyAssociation(r)?.values?.length || 0); + return Math.max(...issueKeyCounts) <= ISSUE_KEY_API_LIMIT; + }; + + + Returns if the max length of the issue key field is within the limit + Assumption is that the transformed resource only has one association which is for + "issueIdOrKeys" association. + + // @ts-ignore + const withinIssueKeyAssociationsLimit = (resources: JiraRemoteLink[]): boolean => { + if (!resources) { + return true; + } + + const issueKeyCounts = resources.filter(resource => resource.associations?.length > 0).map((resource) => resource.associations[0].values.length); + return Math.max(...issueKeyCounts) <= ISSUE_KEY_API_LIMIT; + }; + + + * Deduplicates commits by ID field for a repository payload + + const dedupCommits = (commits: JiraCommit[] = []): JiraCommit[] => + commits.filter((obj, pos, arr) => + arr.map((mapCommit) => mapCommit.id).indexOf(obj.id) === pos + ); + + + * Deduplicates issueKeys field for branches and commits + + // @ts-ignore + const dedupIssueKeys = (repositoryObj) => { + updateRepositoryIssueKeys(repositoryObj, uniq); + }; + + + *Truncates branches, commits and PRs to their first 100 issue keys + + // @ts-ignore + const truncateIssueKeys = (repositoryObj) => { + updateRepositoryIssueKeys(repositoryObj, truncate); + }; + + interface IssueKeyObject { + issueKeys?: string[]; + associations?: JiraAssociation[]; + } + + // @ts-ignore + export const getTruncatedIssuekeys = (data: IssueKeyObject[] = []): IssueKeyObject[] => + data.reduce((acc: IssueKeyObject[], value: IssueKeyObject) => { + if (value?.issueKeys && value.issueKeys.length > ISSUE_KEY_API_LIMIT) { + acc.push({ + issueKeys: value.issueKeys.slice(ISSUE_KEY_API_LIMIT) + }); + } + const association = findIssueKeyAssociation(value); + if (association?.values && association.values.length > ISSUE_KEY_API_LIMIT) { + acc.push({ + // TODO: Shouldn't it be association.values.slice(ISSUE_KEY_API_LIMIT), just as for issue key?! + associations: [association] + }); + } + return acc; + }, []); + + + Runs a mutating function on all branches, commits and PRs + with issue keys in a Jira Repository object + + const updateRepositoryIssueKeys = (repositoryObj, mutatingFunc) => { + if (repositoryObj.commits) { + repositoryObj.commits = updateIssueKeysFor(repositoryObj.commits, mutatingFunc); + } + + if (repositoryObj.branches) { + repositoryObj.branches = updateIssueKeysFor(repositoryObj.branches, mutatingFunc); + repositoryObj.branches.forEach((branch) => { + if (branch.lastCommit) { + branch.lastCommit = updateIssueKeysFor([branch.lastCommit], mutatingFunc)[0]; + } + }); + } + + if (repositoryObj.pullRequests) { + repositoryObj.pullRequests = updateIssueKeysFor(repositoryObj.pullRequests, mutatingFunc); + } + }; + + + Runs the mutatingFunc on the issue keys field for each branch, commit or PR + + const updateIssueKeysFor = (resources, func) => { + resources.forEach((r) => { + if (r.issueKeys) { + r.issueKeys = func(r.issueKeys); + } + const association = findIssueKeyAssociation(r); + if (association) { + association.values = func(association.values); + } + }); + return resources; + }; + + + * Runs the mutatingFunc on the association values field for each entity resource + * Assumption is that the transformed resource only has one association which is for + "issueIdOrKeys" association. + + // @ts-ignore + const updateIssueKeyAssociationValuesFor = (resources: JiraRemoteLink[], mutatingFunc: any): JiraRemoteLink[] => { + resources?.forEach(resource => { + const association = findIssueKeyAssociation(resource); + if (association) { + association.values = mutatingFunc(resource.associations[0].values); + } + }); + return resources; + }; + +// todo rename more descriptive + // @ts-ignore + const truncate = (array) => array.slice(0, ISSUE_KEY_API_LIMIT); +} +*/ From 9ed9721838e9fc39c85532811f8523db9dbbff71 Mon Sep 17 00:00:00 2001 From: jkay Date: Tue, 26 Sep 2023 11:41:01 +1300 Subject: [PATCH 02/10] new jira client create with both clients together - missing tests - created helper file aswell which needs coverage --- src/jira/client/jira-api-client.test.ts | 107 +++ src/jira/client/jira-api-client.ts | 476 ++++++++++++ .../client/jira-client-deployment-helper.ts | 20 + .../client/jira-client-issue-key-helper.ts | 159 ++++ src/jira/client/jira-client-new.ts | 688 ------------------ src/routes/github/setup/github-setup-get.ts | 1 + 6 files changed, 763 insertions(+), 688 deletions(-) create mode 100644 src/jira/client/jira-api-client.test.ts create mode 100644 src/jira/client/jira-api-client.ts create mode 100644 src/jira/client/jira-client-issue-key-helper.ts delete mode 100644 src/jira/client/jira-client-new.ts 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 0000000000..6aff1987b9 --- /dev/null +++ b/src/jira/client/jira-api-client.test.ts @@ -0,0 +1,107 @@ +/* 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"; + +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("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); + }); + }); + +}); \ No newline at end of file diff --git a/src/jira/client/jira-api-client.ts b/src/jira/client/jira-api-client.ts new file mode 100644 index 0000000000..cd55a5755a --- /dev/null +++ b/src/jira/client/jira-api-client.ts @@ -0,0 +1,476 @@ +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 }); + } + + /* + * Tests credentials by making a request to the Jira API + * + * @return {boolean} Returns true if client has access to Jira API + */ + 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 TRANSISTIONS + 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 + } + }) + ]); + } + + 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 await this.axios.post("/rest/builds/0.1/bulk", payload); + } + + 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."); + await 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); + } +} + +/** + * Deduplicates commits by ID field for a repository payload + */ +const dedupCommits = (commits: JiraCommit[] = []): JiraCommit[] => + commits.filter( + (obj, pos, arr) => + arr.map((mapCommit) => mapCommit.id).indexOf(obj.id) === pos + ); + +/** + * 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 = dedupCommits(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-deployment-helper.ts b/src/jira/client/jira-client-deployment-helper.ts index 69f42aaaf0..0597a923db 100644 --- a/src/jira/client/jira-client-deployment-helper.ts +++ b/src/jira/client/jira-client-deployment-helper.ts @@ -1,4 +1,6 @@ import { JiraDeploymentBulkSubmitData } from "interfaces/jira"; +import { createHashWithSharedSecret } from "utils/encryption"; +import Logger from "bunyan"; export const getDeploymentDebugInfo = (jiraPayload: JiraDeploymentBulkSubmitData | undefined): Record => { @@ -16,3 +18,21 @@ export const getDeploymentDebugInfo = (jiraPayload: JiraDeploymentBulkSubmitData commitCount: associations.filter(a => a.associationType === "commit").map(a => a.values?.length || 0).reduce((a, b) => a + b, 0) }; }; + +export const extractDeploymentDataForLoggingPurpose = (data: JiraDeploymentBulkSubmitData, logger: Logger): Record => { + try { + return { + deployments: (data.deployments || []).map(deployment => ({ + updateSequenceNumber: deployment.updateSequenceNumber, + state: createHashWithSharedSecret(deployment.state), + url: createHashWithSharedSecret(deployment.url), + issueKeys: (deployment.associations || []) + .filter(a => ["issueKeys", "issueIdOrKeys", "serviceIdOrKeys"].includes(a.associationType)) + .flatMap(a => (a.values as string[] || []).map((v: string) => createHashWithSharedSecret(v))) + })) + }; + } catch (error) { + logger.error({ error }, "Fail extractDeploymentDataForLoggingPurpose"); + return {}; + } +}; \ No newline at end of file diff --git a/src/jira/client/jira-client-issue-key-helper.ts b/src/jira/client/jira-client-issue-key-helper.ts new file mode 100644 index 0000000000..a4574cb0cb --- /dev/null +++ b/src/jira/client/jira-client-issue-key-helper.ts @@ -0,0 +1,159 @@ + +import { uniq } from "lodash"; +import { createHashWithSharedSecret } from "utils/encryption"; +import { + JiraAssociation, + JiraCommit, + JiraRemoteLink +} from "interfaces/jira"; + +interface IssueKeyObject { + issueKeys?: string[]; + associations?: JiraAssociation[]; +} + +import Logger from "bunyan"; + +// Max number of issue keys we can pass to the Jira API +export const ISSUE_KEY_API_LIMIT = 500; + +/** + * Truncates to 100 elements in an array + */ +export const truncate = (array) => array.slice(0, ISSUE_KEY_API_LIMIT); + +/** + * Truncates branches, commits and PRs to their first 100 issue keys + */ +export const truncateIssueKeys = (repositoryObj) => { + updateRepositoryIssueKeys(repositoryObj, truncate); +}; + + +// TODO: add unit tests +export const getTruncatedIssuekeys = (data: IssueKeyObject[] = []): IssueKeyObject[] => + data.reduce((acc: IssueKeyObject[], value: IssueKeyObject) => { + if (value?.issueKeys && value.issueKeys.length > ISSUE_KEY_API_LIMIT) { + acc.push({ + issueKeys: value.issueKeys.slice(ISSUE_KEY_API_LIMIT) + }); + } + const association = findIssueKeyAssociation(value); + if (association?.values && association.values.length > ISSUE_KEY_API_LIMIT) { + acc.push({ + // TODO: Shouldn't it be association.values.slice(ISSUE_KEY_API_LIMIT), just as for issue key?! + associations: [association] + }); + } + return acc; + }, []); + +/** + * Returns if the max length of the issue + * key field is within the limit + */ +export const withinIssueKeyLimit = (resources: IssueKeyObject[]): boolean => { + if (!resources) return true; + const issueKeyCounts = resources.map((r) => r.issueKeys?.length || findIssueKeyAssociation(r)?.values?.length || 0); + return Math.max(...issueKeyCounts) <= ISSUE_KEY_API_LIMIT; +}; + +//// TO BE BNROKEN INTO A UTILS FILE +/** + * Deduplicates issueKeys field for branches and commits + */ +export const dedupIssueKeys = (repositoryObj) => { + updateRepositoryIssueKeys(repositoryObj, uniq); +}; + + +const findIssueKeyAssociation = (resource: IssueKeyObject): JiraAssociation | undefined => + resource.associations?.find(a => a.associationType == "issueIdOrKeys"); + +/** + * Runs a mutating function on all branches, commits and PRs + * with issue keys in a Jira Repository object + */ +const updateRepositoryIssueKeys = (repositoryObj, mutatingFunc) => { + if (repositoryObj.commits) { + repositoryObj.commits = updateIssueKeysFor(repositoryObj.commits, mutatingFunc); + } + + if (repositoryObj.branches) { + repositoryObj.branches = updateIssueKeysFor(repositoryObj.branches, mutatingFunc); + repositoryObj.branches.forEach((branch) => { + if (branch.lastCommit) { + branch.lastCommit = updateIssueKeysFor([branch.lastCommit], mutatingFunc)[0]; + } + }); + } + + if (repositoryObj.pullRequests) { + repositoryObj.pullRequests = updateIssueKeysFor(repositoryObj.pullRequests, mutatingFunc); + } +}; + +/** + * Runs the mutatingFunc on the issue keys field for each branch, commit or PR + */ +export const updateIssueKeysFor = (resources, func) => { + resources.forEach((r) => { + if (r.issueKeys) { + r.issueKeys = func(r.issueKeys); + } + const association = findIssueKeyAssociation(r); + if (association) { + association.values = func(association.values); + } + }); + return resources; +}; +/** + * Runs the mutatingFunc on the association values field for each entity resource + * Assumption is that the transformed resource only has one association which is for + * "issueIdOrKeys" association. + */ +export const updateIssueKeyAssociationValuesFor = (resources: JiraRemoteLink[], mutatingFunc: any): JiraRemoteLink[] => { + resources?.forEach(resource => { + const association = findIssueKeyAssociation(resource); + if (association) { + association.values = mutatingFunc(resource.associations[0].values); + } + }); + return resources; +}; + +/** + * Returns if the max length of the issue key field is within the limit + * Assumption is that the transformed resource only has one association which is for + * "issueIdOrKeys" association. + */ +export const withinIssueKeyAssociationsLimit = (resources: JiraRemoteLink[]): boolean => { + if (!resources) { + return true; + } + + const issueKeyCounts = resources.filter(resource => resource.associations?.length > 0).map((resource) => resource.associations[0].values.length); + return Math.max(...issueKeyCounts) <= ISSUE_KEY_API_LIMIT; +}; + +export const extractAndHashIssueKeysForLoggingPurpose = (commitChunk: JiraCommit[], logger: Logger): string[] => { + try { + return commitChunk + .flatMap((chunk: JiraCommit) => chunk.issueKeys) + .filter(key => !!key) + .map((key: string) => createHashWithSharedSecret(key)); + } catch (error) { + logger.error({ error }, "Fail extract and hash issue keys before sending to jira"); + return []; + } +}; + +export const safeParseAndHashUnknownIssueKeysForLoggingPurpose = (responseData: any, logger: Logger): string[] => { + try { + return (responseData["unknownIssueKeys"] || []).map((key: string) => createHashWithSharedSecret(key)); + } catch (error) { + logger.error({ error }, "Error parsing unknownIssueKeys from jira api response"); + return []; + } +}; \ No newline at end of file diff --git a/src/jira/client/jira-client-new.ts b/src/jira/client/jira-client-new.ts deleted file mode 100644 index a6aef72bfa..0000000000 --- a/src/jira/client/jira-client-new.ts +++ /dev/null @@ -1,688 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any - */ -/* -import { Installation } from "models/installation"; -// import { Subscription } from "models/subscription"; -import { getAxiosInstance } from "./axios"; -import { getJiraId } from "../util/id"; -import { AxiosInstance, AxiosResponse } from "axios"; -import Logger from "bunyan"; -// import { createHashWithSharedSecret } from "utils/encryption"; - -import { - JiraAssociation, - JiraBuildBulkSubmitData, - JiraCommit, - JiraDeploymentBulkSubmitData, - JiraIssue, - JiraRemoteLink, - JiraSubmitOptions, - JiraVulnerabilityBulkSubmitData -} from "interfaces/jira"; -import { getLogger } from "config/logger"; -import { jiraIssueKeyParser } from "utils/jira-utils"; -import { uniq } from "lodash"; -import { getCloudOrServerFromGitHubAppId } from "utils/get-cloud-or-server"; -import { TransformedRepositoryId, transformRepositoryId } from "~/src/transforms/transform-repository-id"; -// import { getDeploymentDebugInfo } from "./jira-client-deployment-helper"; -// import { -// GetSecretScanningAlertRequestParams, -// SecretScanningAlertResponseItem -// } from "~/src/github/client/github-client.types"; - -// Max number of issue keys we can pass to the Jira API -export const ISSUE_KEY_API_LIMIT = 500; -// @ts-ignore -const issueKeyLimitWarning = "Exceeded issue key reference limit. Some issues may not be linked."; - -export interface DeploymentsResult { - status: number; - rejectedDeployments?: any[]; -} - -export interface JiraClient { - baseURL: string; - issues: { - get: (issueId: string, query?: { fields: string }) => Promise>; - getAll: (issueIds: string[], query?: { fields: string }) => Promise; - parse: (text: string) => string[] | undefined; - comments: { - list: (issue_id: string) => any; - addForIssue: (issue_id: string, payload: any) => any; - updateForIssue: (issue_id: string, comment_id: string, payload: any) => any; - deleteForIssue: (issue_id: string, comment_id: string) => any; - }; - transitions: { - getForIssue: (issue_id: string) => any; - updateForIssue: (issue_id: string, transition_id: string) => any; - }; - worklogs: { - addForIssue: (issue_id: string, payload: any) => any; - }; - }; - devinfo: { - branch: { - delete: (transformedRepositoryId: TransformedRepositoryId, branchRef: string) => any; - }; - installation: { - delete: (gitHubInstallationId: string | number) => Promise; - }; - pullRequest: { - delete: (transformedRepositoryId: TransformedRepositoryId, pullRequestId: string) => any; - }; - repository: { - delete: (repositoryId: number, gitHubBaseUrl?: string) => Promise; - update: (data: any, options?: JiraSubmitOptions) => any; - }, - }, - workflow: { - submit: (data: JiraBuildBulkSubmitData, repositoryId: number, options?: JiraSubmitOptions) => Promise; - }, - deployment: { - submit: ( - data: JiraDeploymentBulkSubmitData, - repositoryId: number, - options?: JiraSubmitOptions - ) => Promise; - }, - remoteLink: { - submit: (data: any, options?: JiraSubmitOptions) => Promise; - }, - security: { - submitVulnerabilities: (data: JiraVulnerabilityBulkSubmitData, options?: JiraSubmitOptions) => Promise; - } -} - -// TODO: need to type jiraClient ASAP -export const getJiraClient = async ( - jiraHost: string, - gitHubInstallationId: number, - gitHubAppId: number | undefined, - log: Logger = getLogger("jira-client") -): Promise => { - const gitHubProduct = getCloudOrServerFromGitHubAppId(gitHubAppId); - const logger = log.child({ jiraHost, gitHubInstallationId, gitHubProduct }); - const installation = await Installation.getForHost(jiraHost); - - if (!installation) { - logger.warn("Cannot initialize Jira Client, Installation doesn't exist."); - return undefined; - } - // @ts-ignore - const instance = getAxiosInstance( - installation.jiraHost, - await installation.decrypt("encryptedSharedSecret", logger), - logger - ); - - // @ts-ignore - class JiraClientNew { - axios: AxiosInstance; - baseURL: string; - gitHubProduct: "server" | "cloud"; - - - // TODO, is this the best name? - static async getNewClient(jiraHost: string, log: Logger) { - const jiraClient = new JiraClientNew(); - jiraClient.baseURL = jiraHost; - jiraClient.gitHubProduct = getCloudOrServerFromGitHubAppId(gitHubAppId); - const installation = await Installation.getForHost(jiraHost); - - if (!installation) { - logger.warn("Cannot initialize Jira Client, Installation doesn't exist."); - return undefined; - } - - jiraClient.axios = getAxiosInstance( - installation.jiraHost, - await installation.decrypt("encryptedSharedSecret", log), - log - ); - return jiraClient; - } - - constructor() { - } - - public async getIssue(issueId: string, query = { fields: "summary" }): Promise> { - return await this.axios.get("/rest/api/latest/issue/{issue_id}", { - params: query, - urlParams: { - issue_id: issueId - } - }); - }; - - // ISSUES - public async getAllIssues(issueIds: string[], query?: { fields: string }): Promise { - const responses = await Promise.all | undefined>( - issueIds.map((issueId) => this.getIssue(issueId, query) - // Ignore any errors - .catch(() => undefined)) - ); - return responses.reduce((acc: JiraIssue[], response) => { - if (response?.status === 200 && !!response?.data) { - acc.push(response.data); - } - return acc; - }, []); - } - - public async parseIssues(issueIds: string[], query?: { fields: string }): Promise { - const responses = await Promise.all | undefined>( - issueIds.map((issueId) => this.getIssue(issueId, query) - .catch(() => undefined)) // Ignore any errors - ); - return responses.reduce((acc: JiraIssue[], response) => { - if (response?.status === 200 && !!response?.data) { - acc.push(response.data); - } - return acc; - }, []); - } - - public parseIssue(text: string): string[] | undefined { - if (!text) return undefined; - return jiraIssueKeyParser(text); - } - - public async listIssueComments(issue_id: string) { - this.axios.get("/rest/api/latest/issue/{issue_id}/comment?expand=properties", { - urlParams: { - issue_id - } - }); - } - - public async addIssueComment(issue_id: string, payload) { - this.axios.post("/rest/api/latest/issue/{issue_id}/comment", payload, { - urlParams: { - issue_id - } - }); - } - - public async updateIssueComment(issue_id: string, comment_id: string, payload) { - this.axios.put("rest/api/latest/issue/{issue_id}/comment/{comment_id}", payload, { - urlParams: { - issue_id, - comment_id - } - }); - } - - public async deleteIssueComment(issue_id: string, comment_id: string) { - this.axios.delete("rest/api/latest/issue/{issue_id}/comment/{comment_id}", { - urlParams: { - issue_id, - comment_id - } - }); - } - - public async getIssueTransitions(issue_id: string) { - this.axios.get("/rest/api/latest/issue/{issue_id}/transitions", { - urlParams: { - issue_id - } - }); - } - - public async updateIssueTransistion(issue_id: string, transition_id: string) { - this.axios.post("/rest/api/latest/issue/{issue_id}/transitions", { - transition: { - id: transition_id - }, - urlParams: { - issue_id - } - }) - } - - - public async deleteBranch(transformedRepositoryId: TransformedRepositoryId, branchRef: string) { - this.axios.delete("/rest/devinfo/0.10/repository/{transformedRepositoryId}/branch/{branchJiraId}", { - params: { - _updateSequenceId: Date.now() - }, - urlParams: { - transformedRepositoryId, - branchJiraId: getJiraId(branchRef) - } - }) - } - - } - -// devinfo: { -// branch: { -// delete: => -// -// }, -// // Add methods for handling installationId properties that exist in Jira -// installation: { -// delete: async (gitHubInstallationId: string | number) => -// Promise.all([ -// -// // We are sending devinfo events with the property "installationId", so we delete by this property. -// instance.delete( -// "/rest/devinfo/0.10/bulkByProperties", -// { -// params: { -// installationId: gitHubInstallationId -// } -// } -// ), -// -// // We are sending build events with the property "gitHubInstallationId", so we delete by this property. -// instance.delete( -// "/rest/builds/0.1/bulkByProperties", -// { -// params: { -// gitHubInstallationId -// } -// } -// ), -// -// // We are sending deployments events with the property "gitHubInstallationId", so we delete by this property. -// instance.delete( -// "/rest/deployments/0.1/bulkByProperties", -// { -// params: { -// gitHubInstallationId -// } -// } -// ) -// ]) -// }, -// pullRequest: { -// delete: (transformedRepositoryId: TransformedRepositoryId, pullRequestId: string) => -// instance.delete( -// "/rest/devinfo/0.10/repository/{transformedRepositoryId}/pull_request/{pullRequestId}", -// { -// params: { -// _updateSequenceId: Date.now() -// }, -// urlParams: { -// transformedRepositoryId, -// pullRequestId -// } -// } -// ) -// }, -// repository: { -// delete: async (repositoryId: number, gitHubBaseUrl?: string) => { -// const transformedRepositoryId = transformRepositoryId(repositoryId, gitHubBaseUrl); -// return Promise.all([ -// // We are sending devinfo events with the property "transformedRepositoryId", so we delete by this property. -// instance.delete("/rest/devinfo/0.10/repository/{transformedRepositoryId}", -// { -// params: { -// _updateSequenceId: Date.now() -// }, -// urlParams: { -// transformedRepositoryId -// } -// } -// ), -// -// // We are sending build events with the property "repositoryId", so we delete by this property. -// instance.delete( -// "/rest/builds/0.1/bulkByProperties", -// { -// params: { -// repositoryId -// } -// } -// ), -// -// // We are sending deployments events with the property "repositoryId", so we delete by this property. -// instance.delete( -// "/rest/deployments/0.1/bulkByProperties", -// { -// params: { -// repositoryId -// } -// } -// ) -// ]); -// }, -// update: async (data, options?: JiraSubmitOptions) => { -// dedupIssueKeys(data); -// if ( -// !withinIssueKeyLimit(data.commits) || -// !withinIssueKeyLimit(data.branches) || -// !withinIssueKeyLimit(data.pullRequests) -// ) { -// 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( -// jiraHost, -// gitHubInstallationId, -// gitHubAppId -// ); -// await subscription?.update({ syncWarning: issueKeyLimitWarning }); -// } -// -// return batchedBulkUpdate( -// data, -// instance, -// gitHubInstallationId, -// logger, -// options -// ); -// } -// } -// }, -// workflow: { -// submit: async (data: JiraBuildBulkSubmitData, repositoryId: number, options?: JiraSubmitOptions) => { -// updateIssueKeysFor(data.builds, uniq); -// if (!withinIssueKeyLimit(data.builds)) { -// logger.warn({ -// truncatedBuilds: getTruncatedIssuekeys(data.builds) -// }, issueKeyLimitWarning); -// updateIssueKeysFor(data.builds, truncate); -// const subscription = await Subscription.getSingleInstallation(jiraHost, gitHubInstallationId, gitHubAppId); -// await subscription?.update({ syncWarning: issueKeyLimitWarning }); -// } -// -// const payload = { -// builds: data.builds, -// properties: { -// gitHubInstallationId, -// repositoryId -// }, -// providerMetadata: { -// product: data.product -// }, -// preventTransitions: options?.preventTransitions || false, -// operationType: options?.operationType || "NORMAL" -// }; -// -// logger?.info({ gitHubProduct }, "Sending builds payload to jira."); -// return await instance.post("/rest/builds/0.1/bulk", payload); -// } -// }, -// deployment: { -// submit: async (data: JiraDeploymentBulkSubmitData, repositoryId: number, options?: JiraSubmitOptions): Promise => { -// updateIssueKeysFor(data.deployments, uniq); -// if (!withinIssueKeyLimit(data.deployments)) { -// logger.warn({ -// truncatedDeployments: getTruncatedIssuekeys(data.deployments) -// }, issueKeyLimitWarning); -// updateIssueKeysFor(data.deployments, truncate); -// const subscription = await Subscription.getSingleInstallation(jiraHost, gitHubInstallationId, gitHubAppId); -// await subscription?.update({ syncWarning: issueKeyLimitWarning }); -// } -// const payload = { -// deployments: data.deployments, -// properties: { -// gitHubInstallationId, -// repositoryId -// }, -// preventTransitions: options?.preventTransitions || false, -// operationType: options?.operationType || "NORMAL" -// }; -// -// logger?.info({ gitHubProduct, ...extractDeploymentDataForLoggingPurpose(data, logger) }, "Sending deployments payload to jira."); -// const response: AxiosResponse = await instance.post("/rest/deployments/0.1/bulk", payload); -// -// if ( -// response.data?.rejectedDeployments?.length || -// response.data?.unknownIssueKeys?.length || -// response.data?.unknownAssociations?.length -// ) { -// 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 { -// logger.info({ -// acceptedDeployments: response.data?.acceptedDeployments, -// options, -// ...getDeploymentDebugInfo(data) -// }, "Jira API accepted deployment!"); -// } -// -// return { -// status: response.status, -// rejectedDeployments: response.data?.rejectedDeployments -// }; -// } -// }, -// remoteLink: { -// submit: async (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(jiraHost, gitHubInstallationId, gitHubAppId); -// await subscription?.update({ syncWarning: issueKeyLimitWarning }); -// } -// const payload = { -// remoteLinks: data.remoteLinks, -// properties: { -// gitHubInstallationId -// }, -// preventTransitions: options?.preventTransitions || false, -// operationType: options?.operationType || "NORMAL" -// }; -// logger.info("Sending remoteLinks payload to jira."); -// await instance.post("/rest/remotelinks/1.0/bulk", payload); -// } -// }, -// security: { -// submitVulnerabilities: async (data, options?: JiraSubmitOptions): Promise => { -// const payload = { -// vulnerabilities: data.vulnerabilities, -// properties: { -// gitHubInstallationId -// }, -// operationType: options?.operationType || "NORMAL" -// }; -// logger.info("Sending vulnerabilities payload to jira."); -// return await instance.post("/rest/security/1.0/bulk", payload); -// } -// } -// }; -// -// return client; -// }; - - - - // Splits commits in data payload into chunks of 400 and makes separate requests - // to avoid Jira API limit - - // @ts-ignore - const batchedBulkUpdate = async ( - data, - instance: AxiosInstance, - installationId: number | undefined, - logger: Logger, - options?: JiraSubmitOptions - ) => { - const dedupedCommits = dedupCommits(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 - } - }; - - // @ts-ignore - logger.info({ - // @ts-ignore - 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, - // @ts-ignore - unknownIssueKeys: safeParseAndHashUnknownIssueKeysForLoggingPurpose(response.data, logger) - }, "Jira devinfo bulk update api returned"); - - return response; - }); - return Promise.all(batchedUpdates); - }; - - const findIssueKeyAssociation = (resource: IssueKeyObject): JiraAssociation | undefined => - resource.associations?.find(a => a.associationType == "issueIdOrKeys"); - - - //Returns if the max length of the issue - //key field is within the limit - - // @ts-ignore - const withinIssueKeyLimit = (resources: IssueKeyObject[]): boolean => { - if (!resources) return true; - const issueKeyCounts = resources.map((r) => r.issueKeys?.length || findIssueKeyAssociation(r)?.values?.length || 0); - return Math.max(...issueKeyCounts) <= ISSUE_KEY_API_LIMIT; - }; - - - Returns if the max length of the issue key field is within the limit - Assumption is that the transformed resource only has one association which is for - "issueIdOrKeys" association. - - // @ts-ignore - const withinIssueKeyAssociationsLimit = (resources: JiraRemoteLink[]): boolean => { - if (!resources) { - return true; - } - - const issueKeyCounts = resources.filter(resource => resource.associations?.length > 0).map((resource) => resource.associations[0].values.length); - return Math.max(...issueKeyCounts) <= ISSUE_KEY_API_LIMIT; - }; - - - * Deduplicates commits by ID field for a repository payload - - const dedupCommits = (commits: JiraCommit[] = []): JiraCommit[] => - commits.filter((obj, pos, arr) => - arr.map((mapCommit) => mapCommit.id).indexOf(obj.id) === pos - ); - - - * Deduplicates issueKeys field for branches and commits - - // @ts-ignore - const dedupIssueKeys = (repositoryObj) => { - updateRepositoryIssueKeys(repositoryObj, uniq); - }; - - - *Truncates branches, commits and PRs to their first 100 issue keys - - // @ts-ignore - const truncateIssueKeys = (repositoryObj) => { - updateRepositoryIssueKeys(repositoryObj, truncate); - }; - - interface IssueKeyObject { - issueKeys?: string[]; - associations?: JiraAssociation[]; - } - - // @ts-ignore - export const getTruncatedIssuekeys = (data: IssueKeyObject[] = []): IssueKeyObject[] => - data.reduce((acc: IssueKeyObject[], value: IssueKeyObject) => { - if (value?.issueKeys && value.issueKeys.length > ISSUE_KEY_API_LIMIT) { - acc.push({ - issueKeys: value.issueKeys.slice(ISSUE_KEY_API_LIMIT) - }); - } - const association = findIssueKeyAssociation(value); - if (association?.values && association.values.length > ISSUE_KEY_API_LIMIT) { - acc.push({ - // TODO: Shouldn't it be association.values.slice(ISSUE_KEY_API_LIMIT), just as for issue key?! - associations: [association] - }); - } - return acc; - }, []); - - - Runs a mutating function on all branches, commits and PRs - with issue keys in a Jira Repository object - - const updateRepositoryIssueKeys = (repositoryObj, mutatingFunc) => { - if (repositoryObj.commits) { - repositoryObj.commits = updateIssueKeysFor(repositoryObj.commits, mutatingFunc); - } - - if (repositoryObj.branches) { - repositoryObj.branches = updateIssueKeysFor(repositoryObj.branches, mutatingFunc); - repositoryObj.branches.forEach((branch) => { - if (branch.lastCommit) { - branch.lastCommit = updateIssueKeysFor([branch.lastCommit], mutatingFunc)[0]; - } - }); - } - - if (repositoryObj.pullRequests) { - repositoryObj.pullRequests = updateIssueKeysFor(repositoryObj.pullRequests, mutatingFunc); - } - }; - - - Runs the mutatingFunc on the issue keys field for each branch, commit or PR - - const updateIssueKeysFor = (resources, func) => { - resources.forEach((r) => { - if (r.issueKeys) { - r.issueKeys = func(r.issueKeys); - } - const association = findIssueKeyAssociation(r); - if (association) { - association.values = func(association.values); - } - }); - return resources; - }; - - - * Runs the mutatingFunc on the association values field for each entity resource - * Assumption is that the transformed resource only has one association which is for - "issueIdOrKeys" association. - - // @ts-ignore - const updateIssueKeyAssociationValuesFor = (resources: JiraRemoteLink[], mutatingFunc: any): JiraRemoteLink[] => { - resources?.forEach(resource => { - const association = findIssueKeyAssociation(resource); - if (association) { - association.values = mutatingFunc(resource.associations[0].values); - } - }); - return resources; - }; - -// todo rename more descriptive - // @ts-ignore - const truncate = (array) => array.slice(0, ISSUE_KEY_API_LIMIT); -} -*/ diff --git a/src/routes/github/setup/github-setup-get.ts b/src/routes/github/setup/github-setup-get.ts index 6095e4e8a9..230a75a9a2 100644 --- a/src/routes/github/setup/github-setup-get.ts +++ b/src/routes/github/setup/github-setup-get.ts @@ -43,6 +43,7 @@ export const GithubSetupGet = async (req: Request, res: Response): Promise const gitHubAppClient = await createAppClient(req.log, jiraHost, gitHubAppId, { trigger: "github-setup-get" }); const { githubInstallation, info } = await getInstallationData(gitHubAppClient, githubInstallationId, req.log); + req.addLogFields({ githubInstallationId, appInfo: info }); req.addLogFields({ githubInstallationId, appInfo: info }); req.log.debug("Received get github setup page request"); From b3e29ead373547c06602cbccbdcaba6381298711 Mon Sep 17 00:00:00 2001 From: jkay Date: Wed, 27 Sep 2023 16:24:13 +1300 Subject: [PATCH 03/10] added helper jira client tests --- .eslintrc.json | 1 + .husky/pre-commit | 4 +- src/jira/client/jira-api-client.ts | 12 +- .../jira-client-issue-key-helper.test.ts | 667 ++++++++++++++++++ .../client/jira-client-issue-key-helper.ts | 67 +- 5 files changed, 716 insertions(+), 35 deletions(-) create mode 100644 src/jira/client/jira-client-issue-key-helper.test.ts diff --git a/.eslintrc.json b/.eslintrc.json index c2fd524068..5df87002f6 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -71,6 +71,7 @@ ] } ] + }, "overrides": [{ "files": [ "src/**/*.test.ts" ], diff --git a/.husky/pre-commit b/.husky/pre-commit index aad24318db..c5d871b376 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/bin/sh -. "$(dirname "$0")/_/husky.sh" +#. "$(dirname "$0")/_/husky.sh" -yarn run precommit +#yarn run precommit diff --git a/src/jira/client/jira-api-client.ts b/src/jira/client/jira-api-client.ts index cd55a5755a..b1f34248a6 100644 --- a/src/jira/client/jira-api-client.ts +++ b/src/jira/client/jira-api-client.ts @@ -12,7 +12,7 @@ import { getCloudOrServerFromGitHubAppId } from "utils/get-cloud-or-server"; import { getDeploymentDebugInfo, extractDeploymentDataForLoggingPurpose } from "./jira-client-deployment-helper"; import { truncateIssueKeys, - getTruncatedIssuekeys, + getTruncatedIssueKeys, withinIssueKeyLimit, updateIssueKeyAssociationValuesFor, extractAndHashIssueKeysForLoggingPurpose, @@ -291,9 +291,9 @@ export class JiraClient { 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 + truncatedCommitsCount: getTruncatedIssueKeys(data.commits).length, + truncatedBranchesCount: getTruncatedIssueKeys(data.branches).length, + truncatedPRsCount: getTruncatedIssueKeys(data.pullRequests).length }, issueKeyLimitWarning); truncateIssueKeys(data); const subscription = await Subscription.getSingleInstallation( @@ -316,7 +316,7 @@ export class JiraClient { async submitBuilds(data: JiraBuildBulkSubmitData, repositoryId: number, options?: JiraSubmitOptions) { updateIssueKeysFor(data.builds, uniq); if (!withinIssueKeyLimit(data.builds)) { - this.logger.warn({ truncatedBuilds: getTruncatedIssuekeys(data.builds) }, issueKeyLimitWarning); + 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 }); @@ -342,7 +342,7 @@ export class JiraClient { 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); + 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 }); diff --git a/src/jira/client/jira-client-issue-key-helper.test.ts b/src/jira/client/jira-client-issue-key-helper.test.ts new file mode 100644 index 0000000000..137529457b --- /dev/null +++ b/src/jira/client/jira-client-issue-key-helper.test.ts @@ -0,0 +1,667 @@ +import { + IssueKeyObject, + truncateIssueKeys, + truncate, + getTruncatedIssueKeys, + withinIssueKeyLimit, + updateRepositoryIssueKeys, + updateIssueKeyAssociationValuesFor, + findIssueKeyAssociation, + updateIssueKeysFor, + withinIssueKeyAssociationsLimit, + dedupIssueKeys, + extractAndHashIssueKeysForLoggingPurpose, safeParseAndHashUnknownIssueKeysForLoggingPurpose +} from "./jira-client-issue-key-helper"; +import * as constants from "./jira-client-issue-key-helper"; +import { getLogger } from "config/logger"; +import { + JiraRemoteLink, + JiraCommit +} from "interfaces/jira"; + +// Force ISSUE_KEY_API_LIMIT to a more easily testable size +Object.defineProperty(constants, "ISSUE_KEY_API_LIMIT", { value: 3 }); + +describe("truncate", () => { + it("should truncate an array to the specified limit", () => { + const inputArray = [1, 2, 3, 4, 5, 6]; + const expectedOutput = [1, 2, 3]; + const result = truncate(inputArray); + + expect(result).toEqual(expectedOutput); + }); + + it("should not modify the array if it is within the limit", () => { + const inputArray = [1, 2]; + const expectedOutput = [1, 2]; + const result = truncate(inputArray); + + expect(result).toEqual(expectedOutput); + }); +}); + +describe("truncateIssueKeys", () => { + it("should truncate issue keys in a object", () => { + const repositoryObj = { + commits: [ + { issueKeys: ["KEY1", "KEY2", "KEY3", "KEY4", "KEY5"] }, + { issueKeys: ["KEY6", "KEY7", "KEY8"] } + ], + branches: [ + { issueKeys: ["KEY9", "KEY10"] } + ], + pullRequests: [ + { issueKeys: ["KEY11", "KEY12", "KEY13", "KEY14"] } + ] + }; + + const expectedOutput = { + commits: [ + { issueKeys: ["KEY1", "KEY2", "KEY3"] }, + { issueKeys: ["KEY6", "KEY7", "KEY8"] } + ], + branches: [ + { issueKeys: ["KEY9", "KEY10"] } + ], + pullRequests: [ + { issueKeys: ["KEY11", "KEY12", "KEY13"] } + ] + }; + + truncateIssueKeys(repositoryObj); + + expect(repositoryObj).toEqual(expectedOutput); + }); + + it("should not modify issue keys if they are within the limit", () => { + const repositoryObj = { + commits: [ + { issueKeys: ["KEY1", "KEY2", "KEY3"] }, + { issueKeys: ["KEY4", "KEY5", "KEY6"] } + ], + branches: [ + { issueKeys: ["KEY7", "KEY8", "KEY9"] } + ], + pullRequests: [ + { issueKeys: ["KEY10", "KEY11", "KEY12"] } + ] + }; + + const expectedOutput = JSON.parse(JSON.stringify(repositoryObj)); + + truncateIssueKeys(repositoryObj); + + expect(repositoryObj).toEqual(expectedOutput); + }); +}); + +describe("getTruncatedIssueKeys", () => { + it("should truncate issue keys and associations that exceed the limit", () => { + const input: IssueKeyObject[] = [ + { + issueKeys: ["KEY1", "KEY2", "KEY3"], + associations: [ + { + associationType: "issueIdOrKeys", + values: ["VALUE1", "VALUE2", "VALUE3"] + } + ] + }, + { + issueKeys: ["KEY4", "KEY5", "KEY6", "KEY7"], + associations: [ + { + associationType: "issueIdOrKeys", + values: ["VALUE4", "VALUE5", "VALUE6", "VALUE7"] + } + ] + } + ]; + + const expectedOutput = [ + { + issueKeys: ["KEY1", "KEY2", "KEY3"], + associations: [ + { associationType: "issueIdOrKeys", values: ["VALUE1", "VALUE2", "VALUE3"] } + ] + }, + { + issueKeys: ["KEY4", "KEY5", "KEY6"], + associations: [ + { associationType: "issueIdOrKeys", values: ["VALUE4", "VALUE5", "VALUE6"] } + ] + } + ]; + + const result = getTruncatedIssueKeys(input); + + expect(result).toEqual(expectedOutput); + }); + + it("should handle empty input data", () => { + const input: IssueKeyObject[] = []; + const result = getTruncatedIssueKeys(input); + + expect(result).toEqual([]); + }); +}); + +describe("withinIssueKeyLimit", () => { + it("should return false when issue keys are outside the limit", () => { + const resources = [ + { issueKeys: ["KEY1", "KEY2", "KEY3", "KEY4", "KEY5", "KEY6"] } + ]; + + const result = withinIssueKeyLimit(resources); + + expect(result).toBe(false); + }); + + it("should return true when issue keys and associations are within the limit", () => { + const resources: IssueKeyObject[] = [ + { + issueKeys: ["KEY1", "KEY2", "KEY3"], + associations: [ + { + associationType: "issueIdOrKeys", + values: ["VALUE1", "VALUE2", "VALUE3"] + } + ] + }, + { + issueKeys: ["KEY4", "KEY5", "KEY6"], + associations: [ + { + associationType: "issueIdOrKeys", + values: ["VALUE4", "VALUE5", "VALUE6"] + } + ] + } + ]; + + const result = withinIssueKeyLimit(resources); + + expect(result).toBe(true); + }); + + it("should return true when there are no issue keys", () => { + const resources: IssueKeyObject[] = [ + { + associations: [ + { + associationType: "issueIdOrKeys", + values: ["VALUE1", "VALUE2", "VALUE3"] + } + ] + }, + { + associations: [ + { + associationType: "issueIdOrKeys", + values: ["VALUE4", "VALUE5", "VALUE6"] + } + ] + } + ]; + + const result = withinIssueKeyLimit(resources); + + expect(result).toBe(true); + }); + + it("should return false when at least one issue key exceeds the limit", () => { + const resources: IssueKeyObject[] = [ + { issueKeys: ["KEY1", "KEY2", "KEY3", "KEY4"] }, + { issueKeys: ["KEY5", "KEY6", "KEY7", "KEY8", "KEY9"] } + ]; + + const result = withinIssueKeyLimit(resources); + + expect(result).toBe(false); + }); + + it("should return true when resources is null", () => { + const resources = null as unknown as IssueKeyObject[]; + + const result = withinIssueKeyLimit(resources); + + expect(result).toBe(true); + }); +}); + +describe("updateRepositoryIssueKeys", () => { + const mockMutatingFunc = (issueKeys: string[]) => { + return issueKeys.map(() => "cat"); + }; + + it("should update commits if they exist", () => { + const repositoryObj = { + commits: [ + { issueKeys: ["KEY1"] } + ] + }; + + updateRepositoryIssueKeys(repositoryObj, mockMutatingFunc); + + expect(repositoryObj.commits[0].issueKeys).toEqual(["cat"]); + }); + + it("should update branches if they exist", () => { + const repositoryObj = { + branches: [ + { issueKeys: ["KEY2"], lastCommit: { issueKeys: ["KEY3"] } } + ] + }; + + updateRepositoryIssueKeys(repositoryObj, mockMutatingFunc); + + expect(repositoryObj.branches[0].issueKeys).toEqual(["cat"]); + expect(repositoryObj.branches[0].lastCommit.issueKeys).toEqual(["cat"]); + }); + + it("should update pullRequests if they exist", () => { + const repositoryObj = { + pullRequests: [ + { issueKeys: ["KEY4"] } + ] + }; + + updateRepositoryIssueKeys(repositoryObj, mockMutatingFunc); + + expect(repositoryObj.pullRequests[0].issueKeys).toEqual(["cat"]); + }); + + it("should not update if commits, branches, or pullRequests do not exist", () => { + const repositoryObj = {}; // No commits, branches, or pullRequests + + updateRepositoryIssueKeys(repositoryObj, mockMutatingFunc); + + expect(repositoryObj).toEqual({}); + }); +}); + +describe("findIssueKeyAssociation", () => { + it("should return the first 'issueIdOrKeys' association if it exists", () => { + const resource: IssueKeyObject = { + associations: [ + { + associationType: "issueIdOrKeys", + values: ["VALUE1", "VALUE2", "VALUE3"] + }, + { + associationType: "commit", + values: ["COMMIT1", "COMMIT2"] + } + ] + }; + + const result = findIssueKeyAssociation(resource); + + expect(result).toEqual({ + associationType: "issueIdOrKeys", + values: ["VALUE1", "VALUE2", "VALUE3"] + }); + }); + + it("should return undefined if no 'issueIdOrKeys' association exists", () => { + const resource: IssueKeyObject = { + associations: [ + { + associationType: "commit", + values: ["COMMIT1", "COMMIT2"] + } + ] + }; + + const result = findIssueKeyAssociation(resource); + + expect(result).toBeUndefined(); + }); + + it("should return undefined if associations array is empty", () => { + const resource: IssueKeyObject = { + associations: [] + }; + + const result = findIssueKeyAssociation(resource); + + expect(result).toBeUndefined(); + }); + + it("should return undefined if associations property is missing", () => { + const resource: IssueKeyObject = {}; + + const result = findIssueKeyAssociation(resource); + + expect(result).toBeUndefined(); + }); +}); + +describe("updateIssueKeysFor", () => { + const mockMutatingFunc = (issueKeys: string[]) => { + return issueKeys.map(() => "cat"); + }; + it("should update issue keys and association values when they exist", () => { + const resourceWithIssueKeys = { + issueKeys: ["KEY1", "KEY2"] + }; + const resourceWithAssociation = { + associations: [ + { + associationType: "issueIdOrKeys", + values: ["VALUE1", "VALUE2"] + } + ] + }; + + const resources = [resourceWithIssueKeys, resourceWithAssociation]; + const expectedUpdatedResources = [ + { + issueKeys: ["cat", "cat"] + }, + { + associations: [ + { + associationType: "issueIdOrKeys", + values: ["cat", "cat"] + } + ] + } + ]; + + const result = updateIssueKeysFor(resources, mockMutatingFunc); + + expect(result).toEqual(expectedUpdatedResources); + }); + + it("should handle resources without issue keys or associations", () => { + const resourceWithoutIssueKeys = {}; + const resourceWithoutAssociation = {}; + + const resources = [resourceWithoutIssueKeys, resourceWithoutAssociation]; + + const result = updateIssueKeysFor(resources, mockMutatingFunc); + + expect(result).toEqual([resourceWithoutIssueKeys, resourceWithoutAssociation]); + }); + +}); + +describe("updateIssueKeyAssociationValuesFor", () => { + const mockMutatingFunc = (issueKeys: string[]) => { + return issueKeys.map(() => "cat"); + }; + + it("should update association values for each JiraRemoteLink", () => { + const resources: JiraRemoteLink[] = [ + { + associations: [ + { + associationType: "issueIdOrKeys", + values: ["VALUE1", "VALUE2", "VALUE3"] + } + ] + }, + { + associations: [ + { + associationType: "issueIdOrKeys", + values: ["VALUE4", "VALUE5"] + } + ] + } + ] as JiraRemoteLink[]; + + const result = updateIssueKeyAssociationValuesFor(resources, mockMutatingFunc); + + expect(result).toEqual([ + { + associations: [ + { + associationType: "issueIdOrKeys", + values: ["cat", "cat", "cat"] + } + ] + }, + { + associations: [ + { + associationType: "issueIdOrKeys", + values: ["cat", "cat"] + } + ] + } + ]); + }); + + it("should not update association values when 'issueIdOrKeys' association is missing", () => { + + const resources: JiraRemoteLink[] = [ + { + associations: [ + { + associationType: "commit", + values: ["LAME1", "LAME2"] + } + ] + } + ] as JiraRemoteLink[]; + + const result = updateIssueKeyAssociationValuesFor(resources, mockMutatingFunc); + + expect(result).toEqual(resources); + }); + + it("should handle empty input data", () => { + + const resources: JiraRemoteLink[] = []; + + const result = updateIssueKeyAssociationValuesFor(resources, mockMutatingFunc); + + expect(result).toEqual([]); + }); +}); + +describe("dedupIssueKeys", () => { + it("should call updateRepositoryIssueKeys with repositoryObj and uniq function", () => { + const repositoryObj = { + commits: [ + { issueKeys: ["KEY1", "KEY2", "KEY1", "KEY3"] } + ], + branches: [ + { issueKeys: ["KEY2", "KEY3", "KEY4"] } + ], + pullRequests: [ + { issueKeys: ["KEY1", "KEY4", "KEY5"] } + ] + }; + + const expectedDeduplicatedObj = { + commits: [ + { issueKeys: ["KEY1", "KEY2", "KEY3"] } + ], + branches: [ + { issueKeys: ["KEY2", "KEY3", "KEY4"] } + ], + pullRequests: [ + { issueKeys: ["KEY1", "KEY4", "KEY5"] } + ] + }; + + dedupIssueKeys(repositoryObj); + + expect(repositoryObj).toEqual(expectedDeduplicatedObj); + }); + +}); + +describe("withinIssueKeyAssociationsLimit", () => { + + it("should return true when resources is an empty array", () => { + const resources: JiraRemoteLink[] = []; + + const result = withinIssueKeyAssociationsLimit(resources); + + expect(result).toBe(true); + }); + + it("should return true when associations are within the limit", () => { + const resources: JiraRemoteLink[] = [ + { + associations: [ + { + associationType: "issueIdOrKeys", + values: ["VALUE1", "VALUE2", "VALUE3"] + } + ] + }, + { + associations: [ + { + associationType: "issueIdOrKeys", + values: ["VALUE4", "VALUE5"] + } + ] + } + ] as JiraRemoteLink[]; + + const result = withinIssueKeyAssociationsLimit(resources); + + expect(result).toBe(true); + }); + + it("should return false when associations exceed the limit", () => { + + const resources: JiraRemoteLink[] = [ + { + associations: [ + { + associationType: "issueIdOrKeys", + values: ["VALUE1", "VALUE2", "VALUE3", "VALUETOOMUCH"] + } + ] + }, + { + associations: [ + { + associationType: "issueIdOrKeys", + values: ["VALUE4", "VALUE5", "VALUE6", "VALUE7", "VALUE8"] + } + ] + } + ] as JiraRemoteLink[]; + + const result = withinIssueKeyAssociationsLimit(resources); + + expect(result).toBe(false); + }); + + it("should return true when no resources", () => { + + const resources = null as unknown as JiraRemoteLink[]; + + const result = withinIssueKeyAssociationsLimit(resources); + + expect(result).toBe(true); + }); +}); + +describe("extractAndHashIssueKeysForLoggingPurpose", () => { + const mockLogger = getLogger("mock-logger"); + + const Key1Hash = "1b3db66faabc90466c99b8c7c116c4667d3df0dbe467200331f96dc308a8f73d"; + const Key2Hash = "0b3669cb698c116aea0c8f8fb48d89049fdf312bb951c2ea9e22edfd143edd3b"; + const Key3Hash = "d4941a929360ab3fbad1399502f05421de6f305da8d4692c5bc76a0db0a8a06b"; + + it("should extract and hash issue keys when commits have issue keys", () => { + const commitChunk: JiraCommit[] = [ + { + issueKeys: ["KEY1", "KEY2"] + }, + { + issueKeys: ["KEY3"] + } + ] as JiraCommit[]; + + const result = extractAndHashIssueKeysForLoggingPurpose(commitChunk, mockLogger); + + expect(result).toEqual([Key1Hash, Key2Hash, Key3Hash]); + }); + + it("should filter out empty issue keys", () => { + const commitChunk: JiraCommit[] = [ + { + issueKeys: ["KEY1", "", "KEY2"] + }, + { + issueKeys: [] + } + ] as JiraCommit[]; + + const result = extractAndHashIssueKeysForLoggingPurpose(commitChunk, mockLogger); + + expect(result).toEqual([Key1Hash, Key2Hash]); + }); + + it("should throw an error", () => { + + mockLogger.error = jest.fn(); + + const commitChunk: JiraCommit[] = "not an array" as unknown as JiraCommit[]; + + extractAndHashIssueKeysForLoggingPurpose(commitChunk, mockLogger); + + expect(mockLogger.error).toHaveBeenCalled(); + }); +}); + + +describe("safeParseAndHashUnknownIssueKeysForLoggingPurpose", () => { + + const mockLogger = getLogger("mock-logger"); + const Key1Hash = "1b3db66faabc90466c99b8c7c116c4667d3df0dbe467200331f96dc308a8f73d"; + const Key2Hash = "0b3669cb698c116aea0c8f8fb48d89049fdf312bb951c2ea9e22edfd143edd3b"; + const Key3Hash = "d4941a929360ab3fbad1399502f05421de6f305da8d4692c5bc76a0db0a8a06b"; + + it("should parse and hash unknownIssueKeys when they exist", () => { + const responseData = { + unknownIssueKeys: ["KEY1", "KEY2", "KEY3"] + }; + + const result = safeParseAndHashUnknownIssueKeysForLoggingPurpose(responseData, mockLogger); + + expect(result).toEqual([Key1Hash, Key2Hash, Key3Hash]); + }); + + it("should handle empty unknownIssueKeys", () => { + const responseData = { + unknownIssueKeys: [] + }; + + const result = safeParseAndHashUnknownIssueKeysForLoggingPurpose(responseData, mockLogger); + + expect(result).toEqual([]); + }); + + it("should handle missing unknownIssueKeys", () => { + const responseData = {}; + + const result = safeParseAndHashUnknownIssueKeysForLoggingPurpose(responseData, mockLogger); + + expect(result).toEqual([]); + }); + + it("should throw an error", () => { + + mockLogger.error = jest.fn(); + + const data = { + unknownIssueKeys: {} // this should be an array, so it will cause an error + }; + + safeParseAndHashUnknownIssueKeysForLoggingPurpose(data, mockLogger); + + expect(mockLogger.error).toHaveBeenCalled(); + }); + +}); diff --git a/src/jira/client/jira-client-issue-key-helper.ts b/src/jira/client/jira-client-issue-key-helper.ts index a4574cb0cb..632c8df3cd 100644 --- a/src/jira/client/jira-client-issue-key-helper.ts +++ b/src/jira/client/jira-client-issue-key-helper.ts @@ -1,5 +1,6 @@ import { uniq } from "lodash"; +import Logger from "bunyan"; import { createHashWithSharedSecret } from "utils/encryption"; import { JiraAssociation, @@ -7,50 +8,52 @@ import { JiraRemoteLink } from "interfaces/jira"; -interface IssueKeyObject { +export interface IssueKeyObject { issueKeys?: string[]; associations?: JiraAssociation[]; } -import Logger from "bunyan"; - // Max number of issue keys we can pass to the Jira API export const ISSUE_KEY_API_LIMIT = 500; /** - * Truncates to 100 elements in an array + * Truncates to ISSUE_KEY_API_LIMIT elements in an array */ export const truncate = (array) => array.slice(0, ISSUE_KEY_API_LIMIT); /** - * Truncates branches, commits and PRs to their first 100 issue keys + * Truncates branches, commits and PRs to their first ISSUE_KEY_API_LIMIT issue keys */ export const truncateIssueKeys = (repositoryObj) => { updateRepositoryIssueKeys(repositoryObj, truncate); }; +/** + * Get truncated issue keys and associations based on the ISSUE_KEY_API_LIMIT. + */ +export const getTruncatedIssueKeys = (data: IssueKeyObject[] = []): IssueKeyObject[] => + data.map((value: IssueKeyObject) => { + const truncatedValue: IssueKeyObject = {}; -// TODO: add unit tests -export const getTruncatedIssuekeys = (data: IssueKeyObject[] = []): IssueKeyObject[] => - data.reduce((acc: IssueKeyObject[], value: IssueKeyObject) => { - if (value?.issueKeys && value.issueKeys.length > ISSUE_KEY_API_LIMIT) { - acc.push({ - issueKeys: value.issueKeys.slice(ISSUE_KEY_API_LIMIT) - }); + if (value?.issueKeys) { + truncatedValue.issueKeys = value.issueKeys.slice(0, ISSUE_KEY_API_LIMIT); } + const association = findIssueKeyAssociation(value); - if (association?.values && association.values.length > ISSUE_KEY_API_LIMIT) { - acc.push({ - // TODO: Shouldn't it be association.values.slice(ISSUE_KEY_API_LIMIT), just as for issue key?! - associations: [association] - }); + if (association?.values) { + truncatedValue.associations = [ + { + associationType: association.associationType, + values: association.values.slice(0, ISSUE_KEY_API_LIMIT) + } + ]; } - return acc; - }, []); + + return truncatedValue; + }); /** - * Returns if the max length of the issue - * key field is within the limit + * Returns if the max length of the issue key field is within the limit */ export const withinIssueKeyLimit = (resources: IssueKeyObject[]): boolean => { if (!resources) return true; @@ -58,7 +61,6 @@ export const withinIssueKeyLimit = (resources: IssueKeyObject[]): boolean => { return Math.max(...issueKeyCounts) <= ISSUE_KEY_API_LIMIT; }; -//// TO BE BNROKEN INTO A UTILS FILE /** * Deduplicates issueKeys field for branches and commits */ @@ -67,14 +69,11 @@ export const dedupIssueKeys = (repositoryObj) => { }; -const findIssueKeyAssociation = (resource: IssueKeyObject): JiraAssociation | undefined => - resource.associations?.find(a => a.associationType == "issueIdOrKeys"); - /** * Runs a mutating function on all branches, commits and PRs * with issue keys in a Jira Repository object */ -const updateRepositoryIssueKeys = (repositoryObj, mutatingFunc) => { +export const updateRepositoryIssueKeys = (repositoryObj, mutatingFunc) => { if (repositoryObj.commits) { repositoryObj.commits = updateIssueKeysFor(repositoryObj.commits, mutatingFunc); } @@ -93,6 +92,13 @@ const updateRepositoryIssueKeys = (repositoryObj, mutatingFunc) => { } }; +/** + * Finds the first association of type "issueIdOrKeys" in a given resource. + */ +export const findIssueKeyAssociation = (resource: IssueKeyObject): JiraAssociation | undefined => { + return resource.associations?.find(a => a.associationType == "issueIdOrKeys"); +}; + /** * Runs the mutatingFunc on the issue keys field for each branch, commit or PR */ @@ -108,6 +114,7 @@ export const updateIssueKeysFor = (resources, func) => { }); return resources; }; + /** * Runs the mutatingFunc on the association values field for each entity resource * Assumption is that the transformed resource only has one association which is for @@ -137,6 +144,9 @@ export const withinIssueKeyAssociationsLimit = (resources: JiraRemoteLink[]): bo return Math.max(...issueKeyCounts) <= ISSUE_KEY_API_LIMIT; }; +/** + * Extracts unique issue keys and hashes them + */ export const extractAndHashIssueKeysForLoggingPurpose = (commitChunk: JiraCommit[], logger: Logger): string[] => { try { return commitChunk @@ -149,6 +159,9 @@ export const extractAndHashIssueKeysForLoggingPurpose = (commitChunk: JiraCommit } }; +/** + * hash unknown issue keys + */ export const safeParseAndHashUnknownIssueKeysForLoggingPurpose = (responseData: any, logger: Logger): string[] => { try { return (responseData["unknownIssueKeys"] || []).map((key: string) => createHashWithSharedSecret(key)); @@ -156,4 +169,4 @@ export const safeParseAndHashUnknownIssueKeysForLoggingPurpose = (responseData: logger.error({ error }, "Error parsing unknownIssueKeys from jira api response"); return []; } -}; \ No newline at end of file +}; From 23f1f2a80847d8a8863b1731efdcef9fccb7bba7 Mon Sep 17 00:00:00 2001 From: jkay Date: Thu, 28 Sep 2023 11:10:12 +1300 Subject: [PATCH 04/10] tidy upclient tests and helper comments --- .eslintrc.json | 74 +++++++++---------- src/jira/client/jira-api-client.test.ts | 3 +- src/jira/client/jira-api-client.ts | 48 ++++++++---- .../client/jira-client-issue-key-helper.ts | 6 +- 4 files changed, 75 insertions(+), 56 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 089bb3422c..2b043aeb55 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -102,41 +102,41 @@ ] } }, - { - "files": [ - "src/routes/**" - ], - "rules": { - // To be removed later - "@typescript-eslint/no-unsafe-assignment": "off", - "@typescript-eslint/no-unsafe-argument": "off", - "@typescript-eslint/no-unsafe-member-access": "off", - "@typescript-eslint/no-unsafe-return": "off", - "@typescript-eslint/no-unsafe-call": "off", - "@typescript-eslint/require-await": "off", - "@typescript-eslint/await-thenable": "off", - "@typescript-eslint/no-misused-promises": "off" - } - }, - { - "files": [ - "src/*.ts", - "src/config/**", - "src/github/**", - "src/jira/**", - "src/middleware/**", - "src/models/**", - "src/sync/**", - "src/transforms/**", - "src/util/**" - ], - "rules": { - // To be removed later - "@typescript-eslint/no-unsafe-assignment": "off", - "@typescript-eslint/no-unsafe-argument": "off", - "@typescript-eslint/no-unsafe-member-access": "off", - "@typescript-eslint/no-unsafe-return": "off", - "@typescript-eslint/no-unsafe-call": "off" - } - }] + { + "files": [ + "src/routes/**" + ], + "rules": { + // To be removed later + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-return": "off", + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/require-await": "off", + "@typescript-eslint/await-thenable": "off", + "@typescript-eslint/no-misused-promises": "off" + } + }, + { + "files": [ + "src/*.ts", + "src/config/**", + "src/github/**", + "src/jira/**", + "src/middleware/**", + "src/models/**", + "src/sync/**", + "src/transforms/**", + "src/util/**" + ], + "rules": { + // To be removed later + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-return": "off", + "@typescript-eslint/no-unsafe-call": "off" + } + }] } diff --git a/src/jira/client/jira-api-client.test.ts b/src/jira/client/jira-api-client.test.ts index 6aff1987b9..90de5d4bfb 100644 --- a/src/jira/client/jira-api-client.test.ts +++ b/src/jira/client/jira-api-client.test.ts @@ -3,6 +3,7 @@ import { getLogger } from "config/logger"; import { JiraClient } from "./jira-api-client"; import { DatabaseStateCreator } from "test/utils/database-state-creator"; + describe("JiraClient", () => { let jiraClient: JiraClient | null; beforeEach(async () => { @@ -104,4 +105,4 @@ describe("JiraClient", () => { }); }); -}); \ No newline at end of file +}); diff --git a/src/jira/client/jira-api-client.ts b/src/jira/client/jira-api-client.ts index b1f34248a6..16e3cb78da 100644 --- a/src/jira/client/jira-api-client.ts +++ b/src/jira/client/jira-api-client.ts @@ -65,11 +65,6 @@ export class JiraClient { this.logger = logger.child({ jiraHost, gitHubInstallationId, gitHubAppId, gitHubProduct }); } - /* - * Tests credentials by making a request to the Jira API - * - * @return {boolean} Returns true if client has access to Jira API - */ async isAuthorized(): Promise { try { return (await this.axios.get("/rest/devinfo/0.10/existsByProperties?fakeProperty=1")).status === 200; @@ -110,6 +105,7 @@ export class JiraClient { return await this.axios.delete(`/rest/security/1.0/linkedWorkspaces/bulk?workspaceIds=${subscriptionId}`); } + // TODO TEST async checkAdminPermissions(accountId: string) { const payload = { accountId, @@ -120,6 +116,7 @@ export class JiraClient { return await this.axios.post("/rest/api/latest/permissions/check", payload); } + // TODO TEST // ISSUES async getIssue(issueId: string, query = { fields: "summary" }): Promise> { return this.axios.get("/rest/api/latest/issue/{issue_id}", { @@ -130,6 +127,7 @@ export class JiraClient { }); } + // TODO TEST async getAllIssues(issueIds: string[], query?: { fields: string }): Promise { const responses = await Promise.all | undefined>( issueIds.map((issueId) => this.getIssue(issueId, query).catch(() => undefined)) @@ -142,11 +140,13 @@ export class JiraClient { }, []); } + // TODO TEST static parseIssueText(text: string): string[] | undefined { if (!text) return undefined; return jiraIssueKeyParser(text); } + // TODO TEST // ISSUE COMMENTS async listIssueComments(issueId: string) { return this.axios.get("/rest/api/latest/issue/{issue_id}/comment?expand=properties", { @@ -156,6 +156,7 @@ export class JiraClient { }); } + // TODO TEST async addIssueComment(issueId: string, payload: any) { return this.axios.post("/rest/api/latest/issue/{issue_id}/comment", payload, { urlParams: { @@ -164,6 +165,7 @@ export class JiraClient { }); } + // TODO TEST async updateIssueComment(issueId: string, commentId: string, payload: any) { return this.axios.put("rest/api/latest/issue/{issue_id}/comment/{comment_id}", payload, { urlParams: { @@ -173,6 +175,7 @@ export class JiraClient { }); } + // TODO TEST async deleteIssueComment(issueId: string, commentId: string) { return this.axios.delete("rest/api/latest/issue/{issue_id}/comment/{comment_id}", { urlParams: { @@ -182,7 +185,8 @@ export class JiraClient { }); } - // ISSUE TRANSISTIONS + // TODO TEST + // ISSUE TRANSITIONS async listIssueTransistions(issueId: string) { return this.axios.get("/rest/api/latest/issue/{issue_id}/transitions", { urlParams: { @@ -191,6 +195,7 @@ export class JiraClient { }); } + // TODO TEST async updateIssueTransistions(issueId: string, transitionId: string) { return this.axios.post("/rest/api/latest/issue/{issue_id}/transitions", { transition: { @@ -203,6 +208,7 @@ export class JiraClient { }); } + // TODO TEST // ISSUE WORKLOGS async addWorklogForIssue(issueId: string, payload: any) { return this.axios.post("/rest/api/latest/issue/{issue_id}/worklog", payload, { @@ -212,6 +218,7 @@ export class JiraClient { }); } + // TODO TEST // DELETE INSTALLATION async deleteInstallation(gitHubInstallationId: string | number) { return Promise.all([ @@ -236,6 +243,7 @@ export class JiraClient { ]); } + // TODO TEST // DEV INFO async deleteBranch(transformedRepositoryId: TransformedRepositoryId, branchRef: string) { return this.axios.delete("/rest/devinfo/0.10/repository/{transformedRepositoryId}/branch/{branchJiraId}", @@ -251,6 +259,7 @@ export class JiraClient { ); } + // TODO TEST async deletePullRequest(transformedRepositoryId: TransformedRepositoryId, pullRequestId: string) { return this.axios.delete("/rest/devinfo/0.10/repository/{transformedRepositoryId}/pull_request/{pullRequestId}", { params: { @@ -263,6 +272,7 @@ export class JiraClient { }); } + // TODO TEST async deleteRepository(repositoryId: number, gitHubBaseUrl?: string) { const transformedRepositoryId = transformRepositoryId(repositoryId, gitHubBaseUrl); return Promise.all([ @@ -287,6 +297,7 @@ export class JiraClient { ]); } + // TODO TEST async updateRepository(data: any, options?: JiraSubmitOptions) { dedupIssueKeys(data); if (!withinIssueKeyLimit(data.commits) || !withinIssueKeyLimit(data.branches) || !withinIssueKeyLimit(data.pullRequests)) { @@ -313,6 +324,7 @@ export class JiraClient { ); } + // TODO TEST async submitBuilds(data: JiraBuildBulkSubmitData, repositoryId: number, options?: JiraSubmitOptions) { updateIssueKeysFor(data.builds, uniq); if (!withinIssueKeyLimit(data.builds)) { @@ -339,6 +351,7 @@ export class JiraClient { return await 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)) { @@ -387,6 +400,7 @@ export class JiraClient { }; } + // TODO TEST async submitRemoteLinks(data, options?: JiraSubmitOptions) { // Note: RemoteLinks doesn't have an issueKey field and takes in associations instead updateIssueKeyAssociationValuesFor(data.remoteLinks, uniq); @@ -407,6 +421,7 @@ export class JiraClient { await this.axios.post("/rest/remotelinks/1.0/bulk", payload); } + // TODO TEST async submitVulnerabilities(data: JiraVulnerabilityBulkSubmitData, options?: JiraSubmitOptions): Promise { const payload = { vulnerabilities: data.vulnerabilities, @@ -420,15 +435,18 @@ export class JiraClient { } } -/** - * Deduplicates commits by ID field for a repository payload - */ -const dedupCommits = (commits: JiraCommit[] = []): JiraCommit[] => - commits.filter( - (obj, pos, arr) => - arr.map((mapCommit) => mapCommit.id).indexOf(obj.id) === pos - ); +// 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 @@ -440,7 +458,7 @@ const batchedBulkUpdate = async ( logger: Logger, options?: JiraSubmitOptions ) => { - const dedupedCommits = dedupCommits(data.commits); + 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 { diff --git a/src/jira/client/jira-client-issue-key-helper.ts b/src/jira/client/jira-client-issue-key-helper.ts index 632c8df3cd..74492507ee 100644 --- a/src/jira/client/jira-client-issue-key-helper.ts +++ b/src/jira/client/jira-client-issue-key-helper.ts @@ -1,4 +1,3 @@ - import { uniq } from "lodash"; import Logger from "bunyan"; import { createHashWithSharedSecret } from "utils/encryption"; @@ -19,7 +18,9 @@ export const ISSUE_KEY_API_LIMIT = 500; /** * Truncates to ISSUE_KEY_API_LIMIT elements in an array */ -export const truncate = (array) => array.slice(0, ISSUE_KEY_API_LIMIT); +export const truncate = (array: unknown[]) => { + return array.slice(0, ISSUE_KEY_API_LIMIT); +}; /** * Truncates branches, commits and PRs to their first ISSUE_KEY_API_LIMIT issue keys @@ -68,7 +69,6 @@ export const dedupIssueKeys = (repositoryObj) => { updateRepositoryIssueKeys(repositoryObj, uniq); }; - /** * Runs a mutating function on all branches, commits and PRs * with issue keys in a Jira Repository object From 762865474ff05ac06f599c723646616b0d1d80e6 Mon Sep 17 00:00:00 2001 From: jkay Date: Thu, 28 Sep 2023 11:50:03 +1300 Subject: [PATCH 05/10] removed bonus logging and added todo comments --- src/jira/client/jira-client-deployment-helper.ts | 3 ++- src/routes/github/setup/github-setup-get.ts | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/jira/client/jira-client-deployment-helper.ts b/src/jira/client/jira-client-deployment-helper.ts index 0597a923db..973eee6355 100644 --- a/src/jira/client/jira-client-deployment-helper.ts +++ b/src/jira/client/jira-client-deployment-helper.ts @@ -19,6 +19,7 @@ export const getDeploymentDebugInfo = (jiraPayload: JiraDeploymentBulkSubmitData }; }; +// TODO Tests required export const extractDeploymentDataForLoggingPurpose = (data: JiraDeploymentBulkSubmitData, logger: Logger): Record => { try { return { @@ -35,4 +36,4 @@ export const extractDeploymentDataForLoggingPurpose = (data: JiraDeploymentBulkS logger.error({ error }, "Fail extractDeploymentDataForLoggingPurpose"); return {}; } -}; \ No newline at end of file +}; diff --git a/src/routes/github/setup/github-setup-get.ts b/src/routes/github/setup/github-setup-get.ts index 230a75a9a2..6095e4e8a9 100644 --- a/src/routes/github/setup/github-setup-get.ts +++ b/src/routes/github/setup/github-setup-get.ts @@ -43,7 +43,6 @@ export const GithubSetupGet = async (req: Request, res: Response): Promise const gitHubAppClient = await createAppClient(req.log, jiraHost, gitHubAppId, { trigger: "github-setup-get" }); const { githubInstallation, info } = await getInstallationData(gitHubAppClient, githubInstallationId, req.log); - req.addLogFields({ githubInstallationId, appInfo: info }); req.addLogFields({ githubInstallationId, appInfo: info }); req.log.debug("Received get github setup page request"); From c35a993f70856d35b7bf6f1f06425300c424a40f Mon Sep 17 00:00:00 2001 From: jkay Date: Fri, 29 Sep 2023 14:59:04 +1300 Subject: [PATCH 06/10] added a loooot of new testing coverage for new jira api client --- src/jira/client/jira-api-client.test.ts | 682 +++++++++++++++++++++++- src/jira/client/jira-api-client.ts | 23 +- 2 files changed, 683 insertions(+), 22 deletions(-) diff --git a/src/jira/client/jira-api-client.test.ts b/src/jira/client/jira-api-client.test.ts index 90de5d4bfb..23f44ccae1 100644 --- a/src/jira/client/jira-api-client.test.ts +++ b/src/jira/client/jira-api-client.test.ts @@ -2,7 +2,8 @@ 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; @@ -105,4 +106,683 @@ describe("JiraClient", () => { }); }); + 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 index 16e3cb78da..4af93243d1 100644 --- a/src/jira/client/jira-api-client.ts +++ b/src/jira/client/jira-api-client.ts @@ -105,7 +105,6 @@ export class JiraClient { return await this.axios.delete(`/rest/security/1.0/linkedWorkspaces/bulk?workspaceIds=${subscriptionId}`); } - // TODO TEST async checkAdminPermissions(accountId: string) { const payload = { accountId, @@ -116,7 +115,6 @@ export class JiraClient { return await this.axios.post("/rest/api/latest/permissions/check", payload); } - // TODO TEST // ISSUES async getIssue(issueId: string, query = { fields: "summary" }): Promise> { return this.axios.get("/rest/api/latest/issue/{issue_id}", { @@ -126,8 +124,6 @@ export class JiraClient { } }); } - - // TODO TEST async getAllIssues(issueIds: string[], query?: { fields: string }): Promise { const responses = await Promise.all | undefined>( issueIds.map((issueId) => this.getIssue(issueId, query).catch(() => undefined)) @@ -140,13 +136,11 @@ export class JiraClient { }, []); } - // TODO TEST static parseIssueText(text: string): string[] | undefined { if (!text) return undefined; return jiraIssueKeyParser(text); } - // TODO TEST // ISSUE COMMENTS async listIssueComments(issueId: string) { return this.axios.get("/rest/api/latest/issue/{issue_id}/comment?expand=properties", { @@ -156,7 +150,6 @@ export class JiraClient { }); } - // TODO TEST async addIssueComment(issueId: string, payload: any) { return this.axios.post("/rest/api/latest/issue/{issue_id}/comment", payload, { urlParams: { @@ -165,7 +158,6 @@ export class JiraClient { }); } - // TODO TEST async updateIssueComment(issueId: string, commentId: string, payload: any) { return this.axios.put("rest/api/latest/issue/{issue_id}/comment/{comment_id}", payload, { urlParams: { @@ -175,7 +167,6 @@ export class JiraClient { }); } - // TODO TEST async deleteIssueComment(issueId: string, commentId: string) { return this.axios.delete("rest/api/latest/issue/{issue_id}/comment/{comment_id}", { urlParams: { @@ -185,7 +176,6 @@ export class JiraClient { }); } - // TODO TEST // ISSUE TRANSITIONS async listIssueTransistions(issueId: string) { return this.axios.get("/rest/api/latest/issue/{issue_id}/transitions", { @@ -195,7 +185,6 @@ export class JiraClient { }); } - // TODO TEST async updateIssueTransistions(issueId: string, transitionId: string) { return this.axios.post("/rest/api/latest/issue/{issue_id}/transitions", { transition: { @@ -208,7 +197,6 @@ export class JiraClient { }); } - // TODO TEST // ISSUE WORKLOGS async addWorklogForIssue(issueId: string, payload: any) { return this.axios.post("/rest/api/latest/issue/{issue_id}/worklog", payload, { @@ -218,7 +206,6 @@ export class JiraClient { }); } - // TODO TEST // DELETE INSTALLATION async deleteInstallation(gitHubInstallationId: string | number) { return Promise.all([ @@ -243,7 +230,6 @@ export class JiraClient { ]); } - // TODO TEST // DEV INFO async deleteBranch(transformedRepositoryId: TransformedRepositoryId, branchRef: string) { return this.axios.delete("/rest/devinfo/0.10/repository/{transformedRepositoryId}/branch/{branchJiraId}", @@ -259,7 +245,6 @@ export class JiraClient { ); } - // TODO TEST async deletePullRequest(transformedRepositoryId: TransformedRepositoryId, pullRequestId: string) { return this.axios.delete("/rest/devinfo/0.10/repository/{transformedRepositoryId}/pull_request/{pullRequestId}", { params: { @@ -272,7 +257,6 @@ export class JiraClient { }); } - // TODO TEST async deleteRepository(repositoryId: number, gitHubBaseUrl?: string) { const transformedRepositoryId = transformRepositoryId(repositoryId, gitHubBaseUrl); return Promise.all([ @@ -324,7 +308,6 @@ export class JiraClient { ); } - // TODO TEST async submitBuilds(data: JiraBuildBulkSubmitData, repositoryId: number, options?: JiraSubmitOptions) { updateIssueKeysFor(data.builds, uniq); if (!withinIssueKeyLimit(data.builds)) { @@ -348,7 +331,7 @@ export class JiraClient { }; this.logger.info("Sending builds payload to jira."); - return await this.axios.post("/rest/builds/0.1/bulk", payload); + return this.axios.post("/rest/builds/0.1/bulk", payload); } // TODO TEST @@ -400,7 +383,6 @@ export class JiraClient { }; } - // TODO TEST async submitRemoteLinks(data, options?: JiraSubmitOptions) { // Note: RemoteLinks doesn't have an issueKey field and takes in associations instead updateIssueKeyAssociationValuesFor(data.remoteLinks, uniq); @@ -418,10 +400,9 @@ export class JiraClient { operationType: options?.operationType || "NORMAL" }; this.logger.info("Sending remoteLinks payload to jira."); - await this.axios.post("/rest/remotelinks/1.0/bulk", payload); + return this.axios.post("/rest/remotelinks/1.0/bulk", payload); } - // TODO TEST async submitVulnerabilities(data: JiraVulnerabilityBulkSubmitData, options?: JiraSubmitOptions): Promise { const payload = { vulnerabilities: data.vulnerabilities, From 69e40d502b27199d2599d505eb68a37676e039e1 Mon Sep 17 00:00:00 2001 From: jkay Date: Tue, 3 Oct 2023 12:29:54 +1300 Subject: [PATCH 07/10] getcloudd test --- src/jira/client/jira-api-client.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/jira/client/jira-api-client.test.ts b/src/jira/client/jira-api-client.test.ts index 23f44ccae1..ad7b91137c 100644 --- a/src/jira/client/jira-api-client.test.ts +++ b/src/jira/client/jira-api-client.test.ts @@ -62,6 +62,17 @@ describe("JiraClient", () => { }); }); + describe("getCloudId()", () => { + it("should return cloudId data", async () => { + jiraNock + .get("_edge/tenant_info") + .reply(200, { data: "cat" }); + + const data = await jiraClient?.getCloudId(); + expect(data).toEqual("cat"); + }); + }); + describe("appPropertiesGet()", () => { it("returns data", async () => { jiraNock From 0dd62480cec0d0c6d33cb622cb8645c9fefa760b Mon Sep 17 00:00:00 2001 From: joshkay10 Date: Tue, 3 Oct 2023 14:32:10 +1300 Subject: [PATCH 08/10] Update jira-api-client.test.ts --- src/jira/client/jira-api-client.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jira/client/jira-api-client.test.ts b/src/jira/client/jira-api-client.test.ts index ad7b91137c..da2d95842a 100644 --- a/src/jira/client/jira-api-client.test.ts +++ b/src/jira/client/jira-api-client.test.ts @@ -65,7 +65,7 @@ describe("JiraClient", () => { describe("getCloudId()", () => { it("should return cloudId data", async () => { jiraNock - .get("_edge/tenant_info") + .get("/_edge/tenant_info") .reply(200, { data: "cat" }); const data = await jiraClient?.getCloudId(); From 8b070c842f107206afc3797700598726f0b307e4 Mon Sep 17 00:00:00 2001 From: jkay Date: Thu, 19 Oct 2023 11:33:37 +1100 Subject: [PATCH 09/10] update cloudid test for jiraclient --- src/jira/client/jira-api-client.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jira/client/jira-api-client.test.ts b/src/jira/client/jira-api-client.test.ts index da2d95842a..701a70b8be 100644 --- a/src/jira/client/jira-api-client.test.ts +++ b/src/jira/client/jira-api-client.test.ts @@ -68,7 +68,7 @@ describe("JiraClient", () => { .get("/_edge/tenant_info") .reply(200, { data: "cat" }); - const data = await jiraClient?.getCloudId(); + const data = await jiraClient?.getCloudId().data; expect(data).toEqual("cat"); }); }); From 43c6691016e1b97e37ef5745dea51bd51616a59c Mon Sep 17 00:00:00 2001 From: jkay Date: Thu, 19 Oct 2023 14:26:24 +1100 Subject: [PATCH 10/10] test fix for cloudid fetch --- src/jira/client/jira-api-client.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/jira/client/jira-api-client.test.ts b/src/jira/client/jira-api-client.test.ts index 701a70b8be..14709902f2 100644 --- a/src/jira/client/jira-api-client.test.ts +++ b/src/jira/client/jira-api-client.test.ts @@ -66,9 +66,9 @@ describe("JiraClient", () => { it("should return cloudId data", async () => { jiraNock .get("/_edge/tenant_info") - .reply(200, { data: "cat" }); + .reply(200, "cat"); - const data = await jiraClient?.getCloudId().data; + const data = await jiraClient!.getCloudId(); expect(data).toEqual("cat"); }); });