• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ubccpsc / classy / 5beb0c07-4c3c-4a18-bad8-11752da6d845

12 Mar 2026 06:33PM UTC coverage: 88.238% (-0.07%) from 88.309%
5beb0c07-4c3c-4a18-bad8-11752da6d845

push

circleci

web-flow
Merge pull request #481 from ubccpsc310/main

Update CircleCI configuration

1091 of 1321 branches covered (82.59%)

Branch coverage included in aggregate %.

3928 of 4367 relevant lines covered (89.95%)

37.08 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.61
packages/portal/backend/src/controllers/GitHubActions.ts
1
import * as crypto from "crypto";
1✔
2
import * as parseLinkHeader from "parse-link-header";
1✔
3
import fetch, { RequestInit } from "node-fetch";
1✔
4

5
import Config, { ConfigKey } from "@common/Config";
1✔
6
import Log from "@common/Log";
1✔
7
import Util from "@common/Util";
1✔
8

9
import { Factory } from "../Factory";
1✔
10
import { DatabaseController } from "./DatabaseController";
1✔
11
import { BranchRule, GitPersonTuple, GitRepoTuple, GitTeamTuple, Issue } from "./GitHubController";
12
import { TeamController } from "./TeamController";
1✔
13
import { GitHubStatus } from "@backend/Types";
1✔
14

15
// tslint:disable-next-line
16
const tmp = require("tmp-promise");
1✔
17
tmp.setGracefulCleanup(); // cleanup files when done
1✔
18

19
export interface IGitHubActions {
20
        /**
21
         * Paging page size. For testing only.
22
         *
23
         * @param {number} size
24
         */
25
        setPageSize(size: number): void;
26

27
        /**
28
         * Creates a given repo and returns its URL. If the repo exists, return the URL for that repo.
29
         *
30
         * Also updates the Repository object in the datastore with the URL and cloneURL.
31
         *
32
         * @param repoName The name of the repo. Must be unique within the organization.
33
         * @returns {Promise<string>} provisioned repo URL
34
         */
35
        createRepo(repoName: string): Promise<string>;
36

37
        /**
38
         * Creates a repo from a template and returns its URL. If the repo exists, return the URL for that repo.
39
         *
40
         * @param repoName The name of the repo. Must be unique within the organization.
41
         * @param templateOwner The org/owner of the template repo.
42
         * @param templateRepo The name of the template repo. Must be readable by the bot user.
43
         */
44
        createRepoFromTemplate(repoName: string, templateOwner: string, templateRepo: string): Promise<string>;
45

46
        /**
47
         * Updates a repo with the settings we want on our repos. This is used for repos created from a template.
48
         * Repos that are created with `createRepo` do not need to use this endpoint (although there is no downside
49
         * to them using it).
50
         *
51
         * @param repoName
52
         */
53
        updateRepo(repoName: string): Promise<boolean>;
54

55
        /**
56
         * Deletes a repo from the organization.
57
         *
58
         * @param repoName
59
         * @returns {Promise<boolean>}
60
         */
61
        deleteRepo(repoName: string): Promise<boolean>;
62

63
        /**
64
         * Checks if a repo exists or not. If the request fails for _ANY_ reason
65
         * the failure will not be reported, only that the repo does not exist.
66
         *
67
         * @param repoName
68
         * @returns {Promise<boolean>}
69
         */
70
        repoExists(repoName: string): Promise<boolean>;
71

72
        /**
73
         * Deletes a team.
74
         *
75
         * NOTE: this used to take a number, but GitHub deprecated this API:
76
         * https://developer.github.com/changes/2020-01-21-moving-the-team-api-endpoints/
77
         *
78
         * @param teamName string
79
         * @returns {Promise<boolean>}
80
         */
81
        deleteTeam(teamName: string): Promise<boolean>;
82

83
        /**
84
         * Gets all repos in an org.
85
         *
86
         * @returns {Promise<{GitRepoTuple}[]>}
87
         */
88
        listRepos(): Promise<GitRepoTuple[]>;
89

90
        /**
91
         * Gets all people in an org.
92
         *
93
         * @returns {Promise<GitPersonTuple[]>}
94
         * this is just a subset of the return, but it is the subset we actually use
95
         */
96
        listPeople(): Promise<GitPersonTuple[]>;
97

98
        /**
99
         * Lists the teams for the current org.
100
         *
101
         * NOTE: this is a slow operation (if there are many teams) so try not to do it too much!
102
         *
103
         * @returns {Promise<{GitTeamTuple}[]>}
104
         */
105
        listTeams(): Promise<GitTeamTuple[]>;
106

107
        /**
108
         * Lists the GitHub IDs of members for a teamName (e.g. students).
109
         *
110
         * @param {string} teamName
111
         * @returns {Promise<string[]>} // list of githubIds
112
         */
113
        listTeamMembers(teamName: string): Promise<string[]>;
114

115
        listWebhooks(repoName: string): Promise<Array<{}>>;
116

117
        updateWebhook(repoName: string, webhookEndpoint: string): Promise<boolean>;
118

119
        addWebhook(repoName: string, webhookEndpoint: string): Promise<boolean>;
120

121
        /**
122
         * Creates a GitHub team (e.g., cpsc310_team1).
123
         *
124
         * @param teamName
125
         * @param permission "admin", "pull", "push" // admin for staff, push for students
126
         * @returns {Promise<GitTeamTuple>}
127
         */
128
        createTeam(teamName: string, permission: string): Promise<GitTeamTuple>;
129

130
        /**
131
         * Add a list of GitHub members (their usernames) to a given team.
132
         *
133
         * @param teamName
134
         * @param memberGithubIds github usernames
135
         * @returns {Promise<GitTeamTuple>}
136
         */
137
        addMembersToTeam(teamName: string, memberGithubIds: string[]): Promise<GitTeamTuple>;
138

139
        /**
140
         * Removes a list of GitHub members (their usernames) from a given team.
141
         *
142
         * @param teamName
143
         * @param memberGithubIds github usernames
144
         * @returns {Promise<GitTeamTuple>}
145
         */
146
        removeMembersFromTeam(teamName: string, memberGithubIds: string[]): Promise<GitTeamTuple>;
147

148
        /**
149
         * Add a team to a repo.
150
         *
151
         * @param {string} teamName
152
         * @param {string} repoName
153
         * @param permission ("pull", "push", "admin")
154
         * @returns {Promise<GitTeamTuple>}
155
         */
156
        addTeamToRepo(teamName: string, repoName: string, permission: string): Promise<GitTeamTuple>;
157

158
        /**
159
         * Gets the internal number for a team.
160
         *
161
         * Returns -1 if the team does not exist.
162
         *
163
         * @param teamName
164
         * @returns {Promise<number>}
165
         */
166
        getTeamNumber(teamName: string): Promise<number>;
167

168
        /**
169
         * Gets the list of users on a team.
170
         *
171
         * NOTE: this used to take a number, but GitHub changed the team API in 2020.
172
         * https://developer.github.com/changes/2020-01-21-moving-the-team-api-endpoints/
173
         *
174
         * Returns [] if the team does not exist or nobody is on the team.
175
         *
176
         * @param {string} teamName
177
         * @returns {Promise<string[]>}
178
         */
179
        getTeamMembers(teamName: string): Promise<string[]>;
180

181
        isOnAdminTeam(userName: string): Promise<boolean>;
182

183
        isOnStaffTeam(userName: string): Promise<boolean>;
184

185
        isOnTeam(teamName: string, userName: string): Promise<boolean>;
186

187
        importRepoFS(importRepo: string, studentRepo: string, seedFilePath?: string): Promise<boolean>;
188

189
        addGithubAuthToken(url: string): string;
190

191
        /**
192
         * Adds a file with the data given, to the specified repository.
193
         * If force is set to true, will overwrite old files.
194
         *
195
         * @param repoURL - name of repository
196
         * @param fileName - name of file to write
197
         * @param fileContent - the content of the file to write to repo
198
         * @param force - allow for overwriting of old files
199
         * @returns {Promise<boolean>} - true if write was successful
200
         */
201
        writeFileToRepo(repoURL: string, fileName: string, fileContent: string, force?: boolean): Promise<boolean>;
202

203
        /**
204
         * Changes permissions for all teams for the given repository.
205
         *
206
         * @param repoName
207
         * @param permissionLevel - one of: "push" "pull"
208
         * @returns {Promise<boolean>}
209
         */
210
        setRepoPermission(repoName: string, permissionLevel: string): Promise<boolean>;
211

212
        /**
213
         * Makes a comment on a commit.
214
         *
215
         * @param {string} url
216
         * @param {string} message any text would work, but markdown is best
217
         * @returns {Promise<boolean>}
218
         */
219
        makeComment(url: string, message: string): Promise<boolean>;
220

221
        /**
222
         * Simulates a comment as if it were received by a webhook (for silently invoking AutoTest).
223
         *
224
         * @param projectName
225
         * @param sha
226
         * @param message
227
         * @returns {Promise<boolean>}
228
         */
229
        simulateWebhookComment(projectName: string, sha: string, message: string): Promise<boolean>;
230

231
        /**
232
         * Returns a list of teams on a repo.
233
         *
234
         * @param  repoId
235
         * @returns {Promise<GitTeamTuple[]>}
236
         */
237
        getTeamsOnRepo(repoId: string): Promise<GitTeamTuple[]>;
238

239
        getTeamByName(teamName: string): Promise<GitTeamTuple | null>;
240

241
        getTeam(teamNumber: number): Promise<GitTeamTuple | null>;
242

243
        addBranchProtectionRule(repoId: string, rule: BranchRule): Promise<boolean>;
244

245
        makeIssue(repoId: string, issue: Issue): Promise<boolean>;
246

247
        /**
248
         * Lists the branches in a repo.
249
         *
250
         * This is used mainly to detect an incomplete provisioned repo (the repo may return with getRepo, but it will have no branches).
251
         *
252
         * @param repoId
253
         * @returns {Promise<string[]} If [], the repo may not be fully provisioned yet.
254
         */
255
        listRepoBranches(repoId: string): Promise<string[]>;
256

257
        /**
258
         * Deletes all branches in a repo except for the ones listed in branchesToKeep.
259
         *
260
         * @param repoId
261
         * @param branchesToKeep Must be an array of at least one branch name that already exists on the repo
262
         * @returns {Promise<boolean>} true if the only remaining branches are the ones listed in branchesToKeep
263
         */
264
        deleteBranches(repoId: string, branchesToKeep: string[]): Promise<boolean>;
265

266
        /**
267
         * Deletes all branches in a repo except for the ones listed in branchesToKeep.
268
         *
269
         * @param repoId
270
         * @param branchesToDelete The name of the branch to delete
271
         * @returns {Promise<boolean>} true if the deletion was successful
272
         */
273
        deleteBranch(repoId: string, branchToDelete: string): Promise<boolean>;
274

275
        /**
276
         * Renames a branch in a repo.
277
         *
278
         * @param repoId
279
         * @param oldName This branch must exist.
280
         * @param newName
281
         * @returns {Promise<boolean>} true if the old branch existed and was successfully updated to the new name
282
         */
283
        renameBranch(repoId: string, oldName: string, newName: string): Promise<boolean>;
284
}
285

