From b26aaa52787fbb8d9c3b6459b47de69296236282 Mon Sep 17 00:00:00 2001 From: Daniel Jankowski Date: Sun, 26 Apr 2020 08:10:59 +0200 Subject: [PATCH] Create `lock` GitHub Action (#30) * Create `lock` GitHub Action * Fix typo in README.md * Do not lock already locked issues * Update README.md --- README.md | 4 ++ lock/.gitignore | 3 ++ lock/.prettierrc.json | 11 +++++ lock/Dockerfile | 7 +++ lock/README.md | 26 ++++++++++ lock/__tests__/main.test.ts | 91 ++++++++++++++++++++++++++++++++++ lock/action.yml | 16 ++++++ lock/jest.config.js | 11 +++++ lock/lib/main.js | 90 +++++++++++++++++++++++++++++++++ lock/package.json | 32 ++++++++++++ lock/src/main.ts | 99 +++++++++++++++++++++++++++++++++++++ lock/tsconfig.json | 64 ++++++++++++++++++++++++ 12 files changed, 454 insertions(+) create mode 100644 lock/.gitignore create mode 100644 lock/.prettierrc.json create mode 100644 lock/Dockerfile create mode 100644 lock/README.md create mode 100644 lock/__tests__/main.test.ts create mode 100644 lock/action.yml create mode 100644 lock/jest.config.js create mode 100644 lock/lib/main.js create mode 100644 lock/package.json create mode 100644 lock/src/main.ts create mode 100644 lock/tsconfig.json diff --git a/README.md b/README.md index 2fa46cb..b2935ec 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,10 @@ The Fastlane GitHub Actions provide a set of GitHub Actions to make maintaining Adds a comment and a label to a pull request and referenced issue when it is released. Read more [here](communicate-on-pull-request-released). +- 🔒 [@github-actions/lock](lock) + + Locks closed issues and pull requests that have not had recent interaction. Read more [here](lock). + ## Versioning All the actions are released in one batch. We do not support semantic versioning (yet). Reference a `latest` branch in your workflow: diff --git a/lock/.gitignore b/lock/.gitignore new file mode 100644 index 0000000..b16767d --- /dev/null +++ b/lock/.gitignore @@ -0,0 +1,3 @@ +package-lock.json +node_modules +__tests__/runner/* diff --git a/lock/.prettierrc.json b/lock/.prettierrc.json new file mode 100644 index 0000000..c134b7f --- /dev/null +++ b/lock/.prettierrc.json @@ -0,0 +1,11 @@ +{ + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": true, + "trailingComma": "none", + "bracketSpacing": false, + "arrowParens": "avoid", + "parser": "typescript" +} \ No newline at end of file diff --git a/lock/Dockerfile b/lock/Dockerfile new file mode 100644 index 0000000..ff13242 --- /dev/null +++ b/lock/Dockerfile @@ -0,0 +1,7 @@ +FROM node:slim + +COPY . . + +RUN npm install --production + +ENTRYPOINT ["node", "/lib/main.js"] \ No newline at end of file diff --git a/lock/README.md b/lock/README.md new file mode 100644 index 0000000..2894899 --- /dev/null +++ b/lock/README.md @@ -0,0 +1,26 @@ +# Lock + +An action for locking closed, inactive issues and pull requests. + +# Usage + +See [action.yml](action.yml) + +```yaml +name: Lock closed, inactive issues and pull requests +on: + schedule: + - cron: "0 0 * * *" + +jobs: + lock: + runs-on: ubuntu-latest + steps: + - uses: fastlane/github-actions/lock@latest + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} +``` + +# License + +The scripts and documentation in this project are released under the [MIT License](LICENSE) \ No newline at end of file diff --git a/lock/__tests__/main.test.ts b/lock/__tests__/main.test.ts new file mode 100644 index 0000000..69caae2 --- /dev/null +++ b/lock/__tests__/main.test.ts @@ -0,0 +1,91 @@ +const path = require('path'); +const nock = require('nock'); + +describe('action test suite', () => { + it(`It locks a closed issue when the issue was not updated in a given timespan`, async () => { + process.env['INPUT_REPO-TOKEN'] = 'token'; + process.env['INPUT_DAYS-BEFORE-LOCK'] = '60'; + process.env['INPUT_OPERATIONS-PER-RUN'] = '2'; + + process.env['GITHUB_REPOSITORY'] = 'foo/bar'; + + var date = new Date(); + date.setDate(date.getDate() - 61); + + const api = nock('https://api.github.com') + .persist() + .get('/repos/foo/bar/issues?state=closed&per_page=100&page=1') + .reply( + 200, + JSON.parse( + `[{"id": 1, "title": "Issue to be locked", "number": 1347, "locked": false, "updated_at": "${date.toISOString()}"}]` + ) + ) + .put('/repos/foo/bar/issues/1347/lock') + .reply(204); + + const main = require('../src/main'); + await main.run(); + + expect(api.isDone()).toBeTruthy(); + nock.cleanAll(); + }); + + it(`It does not lock an issue, when the required time has not passed`, async () => { + process.env['INPUT_REPO-TOKEN'] = 'token'; + process.env['INPUT_DAYS-BEFORE-LOCK'] = '60'; + process.env['INPUT_OPERATIONS-PER-RUN'] = '2'; + + process.env['GITHUB_REPOSITORY'] = 'foo/bar'; + + var date = new Date(); + date.setDate(date.getDate() - 1); // yesterday + + const api = nock('https://api.github.com') + .persist() + .get('/repos/foo/bar/issues?state=closed&per_page=100&page=1') + .reply( + 200, + JSON.parse( + `[{"id": 1, "title": "The issue cannot be locked yet", "number": 1347, "locked": false, "updated_at": "${date.toISOString()}"}]` + ) + ) + .put('/repos/foo/bar/issues/1347/lock') + .reply(204); + + const main = require('../src/main'); + await main.run(); + + expect(api.isDone()).not.toBeTruthy(); + nock.cleanAll(); + }); + + it(`It does not lock an issue, when the issue is already locked`, async () => { + process.env['INPUT_REPO-TOKEN'] = 'token'; + process.env['INPUT_DAYS-BEFORE-LOCK'] = '60'; + process.env['INPUT_OPERATIONS-PER-RUN'] = '2'; + + process.env['GITHUB_REPOSITORY'] = 'foo/bar'; + + var date = new Date(); + date.setDate(date.getDate() - 61); + + const api = nock('https://api.github.com') + .persist() + .get('/repos/foo/bar/issues?state=closed&per_page=100&page=1') + .reply( + 200, + JSON.parse( + `[{"id": 1, "title": "Locked issue", "number": 1347, "locked": true, "updated_at": "${date.toISOString()}"}]` + ) + ) + .put('/repos/foo/bar/issues/1347/lock') + .reply(204); + + const main = require('../src/main'); + await main.run(); + + expect(api.isDone()).not.toBeTruthy(); + nock.cleanAll(); + }); +}); diff --git a/lock/action.yml b/lock/action.yml new file mode 100644 index 0000000..609501e --- /dev/null +++ b/lock/action.yml @@ -0,0 +1,16 @@ +name: 'Lock' +description: 'An action for locking closed, inactive issues and pull requests' +author: 'fastlane' +inputs: + repo-token: + description: 'Token for the repo. Can be passed in using {{ secrets.GITHUB_TOKEN }}' + required: true + days-before-lock: + description: 'The number of days to wait to lock an issue or pull request after it being closed' + default: 60 + operations-per-run: + description: 'The maximum number of operations per run, used to control rate limiting' + default: 30 +runs: + using: 'docker' + image: 'Dockerfile' diff --git a/lock/jest.config.js b/lock/jest.config.js new file mode 100644 index 0000000..563d4cc --- /dev/null +++ b/lock/jest.config.js @@ -0,0 +1,11 @@ +module.exports = { + clearMocks: true, + moduleFileExtensions: ['js', 'ts'], + testEnvironment: 'node', + testMatch: ['**/*.test.ts'], + testRunner: 'jest-circus/runner', + transform: { + '^.+\\.ts$': 'ts-jest' + }, + verbose: true +} \ No newline at end of file diff --git a/lock/lib/main.js b/lock/lib/main.js new file mode 100644 index 0000000..7fd5f56 --- /dev/null +++ b/lock/lib/main.js @@ -0,0 +1,90 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k]; + result["default"] = mod; + return result; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const core = __importStar(require("@actions/core")); +const github = __importStar(require("@actions/github")); +function run() { + return __awaiter(this, void 0, void 0, function* () { + try { + const args = getAndValidateArgs(); + const client = new github.GitHub(args.repoToken); + yield processIssues(client, args.daysBeforeLock, args.operationsPerRun); + } + catch (error) { + core.error(error); + core.setFailed(error.message); + } + }); +} +exports.run = run; +function getAndValidateArgs() { + const args = { + repoToken: core.getInput('repo-token', { required: true }), + daysBeforeLock: parseInt(core.getInput('days-before-lock', { required: true })), + operationsPerRun: parseInt(core.getInput('operations-per-run', { required: true })) + }; + for (var numberInput of ['days-before-lock', 'operations-per-run']) { + if (isNaN(parseInt(core.getInput(numberInput)))) { + throw Error(`input ${numberInput} did not parse to a valid integer`); + } + } + return args; +} +function processIssues(client, daysBeforeLock, operationsLeft, page = 1) { + return __awaiter(this, void 0, void 0, function* () { + const issues = yield client.issues.listForRepo({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + state: 'closed', + per_page: 100, + page: page + }); + operationsLeft -= 1; + if (issues.data.length === 0 || operationsLeft === 0) { + return operationsLeft; + } + for (var issue of issues.data.values()) { + core.debug(`Found issue: "${issue.title}", last updated ${issue.updated_at}`); + if (wasLastUpdatedBefore(issue, daysBeforeLock) && !issue.locked) { + operationsLeft -= yield lock(client, issue); + } + if (operationsLeft <= 0) { + return 0; + } + } + return yield processIssues(client, daysBeforeLock, operationsLeft, page + 1); + }); +} +function wasLastUpdatedBefore(issue, days) { + const daysInMillis = 1000 * 60 * 60 * 24 * days; + const millisSinceLastUpdated = new Date().getTime() - new Date(issue.updated_at).getTime(); + return millisSinceLastUpdated >= daysInMillis; +} +function lock(client, issue) { + return __awaiter(this, void 0, void 0, function* () { + core.debug(`Locking issue "${issue.title}"`); + yield client.issues.lock({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + issue_number: issue.number, + headers: { 'Content-Length': 0 } // if you choose not to pass any parameters, you'll need to set Content-Length to zero when calling out to this endpoint. For more info see https://developer.github.com/v3/#http-verbs + }); + return 1; // the number of API operations performed + }); +} +run(); diff --git a/lock/package.json b/lock/package.json new file mode 100644 index 0000000..fcdcedc --- /dev/null +++ b/lock/package.json @@ -0,0 +1,32 @@ +{ + "name": "lock", + "version": "0.0.0", + "private": true, + "description": "An action for locking closed issues and pull requests", + "main": "lib/main.js", + "scripts": { + "build": "tsc", + "format": "prettier --write **/*.ts", + "test": "jest" + }, + "keywords": [ + "actions", + "fastlane" + ], + "author": "fastlane", + "license": "MIT", + "dependencies": { + "@actions/core": "^1.0.0", + "@actions/github": "^1.0.0" + }, + "devDependencies": { + "@types/jest": "^24.0.13", + "@types/node": "^12.0.4", + "jest": "^24.8.0", + "jest-circus": "^24.7.1", + "nock": "^10.0.6", + "prettier": "^1.17.1", + "ts-jest": "^24.0.2", + "typescript": "^3.5.1" + } +} diff --git a/lock/src/main.ts b/lock/src/main.ts new file mode 100644 index 0000000..beb4839 --- /dev/null +++ b/lock/src/main.ts @@ -0,0 +1,99 @@ +import * as core from '@actions/core'; +import * as github from '@actions/github'; +import * as Octokit from '@octokit/rest'; + +type Issue = Octokit.IssuesListForRepoResponseItem; + +export async function run() { + try { + const args = getAndValidateArgs(); + const client: github.GitHub = new github.GitHub(args.repoToken); + await processIssues(client, args.daysBeforeLock, args.operationsPerRun); + } catch (error) { + core.error(error); + core.setFailed(error.message); + } +} + +function getAndValidateArgs(): { + repoToken: string; + daysBeforeLock: number; + operationsPerRun: number; +} { + const args = { + repoToken: core.getInput('repo-token', {required: true}), + daysBeforeLock: parseInt( + core.getInput('days-before-lock', {required: true}) + ), + operationsPerRun: parseInt( + core.getInput('operations-per-run', {required: true}) + ) + }; + + for (var numberInput of ['days-before-lock', 'operations-per-run']) { + if (isNaN(parseInt(core.getInput(numberInput)))) { + throw Error(`input ${numberInput} did not parse to a valid integer`); + } + } + + return args; +} + +async function processIssues( + client: github.GitHub, + daysBeforeLock: number, + operationsLeft: number, + page: number = 1 +): Promise { + const issues = await client.issues.listForRepo({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + state: 'closed', + per_page: 100, + page: page + }); + + operationsLeft -= 1; + + if (issues.data.length === 0 || operationsLeft === 0) { + return operationsLeft; + } + + for (var issue of issues.data.values()) { + core.debug( + `Found issue: "${issue.title}", last updated ${issue.updated_at}` + ); + + if (wasLastUpdatedBefore(issue, daysBeforeLock) && !issue.locked) { + operationsLeft -= await lock(client, issue); + } + + if (operationsLeft <= 0) { + return 0; + } + } + + return await processIssues(client, daysBeforeLock, operationsLeft, page + 1); +} + +function wasLastUpdatedBefore(issue: Issue, days: number): boolean { + const daysInMillis = 1000 * 60 * 60 * 24 * days; + const millisSinceLastUpdated = + new Date().getTime() - new Date(issue.updated_at).getTime(); + return millisSinceLastUpdated >= daysInMillis; +} + +async function lock(client: github.GitHub, issue: Issue): Promise { + core.debug(`Locking issue "${issue.title}"`); + + await client.issues.lock({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + issue_number: issue.number, + headers: {'Content-Length': 0} // if you choose not to pass any parameters, you'll need to set Content-Length to zero when calling out to this endpoint. For more info see https://developer.github.com/v3/#http-verbs + }); + + return 1; // the number of API operations performed +} + +run(); diff --git a/lock/tsconfig.json b/lock/tsconfig.json new file mode 100644 index 0000000..c66a82d --- /dev/null +++ b/lock/tsconfig.json @@ -0,0 +1,64 @@ +{ + "compilerOptions": { + /* Basic Options */ + // "incremental": true, /* Enable incremental compilation */ + "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ + "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ + // "allowJs": true, /* Allow javascript files to be compiled. */ + // "checkJs": true, /* Report errors in .js files. */ + // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ + // "declaration": true, /* Generates corresponding '.d.ts' file. */ + // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ + // "sourceMap": true, /* Generates corresponding '.map' file. */ + // "outFile": "./", /* Concatenate and emit output to single file. */ + "outDir": "./lib", /* Redirect output structure to the directory. */ + "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ + // "composite": true, /* Enable project compilation */ + // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ + // "removeComments": true, /* Do not emit comments to output. */ + // "noEmit": true, /* Do not emit outputs. */ + // "importHelpers": true, /* Import emit helpers from 'tslib'. */ + // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ + // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ + + /* Strict Type-Checking Options */ + "strict": true, /* Enable all strict type-checking options. */ + "noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* Enable strict null checks. */ + // "strictFunctionTypes": true, /* Enable strict checking of function types. */ + // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ + // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ + // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ + // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ + + /* Additional Checks */ + // "noUnusedLocals": true, /* Report errors on unused locals. */ + // "noUnusedParameters": true, /* Report errors on unused parameters. */ + // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + + /* Module Resolution Options */ + // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ + // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ + // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ + // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ + // "typeRoots": [], /* List of folders to include type definitions from. */ + // "types": [], /* Type declaration files to be included in compilation. */ + // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ + "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + + /* Source Map Options */ + // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ + // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ + + /* Experimental Options */ + // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ + // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ + }, + "exclude": ["node_modules", "**/*.test.ts"] + } + \ No newline at end of file