refactor: move logger to monorepo

This commit is contained in:
2023-03-14 17:18:13 +01:00
parent 2428042fa3
commit e9185d248b
43 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,85 @@
/*
* Copyright (C) 2019-2022 Open StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Api} from '@openstapps/gitlab-api';
import {Logger} from '@openstapps/logger';
import {AddLogLevel} from '@openstapps/logger/lib/transformations/add-log-level';
import {Colorize} from '@openstapps/logger/lib/transformations/colorize';
import {Command} from 'commander';
import {existsSync, readFileSync} from 'fs';
import path from 'path';
import {cwd, stdout} from 'process';
import {GITLAB_API_URL} from './configuration';
import {getUsedVersionMajorMinor} from './tasks/get-used-version';
import {remind} from './tasks/remind';
import {tidy} from './tasks/tidy';
import {unlabel} from './tasks/unlabel';
// add default handler for unhandled rejections
process.on('unhandledRejection', async reason => {
await (reason instanceof Error
? Logger.error('Unhandled rejection', reason.stack)
: Logger.error('Unhandled rejection', reason));
process.exit(1);
});
// instantiate new commander
const commander = new Command('openstapps-projectmanagement');
// error on unknown commands
commander.on('command:*', async () => {
await Logger.error(
'Invalid command: %s\nSee --help for a list of available commands.',
commander.args.join(' '),
);
process.exit(1);
});
const gitlabApi = new Api(GITLAB_API_URL, process.env.GITLAB_PRIVATE_TOKEN as string);
Logger.setTransformations([new AddLogLevel(), new Colorize()]);
// eslint-disable-next-line unicorn/prefer-module
if (existsSync(path.join(__dirname, 'package.json'))) {
// eslint-disable-next-line unicorn/prefer-module
commander.version(JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json')).toString()).version);
}
commander.command('unlabel').action(async () => {
await unlabel(gitlabApi);
Logger.ok('Done!');
});
commander.command('tidy').action(async () => {
await tidy(gitlabApi);
Logger.ok('Done!');
});
commander.command('remind').action(async () => {
await remind(gitlabApi);
Logger.ok('Done!');
});
commander.command('get-used-version <dependency> [path]').action(async (dependency, filePath) => {
let fallbackPath = cwd();
if (typeof filePath === 'string' && filePath.length > 0) {
fallbackPath = path.resolve(filePath);
}
stdout.write(await getUsedVersionMajorMinor(fallbackPath, dependency));
});
commander.parse(process.argv);

View File

@@ -0,0 +1,75 @@
/*
* Copyright (C) 2018-2022 Open StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {asyncPool} from '@krlwlfrt/async-pool';
import {Api} from '@openstapps/gitlab-api';
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);
/**
* Get projects for a list of groups
*
* @param api GitLab API to make requests with
* @param groups List of groups
*/
export async function getProjects(api: Api, groups: number[]): Promise<Project[]> {
Logger.info(`Fetching all projects for specified groups (${groups.length})...`);
const projectResults = await asyncPool(CONCURRENCY, groups, async groupId => {
return api.getProjectsForGroup(groupId);
});
const projects = flatten2dArray(projectResults);
Logger.log(`Fetched ${projects.length} project(s).`);
return projects;
}
/**
* Get subgroups for a list of groups
*
* @param api GitLab API to make requests with
* @param groups List of groups
*/
export async function getSubGroups(api: Api, groups: number[]): Promise<Group[]> {
return flatten2dArray(
await asyncPool(CONCURRENCY, groups, async groupId => {
return api.getSubGroupsForGroup(groupId);
}),
);
}
/**
* Flatten 2d array
*
* @param array Flattened array
*/
export function flatten2dArray<T>(array: T[][]): T[] {
// eslint-disable-next-line unicorn/prefer-spread
return ([] as T[]).concat(...array);
}

View File