286
export class GitHubActions implements IGitHubActions {
1✔
287
        private static instance: IGitHubActions = null;
1✔
288
        private readonly apiPath: string | null = null;
382✔
289
        private readonly gitHubUserName: string | null = null;
382✔
290
        private readonly gitHubAuthToken: string | null = null;
382✔
291

292
        // private LONG_PAUSE = 5000; // was deployed previously
293
        // private SHORT_PAUSE = 1000;
294
        private readonly org: string | null = null;
382✔
295
        /**
296
         * Page size for requests. Should be a constant, but using a
297
         * variable is handy for testing pagination.
298
         *
299
         * 100 is the GitHub maximum, and is the best value for production.
300
         * 10 or less is ignored, but this lower value is handy for testing.
301
         *
302
         * @private
303
         */
304
        private pageSize = 100;
382✔
305
        private dc: DatabaseController = null;
382✔
306

307
        private constructor() {
308
                Log.trace("GitHubActions::<init>");
382✔
309
                // NOTE: this is not very controllable; these would be better as params
310
                this.org = Config.getInstance().getProp(ConfigKey.org);
382✔
311
                this.apiPath = Config.getInstance().getProp(ConfigKey.githubAPI);
382✔
312
                this.gitHubUserName = Config.getInstance().getProp(ConfigKey.githubBotName);
382✔
313
                this.gitHubAuthToken = Config.getInstance().getProp(ConfigKey.githubBotToken);
382✔
314
                this.dc = DatabaseController.getInstance();
382✔
315
                Log.trace("GitHubActions::<init> - url: " + this.apiPath + "/" + this.org);
382✔
316
        }
317

318
        public static getInstance(forceReal?: boolean): IGitHubActions {
319
                // Sometimes we will want to run against the full live GitHub suite
320
                // const override = true; // NOTE: should be commented out for commits; runs full GitHub suite
321
                // const override = true; // NOTE: should NOT be commented out for commits
322

323
                if (Factory.OVERRIDE === true) {
382!
324
                        // poor form to have a dependency into test code here
325
                        Log.trace("GitHubActions::getInstance(..) - forcing real (OVERRIDE == true)");
×
326
                        forceReal = true;
×
327
                }
328
                if (typeof forceReal === "undefined") {
382✔
329
                        forceReal = false;
322✔
330
                }
331

332
                // if we"re on CI, still run the whole thing
333
                const ci = process.env.CI;
382✔
334
                if (typeof ci !== "undefined" && Util.toBoolean(ci) === true) {
382!
335
                        forceReal = true;
382✔
336
                }
337

338
                // NOTE: this is bad form, but we want to make sure we always return the real thing in production
339
                // this detects the mocha testing environment
340
                const isInTest = typeof (global as any).it === "function";
382✔
341
                if (isInTest === false) {
382!
342
                        // we"re in prod, always return the real thing
343
                        Log.trace("GitHubActions::getInstance(.. ) - prod; returning GitHubActions");
×
344
                        return new GitHubActions();
×
345
                }
346

347
                if (forceReal === true) {
382!
348
                        Log.test("GitHubActions::getInstance( true ) - returning live GitHubActions");
382✔
349
                        return new GitHubActions(); // do not need to cache this since it is backed by GitHub instead of an in-memory cache
382✔
350
                }
351

352
                if (GitHubActions.instance === null) {
×
353
                        // TODO: having this test dependency in prod code is poor
354
                        const { TestGitHubActions } = require("../../test/controllers/TestGitHubActions");
×
355
                        GitHubActions.instance = new TestGitHubActions();
×
356
                }
357

358
                Log.test("GitHubActions::getInstance() - returning cached TestGitHubActions");
×
359
                return GitHubActions.instance;
×
360
        }
361

362
        /* istanbul ignore next */
363
        /**
364
         * Checks to make sure the repoName or teamName (or both, if specified) are in the database.
365
         *
366
         * This is like an assertion that should be picked up by tests, although it should never
367
         * happen in production (if our suite is any good).
368
         *
369
         * NOTE: ASYNC FUNCTION!
370
         *
371
         * @param {string | null} repoName
372
         * @param {string | null} teamName
373
         * @returns {Promise<boolean>}
374
         */
375
        public static async checkDatabase(repoName: string | null, teamName: string | null): Promise<boolean> {
376
                Log.trace("GitHubActions::checkDatabase( repo:_" + repoName + "_, team:_" + teamName + "_) - start");
377
                const dbc = DatabaseController.getInstance();
378
                if (repoName !== null) {
379
                        const repo = await dbc.getRepository(repoName);
380
                        if (repo === null) {
381
                                const msg = "Repository: " + repoName + " does not exist in datastore; make sure you add it before calling this operation";
382
                                Log.error("GitHubActions::checkDatabase() - repo ERROR: " + msg);
383
                                throw new Error(msg);
384
                        } else {
385
                                // ensure custom property is there
386
                                if (typeof repo.custom === "undefined" || repo.custom === null || typeof repo.custom !== "object") {
387
                                        const msg = "Repository: " + repoName + " has a non-object .custom property";
388
                                        Log.error("GitHubActions::checkDatabase() - repo ERROR: " + msg);
389
                                        throw new Error(msg);
390
                                }
391
                        }
392
                }
393

394
                if (teamName !== null) {
395
                        const team = await dbc.getTeam(teamName);
396
                        if (team === null) {
397
                                const msg = "Team: " + teamName + " does not exist in datastore; make sure you add it before calling this operation";
398
                                Log.error("GitHubActions::checkDatabase() - team ERROR: " + msg);
399
                                throw new Error(msg);
400
                        } else {
401
                                // ensure custom property is there
402
                                if (typeof team.custom === "undefined" || team.custom === null || typeof team.custom !== "object") {
403
                                        const msg = "Team: " + teamName + " has a non-object .custom property";
404
                                        Log.error("GitHubActions::checkDatabase() - team ERROR: " + msg);
405
                                        throw new Error(msg);
406
                                }
407
                        }
408
                }
409
                Log.trace("GitHubActions::checkDatabase( repo:_" + repoName + "_, team:_" + teamName + "_) - exists");
410
                return true;
411
        }
412

413
        public setPageSize(size: number) {
414
                this.pageSize = size;
133✔
415
        }
416

417
        /**
418
         * Creates a given repo and returns its URL. If the repo exists, return the URL for that repo.
419
         *
420
         * Also updates the Repository object in the datastore with the URL and cloneURL.
421
         *
422
         * @param repoName The name of the repo. Must be unique within the organization.
423
         * @returns {Promise<string>} provisioned team URL
424
         */
425
        public async createRepo(repoName: string): Promise<string> {
426
                const start = Date.now();
24✔
427
                try {
24✔
428
                        Log.info("GitHubAction::createRepo( " + repoName + " ) - start");
24✔
429
                        await GitHubActions.checkDatabase(repoName, null);
24✔
430

431
                        const uri = this.apiPath + "/orgs/" + this.org + "/repos";
23✔
432
                        const options: RequestInit = {
23✔
433
                                method: "POST",
434
                                headers: {
435
                                        Authorization: this.gitHubAuthToken,
436
                                        "User-Agent": this.gitHubUserName,
437
                                        Accept: "application/json",
438
                                },
439
                                body: JSON.stringify({
440
                                        name: repoName,
441
                                        // In Dev and Test, Github free Org Repos cannot be private.
442
                                        private: true,
443
                                        has_issues: true,
444
                                        has_wiki: false,
445
                                        has_downloads: false,
446
                                        // squash merging does not use ff causing branch problems in autotest
447
                                        allow_squash_merge: false,
448
                                        // rebase merging does not use ff causing branch problems in autotest
449
                                        allow_rebase_merge: false,
450
                                        merge_commit_title: "PR_TITLE",
451
                                        merge_commit_message: "PR_BODY",
452
                                        auto_init: false,
453
                                }),
454
                        };
455

456
                        Log.info("GitHubAction::createRepo( " + repoName + " ) - making request");
23✔
457
                        const response = await fetch(uri, options);
23✔
458
                        const body = await response.json();
23✔
459
                        Log.info("GitHubAction::createRepo( " + repoName + " ) - request complete");
23✔
460
                        const url = body.html_url;
23✔
461

462
                        Log.trace("GitHubAction::createRepo( " + repoName + " ) - db start");
23✔
463
                        const repo = await this.dc.getRepository(repoName);
23✔
464
                        repo.URL = url; // only update this field in the existing Repository record
23✔
465
                        repo.cloneURL = body.clone_url; // only update this field in the existing Repository record
23✔
466
                        repo.gitHubStatus = GitHubStatus.PROVISIONED_UNLINKED;
23✔
467
                        await this.dc.writeRepository(repo);
23✔
468
                        Log.trace("GitHubAction::createRepo( " + repoName + " ) - db done");
23✔
469

470
                        Log.info("GitHubAction::createRepo(..) - success; URL: " + url + "; took: " + Util.took(start));
23✔
471

472
                        // would prefer to avoid this long pause
473
                        // try a more dynamic approach below; this works for template repos, but has not been verified for normal repos
474
                        // await Util.delay(this.LONG_PAUSE);
475

476
                        // listing branches is not sufficient because they are often [] for an initial repo
477
                        // whether listing teams is sufficient has not been tested in prod yet (23W2)
478
                        let doesNotExist = true;
23✔
479
                        let existCount = 0; // only try 10 times to avoid spinning forever
23✔
480
                        while (doesNotExist && existCount < 10) {
23✔
481
                                Log.info("GitHubAction::createRepo(..) - checking if repo is ready");
23✔
482
                                const repoData = await this.getTeamsOnRepo(repoName);
23✔
483
                                Log.info("GitHubAction::createRepo(..) - repoData: " + JSON.stringify(repoData));
23✔
484
                                if (repoData !== null) {
23!
485
                                        Log.info("GitHubAction::createRepo(..) - repo is ready");
23✔
486
                                        doesNotExist = false;
23✔
487
                                } else {
488
                                        Log.info("GitHubAction::createRepo(..) - repo is NOT ready");
×
489
                                        existCount++;
×
490
                                        await Util.delay(250); // wait a bit longer
×
491
                                }
492
                        }
493

494
                        Log.info("GitHubAction::createRepo(..) - success; URL: " + url + "; total creation took: " + Util.took(start));
23✔
495

496
                        return url;
23✔
497
                } catch (err) {
498
                        Log.error("GitHubAction::createRepo(..) - ERROR: " + err);
1✔
499
                        throw new Error("Repository not created; " + err.message);
1✔
500
                }
501
        }
502

503
        /**
504
         * Creates a repo from a template and returns its URL. If the repo exists, return the URL for that repo.
505
         * The template repo _must_ have a branch for this to work (essentially it cannot be a completely empty repo).
506
         * If you want a completely empty repo, just use createRepo instead.
507
         *
508
         * @param repoName
509
         * @param templateOwner The org / owner of the template repo
510
         * @param templateRepo The repo to use as a template. (both owner (org) and repo name are required)
511
         * @returns {Promise<string>} provisioned repo URL
512
         */
513
        public async createRepoFromTemplate(repoName: string, templateOwner: string, templateRepo: string): Promise<string> {
514
                const start = Date.now();
2✔
515
                try {
2✔
516
                        Log.info("GitHubAction::createRepoFromTemplate( " + repoName + ", " + templateOwner + ", " + templateRepo + " ) - start");
2✔
517
                        await GitHubActions.checkDatabase(repoName, null);
2✔
518

519
                        const uri = this.apiPath + "/repos/" + templateOwner + "/" + templateRepo + "/generate";
2✔
520
                        // const uri = this.apiPath + "/orgs/" + this.org + "/repos/" + templateOwner + "/" + templateRepo + "/generate";
521
                        const options: RequestInit = {
2✔
522
                                method: "POST",
523
                                headers: {
524
                                        Authorization: this.gitHubAuthToken,
525
                                        "User-Agent": this.gitHubUserName,
526
                                        Accept: "application/vnd.github+json",
527
                                        "X-GitHub-Api-Version": "2022-11-28",
528
                                },
529
                                body: JSON.stringify({
530
                                        include_all_branches: true, // include all branches, will be post-processed after creation
531
                                        owner: this.org,
532
                                        name: repoName,
533
                                        // In Dev and Test, Github free Org Repos cannot be private.
534
                                        private: true,
535
                                }),
536
                        };
537

538
                        Log.info("GitHubAction::createRepoFromTemplate( " + repoName + " ) - making request");
2✔
539
                        Log.trace("GitHubAction::createRepoFromTemplate( " + repoName + " ) - URL: " + uri + "; options: " + JSON.stringify(options));
2✔
540
                        const response = await fetch(uri, options);
2✔
541
                        const body = await response.json();
2✔
542
                        Log.info("GitHubAction::createRepoFromTemplate( " + repoName + " ) - request complete");
2✔
543
                        Log.trace("GitHubAction::createRepoFromTemplate( " + repoName + " ) - request complete; body: " + JSON.stringify(body));
2✔
544
                        if (typeof body.html_url === "undefined" || body.html_url.length < 5) {
2!
545
                                Log.error("GitHubAction::createRepoFromTemplate( " + repoName + " ) - repo not created; ERROR: " + JSON.stringify(body));
×
546
                                throw new Error(
×
547
                                        "Is the import repo (" + templateOwner + "/" + templateRepo + ") configured in GitHub as a template repository?"
548
                                );
549
                        }
550
                        const url = body.html_url;
2✔
551

552
                        Log.trace("GitHubAction::createRepoFromTemplate( " + repoName + " ) - db start");
2✔
553
                        const repo = await this.dc.getRepository(repoName);
2✔
554
                        repo.URL = url; // only update this field in the existing Repository record
2✔
555
                        repo.cloneURL = body.clone_url; // only update this field in the existing Repository record
2✔
556
                        repo.gitHubStatus = GitHubStatus.PROVISIONED_UNLINKED;
2✔
557
                        await this.dc.writeRepository(repo);
2✔
558
                        Log.trace("GitHubAction::createRepoFromTemplate( " + repoName + " ) - db done");
2✔
559
                        Log.info("GitHubAction::createRepoFromTemplate(..) - success; URL: " + url + "; took: " + Util.took(start));
2✔
560

561
                        let doesNotExist = true;
2✔
562
                        let existCount = 0; // only try 10 times to avoid spinning forever
2✔
563
                        while (doesNotExist && existCount < 10) {
2✔
564
                                Log.info("GitHubAction::createRepoFromTemplate(..) - checking if repo is ready");
2✔
565
                                const repoBranches = await this.listRepoBranches(repoName);
2✔
566
                                if (repoBranches !== null && repoBranches.length > 0) {
2!
567
                                        Log.info("GitHubAction::createRepoFromTemplate(..) - repo is ready");
2✔
568
                                        doesNotExist = false;
2✔
569
                                } else {
570
                                        Log.info("GitHubAction::createRepoFromTemplate(..) - repo is NOT ready");
×
571
                                        existCount++;
×
572
                                        await Util.delay(250); // wait a bit longer
×
573
                                }
574
                        }
575

576
                        Log.info("GitHubAction::createRepoFromTemplate(..) - success; URL: " + url + "; total creation took: " + Util.took(start));
2✔
577

578
                        return url;
2✔
579
                } catch (err) {
580
                        Log.error("GitHubAction::createRepoFromTemplate(..) - ERROR: " + err);
×
581
                        throw new Error("Repository not created; " + err.message);
×
582
                }
583
        }
584

585
        public async updateRepo(repoName: string): Promise<boolean> {
586
                // These are the settings we want on our repos, but we can't set them on creation when making them with a template
587

588
                // name: repoName,
589
                //     // In Dev and Test, Github free Org Repos cannot be private.
590
                //     private: true,
591
                //     has_issues: true,
592
                //     has_wiki: false,
593
                //     has_downloads: false,
594
                //     // squash merging does not use ff causing branch problems in autotest
595
                //     allow_squash_merge: false,
596
                //     // rebase merging does not use ff causing branch problems in autotest
597
                //     allow_rebase_merge: false,
598
                //     merge_commit_title: "PR_TITLE",
599
                //     merge_commit_message: "PR_BODY",
600
                //     auto_init: false
601

602
                const start = Date.now();
9✔
603
                try {
9✔
604
                        Log.info("GitHubAction::updateRepo( " + repoName + " ) - start");
9✔
605
                        await GitHubActions.checkDatabase(repoName, null);
9✔
606

607
                        const repoOpts: any = {
9✔
608
                                name: repoName,
609
                                // In Dev and Test, Github free Org Repos cannot be private.
610
                                private: true,
611
                                has_issues: true,
612
                                has_wiki: false,
613
                                has_downloads: false,
614
                                // squash merging does not use ff causing branch problems in autotest
615
                                allow_squash_merge: false,
616
                                // rebase merging does not use ff causing branch problems in autotest
617
                                allow_rebase_merge: false,
618
                                merge_commit_title: "PR_TITLE",
619
                                merge_commit_message: "PR_BODY",
620
                        };
621

622
                        const uri = this.apiPath + "/repos/" + this.org + "/" + repoName;
9✔
623
                        const options: RequestInit = {
9✔
624
                                method: "PATCH",
625
                                headers: {
626
                                        Authorization: this.gitHubAuthToken,
627
                                        "User-Agent": this.gitHubUserName,
628
                                        Accept: "application/vnd.github+json",
629
                                },
630
                                body: JSON.stringify(repoOpts),
631
                        };
632

633
                        Log.trace("GitHubAction::updateRepo( " + repoName + " ) - making request");
9✔
634
                        const response = await fetch(uri, options);
9✔
635
                        const body = await response.json();
9✔
636
                        Log.trace("GitHubAction::updateRepo( " + repoName + " ) - request complete");
9✔
637

638
                        const url = body.html_url;
9✔
639
                        const wasSuccess =
640
                                repoOpts.has_issues === body.has_issues &&
9✔
641
                                repoOpts.has_wiki === body.has_wiki &&
642
                                repoOpts.has_downloads === body.has_downloads &&
643
                                repoOpts.allow_squash_merge === body.allow_squash_merge &&
644
                                repoOpts.allow_rebase_merge === body.allow_rebase_merge;
645
                        Log.info("GitHubAction::updateRepo(..) - wasSuccessful: " + wasSuccess + "; URL: " + url + "; took: " + Util.took(start));
9✔
646
                        return wasSuccess;
9✔
647
                } catch (err) {
648
                        Log.error("GitHubAction::updateRepo(..) - ERROR: " + err);
×
649
                        throw new Error("Repository not created; " + err.message);
×
650
                }
651
        }
652

653
        /**
654
         * Deletes a repo from the organization.
655
         *
656
         * @param repoName
657
         * @returns {Promise<boolean>}
658
         */
659
        public async deleteRepo(repoName: string): Promise<boolean> {
660
                Log.info("GitHubAction::deleteRepo( " + this.org + ", " + repoName + " ) - start");
49✔
661
                const start = Date.now();
49✔
662

663
                try {
49✔
664
                        // first make sure the repo exists
665
                        const repoExists = await this.repoExists(repoName);
49✔
666

667
                        if (repoExists === true) {
49✔
668
                                const uri = this.apiPath + "/repos/" + this.org + "/" + repoName;
25✔
669
                                Log.trace("GitHubAction::deleteRepo( " + repoName + " ) - URI: " + uri);
25✔
670
                                const options: RequestInit = {
25✔
671
                                        method: "DELETE",
672
                                        headers: {
673
                                                Authorization: this.gitHubAuthToken,
674
                                                "User-Agent": this.gitHubUserName,
675
                                                Accept: "application/json",
676
                                        },
677
                                };
678

679
                                await fetch(uri, options);
25✔
680
                                Log.info("GitHubAction::deleteRepo( " + repoName + " ) - successfully deleted; took: " + Util.took(start));
25✔
681
                                return true;
25✔
682
                        } else {
683
                                Log.info("GitHubAction::deleteRepo( " + repoName + " ) - repo does not exist, not deleting; took: " + Util.took(start));
24✔
684
                                return false;
24✔
685
                        }
686
                } catch (err) {
687
                        // jut warn because 404 throws an error
688
                        Log.warn("GitHubAction::deleteRepo(..) - ERROR: " + err.message);
×
689
                        return false;
×
690
                }
691
        }
692

693
        /**
694
         * Checks if a repo exists or not. If the request fails for _ANY_ reason the failure will not
695
         * be reported, only that the repo does not exist.
696
         *
697
         * @param repoName
698
         * @returns {Promise<boolean>}
699
         */
700
        public async repoExists(repoName: string): Promise<boolean> {
701
                const start = Date.now();
121✔
702
                const uri = this.apiPath + "/repos/" + this.org + "/" + repoName;
121✔
703
                const options: RequestInit = {
121✔
704
                        method: "GET",
705
                        headers: {
706
                                Authorization: this.gitHubAuthToken,
707
                                "User-Agent": this.gitHubUserName,
708
                                Accept: "application/json",
709
                        },
710
                };
711

712
                const res = await fetch(uri, options);
121✔
713
                if (res.status === 404) {
121✔
714
                        Log.trace("GitHubAction::repoExists( " + repoName + " ) - false; took: " + Util.took(start));
35✔
715
                        return false;
35✔
716
                }
717
                Log.trace("GitHubAction::repoExists( " + repoName + " ) - true; took: " + Util.took(start));
86✔
718
                return true;
86✔
719
        }
720

721
        /**
722
         * Deletes a team from GitHub. Does _NOT_ modify the Team object in the database.
723
         * NOTE: this used to take a teamId: number, but GitHub deprecated this API:
724
         * https://developer.github.com/changes/2020-01-21-moving-the-team-api-endpoints/
725
         *
726
         * NOTE: if you are deleting the "admin", "staff", or "students" teams, you are doing something terribly wrong.
727
         *
728
         * @param teamName name of the team to delete
729
         */
730
        public async deleteTeam(teamName: string): Promise<boolean> {
731
                try {
30✔
732
                        const start = Date.now();
30✔
733
                        Log.info("GitHubAction::deleteTeam( " + teamName + " ) - start");
30✔
734

735
                        if (teamName === null) {
30!
736
                                throw new Error("GitHubAction::deleteTeam( null ) - null team requested");
×
737
                        }
738

739
                        if (teamName.length < 1) {
30!
740
                                Log.info("GitHubAction::deleteTeam( " + teamName + " ) - team does not exist, not deleting; took: " + Util.took(start));
×
741
                                return false;
×
742
                        }
743

744
                        // DELETE /orgs/:org/teams/:team_slug
745
                        const uri = this.apiPath + "/orgs/" + this.org + "/teams/" + teamName;
30✔
746
                        const options: RequestInit = {
30✔
747
                                method: "DELETE",
748
                                headers: {
749
                                        Authorization: this.gitHubAuthToken,
750
                                        "User-Agent": this.gitHubUserName,
751
                                        Accept: "application/vnd.github.hellcat-preview+json",
752
                                },
753
                        };
754

755
                        const response = await fetch(uri, options);
30✔
756
                        // Log.info("GitHubAction::deleteTeam(..) - response: " + response);
757

758
                        if (response.status === 204) {
30✔
759
                                Log.info("GitHubAction::deleteTeam(..) - success; took: " + Util.took(start));
14✔
760
                                return true;
14✔
761
                        } else {
762
                                Log.info("GitHubAction::deleteTeam(..) - not deleted; code: " + response.status + "; took: " + Util.took(start));
16✔
763
                                return false;
16✔
764
                        }
765
                } catch (err) {
766
                        // just warn because 404 throws an error like this
767
                        Log.warn("GitHubAction::deleteTeam(..) - failed; ERROR: " + err.message);
×
768
                        return false;
×
769
                }
770
        }
771

772
        /**
773
         * Gets all repos in an org.
774
         * This is just a subset of the return, but it is the subset we actually use:
775
         * @returns {Promise<GitRepoTuple[]}
776
         */
777
        public async listRepos(): Promise<GitRepoTuple[]> {
778
                Log.info("GitHubActions::listRepos(..) - start");
8✔
779
                const start = Date.now();
8✔
780

781
                // per_page max is 100; 10 is useful for testing pagination though
782
                const uri = this.apiPath + "/orgs/" + this.org + "/repos?per_page=" + this.pageSize;
8✔
783
                Log.trace("GitHubActions::listRepos(..) - URI: " + uri);
8✔
784
                const options: RequestInit = {
8✔
785
                        method: "GET",
786
                        headers: {
787
                                Authorization: this.gitHubAuthToken,
788
                                "User-Agent": this.gitHubUserName,
789
                                Accept: "application/json",
790
                        },
791
                };
792

793
                const raw: any = await this.handlePagination(uri, options);
8✔
794

795
                const rows: GitRepoTuple[] = [];
8✔
796
                for (const entry of raw) {
8✔
797
                        const id = entry.id;
85✔
798
                        const name = entry.name;
85✔
799
                        const url = entry.html_url;
85✔
800
                        rows.push({ repoName: name, githubRepoNumber: id, url: url });
85✔
801
                }
802

803
                Log.info("GitHubActions::listRepos(..) - done; # repos: " + rows.length + "; took: " + Util.took(start));
8✔
804

805
                return rows;
8✔
806
        }
807

808
        /**
809
         * Gets all people in an org.
810
         *
811
         * @returns {Promise<{ id: number, type: string, url: string, name: string }[]>}
812
         * this is just a subset of the return, but it is the subset we actually use
813
         */
814
        public async listPeople(): Promise<GitPersonTuple[]> {
815
                Log.info("GitHubActions::listPeople(..) - start");
1✔
816
                const start = Date.now();
1✔
817

818
                // GET /orgs/:org/members
819
                const uri = this.apiPath + "/orgs/" + this.org + "/members?per_page=" + this.pageSize;
1✔
820
                const options: RequestInit = {
1✔
821
                        method: "GET",
822
                        headers: {
823
                                Authorization: this.gitHubAuthToken,
824
                                "User-Agent": this.gitHubUserName,
825
                                Accept: "application/json",
826
                        },
827
                };
828

829
                const raw: any = await this.handlePagination(uri, options);
1✔
830

831
                const rows: GitPersonTuple[] = [];
1✔
832
                for (const entry of raw) {
1✔
833
                        const id = entry.id;
14✔
834
                        const url = entry.html_url;
14✔
835
                        const githubId = entry.login;
14✔
836
                        rows.push({ githubId: githubId, githubPersonNumber: id, url: url });
14✔
837
                }
838

839
                Log.info("GitHubActions::listPeople(..) - done; # people: " + rows.length + "; took: " + Util.took(start));
1✔
840
                return rows;
1✔
841
        }
842

843
        /**
844
         * Lists the teams for the current org.
845
         *
846
         * NOTE: this is a slow operation (if there are many teams)
847
         * so try not to do it too often!
848
         *
849
         * @returns {Promise<{id: number, name: string}[]>}
850
         */
851
        public async listTeams(): Promise<GitTeamTuple[]> {
852
                // Log.trace("GitHubActions::listTeams(..) - start");
853
                const start = Date.now();
4✔
854

855
                // per_page max is 100
856
                const uri = this.apiPath + "/orgs/" + this.org + "/teams?per_page=" + this.pageSize;
4✔
857
                Log.info("GitHubActions::listTeams(..) - start"); // uri: " + uri);
4✔
858
                const options: RequestInit = {
4✔
859
                        method: "GET",
860
                        headers: {
861
                                Authorization: this.gitHubAuthToken,
862
                                "User-Agent": this.gitHubUserName,
863
                                Accept: "application/vnd.github.hellcat-preview+json",
864
                        },
865
                };
866

867
                const teamsRaw: any = await this.handlePagination(uri, options);
4✔
868

869
                const teams: GitTeamTuple[] = [];
4✔
870
                for (const team of teamsRaw) {
4✔
871
                        const teamNumber = team.id;
33✔
872
                        const teamName = team.name;
33✔
873
                        teams.push({ githubTeamNumber: teamNumber, teamName: teamName });
33✔
874
                }
875

876
                Log.info("GitHubActions::listTeams(..) - done; # teams: " + teams.length + "; took: " + Util.took(start));
4✔
877
                return teams;
4✔
878
        }
879

880
        public async listWebhooks(repoName: string): Promise<Array<{}>> {
881
                Log.trace("GitHubAction::listWebhooks( " + this.org + ", " + repoName + " ) - start");
6✔
882
                const start = Date.now();
6✔
883
                // POST /repos/:owner/:repo/hooks
884
                const uri = this.apiPath + "/repos/" + this.org + "/" + repoName + "/hooks";
6✔
885
                const opts: RequestInit = {
6✔
886
                        method: "GET",
887
                        headers: {
888
                                Authorization: this.gitHubAuthToken,
889
                                "User-Agent": this.gitHubUserName,
890
                        },
891
                };
892

893
                const response = await fetch(uri, opts);
6✔
894
                Log.trace("GitHubAction::listWebhooks(..) - success; took: " + Util.took(start));
6✔
895
                return response.json();
6✔
896
        }
897

898
        public async addWebhook(repoName: string, webhookEndpoint: string): Promise<boolean> {
899
                Log.info("GitHubAction::addWebhook( " + repoName + ", " + webhookEndpoint + " ) - start");
9✔
900

901
                let secret = Config.getInstance().getProp(ConfigKey.autotestSecret);
9✔
902
                secret = crypto.createHash("sha256").update(secret, "utf8").digest("hex"); // webhook w/ sha256
9✔
903
                Log.info("GitHubAction::addWebhook( .. ) - secret: " + secret);
9✔
904
                const start = Date.now();
9✔
905

906
                // https://developer.github.com/webhooks/creating/
907
                // https://developer.github.com/v3/repos/hooks/#create-a-hook
908
                // POST /repos/:owner/:repo/hooks
909
                const uri = this.apiPath + "/repos/" + this.org + "/" + repoName + "/hooks";
9✔
910
                const opts: RequestInit = {
9✔
911
                        method: "POST",
912
                        headers: {
913
                                Authorization: this.gitHubAuthToken,
914
                                "User-Agent": this.gitHubUserName,
915
                        },
916
                        body: JSON.stringify({
917
                                name: "web",
918
                                active: true,
919
                                events: ["commit_comment", "push", "issue_comment"],
920
                                config: {
921
                                        url: webhookEndpoint,
922
                                        secret: secret,
923
                                        content_type: "json",
924
                                },
925
                        }),
926
                };
927

928
                await fetch(uri, opts);
9✔
929
                Log.info("GitHubAction::addWebhook(..) - success; took: " + Util.took(start));
9✔
930
                return true;
9✔
931
        }
932

933
        public async updateWebhook(repoName: string, webhookEndpoint: string): Promise<boolean> {
934
                Log.info("GitHubAction::updateWebhook( " + repoName + ", " + webhookEndpoint + " ) - start");
1✔
935

936
                const existingWebhooks = await this.listWebhooks(repoName);
1✔
937
                if (existingWebhooks.length === 1) {
1!
938
                        const hookId = (existingWebhooks[0] as any).id;
1✔
939

940
                        let secret = Config.getInstance().getProp(ConfigKey.autotestSecret);
1✔
941
                        secret = crypto.createHash("sha256").update(secret, "utf8").digest("hex"); // webhook w/ sha256
1✔
942
                        Log.info("GitHubAction::updateWebhook( .. ) - secret: " + secret);
1✔
943
                        const start = Date.now();
1✔
944

945
                        // https://developer.github.com/webhooks/creating/
946
                        // https://developer.github.com/v3/repos/hooks/#edit-a-hook
947
                        // PATCH /repos/:owner/:repo/hooks/:hook_id
948
                        const uri = this.apiPath + "/repos/" + this.org + "/" + repoName + "/hooks/" + hookId;
1✔
949
                        const opts: RequestInit = {
1✔
950
                                method: "PATCH",
951
                                headers: {
952
                                        Authorization: this.gitHubAuthToken,
953
                                        "User-Agent": this.gitHubUserName,
954
                                },
955
                                body: JSON.stringify({
956
                                        name: "web",
957
                                        active: true,
958
                                        events: ["commit_comment", "push", "issue_comment"],
959
                                        config: {
960
                                                url: webhookEndpoint,
961
                                                secret: secret,
962
                                                content_type: "json",
963
                                        },
964
                                }),
965
                        };
966

967
                        await fetch(uri, opts);
1✔
968
                        Log.info("GitHubAction::updateWebhook(..) - success; took: " + Util.took(start));
1✔
969
                        return true;
1✔
970
                } else {
971
                        Log.error(
×
972
                                "GitHubAction::updateWebhook( " +
973
                                        repoName +
974
                                        ", " +
975
                                        webhookEndpoint +
976
                                        " ) - Invalid number of existing webhooks: " +
977
                                        JSON.stringify(existingWebhooks)
978
                        );
979
                }
980
                return false;
×
981
        }
982

983
        /**
984
         * Creates a team for a groupName (e.g., cpsc310_team1).
985
         *
986
         * Returns a team tuple.
987
         *
988
         * @param teamName
989
         * @param permission "admin", "pull", "push" // admin for staff, push for students
990
         * @returns {Promise<GitTeamTuple>} team tuple
991
         */
992
        public async createTeam(teamName: string, permission: string): Promise<GitTeamTuple> {
993
                Log.info("GitHubAction::teamCreate( " + this.org + ", " + teamName + ", " + permission + ", ... ) - start");
16✔
994
                if (permission !== "push" && permission !== "pull" && permission !== "admin") {
16!
995
                        throw new Error("GitHubAction::teamCreate(..) - invalid permission: " + permission);
×
996
                }
997

998
                const start = Date.now();
16✔
999
                try {
16✔
1000
                        await GitHubActions.checkDatabase(null, teamName);
16✔
1001

1002
                        const team = await this.getTeamByName(teamName); // be conservative, do not use TeamController on purpose
16✔
1003
                        if (team !== null) {
16✔
1004
                                Log.info("GitHubAction::teamCreate( " + teamName + ", ... ) - already exists; returning");
2✔
1005
                                return { teamName: teamName, githubTeamNumber: team.githubTeamNumber };
2✔
1006
                        } else {
1007
                                Log.info("GitHubAction::teamCreate( " + teamName + ", ... ) - does not exist; creating");
14✔
1008
                                const uri = this.apiPath + "/orgs/" + this.org + "/teams";
14✔
1009
                                const options: RequestInit = {
14✔
1010
                                        method: "POST",
1011
                                        headers: {
1012
                                                Authorization: this.gitHubAuthToken,
1013
                                                "User-Agent": this.gitHubUserName,
1014
                                                Accept: "application/json",
1015
                                        },
1016
                                        body: JSON.stringify({
1017
                                                name: teamName,
1018
                                                permission: permission,
1019
                                        }),
1020
                                };
1021
                                const response = await fetch(uri, options);
14✔
1022
                                const body = await response.json();
14✔
1023
                                Log.info("GitHubAction::teamCreate(..) - success; new: " + body.id + "; took: " + Util.took(start));
14✔
1024

1025
                                // remove default token provider/maintainer from team
1026
                                await this.removeMembersFromTeam(teamName, [Config.getInstance().getProp(ConfigKey.githubBotName)]);
14✔
1027

1028
                                // TODO: why is the team not reflected to the DB here (with status UNLINKED) like in createRepo?
1029

1030
                                return { teamName: teamName, githubTeamNumber: body.id };
14✔
1031
                        }
1032
                } catch (err) {
1033
                        // explicitly log this failure
1034
                        Log.error("GitHubAction::teamCreate(..) - ERROR: " + err);
×
1035
                        throw err;
×
1036
                }
1037
        }
1038

1039
        /**
1040
         * Add a set of GitHub members (their usernames) to a given team.
1041
         *
1042
         * @param teamName
1043
         * @param members GitHub usernames to add to the team
1044
         * @returns {Promise<GitTeamTuple>}
1045
         */
1046
        public async addMembersToTeam(teamName: string, members: string[]): Promise<GitTeamTuple> {
1047
                Log.info(
11✔
1048
                        "GitHubAction::addMembersToTeam( " + teamName + ", ..) - start; teamName: " + teamName + "; members: " + JSON.stringify(members)
1049
                );
1050
                const start = Date.now();
11✔
1051

1052
                const tc = new TeamController();
11✔
1053
                const teamNumber = await tc.getTeamNumber(teamName); // try to use cache
11✔
1054

1055
                // sanity check (members should be githubIds, not other ids)
1056
                for (const member of members) {
11✔
1057
                        const person = this.dc.getGitHubPerson(member);
19✔
1058
                        if (person === null) {
19!
1059
                                const errMsg =
1060
                                        "GitHubAction::addMembersToTeam( .. ) - githubId: " +
×
1061
                                        member +
1062
                                        " is unknown; is this actually an id instead of a githubId?";
1063
                                Log.error(errMsg);
×
1064
                                throw new Error(errMsg);
×
1065
                        }
1066
                }
1067

1068
                const promises: any = [];
11✔
1069
                for (const member of members) {
11✔
1070
                        Log.info("GitHubAction::addMembersToTeam(..) - adding member: " + member);
19✔
1071

1072
                        // PUT /teams/:id/memberships/:username
1073
                        const uri = this.apiPath + "/teams/" + teamNumber + "/memberships/" + member;
19✔
1074
                        Log.info("GitHubAction::addMembersToTeam(..) - uri: " + uri);
19✔
1075
                        const opts: RequestInit = {
19✔
1076
                                method: "PUT",
1077
                                headers: {
1078
                                        Authorization: this.gitHubAuthToken,
1079
                                        "User-Agent": this.gitHubUserName,
1080
                                        Accept: "application/json",
1081
                                },
1082
                        };
1083
                        promises.push(fetch(uri, opts));
19✔
1084
                }
1085

1086
                const results = await Promise.all(promises);
11✔
1087
                Log.info("GitHubAction::addMembersToTeam(..) - success; took: " + Util.took(start) + "; results:" + JSON.stringify(results));
11✔
1088

1089
                return { teamName: teamName, githubTeamNumber: teamNumber };
11✔
1090
        }
1091

1092
        /**
1093
         * Remove a set of GitHub members (their usernames) from a given team.
1094
         *
1095
         * @param teamName
1096
         * @param members GitHub usernames to remove from the team
1097
         * @returns {Promise<GitTeamTuple>}
1098
         */
1099
        public async removeMembersFromTeam(teamName: string, members: string[]): Promise<GitTeamTuple> {
1100
                Log.info(
14✔
1101
                        "GitHubAction::removeMembersFromTeam( " +
1102
                                teamName +
1103
                                ", ..) - start; teamName: " +
1104
                                teamName +
1105
                                "; members: " +
1106
                                JSON.stringify(members)
1107
                );
1108
                const start = Date.now();
14✔
1109

1110
                const tc = new TeamController();
14✔
1111
                const teamNumber = await tc.getTeamNumber(teamName); // try to use cache
14✔
1112

1113
                // sanity check (members should be githubIds, not other ids)
1114
                for (const member of members) {
14✔
1115
                        const person = this.dc.getGitHubPerson(member);
14✔
1116
                        if (person === null) {
14!
1117
                                const emsg =
1118
                                        "GitHubAction::removeMembersFromTeam( .. ) - githubId: " +
×
1119
                                        member +
1120
                                        " is unknown; is this actually an id instead of a githubId?";
1121
                                Log.error(emsg);
×
1122
                                throw new Error(emsg);
×
1123
                        }
1124
                }
1125

1126
                const promises: any = [];
14✔
1127
                for (const member of members) {
14✔
1128
                        Log.info("GitHubAction::removeMembersFromTeam(..) - removing member: " + member);
14✔
1129

1130
                        // DELETE /teams/:id/memberships/:username
1131
                        const uri = this.apiPath + "/teams/" + teamNumber + "/memberships/" + member;
14✔
1132
                        Log.info("GitHubAction::removeMembersFromTeam(..) - uri: " + uri);
14✔
1133
                        const opts: RequestInit = {
14✔
1134
                                method: "DELETE",
1135
                                headers: {
1136
                                        Authorization: this.gitHubAuthToken,
1137
                                        "User-Agent": this.gitHubUserName,
1138
                                        Accept: "application/json",
1139
                                },
1140
                        };
1141
                        promises.push(fetch(uri, opts));
14✔
1142
                }
1143

1144
                const results = await Promise.all(promises);
14✔
1145
                Log.info("GitHubAction::removeMembersFromTeam(..) - success; took: " + Util.took(start) + "; results:" + JSON.stringify(results));
14✔
1146

1147
                return { teamName: teamName, githubTeamNumber: teamNumber };
14✔
1148
        }
1149

1150
        /**
1151
         * NOTE: needs the team teamId (number), not the team name (string)!
1152
         *
1153
         * @param {string} teamName
1154
         * @param repoName
1155
         * @param permission ("pull", "push", "admin")
1156
         * @returns {Promise<GitTeamTuple>}
1157
         */
1158
        public async addTeamToRepo(teamName: string, repoName: string, permission: string): Promise<GitTeamTuple> {
1159
                Log.trace("GitHubAction::addTeamToRepo( " + teamName + ", " + repoName + " ) - start");
25✔
1160
                if (permission !== "push" && permission !== "pull" && permission !== "admin") {
25!
1161
                        throw new Error("GitHubAction::addTeamToRepo(..) - invalid permission: " + permission);
×
1162
                }
1163

1164
                const start = Date.now();
25✔
1165
                try {
25✔
1166
                        const team = await this.getTeamByName(teamName);
25✔
1167
                        if (team === null) {
25!
1168
                                throw new Error("GitHubAction::addTeamToRepo(..) - team does not exist: " + teamName);
×
1169
                        }
1170

1171
                        const repoExists = await this.repoExists(repoName);
25✔
1172
                        if (repoExists === false) {
25!
1173
                                throw new Error("GitHubAction::addTeamToRepo(..) - repo does not exist: " + repoName);
×
1174
                        }
1175

1176
                        // with teamId:
1177
                        // PUT /teams/:team_id/repos/:owner/:repo (OLD)
1178
                        // const uri = this.apiPath + "/teams/" + teamId + "/repos/" + this.org + "/" + repoName;
1179

1180
                        // with teamName: DOES NOT WORK in v3
1181
                        // PUT /orgs/:org/teams/:team_slug/repos/:owner/:repo (NEW)
1182
                        // const teamName = await this.getTeam(teamId);
1183
                        const uri = this.apiPath + "/orgs/" + this.org + "/teams/" + teamName + "/repos/" + this.org + "/" + repoName;
25✔
1184
                        Log.trace("GitHubAction::addTeamToRepo(..) - uri: " + uri);
25✔
1185
                        const options: RequestInit = {
25✔
1186
                                method: "PUT",
1187
                                headers: {
1188
                                        Authorization: this.gitHubAuthToken,
1189
                                        "User-Agent": this.gitHubUserName,
1190
                                        // "Accept": "application/json"
1191
                                        Accept: "application/vnd.github+json",
1192
                                },
1193
                                body: JSON.stringify({
1194
                                        permission: permission,
1195
                                }),
1196
                        };
1197

1198
                        const response = await fetch(uri, options);
25✔
1199
                        if (!response.ok) {
25!
1200
                                throw new Error(response.statusText);
×
1201
                        }
1202

1203
                        Log.info(
25✔
1204
                                "GitHubAction::addTeamToRepo(..) - success; team: " + teamName + "; repo: " + repoName + "; took: " + Util.took(start)
1205
                        );
1206

1207
                        // const teamId = await this.getTeamNumber(teamName);
1208
                        return { githubTeamNumber: team.githubTeamNumber, teamName: "NOTSETHERE" }; // TODO: why NOTSETHERE?
25✔
1209
                } catch (err) {
1210
                        Log.error("GitHubAction::addTeamToRepo(..) - ERROR: " + err);
×
1211
                        throw err;
×
1212
                }
1213
        }
1214

1215
        /**
1216
         * Gets the internal number for a team.
1217
         *
1218
         * Returns -1 if the team does not exist.
1219
         *
1220
         * NOTE: most clients will want to use TeamController::getTeamNumber instead.
1221
         *
1222
         * @param {string} teamName
1223
         * @returns {Promise<number>}
1224
         */
1225
        public async getTeamNumber(teamName: string): Promise<number> {
1226
                Log.info("GitHubAction::getTeamNumber( " + teamName + " ) - start");
4✔
1227
                const start = Date.now();
4✔
1228
                try {
4✔
1229
                        // NOTE: this cannot use TeamController::getTeamNumber because that causes an infinite loop
1230
                        const team = await this.getTeamByName(teamName);
4✔
1231
                        let teamId = -1;
4✔
1232
                        if (team !== null) {
4✔
1233
                                teamId = team.githubTeamNumber;
2✔
1234
                        }
1235

1236
                        if (teamId <= 0) {
4✔
1237
                                Log.info("GitHubAction::getTeamNumber(..) - WARN: Could not find team: " + teamName + "; took: " + Util.took(start));
2✔
1238
                                return -1;
2✔
1239
                        } else {
1240
                                Log.info(
2✔
1241
                                        "GitHubAction::getTeamNumber(..) - Found team: " + teamName + "; teamId: " + teamId + "; took: " + Util.took(start)
1242
                                );
1243
                                return teamId;
2✔
1244
                        }
1245
                } catch (err) {
1246
                        Log.warn("GitHubAction::getTeamNumber(..) - could not match team: " + teamName + "; ERROR: " + err);
×
1247
                        return -1;
×
1248
                }
1249
        }
1250

1251
        /**
1252
         * Gets the list of users on a team.
1253
         *
1254
         * Returns [] if the team does not exist or nobody is on the team.
1255
         *
1256
         * @param {string} teamName
1257
         * @returns {Promise<string[]>}
1258
         */
1259
        public async getTeamMembers(teamName: string): Promise<string[]> {
1260
                Log.trace("GitHubAction::getTeamMembers( " + teamName + " ) - start");
52✔
1261

1262
                if (teamName === null) {
52✔
1263
                        throw new Error("GitHubAction::getTeamMembers( null ) - null team requested");
1✔
1264
                }
1265

1266
                const start = Date.now();
51✔
1267
                try {
51✔
1268
                        // /orgs/{org}/teams/{team_slug}/members
1269
                        const uri = this.apiPath + "/orgs/" + this.org + "/teams/" + teamName + "/members";
51✔
1270
                        const options: RequestInit = {
51✔
1271
                                method: "GET",
1272
                                headers: {
1273
                                        Authorization: this.gitHubAuthToken,
1274
                                        "User-Agent": this.gitHubUserName,
1275
                                        Accept: "application/vnd.github+json",
1276
                                },
1277
                        };
1278

1279
                        const teamMembersRaw: any = await this.handlePagination(uri, options);
51✔
1280
                        const ids: string[] = [];
51✔
1281
                        for (const teamMember of teamMembersRaw) {
51✔
1282
                                ids.push(teamMember.login);
172✔
1283
                        }
1284

1285
                        Log.trace("GitHubAction::getTeamMembers( " + teamName + " ) - done; # results: " + ids.length + "; took: " + Util.took(start));
49✔
1286

1287
                        return ids;
49✔
1288
                } catch (err) {
1289
                        Log.warn("GitHubAction::getTeamMembers(..) - ERROR: " + JSON.stringify(err));
2✔
1290
                        // just return empty [] rather than failing
1291
                        return [];
2✔
1292
                }
1293
        }
1294

1295
        /**
1296
         * Gets the team associated with the team name.
1297
         *
1298
         * Returns null if the team does not exist.
1299
         *
1300
         * @param {string} teamName
1301
         * @returns {Promise<number>}
1302
         */
1303
        public async getTeamByName(teamName: string): Promise<GitTeamTuple | null> {
1304
                if (teamName === null) {
80!
1305
                        throw new Error("GitHubAction::getTeamByName( null ) - null team requested");
×
1306
                }
1307

1308
                const start = Date.now();
80✔
1309
                // /orgs/{org}/teams/{team_slug}
1310
                const uri = this.apiPath + "/orgs/" + this.org + "/teams/" + teamName;
80✔
1311
                const options: RequestInit = {
80✔
1312
                        method: "GET",
1313
                        headers: {
1314
                                Authorization: this.gitHubAuthToken,
1315
                                "User-Agent": this.gitHubUserName,
1316
                                Accept: "application/json",
1317
                        },
1318
                };
1319

1320
                const response = await fetch(uri, options);
80✔
1321

1322
                if (response.status === 404) {
80✔
1323
                        Log.warn("GitHubAction::getTeam( " + teamName + " ) - team does not exist; status: " + response.status);
27✔
1324
                        return null;
27✔
1325
                }
1326

1327
                const body = await response.json();
53✔
1328
                const ret = { githubTeamNumber: body.id, teamName: body.name };
53✔
1329
                Log.info("GitHubAction::getTeam( " + teamName + " ) - found: " + JSON.stringify(ret) + "; took: " + Util.took(start));
53✔
1330
                return ret;
53✔
1331
        }
1332

1333
        /**
1334
         * Gets the team associated with the team number.
1335
         *
1336
         * Returns null if the team does not exist.
1337
         *
1338
         * @param {string} teamNumber
1339
         * @returns {Promise<number>}
1340
         */
1341
        public async getTeam(teamNumber: number): Promise<GitTeamTuple | null> {
1342
                Log.info("GitHubAction::getTeam( " + teamNumber + " ) - start");
3✔
1343

1344
                if (teamNumber === null) {
3✔
1345
                        throw new Error("GitHubAction::getTeam( null ) - null team requested");
1✔
1346
                }
1347

1348
                const start = Date.now();
2✔
1349
                const uri = this.apiPath + "/teams/" + teamNumber;
2✔
1350
                const options: RequestInit = {
2✔
1351
                        method: "GET",
1352
                        headers: {
1353
                                Authorization: this.gitHubAuthToken,
1354
                                "User-Agent": this.gitHubUserName,
1355
                                Accept: "application/json",
1356
                        },
1357
                };
1358

1359
                const response = await fetch(uri, options);
2✔
1360

1361
                if (response.status === 404) {
2✔
1362
                        Log.warn("GitHubAction::getTeam( " + teamNumber + " ) - ERROR: Github Team " + response.status);
1✔
1363
                        return null;
1✔
1364
                }
1365

1366
                const body = await response.json();
1✔
1367
                const ret = { githubTeamNumber: body.id, teamName: body.name };
1✔
1368
                Log.info("GitHubAction::getTeam( " + teamNumber + " ) - found: " + JSON.stringify(ret) + "; took: " + Util.took(start));
1✔
1369
                return ret;
1✔
1370
        }
1371

1372
        public async isOnAdminTeam(userName: string): Promise<boolean> {
1373
                const isAdmin = await this.isOnTeam(TeamController.ADMIN_NAME, userName);
22✔
1374
                Log.trace("GitHubAction::isOnAdminTeam( " + userName + " ) - result: " + isAdmin);
22✔
1375
                return isAdmin;
22✔
1376
        }
1377

1378
        public async isOnStaffTeam(userName: string): Promise<boolean> {
1379
                const isStaff = await this.isOnTeam(TeamController.STAFF_NAME, userName);
22✔
1380
                Log.trace("GitHubAction::isOnStaffTeam( " + userName + " ) - result: " + isStaff);
22✔
1381
                return isStaff;
22✔
1382
        }
1383

1384
        public async isOnTeam(teamName: string, userName: string): Promise<boolean> {
1385
                const gh = this;
44✔
1386
                const start = Date.now();
44✔
1387

1388
                if (teamName !== TeamController.STAFF_NAME && teamName !== TeamController.ADMIN_NAME) {
44!
1389
                        // sanity-check non admin/staff teams
1390
                        await GitHubActions.checkDatabase(null, teamName);
×
1391
                }
1392

1393
                const teamMembers = await gh.getTeamMembers(teamName);
44✔
1394
                for (const member of teamMembers) {
44✔
1395
                        if (member === userName) {
138✔
1396
                                Log.info("GitHubAction::isOnTeam( " + userName + " ) - IS on team: " + teamName + "; took: " + Util.took(start));
7✔
1397
                                return true;
7✔
1398
                        }
1399
                }
1400

1401
                // only info by default if you are _on_ a team
1402
                Log.trace("GitHubAction::isOnTeam( " + userName + " ) - is NOT on team: " + teamName + "; took: " + Util.took(start));
37✔
1403
                return false;
37✔
1404
        }
1405

1406
        public async listTeamMembers(teamName: string): Promise<string[]> {
1407
                Log.info("GitHubAction::listTeamMembers( " + teamName + " ) - start");
×
1408

1409
                const gh = this;
×
1410
                const teamMembers = await gh.getTeamMembers(teamName);
×
1411

1412
                return teamMembers;
×
1413
        }
1414

1415
        public async listRepoBranches(repoId: string): Promise<string[]> {
1416
                const start = Date.now();
15✔
1417
                const repoExists = await this.repoExists(repoId); // ensure the repo exists
15✔
1418
                if (repoExists === false) {
15!
1419
                        Log.error("GitHubAction::listRepoBranches( " + repoId + " ) - failed; repo does not exist");
×
1420
                        return null;
×
1421
                }
1422

1423
                // get branches
1424
                // GET /repos/{owner}/{repo}/branches
1425
                const listUri = this.apiPath + "/repos/" + this.org + "/" + repoId + "/branches";
15✔
1426
                Log.info("GitHubAction::listRepoBranches(..) - list branch uri: " + listUri);
15✔
1427
                const listOptions: RequestInit = {
15✔
1428
                        method: "GET",
1429
                        headers: {
1430
                                Authorization: this.gitHubAuthToken,
1431
                                "User-Agent": this.gitHubUserName,
1432
                                Accept: "application/vnd.github+json",
1433
                                "X-GitHub-Api-Version": "2022-11-28",
1434
                        },
1435
                };
1436

1437
                const listResp = await fetch(listUri, listOptions);
15✔
1438
                Log.trace("GitHubAction::listRepoBranches(..) - list response code: " + listResp.status); // 201 success
15✔
1439
                const listRespBody = await listResp.json();
15✔
1440

1441
                if (listResp.status !== 200) {
15!
1442
                        Log.warn("GitHubAction::listRepoBranches(..) - failed to list branches for repo; response: " + JSON.stringify(listRespBody));
×
1443
                        return null;
×
1444
                }
1445

1446
                Log.trace("GitHubAction::listRepoBranches(..) - branch list: " + JSON.stringify(listRespBody));
15✔
1447

1448
                const branches: string[] = [];
15✔
1449
                for (const githubBranch of listRespBody) {
15✔
1450
                        branches.push(githubBranch.name);
24✔
1451
                }
1452
                Log.trace("GitHubAction::listRepoBranches(..) - branches: " + JSON.stringify(branches) + "; took: " + Util.took(start));
15✔
1453
                return branches;
15✔
1454
        }
1455

1456
        // public async listBranches(repoId: string): Promise<string[]> {
1457
        //     const start = Date.now();
1458
        //
1459
        //     const repoExists = await this.repoExists(repoId); // ensure the repo exists
1460
        //     if (repoExists === false) {
1461
        //         Log.error("GitHubAction::listBranches(..) - failed; repo does not exist");
1462
        //         return [];
1463
        //     }
1464
        //
1465
        //     // get branches
1466
        //     // GET /repos/{owner}/{repo}/branches
1467
        //     const listUri = this.apiPath + "/repos/" + this.org + "/" + repoId + "/branches";
1468
        //     Log.info("GitHubAction::listBranches(..) - starting; branch uri: " + listUri);
1469
        //     const listOptions: RequestInit = {
1470
        //         method: "GET",
1471
        //         headers: {
1472
        //             "Authorization": this.gitHubAuthToken,
1473
        //             "User-Agent": this.gitHubUserName,
1474
        //             "Accept": "application/vnd.github+json",
1475
        //             "X-GitHub-Api-Version": "2022-11-28"
1476
        //         }
1477
        //     };
1478
        //
1479
        //     const listResp = await fetch(listUri, listOptions);
1480
        //     Log.trace("GitHubAction::listBranches(..) - list response code: " + listResp.status); // 201 success
1481
        //     const listRespBody = await listResp.json();
1482
        //
1483
        //     if (listResp.status !== 200) {
1484
        //         Log.warn("GitHubAction::deleteBranches(..) - failed to list branches for repo; response: " + JSON.stringify(listRespBody));
1485
        //         return [];
1486
        //     }
1487
        //     Log.trace("GitHubAction::listBranches(..) - branch list: " + JSON.stringify(listRespBody));
1488
        //
1489
        //     const branches: string[] = [];
1490
        //     for (const githubBranch of listRespBody) {
1491
        //         branches.push(githubBranch.name);
1492
        //     }
1493
        //
1494
        //     Log.info("GitHubAction::listBranches(..) - done; branches found: " + JSON.stringify(branches));
1495
        //     return branches;
1496
        // }
1497

1498
        /**
1499
         * NOTE: This method will delete all branches EXCEPT those in the branchesToKeep list.
1500
         *
1501
         * @param repoId
1502
         * @param branchesToKeep
1503
         */
1504
        public async deleteBranches(repoId: string, branchesToKeep: string[]): Promise<boolean> {
1505
                const start = Date.now();
3✔
1506

1507
                const repoExists = await this.repoExists(repoId); // ensure the repo exists
3✔
1508
                if (repoExists === false) {
3!
1509
                        Log.error("GitHubAction::deleteBranches(..) - failed; repo does not exist");
×
1510
                        return false;
×
1511
                }
1512

1513
                const allBranches = await this.listRepoBranches(repoId);
3✔
1514

1515
                const branchesToKeepThatExist: string[] = [];
3✔
1516
                const branchesToDelete: string[] = [];
3✔
1517
                for (const githubBranch of allBranches) {
3✔
1518
                        if (branchesToKeep.indexOf(githubBranch) < 0) {
5✔
1519
                                branchesToDelete.push(githubBranch);
4✔
1520
                        } else {
1521
                                branchesToKeepThatExist.push(githubBranch);
1✔
1522
                        }
1523
                }
1524

1525
                Log.info("GitHubAction::deleteBranches(..) - branches to delete: " + JSON.stringify(branchesToDelete));
3✔
1526

1527
                // make sure there will be at least one branch left on the repo
1528
                // requires a real branchToKeep or that all of the existing branches are not in branchesToKeep
1529
                if (branchesToKeepThatExist.length < 1) {
3✔
1530
                        Log.error("GitHubAction::deleteBranches(..) - none of the branchesToKeep actually exist (one must remain)");
2✔
1531
                        return false;
2✔
1532
                }
1533

1534
                // delete branches we do not want
1535
                let deleteSucceeded = true;
1✔
1536
                for (const branch of branchesToDelete) {
1✔
1537
                        deleteSucceeded = await this.deleteBranch(repoId, branch);
1✔
1538
                }
1539

1540
                // This is an unsatisfying check. But GitHub Enterprise often returns repo provisioning
1541
                // before it is actually complete. This means that all of the branches may not be found
1542
                // at the time this method runs the first time. Hopefully after some deletions enough time
1543
                // has passed that this will work correctly. An alternative would have been to put a wait
1544
                // into the repo provisioning workflow, but the whole reason to change to templates was for
1545
                // performance. Hopefully this is good enough.
1546

1547
                Log.info("GitHubAction::deleteBranches(..) - verifying remaining branches");
1✔
1548
                const branchesAfter = await this.listRepoBranches(repoId);
1✔
1549
                if (branchesAfter.length > branchesToKeep.length) {
1!
1550
                        // do it again
1551
                        Log.info("GitHubAction::deleteBranches(..) - branches still remain; retry removal");
×
1552
                        await this.deleteBranches(repoId, branchesToKeep);
×
1553
                } else {
1554
                        Log.info("GitHubAction::deleteBranches(..) - extra branches not found");
1✔
1555
                }
1556

1557
                Log.info("GitHubAction::deleteBranches(..) - done; success: " + deleteSucceeded + "; took: " + Util.took(start));
1✔
1558
                return deleteSucceeded;
1✔
1559
        }
1560

1561
        /**
1562
         * NOTE: If a repo has a branch, it will be deleted.
1563
         *
1564
         * @param repoId
1565
         * @param branchToDelete
1566
         * @returns {Promise<boolean>} true if the branch was deleted, false otherwise; throws error if something bad happened.
1567
         */
1568
        public async deleteBranch(repoId: string, branchToDelete: string): Promise<boolean> {
1569
                const start = Date.now();
1✔
1570

1571
                const repoExists = await this.repoExists(repoId); // ensure the repo exists
1✔
1572
                if (repoExists === false) {
1!
1573
                        Log.error("GitHubAction::deleteBranch(..) - failed; repo does not exist");
×
1574
                        return false;
×
1575
                }
1576

1577
                Log.info("GitHubAction::deleteBranch( " + repoId + ", " + branchToDelete + " ) - start");
1✔
1578

1579
                // DELETE /repos/{owner}/{repo}/git/refs/{ref}
1580
                const delUri = this.apiPath + "/repos/" + this.org + "/" + repoId + "/git/refs/" + "heads/" + branchToDelete;
1✔
1581
                Log.info("GitHubAction::deleteBranch(..) - delete branch; uri: " + delUri);
1✔
1582

1583
                const delOptions: RequestInit = {
1✔
1584
                        method: "DELETE",
1585
                        headers: {
1586
                                Authorization: this.gitHubAuthToken,
1587
                                "User-Agent": this.gitHubUserName,
1588
                                Accept: "application/vnd.github+json",
1589
                                "X-GitHub-Api-Version": "2022-11-28",
1590
                        },
1591
                };
1592

1593
                const deleteResp = await fetch(delUri, delOptions);
1✔
1594
                Log.trace("GitHubAction::deleteBranch(..) - delete response code: " + deleteResp.status);
1✔
1595

1596
                if (deleteResp.status !== 204) {
1!
1597
                        const delRespBody = await deleteResp.json();
×
1598
                        Log.warn("GitHubAction::deleteBranches(..) - failed to delete branch for repo; response: " + JSON.stringify(delRespBody));
×
1599
                        return false;
×
1600
                } else {
1601
                        Log.info(
1✔
1602
                                "GitHubAction::deleteBranches(..) - successfully deleted branch: " +
1603
                                        branchToDelete +
1604
                                        " from repo: " +
1605
                                        repoId +
1606
                                        "; took: " +
1607
                                        Util.took(start)
1608
                        );
1609
                        return true;
1✔
1610
                }
1611
        }
1612

1613
        public async renameBranch(repoId: string, oldName: string, newName: string): Promise<boolean> {
1614
                Log.info("GitHubAction::renameBranch( " + repoId + ", " + oldName + ", " + newName + " ) - start");
2✔
1615

1616
                const repoExists = await this.repoExists(repoId); // ensure the repo exists
2✔
1617
                if (repoExists === false) {
2!
1618
                        Log.error("GitHubAction::renameBranch(..) - failed; repo does not exist");
×
1619
                        return false;
×
1620
                }
1621

1622
                const start = Date.now();
2✔
1623
                // /repos/{owner}/{repo}/branches/{branch}/rename
1624
                const uri = this.apiPath + "/repos/" + this.org + "/" + repoId + "/branches/" + oldName + "/rename";
2✔
1625
                Log.info("GitHubAction::renameBranch(..) - uri: " + uri);
2✔
1626
                const options: RequestInit = {
2✔
1627
                        method: "POST",
1628
                        headers: {
1629
                                Authorization: this.gitHubAuthToken,
1630
                                "User-Agent": this.gitHubUserName,
1631
                                Accept: "application/vnd.github+json",
1632
                                "X-GitHub-Api-Version": "2022-11-28",
1633
                        },
1634
                        body: JSON.stringify({
1635
                                new_name: newName,
1636
                        }),
1637
                };
1638

1639
                const response = await fetch(uri, options);
2✔
1640
                Log.trace("GitHubAction::renameBranch(..) - response code: " + response.status); // 201 success
2✔
1641

1642
                if (response.status === 201) {
2✔
1643
                        Log.info("GitHubAction::renameBranch(..) - success; took: " + Util.took(start));
1✔
1644
                        return true;
1✔
1645
                } else {
1646
                        const body = await response.json();
1✔
1647
                        Log.warn("GitHubAction::renameBranch(..) - failed; response: " + JSON.stringify(body));
1✔
1648
                        return false;
1✔
1649
                }
1650
        }
1651

1652
        public async importRepoFS(importRepo: string, studentRepo: string, seedFilePath?: string): Promise<boolean> {
1653
                Log.info("GitHubAction::importRepoFS( " + importRepo + ", " + studentRepo + " ) - start");
17✔
1654
                const that = this;
17✔
1655
                const start = Date.now();
17✔
1656

1657
                // if we do not need to do this step, just skip it rather than crashing later on
1658
                if (
17!
1659
                        typeof importRepo === "undefined" ||
102✔
1660
                        typeof studentRepo === "undefined" ||
1661
                        importRepo === null ||
1662
                        studentRepo === null ||
1663
                        importRepo === "" ||
1664
                        studentRepo === ""
1665
                ) {
1666
                        Log.info("GitHubAction::importRepoFS(..) - FS import skipped; missing import or student repo");
×
1667
                        return true;
×
1668
                }
1669

1670
                function addGithubAuthToken(url: string) {
1671
                        try {
34✔
1672
                                [url] = url.split(".git");
34✔
1673
                                const startAppend = url.indexOf("//") + 2;
34✔
1674
                                const token = that.gitHubAuthToken;
34✔
1675
                                const authKey = token.substr(token.indexOf("token ") + 6) + "@";
34✔
1676
                                // creates "longokenstring@githuburi"
1677
                                return url.slice(0, startAppend) + authKey + url.slice(startAppend);
34✔
1678
                        } catch (err) {
1679
                                Log.error("GitHubActions::importRepoFS(..)::addGithubAuthToken() - Unexpected error", err);
×
1680
                                return "";
×
1681
                        }
1682
                }
1683

1684
                function getImportBranch(url: string): string {
1685
                        try {
17✔
1686
                                const [cloneUrl, specifiers] = url.split("#");
17✔
1687
                                const [branch, path] = (specifiers || "").split(":");
17✔
1688
                                return branch;
17✔
1689
                        } catch (err) {
1690
                                Log.error("GitHubActions::importRepoFS(..)::getImportBranch() - Unexpected error", err);
×
1691
                                return "";
×
1692
                        }
1693
                }
1694

1695
                function getPath(url: string): string {
1696
                        try {
17✔
1697
                                const [cloneUrl, specifiers] = url.split(".git");
17✔
1698
                                const [branch, pathSpecifier] = (specifiers || "").split(":");
17✔
1699
                                let path = pathSpecifier || "";
17✔
1700
                                path = path.startsWith("/") ? path.slice(1) : path;
17✔
1701
                                path = path.endsWith("/") ? path.slice(0, -1) : path;
17✔
1702
                                return path;
17✔
1703
                        } catch (err) {
1704
                                Log.error("GitHubActions::importRepoFS(..)::getPath() - Unexpected error", err);
×
1705
                                return "";
×
1706
                        }
1707
                }
1708

1709
                function selectPath(dirPath: string, filePath: string): string {
1710
                        let finalPath = filePath;
17✔
1711
                        if (dirPath && filePath) {
17✔
1712
                                finalPath = `${dirPath}/${filePath}`;
2✔
1713
                        } else if (dirPath) {
15✔
1714
                                finalPath = `${dirPath}/*`;
3✔
1715
                        }
1716
                        return finalPath;
17✔
1717
                }
1718

1719
                const exec = require("child-process-promise").exec;
17✔
1720
                const cloneTempDir = await tmp.dir({ dir: "/tmp", unsafeCleanup: true });
17✔
1721
                const authedStudentRepo = addGithubAuthToken(studentRepo);
17✔
1722
                const authedImportRepo = addGithubAuthToken(importRepo);
17✔
1723
                const importBranch = getImportBranch(importRepo);
17✔
1724
                const urlPath = getPath(importRepo);
17✔
1725
                const importPath = selectPath(urlPath, seedFilePath);
17✔
1726
                // this was just a github-dev testing issue; we might need to consider using per-org import test targets or something
1727
                // if (importRepo === "https://github.com/SECapstone/capstone" || importRepo === "https://github.com/SECapstone/bootstrap") {
1728
                //     authedImportRepo = importRepo; // HACK: for testing
1729
                // }
1730

1731
                if (typeof importPath === "string" && importPath !== "") {
17✔
1732
                        const seedTempDir = await tmp.dir({ dir: "/tmp", unsafeCleanup: true });
7✔
1733
                        // First clone to a temporary directory, then move only the required files
1734
                        return cloneRepo(seedTempDir.path).then(() => {
7✔
1735
                                return checkout(seedTempDir.path, importBranch)
7✔
1736
                                        .then(() => {
1737
                                                return moveFiles(seedTempDir.path, importPath, cloneTempDir.path);
7✔
1738
                                        })
1739
                                        .then(() => {
1740
                                                return removeGitDir();
7✔
1741
                                        })
1742
                                        .then(() => {
1743
                                                return initGitDir();
7✔
1744
                                        })
1745
                                        .then(() => {
1746
                                                return changeGitRemote();
7✔
1747
                                        })
1748
                                        .then(() => {
1749
                                                return addFilesToRepo();
7✔
1750
                                        })
1751
                                        .then(() => {
1752
                                                return pushToNewRepo();
7✔
1753
                                        })
1754
                                        .then(() => {
1755
                                                Log.info("GitHubAction::cloneRepo() seedPath - done; took: " + Util.took(start));
7✔
1756
                                                seedTempDir.cleanup();
7✔
1757
                                                cloneTempDir.cleanup();
7✔
1758
                                                return Promise.resolve(true); // made it cleanly
7✔
1759
                                        })
1760
                                        .catch((err: any) => {
1761
                                                /* istanbul ignore next */
1762
                                                Log.error("GitHubAction::cloneRepo() seedPath - ERROR: " + err);
1763
                                                seedTempDir.cleanup();
×
1764
                                                cloneTempDir.cleanup();
×
1765
                                                return Promise.reject(err);
×
1766
                                        });
1767
                        });
1768
                } else {
1769
                        return cloneRepo(cloneTempDir.path).then(() => {
10✔
1770
                                return checkout(cloneTempDir.path, importBranch)
10✔
1771
                                        .then(() => {
1772
                                                return removeGitDir();
10✔
1773
                                        })
1774
                                        .then(() => {
1775
                                                return initGitDir();
10✔
1776
                                        })
1777
                                        .then(() => {
1778
                                                return changeGitRemote();
10✔
1779
                                        })
1780
                                        .then(() => {
1781
                                                return addFilesToRepo();
10✔
1782
                                        })
1783
                                        .then(() => {
1784
                                                return pushToNewRepo();
10✔
1785
                                        })
1786
                                        .then(() => {
1787
                                                Log.info("GitHubAction::cloneRepo() - done; took: " + Util.took(start));
10✔
1788
                                                cloneTempDir.cleanup();
10✔
1789
                                                return Promise.resolve(true); // made it cleanly
10✔
1790
                                        })
1791
                                        .catch((err: any) => {
1792
                                                /* istanbul ignore next */
1793
                                                Log.error("GitHubAction::cloneRepo() - ERROR: " + err);
1794
                                                cloneTempDir.cleanup();
×
1795
                                                return Promise.reject(err);
×
1796
                                        });
1797
                        });
1798
                }
1799

1800
                function moveFiles(originPath: string, filesLocation: string, destPath: string) {
1801
                        Log.info(
7✔
1802
                                "GitHubActions::importRepoFS(..)::moveFiles( " + originPath + ", " + filesLocation + ", " + destPath + ") - moving files"
1803
                        );
1804
                        return exec(`cp -r ${originPath}/${filesLocation} ${destPath}`).then(function (result: any) {
7✔
1805
                                Log.info("GitHubActions::importRepoFS(..)::moveFiles(..) - done");
7✔
1806
                                that.reportStdOut(result.stdout, "GitHubActions::importRepoFS(..)::moveFiles(..)");
7✔
1807
                                that.reportStdErr(result.stderr, "importRepoFS(..)::moveFiles(..)");
7✔
1808
                        });
1809
                }
1810

1811
                function cloneRepo(repoPath: string) {
1812
                        Log.info("GitHubActions::importRepoFS(..)::cloneRepo() - cloning: " + importRepo);
17✔
1813
                        return exec(`git clone -q ${authedImportRepo} ${repoPath}`).then(function (result: any) {
17✔
1814
                                Log.info("GitHubActions::importRepoFS(..)::cloneRepo() - done");
17✔
1815
                                that.reportStdOut(result.stdout, "GitHubActions::importRepoFS(..)::cloneRepo()");
17✔
1816
                                that.reportStdErr(result.stderr, "importRepoFS(..)::cloneRepo()");
17✔
1817
                        });
1818
                }
1819

1820
                function checkout(repoPath: string, branch: string) {
1821
                        if (typeof branch === "string" && branch !== "") {
17✔
1822
                                Log.info(`GitHubActions::importRepoFS(..)::checkout() - Checking out "${branch}"`);
9✔
1823
                                return exec(`cd ${repoPath} && git checkout ${branch}`).then(function (result: any) {
9✔
1824
                                        Log.info("GitHubActions::importRepoFS(..)::checkout() - done");
9✔
1825
                                        that.reportStdOut(result.stdout, "GitHubActions::importRepoFS(..)::checkout()");
9✔
1826
                                        that.reportStdErr(result.stderr, "importRepoFS(..)::checkout()");
9✔
1827
                                });
1828
                        } else {
1829
                                Log.info(`GitHubActions::importRepoFS(..)::checkout() - Using default branch`);
8✔
1830
                                return Promise.resolve();
8✔
1831
                        }
1832
                }
1833

1834
                function removeGitDir() {
1835
                        Log.info("GitHubActions::importRepoFS(..)::removeGitDir() - removing .git from cloned repo");
17✔
1836
                        return exec(`cd ${cloneTempDir.path} && rm -rf .git`).then(function (result: any) {
17✔
1837
                                Log.info("GitHubActions::importRepoFS(..)::removeGitDir() - done");
17✔
1838
                                that.reportStdOut(result.stdout, "GitHubActions::importRepoFS(..)::removeGitDir()");
17✔
1839
                                that.reportStdErr(result.stderr, "importRepoFS(..)::removeGitDir()");
17✔
1840
                        });
1841
                }
1842

1843
                function initGitDir() {
1844
                        Log.info("GitHubActions::importRepoFS(..)::initGitDir() - start");
17✔
1845
                        return exec(`cd ${cloneTempDir.path} && git init -q && git branch -m main`).then(function (result: any) {
17✔
1846
                                Log.info("GitHubActions::importRepoFS(..)::initGitDir() - done");
17✔
1847
                                that.reportStdOut(result.stdout, "GitHubActions::importRepoFS(..)::initGitDir()");
17✔
1848
                                that.reportStdErr(result.stderr, "importRepoFS(..)::initGitDir()");
17✔
1849
                        });
1850
                }
1851

1852
                function changeGitRemote() {
1853
                        Log.info("GitHubActions::importRepoFS(..)::changeGitRemote() - start");
17✔
1854
                        const command = `cd ${cloneTempDir.path} && git remote add origin ${authedStudentRepo}.git && git fetch --all -q`;
17✔
1855
                        return exec(command).then(function (result: any) {
17✔
1856
                                Log.info("GitHubActions::importRepoFS(..)::changeGitRemote() - done");
17✔
1857
                                that.reportStdOut(result.stdout, "GitHubActions::importRepoFS(..)::changeGitRemote()");
17✔
1858
                                that.reportStdErr(result.stderr, "importRepoFS(..)::changeGitRemote()");
17✔
1859
                        });
1860
                }
1861

1862
                function addFilesToRepo() {
1863
                        Log.info("GitHubActions::importRepoFS(..)::addFilesToRepo() - start");
17✔
1864
                        // tslint:disable-next-line
1865
                        const command = `cd ${cloneTempDir.path} && git config user.email "classy@cs.ubc.ca" && git config user.name "classy" && git add . && git commit -q -m "Starter files"`;
17✔
1866
                        return exec(command).then(function (result: any) {
17✔
1867
                                Log.info("GitHubActions::importRepoFS(..)::addFilesToRepo() - done");
17✔
1868
                                that.reportStdOut(result.stdout, "GitHubActions::importRepoFS(..)::addFilesToRepo()");
17✔
1869
                                that.reportStdErr(result.stderr, "importRepoFS(..)::addFilesToRepo()");
17✔
1870
                        });
1871
                }
1872

1873
                function pushToNewRepo() {
1874
                        const pushStart = Date.now();
17✔
1875
                        Log.info("GitHubActions::importRepoFS(..)::pushToNewRepo() - start");
17✔
1876
                        const command = `cd ${cloneTempDir.path} && git push -q origin main`;
17✔
1877
                        return exec(command).then(function (result: any) {
17✔
1878
                                Log.info("GitHubActions::importRepoFS(..)::pushToNewRepo() - done; took: " + Util.took(pushStart));
17✔
1879
                                that.reportStdOut(result.stdout, "GitHubActions::importRepoFS(..)::pushToNewRepo()");
17✔
1880
                                that.reportStdErr(result.stderr, "importRepoFS(..)::pushToNewRepo()");
17✔
1881
                        });
1882
                }
1883

1884
                // not used and not tested; trying graceful cleanup instead
1885
                // function removeTempPath() {
1886
                //     Log.info("GitHubActions::importRepoFS(..)::removeTempPath() - start");
1887
                //     const command = `rm -rf ${tempPath}`;
1888
                //     return exec(command)
1889
                //         .then(function(result: any) {
1890
                //             Log.info("GitHubActions::importRepoFS(..)::removeTempPath() - done ");
1891
                //             Log.trace("GitHubActions::importRepoFS(..)::removeTempPath() - stdout: " + result.stdout);
1892
                //             that.reportStdErr(result.stderr, "importRepoFS(..)::removeTempPath()");
1893
                //         });
1894
                // }
1895
        }
1896

1897
        public addGithubAuthToken(url: string) {
1898
                const startAppend = url.indexOf("//") + 2;
5✔
1899
                const token = this.gitHubAuthToken;
5✔
1900
                const authKey = token.substring(token.indexOf("token ") + 6) + "@";
5✔
1901
                // creates "longokenstring@githuburi"
1902
                return url.slice(0, startAppend) + authKey + url.slice(startAppend);
5✔
1903
        }
1904

1905
        /**
1906
         * Adds a file with the data given, to the specified repository.
1907
         * If force is set to true, will overwrite old files
1908
         * @param {string} repoURL - name of repository
1909
         * @param {string} fileName - name of file to write
1910
         * @param {string} fileContent - the content of the file to write to repo
1911
         * @param {boolean} force - allow for overwriting of old files
1912
         * @returns {Promise<boolean>} - true if write was successful
1913
         */
1914
        public async writeFileToRepo(repoURL: string, fileName: string, fileContent: string, force?: boolean): Promise<boolean> {
1915
                Log.info("GithubAction::writeFileToRepo( " + repoURL + " , " + fileName + "" + " , " + fileContent + " , " + force + " ) - start");
5✔
1916
                const that = this;
5✔
1917

1918
                if (typeof force === "undefined") {
5✔
1919
                        force = false;
2✔
1920
                }
1921
                // TAKEN FROM importFS
1922

1923
                // generate temp path
1924
                const exec = require("child-process-promise").exec;
5✔
1925
                const tempDir = await tmp.dir({ dir: "/tmp", unsafeCleanup: true });
5✔
1926
                const tempPath = tempDir.path;
5✔
1927
                const authedRepo = this.addGithubAuthToken(repoURL);
5✔
1928

1929
                // clone repository
1930
                try {
5✔
1931
                        await cloneRepo(tempPath);
5✔
1932
                        await enterRepoPath();
3✔
1933
                        if (force) {
3✔
1934
                                await createNewFileForce();
2✔
1935
                        } else {
1936
                                await createNewFile();
1✔
1937
                        }
1938
                        await addFilesToRepo();
3✔
1939
                        try {
3✔
1940
                                await commitFilesToRepo();
3✔
1941
                        } catch (err) {
1942
                                Log.warn("GithubActions::writeFileToRepo(..) - No file differences; " + "Did not write file to repo");
×
1943
                                // this only fails when the files have not changed,
1944
                                return true; // we technically "wrote" the file still
×
1945
                        }
1946
                        await pushToRepo();
3✔
1947
                } catch (err) {
1948
                        Log.error("GithubActions::writeFileToRepo(..) - Error: " + err);
2✔
1949
                        return false;
2✔
1950
                }
1951

1952
                return true;
3✔
1953

1954
                function cloneRepo(repoPath: string) {
1955
                        const cloneStart = Date.now();
5✔
1956
                        Log.info("GitHubActions::writeFileToRepo(..)::cloneRepo() - cloning: " + repoURL);
5✔
1957
                        return exec(`git clone -q ${authedRepo} ${repoPath}`).then(function (result: any) {
5✔
1958
                                Log.info("GitHubActions::writeFileToRepo(..)::cloneRepo() - done; took: " + Util.took(cloneStart));
3✔
1959
                                that.reportStdOut(result.stdout, "GitHubActions::writeFileToRepo(..)::cloneRepo()");
3✔
1960
                                // if (result.stderr) {
1961
                                //     Log.warn("GitHubActions::writeFileToRepo(..)::cloneRepo() - stderr: " + result.stderr);
1962
                                // }
1963
                                that.reportStdErr(result.stderr, "writeFileToRepo(..)::cloneRepo()");
3✔
1964
                        });
1965
                }
1966

1967
                function enterRepoPath() {
1968
                        Log.info("GitHubActions::writeFileToRepo(..)::enterRepoPath() - entering: " + tempPath);
3✔
1969
                        return exec(`cd ${tempPath}`).then(function (result: any) {
3✔
1970
                                Log.info("GitHubActions::writeFileToRepo(..)::enterRepoPath() - done");
3✔
1971
                                that.reportStdOut(result.stdout, "GitHubActions::writeFileToRepo(..)::enterRepoPath()");
3✔
1972
                                that.reportStdErr(result.stderr, "writeFileToRepo(..)::enterRepoPath()");
3✔
1973
                        });
1974
                }
1975

1976
                function createNewFileForce() {
1977
                        Log.info("GitHubActions::writeFileToRepo(..)::createNewFileForce() - writing: " + fileName);
2✔
1978
                        return exec(`cd ${tempPath} && if [ -f ${fileName} ]; then rm ${fileName};  fi; echo "${fileContent}" >> ${fileName};`).then(
2✔
1979
                                function (result: any) {
1980
                                        Log.info("GitHubActions::writeFileToRepo(..)::createNewFileForce() - done");
2✔
1981
                                        that.reportStdOut(result.stdout, "GitHubActions::writeFileToRepo(..)::createNewFileForce()");
2✔
1982
                                        that.reportStdErr(result.stderr, "writeFileToRepo(..)::createNewFileForce()");
2✔
1983
                                }
1984
                        );
1985
                }
1986

1987
                function createNewFile() {
1988
                        Log.info("GitHubActions::writeFileToRepo(..)::createNewFile() - writing: " + fileName);
1✔
1989
                        return exec(`cd ${tempPath} && if [ ! -f ${fileName} ]; then echo \"${fileContent}\" >> ${fileName};fi`).then(function (
1✔
1990
                                result: any
1991
                        ) {
1992
                                Log.info("GitHubActions::writeFileToRepo(..)::createNewFile() - done");
1✔
1993
                                that.reportStdOut(result.stdout, "GitHubActions::writeFileToRepo(..)::createNewFile()");
1✔
1994
                                that.reportStdErr(result.stderr, "writeFileToRepo(..)::createNewFile()");
1✔
1995
                        });
1996
                }
1997

1998
                function addFilesToRepo() {
1999
                        Log.info("GitHubActions::writeFileToRepo(..)::addFilesToRepo() - start");
3✔
2000
                        const command = `cd ${tempPath} && git add ${fileName}`;
3✔
2001
                        return exec(command).then(function (result: any) {
3✔
2002
                                Log.info("GitHubActions::writeFileToRepo(..)::addFilesToRepo() - done");
3✔
2003
                                that.reportStdOut(result.stdout, "GitHubActions::writeFileToRepo(..)::addFilesToRepo()");
3✔
2004
                                that.reportStdErr(result.stderr, "writeFileToRepo(..)::addFilesToRepo()");
3✔
2005
                        });
2006
                }
2007

2008
                function commitFilesToRepo() {
2009
                        Log.info("GitHubActions::writeFileToRepo(..)::commitFilesToRepo() - start");
3✔
2010
                        const command = `cd ${tempPath} && git commit -q -m "Update ${fileName}"`;
3✔
2011
                        return exec(command).then(function (result: any) {
3✔
2012
                                Log.info("GitHubActions::writeFileToRepo(..)::commitFilesToRepo() - done");
3✔
2013
                                that.reportStdOut(result.stdout, "GitHubActions::writeFileToRepo(..)::commitFilesToRepo()");
3✔
2014
                                that.reportStdErr(result.stderr, "writeFileToRepo(..)::commitFilesToRepo()");
3✔
2015
                        });
2016
                }
2017

2018
                function pushToRepo() {
2019
                        Log.info("GitHubActions::writeFileToRepo(..)::pushToRepo() - start");
3✔
2020
                        const command = `cd ${tempPath} && git push -q`;
3✔
2021
                        return exec(command).then(function (result: any) {
3✔
2022
                                Log.info("GitHubActions::writeFileToRepo(..)::pushToNewRepo() - done");
3✔
2023
                                that.reportStdOut(result.stdout, "GitHubActions::writeFileToRepo(..)::pushToNewRepo()");
3✔
2024
                                that.reportStdErr(result.stderr, "writeFileToRepo(..)::pushToNewRepo()");
3✔
2025
                        });
2026
                }
2027
        }
2028

2029
        /**
2030
         * Changes permissions for all teams for the given repository
2031
         * @param {string} repoName
2032
         * @param {string} permission - one of: "push" "pull"
2033
         * @returns {Promise<boolean>}
2034
         */
2035
        public async setRepoPermission(repoName: string, permission: string): Promise<boolean> {
2036
                Log.info("GithubAction::setRepoPermission( " + repoName + ", " + permission + " ) - start");
3✔
2037

2038
                try {
3✔
2039
                        // Check if permission is one of: {push, pull}
2040
                        // We do not want to be able to grant a team admin access!
2041
                        if (permission !== "pull" && permission !== "push") {
3✔
2042
                                const msg = "GitHubAction::setRepoPermission(..) - ERROR, Invalid permission: " + permission;
1✔
2043
                                Log.error(msg);
1✔
2044
                                throw new Error(msg);
1✔
2045
                        }
2046

2047
                        // Make sure the repo exists
2048
                        // tslint:disable-next-line:no-floating-promises
2049
                        const repoExists = await this.repoExists(repoName);
2✔
2050
                        if (repoExists) {
2✔
2051
                                Log.info("GitHubAction::setRepoPermission(..) - repo exists");
1✔
2052
                                Log.info("GitHubAction::setRepoPermission(..) - getting teams associated with repo");
1✔
2053
                                const teamsUri = this.apiPath + "/repos/" + this.org + "/" + repoName + "/teams";
1✔
2054
                                Log.trace("GitHubAction::setRepoPermission(..) - URI: " + teamsUri);
1✔
2055
                                const teamOptions: RequestInit = {
1✔
2056
                                        method: "GET",
2057
                                        headers: {
2058
                                                Authorization: this.gitHubAuthToken,
2059
                                                "User-Agent": this.gitHubUserName,
2060
                                                Accept: "application/json",
2061
                                        },
2062
                                };
2063

2064
                                // Change each team"s permission
2065
                                // tslint:disable-next-line:no-floating-promises
2066
                                const response = await fetch(teamsUri, teamOptions); // .then(function(responseData: any) {
1✔
2067
                                const body = await response.json();
1✔
2068
                                Log.info("GitHubAction::setRepoPermission(..) - setting permission for teams on repo");
1✔
2069
                                for (const team of body) {
1✔
2070
                                        // Do not change teams that have admin permission
2071
                                        if (team.permission !== "admin") {
2!
2072
                                                Log.info("GitHubAction::setRepoPermission(..) - set team: " + team.name + " to " + permission);
2✔
2073
                                                const permissionUri = this.apiPath + "/teams/" + team.id + "/repos/" + this.org + "/" + repoName;
2✔
2074
                                                Log.trace("GitHubAction::setRepoPermission(..) - URI: " + permissionUri);
2✔
2075
                                                const permissionOptions: RequestInit = {
2✔
2076
                                                        method: "PUT",
2077
                                                        headers: {
2078
                                                                Authorization: this.gitHubAuthToken,
2079
                                                                "User-Agent": this.gitHubUserName,
2080
                                                                Accept: "application/json",
2081
                                                        },
2082
                                                        body: JSON.stringify({
2083
                                                                permission: permission,
2084
                                                        }),
2085
                                                };
2086

2087
                                                await fetch(permissionUri, permissionOptions); // TODO: evaluate statusCode from this call
2✔
2088
                                                Log.info("GitHubAction::setRepoPermission(..) - changed team: " + team.id + " permissions");
2✔
2089
                                        }
2090
                                }
2091
                                return true;
1✔
2092
                        } else {
2093
                                Log.info("GitHubAction::setRepoPermission(..) - repo does not exist; unable to revoke push");
1✔
2094
                                return false;
1✔
2095
                        }
2096
                } catch (err) {
2097
                        // If we get an error; something went wrong
2098
                        Log.error("GitHubAction::setRepoPermission(..) - ERROR: " + err.message);
1✔
2099
                        throw err;
1✔
2100
                }
2101
        }
2102

2103
        /**
2104
         * Adds a single branch protection rule to a repo
2105
         * @param repoId
2106
         * @param rule
2107
         */
2108
        public async addBranchProtectionRule(repoId: string, rule: BranchRule): Promise<boolean> {
2109
                // TODO this code has no unit tests
2110
                Log.info("GitHubAction::addBranchProtectionRule(..) - start; repo:", repoId, "; branch:", rule.name);
1✔
2111
                const start = Date.now();
1✔
2112
                try {
1✔
2113
                        const uri = `${this.apiPath}/repos/${this.org}/${repoId}/branches/${rule.name}/protection`;
1✔
2114
                        const body = JSON.stringify({
1✔
2115
                                required_status_checks: null,
2116
                                enforce_admins: null,
2117
                                required_pull_request_reviews: {
2118
                                        dismissal_restrictions: {},
2119
                                        dismiss_stale_reviews: true,
2120
                                        require_code_owner_reviews: false,
2121
                                        required_approving_review_count: rule.reviews,
2122
                                },
2123
                                restrictions: null,
2124
                        });
2125
                        const options: RequestInit = {
1✔
2126
                                method: "PUT",
2127
                                headers: {
2128
                                        Authorization: this.gitHubAuthToken,
2129
                                        "User-Agent": this.gitHubUserName,
2130
                                        // TODO this API is being used in a beta state. Get off the beta!
2131
                                        // https://developer.github.com/enterprise/2.19/v3/repos/branches/#update-branch-protection
2132
                                        Accept: "application/vnd.github.luke-cage-preview+json",
2133
                                },
2134
                                body,
2135
                        };
2136
                        await fetch(uri, options);
1✔
2137
                        Log.info("GitHubAction::addBranchProtectionRule(", repoId, ",", rule.name, ") - Success! took: ", Util.took(start));
1✔
2138
                        return true;
1✔
2139
                } catch (err) {
2140
                        Log.warn("GitHubAction::addBranchProtectionRule(", repoId, ",", rule.name, ") - ERROR:", err.message);
×
2141
                }
2142
                return false;
×
2143
        }
2144

2145
        /**
2146
         * Creates a new issue on the specified repo
2147
         * @param repoId
2148
         * @param issue
2149
         */
2150
        public async makeIssue(repoId: string, issue: Issue): Promise<boolean> {
2151
                Log.info("GitHubAction::makeIssue(..) - start; repo:", repoId, "; issue:", issue.title);
1✔
2152
                const start = Date.now();
1✔
2153
                try {
1✔
2154
                        const uri = `${this.apiPath}/repos/${this.org}/${repoId}/issues`;
1✔
2155
                        const body = JSON.stringify({
1✔
2156
                                title: issue.title,
2157
                                body: issue.body,
2158
                        });
2159
                        const options: RequestInit = {
1✔
2160
                                method: "POST",
2161
                                headers: {
2162
                                        Authorization: this.gitHubAuthToken,
2163
                                        "User-Agent": this.gitHubUserName,
2164
                                        Accept: "application/json",
2165
                                },
2166
                                body,
2167
                        };
2168
                        await fetch(uri, options);
1✔
2169
                        Log.info("GitHubAction::makeIssue(", repoId, ",", issue.title, ") - Success! took: ", Util.took(start));
1✔
2170
                        return true;
1✔
2171
                } catch (err) {
2172
                        Log.warn("GitHubAction::makeIssue(", repoId, ",", issue.title, ") - ERROR:", err.message);
×
2173
                }
2174
                return false;
×
2175
        }
2176

2177
        public async simulateWebhookComment(projectName: string, sha: string, message: string): Promise<boolean> {
2178
                try {
6✔
2179
                        if (typeof projectName === "undefined" || projectName === null) {
6✔
2180
                                Log.error("GitHubActions::simulateWebhookComment(..)  - url is required");
1✔
2181
                                return Promise.resolve(false);
1✔
2182
                        }
2183

2184
                        if (typeof sha === "undefined" || sha === null) {
5✔
2185
                                Log.error("GitHubActions::simulateWebhookComment(..)  - sha is required");
1✔
2186
                                return Promise.resolve(false);
1✔
2187
                        }
2188

2189
                        if (typeof message === "undefined" || message === null) {
4✔
2190
                                Log.error("GitHubActions::simulateWebhookComment(..)  - message is required");
1✔
2191
                                return Promise.resolve(false);
1✔
2192
                        }
2193

2194
                        // find a better short string for logging
2195
                        let messageToPrint = message;
3✔
2196
                        if (messageToPrint.indexOf("\n") > 0) {
3✔
2197
                                messageToPrint = messageToPrint.substring(0, messageToPrint.indexOf("\n"));
1✔
2198
                        }
2199
                        if (messageToPrint.length > 80) {
3✔
2200
                                messageToPrint = messageToPrint.substring(0, 80) + "...";
1✔
2201
                        }
2202

2203
                        Log.info(
3✔
2204
                                "GitHubActions::simulateWebhookComment(..) - Simulating comment to project: " +
2205
                                        projectName +
2206
                                        "; sha: " +
2207
                                        sha +
2208
                                        "; message: " +
2209
                                        messageToPrint
2210
                        );
2211

2212
                        const c = Config.getInstance();
3✔
2213
                        // const repoUrl = "https://github.ugrad.cs.ubc.ca/CPSC310-2018W-T1/project_r2d2_c3p0";
2214
                        const repoUrl = c.getProp(ConfigKey.githubHost) + "/" + c.getProp(ConfigKey.org) + "/" + projectName;
3✔
2215
                        // const apiUrl= "https://github.ugrad.cs.ubc.ca/api/v3/repos/CPSC310-2018W-T1/project_r2d2_c3p0";
2216
                        const apiUrl = c.getProp(ConfigKey.githubAPI) + "/api/v3/repos/" + c.getProp(ConfigKey.org) + "/" + projectName;
3✔
2217

2218
                        const body = {
3✔
2219
                                comment: {
2220
                                        commit_id: sha,
2221
                                        // tslint:disable-next-line
2222
                                        html_url: repoUrl + "/commit/" + sha + "#fooWillBeStripped", // https://github.ugrad.cs.ubc.ca/CPSC310-2018W-T1/project_r2d2_c3p0/commit/82ldl2731c665c364ad979c9135688d1c206462c#commitcomment-285811"
2223
                                        user: {
2224
                                                login: Config.getInstance().getProp(ConfigKey.botName), // userId // autobot
2225
                                        },
2226
                                        body: message,
2227
                                },
2228
                                repository: {
2229
                                        // tslint:disable-next-line
2230
                                        commits_url: apiUrl + "/commits{/sha}", // https://github.ugrad.cs.ubc.ca/api/v3/repos/CPSC310-2018W-T1/project_r2d2_c3p0/commits{/sha}
2231
                                        clone_url: repoUrl + ".git", // https://github.ugrad.cs.ubc.ca/CPSC310-2018W-T1/project_r2d2_c3p0.git
2232
                                        name: projectName,
2233
                                },
2234
                        };
2235

2236
                        const urlToSend = Config.getInstance().getProp(ConfigKey.publichostname) + "/portal/githubWebhook";
3✔
2237
                        Log.info("GitHubService::simulateWebhookComment(..) - url: " + urlToSend + "; body: " + JSON.stringify(body));
3✔
2238

2239
                        const options: RequestInit = {
3✔
2240
                                method: "POST",
2241
                                headers: {
2242
                                        "Content-Type": "application/json",
2243
                                        "User-Agent": "UBC-AutoTest",
2244
                                        "X-GitHub-Event": "commit_comment",
2245
                                        Authorization: Config.getInstance().getProp(ConfigKey.githubBotToken), // TODO: support auth from github
2246
                                },
2247
                                body: JSON.stringify(body),
2248
                        };
2249

2250
                        if (Config.getInstance().getProp(ConfigKey.postback) === true) {
3!
2251
                                try {
3✔
2252
                                        await fetch(urlToSend, options); // NOTE: should we check return?
3✔
2253
                                        Log.trace("GitHubService::simulateWebhookComment(..) - success"); // : " + res);
×
2254
                                        return Promise.resolve(true);
×
2255
                                } catch (err) {
2256
                                        Log.error("GitHubService::simulateWebhookComment(..) - ERROR: " + err);
3✔
2257
                                        return Promise.resolve(false);
3✔
2258
                                }
2259
                        } else {
2260
                                Log.trace("GitHubService::simulateWebhookComment(..) - send skipped (config.postback === false)");
×
2261
                                return Promise.resolve(true);
×
2262
                        }
2263
                } catch (err) {
2264
                        Log.error("GitHubService::simulateWebhookComment(..) - ERROR: " + err);
×
2265
                        return Promise.resolve(false);
×
2266
                }
2267
        }
2268

2269
        public async makeComment(url: string, message: string): Promise<boolean> {
2270
                try {
4✔
2271
                        if (typeof url === "undefined" || url === null) {
4✔
2272
                                Log.error("GitHubActions::makeComment(..)  - message.url is required");
1✔
2273
                                return Promise.resolve(false);
1✔
2274
                        }
2275

2276
                        if (typeof message === "undefined" || message === null || message.length < 1) {
3✔
2277
                                Log.error("GitHubActions::makeComment(..)  - message.message is required");
1✔
2278
                                return Promise.resolve(false);
1✔
2279
                        }
2280

2281
                        // find a better short string for logging
2282
                        let messageToPrint = message;
2✔
2283
                        if (messageToPrint.indexOf("\n") > 0) {
2✔
2284
                                messageToPrint = messageToPrint.substring(0, messageToPrint.indexOf("\n"));
1✔
2285
                        }
2286
                        if (messageToPrint.length > 80) {
2✔
2287
                                messageToPrint = messageToPrint.substring(0, 80) + "...";
1✔
2288
                        }
2289

2290
                        Log.info("GitHubActions::makeComment(..) - Posting markdown to url: " + url + "; message: " + messageToPrint);
2✔
2291

2292
                        const body = { body: message };
2✔
2293
                        const options: RequestInit = {
2✔
2294
                                method: "POST",
2295
                                headers: {
2296
                                        "Content-Type": "application/json",
2297
                                        "User-Agent": "UBC-AutoTest",
2298
                                        Authorization: Config.getInstance().getProp(ConfigKey.githubBotToken),
2299
                                },
2300
                                body: JSON.stringify(body),
2301
                        };
2302

2303
                        Log.trace("GitHubService::makeComment(..) - url: " + url);
2✔
2304

2305
                        if (Config.getInstance().getProp(ConfigKey.postback) === true) {
2!
2306
                                try {
2✔
2307
                                        const res = await fetch(url, options);
2✔
2308
                                        if (res.status === 201) {
2✔
2309
                                                Log.trace("GitHubService::makeComment(..) - success");
1✔
2310
                                                return Promise.resolve(true);
1✔
2311
                                        } else {
2312
                                                Log.trace("GitHubService::makeComment(..) - failed; code: " + res.status);
1✔
2313
                                                return Promise.resolve(false);
1✔
2314
                                        }
2315
                                } catch (err) {
2316
                                        Log.error("GitHubService::makeComment(..) - ERROR: " + err);
×
2317
                                        return Promise.resolve(false);
×
2318
                                }
2319
                        } else {
2320
                                Log.trace("GitHubService::makeComment(..) - send skipped (config.postback === false)");
×
2321
                                return Promise.resolve(true);
×
2322
                        }
2323
                } catch (err) {
2324
                        Log.error("GitHubService::makeComment(..) - ERROR: " + err);
×
2325
                        return Promise.resolve(false);
×
2326
                }
2327
        }
2328

2329
        public async getTeamsOnRepo(repoId: string): Promise<GitTeamTuple[]> {
2330
                // GET /repos/:owner/:repo/teams
2331
                Log.trace("GitHubActions::getTeamsOnRepo( " + repoId + " ) - start");
29✔
2332
                const start = Date.now();
29✔
2333
                const uri = this.apiPath + "/repos/" + this.org + "/" + repoId + "/teams";
29✔
2334
                const options: RequestInit = {
29✔
2335
                        method: "GET",
2336
                        headers: {
2337
                                Authorization: this.gitHubAuthToken,
2338
                                "User-Agent": this.gitHubUserName,
2339
                                Accept: "application/json",
2340
                        },
2341
                };
2342

2343
                try {
29✔
2344
                        const response = await fetch(uri, options);
29✔
2345
                        const results = await response.json();
29✔
2346
                        Log.trace("GitHubAction::getTeamsOnRepo( " + repoId + " ) - response received");
29✔
2347

2348
                        const toReturn: GitTeamTuple[] = [];
29✔
2349
                        for (const result of results) {
29✔
2350
                                toReturn.push({ teamName: result.name, githubTeamNumber: result.id });
5✔
2351
                        }
2352

2353
                        Log.trace("GitHubAction::getTeamsOnRepo( " + repoId + " ) - done; # teams: " + toReturn.length + "; took: " + Util.took(start));
28✔
2354
                        return toReturn;
28✔
2355
                } catch (err) {
2356
                        Log.trace("GitHubAction::getTeamsOnRepo( " + repoId + " ) - failed; took: " + Util.took(start));
1✔
2357
                        return [];
1✔
2358
                }
2359
        }
2360

2361
        private async handlePagination(uri: string, options: RequestInit): Promise<object[]> {
2362
                Log.trace("GitHubActions::handlePagination(..) - start; PAGE_SIZE: " + this.pageSize);
64✔
2363
                const start = Date.now();
64✔
2364

2365
                try {
64✔
2366
                        Log.trace("GitHubActions::handlePagination(..) - requesting: " + uri);
64✔
2367
                        let response = await fetch(uri, options);
64✔
2368
                        let body = await response.json();
64✔
2369
                        let results: any[] = body; // save the first page of values
64✔
2370

2371
                        if (response.headers.has("link") === false) {
64✔
2372
                                // single page, save the results and keep going
2373
                                Log.trace("GitHubActions::handlePagination(..) - single page");
54✔
2374
                        } else {
2375
                                Log.trace("GitHubActions::handlePagination(..) - multiple pages");
10✔
2376

2377
                                let linkText = response.headers.get("link");
10✔
2378
                                Log.trace("GitHubActions::handlePagination(..) - outer linkText: " + linkText);
10✔
2379
                                let links = parseLinkHeader(linkText);
10✔
2380
                                Log.trace("GitHubActions::handlePagination(..) - outer parsed Links: " + JSON.stringify(links));
10✔
2381

2382
                                // when on the last page links.last will not be present
2383
                                while (typeof links.last !== "undefined") {
10✔
2384
                                        // process current body
2385
                                        uri = links.next.url;
39✔
2386
                                        Log.trace("GitHubActions::handlePagination(..) - requesting: " + uri);
39✔
2387

2388
                                        // NOTE: this needs to be slowed down to prevent DNS problems
2389
                                        // (issuing 10+ concurrent dns requests can be problematic)
2390
                                        await Util.delay(100);
39✔
2391

2392
                                        response = await fetch(uri, options);
39✔
2393
                                        body = await response.json();
39✔
2394
                                        results = results.concat(body); // append subsequent pages of values to the first page
39✔
2395

2396
                                        linkText = response.headers.get("link");
39✔
2397
                                        Log.trace("GitHubActions::handlePagination(..) - inner linkText: " + linkText);
39✔
2398
                                        links = parseLinkHeader(linkText);
39✔
2399
                                        Log.trace("GitHubActions::handlePagination(..) - parsed Links: " + JSON.stringify(links));
39✔
2400
                                }
2401
                        }
2402

2403
                        if (typeof (results as any).message !== "undefined" && (results as any).message === "Bad credentials") {
64!
2404
                                // This is an odd place for this check, but seems like
2405
                                // a good canary for uncovering credential problems
2406
                                Log.error("GitHubActions::handlePagination(..) - Bad Credentials encountered");
×
2407
                                Log.error("GitHubActions::handlePagination(..) - .env GH_BOT_TOKEN is incorrect"); // probably
×
2408
                                return [];
×
2409
                        }
2410

2411
                        Log.trace("GitHubActions::handlePagination(..) - done; elements: " + results.length + "; took: " + Util.took(start));
64✔
2412
                        return results;
64✔
2413
                } catch (err) {
2414
                        Log.error("GitHubActions::handlePagination(..) - ERROR: " + err.message);
×
2415
                        return [];
×
2416
                }
2417
        }
2418

2419
        private reportStdOut(stdout: any, prefix: string) {
2420
                if (stdout) {
136✔
2421
                        Log.warn("GitHubActions::stdOut(..) - " + prefix + ": " + stdout);
9✔
2422
                }
2423
        }
2424

2425
        private reportStdErr(stderr: any, prefix: string) {
2426
                if (stderr) {
136✔
2427
                        Log.warn("GitHubActions::stdErr(..) - " + prefix + ": " + stderr);
9✔
2428
                }
2429
        }
2430
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc