-
Notifications
You must be signed in to change notification settings - Fork 176
Expand file tree
/
Copy pathbuild.js
More file actions
314 lines (272 loc) · 13.3 KB
/
build.js
File metadata and controls
314 lines (272 loc) · 13.3 KB
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
//
// This file contains functions that are used in GitHub workflows
// (e.g. to implement the pr-bot for running tests
// There are tests for this code in build.test.js
// These tests can be run from the dev container using the run-tests.sh script
//
const { createHash } = require('crypto');
const { create } = require('domain');
async function getCommandFromComment({ core, context, github }) {
const commentUsername = context.payload.comment.user.login;
const repoFullName = context.payload.repository.full_name;
const repoParts = repoFullName.split("/");
const repoOwner = repoParts[0];
const repoName = repoParts[1];
const prNumber = context.payload.issue.number;
const commentLink = context.payload.comment.html_url;
const runId = context.runId;
const prAuthorUsername = context.payload.issue.user.login;
// Determine PR SHA etc
const ciGitRef = getRefForPr(prNumber);
logAndSetOutput(core, "ciGitRef", ciGitRef);
const prRefId = getRefIdForPr(prNumber);
logAndSetOutput(core, "prRefId", prRefId);
const pr = (await github.rest.pulls.get({ owner: repoOwner, repo: repoName, pull_number: prNumber })).data;
if (repoFullName === pr.head.repo.full_name) {
core.info(`Using head ref: ${pr.head.ref}`)
const branchRefId = getRefIdForBranch(pr.head.ref);
logAndSetOutput(core, "branchRefId", branchRefId);
} else {
core.info("Skipping branchRefId as PR is from a fork")
}
const potentialMergeCommit = pr.merge_commit_sha;
logAndSetOutput(core, "prRef", potentialMergeCommit);
const prHeadSha = pr.head.sha;
logAndSetOutput(core, "prHeadSha", prHeadSha);
const gotNonDocChanges = await prContainsNonDocChanges(github, repoOwner, repoName, prNumber);
logAndSetOutput(core, "nonDocsChanges", gotNonDocChanges.toString());
//
// Determine what action to take
// Only use the first line of the comment to allow remainder of the body for other comments/notes
//
const commentBody = context.payload.comment.body;
const commentFirstLine = commentBody.split("\n")[0];
let command = "none";
const trimmedFirstLine = commentFirstLine.trim();
if (trimmedFirstLine[0] === "/") {
// only allow actions for users with write access
if (!await userHasWriteAccessToRepo({ core, github }, commentUsername, repoOwner, repoName)) {
core.notice("Command: none - user doesn't have write permission]");
await github.rest.issues.createComment({
owner: repoOwner,
repo: repoName,
issue_number: prNumber,
body: `Sorry, @${commentUsername}, only users with write access to the repo can run pr-bot commands.`
});
logAndSetOutput(core, "command", "none");
return "none";
}
const parts = trimmedFirstLine.split(' ').filter(p => p !== '');
const commandText = parts[0];
switch (commandText) {
case "/test":
{
// Docs only changes don't run tests with secrets so don't require the check for
// whether a SHA needs to be supplied
if (!gotNonDocChanges) {
command = "test-force-approve";
const message = `:white_check_mark: PR only contains docs changes - marking tests as complete`;
await addActionComment({ github }, repoOwner, repoName, prNumber, commentUsername, commentLink, message);
break;
}
const runTests = await handleTestCommand({ core, github }, parts, "tests", runId, { number: prNumber, authorUsername: prAuthorUsername, repoOwner, repoName, headSha: prHeadSha, refId: prRefId, details: pr }, { username: commentUsername, link: commentLink });
if (runTests) {
command = "run-tests";
}
break;
}
case "/test-extended":
{
const runTests = await handleTestCommand({ core, github }, parts, "extended tests", runId, { number: prNumber, authorUsername: prAuthorUsername, repoOwner, repoName, headSha: prHeadSha, refId: prRefId, details: pr }, { username: commentUsername, link: commentLink });
if (runTests) {
command = "run-tests-extended";
}
break;
}
case "/test-extended-aad":
{
const runTests = await handleTestCommand({ core, github }, parts, "extended AAD tests", runId, { number: prNumber, authorUsername: prAuthorUsername, repoOwner, repoName, headSha: prHeadSha, refId: prRefId, details: pr }, { username: commentUsername, link: commentLink });
if (runTests) {
command = "run-tests-extended-aad";
}
break;
}
case "/test-shared-services":
{
const runTests = await handleTestCommand({ core, github }, parts, "shared service tests", runId, { number: prNumber, authorUsername: prAuthorUsername, repoOwner, repoName, headSha: prHeadSha, refId: prRefId, details: pr }, { username: commentUsername, link: commentLink });
if (runTests) {
command = "run-tests-shared-services";
}
break;
}
case "/test-manual-app":
{
const runTests = await handleTestCommand({ core, github }, parts, "manual app tests", runId, { number: prNumber, authorUsername: prAuthorUsername, repoOwner, repoName, headSha: prHeadSha, refId: prRefId, details: pr }, { username: commentUsername, link: commentLink });
if (runTests) {
command = "run-tests-manual-app";
}
break;
}
case "/test-force-approve":
{
command = "test-force-approve";
const message = `:white_check_mark: Marking tests as complete (for commit ${prHeadSha})`;
await addActionComment({ github }, repoOwner, repoName, prNumber, commentUsername, commentLink, message);
break;
}
case "/test-destroy-env":
command = "test-destroy-env";
break;
case "/help":
showHelp({ github }, repoOwner, repoName, prNumber, commentUsername, commentLink, null);
command = "none"; // command has been handled, so don't need to return a value for future steps
break;
default:
core.warning(`'${commandText}' not recognised as a valid command`);
await showHelp({ github }, repoOwner, repoName, prNumber, commentUsername, commentLink, commandText);
command = "none";
break;
}
}
logAndSetOutput(core, "command", command);
return command;
}
async function handleTestCommand({ core, github }, commandParts, testDescription, runId, pr, comment) {
if (!pr.details.mergeable) {
// Since we use the potential merge commit as the ref to checkout, we can only run if there is such a commit
// If the PR isn't mergeable, add a comment indicating that the merge issue needs addressing
const message = `:warning: Cannot run tests as PR is not mergeable. Ensure that the PR is open and doesn't have any conflicts.`;
await addActionComment({ github }, pr.repoOwner, pr.repoName, pr.number, comment.username, comment.link, message);
return false;
}
// check if this is an external PR (i.e. author not a maintainer)
// if so, need to specify the SHA that has been vetted and check that it matches
// the latest head SHA for the PR
const command = commandParts[0]
const prAuthorHasWriteAccess = await userHasWriteAccessToRepo({ core, github }, pr.authorUsername, pr.repoOwner, pr.repoName);
const externalPr = !prAuthorHasWriteAccess;
if (externalPr) {
if (commandParts.length === 1) {
const message = `:warning: When using \`${command}\` on external PRs, the SHA of the checked commit must be specified`;
await addActionComment({ github }, pr.repoOwner, pr.repoName, pr.number, comment.username, comment.link, message);
return false;
}
const commentSha = commandParts[1];
if (commentSha.length < 7) {
const message = `:warning: When specifying a commit SHA it must be at least 7 characters (received \`${commentSha}\`)`;
await addActionComment({ github }, pr.repoOwner, pr.repoName, pr.number, comment.username, comment.link, message);
return false;
}
if (!pr.headSha.startsWith(commentSha)) {
const message = `:warning: The specified SHA \`${commentSha}\` is not the latest commit on the PR. Please validate the latest commit and re-run \`/test\``;
await addActionComment({ github }, pr.repoOwner, pr.repoName, pr.number, comment.username, comment.link, message);
return false;
}
}
const message = `:runner: Running ${testDescription}: https://github.com/${pr.repoOwner}/${pr.repoName}/actions/runs/${runId} (with refid \`${pr.refId}\`)`;
await addActionComment({ github }, pr.repoOwner, pr.repoName, pr.number, comment.username, comment.link, message);
return true
}
async function prContainsNonDocChanges(github, repoOwner, repoName, prNumber) {
const prFilesResponse = await github.paginate(github.rest.pulls.listFiles, {
owner: repoOwner,
repo: repoName,
pull_number: prNumber
});
const prFiles = prFilesResponse.map(file => file.filename);
// Regexes describing allowed filenames
// If a filename matches any regex in the array then it is considered a doc
// Currently, match all `.md` files and `mkdocs.yml` in the root
const docsRegexes = [/\.md$/, /^mkdocs.yml$/];
const gotNonDocChanges = prFiles.some(file => docsRegexes.every(regex => !regex.test(file)));
return gotNonDocChanges;
}
async function labelAsExternalIfAuthorDoesNotHaveWriteAccess({ core, context, github }) {
const username = context.payload.pull_request.user.login;
const owner = context.repo.owner;
const repo = context.repo.repo;
if (!await userHasWriteAccessToRepo({ core, github }, username, owner, repo)) {
core.info("Adding external label to PR " + context.payload.pull_request.number)
await github.rest.issues.addLabels({
owner,
repo,
issue_number: context.payload.pull_request.number,
labels: ['external']
});
}
}
async function userHasWriteAccessToRepo({ core, github }, username, repoOwner, repoName) {
// Previously, we attempted to use github.event.comment.author_association to check for OWNER or COLLABORATOR
// Unfortunately, that always shows MEMBER if you are in the microsoft org and have that set to publicly visible
// (Can check via https://github.com/orgs/microsoft/people?query=<username>)
// https://docs.github.com/en/rest/reference/collaborators#check-if-a-user-is-a-repository-collaborator
let userHasWriteAccess = false;
try {
core.info(`Checking if user "${username}" has write access to ${repoOwner}/${repoName} ...`)
const result = await github.request('GET /repos/{owner}/{repo}/collaborators/{username}', {
owner: repoOwner,
repo: repoName,
username
});
userHasWriteAccess = result.status === 204;
} catch (err) {
if (err.status === 404) {
core.info("User not found in collaborators");
} else {
core.error(`Error checking if user has write access: ${err.status} (${err.response.data.message}) `)
}
}
core.info("User has write access: " + userHasWriteAccess);
return userHasWriteAccess
}
async function showHelp({ github }, repoOwner, repoName, prNumber, commentUser, commentLink, invalidCommand) {
const leadingContent = invalidCommand ? `\`${invalidCommand}\` is not recognised as a valid command.` : "Hello!";
const body = `${leadingContent}
You can use the following commands:
/test - build, deploy and run smoke tests on a PR
/test-extended - build, deploy and run smoke & extended tests on a PR
/test-extended-aad - build, deploy and run smoke & extended AAD tests on a PR
/test-shared-services - test the deployment of shared services on a PR build
/test-manual-app - run the manual workspace application test suite on a PR build
/test-force-approve - force approval of the PR tests (i.e. skip the deployment checks)
/test-destroy-env - delete the validation environment for a PR (e.g. to enable testing a deployment from a clean start after previous tests)
/help - show this help`;
await addActionComment({ github }, repoOwner, repoName, prNumber, commentUser, commentLink, body);
}
async function addActionComment({ github }, repoOwner, repoName, prNumber, commentUser, commentLink, message) {
const body = `:robot: pr-bot :robot:
${message}
(in response to [this comment](${commentLink}) from @${commentUser})
`;
await github.rest.issues.createComment({
owner: repoOwner,
repo: repoName,
issue_number: prNumber,
body: body
});
}
function logAndSetOutput(core, name, value) {
core.info(`Setting output '${name}: ${value}`);
core.setOutput(name, value);
}
function getRefForPr(prNumber) {
return `refs/pull/${prNumber}/merge`;
}
function getRefIdForPr(prNumber) {
const prRef = getRefForPr(prNumber);
// Trailing newline is for compatibility with previous bash SHA calculation
return createShortHash(`${prRef}\n`);
}
function getRefIdForBranch(branchName) {
// Trailing newline is for compatibility with previous bash SHA calculation
return createShortHash(`refs/heads/${branchName}\n`);
}
function createShortHash(ref) {
const hash = createHash('sha512').update(ref, 'utf8').digest('hex');
return hash.substring(0, 8);
}
module.exports = {
getCommandFromComment,
labelAsExternalIfAuthorDoesNotHaveWriteAccess,
createShortHash
}