feat: add projectmanagement and pack

This commit is contained in:
Karl-Philipp Wulfert
2018-11-29 13:36:34 +01:00
commit 19fd0f6e4c
28 changed files with 4801 additions and 0 deletions

99
src/tasks/move.ts Normal file
View File

@@ -0,0 +1,99 @@
/*
* Copyright (C) 2018 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 {Milestone} from '@openstapps/gitlab-api/lib/types';
import {asyncPool} from 'async-pool-native/dist/async-pool';
import {prompt} from 'inquirer';
import {flatten2dArray, logger} from '../common';
import {GROUPS, NOTE_PREFIX} from '../configuration';
/**
* Move closed issues away from Meeting milestone
*
* @param api Instance of GitLabAPI to send requests with
*/
export async function move(api: Api) {
const issueResults = await asyncPool(3, GROUPS, (groupId) => {
return api.getIssues({
groupId: groupId,
state: 'closed',
});
});
const issues = flatten2dArray(issueResults);
logger.log('Fetched ' + issues.length + ' closed issue(s).');
const milestoneCache: { [s: number]: Milestone[] } = {};
await asyncPool(1, issues, async (issue) => {
const selectedMilestone = issue.milestone;
let milestoneId: number | null = null;
if (selectedMilestone !== null) {
milestoneId = selectedMilestone.id;
}
if (typeof milestoneCache[issue.project_id] === 'undefined') {
const milestones = await api.getMilestonesForProject(issue.project_id);
milestones.sort((a, b) => {
return a.title.localeCompare(b.title);
});
milestoneCache[issue.project_id] = milestones;
}
const selectableMilestones: Array<{ name: string, value: number | null }> = [
{
name: '>skip<',
value: milestoneId,
}, {
name: '>none<',
value: null,
},
];
milestoneCache[issue.project_id].forEach((milestone) => {
selectableMilestones.push({value: milestone.id, name: milestone.title});
});
const answer: any = await prompt({
choices: selectableMilestones,
message: '(' + issue.web_url + '): ' + issue.title,
name: 'Milestone',
type: 'list',
});
const chosenMilestoneId = answer[Object.keys(answer)[0]];
if (chosenMilestoneId === milestoneId) {
logger.info('Milestone unchanged...');
return;
}
await api.setMilestoneForIssue(issue, chosenMilestoneId);
await api.createNote(
issue.project_id,
issue.iid,
`${NOTE_PREFIX} Issue was moved automatically.`,
);
logger.log('Milestone has been updated...');
});
logger.ok('Closed issues have been moved.');
}

358
src/tasks/report.ts Normal file
View File

