-
Notifications
You must be signed in to change notification settings - Fork 191
/
transform-deployment.ts
425 lines (374 loc) · 15.4 KB
/
transform-deployment.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
import Logger from "bunyan";
import { JiraAssociation, JiraDeploymentBulkSubmitData } from "interfaces/jira";
import type { DeploymentStatusEvent } from "@octokit/webhooks-types";
import { Octokit } from "@octokit/rest";
import {
CommitSummary,
extractMessagesFromCommitSummaries,
getAllCommitsBetweenReferences
} from "./util/github-api-requests";
import { GitHubInstallationClient } from "../github/client/github-installation-client";
import { AxiosResponse } from "axios";
import _, { deburr } from "lodash";
import { jiraIssueKeyParser } from "utils/jira-utils";
import { Config } from "interfaces/common";
import { Subscription } from "models/subscription";
import minimatch from "minimatch";
import { getRepoConfig } from "services/user-config-service";
import { TransformedRepositoryId, transformRepositoryId } from "~/src/transforms/transform-repository-id";
import { shouldSendAll, booleanFlag, BooleanFlags } from "config/feature-flags";
import { findLastSuccessDeploymentFromCache } from "services/deployment-cache-service";
import { statsd } from "config/statsd";
import { metricDeploymentCache } from "config/metric-names";
import { getCloudOrServerFromGitHubAppId } from "utils/get-cloud-or-server";
const MAX_ASSOCIATIONS_PER_ENTITY = 500;
// https://docs.github.com/en/rest/reference/repos#list-deployments
const getLastSuccessfulDeployCommitSha = async (
owner: string,
repoName: string,
githubInstallationClient: GitHubInstallationClient,
deployments: Octokit.ReposListDeploymentsResponseItem[],
logger?: Logger
): Promise<string> => {
try {
for (const deployment of deployments) {
// Get each deployment status for this environment so we can have their statuses' ids
const listDeploymentStatusResponse: AxiosResponse<Octokit.ReposListDeploymentStatusesResponse> =
await githubInstallationClient.listDeploymentStatuses(owner, repoName, deployment.id, 100);
// Find the first successful one
const lastSuccessful = listDeploymentStatusResponse.data.find(deployment => deployment.state === "success");
if (lastSuccessful) {
return deployment.sha;
}
}
} catch (e: unknown) {
logger?.debug(`Failed to get deployment statuses.`);
}
// If there's no successful deployment on the list of deployments that GitHub returned us (max. 100) then we'll return the last one from the array, even if it's a failed one.
return deployments[deployments.length - 1].sha;
};
const getLastSuccessDeploymentShaFromCache = async (
type: "webhook" | "backfill",
jiraHost: string,
repoId: number,
currentDeployEnv: string,
currentDeployDate: string,
githubInstallationClient: GitHubInstallationClient,
logger: Logger
): Promise<string | undefined> => {
logger.info("Using dynamodb for get last success deployment");
const gitHubProduct = getCloudOrServerFromGitHubAppId(githubInstallationClient.gitHubServerAppId);
const tags = { gitHubProduct, type };
const info = { jiraHost };
try {
statsd.increment(metricDeploymentCache.lookup, tags, info);
if (!githubInstallationClient.baseUrl) {
logger.warn("Skip lookup from dynamodb as gitHub baseUrl is empty");
statsd.increment(metricDeploymentCache.miss, { missedType: "baseurl-empty", ...tags }, info);
return undefined;
}
if (!currentDeployEnv) {
logger.warn("Skip lookup from dynamodb as currentDeployEnv is empty");
statsd.increment(metricDeploymentCache.miss, { missedType: "env-empty", ...tags }, info);
return undefined;
}
if (!currentDeployDate) {
logger.warn("Skip lookup from dynamodb as currentDeployDate is empty");
statsd.increment(metricDeploymentCache.miss, { missedType: "date-empty", ...tags }, info);
return undefined;
}
if (!repoId) {
logger.warn("Skip lookup from dynamodb as repoId is empty");
statsd.increment(metricDeploymentCache.miss, { missedType: "repoId-empty", ...tags }, info);
return undefined;
}
const lastSuccessful = await findLastSuccessDeploymentFromCache({
gitHubBaseUrl: githubInstallationClient.baseUrl,
env: currentDeployEnv,
repositoryId: repoId,
currentDate: new Date(currentDeployDate)
}, logger);
if (!lastSuccessful) {
logger.info("Couldn't find last success deployment from dynamodb");
statsd.increment(metricDeploymentCache.miss, { missedType: "not-found", ...tags }, info);
return undefined;
} else if (!lastSuccessful.commitSha) {
logger.warn("Missing commit sha from deployment");
statsd.increment(metricDeploymentCache.miss, { missedType: "sha-empty", ...tags }, info);
return undefined;
} else {
logger.info("Found last success deployment info");
statsd.increment(metricDeploymentCache.hit, tags, info);
return lastSuccessful.commitSha;
}
} catch (e: unknown) {
statsd.increment(metricDeploymentCache.failed, { failType: "lookup", ...tags }, info);
logger.error({ err: e }, "Error look up deployment information from dynamodb");
throw e;
}
};
const getCommitsSinceLastSuccessfulDeploymentFromCache = async (
jiraHost: string,
type: "backfill" | "webhook",
owner: string,
repoId: number,
repoName: string,
currentDeploySha: string,
currentDeployId: number,
currentDeployEnv: string,
currentDeployDate: string,
githubInstallationClient: GitHubInstallationClient,
logger: Logger
): Promise<CommitSummary[] | undefined> => {
let lastSuccessfullyDeployedCommit: string | undefined = undefined;
if (type === "webhook") {
lastSuccessfullyDeployedCommit = await getLastSuccessDeploymentShaFromCache(type, jiraHost, repoId, currentDeployEnv, currentDeployDate, githubInstallationClient, logger);
}
if (type === "backfill") {
lastSuccessfullyDeployedCommit = await getLastSuccessDeploymentShaFromCache(type, jiraHost, repoId, currentDeployEnv, currentDeployDate, githubInstallationClient, logger);
}
if (!lastSuccessfullyDeployedCommit) {
// Grab the last 10 deployments for this repo
const deployments: Octokit.Response<Octokit.ReposListDeploymentsResponse> | AxiosResponse<Octokit.ReposListDeploymentsResponse> =
await githubInstallationClient.listDeployments(owner, repoName, currentDeployEnv, 10);
// Filter per current environment and exclude itself
const filteredDeployments = deployments.data
.filter(deployment => deployment.id !== currentDeployId);
// If this is the very first successful deployment ever, return nothing because we won't have any commit sha to compare with the current one.
if (!filteredDeployments.length) {
return undefined;
}
lastSuccessfullyDeployedCommit = await getLastSuccessfulDeployCommitSha(owner, repoName, githubInstallationClient, filteredDeployments, logger);
}
const compareCommitsPayload = {
owner: owner,
repo: repoName,
base: lastSuccessfullyDeployedCommit,
head: currentDeploySha
};
return await getAllCommitsBetweenReferences(
compareCommitsPayload,
githubInstallationClient,
logger
);
};
// We need to map the state of a GitHub deployment back to a valid deployment state in Jira.
// https://docs.github.com/en/rest/reference/repos#list-deployments
// Deployment state - GitHub: Can be one of error, failure, pending, in_progress, queued, or success
// https://developer.atlassian.com/cloud/jira/software/rest/api-group-builds/#api-deployments-0-1-bulk-post
// Deployment state - Jira: Can be one of unknown, pending, in_progress, cancelled, failed, rolled_back, successful
export const mapState = (state: string | undefined): string => {
switch (state?.toLowerCase()) {
case "queued":
case "waiting":
return "pending";
// We send "pending" as "in progress" because the GitHub API goes Pending -> Success (there's no in progress update).
// For users, it's a better UI experience if they see In progress instead of Pending, because the deployment might be running already.
case "pending":
case "in_progress":
return "in_progress";
case "success":
return "successful";
case "error":
case "failure":
return "failed";
default:
return "unknown";
}
};
const matchesEnvironment = (environment: string, globPatterns: string[] | undefined | null): boolean => {
for (const glob of globPatterns || []) {
if (minimatch(environment, glob)) {
return true;
}
}
return false;
};
/**
* Maps a given environment name to a Jira environment name using the custom mapping defined in a Config.
*/
export const mapEnvironmentWithConfig = (environment: string, config: Config): string | undefined => {
return _.keys(config?.deployments?.environmentMapping)
.find(jiraEnvironmentType => matchesEnvironment(environment, config.deployments?.environmentMapping?.[jiraEnvironmentType]));
};
// We need to map the environment of a GitHub deployment back to a valid deployment environment in Jira.
// https://docs.github.com/en/actions/reference/environments
// GitHub: does not have pre-defined values and users can name their environments whatever they like. We try to map as much as we can here and log the unmapped ones.
// Jira: Can be one of unmapped, development, testing, staging, production
export const mapEnvironment = (environment: string, config?: Config): string => {
const isEnvironment = (envNames: string[]): boolean => {
// Matches any of the input names exactly
const exactMatch = envNames.join("|");
// Matches separators within environment names, e.g. "-" in "prod-east" or ":" in "test:mary"
const separator = "[^a-z0-9]";
// Matches an optional prefix, followed by one of the input names, followed by an optional suffix.
// This lets us match variants, e.g. "prod-east" and "prod-west" are considered variants of "prod".
const envNamesPattern = RegExp(
`^(.*${separator})?(${exactMatch})(${separator}.*)?$`,
"i"
);
return envNamesPattern.test(deburr(environment));
};
// if there is a user-defined config, we use that config for the mapping
if (config) {
const environmentType = mapEnvironmentWithConfig(environment, config);
if (environmentType) {
const validEnvs = ["development", "testing", "staging", "production"];
return validEnvs.includes(environmentType) ? environmentType : "unmapped";
}
}
// if there is no user-defined config (or the user-defined config didn't match anything),
// we fall back to hardcoded mapping
const environmentMapping = {
development: ["development", "dev", "trunk", "develop"],
testing: ["testing", "test", "tests", "tst", "integration", "integ", "intg", "int", "acceptance", "accept", "acpt", "qa", "qc", "control", "quality", "uat", "sit"],
staging: ["staging", "stage", "stg", "sta", "preprod", "model", "internal"],
production: ["production", "prod", "prd", "live"]
};
return Object.keys(environmentMapping).find(key => isEnvironment(environmentMapping[key])) || "unmapped";
};
// Maps issue ids and commit summaries to an array of associations (one for issue ids, and one for commits).
// Returns undefined when there are no issue ids to map.
const mapJiraIssueIdsCommitsAndServicesToAssociationArray = (
issueIds: string[],
transformedRepositoryId: TransformedRepositoryId,
commitSummaries?: CommitSummary[],
config?: Config
): JiraAssociation[] | undefined => {
const associations: JiraAssociation[] = [];
let totalAssociationCount = 0;
if (issueIds?.length) {
const maximumIssuesToSubmit = MAX_ASSOCIATIONS_PER_ENTITY - totalAssociationCount;
const issues = issueIds
.slice(0, maximumIssuesToSubmit);
associations.push(
{
associationType: "issueIdOrKeys",
values: issues
}
);
totalAssociationCount += issues.length;
}
if (config?.deployments?.services?.ids?.length) {
const maximumServicesToSubmit = MAX_ASSOCIATIONS_PER_ENTITY - totalAssociationCount;
const services = config.deployments.services.ids
.slice(0, maximumServicesToSubmit);
associations.push(
{
associationType: "serviceIdOrKeys",
values: services
}
);
totalAssociationCount += config.deployments.services.ids.length;
}
if (commitSummaries?.length) {
const maximumCommitsToSubmit = MAX_ASSOCIATIONS_PER_ENTITY - totalAssociationCount;
const commitKeys = commitSummaries
.slice(0, maximumCommitsToSubmit)
.map((commitSummary) => {
return {
commitHash: commitSummary.sha,
repositoryId: transformedRepositoryId
};
});
if (commitKeys.length) {
associations.push(
{
associationType: "commit",
values: commitKeys
}
);
}
}
return associations;
};
export const transformDeployment = async (
githubInstallationClient: GitHubInstallationClient,
payload: DeploymentStatusEvent,
jiraHost: string,
type: "backfill" | "webhook",
logger: Logger, gitHubAppId: number | undefined
): Promise<JiraDeploymentBulkSubmitData | undefined> => {
const deployment = payload.deployment;
const deployment_status = payload.deployment_status;
const { data: { commit: { message } } } = await githubInstallationClient.getCommit(payload.repository.owner.login, payload.repository.name, deployment.sha);
const commitSummaries = await getCommitsSinceLastSuccessfulDeploymentFromCache(
jiraHost,
type,
payload.repository.owner.login,
payload.repository.id,
payload.repository.name,
deployment.sha,
deployment.id,
deployment_status.environment,
deployment_status.created_at,
githubInstallationClient,
logger
);
let config: Config | undefined;
const subscription = await Subscription.getSingleInstallation(jiraHost, githubInstallationClient.githubInstallationId.installationId, gitHubAppId);
if (subscription) {
config = await getRepoConfig(
subscription,
githubInstallationClient,
payload.repository.id,
payload.repository.owner.login,
payload.repository.name,
logger
);
} else {
logger.warn({
jiraHost,
githubInstallationId: githubInstallationClient.githubInstallationId.installationId
}, "could not find subscription - not using user config to map environments!");
}
const allCommitsMessages = extractMessagesFromCommitSummaries(commitSummaries);
const shouldSkipSendingCommitAssociations = await booleanFlag(BooleanFlags.SKIP_SENDING_COMMIT_ASSOCIATION, jiraHost);
const associations = mapJiraIssueIdsCommitsAndServicesToAssociationArray(
jiraIssueKeyParser(`${deployment.ref}\n${message}\n${allCommitsMessages}`),
transformRepositoryId(payload.repository.id, githubInstallationClient.baseUrl),
shouldSkipSendingCommitAssociations ? [] : commitSummaries,
config
);
const alwaysSend = type === "webhook" ?
await shouldSendAll("deployments", jiraHost, logger) :
await shouldSendAll("deployments-backfill", jiraHost, logger);
if (!associations?.length && !alwaysSend) {
return undefined;
}
const environment = mapEnvironment(deployment_status.environment, config);
const state = mapState(deployment_status.state);
if (environment === "unmapped") {
logger?.info({
environment: deployment_status.environment,
description: deployment.description
}, "Unmapped environment detected.");
}
logger.info({
deploymentState: state,
deploymentEnvironment: environment
}, "Sending deployment data to Jira");
return {
deployments: [{
schemaVersion: "1.0",
deploymentSequenceNumber: deployment.id,
updateSequenceNumber: deployment_status.id,
displayName: (message || String(payload.deployment.id) || "").substring(0, 255),
url: deployment_status.target_url || deployment.url,
description: (deployment.description || deployment_status.description || deployment.task || "").substring(0, 255),
lastUpdated: new Date(deployment_status.updated_at),
state,
pipeline: {
id: deployment.task,
displayName: deployment.task,
url: deployment_status.target_url || deployment.url
},
environment: {
id: deployment_status.environment,
displayName: deployment_status.environment,
type: environment
},
associations
}]
};
};