diff --git a/package-lock.json b/package-lock.json index 3cb43531..6b22a3b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,14 +22,6 @@ "chalk": "^2.0.0", "esutils": "^2.0.2", "js-tokens": "^4.0.0" - }, - "dependencies": { - "js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - } } }, "@babel/runtime": { @@ -47,33 +39,62 @@ "integrity": "sha512-PbDyjVme3HR8CrMI04SokU97Enq/+txP5fS2O0XYVSmMYteJ7Q9CLO2y0t8PmNZkt4YCxmHgaNEdMs+/Ki+PAA==" }, "@openstapps/configuration": { - "version": "0.16.1", - "resolved": "https://registry.npmjs.org/@openstapps/configuration/-/configuration-0.16.1.tgz", - "integrity": "sha512-GEYYfL0do3jikl2UyfvNdGJoQZGeo9sCYkfDrCsOYDZNxuHkHq5fzOPx8OJtMLblNzLgN65tiW+JPRWw6wTwKg==", + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@openstapps/configuration/-/configuration-0.18.0.tgz", + "integrity": "sha512-Ufi3jzCozVqCymNeaeRzuOHO2Yd5qXJ10uF4xNHk6Q4LFD9NAMMBkYbawkjmecZoNR+Llqs4AnwSxIkuEAxcxA==", "dev": true, "requires": { - "@types/node": "10.14.4", + "@types/node": "10.14.7", "@types/semver": "6.0.0", "@types/yaml": "1.0.2", "chalk": "2.4.2", "commander": "2.20.0", - "semver": "6.0.0", + "semver": "6.1.0", "tslint": "5.16.0", "tslint-eslint-rules": "5.4.0", - "yaml": "1.5.0" + "yaml": "1.6.0" }, "dependencies": { "@types/node": { - "version": "10.14.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-10.14.4.tgz", - "integrity": "sha512-DT25xX/YgyPKiHFOpNuANIQIVvYEwCWXgK2jYYwqgaMrYE6+tq+DtmMwlD3drl6DJbUwtlIDnn0d7tIn/EbXBg==", + "version": "10.14.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.14.7.tgz", + "integrity": "sha512-on4MmIDgHXiuJDELPk1NFaKVUxxCFr37tm8E9yN6rAiF5Pzp/9bBfBHkoexqRiY+hk/Z04EJU9kKEb59YqJ82A==", "dev": true }, "semver": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.0.0.tgz", - "integrity": "sha512-0UewU+9rFapKFnlbirLi3byoOuhrSsli/z/ihNnvM24vgF+8sNBiI1LZPBSH9wJKUwaUbw+s3hToDLCXkrghrQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.1.0.tgz", + "integrity": "sha512-kCqEOOHoBcFs/2Ccuk4Xarm/KiWRSLEX9CAZF8xkJ6ZPlIoTZ8V5f7J16vYLJqDbR7KrxTJpR2lqjIEm2Qx9cQ==", "dev": true + }, + "tslint": { + "version": "5.16.0", + "resolved": "https://registry.npmjs.org/tslint/-/tslint-5.16.0.tgz", + "integrity": "sha512-UxG2yNxJ5pgGwmMzPMYh/CCnCnh0HfPgtlVRDs1ykZklufFBL1ZoTlWFRz2NQjcoEiDoRp+JyT0lhBbbH/obyA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "builtin-modules": "^1.1.1", + "chalk": "^2.3.0", + "commander": "^2.12.1", + "diff": "^3.2.0", + "glob": "^7.1.1", + "js-yaml": "^3.13.0", + "minimatch": "^3.0.4", + "mkdirp": "^0.5.1", + "resolve": "^1.3.2", + "semver": "^5.3.0", + "tslib": "^1.8.0", + "tsutils": "^2.29.0" + }, + "dependencies": { + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } } } }, @@ -115,26 +136,26 @@ } }, "@openstapps/logger": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@openstapps/logger/-/logger-0.1.0.tgz", - "integrity": "sha512-5z7Yf3WrzayEVNPp1TBoGiCVgPlQtqzOFh0yQ06gac/vFedWLPLBmENGDdRoEKar8bXzghkxDLy6Rvj/8HEQaQ==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@openstapps/logger/-/logger-0.2.1.tgz", + "integrity": "sha512-6+F1nxEBuNTrd3hhBxKnvkH8B84HvB/dVmoMP9Pmv2g3mL3pYJ9l2BBGaACDRA7oUCyLpbNQw+4Kf+VdyzOttw==", "requires": { - "@types/node": "10.14.6", - "@types/nodemailer": "4.6.8", + "@types/node": "10.14.7", + "@types/nodemailer": "6.1.0", "chalk": "2.4.2", "flatted": "2.0.0", "nodemailer": "6.1.1" }, "dependencies": { "@types/node": { - "version": "10.14.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-10.14.6.tgz", - "integrity": "sha512-Fvm24+u85lGmV4hT5G++aht2C5I4Z4dYlWZIh62FAfFO/TfzXtPpoLI6I7AuBWkIFqZCnhFOoTT7RjjaIL5Fjg==" + "version": "10.14.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.14.7.tgz", + "integrity": "sha512-on4MmIDgHXiuJDELPk1NFaKVUxxCFr37tm8E9yN6rAiF5Pzp/9bBfBHkoexqRiY+hk/Z04EJU9kKEb59YqJ82A==" }, "@types/nodemailer": { - "version": "4.6.8", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-4.6.8.tgz", - "integrity": "sha512-IX1P3bxDP1VIdZf6/kIWYNmSejkYm9MOyMEtoDFi4DVzKjJ3kY4GhOcOAKs6lZRjqVVmF9UjPOZXuQczlpZThw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.1.0.tgz", + "integrity": "sha512-WysSJ4sGW2Aum1Cs6HFosZdlR3WUzX0XoSLsI53q77gLd4wDfE84OXffZu5/nLIjeKh4SwfTsdrKsgBL9WowMA==", "requires": { "@types/node": "*" } @@ -314,9 +335,9 @@ "integrity": "sha512-RTVWV485OOf4+nO2+feurk0chzHkSjkjALiejpHltyuMf/13fGymbbNNFrSKdSSUg1TIwzszXdWsVirxgqYiFA==" }, "@types/node": { - "version": "10.14.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-10.14.7.tgz", - "integrity": "sha512-on4MmIDgHXiuJDELPk1NFaKVUxxCFr37tm8E9yN6rAiF5Pzp/9bBfBHkoexqRiY+hk/Z04EJU9kKEb59YqJ82A==" + "version": "10.14.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.14.8.tgz", + "integrity": "sha512-I4+DbJEhLEg4/vIy/2gkWDvXBOOtPKV9EnLhYjMoqxcRW+TTZtUftkHktz/a8suoD5mUL7m6ReLrkPvSsCQQmw==" }, "@types/nodemailer": { "version": "4.6.5", @@ -1535,6 +1556,12 @@ "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, "js-yaml": { "version": "3.13.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", @@ -2479,9 +2506,9 @@ "dev": true }, "tslint": { - "version": "5.16.0", - "resolved": "https://registry.npmjs.org/tslint/-/tslint-5.16.0.tgz", - "integrity": "sha512-UxG2yNxJ5pgGwmMzPMYh/CCnCnh0HfPgtlVRDs1ykZklufFBL1ZoTlWFRz2NQjcoEiDoRp+JyT0lhBbbH/obyA==", + "version": "5.17.0", + "resolved": "https://registry.npmjs.org/tslint/-/tslint-5.17.0.tgz", + "integrity": "sha512-pflx87WfVoYepTet3xLfDOLDm9Jqi61UXIKePOuca0qoAZyrGWonDG9VTbji58Fy+8gciUn8Bt7y69+KEVjc/w==", "dev": true, "requires": { "@babel/code-frame": "^7.0.0", @@ -2490,7 +2517,7 @@ "commander": "^2.12.1", "diff": "^3.2.0", "glob": "^7.1.1", - "js-yaml": "^3.13.0", + "js-yaml": "^3.13.1", "minimatch": "^3.0.4", "mkdirp": "^0.5.1", "resolve": "^1.3.2", @@ -2517,9 +2544,9 @@ "dev": true }, "tsutils": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.10.0.tgz", - "integrity": "sha512-q20XSMq7jutbGB8luhKKsQldRKWvyBO2BGqni3p4yq8Ys9bEP/xQw3KepKmMRt9gJ4lvQSScrihJrcKdKoSU7Q==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.13.0.tgz", + "integrity": "sha512-wRtEjVU8Su72sDIDoqno5Scwt8x4eaF0teKO3m4hu8K1QFPnIZMM88CLafs2tapUeWnY9SwwO3bWeOt2uauBcg==", "dev": true, "requires": { "tslib": "^1.8.1" @@ -2590,9 +2617,9 @@ "dev": true }, "typescript": { - "version": "3.4.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.4.5.tgz", - "integrity": "sha512-YycBxUb49UUhdNMU5aJ7z5Ej2XGmaIBL0x34vZ82fn3hGvD+bgrMrVDpatgz2f7YxUMJxMkbWxJZeAvDxVe7Vw==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.5.1.tgz", + "integrity": "sha512-64HkdiRv1yYZsSe4xC1WVgamNigVYjlssIoaH2HcZF0+ijsk5YK2g0G34w9wJkze8+5ow4STd22AynfO6ZYYLw==", "dev": true }, "uglify-js": { @@ -2678,12 +2705,12 @@ "dev": true }, "yaml": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.5.0.tgz", - "integrity": "sha512-nKxSWOa7vxAP2pikrGxbkZsG/garQseRiLn9mIDjzwoQsyVy7ZWIpLoARejnINGGLA4fttuzRFFNxxbsztdJgw==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.6.0.tgz", + "integrity": "sha512-iZfse3lwrJRoSlfs/9KQ9iIXxs9++RvBFVzAqbbBiFT+giYtyanevreF9r61ZTbGMgWQBxAua3FzJiniiJXWWw==", "dev": true, "requires": { - "@babel/runtime": "^7.4.3" + "@babel/runtime": "^7.4.5" } }, "yn": { diff --git a/package.json b/package.json index b6738869..4f3b436e 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,11 @@ "check-configuration": "openstapps-configuration", "compile": "rimraf lib && tsc && prepend lib/cli.js '#!/usr/bin/env node\n'", "documentation": "typedoc --includeDeclarations --mode modules --out docs --readme README.md --listInvalidSymbolLinks src", + "postversion": "npm run changelog", "prepublishOnly": "npm ci && npm run build", - "tslint": "tslint 'src/**/*.ts'" + "preversion": "npm run prepublishOnly", + "push": "git push && git push origin \"v$npm_package_version\"", + "tslint": "tslint -p tsconfig.json -c tslint.json 'src/**/*.ts'" }, "author": "Karl-Philipp Wulfert ", "contributors": [ @@ -25,25 +28,25 @@ "dependencies": { "@krlwlfrt/async-pool": "0.1.0", "@openstapps/gitlab-api": "0.5.1", - "@openstapps/logger": "0.1.0", + "@openstapps/logger": "0.2.1", "@slack/client": "5.0.1", "@types/glob": "7.1.1", "@types/mustache": "0.8.32", - "@types/node": "10.14.7", + "@types/node": "10.14.8", "commander": "2.20.0", "glob": "7.1.4", "moment": "2.24.0", "mustache": "3.0.1" }, "devDependencies": { - "@openstapps/configuration": "0.16.1", + "@openstapps/configuration": "0.18.0", "conventional-changelog-cli": "2.0.21", "prepend-file-cli": "1.0.6", "rimraf": "2.6.3", "ts-node": "8.2.0", - "tslint": "5.16.0", + "tslint": "5.17.0", "typedoc": "0.14.2", - "typescript": "3.4.5" + "typescript": "3.5.1" }, "main": "lib/common.js", "typings": "lib/common.d.ts", diff --git a/src/cli.ts b/src/cli.ts index 1ac90bb3..411d1931 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -23,13 +23,14 @@ import {tidy} from './tasks/tidy'; import {unlabel} from './tasks/unlabel'; // add default handler for unhandled rejections -process.on('unhandledRejection', (err) => { - Logger.error('UNHANDLED REJECTION', err.stack); +process.on('unhandledRejection', async (err) => { + await Logger.error('UNHANDLED REJECTION', err.stack); process.exit(1); }); // check that environment variable GITLAB_PRIVATE_TOKEN is set if (typeof process.env.GITLAB_PRIVATE_TOKEN !== 'string' || process.env.GITLAB_PRIVATE_TOKEN.length === 0) { + // tslint:disable-next-line:no-floating-promises Logger.error('Environment variable GITLAB_PRIVATE_TOKEN is not set!'); process.exit(1); } @@ -37,7 +38,8 @@ if (typeof process.env.GITLAB_PRIVATE_TOKEN !== 'string' || process.env.GITLAB_P const gitlabApi = new Api(GITLAB_API_URL, process.env.GITLAB_PRIVATE_TOKEN as string); if (existsSync(join(__dirname, 'package.json'))) { - commander.version(JSON.parse(readFileSync(join(__dirname, '..', 'package.json')).toString()).version); + commander.version(JSON.parse(readFileSync(join(__dirname, '..', 'package.json')) + .toString()).version); } commander diff --git a/src/common.ts b/src/common.ts index 6ef96778..daf9ff30 100644 --- a/src/common.ts +++ b/src/common.ts @@ -18,8 +18,16 @@ import {Group, Project} from '@openstapps/gitlab-api/lib/types'; import {Logger} from '@openstapps/logger'; import {readFile, writeFile} from 'fs'; import {promisify} from 'util'; +import {CONCURRENCY} from './configuration'; +/** + * Promisified version of readFile + */ export const readFilePromisified = promisify(readFile); + +/** + * Promisified version of writeFile + */ export const writeFilePromisified = promisify(writeFile); /** @@ -29,15 +37,15 @@ export const writeFilePromisified = promisify(writeFile); * @param groups List of groups */ export async function getProjects(api: Api, groups: number[]): Promise { - Logger.info('Fetching all projects for specified groups (' + groups.length + ')...'); + Logger.info(`Fetching all projects for specified groups (${groups.length})...`); - const projectResults = await asyncPool(3, groups, (groupId) => { + const projectResults = await asyncPool(CONCURRENCY, groups, async (groupId) => { return api.getProjectsForGroup(groupId); }); const projects = flatten2dArray(projectResults); - Logger.log('Fetched ' + projects.length + ' project(s).'); + Logger.log(`Fetched ${projects.length} project(s).`); return projects; } @@ -49,8 +57,8 @@ export async function getProjects(api: Api, groups: number[]): Promise { - return flatten2dArray(await asyncPool(2, groups, async (groupId) => { - return await api.getSubGroupsForGroup(groupId); + return flatten2dArray(await asyncPool(CONCURRENCY, groups, async (groupId) => { + return api.getSubGroupsForGroup(groupId); })); } diff --git a/src/configuration.ts b/src/configuration.ts index fc2d5ece..8f858c5f 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -17,21 +17,27 @@ import {Label} from '@openstapps/gitlab-api/lib/types'; /** * List of schools with their IDs */ -export const SCHOOLS: { [school: string]: number } = {}; +export const SCHOOLS: { [school: string]: number; } = {}; + +/** + * ID OF openstapps main group + */ +const STAPPS_GROUP_ID = 4088298; /** * List of group IDs to fetch issues for - * - * 4088298 is openstapps main group */ -export const GROUPS: number[] = [4088298].concat(Object.keys(SCHOOLS).map((school) => { - return SCHOOLS[school]; -})); +export const GROUPS: number[] = [STAPPS_GROUP_ID] + .concat(Object.keys(SCHOOLS) + .map((school) => { + return SCHOOLS[school]; + }), + ); /** * */ -export const LABEL_WEIGHTS: any = { +export const LABEL_WEIGHTS: { [key: string]: number; } = { 'bug': 1, 'critical': 2, }; @@ -67,12 +73,11 @@ export const PROTECTED_BRANCHES = [ /** * Labels to add to all projects */ -export const NEEDED_LABELS: Label[] = [ - { - color: '#FF0000', - description: 'An error/something that is not working as expected', - name: 'bug', - }, +export const NEEDED_LABELS: Label[] = [{ + color: '#FF0000', + description: 'An error/something that is not working as expected', + name: 'bug', +}, { color: '#5CB85C', name: 'consistency', @@ -154,20 +159,23 @@ export const NEEDED_LABELS: Label[] = [ color: '#428BCA', description: 'Feedback from the feedback-module of the app', name: 'user-feedback', - }, -].concat(Object.keys(SCHOOLS).map((school) => { - return { - color: '#F0AD4E', - description: 'An issue that specifically applies to this school', - name: `school-${school}`, - }; -})).concat(['android', 'iOS', 'web', 'node'].map((platform) => { - return { - color: '#FFECDB', - description: 'An issue that specifically applies to this platform', - name: 'platform-' + platform, - }; -})); + }] + .concat(Object.keys(SCHOOLS) + .map((school) => { + return { + color: '#F0AD4E', + description: 'An issue that specifically applies to this school', + name: `school-${school}`, + }; + })) + .concat(['android', 'iOS', 'web', 'node'] + .map((platform) => { + return { + color: '#FFECDB', + description: 'An issue that specifically applies to this platform', + name: `platform-${platform}`, + }; + })); /** * Prefix for automatically created notes @@ -178,3 +186,8 @@ export const NOTE_PREFIX = '`openstapps/projectmanagement`'; * Slack channel to post messages to */ export const SLACK_CHANNEL = 'C762UG76Z'; + +/** + * Concurrency for async pool + */ +export const CONCURRENCY = 3; diff --git a/src/tasks/remind.ts b/src/tasks/remind.ts index e31189f7..2ee578a2 100644 --- a/src/tasks/remind.ts +++ b/src/tasks/remind.ts @@ -24,8 +24,13 @@ import { } from '@openstapps/gitlab-api/lib/types'; import {Logger} from '@openstapps/logger'; import {WebClient} from '@slack/client'; -import {GROUPS, NOTE_PREFIX, SLACK_CHANNEL} from '../configuration'; +import {CONCURRENCY, GROUPS, NOTE_PREFIX, SLACK_CHANNEL} from '../configuration'; +/** + * Remind people of open merge requests + * + * @param api GitLab API to make requests with + */ export async function remind(api: Api): Promise { // get list of open merge requests const mergeRequests = await api.getMergeRequests(MembershipScope.GROUPS, GROUPS[0], MergeRequestState.OPENED); @@ -51,10 +56,11 @@ export async function remind(api: Api): Promise { Logger.info(`Found ${maintainers.length} maintainer(s).`); - await asyncPool(2, mergeRequests, async (mergeRequest) => { + await asyncPool(CONCURRENCY, mergeRequests, async (mergeRequest) => { // check if merge request is WIP if (mergeRequest.work_in_progress) { Logger.info(`Merge request '${mergeRequest.title}' is WIP.`); + return; } @@ -67,14 +73,14 @@ export async function remind(api: Api): Promise { // check if at least one of the discussions is unresolved const hasUnresolvedDiscussions = discussions.some((discussion) => { return discussion.notes.some((note) => { - return note.resolvable && !note.resolved; + return note.resolvable && (typeof note.resolved === 'undefined' || !note.resolved); }); }); if (hasUnresolvedDiscussions) { let recipient = mergeRequest.author.username; - if (typeof mergeRequest.assignee !== 'undefined') { + if (typeof mergeRequest.assignee !== 'undefined' && mergeRequest.assignee !== null) { recipient = mergeRequest.assignee.username; } @@ -100,11 +106,16 @@ export async function remind(api: Api): Promise { return true; } - return approval.approved_by.find((approver: { user: User }) => { + return approval.approved_by.find((approver: { + /** + * Possible approver + */ + user: User; + }) => { return approver.user.username !== username; }); }) - .map((username) => '@' + username) + .map((username) => `@${username}`) .join(', '); // send message to slack @@ -131,7 +142,7 @@ export async function remind(api: Api): Promise { // prefix maintainers with '@' and join with commas const possibleMergers = maintainerUsernames - .map((username) => '@' + username) + .map((username) => `@${username}`) .join(', '); // create note in merge request diff --git a/src/tasks/report.ts b/src/tasks/report.ts index 8cd91edf..5455b986 100644 --- a/src/tasks/report.ts +++ b/src/tasks/report.ts @@ -21,14 +21,23 @@ import {render} from 'mustache'; import {join, resolve} from 'path'; import {cwd} from 'process'; import {flatten2dArray, getProjects, readFilePromisified, writeFilePromisified} from '../common'; -import {BOLD_LABELS, GROUPS, LABEL_WEIGHTS} from '../configuration'; +import {BOLD_LABELS, CONCURRENCY, GROUPS, LABEL_WEIGHTS} from '../configuration'; /** * A structure for template compilation */ export interface StructureForTemplate { + /** + * List of issues by assignee + */ issuesByAssignee: AssigneeWithIssues[]; + /** + * Meeting day + */ meetingDay: string; + /** + * Timestamp, when the report was generated + */ timestamp: string; } @@ -43,12 +52,30 @@ export interface IssuesGroupedByAssigneeId { * An assignee with assigned issues */ export interface AssigneeWithIssues { + /** + * Assignee of the issues + */ assignee: User; + /** + * Counts of issues + */ issueCounts: { + /** + * Count of closed issues + */ closed: number; + /** + * Count of opened issues + */ opened: number; }; + /** + * List of issues + */ issues: IssueWithMeta[]; + /** + * Quota of closed issues + */ quota: number; } @@ -56,14 +83,38 @@ export interface AssigneeWithIssues { * Issue with meta information */ export interface IssueWithMeta extends Issue { + /** + * Whether or not an issue branch exists + */ $branchExists: boolean; + /** + * Whether or not the issue is closed + */ $closed: boolean; + /** + * List of labels + */ $labels: Array<{ + /** + * Whether or not to print the label bold + */ bold: boolean; + /** + * Actual label + */ label: string; }>; + /** + * URL of merge request + */ $mergeRequestUrl: string; + /** + * Name of project + */ $project: string; + /** + * Number of weeks the issue is open + */ $weeksOpen: number; } @@ -72,7 +123,13 @@ export interface IssueWithMeta extends Issue { */ export interface MergeRequestsForProjects { [projectId: string]: Array<{ + /** + * IID of issue + */ issue_iid: number; + /** + * URL of issue + */ web_url: string; }>; } @@ -100,11 +157,13 @@ export function getMergeRequestUrls(projectMergeRequests: MergeRequestsForProjec return []; } - return projectMergeRequests[projectId].filter((obj) => { - return obj.issue_iid === issueIid; - }).map((obj) => { - return obj.web_url; - }); + return projectMergeRequests[projectId] + .filter((obj) => { + return obj.issue_iid === issueIid; + }) + .map((obj) => { + return obj.web_url; + }); } /** @@ -115,17 +174,18 @@ export function getMergeRequestUrls(projectMergeRequests: MergeRequestsForProjec * @param groups List of groups to get issues for */ export async function getIssues(api: Api, label: string, groups: number[]): Promise { - const issueResults = await asyncPool(2, groups, (groupId) => { + const issueResults = await asyncPool(CONCURRENCY, groups, async (groupId) => { return api.getIssues({ groupId: groupId, }); }); - const issues = flatten2dArray(issueResults).filter((issue) => { - return issue.labels.indexOf(label) >= 0; - }); + const issues = flatten2dArray(issueResults) + .filter((issue) => { + return issue.labels.indexOf(label) >= 0; + }); - Logger.log('Fetched ' + issues.length + ' issue(s).'); + Logger.log(`Fetched ${issues.length} issue(s).`); return issues; } @@ -138,20 +198,23 @@ export async function getIssues(api: Api, label: string, groups: number[]): Prom */ export async function getIssueBranches( api: Api, - projects: Project[]): Promise<{ [k: string]: number[] }> { - const projectBranches: { [k: string]: number[] } = {}; + projects: Project[]): Promise<{ [k: string]: number[]; }> { + const projectBranches: { [k: string]: number[]; } = {}; - await asyncPool(2, projects, async (project) => { + await asyncPool(CONCURRENCY, projects, async (project) => { const branches = await api.getBranchesForProject(project.id); // extract issue number from branch - projectBranches[project.id] = branches.map((branch) => { - return branch.name.split('-')[0]; - }).filter((branchNameStart) => { - return branchNameStart.match(/^[0-9]+$/); - }).map((branchNameStart) => { - return parseInt(branchNameStart, 10); - }); + projectBranches[project.id] = branches + .map((branch) => { + return branch.name.split('-')[0]; + }) + .filter((branchNameStart) => { + return branchNameStart.match(/^[0-9]+$/); + }) + .map((branchNameStart) => { + return parseInt(branchNameStart, 10); + }); }); return projectBranches; @@ -166,7 +229,7 @@ export async function getIssueBranches( export async function getIssuesGroupedByAssignees(api: Api, label: string): Promise { const issuesByAssignee: IssuesGroupedByAssigneeId = {}; - const groups = flatten2dArray(await asyncPool(2, GROUPS, async (groupId) => { + const groups = flatten2dArray(await asyncPool(CONCURRENCY, GROUPS, async (groupId) => { return (await api.getSubGroupsForGroup(groupId)).map((group) => { return group.id; }); @@ -184,6 +247,7 @@ export async function getIssuesGroupedByAssignees(api: Api, label: string): Prom issues.forEach((issue) => { if (issue.assignee === null) { Logger.warn('Issue without assignee!', issue.web_url); + return; } @@ -217,8 +281,11 @@ export async function getIssuesGroupedByAssignees(api: Api, label: string): Prom }; }), $mergeRequestUrl: getMergeRequestUrls(mergeRequests, issue.project_id, issue.iid)[0], - $project: issue.web_url.replace('https://gitlab.com/', '').split('/issues/')[0], - $weeksOpen: moment().diff(moment(issue.created_at), 'weeks'), + $project: issue.web_url + .replace('https://gitlab.com/', '') + .split('/issues/')[0], + $weeksOpen: moment() + .diff(moment(issue.created_at), 'weeks'), }, }; @@ -229,25 +296,38 @@ export async function getIssuesGroupedByAssignees(api: Api, label: string): Prom }); // calculate quota - Object.keys(issuesByAssignee).forEach((_assigneeId) => { + for (const _assigneeId in issuesByAssignee) { + if (!issuesByAssignee.hasOwnProperty(_assigneeId)) { + continue; + } + const assigneeId = parseInt(_assigneeId, 10); issuesByAssignee[assigneeId].quota = Math.floor( issuesByAssignee[assigneeId].issueCounts.closed / (issuesByAssignee[assigneeId].issueCounts.opened + // tslint:disable-next-line:no-magic-numbers + issuesByAssignee[assigneeId].issueCounts.closed) * 100, ); - }); + } // sort issues by weight of labels and status - Object.keys(issuesByAssignee).forEach((_assigneeId) => { + for (const _assigneeId in issuesByAssignee) { + if (!issuesByAssignee.hasOwnProperty(_assigneeId)) { + continue; + } + const assigneeId = parseInt(_assigneeId, 10); issuesByAssignee[assigneeId].issues.sort((a, b) => { let weightA = 0; let weightB = 0; - Object.keys(LABEL_WEIGHTS).forEach((issueLabel) => { + for (const issueLabel in LABEL_WEIGHTS) { + if (!LABEL_WEIGHTS.hasOwnProperty(issueLabel)) { + continue; + } + if (a.labels.indexOf(issueLabel) >= 0) { weightA += LABEL_WEIGHTS[issueLabel]; } @@ -255,19 +335,21 @@ export async function getIssuesGroupedByAssignees(api: Api, label: string): Prom if (b.labels.indexOf(issueLabel) >= 0) { weightB += LABEL_WEIGHTS[issueLabel]; } - }); + } if (a.state === IssueState.CLOSED) { + // tslint:disable-next-line:no-magic-numbers weightA -= 10; } if (b.state === IssueState.CLOSED) { + // tslint:disable-next-line:no-magic-numbers weightB -= 10; } return weightB - weightA; }); - }); + } return Object.values(issuesByAssignee); } @@ -280,7 +362,12 @@ export function getNextMeetingDay() { const now = moment(); // get first wednesday of month - const meetingDayMoment = moment().startOf('month').hour(10).isoWeekday(3); + const meetingDayMoment = moment() + .startOf('month') + // tslint:disable-next-line:no-magic-numbers + .hour(10) + // tslint:disable-next-line:no-magic-numbers + .isoWeekday(3); while (meetingDayMoment.isBefore(now)) { // add one week until meeting day is after now @@ -290,7 +377,7 @@ export function getNextMeetingDay() { const meetingDay = meetingDayMoment.format('YYYY-MM-DD'); // log found meeting day - Logger.info('Generating report for ' + meetingDay + ' of ' + GROUPS.length + ' group(s)...'); + Logger.info(`Generating report for '${meetingDay}' of '${GROUPS.length}' group(s)...`); return meetingDay; } @@ -306,7 +393,7 @@ export async function getMergeRequests(api: Api, const projectMergeRequests: MergeRequestsForProjects = {}; // iterate over projects - await asyncPool(2, projects, async (project) => { + await asyncPool(CONCURRENCY, projects, async (project) => { // check if project can have merge requests if (!project.merge_requests_enabled) { return; @@ -316,17 +403,20 @@ export async function getMergeRequests(api: Api, const mergeRequests = await api.getMergeRequests(MembershipScope.PROJECTS, project.id, MergeRequestState.OPENED); // extract issue number from merge request - projectMergeRequests[project.id] = mergeRequests.map((mergeRequest) => { - // keep information about web url too - return {issue_iid: mergeRequest.source_branch.split('-')[0], web_url: mergeRequest.web_url}; - }).filter((branchNameStartAndUrl) => { - return branchNameStartAndUrl.issue_iid.match(/^[0-9]+$/); - }).map((branchNameStartAndUrl) => { - return { - issue_iid: parseInt(branchNameStartAndUrl.issue_iid, 10), - web_url: branchNameStartAndUrl.web_url, - }; - }); + projectMergeRequests[project.id] = mergeRequests + .map((mergeRequest) => { + // keep information about web url too + return {issue_iid: mergeRequest.source_branch.split('-')[0], web_url: mergeRequest.web_url}; + }) + .filter((branchNameStartAndUrl) => { + return branchNameStartAndUrl.issue_iid.match(/^[0-9]+$/); + }) + .map((branchNameStartAndUrl) => { + return { + issue_iid: parseInt(branchNameStartAndUrl.issue_iid, 10), + web_url: branchNameStartAndUrl.web_url, + }; + }); }); return projectMergeRequests; @@ -345,7 +435,8 @@ export async function generateReport(api: Api, label: string, template: string): const structureForTemplate: StructureForTemplate = { issuesByAssignee: issuesGroupedByAssignee, meetingDay: getNextMeetingDay(), - timestamp: moment().format('LLL'), + timestamp: moment() + .format('LLL'), }; return render( @@ -369,7 +460,7 @@ export async function report(api: Api, label: string) { (await readFilePromisified(resolve(__dirname, '..', '..', 'templates', 'report.md.mustache'))).toString(), ); - let filename = join(cwd(), 'reports', meetingDay + '.md'); + let filename = join(cwd(), 'reports', `${meetingDay}.md`); if (label !== 'meeting') { filename = join(cwd(), 'reports', `${label}.md`); diff --git a/src/tasks/tidy.ts b/src/tasks/tidy.ts index e30cfaac..3e7be89f 100644 --- a/src/tasks/tidy.ts +++ b/src/tasks/tidy.ts @@ -17,7 +17,15 @@ import {Api} from '@openstapps/gitlab-api'; import {AccessLevel, IssueState, MembershipScope, Milestone, Project, Scope} from '@openstapps/gitlab-api/lib/types'; import {Logger} from '@openstapps/logger'; import {flatten2dArray, getProjects} from '../common'; -import {GROUPS, NEEDED_LABELS, NEEDED_MILESTONES, NOTE_PREFIX, PROTECTED_BRANCHES, SCHOOLS} from '../configuration'; +import { + CONCURRENCY, + GROUPS, + NEEDED_LABELS, + NEEDED_MILESTONES, + NOTE_PREFIX, + PROTECTED_BRANCHES, + SCHOOLS, +} from '../configuration'; /** * Tidy issues without milestone @@ -28,7 +36,7 @@ import {GROUPS, NEEDED_LABELS, NEEDED_MILESTONES, NOTE_PREFIX, PROTECTED_BRANCHE */ export async function tidyIssuesWithoutMilestone(api: Api): Promise { // fetch issues without milestone from all groups - const issueResults = await asyncPool(3, GROUPS, (groupId) => { + const issueResults = await asyncPool(CONCURRENCY, GROUPS, async (groupId) => { return api.getIssues({ groupId: groupId, milestone: 'No Milestone', @@ -39,11 +47,11 @@ export async function tidyIssuesWithoutMilestone(api: Api): Promise { // flatten structure, e.g. put all issues in one array const issuesWithoutMilestone = flatten2dArray(issueResults); - Logger.info('Found `' + issuesWithoutMilestone.length + '` issue(s) without milestone.'); + Logger.info(`Found '${issuesWithoutMilestone.length}' issue(s) without milestone.`); - const milestoneCache: { [s: number]: Milestone[] } = {}; + const milestoneCache: { [s: number]: Milestone[]; } = {}; - await asyncPool(5, issuesWithoutMilestone, async (issue) => { + await asyncPool(CONCURRENCY, issuesWithoutMilestone, async (issue) => { if (typeof milestoneCache[issue.project_id] === 'undefined') { milestoneCache[issue.project_id] = await api.getMilestonesForProject(issue.project_id); } @@ -57,19 +65,20 @@ export async function tidyIssuesWithoutMilestone(api: Api): Promise { }); if (milestoneId === null) { - Logger.warn('Milestone `Meeting` was not available for issue ' + issue.title + ' (' + issue.web_url + ').'); + Logger.warn(`Milestone 'Meeting' was not available for issue ${issue.title} (${issue.web_url}).`); + return; } await api.setMilestoneForIssue(issue, milestoneId); - Logger.log('Milestone was set to `Meeting` for issue ' + issue.title + ' (' + issue.web_url + ').'); + Logger.log(`Milestone was set to 'Meeting' for issue ${issue.title} (${issue.web_url})`); await api.createNote( issue.project_id, Scope.ISSUES, issue.iid, - `${NOTE_PREFIX} Milestone was set automatically to \`Meeting\`.`, + `${NOTE_PREFIX} Milestone was set automatically to 'Meeting'.`, ); }); @@ -85,7 +94,7 @@ export async function tidyIssuesWithoutMilestone(api: Api): Promise { */ export async function tidyOpenIssuesWithoutMeetingLabel(api: Api): Promise { // fetch all open issues - const issueResults = await asyncPool(3, GROUPS, (groupId) => { + const issueResults = await asyncPool(CONCURRENCY, GROUPS, async (groupId) => { return api.getIssues({ groupId: groupId, state: IssueState.OPENED, @@ -104,9 +113,10 @@ export async function tidyOpenIssuesWithoutMeetingLabel(api: Api): Promise Logger.info(`Filtered ${openIssuesWithoutMeetingLabel.length} open issue(s) without label 'meeting'.`); - await asyncPool(5, openIssuesWithoutMeetingLabel, async (issue) => { + await asyncPool(CONCURRENCY, openIssuesWithoutMeetingLabel, async (issue) => { if (issue.milestone !== null && issue.milestone.title === 'Backlog') { Logger.info(`Skipping issue "${issue.title}" because it is in backlog.`); + return; } @@ -127,7 +137,7 @@ export async function tidyOpenIssuesWithoutMeetingLabel(api: Api): Promise * @param projects List of projects to tidy labels on */ export async function tidyLabels(api: Api, projects: Project[]): Promise { - await asyncPool(5, projects, async (project) => { + await asyncPool(CONCURRENCY, projects, async (project) => { const labels = await api.getLabels(project.id); const neededLabels = NEEDED_LABELS.slice(0); @@ -148,10 +158,10 @@ export async function tidyLabels(api: Api, projects: Project[]): Promise { } */ }); - await asyncPool(2, neededLabels, async (neededLabel) => { + await asyncPool(CONCURRENCY, neededLabels, async (neededLabel) => { await api.createLabel(project.id, neededLabel.name, neededLabel.description, neededLabel.color); - Logger.log('Created label `' + neededLabel.name + '` in ' + project.name_with_namespace + '.'); + Logger.log(`Created label '${neededLabel.name}' in '${project.name_with_namespace}'.`); }); // await asyncPool(2, extraneousLabels, async (extraneousLabel) => { @@ -171,7 +181,7 @@ export async function tidyLabels(api: Api, projects: Project[]): Promise { * @param projects List of projects to tidy milestones on */ export async function tidyMilestones(api: Api, projects: Project[]): Promise { - await asyncPool(5, projects, async (project) => { + await asyncPool(CONCURRENCY, projects, async (project) => { const milestones = await api.getMilestonesForProject(project.id); const missingMilestones = NEEDED_MILESTONES.slice(0); @@ -184,9 +194,9 @@ export async function tidyMilestones(api: Api, projects: Project[]): Promise 0) { - await asyncPool(2, missingMilestones, async (milestone) => { + await asyncPool(CONCURRENCY, missingMilestones, async (milestone) => { await api.createMilestone(project.id, milestone); - Logger.log('Created milestone ' + milestone + ' for project ' + project.name_with_namespace + '.'); + Logger.log(`Created milestone '${milestone}' for project ${project.name_with_namespace}'.`); }); } }); @@ -201,7 +211,7 @@ export async function tidyMilestones(api: Api, projects: Project[]): Promise { - await asyncPool(2, projects, async (project) => { + await asyncPool(CONCURRENCY, projects, async (project) => { const branches = await api.getBranchesForProject(project.id); const protectableBranches = branches.filter((branch) => { @@ -212,10 +222,10 @@ export async function tidyProtectedBranches(api: Api, projects: Project[]): Prom return !branch.protected; }); - await asyncPool(2, unprotectedBranches, async (branch) => { + await asyncPool(CONCURRENCY, unprotectedBranches, async (branch) => { await api.protectBranch(project.id, branch.name); - Logger.log('Added protected branch `' + branch.name + '` in project `' + project.name_with_namespace + '`...'); + Logger.log(`Added protected branch '${branch.name}' in project '${project.name_with_namespace}'...`); }); }); @@ -229,18 +239,31 @@ export async function tidyProtectedBranches(api: Api, projects: Project[]): Prom * @param projects List of projects to tidy protected tags on */ export async function tidyProtectedTags(api: Api, projects: Project[]): Promise { - await asyncPool(2, projects, async (project) => { + await asyncPool(CONCURRENCY, projects, async (project) => { + // TODO: move this to GitLab API const protectedTags: Array<{ + /** + * List of access levels to create a tag + */ create_access_levels: Array<{ - access_level: AccessLevel, + /** + * Access level + */ + access_level: AccessLevel; + /** + * Description of access level + */ access_level_description: string; }>; + /** + * Name of the tag + */ name: string; }> = await api.makeGitLabAPIRequest(`projects/${project.id}/protected_tags`); - if (!protectedTags.find((protectedTag) => { + if (protectedTags.findIndex((protectedTag) => { return protectedTag.name === 'v*'; - })) { + }) >= 0) { await api.makeGitLabAPIRequest(`projects/${project.id}/protected_tags`, { data: { create_access_level: AccessLevel.Maintainer, @@ -265,34 +288,35 @@ export async function tidySubGroupMembers(api: Api): Promise { const stappsMembers = await api.getMembers(MembershipScope.GROUPS, GROUPS[0]); const stappsMemberIds = stappsMembers.map((member) => member.id); - const groupIdsToSchool: any = {}; + const groupIdsToSchool: { [id: number]: string; } = {}; - Object.keys(SCHOOLS).map((school) => { - groupIdsToSchool[SCHOOLS[school]] = school; - }); + Object.keys(SCHOOLS) + .map((school) => { + groupIdsToSchool[SCHOOLS[school]] = school; + }); - await asyncPool(2, GROUPS.slice(1), async (groupId) => { + await asyncPool(CONCURRENCY, GROUPS.slice(1), async (groupId) => { const members = await api.getMembers(MembershipScope.GROUPS, groupId); const memberIds = members.map((member) => member.id); - await asyncPool(2, stappsMembers, async (stappsMember) => { + await asyncPool(CONCURRENCY, stappsMembers, async (stappsMember) => { if (memberIds.indexOf(stappsMember.id) === -1) { await api.addMember(MembershipScope.GROUPS, groupId, stappsMember.id, AccessLevel.Developer); - Logger.log('Added ' + stappsMember.name + ' to group ' + groupIdsToSchool[groupId] + '.'); + Logger.log(`Added '${stappsMember.name}' to group '${groupIdsToSchool[groupId]}'.`); } }); - await asyncPool(2, members, async (member) => { + await asyncPool(CONCURRENCY, members, async (member) => { if (stappsMemberIds.indexOf(member.id) === -1) { await api.deleteMember(MembershipScope.GROUPS, groupId, member.id); - Logger.log('Deleted member ' + member.name + ' from group ' + groupIdsToSchool[groupId] + '.'); + Logger.log(`Deleted member '${member.name}' from group '${groupIdsToSchool[groupId]}'.`); } }); }); - Logger.ok('Tidied "sub" group members.'); + Logger.ok(`Tidied 'sub' group members.`); } /** @@ -300,11 +324,11 @@ export async function tidySubGroupMembers(api: Api): Promise { * * Set assignee to author if no assignee is set. * - * @param api + * @param api GitLab API instance to use for the requests */ export async function tidyIssuesWithoutAssignee(api: Api): Promise { // fetch issues without milestone from all groups - const issueResults = await asyncPool(3, GROUPS, (groupId) => { + const issueResults = await asyncPool(CONCURRENCY, GROUPS, async (groupId) => { return api.getIssues({ groupId: groupId, state: IssueState.OPENED, @@ -318,12 +342,12 @@ export async function tidyIssuesWithoutAssignee(api: Api): Promise { return issue.assignee === null; }); - Logger.info('Found `' + issuesWithoutAssignee.length + '` issue(s) without assignee.'); + Logger.info(`Found '${issuesWithoutAssignee.length}' issue(s) without assignee.`); - await asyncPool(3, issuesWithoutAssignee, async (issue) => { + await asyncPool(CONCURRENCY, issuesWithoutAssignee, async (issue) => { await api.setAssigneeForIssue(issue, issue.author.id); - Logger.log('Set assignee for `' + issue.title + '` to ' + issue.author.name + '.'); + Logger.log(`Set assignee for '${issue.title}' to '${issue.author.name}'.`); await api.createNote( issue.project_id, diff --git a/src/tasks/unlabel.ts b/src/tasks/unlabel.ts index bf4ee56d..7af900df 100644 --- a/src/tasks/unlabel.ts +++ b/src/tasks/unlabel.ts @@ -17,7 +17,7 @@ import {Api} from '@openstapps/gitlab-api'; import {IssueState, Scope} from '@openstapps/gitlab-api/lib/types'; import {Logger} from '@openstapps/logger'; import {flatten2dArray} from '../common'; -import {GROUPS, NOTE_PREFIX} from '../configuration'; +import {CONCURRENCY, GROUPS, NOTE_PREFIX} from '../configuration'; /** * Remove label `meeting` from closed issues @@ -25,7 +25,7 @@ import {GROUPS, NOTE_PREFIX} from '../configuration'; * @param api Instance of GitLabAPI to send requests with */ export async function unlabel(api: Api) { - const issueResults = await asyncPool(3, GROUPS, (groupId) => { + const issueResults = await asyncPool(CONCURRENCY, GROUPS, async (groupId) => { return api.getIssues({ groupId: groupId, state: IssueState.CLOSED, @@ -34,9 +34,9 @@ export async function unlabel(api: Api) { const issues = flatten2dArray(issueResults); - Logger.log('Fetched ' + issues.length + ' closed issue(s).'); + Logger.log(`Fetched ${issues.length} closed issue(s).`); - await asyncPool(1, issues, async (issue) => { + await asyncPool(CONCURRENCY, issues, async (issue) => { if (issue.labels.indexOf('meeting') >= 0) { Logger.info(`Issue ${issue.title} is closed and has label "meeting". Removing it.`);