@@ -0,0 +1,213 @@
/*
* Copyright (C) 2018-2022 Open StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Label} from '@openstapps/gitlab-api/lib/types';
import moment from 'moment';
/**
* List of schools with their IDs
*/
export const SCHOOLS: {[school: string]: number} = {};
/**
* ID OF openstapps main group
*/
const STAPPS_GROUP_ID = 4_088_298;
/**
* List of group IDs to fetch issues for
*/
export const GROUPS: number[] = [
STAPPS_GROUP_ID,
...Object.keys(SCHOOLS).map(school => {
return SCHOOLS[school];
}),
];
/**
*
*/
export const LABEL_WEIGHTS: {[key: string]: number} = {
bug: 1,
critical: 2,
};
/**
* List of labels to print bold in report
*/
export const BOLD_LABELS: string[] = ['bug', 'critical'];
/**
* GitLab API URL
*/
export const GITLAB_API_URL = 'https://gitlab.com/api/v4/';
/**
* Milestones to add to projects
*/
export const NEEDED_MILESTONES = ['Backlog'];
/**
* Protected branches
*/
export const PROTECTED_BRANCHES = ['develop', 'master'];
/**
* Labels to add to all projects
*/
export const NEEDED_LABELS: Label[] = [
// eslint-disable-next-line unicorn/no-useless-spread
...[
{
color: '#FF0000',
description: 'An error/something that is not working as expected',
name: 'bug',
},
{
color: '#5CB85C',
name: 'consistency',
},
{
color: '#FF0000',
name: 'confirmed',
},
{
color: '#FF0000',
description: 'A blocking issue/something that needs to be fixed ASAP',
name: 'critical',
},
{
color: '#428BCA',
name: 'design',
},
{
color: '#0033CC',
description: 'An issue about the documentation of the software',
name: 'documentation',
},
{
color: '#5CB85C',
name: 'Doing',
},
{
color: '#5CB85C',
description: 'A feature proposal/something that will be developed',
name: 'feature',
},
{
color: '#7F8C8D',
description: 'An issue that is unimportant or invalid',
name: 'invalid',
},
{
color: '#FFFF88',
name: 'meeting',
},
{
color: '#8E44AD',
name: 'organization',
},
{
color: '#FF0000',
description: 'An issue with the performance of the software',
name: 'performance',
},
{
color: '#69D100',
name: 'refactoring',
},
{
color: '#FF0000',
description: 'An issue with the security of the software',
name: 'security',
},
{
color: '#D1D100',
description: 'An issue about the testing procedure of the software',
name: 'testing',
},
{
color: '#F0AD4E',
name: 'To Do',
},
{
color: '#A8D695',
description: 'An issue with low priority',
name: 'unimportant',
},
{
color: '#D10069',
description: 'An issue with the usability of the software',
name: 'usability',
},
{
color: '#428BCA',
description: 'Feedback from the feedback-module of the app',
name: 'user-feedback',
},
],
...Object.keys(SCHOOLS).map(school => {
return {
color: '#F0AD4E',
description: 'An issue that specifically applies to this school',
name: `school-${school}`,
};
}),
...['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
*/
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;
/**
* Maximum depth for merge request reminders
*/
export const MAX_DEPTH_FOR_REMINDER = 2;
/**
* Next meeting
*/
export const NEXT_MEETING = moment()
.startOf('week')
// tslint:disable-next-line:no-magic-numbers
.hour(10)
// tslint:disable-next-line:no-magic-numbers
.day(3);
if (NEXT_MEETING.isBefore(moment())) {
NEXT_MEETING.add(1, 'week');
}
/**
* Last meeting
*/
export const LAST_MEETING = moment(NEXT_MEETING).subtract(1, 'week');

View File

@@ -0,0 +1,63 @@
/*
* Copyright (C) 2019-2022 Open StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {existsSync, PathLike} from 'fs';
import path from 'path';
import {readFilePromisified} from '../common';
/**
* Get used version of a dependency of a project referenced by a path
*
* @param directoryPath Path to a Node.js project directory
* @param dependency Dependency to get used version of
*/
export async function getUsedVersion(directoryPath: PathLike, dependency: string): Promise<string> {
if (!existsSync(path.join(directoryPath.toString(), 'package.json'))) {
throw new Error(`'package.json' does not exist in '${directoryPath}'. Not a Node.js project?`);
}
const buffer = await readFilePromisified(path.join(directoryPath.toString(), 'package.json'));
const content = buffer.toString();
const packageJson = JSON.parse(content);
if (typeof packageJson.dependencies !== 'object') {
throw new TypeError(`Project in '${directoryPath}' has no dependencies!`);
}
if (typeof packageJson.dependencies[dependency] !== 'string') {
throw new TypeError(`Project in '${directoryPath}' does not depend on '${dependency}'.`);
}
return packageJson.dependencies[dependency];
}
/**
* Get 'MAJOR.MINOR' part of a used version
*
* See [[getUsedVersion]].
*
* @param path see [[getUsedVersion]]
* @param dependency see [[getUsedVersion]]
*/
export async function getUsedVersionMajorMinor(path: PathLike, dependency: string): Promise<string> {
const usedVersions = await getUsedVersion(path, dependency);
const versionMatch = usedVersions.match(/([0-9]+\.[0-9]+)\.[0-9]+/);
// istanbul ignore if
if (versionMatch === null) {
throw new Error(`Used version of '${dependency}' of project in '${path}' could not be determined.`);
}
return versionMatch[1];
}

View File

@@ -0,0 +1,189 @@
/*
* Copyright (C) 2019-2022 Open StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {asyncPool} from '@krlwlfrt/async-pool';
import {Api} from '@openstapps/gitlab-api';
import {
AccessLevel,
MembershipScope,
MergeRequestMergeStatus,
MergeRequestState,
Scope,
User,
} from '@openstapps/gitlab-api/lib/types';
import {Logger} from '@openstapps/logger';
import {WebClient} from '@slack/client';
import {CONCURRENCY, GROUPS, MAX_DEPTH_FOR_REMINDER, 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<void> {
// get list of open merge requests
const allMergeRequests = await api.getMergeRequests(
MembershipScope.GROUPS,
GROUPS[0],
MergeRequestState.OPENED,
);
const mergeRequests = allMergeRequests.filter(mergeRequest => {
const parts = mergeRequest.web_url.split('/');
// remove protocol, server name and main group
parts.splice(0, parts.indexOf('gitlab.com') + 1 + 1);
// remove merge_requests and INDEX parts
parts.splice(-1 - 1);
return parts.length <= MAX_DEPTH_FOR_REMINDER;
});
Logger.info(`Found ${mergeRequests.length} open merge requests.`);
// instantiate slack client
const client =
typeof process.env.SLACK_API_TOKEN !== 'undefined'
? new WebClient(process.env.SLACK_API_TOKEN)
: undefined;
// get members of main group
const members = await api.getMembers(MembershipScope.GROUPS, GROUPS[0]);
// filter members with at least maintainer status
const maintainers = members.filter(member => member.access_level >= AccessLevel.Maintainer);
// extract maintainer's usernames
const maintainerUsernames = maintainers.map(maintainer => maintainer.username);
// sort maintainer's usernames alphabetically
maintainerUsernames.sort((a, b) => {
return a.localeCompare(b);
});
Logger.info(`Found ${maintainers.length} maintainer(s).`);
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;
}
// get merge request approval
const approval = await api.getMergeRequestApproval(mergeRequest.project_id, mergeRequest.iid);
// get merge request discussions
const discussions = await api.getMergeRequestDiscussions(mergeRequest.project_id, mergeRequest.iid);
// check if at least one of the discussions is unresolved
const hasUnresolvedDiscussions = discussions.some(discussion => {
return discussion.notes.some(note => {
return note.resolvable && (typeof note.resolved === 'undefined' || !note.resolved);
});
});
if (hasUnresolvedDiscussions) {
let recipient = mergeRequest.author.username;
if (typeof mergeRequest.assignee !== 'undefined' && mergeRequest.assignee !== null) {
recipient = mergeRequest.assignee.username;
}
// create note in merge request
await api.createNote(
mergeRequest.project_id,
Scope.MERGE_REQUESTS,
mergeRequest.iid,
`${NOTE_PREFIX} Please resolve pending discussions, @${recipient}!`,
);
return;
}
if (approval.merge_status === MergeRequestMergeStatus.CAN_BE_MERGED) {
if (approval.approvals_left > 0) {
Logger.warn(`Merge request '${mergeRequest.title}' needs more approvals!`);
// get possible appropers, prefixed with '@' and joined with commas
const possibleApprovers = maintainerUsernames
.filter(username => {
if (mergeRequest.assignee.username === username) {
return false;
}
if (username.includes('openstapps') || username.includes('kphilipp')) {
return false;
}
if (approval.approved_by.length === 0) {
return true;
}
return approval.approved_by.find(
(approver: {
/**
* Possible approver
*/
user: User;
}) => {
return approver.user.username !== username;
},
);
})
.map(username => `@${username}`)
.join(' ');
// send message to slack
await client?.chat.postMessage({
channel: SLACK_CHANNEL,
text: `Merge request '${mergeRequest.title}' needs more approvals! See ${mergeRequest.web_url}!`,
});
// assign reviewers
await api.createNote(
mergeRequest.project_id,
Scope.MERGE_REQUESTS,
mergeRequest.iid,
`/assign_reviewer ${possibleApprovers}`,
);
} else {
Logger.log(`Merge request '${mergeRequest.title}' is ready to be merged!`);
// send message to slack
await client?.chat.postMessage({
channel: SLACK_CHANNEL,
text: `Merge request '${mergeRequest.title}' is ready to be merged! See ${mergeRequest.web_url}!`,
});
// prefix maintainers with '@' and join with commas
const possibleMergers = maintainerUsernames
.filter(username => {
return mergeRequest.assignee.username !== username;
})
.map(username => `@${username}`)
.join(', ');
// create note in merge request
await api.createNote(
mergeRequest.project_id,
Scope.MERGE_REQUESTS,
mergeRequest.iid,
`${NOTE_PREFIX} Merge request is ready to be merged, ${possibleMergers}!`,
);
}
}
});
}

