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 { 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 { 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, ): Promise { 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 { 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('redmineEccm'); } async getReport(): Promise { if (!this.report) { await this.createEmptyReport(); } return this.report; } async generateReport( params: DailyEccmReport.Models.Params, ): Promise { 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 { 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 { 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 { 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 { 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 { 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 = {}; 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; } }