From d1f00b897c55bf01c8745daff21e80222ffd27c6 Mon Sep 17 00:00:00 2001 From: Joey Parrish Date: Tue, 25 Jan 2022 10:17:47 -0800 Subject: [PATCH] ci: Add tests for new update-issues tool (#3900) This ports over internal tests suggested in #3889. --- .github/workflows/README.md | 2 + .github/workflows/test_update_issues.yaml | 28 + .github/workflows/tools/update-issues/main.js | 75 ++- .../workflows/tools/update-issues/mocks.js | 129 +++++ .../tools/update-issues/package-lock.json | 196 ++++++- .../tools/update-issues/package.json | 5 +- .../workflows/tools/update-issues/reporter.js | 55 ++ .../workflows/tools/update-issues/tests.js | 500 ++++++++++++++++++ 8 files changed, 967 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/test_update_issues.yaml create mode 100644 .github/workflows/tools/update-issues/mocks.js create mode 100644 .github/workflows/tools/update-issues/reporter.js create mode 100644 .github/workflows/tools/update-issues/tests.js diff --git a/.github/workflows/README.md b/.github/workflows/README.md index e13f7f254e..1b0cc0e299 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -5,3 +5,5 @@ Lints the player, and builds and tests each combination of OS and browser. - 'update_issues.yaml': Updates GitHub issues on a timer. + - 'test_update_issues.yaml': + Runs tests on the update-issues tool when it changes. diff --git a/.github/workflows/test_update_issues.yaml b/.github/workflows/test_update_issues.yaml new file mode 100644 index 0000000000..1c9540ad94 --- /dev/null +++ b/.github/workflows/test_update_issues.yaml @@ -0,0 +1,28 @@ +name: Test Update Issues Tool + +on: + pull_request: # Trigger for pull requests. + types: [opened, synchronize, reopened] + paths: + .github/workflows/tools/update-issues/** + workflow_dispatch: # Allows for manual triggering. + inputs: + ref: + description: "The ref to build and test." + required: False + +jobs: + test: + name: Test Update Issues Tool + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + with: + ref: ${{ github.event.inputs.ref || github.ref }} + + - name: Test + run: | + cd .github/workflows/tools/update-issues + npm install + npm test diff --git a/.github/workflows/tools/update-issues/main.js b/.github/workflows/tools/update-issues/main.js index d0856670bd..e2ff1bcb3b 100644 --- a/.github/workflows/tools/update-issues/main.js +++ b/.github/workflows/tools/update-issues/main.js @@ -12,14 +12,19 @@ const core = require('@actions/core'); const { Issue, Milestone } = require('./issues.js'); const TYPE_ACCESSIBILITY = 'type: accessibility'; +const TYPE_ANNOUNCEMENT = 'type: announcement'; const TYPE_BUG = 'type: bug'; const TYPE_CI = 'type: CI'; const TYPE_CODE_HEALTH = 'type: code health'; const TYPE_DOCS = 'type: docs'; const TYPE_ENHANCEMENT = 'type: enhancement'; const TYPE_PERFORMANCE = 'type: performance'; +const TYPE_PROCESS = 'type: process'; const TYPE_QUESTION = 'type: question'; +const PRIORITY_P0 = 'priority: P0'; +const PRIORITY_P1 = 'priority: P1'; +const PRIORITY_P2 = 'priority: P2'; const PRIORITY_P3 = 'priority: P3'; const PRIORITY_P4 = 'priority: P4'; @@ -196,24 +201,9 @@ const ALL_ISSUE_TASKS = [ maintainMilestones, ]; -async function main() { - const milestones = await Milestone.getAll(); - const issues = await Issue.getAll(); - - const backlog = milestones.find(m => m.isBacklog()); - if (!backlog) { - core.error('No backlog milestone found!'); - process.exit(1); - } +async function processIssues(issues, nextMilestone, backlog) { + let success = true; - milestones.sort(Milestone.compare); - const nextMilestone = milestones[0]; - if (nextMilestone.version == null) { - core.error('No version milestone found!'); - process.exit(1); - } - - let failed = false; for (const issue of issues) { if (issue.hasLabel(FLAG_IGNORE)) { core.info(`Ignoring issue #${issue.number}`); @@ -231,14 +221,59 @@ async function main() { core.error( `Failed to process issue #${issue.number} in task ${task.name}: ` + `${error}\n${error.stack}`); - failed = true; + success = false; } } } - if (failed) { + return success; +} + +async function main() { + const milestones = await Milestone.getAll(); + const issues = await Issue.getAll(); + + const backlog = milestones.find(m => m.isBacklog()); + if (!backlog) { + core.error('No backlog milestone found!'); + process.exit(1); + } + + milestones.sort(Milestone.compare); + const nextMilestone = milestones[0]; + if (nextMilestone.version == null) { + core.error('No version milestone found!'); + process.exit(1); + } + + const success = await processIssues(issues, nextMilestone, backlog); + if (!success) { process.exit(1); } } -main(); +// If this file is the entrypoint, run main. Otherwise, export certain pieces +// to the tests. +if (require.main == module) { + main(); +} else { + module.exports = { + processIssues, + TYPE_ACCESSIBILITY, + TYPE_ANNOUNCEMENT, + TYPE_BUG, + TYPE_CODE_HEALTH, + TYPE_DOCS, + TYPE_ENHANCEMENT, + TYPE_PROCESS, + TYPE_QUESTION, + PRIORITY_P0, + PRIORITY_P1, + PRIORITY_P2, + PRIORITY_P3, + PRIORITY_P4, + STATUS_ARCHIVED, + STATUS_WAITING, + FLAG_IGNORE, + }; +} diff --git a/.github/workflows/tools/update-issues/mocks.js b/.github/workflows/tools/update-issues/mocks.js new file mode 100644 index 0000000000..e3d1e984a2 --- /dev/null +++ b/.github/workflows/tools/update-issues/mocks.js @@ -0,0 +1,129 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Mocks for the classes in issues.js + */ + +function randomInt() { + return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); +} +let nextIssueNumber = 1; + +class MockGitHubObject { + constructor(subclassDefaults, params) { + const defaults = { + id: randomInt(), + ageInDays: 0, + closedDays: 0, + + ...subclassDefaults, + }; + + const mergedParams = { + ...defaults, + ...params, + }; + + for (const k in mergedParams) { + this[k] = mergedParams[k]; + } + } + + toString() { + return JSON.stringify(this, null, ' '); + } +} + +class MockMilestone extends MockGitHubObject { + constructor(params) { + const defaults = { + title: 'MockMilestone', + version: null, + closed: false, + isBacklog: () => false, + }; + + super(defaults, params); + } +} + +class MockComment extends MockGitHubObject { + constructor(params) { + const defaults = { + author: 'SomeUser', + body: 'Howdy!', + authorAssociation: 'NONE', + fromTeam: false, + }; + + super(defaults, params); + } +} + +class MockIssue extends MockGitHubObject { + constructor(params) { + const defaults = { + number: nextIssueNumber++, + author: 'SomeUser', + labels: [], + closed: false, + locked: false, + milestone: null, + comments: [], + }; + + super(defaults, params); + + this.getLabelAgeInDays = + jasmine.createSpy('getLabelAgeInDays') + .and.returnValue(params.labelAgeInDays || 0); + this.addLabel = jasmine.createSpy('addLabel').and.callFake((name) => { + console.log(`Adding label ${name}`); + }); + this.removeLabel = jasmine.createSpy('removeLabel').and.callFake((name) => { + console.log(`Removing label ${name}`); + }); + this.lock = jasmine.createSpy('lock').and.callFake(() => { + console.log('Locking'); + }); + this.unlock = jasmine.createSpy('unlock').and.callFake(() => { + console.log('Unlocking'); + }); + this.close = jasmine.createSpy('close').and.callFake(() => { + console.log('Closing'); + }); + this.reopen = jasmine.createSpy('reopen').and.callFake(() => { + console.log('Reopening'); + }); + this.setMilestone = + jasmine.createSpy('setMilestone').and.callFake((milestone) => { + console.log(`Setting milestone to "${milestone.title}"`); + }); + this.removeMilestone = + jasmine.createSpy('removeMilestone').and.callFake(() => { + console.log('Removing milestone.'); + }); + this.postComment = jasmine.createSpy('postComment').and.callFake((body) => { + console.log(`Posting comment: ${body}`); + }); + this.loadComments = jasmine.createSpy('loadComments'); + } + + hasLabel(name) { + return this.labels.includes(name); + } + + hasAnyLabel(names) { + return this.labels.some(l => names.includes(l)); + } +} + +module.exports = { + MockMilestone, + MockComment, + MockIssue, +}; diff --git a/.github/workflows/tools/update-issues/package-lock.json b/.github/workflows/tools/update-issues/package-lock.json index 594bc815a4..8e74a8e9fe 100644 --- a/.github/workflows/tools/update-issues/package-lock.json +++ b/.github/workflows/tools/update-issues/package-lock.json @@ -6,7 +6,8 @@ "": { "devDependencies": { "@actions/core": "^1.6.0", - "@actions/github": "^5.0.0" + "@actions/github": "^5.0.0", + "jasmine": "^4.0.2" } }, "node_modules/@actions/core": { @@ -150,18 +151,82 @@ "@octokit/openapi-types": "^11.2.0" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, "node_modules/before-after-hook": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.2.tgz", "integrity": "sha512-3pZEU3NT5BFUo/AD5ERPWOgQOCZITni6iavr5AUw5AUwQjMlI0kzu5btnyD39AF0gUEsDPwJT+oY1ORBJijPjQ==", "dev": true }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, "node_modules/deprecation": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", "dev": true }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "node_modules/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, "node_modules/is-plain-object": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", @@ -171,6 +236,37 @@ "node": ">=0.10.0" } }, + "node_modules/jasmine": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/jasmine/-/jasmine-4.0.2.tgz", + "integrity": "sha512-YsrgxJQEggxzByYe4j68eQLOiQeSrPDYGv4sHhGBp3c6HHdq+uPXeAQ73kOAQpdLZ3/0zN7x/TZTloqeE1/qIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.6", + "jasmine-core": "^4.0.0" + }, + "bin": { + "jasmine": "bin/jasmine.js" + } + }, + "node_modules/jasmine-core": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-4.0.0.tgz", + "integrity": "sha512-tq24OCqHElgU9KDpb/8O21r1IfotgjIzalfW9eCmRR40LZpvwXT68iariIyayMwi0m98RDt16aljdbwK0sBMmQ==", + "dev": true + }, + "node_modules/minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", @@ -200,6 +296,15 @@ "wrappy": "1" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -380,24 +485,107 @@ "@octokit/openapi-types": "^11.2.0" } }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, "before-after-hook": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.2.tgz", "integrity": "sha512-3pZEU3NT5BFUo/AD5ERPWOgQOCZITni6iavr5AUw5AUwQjMlI0kzu5btnyD39AF0gUEsDPwJT+oY1ORBJijPjQ==", "dev": true }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, "deprecation": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", "dev": true }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, "is-plain-object": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", "dev": true }, + "jasmine": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/jasmine/-/jasmine-4.0.2.tgz", + "integrity": "sha512-YsrgxJQEggxzByYe4j68eQLOiQeSrPDYGv4sHhGBp3c6HHdq+uPXeAQ73kOAQpdLZ3/0zN7x/TZTloqeE1/qIA==", + "dev": true, + "requires": { + "glob": "^7.1.6", + "jasmine-core": "^4.0.0" + } + }, + "jasmine-core": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-4.0.0.tgz", + "integrity": "sha512-tq24OCqHElgU9KDpb/8O21r1IfotgjIzalfW9eCmRR40LZpvwXT68iariIyayMwi0m98RDt16aljdbwK0sBMmQ==", + "dev": true + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, "node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", @@ -416,6 +604,12 @@ "wrappy": "1" } }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, "tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", diff --git a/.github/workflows/tools/update-issues/package.json b/.github/workflows/tools/update-issues/package.json index 4a3e9e626b..8b4b5f8c6f 100644 --- a/.github/workflows/tools/update-issues/package.json +++ b/.github/workflows/tools/update-issues/package.json @@ -1,9 +1,10 @@ { "devDependencies": { "@actions/core": "^1.6.0", - "@actions/github": "^5.0.0" + "@actions/github": "^5.0.0", + "jasmine": "^4.0.2" }, "scripts": { - "test": "true # FIXME - port internal test cases" + "test": "jasmine tests.js" } } diff --git a/.github/workflows/tools/update-issues/reporter.js b/.github/workflows/tools/update-issues/reporter.js new file mode 100644 index 0000000000..9820116bc2 --- /dev/null +++ b/.github/workflows/tools/update-issues/reporter.js @@ -0,0 +1,55 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview A custom Jasmine reporter for GitHub Actions. + */ + +const core = require('@actions/core'); + +class GitHubActionsReporter { + constructor() { + // In the Actions environment, log through the Actions toolkit. + if (process.env.GITHUB_ACTIONS) { + this.logger = core; + } else { + this.logger = console; + } + + // Escape sequence for ANSI blue. + this.blue = '\u001b[36m'; + // Escape sequence for ANSI red. + this.red = '\u001b[31m'; + // Escape sequence for ANSI color reset. Not needed in GitHub Actions + // environment, but useful for local testing to reset the terminal to + // defaults. + this.reset = '\u001b[0m'; + } + + specStarted(result) { + // Escape sequence is for ANSI bright blue on a black background. + this.logger.info(`\n${this.blue} -- ${result.fullName} --${this.reset}`); + } + + specDone(result) { + for (const failure of result.failedExpectations) { + // The text in error() is bubbled up in GitHub Actions to the top level, + // but at that level, the color escape sequences are not understood. So + // those are done before and after in info() calls. + this.logger.info(this.red); + this.logger.error(`${result.fullName} FAILED`); + this.logger.info(this.reset); + + // This only appears in the logs of the job. + const indentedMessage = failure.message.replaceAll('\n', '\n '); + this.logger.info(` ${indentedMessage}`); + } + } +} + +module.exports = { + GitHubActionsReporter, +}; diff --git a/.github/workflows/tools/update-issues/tests.js b/.github/workflows/tools/update-issues/tests.js new file mode 100644 index 0000000000..ec3f7aa3d1 --- /dev/null +++ b/.github/workflows/tools/update-issues/tests.js @@ -0,0 +1,500 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Test cases for the update-issues tool. + */ + +// Bogus values to satisfy load-time calls in issues.js. +process.env.GITHUB_TOKEN = 'asdf'; +process.env.GITHUB_REPOSITORY = 'foo/someproject'; + +// Always execute tests in a consistent order. +jasmine.getEnv().configure({ + random: false, +}); + +// Report results through GitHub Actions when possible. +const {GitHubActionsReporter} = require('./reporter.js'); +jasmine.getEnv().clearReporters(); +jasmine.getEnv().addReporter(new GitHubActionsReporter()); + +const { + MockMilestone, + MockComment, + MockIssue, +} = require('./mocks.js'); + +const { + Milestone, +} = require('./issues.js'); + +const { + processIssues, + TYPE_ACCESSIBILITY, + TYPE_ANNOUNCEMENT, + TYPE_BUG, + TYPE_CODE_HEALTH, + TYPE_DOCS, + TYPE_ENHANCEMENT, + TYPE_PROCESS, + TYPE_QUESTION, + PRIORITY_P0, + PRIORITY_P1, + PRIORITY_P2, + PRIORITY_P3, + PRIORITY_P4, + STATUS_ARCHIVED, + STATUS_WAITING, + FLAG_IGNORE, +} = require('./main.js'); + +describe('update-issues tool', () => { + const nextMilestone = new MockMilestone({ + title: 'v5.1', + version: [5, 1], + }); + + const backlog = new MockMilestone({ + title: 'Backlog', + isBacklog: () => true, + }); + + const teamCommentOld = new MockComment({ + author: 'SomeTeamMember', + fromTeam: true, + ageInDays: 100, + }); + + const teamCommentNew = new MockComment({ + author: 'SomeTeamMember', + fromTeam: true, + ageInDays: 0, + }); + + const externalCommentOld = new MockComment({ + author: 'SomeUser', + fromTeam: false, + ageInDays: 1000, + }); + + const externalCommentNew = new MockComment({ + author: 'SomeUser', + fromTeam: false, + ageInDays: 0, + }); + + it('archives old issues', async () => { + const matchingIssues = [ + new MockIssue({ + closed: true, + closedDays: 60, + }), + new MockIssue({ + closed: true, + closedDays: 100, + }), + // This has the "archived" label, but is not locked. It should still get + // locked. + new MockIssue({ + closed: true, + closedDays: 100, + labels: [STATUS_ARCHIVED], + }), + ]; + + const nonMatchingIssues = [ + new MockIssue({ + closed: false, + }), + new MockIssue({ + closed: true, + closedDays: 1, + }), + // This is already locked, but doesn't have the "archived" label. + // The unarchive task will unlock this one, but the archive task won't + // label it. + new MockIssue({ + closed: true, + closedDays: 100, + locked: true, + }), + ]; + + const issues = matchingIssues.concat(nonMatchingIssues); + + await processIssues(issues, nextMilestone, backlog); + + for (const issue of matchingIssues) { + expect(issue.addLabel).toHaveBeenCalledWith(STATUS_ARCHIVED); + expect(issue.lock).toHaveBeenCalled(); + // Show that there is no conflict with the task to unarchive issues. + expect(issue.unlock).not.toHaveBeenCalled(); + expect(issue.reopen).not.toHaveBeenCalled(); + } + for (const issue of nonMatchingIssues) { + expect(issue.addLabel).not.toHaveBeenCalled(); + expect(issue.lock).not.toHaveBeenCalled(); + } + }); + + it('unarchives issues', async () => { + const matchingIssues = [ + // Closed and locked, but the "archived" label has been removed. + new MockIssue({ + closed: true, + locked: true, + }), + ]; + + const nonMatchingIssues = [ + // Closed and locked, and with the "archived" label still in place. + new MockIssue({ + closed: true, + locked: true, + labels: [STATUS_ARCHIVED], + }), + ]; + + const issues = matchingIssues.concat(nonMatchingIssues); + + await processIssues(issues, nextMilestone, backlog); + + for (const issue of matchingIssues) { + expect(issue.unlock).toHaveBeenCalled(); + expect(issue.reopen).toHaveBeenCalled(); + } + for (const issue of nonMatchingIssues) { + expect(issue.unlock).not.toHaveBeenCalled(); + expect(issue.reopen).not.toHaveBeenCalled(); + // Show that there is no conflict with the task to archive issues. + expect(issue.lock).not.toHaveBeenCalled(); + expect(issue.addLabel).not.toHaveBeenCalled(); + } + }); + + it('removes "waiting" label', async () => { + const matchingIssues = [ + new MockIssue({ + labels: [STATUS_WAITING], + labelAgeInDays: 1, + comments: [externalCommentNew], + }), + new MockIssue({ + labels: [STATUS_WAITING], + labelAgeInDays: 1, + // Most recent comments go first. + comments: [externalCommentNew, teamCommentOld], + }), + new MockIssue({ + labels: [TYPE_BUG, STATUS_WAITING], + labelAgeInDays: 1, + // Most recent comments go first. + comments: [externalCommentNew, teamCommentOld], + }), + ]; + + const nonMatchingIssues = [ + new MockIssue({ + labels: [STATUS_WAITING], + labelAgeInDays: 1, + comments: [teamCommentOld], + }), + new MockIssue({ + labels: [STATUS_WAITING], + labelAgeInDays: 1, + // Most recent comments go first. + comments: [teamCommentNew, externalCommentOld], + }), + ]; + + const issues = matchingIssues.concat(nonMatchingIssues); + + await processIssues(issues, nextMilestone, backlog); + + for (const issue of matchingIssues) { + expect(issue.removeLabel).toHaveBeenCalledWith(STATUS_WAITING); + } + for (const issue of nonMatchingIssues) { + expect(issue.removeLabel).not.toHaveBeenCalled(); + } + }); + + it('closes stale issues', async () => { + const matchingIssues = [ + new MockIssue({ + labels: [STATUS_WAITING], + labelAgeInDays: 100, + }), + new MockIssue({ + labels: [STATUS_WAITING], + labelAgeInDays: 100, + comments: [teamCommentOld], + }), + ]; + + const nonMatchingIssues = [ + new MockIssue({ + labels: [STATUS_WAITING], + labelAgeInDays: 1, + }), + new MockIssue({ + labels: [STATUS_WAITING], + labelAgeInDays: 100, + closed: true, + }), + ]; + + const issues = matchingIssues.concat(nonMatchingIssues); + + await processIssues(issues, nextMilestone, backlog); + + for (const issue of matchingIssues) { + expect(issue.postComment).toHaveBeenCalled(); + expect(issue.close).toHaveBeenCalled(); + } + for (const issue of nonMatchingIssues) { + expect(issue.postComment).not.toHaveBeenCalled(); + expect(issue.close).not.toHaveBeenCalled(); + } + }); + + it('cleans up labels on closed issues', async () => { + const matchingIssues = [ + new MockIssue({ + closed: true, + labels: [STATUS_WAITING], + }), + ]; + + const nonMatchingIssues = [ + new MockIssue({ + labels: [STATUS_WAITING], + }), + ]; + + const issues = matchingIssues.concat(nonMatchingIssues); + + await processIssues(issues, nextMilestone, backlog); + + for (const issue of matchingIssues) { + expect(issue.removeLabel).toHaveBeenCalledWith(STATUS_WAITING); + } + for (const issue of nonMatchingIssues) { + expect(issue.removeLabel).not.toHaveBeenCalled(); + } + }); + + it('pings questions waiting for a response', async () => { + const matchingIssues = [ + new MockIssue({ + labels: [TYPE_QUESTION], + comments: [teamCommentOld], + }), + new MockIssue({ + labels: [TYPE_QUESTION], + // Most recent comments go first. + comments: [teamCommentOld, externalCommentOld], + }), + ]; + + const nonMatchingIssues = [ + // Won't be touched because it's closed. + new MockIssue({ + closed: true, + labels: [TYPE_QUESTION], + comments: [teamCommentOld], + }), + // Won't be touched because it's not a "question" type. + new MockIssue({ + labels: [TYPE_BUG], + comments: [teamCommentOld], + }), + // Won't be touched because the team comment is too new. + new MockIssue({ + labels: [TYPE_QUESTION], + comments: [teamCommentNew], + }), + // Won't be touched because the most recent comment was external. + new MockIssue({ + labels: [TYPE_QUESTION], + // Most recent comments go first. + comments: [externalCommentOld, teamCommentOld], + }), + ]; + + const issues = matchingIssues.concat(nonMatchingIssues); + + await processIssues(issues, nextMilestone, backlog); + + for (const issue of matchingIssues) { + expect(issue.postComment).toHaveBeenCalled(); + expect(issue.addLabel).toHaveBeenCalledWith(STATUS_WAITING); + } + for (const issue of nonMatchingIssues) { + expect(issue.postComment).not.toHaveBeenCalled(); + expect(issue.addLabel).not.toHaveBeenCalled(); + } + }); + + it('sets an appropriate milestone', async () => { + const nextMilestoneIssues = [ + // Bugs go to the next milestone. (If the priority is P0-P2 or unset.) + new MockIssue({ + labels: [TYPE_BUG], + }), + new MockIssue({ + labels: [TYPE_BUG, PRIORITY_P0], + }), + new MockIssue({ + labels: [TYPE_BUG, PRIORITY_P1], + }), + new MockIssue({ + labels: [TYPE_BUG, PRIORITY_P2], + }), + // Docs issues also go to the next milestone. (Same priority rules.) + new MockIssue({ + labels: [TYPE_DOCS], + }), + new MockIssue({ + labels: [TYPE_DOCS, PRIORITY_P2], + }), + // A11y issues also go to the next milestone. (Same priority rules.) + new MockIssue({ + labels: [TYPE_ACCESSIBILITY], + }), + new MockIssue({ + labels: [TYPE_ACCESSIBILITY, PRIORITY_P2], + }), + ]; + + const backlogIssues = [ + // Low priority bugs/docs/a11y issues go the backlog. + new MockIssue({ + labels: [TYPE_BUG, PRIORITY_P3], + }), + new MockIssue({ + labels: [TYPE_BUG, PRIORITY_P4], + }), + new MockIssue({ + labels: [TYPE_DOCS, PRIORITY_P4], + }), + new MockIssue({ + labels: [TYPE_ACCESSIBILITY, PRIORITY_P4], + }), + // Enhancements go to the backlog, regardless of priority. + new MockIssue({ + labels: [TYPE_ENHANCEMENT], + }), + new MockIssue({ + labels: [TYPE_ENHANCEMENT, PRIORITY_P1], + }), + // Code health issues also go to the backlog. + new MockIssue({ + labels: [TYPE_CODE_HEALTH], + }), + ]; + + const clearMilestoneIssues = [ + // Some issue types are always removed from milestones. + new MockIssue({ + labels: [TYPE_QUESTION], + milestone: backlog, + }), + new MockIssue({ + labels: [TYPE_PROCESS], + milestone: nextMilestone, + }), + ]; + + const nonMatchingIssues = [ + // Issue types that _can_ have milestones should always keep their + // milestones once assigned manually, even if they are not the default + // for that type. + new MockIssue({ + labels: [TYPE_BUG], + milestone: backlog, + }), + new MockIssue({ + labels: [TYPE_DOCS], + milestone: backlog, + }), + new MockIssue({ + labels: [TYPE_CODE_HEALTH], + milestone: nextMilestone, + }), + new MockIssue({ + labels: [TYPE_ENHANCEMENT], + milestone: nextMilestone, + }), + // Once closed, issues are never assigned to a milestone regardless of + // type. + new MockIssue({ + labels: [TYPE_BUG], + closed: true, + }), + new MockIssue({ + labels: [TYPE_ENHANCEMENT], + closed: true, + }), + ]; + + const issues = nextMilestoneIssues + .concat(backlogIssues) + .concat(clearMilestoneIssues) + .concat(nonMatchingIssues); + + await processIssues(issues, nextMilestone, backlog); + + for (const issue of nextMilestoneIssues) { + expect(issue.setMilestone).toHaveBeenCalledWith(nextMilestone); + } + for (const issue of backlogIssues) { + expect(issue.setMilestone).toHaveBeenCalledWith(backlog); + } + for (const issue of clearMilestoneIssues) { + expect(issue.removeMilestone).toHaveBeenCalled(); + } + for (const issue of nonMatchingIssues) { + expect(issue.setMilestone).not.toHaveBeenCalled(); + expect(issue.removeMilestone).not.toHaveBeenCalled(); + } + }); + + it('parses and sorts milestone versions', () => { + const milestones = [ + new Milestone({title: 'v1.0'}), + new Milestone({title: 'Backlog'}), + new Milestone({title: 'v1.1'}), + new Milestone({title: 'v11.0'}), + new Milestone({title: 'v11.0-beta'}), + new Milestone({title: 'v10.0'}), + new Milestone({title: 'v2.0'}), + ]; + + expect(milestones.map(m => m.version)).toEqual([ + [1, 0], + null, + [1, 1], + [11, 0], + [11, 0, -1, 'beta'], + [10, 0], + [2, 0], + ]); + + milestones.sort(Milestone.compare); + + expect(milestones.map(m => m.title)).toEqual([ + 'v1.0', + 'v1.1', + 'v2.0', + 'v10.0', + 'v11.0-beta', + 'v11.0', + 'Backlog', + ]); + }); +});