pinkmine/src/reports/daily-eccm.report.service.ts

613 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ChangesService } from 'src/changes/changes.service';
import { EccmConfig } from 'src/models/eccm-config.model';
import { CurrentIssuesEccmReportService } from './current-issues-eccm.report.service';
import nano from 'nano';
import { UNLIMITED } from '@app/event-emitter/consts/consts';
import { TimestampConverter } from '@app/event-emitter/utils/timestamp-converter';
import { IssuesService } from '@app/event-emitter/issues/issues.service';
import { UsersService } from '@app/event-emitter/users/users.service';
import { GetParentsHint } from '@app/event-emitter/utils/get-parents-hint';
import { DailyEccmReportsDatasource } from 'src/couchdb-datasources/daily-eccm-reports.datasource';
import { Timestamped } from '@app/event-emitter/models/timestamped';
import { TimestampNowFill } from '@app/event-emitter/utils/timestamp-now-fill';
import { DateTime } from 'luxon';
import { ISO_DATE_FORMAT } from 'src/consts/date-time.consts';
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace DailyEccmReport {
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace Models {
export type User = {
id: number;
firstname: string;
lastname: string;
};
export type Status = {
id: number;
name: string;
};
export type IssuesAndStatus = {
status: Status;
issues: IssueWithParents[];
};
export type UserReport = {
user: User;
dailyMessage?: string;
teamleadMessage?: string;
issuesGroupedByStatus: IssuesAndStatus[];
activities: IssueAndChanges[];
};
export type NewIssues = {
newIssues: IssueWithParents[];
newIssuesCount: number;
};
export type IssuesByStatuses = {
status: string;
issues: IssueWithParents[];
issuesCount: number;
};
export type MetricsByVersion = {
version: string;
newIssues: NewIssues;
issuesByStatuses: IssuesByStatuses[];
};
export type MetricsByParents = {
parentIssueId: number;
parentIssue: RedmineTypes.Issue;
issuesByStatuses: IssuesByStatuses[];
};
/**
* Метрики сгруппированные по версиям
*/
export type Metrics = {
byVersions: MetricsByVersion[];
byParents: MetricsByParents[];
};
/**
* Все данные для отчёта
*/
export type Report = {
byUsers: UserReport[];
metrics?: Metrics;
/** Переданные параметры для создания отчёта */
params: Params;
};
/**
* Данные по задаче + для hint-а подсказка о родительских задачах
*
* Полезно будет для восстановления контекста
*/
export type IssueWithParents = {
issue: RedmineTypes.Issue;
parents: string;
};
/**
* Параметры отчёта
*/
export type Params = {
from: string;
to: string;
name: string;
project: string;
versions: string[];
};
export type IssueAndChanges = {
issue: RedmineTypes.Issue;
parents: string;
changes: Change[];
};
export type Change = {
initiator?: RedmineTypes.PublicUser;
dev?: RedmineTypes.PublicUser;
cr?: RedmineTypes.PublicUser;
qa?: RedmineTypes.PublicUser;
current_user?: RedmineTypes.PublicUser;
author?: RedmineTypes.PublicUser;
issue_id: number;
issue_url: string;
issue_tracker: string;
issue_subject: string;
journal_note?: string;
old_status?: {
id?: number;
name?: string;
} | null;
new_status?: {
id?: number;
name?: string;
};
created_on: string;
created_on_timestamp: number | null;
change_message: string;
recipient: RedmineTypes.PublicUser;
};
}
export class Report {
private report: Models.Report;
private logger = new Logger(Report.name);
constructor(
private eccmUsers: RedmineTypes.User[],
private issuesService: IssuesService,
) {
const users: Models.UserReport[] = this.eccmUsers
.map((user) => {
if (!user) {
this.logger.error(`Not found user data`);
return;
}
const u: Models.User = {
id: user.id,
lastname: user.lastname,
firstname: user.firstname,
};
return {
user: u,
activities: [],
issuesGroupedByStatus: [],
} as Models.UserReport;
})
.filter((user) => Boolean(user));
this.report = {
byUsers: users,
params: {
from: '',
to: '',
name: '',
project: '',
versions: [],
},
};
}
get(): Models.Report {
return this.report;
}
setParams(params: Models.Params): void {
this.report.params.from = params.from;
this.report.params.to = params.to;
this.report.params.name = params.name;
this.report.params.versions = [...params.versions];
this.report.params.project = params.project;
}
async setCurrentIssues(issues: RedmineTypes.Issue[]): Promise<void> {
for (let i = 0; i < issues.length; i++) {
const issue = issues[i];
await this.appendCurrentIssue(issue);
}
this.logger.debug(`Issues for report loaded successed`);
}
async setActivities(activities: Models.Change[]): Promise<void> {
for (let i = 0; i < activities.length; i++) {
const activity = activities[i];
await this.appendActivity(activity);
}
this.logger.debug(`Activities for report loaded successed`);
}
async appendCurrentIssue(
issue: RedmineTypes.Issue & Record<string, any>,
): Promise<boolean> {
const currentUser: RedmineTypes.PublicUser = issue.current_user;
if (!currentUser) {
return false;
}
const byUser = this.report.byUsers.find(
(item) => item.user.id == currentUser.id,
);
if (!byUser) {
return false;
}
let byStatus: Models.IssuesAndStatus = byUser.issuesGroupedByStatus.find(
(item) => item.status.name == issue.status.name,
);
if (!byStatus) {
byStatus = {
status: { ...issue.status },
issues: [],
};
byUser.issuesGroupedByStatus.push(byStatus);
}
const existsIssue = byStatus.issues.find(
(item) => item.issue.id == issue.id,
);
if (existsIssue) {
return false;
}
const parents = await this.issuesService.getParents(issue.id);
// parents.pop();
const parentsHint = GetParentsHint(parents);
byStatus.issues.push({
issue: issue,
parents: parentsHint,
});
return true;
}
async appendActivity(activity: Models.Change): Promise<boolean> {
const recipient = activity.recipient;
if (!recipient) {
this.logger.debug(
`Recipient is empty for activity ` +
`created_on ${activity.created_on} ` +
`for issue #${activity.issue_id} (${activity.issue_subject})`,
);
return false;
}
const recipientId = recipient.id;
const byUser = this.report.byUsers.find(
(byUser) => byUser.user.id == recipientId,
);
if (!byUser) {
this.logger.log(`Not interested user ${recipient.name}`);
return false;
} else {
this.logger.debug(
`Found user section in report data ` +
`user=${JSON.stringify(byUser.user)}`,
);
}
let issueAndChanges: Models.IssueAndChanges = byUser.activities.find(
(issueAndChanges) => {
return issueAndChanges.issue.id == activity.issue_id;
},
);
if (!issueAndChanges) {
const issue = await this.issuesService.getIssue(activity.issue_id);
if (!issue) {
this.logger.error(`Not found issue data for #${activity.issue_id}`);
return false;
}
const parents = await this.issuesService.getParents(activity.issue_id);
const parentsHint = GetParentsHint(parents);
issueAndChanges = {
issue: issue,
parents: parentsHint,
changes: [],
};
this.logger.debug(
`Created issue section in report data ` +
`user=${JSON.stringify(byUser.user)} ` +
`issue=${JSON.stringify({
id: issueAndChanges.issue.id,
subject: issueAndChanges.issue.subject,
})}`,
);
byUser.activities.push(issueAndChanges);
} else {
this.logger.debug(
`Found issue section in report data ` +
`user=${JSON.stringify(byUser.user)} ` +
`issue=${JSON.stringify({
id: issueAndChanges.issue.id,
subject: issueAndChanges.issue.subject,
})}`,
);
}
issueAndChanges.changes.push(activity);
this.logger.debug(
`Activity pushed into report ` +
`user=${JSON.stringify(byUser.user)} ` +
`issue=${JSON.stringify({
id: issueAndChanges.issue.id,
subject: issueAndChanges.issue.subject,
})} ` +
`activity=${JSON.stringify({
created_on: activity.created_on,
change_message: activity.change_message,
})}`,
);
return true;
}
sortUsers(): void {
this.report.byUsers.sort((a, b) => {
const nameA = `${a.user.firstname} ${a.user.lastname}`;
const nameB = `${b.user.firstname} ${b.user.lastname}`;
if (nameA > nameB) {
return 1;
}
if (nameA < nameB) {
return -1;
}
return 0;
});
}
sortActivities(): void {
for (let i = 0; i < this.report.byUsers.length; i++) {
const byUser = this.report.byUsers[i];
for (let j = 0; j < byUser.activities.length; j++) {
const activity = byUser.activities[j];
activity.changes.sort((a, b) => {
return a.created_on_timestamp - b.created_on_timestamp;
});
}
}
}
}
}
@Injectable()
export class DailyEccmReportService {
private logger = new Logger(DailyEccmReportService.name);
private eccmConfig: EccmConfig.Config;
private report: DailyEccmReport.Report;
constructor(
private configService: ConfigService,
private currentIssuesEccmReportService: CurrentIssuesEccmReportService,
private changesService: ChangesService,
private issuesService: IssuesService,
private usersService: UsersService,
private dailyEccmReportsDatasource: DailyEccmReportsDatasource,
) {
this.eccmConfig = this.configService.get<EccmConfig.Config>('redmineEccm');
}
async getReport(): Promise<DailyEccmReport.Report> {
if (!this.report) {
await this.createEmptyReport();
}
return this.report;
}
async generateReport(
params: DailyEccmReport.Models.Params,
): Promise<DailyEccmReport.Models.Report> {
await this.createEmptyReport();
const report = this.report;
report.setParams(params);
const currentIssues = await this.getCurrentIssues();
this.logger.log(`Current issues count - ${currentIssues.length}`);
await report.setCurrentIssues(currentIssues);
const activities = await this.getActivities(params);
this.logger.log(`Activities count - ${activities.length}`);
await report.setActivities(activities);
report.sortUsers();
report.sortActivities();
this.logger.log(`Daily eccm report created successed`);
return report.get();
}
generateParams(
from: string,
to: string,
name?: string,
): DailyEccmReport.Models.Params {
const fromDate = DateTime.fromISO(from);
if (!fromDate.isValid) throw new Error('from is invalid date');
const toDate = DateTime.fromISO(to);
if (!toDate.isValid) throw new Error('to is invalid date');
let nameValue: string | null = name || null;
if (!nameValue) {
nameValue = DateTime.now().toFormat(ISO_DATE_FORMAT);
}
return {
from: fromDate.toISO(),
to: toDate.toISO(),
name: nameValue,
project: this.eccmConfig.projectName,
versions: [...this.eccmConfig.currentVersions],
};
}
async saveReport(
report: DailyEccmReport.Models.Report,
overwrite?: boolean,
): Promise<boolean> {
const name = report.params.name;
const existsReport = await this.loadReport(name);
if (existsReport && !overwrite) {
return false;
}
type ReportType = nano.DocumentGetResponse &
DailyEccmReport.Models.Report &
Timestamped;
const newReport: ReportType = TimestampNowFill({
...report,
_id: name,
_rev: existsReport?._rev,
});
const datasource = await this.dailyEccmReportsDatasource.getDatasource();
await datasource.insert(newReport);
return true;
}
async loadReport(
name: string,
): Promise<
| (DailyEccmReport.Models.Report & nano.DocumentGetResponse & Timestamped)
| null
> {
const datasource = await this.dailyEccmReportsDatasource.getDatasource();
let resp: any = null;
try {
resp = await datasource.get(name);
} catch (ex) {
this.logger.warn(
`Cannot load report ${name} with error message ${ex.message}`,
);
}
return resp;
}
async deleteReport(name: string): Promise<boolean> {
const datasource = await this.dailyEccmReportsDatasource.getDatasource();
const report = await this.loadReport(name);
let res = false;
try {
await datasource.destroy(report._id, report._rev);
res = true;
} catch (ex) {}
return res;
}
private async createEmptyReport(): Promise<DailyEccmReport.Report> {
const usersList: string[] = this.eccmConfig.groups.reduce((acc, group) => {
group.people.forEach((fullname) => {
acc.push(fullname);
});
return acc;
}, []);
this.logger.debug(
`Create new daily report for users - ${JSON.stringify(usersList)}`,
);
const usersPromises = usersList.map((fullname) => {
return this.usersService.findUserByFullname(fullname);
});
const users = await Promise.all(usersPromises);
this.report = new DailyEccmReport.Report(users, this.issuesService);
return this.report;
}
private async getCurrentIssues(): Promise<RedmineTypes.Issue[]> {
return await this.currentIssuesEccmReportService.getData({
project: this.eccmConfig.projectName,
statuses: this.eccmConfig.currentIssuesStatuses,
versions: this.eccmConfig.currentVersions,
});
}
private async getActivities(
params: DailyEccmReport.Models.Params,
): Promise<DailyEccmReport.Models.Change[]> {
const fromTimestamp = TimestampConverter.toTimestamp(params.from);
const toTimestamp = TimestampConverter.toTimestamp(params.to);
const query: nano.MangoQuery = {
selector: {
$and: [
{
created_on_timestamp: {
$gte: fromTimestamp,
},
},
{
created_on_timestamp: {
$lt: toTimestamp,
},
},
],
},
limit: UNLIMITED,
};
this.logger.debug(
`Query for get activities data - ${JSON.stringify(query)}`,
);
const changes = await this.changesService.find(query);
this.logger.log(
`Loaded changes for daily report - count = ${changes.length}`,
);
const uniqIssueIds: number[] = [];
changes.forEach((change) => {
const issueId = change.issue_id;
if (!uniqIssueIds.includes(issueId)) {
uniqIssueIds.push(issueId);
}
});
this.logger.debug(`Uniq issues - ${JSON.stringify(uniqIssueIds)}`);
const issues = await this.issuesService.getIssues(uniqIssueIds);
this.logger.debug(`Loaded data for uniq issues - count = ${issues.length}`);
const issuesMap: Record<number, RedmineTypes.Issue> = {};
issues.forEach((issue) => {
if (!issue || !issue.fixed_version) {
return;
}
const version = issue.fixed_version.name;
if (this.eccmConfig.currentVersions.includes(version)) {
issuesMap[issue.id] = issue;
}
});
const reportChanges: DailyEccmReport.Models.Change[] = changes.reduce(
(acc: DailyEccmReport.Models.Change[], change) => {
change.messages.forEach((message) => {
if (!message || !message.recipient || !message.change_message) {
return;
}
const c: DailyEccmReport.Models.Change = {
initiator: change.initiator,
dev: change.dev,
cr: change.cr,
qa: change.qa,
current_user: change.current_user,
author: change.author,
issue_id: change.issue_id,
issue_url: change.issue_url,
issue_tracker: change.issue_tracker,
issue_subject: change.issue_subject,
journal_note: change.journal_note,
old_status: change.old_status && {
id: change.old_status.id,
name: change.old_status.name,
},
new_status: change.new_status && {
id: change.new_status.id,
name: change.new_status.name,
},
created_on: change.created_on,
created_on_timestamp: change.created_on_timestamp,
recipient: {
firstname: message.recipient.firstname,
lastname: message.recipient.lastname,
id: message.recipient.id,
login: message.recipient.login,
name: message.recipient.name,
},
change_message: message.change_message,
};
acc.push(c);
});
return acc;
},
[] as DailyEccmReport.Models.Change[],
);
this.logger.log(
`Prepared activities for report - count = ${reportChanges.length}`,
);
return reportChanges;
}
}