@@ -0,0 +1,358 @@
/*
* Copyright (C) 2018 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 {Issue, Project, User} from '@openstapps/gitlab-api/lib/types';
import {asyncPool} from 'async-pool-native/dist/async-pool';
import {readFileSync, writeFile} from 'fs';
import * as moment from 'moment';
import {join} from 'path';
import {cwd} from 'process';
import {promisify} from 'util';
import {flatten2dArray, getProjects, logger} from '../common';
import {BOLD_LABELS, GROUPS, LABEL_WEIGHTS} from '../configuration';
const asyncWriteFile = promisify(writeFile);
/**
* Check if issue state is opened or closed
*
* @param state State to check
*/
export function issueStateIsOpenedOrClosed(state: string): state is 'opened' | 'closed' {
return ['opened', 'closed'].indexOf(state) >= 0;
}
/**
* 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: MergeRequestData,
projectId: number,
issueIid: number): string[] {
if (typeof projectMergeRequests[projectId] === 'undefined' || projectMergeRequests[projectId].length === 0) {
return [];
}
return projectMergeRequests[projectId].filter((obj) => {
return obj.issue_iid === issueIid;
}).map((obj) => {
return obj.web_url;
});
}
/**
* An assignee with his/her issues
*/
export interface AssigneeWithIssues {
assignee: User;
issueCounts: {
closed: number;
opened: number;
};
issues: Issue[];
quota: number;
}
export interface MergeRequestData {
[k: string]: Array<{
issue_iid: number;
web_url: string;
}>;
}
/**
* Tangular compiled template
*/
export type TangularCompiled = (model?: any, $?: any) => string;
/**
* Convert an issue to a markdown table row
*
* @param issue Issue to convert
* @param projectBranches Map of branches for project IDs
* @param projectMergeRequests Map of merge requests for project IDs
* @param template Template to render this issue with
*/
function issueToString(issue: any,
projectBranches: any,
projectMergeRequests: any,
template: any): string {
issue._labels = issue.labels.map((label: string) => {
// print specific labels bold
if (BOLD_LABELS.indexOf(label) >= 0) {
label = '__' + label + '__';
}
return label;
}).join(', ');
/* tslint:disable: max-line-length */
return template({
branchExists: typeof projectBranches[issue.project_id] !== 'undefined' && projectBranches[issue.project_id].indexOf(issue.iid) >= 0,
issue: issue,
// take the first URL from the merge request urls array (usually if there are URLs, then there is that only one)
mergeRequestUrl: getMergeRequestUrls(projectMergeRequests, issue.project_id, issue.iid)[0],
project: Api.getProjectPath(issue),
weeksOpen: moment().diff(moment(issue.created_at), 'weeks'),
});
/* tslint:enable */
}
/**
* Get issues from all groups with a specific milestone
*
* @param api GitLab API to make requests with
* @param milestone Milestone to filter by
*/
export async function getIssues(api: Api, milestone: string): Promise<Issue[]> {
const issueResults = await asyncPool(2, GROUPS, (groupId) => {
return api.getIssues({
groupId: groupId,
milestone: milestone as any,
});
});
const issues = flatten2dArray(issueResults);
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 getIdsOfIssuesWithBranchesForProjects(
api: Api,
projects: Project[]): Promise<{ [k: string]: number[] }> {
const projectBranches: { [k: string]: number[] } = {};
await asyncPool(2, 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);
});
});
return projectBranches;
}
/**
* Group a list of issues by their assignees
*
* @param issues List of issues to group
*/
export function groupIssuesByAssignee(issues: Issue[]): { [k: number]: AssigneeWithIssues } {
const issuesByAssignee: {
[k: number]: AssigneeWithIssues;
} = {};
issues.forEach((issue) => {
if (issue.assignee === null) {
logger.warn('Issue without assignee!', issue.web_url);
return;
}
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 === 'reopened') {
issue.state = 'opened';
}
if (issueStateIsOpenedOrClosed(issue.state)) {
issuesByAssignee[issue.assignee.id].issueCounts[issue.state]++;
issuesByAssignee[issue.assignee.id].issues.push(issue);
}
});
Object.keys(issuesByAssignee).forEach((_assigneeId) => {
const assigneeId = parseInt(_assigneeId, 10);
issuesByAssignee[assigneeId].quota = Math.floor(
issuesByAssignee[assigneeId].issueCounts.closed
/ (issuesByAssignee[assigneeId].issueCounts.opened
+ issuesByAssignee[assigneeId].issueCounts.closed) * 100,
);
});
return issuesByAssignee;
}
/**
* Compile templates
*/
export function compileTemplates(): { [k: string]: TangularCompiled } {
const tangular: { compile: (template: string) => TangularCompiled } = require('tangular');
const templates = {
assigneeFooter: tangular.compile(readFileSync('templates/md/assigneeFooter.md').toString()),
assigneeHeader: tangular.compile(readFileSync('templates/md/assigneeHeader.md').toString()),
header: tangular.compile(readFileSync('templates/md/header.md').toString()),
issue: tangular.compile(readFileSync('templates/md/issue.md').toString()),
};
logger.log('Compiled templates.');
return templates;
}
/**
* Get next meeting day
*/
export function getNextMeetingDay() {
// get "now"
const now = moment();
// get first wednesday of month
const meetingDayMoment = moment().startOf('month').hour(10).isoWeekday(3);
while (meetingDayMoment.isBefore(now)) {
// add one week until meeting day is after now
meetingDayMoment.add(1, 'weeks');
}
const meetingDay = meetingDayMoment.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 getMergeRequestsForProjects(api: Api,
projects: Project[]): Promise<MergeRequestData> {
const projectMergeRequests: MergeRequestData = {};
// iterate over projects
await asyncPool(2, 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.getMergeRequestsForProject(project.id);
// 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,
};
});
});
return projectMergeRequests;
}
export async function report(api: Api, milestone: string) {
const templates = compileTemplates();
const projects = await getProjects(api, GROUPS);
const issues = await getIssues(api, milestone);
const issuesGroupedByAssignee = groupIssuesByAssignee(issues);
const issueBranches = await getIdsOfIssuesWithBranchesForProjects(api, projects);
const mergeRequests = await getMergeRequestsForProjects(api, projects);
const meetingDay = getNextMeetingDay();
let allMarkdown = templates.header();
Object.keys(issuesGroupedByAssignee).forEach((_assigneeId) => {
const assigneeId = parseInt(_assigneeId, 10);
allMarkdown += templates.assigneeHeader(issuesGroupedByAssignee[assigneeId]);
issuesGroupedByAssignee[assigneeId].issues.sort((a, b) => {
let weightA = 0;
let weightB = 0;
Object.keys(LABEL_WEIGHTS).forEach((label: string) => {
if (a.labels.indexOf(label) >= 0) {
weightA += LABEL_WEIGHTS[label];
}
if (b.labels.indexOf(label) >= 0) {
weightB += LABEL_WEIGHTS[label];
}
});
if (a.state === 'closed') {
weightA -= 10;
}
if (b.state === 'closed') {
weightB -= 10;
}
return weightB - weightA;
}).forEach((issue) => {
allMarkdown += issueToString(
issue,
issueBranches,
mergeRequests,
templates.issue,
);
});
allMarkdown += templates.assigneeFooter(issuesGroupedByAssignee[assigneeId]);
});
let filename = join(cwd(), 'reports', meetingDay + '.md');
if (milestone === 'Backlog') {
filename = join(cwd(), 'reports', 'Backlog.md');
}
await asyncWriteFile(filename, allMarkdown);
logger.ok('Wrote file `' + filename + '`.');
}

268
src/tasks/tidy.ts Normal file
View File

@@ -0,0 +1,268 @@
/*
* Copyright (C) 2018 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 {AccessLevel, Label, Milestone, Project} from '@openstapps/gitlab-api/lib/types';
import {asyncPool} from 'async-pool-native/dist/async-pool';
import {flatten2dArray, getProjects, logger} from '../common';
import {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(3, GROUPS, (groupId) => {
return api.getIssues({
groupId: groupId,
milestone: 'No Milestone',
state: '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(5, issuesWithoutMilestone, async (issue) => {
if (typeof milestoneCache[issue.project_id] === 'undefined') {
milestoneCache[issue.project_id] = await api.getMilestonesForProject(issue.project_id);
}
let milestoneId = null;
milestoneCache[issue.project_id].forEach((milestone) => {
if (milestone.title === 'Meeting') {
milestoneId = milestone.id;
}
});
if (milestoneId === null) {
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,
issue.iid,
`${NOTE_PREFIX} Milestone was set automatically to \`Meeting\`.`,
);
});
logger.ok('Tidied issues without milestones.');
}
/**
* 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(5, projects, async (project) => {
const labels = await api.getLabels(project.id);
const neededLabels = NEEDED_LABELS.slice(0);
const extraneousLabels: Label[] = [];
labels.forEach((label) => {
let needed = false;
neededLabels.forEach((neededLabel, neededLabelIdx) => {
if (neededLabel.name.toLowerCase() === label.name.toLowerCase()) {
neededLabels.splice(neededLabelIdx, 1);
needed = true;
}
});
if (!needed) {
extraneousLabels.push(label);
}
});
await asyncPool(2, 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(5, projects, async (project) => {
const milestones = await api.getMilestonesForProject(project.id);
const missingMilestones = NEEDED_MILESTONES.slice(0);
milestones.forEach((milestone) => {
const idx = missingMilestones.indexOf(milestone.title);
if (idx >= 0) {
missingMilestones.splice(idx, 1);
}
});
if (missingMilestones.length > 0) {
await asyncPool(2, 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(2, projects, async (project) => {
const branches = await api.getBranchesForProject(project.id);
const protectableBranches = branches.filter((branch) => {
return PROTECTED_BRANCHES.indexOf(branch.name) >= 0;
});
const unprotectedBranches = protectableBranches.filter((branch) => {
return !branch.protected;
});
await asyncPool(2, 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 "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('groups', GROUPS[0]);
const stappsMemberIds = stappsMembers.map((member) => member.id);
const groupIdsToSchool: any = {};
Object.keys(SCHOOLS).map((school) => {
groupIdsToSchool[SCHOOLS[school]] = school;
});
await asyncPool(2, GROUPS.slice(1), async (groupId) => {
const members = await api.getMembers('groups', groupId);
const memberIds = members.map((member) => member.id);
await asyncPool(2, stappsMembers, async (stappsMember) => {
if (memberIds.indexOf(stappsMember.id) === -1) {
await api.addMember('groups', groupId, stappsMember.id, AccessLevel.Developer);
logger.log('Added ' + stappsMember.name + ' to group ' + groupIdsToSchool[groupId] + '.');
}
});
await asyncPool(2, members, async (member) => {
if (stappsMemberIds.indexOf(member.id) === -1) {
await api.deleteMember('groups', groupId, member.id);
logger.log('Deleted member ' + member.name + ' from group ' + groupIdsToSchool[groupId] + '.');
}
});
});
logger.ok('Tidied "sub" group members.');
}
export async function tidyIssuesWithoutAssignee(api: Api): Promise<void> {
// fetch issues without milestone from all groups
const issueResults = await asyncPool(3, GROUPS, (groupId) => {
return api.getIssues({
groupId: groupId,
state: '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(3, 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,
issue.iid,
`${NOTE_PREFIX} Assignee was set automatically to author.`,
);
});
logger.ok('Tidied issues without assignee.');
}
/**
* Tidy
*
* @param api GitLab API instance to use for the requests
*/
export async function tidy(api: Api) {
const projects = await getProjects(api, GROUPS);
await Promise.all([
tidyLabels(api, projects),
tidyMilestones(api, projects),
tidyProtectedBranches(api, projects),
tidySubGroupMembers(api),
]);
await tidyIssuesWithoutMilestone(api);
await tidyIssuesWithoutAssignee(api);
}