View File

@@ -0,0 +1,468 @@
/*
* Copyright (C) 2018-2022 Open StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {asyncPool} from '@krlwlfrt/async-pool';
import {Api} from '@openstapps/gitlab-api';
import {
Issue,
IssueState,
MembershipScope,
MergeRequestState,
Project,
User,
} from '@openstapps/gitlab-api/lib/types';
import {Logger} from '@openstapps/logger';
import moment from 'moment';
import {render} from 'mustache';
import path from 'path';
import {cwd} from 'process';
import {flatten2dArray, getProjects, readFilePromisified, writeFilePromisified} from '../common';
import {BOLD_LABELS, CONCURRENCY, GROUPS, LABEL_WEIGHTS, NEXT_MEETING} 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;
}
/**
* A map
*/
export interface IssuesGroupedByAssigneeId {
[assigneeId: number]: AssigneeWithIssues;
}
/**
* 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;
}
/**
* 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;
}
/**
* Merge request data
*/
export interface MergeRequestsForProjects {
[projectId: string]: Array<{
/**
* IID of issue
*/
issue_iid: number;
/**
* URL of issue
*/
web_url: string;
}>;
}
/**
* Check if issue state is opened or closed
*
* @param state State to check
*/
export function issueStateIsOpenedOrClosed(
state: IssueState,
): state is IssueState.OPENED | IssueState.CLOSED {
return ['opened', 'closed'].includes(state);
}
/**
* Get merge request URLs from given data
*
* @param projectMergeRequests Merge requests data (object containing array of objects)
* @param projectId Project ID to get data about merge requests for
* @param issueIid Issue IID in certain project (relative ID, and not issue's GitLab API ID)
*/
export function getMergeRequestUrls(
projectMergeRequests: MergeRequestsForProjects,
projectId: number,
issueIid: number,
): string[] {
if (
typeof projectMergeRequests[projectId] === 'undefined' ||
projectMergeRequests[projectId].length === 0
) {
return [];
}
return projectMergeRequests[projectId]
.filter(object => {
return object.issue_iid === issueIid;
})
.map(object => {
return object.web_url;
});
}
/**
* Get issues from all groups with a specific milestone
*
* @param api GitLab API to make requests with
* @param label Label to filter by
* @param groups List of groups to get issues for
*/
export async function getIssues(api: Api, label: string, groups: number[]): Promise<Issue[]> {
const issueResults = await asyncPool(CONCURRENCY, groups, async groupId => {
return api.getIssues({
groupId: groupId,
});
});
const issues = flatten2dArray(issueResults).filter(issue => {
return issue.labels.includes(label);
});
Logger.log(`Fetched ${issues.length} issue(s).`);
return issues;
}
/**
* Get IDs of issues with branches for projects
*
* @param api GitLab API To make requests with
* @param projects List of projects
*/
export async function getIssueBranches(api: Api, projects: Project[]): Promise<{[k: string]: number[]}> {
const projectBranches: {[k: string]: number[]} = {};
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 Number.parseInt(branchNameStart, 10);
});
});
return projectBranches;
}
/**
* Get issues grouped by assignees
*
* @param api GitLab API to make requests with
* @param label Label to generate report for
*/
export async function getIssuesGroupedByAssignees(api: Api, label: string): Promise<AssigneeWithIssues[]> {
const issuesByAssignee: IssuesGroupedByAssigneeId = {};
const groups = flatten2dArray(
await asyncPool(CONCURRENCY, GROUPS, async groupId => {
const subGroups = await api.getSubGroupsForGroup(groupId);
return subGroups.map(group => {
return group.id;
});
}),
);
groups.push(...groups, ...GROUPS);
const [issues, projects] = await Promise.all([getIssues(api, label, groups), getProjects(api, groups)]);
const issueBranches = await getIssueBranches(api, projects);
const mergeRequests = await getMergeRequests(api, projects);
for (const issue of issues) {
if (issue.assignee === null) {
Logger.warn('Issue without assignee!', issue.web_url);
continue;
}
if (typeof issuesByAssignee[issue.assignee.id] === 'undefined') {
issuesByAssignee[issue.assignee.id] = {
assignee: issue.assignee,
issueCounts: {
closed: 0,
opened: 0,
},
issues: [],
quota: 0,
};
}
if (issue.state === IssueState.REOPENED) {
issue.state = IssueState.OPENED;
}
const issueMeta = {
$branchExists:
typeof issueBranches[issue.project_id] !== 'undefined' &&
issueBranches[issue.project_id].includes(issue.iid),
$closed: issue.state === IssueState.CLOSED,
$issue: issue,
$labels: issue.labels.map((issueLabel: string) => {
return {
bold: BOLD_LABELS.includes(issueLabel),
label: issueLabel,
};
}),
$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'),
};
const issueWithMeta: IssueWithMeta = {
...issue,
...issueMeta,
};
if (issueStateIsOpenedOrClosed(issue.state)) {
issuesByAssignee[issue.assignee.id].issueCounts[issue.state]++;
issuesByAssignee[issue.assignee.id].issues.push(issueWithMeta);
}
}
// calculate quota
for (const _assigneeId in issuesByAssignee) {
if (!issuesByAssignee.hasOwnProperty(_assigneeId)) {
continue;
}
const assigneeId = Number.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
for (const _assigneeId in issuesByAssignee) {
if (!issuesByAssignee.hasOwnProperty(_assigneeId)) {
continue;
}
const assigneeId = Number.parseInt(_assigneeId, 10);
issuesByAssignee[assigneeId].issues.sort((a, b) => {
let weightA = 0;
let weightB = 0;
for (const issueLabel in LABEL_WEIGHTS) {
if (!LABEL_WEIGHTS.hasOwnProperty(issueLabel)) {
continue;
}
if (a.labels.includes(issueLabel)) {
weightA += LABEL_WEIGHTS[issueLabel];
}
if (b.labels.includes(issueLabel)) {
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);
}
/**
* Get next meeting day
*/
export function getNextMeetingDay() {
const meetingDay = NEXT_MEETING.format('YYYY-MM-DD');
// log found meeting day
Logger.info(`Generating report for '${meetingDay}' of '${GROUPS.length}' group(s)...`);
return meetingDay;
}
/**
* Get a list of merge requests for projects
*
* @param api GitLab API to make requests with
* @param projects List of projects
*/
export async function getMergeRequests(api: Api, projects: Project[]): Promise<MergeRequestsForProjects> {
const projectMergeRequests: MergeRequestsForProjects = {};
// iterate over projects
await asyncPool(CONCURRENCY, projects, async project => {
// check if project can have merge requests
if (!project.merge_requests_enabled) {
return;
}
// get all merge requests for project
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: Number.parseInt(branchNameStartAndUrl.issue_iid, 10),
web_url: branchNameStartAndUrl.web_url,
};
});
});
return projectMergeRequests;
}
/**
* Generate a report
*
* @param api GitLab API to make requests with
* @param label Label to generate report for
* @param template Template to generate report with
*/
export async function generateReport(api: Api, label: string, template: string): Promise<string> {
const issuesGroupedByAssignee = await getIssuesGroupedByAssignees(api, label);
const structureForTemplate: StructureForTemplate = {
issuesByAssignee: issuesGroupedByAssignee,
meetingDay: getNextMeetingDay(),
timestamp: moment().format('LLL'),
};
return render(template, structureForTemplate);
}
/**
* Generate a markdown report
*
* @param api GitLab API to make requests with
* @param label Label to generate report for
*/
export async function report(api: Api, label: string) {
const meetingDay = getNextMeetingDay();
const readReportFile = await readFilePromisified(
// eslint-disable-next-line unicorn/prefer-module
path.resolve(__dirname, '..', '..', 'templates', 'report.md.mustache'),
);
const markdown = await generateReport(api, label, readReportFile.toString());
let filename = path.join(cwd(), 'reports', `${meetingDay}.md`);
if (label !== 'meeting') {
filename = path.join(cwd(), 'reports', `${label}.md`);
}
await writeFilePromisified(filename, markdown);
Logger.ok(`Wrote file '${filename}'.`);
}

View File

@@ -0,0 +1,436 @@
/*
* Copyright (C) 2018-2022 Open StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {asyncPool} from '@krlwlfrt/async-pool';
import {Api} from '@openstapps/gitlab-api';
import {
AccessLevel,
IssueState,
MembershipScope,
MergeRequestState,
Milestone,
Project,
Scope,
} from '@openstapps/gitlab-api/lib/types';
import {Logger} from '@openstapps/logger';
import {flatten2dArray, getProjects} from '../common';
import {
CONCURRENCY,
GROUPS,
NEEDED_LABELS,
NEEDED_MILESTONES,
NOTE_PREFIX,
PROTECTED_BRANCHES,
SCHOOLS,
} from '../configuration';
/**
* Tidy issues without milestone
*
* This will set the milestone of issues without milestone to 'Meeting'.
*
* @param api GitLab API instance to use for the requests
*/
export async function tidyIssuesWithoutMilestone(api: Api): Promise<void> {
// fetch issues without milestone from all groups
const issueResults = await asyncPool(CONCURRENCY, GROUPS, async groupId => {
return api.getIssues({
groupId: groupId,
milestone: 'No Milestone',
state: IssueState.OPENED,
});
});
// flatten structure, e.g. put all issues in one array
const issuesWithoutMilestone = flatten2dArray(issueResults);
Logger.info(`Found '${issuesWithoutMilestone.length}' issue(s) without milestone.`);
const milestoneCache: {[s: number]: Milestone[]} = {};
await asyncPool(CONCURRENCY, issuesWithoutMilestone, async issue => {
if (typeof milestoneCache[issue.project_id] === 'undefined') {
milestoneCache[issue.project_id] = await api.getMilestonesForProject(issue.project_id);
}
let milestoneId;
for (const milestone of milestoneCache[issue.project_id]) {
if (milestone.title === 'Meeting') {
milestoneId = milestone.id;
}
}
if (typeof milestoneId === 'undefined') {
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})`);
await api.createNote(
issue.project_id,
Scope.ISSUES,
issue.iid,
`${NOTE_PREFIX} Milestone was set automatically to 'Meeting'.`,
);
});
Logger.ok('Tidied issues without milestones.');
}
/**
* Tidy open issues without meeting label
*
* This adds the label 'meeting' to all open issues that do not have this label.
*
* @param api GitLab API instante to use for requests
*/
export async function tidyOpenIssuesWithoutMeetingLabel(api: Api): Promise<void> {
// fetch all open issues
const issueResults = await asyncPool(CONCURRENCY, GROUPS, async groupId => {
return api.getIssues({
groupId: groupId,
state: IssueState.OPENED,
});
});
// flatten structure, e.g. put all issues in one array
const openIssues = flatten2dArray(issueResults);
Logger.info(`Found ${openIssues.length} open issue(s).`);
// filter issues without meeting label
const openIssuesWithoutMeetingLabel = openIssues.filter(openIssue => {
return !openIssue.labels.includes('meeting');
});
Logger.info(`Filtered ${openIssuesWithoutMeetingLabel.length} open issue(s) without label 'meeting'.`);
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;
}
return api.createNote(
issue.project_id,
Scope.ISSUES,
issue.iid,
`${NOTE_PREFIX} Automatically adding label 'meeting'\n\n/label ~meeting`,
);
});
Logger.ok(`Tidied open issues without label 'meeting'.`);
}
/**
* Tidy labels in a list of projects
*
* @param api GitLab API instance to use for the requests
* @param projects List of projects to tidy labels on
*/
export async function tidyLabels(api: Api, projects: Project[]): Promise<void> {
await asyncPool(CONCURRENCY, projects, async project => {
const labels = await api.getLabels(project.id);
const neededLabels = [...NEEDED_LABELS];
// const extraneousLabels: Label[] = [];
for (const label of labels) {
// let needed = false;
for (const [neededLabelIndex, neededLabel] of neededLabels.entries()) {
if (neededLabel.name.toLowerCase() === label.name.toLowerCase()) {
neededLabels.splice(neededLabelIndex, 1);
// needed = true;
}
}
/* if (!needed) {
extraneousLabels.push(label);
} */
}
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}'.`);
});
// await asyncPool(2, extraneousLabels, async (extraneousLabel) => {
// await api.deleteLabel(project.id, extraneousLabel.name);
//
// Logger.log('Deleted label `' + extraneousLabel.name + '` from ' + project.name_with_namespace + '.');
// });
});
Logger.ok('Tidied labels.');
}
/**
* Tidy milestones in a list of projects
*
* @param api GitLab API instance to use for the requests
* @param projects List of projects to tidy milestones on
*/
export async function tidyMilestones(api: Api, projects: Project[]): Promise<void> {
await asyncPool(CONCURRENCY, projects, async project => {
const milestones = await api.getMilestonesForProject(project.id);
const missingMilestones = [...NEEDED_MILESTONES];
for (const milestone of milestones) {
const index = missingMilestones.indexOf(milestone.title);
if (index >= 0) {
missingMilestones.splice(index, 1);
}
}
if (missingMilestones.length > 0 && !project.archived) {
await asyncPool(CONCURRENCY, missingMilestones, async milestone => {
await api.createMilestone(project.id, milestone);
Logger.log(`Created milestone '${milestone}' for project ${project.name_with_namespace}'.`);
});
}
});
Logger.ok('Tidied milestones.');
}
/**
* Tidy protected branches in a list of projects
*
* @param api GitLab API instance to use for the requests
* @param projects List of projects to tidy milestones on
*/
export async function tidyProtectedBranches(api: Api, projects: Project[]): Promise<void> {
await asyncPool(CONCURRENCY, projects, async project => {
const branches = await api.getBranchesForProject(project.id);
const protectableBranches = branches.filter(branch => {
return PROTECTED_BRANCHES.includes(branch.name);
});
const unprotectedBranches = protectableBranches.filter(branch => {
return !branch.protected;
});
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.ok('Tidied protected branches.');
}
/**
* Tidy protected tags
*
* @param api GitLab API instance to use for the requests
* @param projects List of projects to tidy protected tags on
*/
export async function tidyProtectedTags(api: Api, projects: Project[]): Promise<void> {
await asyncPool(CONCURRENCY, projects, async project => {
// TODO: move this to GitLab API
const protectedTags = (await api.makeGitLabAPIRequest(`projects/${project.id}/protected_tags`)) as Array<{
/**
* List of access levels to create a tag
*/
create_access_levels: Array<{
/**
* Access level
*/
access_level: AccessLevel;
/**
* Description of access level
*/
access_level_description: string;
}>;
/**
* Name of the tag
*/
name: string;
}>;
if (
protectedTags.findIndex(protectedTag => {
return protectedTag.name === 'v*';
}) === -1
) {
await api.makeGitLabAPIRequest(`projects/${project.id}/protected_tags`, {
data: {
create_access_level: AccessLevel.Maintainer,
name: 'v*',
},
method: 'POST',
});
Logger.log(`Added protected version tag in project '${project.name_with_namespace}'.`);
}
});
Logger.ok('Tidied protected tags.');
}
/**
* Tidy "sub" group members
*
* @param api GitLab API instance to use for the requests
*/
export async function tidySubGroupMembers(api: Api): Promise<void> {
const stappsMembers = await api.getMembers(MembershipScope.GROUPS, GROUPS[0]);
const stappsMemberIds = new Set(stappsMembers.map(member => member.id));
const groupIdsToSchool: {[id: number]: string} = {};
Object.keys(SCHOOLS).map(school => {
groupIdsToSchool[SCHOOLS[school]] = school;
});
await asyncPool(CONCURRENCY, GROUPS.slice(1), async groupId => {
const members = await api.getMembers(MembershipScope.GROUPS, groupId);
const memberIds = new Set(members.map(member => member.id));
await asyncPool(CONCURRENCY, stappsMembers, async stappsMember => {
if (!memberIds.has(stappsMember.id)) {
await api.addMember(MembershipScope.GROUPS, groupId, stappsMember.id, AccessLevel.Developer);
Logger.log(`Added '${stappsMember.name}' to group '${groupIdsToSchool[groupId]}'.`);
}
});
await asyncPool(CONCURRENCY, members, async member => {
if (!stappsMemberIds.has(member.id)) {
await api.deleteMember(MembershipScope.GROUPS, groupId, member.id);
Logger.log(`Deleted member '${member.name}' from group '${groupIdsToSchool[groupId]}'.`);
}
});
});
Logger.ok(`Tidied 'sub' group members.`);
}
/**
* Tidy issues without assignee
*
* Set assignee to author if no assignee is set.
*
* @param api GitLab API instance to use for the requests
*/
export async function tidyIssuesWithoutAssignee(api: Api): Promise<void> {
// fetch issues without milestone from all groups
const issueResults = await asyncPool(CONCURRENCY, GROUPS, async groupId => {
return api.getIssues({
groupId: groupId,
state: IssueState.OPENED,
});
});
// flatten structure, e.g. put all issues in one array
const issues = flatten2dArray(issueResults);
const issuesWithoutAssignee = issues.filter(issue => {
return issue.assignee === null;
});
Logger.info(`Found '${issuesWithoutAssignee.length}' issue(s) without assignee.`);
await asyncPool(CONCURRENCY, issuesWithoutAssignee, async issue => {
await api.setAssigneeForIssue(issue, issue.author.id);
Logger.log(`Set assignee for '${issue.title}' to '${issue.author.name}'.`);
await api.createNote(
issue.project_id,
Scope.ISSUES,
issue.iid,
`${NOTE_PREFIX} Assignee was set automatically to author.`,
);
});
Logger.ok('Tidied issues without assignee.');
}
/**
* Tidy merge requests without assignee
*
* Set assignee to author if no assignee is set.
*
* @param api GitLab API instance to use for the requests
*/
export async function tidyMergeRequestsWithoutAssignee(api: Api): Promise<void> {
const mergeRequestResults = await asyncPool(CONCURRENCY, GROUPS, async groupId => {
return api.getMergeRequests(MembershipScope.GROUPS, groupId, MergeRequestState.OPENED);
});
// flatten structure, e.g. put all issues in one array
const mergeRequests = flatten2dArray(mergeRequestResults);
const mergeRequestsWithoutAssignee = mergeRequests.filter(mergeRequest => {
return mergeRequest.assignee === null;
});
Logger.info(`Found '${mergeRequestsWithoutAssignee.length}' merge requests without assignee.`);
await asyncPool(CONCURRENCY, mergeRequestsWithoutAssignee, async mergeRequest => {
await api.setAssigneeForMergeRequest(mergeRequest, mergeRequest.author.id);
Logger.log(`Set assignee for '${mergeRequest.title}' to '${mergeRequest.author.name}'.`);
await api.createNote(
mergeRequest.project_id,
Scope.MERGE_REQUESTS,
mergeRequest.iid,
`${NOTE_PREFIX} Assignee was set automatically to author.`,
);
});
Logger.ok('Tidied merge requests without assignee.');
}
/**
* Tidy
*
* @param api GitLab API instance to use for the requests
*/
export async function tidy(api: Api) {
// get first level sub groups
const groups = [...GROUPS];
const subGroups = await api.getSubGroupsForGroup(groups[0]);
groups.push(...groups, ...subGroups.map(group => group.id));
// get non archived projects of groups
let projects = await getProjects(api, groups);
projects = projects.filter(project => !project.archived);
await Promise.all([
// Labels are now specified at Group level
// await tidyLabels(api, projects),
await tidyMilestones(api, projects),
await tidyProtectedBranches(api, projects),
await tidyProtectedTags(api, projects),
]);
await tidyOpenIssuesWithoutMeetingLabel(api);
await tidyIssuesWithoutAssignee(api);
await tidyMergeRequestsWithoutAssignee(api);
}

View File

@@ -0,0 +1,59 @@
/*
* Copyright (C) 2018-2022 Open StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {asyncPool} from '@krlwlfrt/async-pool';
import {Api} from '@openstapps/gitlab-api';
import {IssueState, Scope} from '@openstapps/gitlab-api/lib/types';
import {Logger} from '@openstapps/logger';
import moment from 'moment';
import {flatten2dArray} from '../common';
import {CONCURRENCY, GROUPS, LAST_MEETING, NOTE_PREFIX} from '../configuration';
/**
* Remove label `meeting` from closed issues
*
* @param api Instance of GitLabAPI to send requests with
*/
export async function unlabel(api: Api) {
const issueResults = await asyncPool(CONCURRENCY, GROUPS, async groupId => {
return api.getIssues({
groupId: groupId,
state: IssueState.CLOSED,
});
});
const issues = flatten2dArray(issueResults);
Logger.log(`Fetched ${issues.length} closed issue(s).`);
await asyncPool(CONCURRENCY, issues, async issue => {
if (
issue.labels.includes('meeting') &&
issue.closed_at !== null &&
moment(issue.closed_at).isBefore(LAST_MEETING)
) {
Logger.info(`Issue ${issue.title} is closed before last meeting and has label "meeting". Removing it.`);
await api.createNote(
issue.project_id,
Scope.ISSUES,
issue.iid,
`${NOTE_PREFIX} Removed label \`meeting\` automatically.
/unlabel ~meeting`,
);
}
});
Logger.ok('Label `meeting` has been removed from closed issues.');
}