feat: use mustache instead of tangular

This commit is contained in:
Karl-Philipp Wulfert
2019-03-20 15:46:59 +01:00
parent 9847fc809c
commit 95f7521dbd
11 changed files with 450 additions and 745 deletions

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2018 StApps
* Copyright (C) 2018, 2019 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.
@@ -20,9 +20,11 @@ import {readFile, unlink, writeFile} from 'fs';
import * as glob from 'glob';
import {promisify} from 'util';
/**
* Instantiated logger
*/
export const readFilePromisified = promisify(readFile);
export const globPromisified = promisify(glob);
export const writeFilePromisified = promisify(writeFile);
export const unlinkPromisified = promisify(unlink);
export const logger = new Logger();
/**
@@ -65,20 +67,3 @@ export async function getSubGroups(api: Api, groups: number[]): Promise<Group[]>
export function flatten2dArray<T>(arr: T[][]): T[] {
return ([] as T[]).concat(...arr);
}
/**
* Promisified version of readFile
*/
export const readFilePromisified = promisify(readFile);
/**
* Promisified version of glob
*/
export const globPromisified = promisify(glob);
/**
* Promisified version of writeFile
*/
export const writeFilePromisified = promisify(writeFile);
/**
* Promisified version of unlink
*/
export const unlinkPromisified = promisify(unlink);

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2018 StApps
* Copyright (C) 2018, 2019 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.
@@ -15,15 +15,61 @@
import {Api} from '@openstapps/gitlab-api';
import {Issue, IssueState, MembershipScope, MergeRequestState, 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 {render} from 'mustache';
import {join, resolve} from 'path';
import {cwd} from 'process';
import {promisify} from 'util';
import {flatten2dArray, getProjects, getSubGroups, logger} from '../common';
import {flatten2dArray, getProjects, logger, readFilePromisified, writeFilePromisified} from '../common';
import {BOLD_LABELS, GROUPS, LABEL_WEIGHTS} from '../configuration';
const asyncWriteFile = promisify(writeFile);
/**
* A structure for template compilation
*/
export interface StructureForTemplate {
issuesByAssignee: AssigneeWithIssues[];
meetingDay: string;
}
/**
* A map
*/
export interface IssuesGroupedByAssigneeId {
[assigneeId: number]: AssigneeWithIssues;
}
/**
* An assignee with assigned issues
*/
export interface AssigneeWithIssues {
assignee: User;
issueCounts: {
closed: number;
opened: number;
};
issues: IssueWithMeta[];
quota: number;
}
/**
* Issue with meta information
*/
export interface IssueWithMeta extends Issue {
$branchExists: boolean;
$labels: string;
$mergeRequestUrl: string;
$project: string;
$weeksOpen: number;
}
/**
* Merge request data
*/
export interface MergeRequestsForProjects {
[projectId: string]: Array<{
issue_iid: number;
web_url: string;
}>;
}
/**
* Check if issue state is opened or closed
@@ -41,8 +87,7 @@ export function issueStateIsOpenedOrClosed(state: IssueState): state is IssueSta
* @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,
export function getMergeRequestUrls(projectMergeRequests: MergeRequestsForProjects,
projectId: number,
issueIid: number): string[] {
if (typeof projectMergeRequests[projectId] === 'undefined' || projectMergeRequests[projectId].length === 0) {
@@ -56,72 +101,15 @@ export function getMergeRequestUrls(projectMergeRequests: MergeRequestData,
});
}
/**
* 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: issue.web_url.replace('https://gitlab.com/', '').split('/issues/')[0],
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 label Label to filter by
* @param groups List of groups to get issues for
*/
export async function getIssues(api: Api, label: string): Promise<Issue[]> {
const issueResults = await asyncPool(2, GROUPS, (groupId) => {
export async function getIssues(api: Api, label: string, groups: number[]): Promise<Issue[]> {
const issueResults = await asyncPool(2, groups, (groupId) => {
return api.getIssues({
groupId: groupId,
});
@@ -142,7 +130,7 @@ export async function getIssues(api: Api, label: string): Promise<Issue[]> {
* @param api GitLab API To make requests with
* @param projects List of projects
*/
export async function getIdsOfIssuesWithBranchesForProjects(
export async function getIssueBranches(
api: Api,
projects: Project[]): Promise<{ [k: string]: number[] }> {
const projectBranches: { [k: string]: number[] } = {};
@@ -164,14 +152,28 @@ export async function getIdsOfIssuesWithBranchesForProjects(
}
/**
* Group a list of issues by their assignees
* Get issues grouped by assignees
*
* @param issues List of issues to group
* @param api GitLab API to make requests with
* @param label Label to generate report for
*/
export function groupIssuesByAssignee(issues: Issue[]): { [k: number]: AssigneeWithIssues } {
const issuesByAssignee: {
[k: number]: AssigneeWithIssues;
} = {};
export async function getIssuesGroupedByAssignees(api: Api, label: string): Promise<AssigneeWithIssues[]> {
const issuesByAssignee: IssuesGroupedByAssigneeId = {};
const groups = flatten2dArray(await asyncPool(2, GROUPS, async (groupId) => {
return (await api.getSubGroupsForGroup(groupId)).map((group) => {
return group.id;
});
}));
groups.push.apply(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);
issues.forEach((issue) => {
if (issue.assignee === null) {
@@ -195,12 +197,33 @@ export function groupIssuesByAssignee(issues: Issue[]): { [k: number]: AssigneeW
issue.state = IssueState.OPENED;
}
const issueWithMeta: IssueWithMeta = {
...issue,
...{
$branchExists: typeof issueBranches[issue.project_id] !== 'undefined'
&& issueBranches[issue.project_id].indexOf(issue.iid) >= 0,
$issue: issue,
$labels: issue.labels.map((issueLabel: string) => {
// print specific labels bold
if (BOLD_LABELS.indexOf(issueLabel) >= 0) {
issueLabel = '__' + issueLabel + '__';
}
return issueLabel;
}).join(', '),
$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'),
},
};
if (issueStateIsOpenedOrClosed(issue.state)) {
issuesByAssignee[issue.assignee.id].issueCounts[issue.state]++;
issuesByAssignee[issue.assignee.id].issues.push(issue);
issuesByAssignee[issue.assignee.id].issues.push(issueWithMeta);
}
});
// calculate quota
Object.keys(issuesByAssignee).forEach((_assigneeId) => {
const assigneeId = parseInt(_assigneeId, 10);
@@ -211,25 +234,37 @@ export function groupIssuesByAssignee(issues: Issue[]): { [k: number]: AssigneeW
);
});
return issuesByAssignee;
}
// sort issues by weight of labels and status
Object.keys(issuesByAssignee).forEach((_assigneeId) => {
const assigneeId = parseInt(_assigneeId, 10);
/**
* Compile templates
*/
export function compileTemplates(): { [k: string]: TangularCompiled } {
const tangular: { compile: (template: string) => TangularCompiled } = require('tangular');
issuesByAssignee[assigneeId].issues.sort((a, b) => {
let weightA = 0;
let weightB = 0;
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()),
};
Object.keys(LABEL_WEIGHTS).forEach((issueLabel) => {
if (a.labels.indexOf(issueLabel) >= 0) {
weightA += LABEL_WEIGHTS[issueLabel];
}
logger.log('Compiled templates.');
if (b.labels.indexOf(issueLabel) >= 0) {
weightB += LABEL_WEIGHTS[issueLabel];
}
});
return templates;
if (a.state === IssueState.CLOSED) {
weightA -= 10;
}
if (b.state === IssueState.CLOSED) {
weightB -= 10;
}
return weightB - weightA;
});
});
return Object.values(issuesByAssignee);
}
/**
@@ -261,9 +296,9 @@ export function getNextMeetingDay() {
* @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 = {};
export async function getMergeRequests(api: Api,
projects: Project[]): Promise<MergeRequestsForProjects> {
const projectMergeRequests: MergeRequestsForProjects = {};
// iterate over projects
await asyncPool(2, projects, async (project) => {
@@ -292,72 +327,41 @@ export async function getMergeRequestsForProjects(api: Api,
return projectMergeRequests;
}
export async function generateReport(api: Api, label: string): Promise<string> {
const templates = compileTemplates();
/**
* 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 groupIds: number[] = [];
groupIds.push.apply(groupIds, GROUPS);
groupIds.push.apply(groupIds, (await getSubGroups(api, GROUPS)).map((group) => group.id));
const structureForTemplate: StructureForTemplate = {
issuesByAssignee: issuesGroupedByAssignee,
meetingDay: getNextMeetingDay(),
};
logger.log(`Getting data for ${groupIds.length} group(s).`);
const projects = await getProjects(api, groupIds);
const issues = await getIssues(api, label);
const issuesGroupedByAssignee = groupIssuesByAssignee(issues);
const issueBranches = await getIdsOfIssuesWithBranchesForProjects(api, projects);
const mergeRequests = await getMergeRequestsForProjects(api, projects);
let markdown = templates.header();
Object.keys(issuesGroupedByAssignee).forEach((_assigneeId) => {
const assigneeId = parseInt(_assigneeId, 10);
markdown += templates.assigneeHeader(issuesGroupedByAssignee[assigneeId]);
issuesGroupedByAssignee[assigneeId].issues.sort((a, b) => {
let weightA = 0;
let weightB = 0;
Object.keys(LABEL_WEIGHTS).forEach((issueLabel) => {
if (a.labels.indexOf(issueLabel) >= 0) {
weightA += LABEL_WEIGHTS[issueLabel];
}
if (b.labels.indexOf(issueLabel) >= 0) {
weightB += LABEL_WEIGHTS[issueLabel];
}
});
if (a.state === IssueState.CLOSED) {
weightA -= 10;
}
if (b.state === IssueState.CLOSED) {
weightB -= 10;
}
return weightB - weightA;
}).forEach((issue) => {
markdown += issueToString(
issue,
issueBranches,
mergeRequests,
templates.issue,
);
});
markdown += templates.assigneeFooter(issuesGroupedByAssignee[assigneeId]);
});
return markdown;
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 markdown = generateReport(api, label);
const markdown = await generateReport(
api,
label,
(await readFilePromisified(resolve(__dirname, '..', '..', 'templates', 'report.md.mustache'))).toString(),
);
let filename = join(cwd(), 'reports', meetingDay + '.md');
@@ -365,7 +369,7 @@ export async function report(api: Api, label: string) {
filename = join(cwd(), 'reports', `${label}.md`);
}
await asyncWriteFile(filename, markdown);
await writeFilePromisified(filename, markdown);
logger.ok(`Wrote file '${filename}'.`);
}