From 7d94350ae0694ae512091a51bccf7160f6c42a42 Mon Sep 17 00:00:00 2001 From: Pavel Gnedov Date: Tue, 8 Nov 2022 07:44:27 +0700 Subject: [PATCH] =?UTF-8?q?=D0=93=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=B0=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=B4=D0=B5=D0=B9=D0=BB=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- configs/eccm-config.jsonc.dist | 8 +- .../src/events/redmine-events.gateway.ts | 3 +- .../src/issues/issues.service.ts | 47 +- .../event-emitter/src/models/redmine-types.ts | 11 + .../redmine-data-loader.ts | 32 +- libs/event-emitter/src/users/users.service.ts | 9 + .../src/utils/get-parents-hint.ts | 13 + package-lock.json | 49 ++ package.json | 1 + src/app.module.ts | 15 +- src/changes/changes.service.ts | 15 + src/main.ts | 22 +- src/models/eccm-config.model.ts | 6 + .../current-issues-eccm.report.controller.ts | 60 ++ .../current-issues-eccm.report.service.ts | 60 +- src/reports/daily-eccm.report.controller.ts | 31 ++ src/reports/daily-eccm.report.service.ts | 511 ++++++++++++++++++ .../current-issues.bot-handler.service.ts | 3 +- views/daily-eccm-report.hbs | 50 ++ 19 files changed, 909 insertions(+), 37 deletions(-) create mode 100644 libs/event-emitter/src/utils/get-parents-hint.ts create mode 100644 src/changes/changes.service.ts create mode 100644 src/reports/current-issues-eccm.report.controller.ts create mode 100644 src/reports/daily-eccm.report.controller.ts create mode 100644 src/reports/daily-eccm.report.service.ts create mode 100644 views/daily-eccm-report.hbs diff --git a/configs/eccm-config.jsonc.dist b/configs/eccm-config.jsonc.dist index 79d322b..3975be1 100644 --- a/configs/eccm-config.jsonc.dist +++ b/configs/eccm-config.jsonc.dist @@ -1,5 +1,11 @@ { "currentVersions": [], "projectName": "", - "currentIssuesStatuses": [] + "currentIssuesStatuses": [], + "groups": [ + { + "name": "", + "people": [""] + } + ] } \ No newline at end of file diff --git a/libs/event-emitter/src/events/redmine-events.gateway.ts b/libs/event-emitter/src/events/redmine-events.gateway.ts index 6240c2e..fd05823 100644 --- a/libs/event-emitter/src/events/redmine-events.gateway.ts +++ b/libs/event-emitter/src/events/redmine-events.gateway.ts @@ -65,7 +65,8 @@ export class RedmineEventsGateway { res = await this.redmineDataLoader.loadIssues(issueNumbers); } catch (e) { this.logger.error( - `Error load issues: ${e.message} for issues: ${issueNumbers}`, + `Error load issues: ${e.message} ` + + `for issues: ${JSON.stringify(issueNumbers)}`, ); return []; } diff --git a/libs/event-emitter/src/issues/issues.service.ts b/libs/event-emitter/src/issues/issues.service.ts index 8358be7..6b04cd7 100644 --- a/libs/event-emitter/src/issues/issues.service.ts +++ b/libs/event-emitter/src/issues/issues.service.ts @@ -1,11 +1,13 @@ import { RedmineTypes } from '../models/redmine-types'; -import { Injectable, Logger } from '@nestjs/common'; +import { CacheTTL, Injectable, Logger } from '@nestjs/common'; import { Issues } from '../couchdb-datasources/issues'; import { RedmineEventsGateway } from '../events/redmine-events.gateway'; import { RedmineIssuesCacheWriterService } from '../issue-cache-writer/redmine-issues-cache-writer.service'; import { RedmineDataLoader } from '../redmine-data-loader/redmine-data-loader'; import { MemoryCache } from '../utils/memory-cache'; import nano from 'nano'; +import { UNLIMITED } from '../consts/consts'; +import { GetParentsHint } from '../utils/get-parents-hint'; export const ISSUE_MEMORY_CACHE_LIFETIME = 30 * 1000; const ISSUE_MEMORY_CACHE_AUTOCLEAN_INTERVAL = 1000 * 60 * 5; @@ -32,6 +34,49 @@ export class IssuesService { return res.docs; } + @CacheTTL(60) + async getIssues(ids: number[]): Promise { + const issueDb = await this.issues.getDatasource(); + try { + const docs = await issueDb.find({ + selector: { + id: { + $in: ids, + }, + }, + limit: UNLIMITED, + }); + return docs.docs; + } catch (ex) { + return []; + } + } + + @CacheTTL(120) + async getParents( + issueId: number, + count?: number, + ): Promise { + let index = 0; + const res: RedmineTypes.Issue[] = []; + let currentIssue = await this.getIssue(issueId); + res.unshift(currentIssue); + let parentIssueId = currentIssue.parent?.id || null; + while ( + parentIssueId !== null && + currentIssue.id >= 0 && + (typeof count === 'undefined' || count === null || index < count) + ) { + currentIssue = await this.getIssue(parentIssueId); + res.unshift(currentIssue); + parentIssueId = currentIssue.parent?.id || null; + index++; + } + const parentsHint = GetParentsHint(res); + this.logger.debug(`Parents for issue #${issueId} - ${parentsHint}`); + return res; + } + async getIssue( issueId: number, force = false, diff --git a/libs/event-emitter/src/models/redmine-types.ts b/libs/event-emitter/src/models/redmine-types.ts index 0777cd5..12f1d9d 100644 --- a/libs/event-emitter/src/models/redmine-types.ts +++ b/libs/event-emitter/src/models/redmine-types.ts @@ -26,6 +26,15 @@ export module RedmineTypes { details?: JournalDetail[]; }; + export type ChildIssue = { + id: number; + tracker: IdAndName; + subject: string; + children?: Children; + }; + + export type Children = ChildIssue[]; + export type Issue = { id: number; project: IdAndName; @@ -48,6 +57,8 @@ export module RedmineTypes { closed_on?: string; relations?: Record[]; journals?: Journal[]; + children?: Children; + parent?: { id: number }; }; // eslint-disable-next-line @typescript-eslint/prefer-namespace-keyword, @typescript-eslint/no-namespace diff --git a/libs/event-emitter/src/redmine-data-loader/redmine-data-loader.ts b/libs/event-emitter/src/redmine-data-loader/redmine-data-loader.ts index c4276b4..4eef4cf 100644 --- a/libs/event-emitter/src/redmine-data-loader/redmine-data-loader.ts +++ b/libs/event-emitter/src/redmine-data-loader/redmine-data-loader.ts @@ -24,7 +24,14 @@ export class RedmineDataLoader { async loadIssue(issueNumber: number): Promise { const url = this.getIssueUrl(issueNumber); - const resp = await axios.get(url); + let resp; + try { + resp = await axios.get(url); + } catch (ex) { + const errorMsg = ex.message || 'Unknown error'; + this.logger.error(`${errorMsg} for url = ${url}`); + throw ex; + } if (!resp || !resp.data || !resp.data.issue) { this.logger.error( `Failed to load issue from redmine, issueNumber = ${issueNumber}`, @@ -34,7 +41,15 @@ export class RedmineDataLoader { this.logger.debug( `Loaded issue, issueNumber = ${issueNumber}, subject = ${resp.data.issue.subject}`, ); - return await this.enhancerService.enhanceIssue(resp.data.issue); + let enhancedIssue; + try { + enhancedIssue = await this.enhancerService.enhanceIssue(resp.data.issue); + } catch (ex) { + const errorMsg = ex.message || 'Unknown error'; + this.logger.error(`${errorMsg} at enhance issue #${issueNumber}`); + throw ex; + } + return enhancedIssue; } async loadUsers(users: number[]): Promise<(RedmineTypes.User | null)[]> { @@ -43,8 +58,19 @@ export class RedmineDataLoader { } async loadUser(userNumber: number): Promise { + if (userNumber <= 0) { + this.logger.warn(`Invalid userNumber = ${userNumber}`); + return null; + } const url = this.getUserUrl(userNumber); - const resp = await axios.get(url); + let resp; + try { + resp = await axios.get(url); + } catch (ex) { + const errorMsg = ex.message || 'Unknown error'; + this.logger.error(`${errorMsg} at load user by url ${url}`); + return null; + } if (!resp || !resp.data?.user) { this.logger.error( `Failed to load user from redmine, userNumber = ${userNumber}`, diff --git a/libs/event-emitter/src/users/users.service.ts b/libs/event-emitter/src/users/users.service.ts index 47d542b..cdc34b7 100644 --- a/libs/event-emitter/src/users/users.service.ts +++ b/libs/event-emitter/src/users/users.service.ts @@ -72,6 +72,15 @@ export class UsersService { return RedmineTypes.CreateUser(userFromDb); } + async findUserByFullname( + fullname: string, + ): Promise { + const parts = fullname.split(' ').map((item) => item.trim()); + const lastname = parts.splice(parts.length - 1, 1).join(' '); + const firstname = parts.join(' '); + return await this.findUserByName(firstname, lastname); + } + private async getUserFromRedmine( userId: number, ): Promise { diff --git a/libs/event-emitter/src/utils/get-parents-hint.ts b/libs/event-emitter/src/utils/get-parents-hint.ts new file mode 100644 index 0000000..48b8e99 --- /dev/null +++ b/libs/event-emitter/src/utils/get-parents-hint.ts @@ -0,0 +1,13 @@ +import { RedmineTypes } from '../models/redmine-types'; + +export function GetParentsHint(issues: RedmineTypes.Issue[]): string { + const parentsHint = issues + .map((issue) => { + if (issue.id < 0) { + return '...'; + } + return `${issue.tracker.name} #${issue.id} (${issue.subject})`; + }) + .join(' > '); + return parentsHint; +} diff --git a/package-lock.json b/package-lock.json index fbcdd6c..bc367dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "axios": "^0.27.2", "cache-manager": "^4.1.0", "handlebars": "^4.7.7", + "hbs": "^4.2.0", "imap-simple": "^5.1.0", "nano": "^10.0.0", "node-telegram-bot-api": "^0.59.0", @@ -4864,6 +4865,11 @@ } } }, + "node_modules/foreachasync": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/foreachasync/-/foreachasync-3.0.0.tgz", + "integrity": "sha512-J+ler7Ta54FwwNcx6wQRDhTIbNeyDcARMkOcguEqnEdtm0jKvN3Li3PDAb2Du3ubJYEWfYL83XMROXdsXAXycw==" + }, "node_modules/forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", @@ -5343,6 +5349,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hbs": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/hbs/-/hbs-4.2.0.tgz", + "integrity": "sha512-dQwHnrfWlTk5PvG9+a45GYpg0VpX47ryKF8dULVd6DtwOE6TEcYQXQ5QM6nyOx/h7v3bvEQbdn19EDAcfUAgZg==", + "dependencies": { + "handlebars": "4.7.7", + "walk": "2.3.15" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/hexoid": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", @@ -9899,6 +9918,14 @@ "node": ">=10" } }, + "node_modules/walk": { + "version": "2.3.15", + "resolved": "https://registry.npmjs.org/walk/-/walk-2.3.15.tgz", + "integrity": "sha512-4eRTBZljBfIISK1Vnt69Gvr2w/wc3U6Vtrw7qiN5iqYJPH7LElcYh/iU4XWhdCy2dZqv1ToMyYlybDylfG/5Vg==", + "dependencies": { + "foreachasync": "^3.0.0" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -14010,6 +14037,11 @@ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz", "integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==" }, + "foreachasync": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/foreachasync/-/foreachasync-3.0.0.tgz", + "integrity": "sha512-J+ler7Ta54FwwNcx6wQRDhTIbNeyDcARMkOcguEqnEdtm0jKvN3Li3PDAb2Du3ubJYEWfYL83XMROXdsXAXycw==" + }, "forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", @@ -14346,6 +14378,15 @@ "has-symbols": "^1.0.2" } }, + "hbs": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/hbs/-/hbs-4.2.0.tgz", + "integrity": "sha512-dQwHnrfWlTk5PvG9+a45GYpg0VpX47ryKF8dULVd6DtwOE6TEcYQXQ5QM6nyOx/h7v3bvEQbdn19EDAcfUAgZg==", + "requires": { + "handlebars": "4.7.7", + "walk": "2.3.15" + } + }, "hexoid": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", @@ -17740,6 +17781,14 @@ "xml-name-validator": "^3.0.0" } }, + "walk": { + "version": "2.3.15", + "resolved": "https://registry.npmjs.org/walk/-/walk-2.3.15.tgz", + "integrity": "sha512-4eRTBZljBfIISK1Vnt69Gvr2w/wc3U6Vtrw7qiN5iqYJPH7LElcYh/iU4XWhdCy2dZqv1ToMyYlybDylfG/5Vg==", + "requires": { + "foreachasync": "^3.0.0" + } + }, "walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", diff --git a/package.json b/package.json index f6ad0b3..4bfddfc 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "axios": "^0.27.2", "cache-manager": "^4.1.0", "handlebars": "^4.7.7", + "hbs": "^4.2.0", "imap-simple": "^5.1.0", "nano": "^10.0.0", "node-telegram-bot-api": "^0.59.0", diff --git a/src/app.module.ts b/src/app.module.ts index d696e15..d2cddd0 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -4,7 +4,7 @@ import { EnhancerService } from '@app/event-emitter/issue-enhancers/enhancer.ser import { TimestampEnhancer } from '@app/event-emitter/issue-enhancers/timestamps-enhancer'; import { MainController } from '@app/event-emitter/main/main.controller'; import { CacheModule, Logger, Module, OnModuleInit } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; +import { ConfigModule, ConfigService } from '@nestjs/config'; import { switchMap, tap } from 'rxjs'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @@ -25,6 +25,10 @@ import { PersonalNotificationAdapterService } from './notifications/adapters/per import { StatusChangeAdapterService } from './notifications/adapters/status-change.adapter.service'; import { CurrentIssuesEccmReportService } from './reports/current-issues-eccm.report.service'; import { CurrentIssuesBotHandlerService } from './telegram-bot/handlers/current-issues.bot-handler.service'; +import { CurrentIssuesEccmReportController } from './reports/current-issues-eccm.report.controller'; +import { DailyEccmReportController } from './reports/daily-eccm.report.controller'; +import { DailyEccmReportService } from './reports/daily-eccm.report.service'; +import { ChangesService } from './changes/changes.service'; @Module({ imports: [ @@ -36,7 +40,12 @@ import { CurrentIssuesBotHandlerService } from './telegram-bot/handlers/current- isGlobal: true, }), ], - controllers: [AppController, MainController], + controllers: [ + AppController, + MainController, + CurrentIssuesEccmReportController, + DailyEccmReportController, + ], providers: [ AppService, CustomFieldsEnhancer, @@ -53,6 +62,8 @@ import { CurrentIssuesBotHandlerService } from './telegram-bot/handlers/current- StatusChangeAdapterService, CurrentIssuesEccmReportService, CurrentIssuesBotHandlerService, + DailyEccmReportService, + ChangesService, ], }) export class AppModule implements OnModuleInit { diff --git a/src/changes/changes.service.ts b/src/changes/changes.service.ts new file mode 100644 index 0000000..0fb8574 --- /dev/null +++ b/src/changes/changes.service.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; +import { Changes } from 'src/couchdb-datasources/changes'; +import nano from 'nano'; +import { Change } from 'src/models/change.model'; + +@Injectable() +export class ChangesService { + constructor(private changes: Changes) {} + + async find(query: nano.MangoQuery): Promise { + const changesDb = await this.changes.getDatasource(); + const res = await changesDb.find(query); + return res.docs; + } +} diff --git a/src/main.ts b/src/main.ts index af1e239..18ef801 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,12 +1,30 @@ import { NestFactory } from '@nestjs/core'; +import { NestExpressApplication } from '@nestjs/platform-express'; +import { join } from 'path'; import { AppModule } from './app.module'; +import * as hbs from 'hbs'; +import configuration from './configs/app'; -process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = "0"; +process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0'; async function bootstrap() { - const app = await NestFactory.create(AppModule, { + const app = await NestFactory.create(AppModule, { logger: ['debug', 'error', 'warn', 'log', 'verbose'], }); + + app.setBaseViewsDir(join(__dirname, '..', 'views')); + app.setViewEngine('hbs'); + + // TODO: Продумать как правильно иницировать partial-ы в handlebars + // Возможно подойдёт решение описанное тут - https://www.makeuseof.com/handlebars-nestjs-templating/ + // Низкоуровневое решение по исходному коду hbs: + const redminePublicUrl = + configuration().redmineIssueEventEmitterConfig.redmineUrlPublic; + hbs.registerPartial( + 'redmineIssueAHref', + `{{issue.tracker.name}} #{{issue.id}}`, + ); + await app.listen(process.env['PORT'] || 3000); } bootstrap(); diff --git a/src/models/eccm-config.model.ts b/src/models/eccm-config.model.ts index 93c3eb2..37317b7 100644 --- a/src/models/eccm-config.model.ts +++ b/src/models/eccm-config.model.ts @@ -1,8 +1,14 @@ /* eslint-disable @typescript-eslint/no-namespace */ export namespace EccmConfig { + export type UserGroup = { + name: string; + people: string[]; + }; + export type Config = { currentVersions: string[]; projectName: string; currentIssuesStatuses: string[]; + groups: UserGroup[]; }; } diff --git a/src/reports/current-issues-eccm.report.controller.ts b/src/reports/current-issues-eccm.report.controller.ts new file mode 100644 index 0000000..a984bed --- /dev/null +++ b/src/reports/current-issues-eccm.report.controller.ts @@ -0,0 +1,60 @@ +import { Controller, Get, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { EccmConfig } from 'src/models/eccm-config.model'; +import { + CurrentIssuesEccmReport, + CurrentIssuesEccmReportService, +} from './current-issues-eccm.report.service'; + +@Controller('current-issues-eccm') +export class CurrentIssuesEccmReportController { + private logger = new Logger(CurrentIssuesEccmReportController.name); + private eccmConfig: EccmConfig.Config; + + /* eslint-disable */ + private reportTemplate = [ + // context - this.report: UserReport[] + '{{#each this}}', + '
', + // context - UserReport + '

{{user.firstname}} {{user.lastname}}:

', + '
    ', + '{{#each issuesGroupedByStatus}}', + '
  • ', + // context - IssuesAndStatus + '{{status.name}}:', + '
      ', + '{{#each issues}}', + '
    • ', + // context - RedmineTypes.Issue + `{{>redmineIssueAHref issue=.}}: {{subject}} (прио - {{priority.name}}, версия - {{fixed_version.name}})`, + '
    • ', + '{{/each}}', + '
    ', + '
  • ', + '{{/each}}', + '
', + '
', + '{{/each}}', + ].join('\n'); + /* eslint-enable */ + + constructor( + private currentIssuesEccmReportService: CurrentIssuesEccmReportService, + private configService: ConfigService, + ) { + this.eccmConfig = this.configService.get('redmineEccm'); + this.logger.debug(`Eccm config - ${JSON.stringify(this.eccmConfig)}`); + } + + @Get() + async getReport(): Promise { + return await this.currentIssuesEccmReportService.getReport({ + project: this.eccmConfig.projectName, + versions: this.eccmConfig.currentVersions, + statuses: this.eccmConfig.currentIssuesStatuses, + template: this.reportTemplate, + fields: CurrentIssuesEccmReport.Defaults.currentIssuesFields, + }); + } +} diff --git a/src/reports/current-issues-eccm.report.service.ts b/src/reports/current-issues-eccm.report.service.ts index d357db4..3b4640f 100644 --- a/src/reports/current-issues-eccm.report.service.ts +++ b/src/reports/current-issues-eccm.report.service.ts @@ -7,6 +7,8 @@ import { RedmineTypes } from '@app/event-emitter/models/redmine-types'; import nano from 'nano'; import Handlebars from 'handlebars'; import { RedminePublicUrlConverter } from 'src/converters/redmine-public-url.converter'; +import { EccmConfig } from 'src/models/eccm-config.model'; +import { IssuesService } from '@app/event-emitter/issues/issues.service'; // eslint-disable-next-line @typescript-eslint/no-namespace export namespace CurrentIssuesEccmReport { @@ -15,6 +17,8 @@ export namespace CurrentIssuesEccmReport { versions?: string[]; userIds?: number[]; project?: string; + template?: string; + fields?: string[]; }; // eslint-disable-next-line @typescript-eslint/no-namespace @@ -29,6 +33,22 @@ export namespace CurrentIssuesEccmReport { ]; export const projectName = 'ECCM'; + + export const currentIssuesFields = [ + 'id', + 'tracker.name', + 'status.id', + 'status.name', + 'priority.id', + 'priority.name', + 'fixed_version.name', + 'subject', + 'updated_on', + 'updated_on_timestamp', + 'current_user.id', + 'current_user.firstname', + 'current_user.lastname', + ]; } export type User = { @@ -55,7 +75,6 @@ export namespace CurrentIssuesEccmReport { export class UsersReport { private report: UserReport[] = []; - private reportFormatter: HandlebarsTemplateDelegate; private reportTemplate = [ // context - this.report: UserReport[] '{{#each this}}', @@ -78,7 +97,6 @@ export namespace CurrentIssuesEccmReport { private redminePublicUrlConverter: RedminePublicUrlConverter, private redminePublicUrl: string, ) { - this.reportFormatter = Handlebars.compile(this.reportTemplate); Handlebars.registerPartial( 'redmineIssueAHref', `{{issue.tracker.name}} #{{issue.id}}`, @@ -128,23 +146,25 @@ export namespace CurrentIssuesEccmReport { return issuesAndStatus; } - getAllUsersReportByTemplate(): string { - return this.reportFormatter(this.report); + getAllUsersReportByTemplate(template?: string): string { + const reportFormatter = Handlebars.compile(template || this.reportTemplate); + return reportFormatter(this.report); } } } @Injectable() export class CurrentIssuesEccmReportService { - private eccmVersions: string[]; + private eccmConfig: EccmConfig.Config; private logger = new Logger(CurrentIssuesEccmReportService.name); constructor( private configService: ConfigService, private issuesDatasource: Issues, private redminePublicUrlConverter: RedminePublicUrlConverter, + private issuesService: IssuesService, ) { - this.eccmVersions = this.configService.get('redmineEccmVersions'); + this.eccmConfig = this.configService.get('redmineEccm'); } async getData( @@ -158,29 +178,17 @@ export class CurrentIssuesEccmReportService { $in: CurrentIssuesEccmReport.Defaults.statuses, }, 'fixed_version.name': { - $in: this.eccmVersions, + $in: this.eccmConfig.currentVersions, }, 'project.name': options?.project || CurrentIssuesEccmReport.Defaults.projectName, }, - fields: [ - 'id', - 'tracker.name', - 'status.id', - 'status.name', - 'priority.id', - 'priority.name', - 'fixed_version.name', - 'subject', - 'updated_on', - 'updated_on_timestamp', - 'current_user.id', - 'current_user.firstname', - 'current_user.lastname', - ], limit: UNLIMITED, }; + if (options && options.fields) { + query.selector.fields = options.fields; + } if (options && options.statuses) { query.selector['status.name'] = { $in: options.statuses, @@ -203,9 +211,9 @@ export class CurrentIssuesEccmReportService { this.logger.debug(`Query for get report data: ${JSON.stringify(query)}`); - const rawData = await datasource.find(query); - this.logger.debug(`Raw data for report: ${JSON.stringify(rawData)}`); - const data = rawData.docs as RedmineTypes.Issue[]; + const data = await this.issuesService.find(query); + this.logger.debug(`Found issues for report: ${JSON.stringify(data.length)}`); + return data; } @@ -218,6 +226,6 @@ export class CurrentIssuesEccmReportService { this.configService.get('redmineUrlPublic'), ); data.forEach((item) => report.push(item)); - return report.getAllUsersReportByTemplate(); + return report.getAllUsersReportByTemplate(options?.template); } } diff --git a/src/reports/daily-eccm.report.controller.ts b/src/reports/daily-eccm.report.controller.ts new file mode 100644 index 0000000..ab3d1eb --- /dev/null +++ b/src/reports/daily-eccm.report.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Get, Logger, Render } from '@nestjs/common'; +import { + DailyEccmReport, + DailyEccmReportService, +} from './daily-eccm.report.service'; + +@Controller('daily-eccm') +export class DailyEccmReportController { + constructor(private dailyEccmReportService: DailyEccmReportService) {} + + // TODO: Заменить хардкоды на параметры + + @Get() + @Render('daily-eccm-report') + async getReport(): Promise { + return await this.dailyEccmReportService.generateReport({ + from: '2022-09-01T00:00:00+07:00', + to: '2022-11-04T00:00:00+07:00', + reportDate: '', + }); + } + + @Get('/raw') + async getReportRawData(): Promise { + return await this.dailyEccmReportService.generateReport({ + from: '2022-09-01T00:00:00+07:00', + to: '2022-11-04T00:00:00+07:00', + reportDate: '', + }); + } +} diff --git a/src/reports/daily-eccm.report.service.ts b/src/reports/daily-eccm.report.service.ts new file mode 100644 index 0000000..15f1e29 --- /dev/null +++ b/src/reports/daily-eccm.report.service.ts @@ -0,0 +1,511 @@ +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'; + +// 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; + reportDate: string; + }; + + export type IssueAndChanges = { + issue: RedmineTypes.Issue; + 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) => { + const u: Models.User = { + id: user.id, + lastname: user.lastname, + firstname: user.firstname, + }; + return { + user: u, + activities: [], + issuesGroupedByStatus: [], + } as Models.UserReport; + }); + this.report = { + byUsers: users, + params: { + from: '', + to: '', + reportDate: '', + }, + }; + } + + 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.reportDate = params.reportDate; + } + + 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; + } + issueAndChanges = { + issue: issue, + 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, + ) { + 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(); + } + + 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) => { + 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; + } +} diff --git a/src/telegram-bot/handlers/current-issues.bot-handler.service.ts b/src/telegram-bot/handlers/current-issues.bot-handler.service.ts index 0e047e2..78f9e60 100644 --- a/src/telegram-bot/handlers/current-issues.bot-handler.service.ts +++ b/src/telegram-bot/handlers/current-issues.bot-handler.service.ts @@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import TelegramBot from 'node-telegram-bot-api'; import { EccmConfig } from 'src/models/eccm-config.model'; -import { CurrentIssuesEccmReportService } from 'src/reports/current-issues-eccm.report.service'; +import { CurrentIssuesEccmReport, CurrentIssuesEccmReportService } from 'src/reports/current-issues-eccm.report.service'; import { UserMetaInfoService } from 'src/user-meta-info/user-meta-info.service'; import { TelegramBotService } from '../telegram-bot.service'; @@ -36,6 +36,7 @@ export class CurrentIssuesBotHandlerService { project: this.eccmConfig.projectName, versions: this.eccmConfig.currentVersions, statuses: this.eccmConfig.currentIssuesStatuses, + fields: CurrentIssuesEccmReport.Defaults.currentIssuesFields, }); this.logger.debug(`Current issues eccm report: ${report}`); bot.sendMessage(msg.chat.id, report || 'empty report', { diff --git a/views/daily-eccm-report.hbs b/views/daily-eccm-report.hbs new file mode 100644 index 0000000..efbdd4d --- /dev/null +++ b/views/daily-eccm-report.hbs @@ -0,0 +1,50 @@ + + + + + Daily Eccm Report + + +
+ Параметры отчёта +
    +
  • От - {{this.params.from}}
  • +
  • До - {{this.params.to}}
  • +
  • Дата выгрузки - {{this.params.reportDate}}
  • +
+
+

Отчёт по работникам

+ {{#each this.byUsers}} + +

{{this.user.firstname}} {{this.user.lastname}}

+ +

Текущие задачи

+
    + {{#each this.issuesGroupedByStatus}} +
  • + {{this.status.name}}: +
      + {{#each this.issues}} +
    • {{>redmineIssueAHref issue=this.issue}} (приоритет {{this.issue.priority.name}}; версия {{this.issue.fixed_version.name}}) - {{this.issue.subject}}
    • + {{/each}} +
    +
  • + {{/each}} +
+ +

Активности за период

+
    + {{#each this.activities}} +
  • + {{>redmineIssueAHref issue=this.issue}} (приоритет {{this.issue.priority.name}}; версия {{this.issue.fixed_version.name}}) - {{this.issue.subject}} +
      + {{#each this.changes}} +
    • {{this.created_on}}: {{this.change_message}}
    • + {{/each}} +
    +
  • + {{/each}} +
+ {{/each}} + + \ No newline at end of file