diff --git a/.gitignore b/.gitignore index 2148fa8..1dce6ce 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ configs/issue-event-emitter-config.jsonc tmp/* configs/redmine-statuses-config.jsonc configs/redmine-status-changes-config.jsonc +configs/eccm-versions-config.jsonc +configs/eccm-config.jsonc +configs/current-user-rules.jsonc diff --git a/configs/current-user-rules.jsonc.dist b/configs/current-user-rules.jsonc.dist new file mode 100644 index 0000000..8da3e4e --- /dev/null +++ b/configs/current-user-rules.jsonc.dist @@ -0,0 +1,4 @@ +{ + "New": "", + "*": "" +} \ No newline at end of file diff --git a/configs/eccm-config.jsonc.dist b/configs/eccm-config.jsonc.dist new file mode 100644 index 0000000..3975be1 --- /dev/null +++ b/configs/eccm-config.jsonc.dist @@ -0,0 +1,11 @@ +{ + "currentVersions": [], + "projectName": "", + "currentIssuesStatuses": [], + "groups": [ + { + "name": "", + "people": [""] + } + ] +} \ No newline at end of file diff --git a/libs/event-emitter/src/consts/consts.ts b/libs/event-emitter/src/consts/consts.ts new file mode 100644 index 0000000..e270e67 --- /dev/null +++ b/libs/event-emitter/src/consts/consts.ts @@ -0,0 +1 @@ +export const UNLIMITED = 999999; 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..a4c7667 100644 --- a/libs/event-emitter/src/users/users.service.ts +++ b/libs/event-emitter/src/users/users.service.ts @@ -64,7 +64,7 @@ export class UsersService { }, limit: 1, }); - if (!res && !res.docs && !res.docs[0]) { + if (!res || !res.docs || !res.docs[0]) { return null; } const userFromDb = res.docs[0]; @@ -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 bc20acf..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'; @@ -22,8 +22,13 @@ import { TelegramBotService } from './telegram-bot/telegram-bot.service'; import { UserMetaInfoService } from './user-meta-info/user-meta-info.service'; import { UserMetaInfo } from './couchdb-datasources/user-meta-info'; import { PersonalNotificationAdapterService } from './notifications/adapters/personal-notification.adapter/personal-notification.adapter.service'; -import { PublicUrlAdapterService } from './notifications/adapters/public-url.adapter.service'; 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: [ @@ -35,7 +40,12 @@ import { StatusChangeAdapterService } from './notifications/adapters/status-chan isGlobal: true, }), ], - controllers: [AppController, MainController], + controllers: [ + AppController, + MainController, + CurrentIssuesEccmReportController, + DailyEccmReportController, + ], providers: [ AppService, CustomFieldsEnhancer, @@ -49,8 +59,11 @@ import { StatusChangeAdapterService } from './notifications/adapters/status-chan UserMetaInfoService, UserMetaInfo, PersonalNotificationAdapterService, - PublicUrlAdapterService, 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/configs/app.ts b/src/configs/app.ts index 1177c8b..9919b6c 100644 --- a/src/configs/app.ts +++ b/src/configs/app.ts @@ -5,10 +5,14 @@ import { parse } from 'jsonc-parser'; import { AppConfig } from 'src/models/app-config.model'; import RedmineStatusesConfigLoader from './statuses.config'; import RedmineStatusChangesConfigLoader from './status-changes.config'; +import RedmineEccmConfig from './eccm.config'; +import RedmineCurrentUserRulesConfig from './current-user-rules.config'; const redmineIssueEventEmitterConfig = RedmineIssueEventEmitterConfigLoader(); const redmineStatusesConfig = RedmineStatusesConfigLoader(); const redmineStatusChanges = RedmineStatusChangesConfigLoader(); +const redmineEccm = RedmineEccmConfig(); +const redmineCurrentUserRules = RedmineCurrentUserRulesConfig(); let appConfig: AppConfig; @@ -30,6 +34,8 @@ export default (): AppConfig => { redmineStatuses: redmineStatusesConfig, redmineIssueEventEmitterConfig: redmineIssueEventEmitterConfig, redmineStatusChanges: redmineStatusChanges, + redmineEccm: redmineEccm, + redmineCurrentUserRules: redmineCurrentUserRules, }; return appConfig; diff --git a/src/configs/current-user-rules.config.ts b/src/configs/current-user-rules.config.ts new file mode 100644 index 0000000..52e9117 --- /dev/null +++ b/src/configs/current-user-rules.config.ts @@ -0,0 +1,23 @@ +import { join } from 'path'; +import { readFileSync } from 'fs'; +import { parse } from 'jsonc-parser'; + +let currentUserRules: Record; + +export default (): Record => { + if (currentUserRules) { + return currentUserRules; + } + + const userDefinedConfigPath = + process.env['ELTEX_REDMINE_HELPER_CURRENT_USER_RULES_CONFIG_PATH']; + const defaultConfigPath = join('configs', 'current-user-rules.jsonc'); + + const configPath = userDefinedConfigPath || defaultConfigPath; + + const rawData = readFileSync(configPath, { encoding: 'utf-8' }); + + currentUserRules = parse(rawData); + + return currentUserRules; +}; diff --git a/src/configs/eccm.config.ts b/src/configs/eccm.config.ts new file mode 100644 index 0000000..838efbb --- /dev/null +++ b/src/configs/eccm.config.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { EccmConfig } from 'src/models/eccm-config.model'; +import { parse } from 'jsonc-parser'; + +let eccmVersion: EccmConfig.Config; + +export default (): EccmConfig.Config => { + if (eccmVersion) { + return eccmVersion; + } + + const userDefinedConfigPath = + process.env['ELTEX_REDMINE_HELPER_ECCM_VERSIONS_CONFIG_PATH']; + const defaultConfigPath = join('configs', 'eccm-config.jsonc'); + + const configPath = userDefinedConfigPath || defaultConfigPath; + + const rawData = readFileSync(configPath, { encoding: 'utf-8' }); + + eccmVersion = parse(rawData); + + return eccmVersion; +}; diff --git a/src/converters/redmine-public-url.converter.ts b/src/converters/redmine-public-url.converter.ts index 1abf0b1..da7b4d2 100644 --- a/src/converters/redmine-public-url.converter.ts +++ b/src/converters/redmine-public-url.converter.ts @@ -1,3 +1,4 @@ +import { RedmineTypes } from '@app/event-emitter/models/redmine-types'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -14,4 +15,13 @@ export class RedminePublicUrlConverter { convert(issueId: number | string): string { return `${this.redminePublicUrlPrefix}/issues/${issueId}`; } + + getUrl(issueId: number | string): string { + return this.convert(issueId); + } + + getHtmlHref(issue: RedmineTypes.Issue): string { + const url = this.getUrl(issue.id); + return `${issue.tracker.name} #${issue.id}`; + } } diff --git a/src/issue-enhancers/current-user-enhancer.ts b/src/issue-enhancers/current-user-enhancer.ts index e48e2e0..63045ec 100644 --- a/src/issue-enhancers/current-user-enhancer.ts +++ b/src/issue-enhancers/current-user-enhancer.ts @@ -1,26 +1,23 @@ import { IssueEnhancerInterface } from '@app/event-emitter/issue-enhancers/issue-enhancer-interface'; import { RedmineTypes } from '@app/event-emitter/models/redmine-types'; -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; @Injectable() export class CurrentUserEnhancer implements IssueEnhancerInterface { name = 'current-user'; - // TODO: Переместить правила в конфиг + private rules: Record; + private logger = new Logger(CurrentUserEnhancer.name); - private rules = { - New: 'dev', - 'In Progress': 'dev', - 'Re-opened': 'dev', - 'Code Review': 'cr', - Resolved: 'qa', - Testing: 'qa', - 'Wait Release': 'dev', - Pending: 'dev', - Feedback: 'qa', - Closed: 'dev', - Rejected: 'dev', - }; + constructor(private configService: ConfigService) { + this.rules = this.configService.get>( + 'redmineCurrentUserRules', + ); + this.logger.debug( + `Loaded rules for current user enhancer - ${JSON.stringify(this.rules)}`, + ); + } async enhance( issue: RedmineTypes.Issue, @@ -29,7 +26,7 @@ export class CurrentUserEnhancer implements IssueEnhancerInterface { const status = issue.status.name; - const fieldName = this.rules[status]; + const fieldName = this.rules[status] || this.rules['*'] || null; if (fieldName) { res.current_user = { ...res[fieldName] }; } 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/app-config.model.ts b/src/models/app-config.model.ts index b2356c6..be968a9 100644 --- a/src/models/app-config.model.ts +++ b/src/models/app-config.model.ts @@ -1,4 +1,5 @@ import { MainConfigModel } from '@app/event-emitter/models/main-config-model'; +import { EccmConfig } from './eccm-config.model'; import { StatusChangesConfig } from './status-changes-config.model'; import { StatusesConfig } from './statuses-config.model'; @@ -6,6 +7,8 @@ export type AppConfig = { redmineIssueEventEmitterConfig: MainConfigModel; redmineStatuses: StatusesConfig.Config; redmineStatusChanges: StatusChangesConfig.Config; + redmineEccm: EccmConfig.Config; + redmineCurrentUserRules: Record; couchDb: { dbs: { changes: string; diff --git a/src/models/eccm-config.model.ts b/src/models/eccm-config.model.ts new file mode 100644 index 0000000..37317b7 --- /dev/null +++ b/src/models/eccm-config.model.ts @@ -0,0 +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/notifications/adapters/personal-notification.adapter/personal-notification.adapter.service.ts b/src/notifications/adapters/personal-notification.adapter/personal-notification.adapter.service.ts index 83c645a..6d946ea 100644 --- a/src/notifications/adapters/personal-notification.adapter/personal-notification.adapter.service.ts +++ b/src/notifications/adapters/personal-notification.adapter/personal-notification.adapter.service.ts @@ -4,7 +4,7 @@ import { ConfigService } from '@nestjs/config'; import { IssueAndPersonalParsedMessageModel } from 'src/models/issue-and-personal-parsed-message.model'; import { TelegramBotService } from 'src/telegram-bot/telegram-bot.service'; import Handlebars from 'handlebars'; -import { PublicUrlAdapterService } from '../public-url.adapter.service'; +import { RedminePublicUrlConverter } from 'src/converters/redmine-public-url.converter'; @Injectable() export class PersonalNotificationAdapterService { @@ -14,7 +14,7 @@ export class PersonalNotificationAdapterService { private telegramBotService: TelegramBotService, private configService: ConfigService, private usersService: UsersService, - private publicUrlAdapterService: PublicUrlAdapterService, + private redminePublicUrlConverter: RedminePublicUrlConverter, ) { const template = this.configService.get('personalMessageTemplate'); this.personalMessageTemplate = Handlebars.compile(template); @@ -26,7 +26,7 @@ export class PersonalNotificationAdapterService { const promises = issueAndMessages.personalParsedMessage.recipients.map( async (recipient) => { const redmineId = recipient; - const issueUrlHtml = this.publicUrlAdapterService.getHtmlHref( + const issueUrlHtml = this.redminePublicUrlConverter.getHtmlHref( issueAndMessages.issue, ); const sender = await this.usersService.getUser( diff --git a/src/notifications/adapters/public-url.adapter.service.ts b/src/notifications/adapters/public-url.adapter.service.ts deleted file mode 100644 index fe2b4de..0000000 --- a/src/notifications/adapters/public-url.adapter.service.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { RedmineTypes } from '@app/event-emitter/models/redmine-types'; -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; - -@Injectable() -export class PublicUrlAdapterService { - private publicUrl: string; - - constructor(private configService: ConfigService) { - this.publicUrl = this.configService.get('redmineUrlPublic'); - } - - getUrl(issueId: number): string { - return `${this.publicUrl}/issues/${issueId}`; - } - - getHtmlHref(issue: RedmineTypes.Issue): string { - const url = this.getUrl(issue.id); - return `${issue.tracker.name} #${issue.id}`; - } -} 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 new file mode 100644 index 0000000..13489ac --- /dev/null +++ b/src/reports/current-issues-eccm.report.service.ts @@ -0,0 +1,231 @@ +/* eslint-disable prettier/prettier */ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Issues } from '@app/event-emitter/couchdb-datasources/issues'; +import { UNLIMITED } from '@app/event-emitter/consts/consts'; +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 { + export type Options = { + statuses?: string[]; + versions?: string[]; + userIds?: number[]; + project?: string; + template?: string; + fields?: string[]; + }; + + // eslint-disable-next-line @typescript-eslint/no-namespace + export namespace Defaults { + export const statuses = [ + 'In Progress', + 'Feedback', + 'Re-opened', + 'Code Review', + 'Resolved', + 'Testing', + ]; + + 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 = { + id: number; + firstname: string; + lastname: string; + }; + + export type Status = { + id: number; + name: string; + }; + + export type IssuesAndStatus = { + status: Status; + issues: RedmineTypes.Issue[]; + }; + + export type UserReport = { + user: User; + issuesGroupedByStatus: IssuesAndStatus[]; + }; + + export class UsersReport { + private report: UserReport[] = []; + + 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'); + + constructor( + private redminePublicUrlConverter: RedminePublicUrlConverter, + private redminePublicUrl: string, + ) { + Handlebars.registerPartial( + 'redmineIssueAHref', + `{{issue.tracker.name}} #{{issue.id}}`, + ); + } + + push(item: any): void { + const user: User = { + id: item.current_user.id, + firstname: item.current_user.firstname, + lastname: item.current_user.lastname, + }; + const status: Status = { + id: item.status.id, + name: item.status.name, + }; + const issuesAndStatus = this.getOrCreateIssuesAndStatus(user, status); + issuesAndStatus.issues.push(item); + } + + getOrCreateUserReport(user: User): UserReport { + let userReport: UserReport; + userReport = this.report.find((r) => r.user.id == user.id); + if (!userReport) { + userReport = { + user: user, + issuesGroupedByStatus: [], + }; + this.report.push(userReport); + } + return userReport; + } + + getOrCreateIssuesAndStatus(user: User, status: Status): IssuesAndStatus { + const userReport = this.getOrCreateUserReport(user); + let issuesAndStatus: IssuesAndStatus; + issuesAndStatus = userReport.issuesGroupedByStatus.find( + (i) => i.status.id == status.id, + ); + if (!issuesAndStatus) { + issuesAndStatus = { + issues: [], + status: status, + }; + userReport.issuesGroupedByStatus.push(issuesAndStatus); + } + return issuesAndStatus; + } + + getAllUsersReportByTemplate(template?: string): string { + const reportFormatter = Handlebars.compile(template || this.reportTemplate); + return reportFormatter(this.report); + } + } +} + +@Injectable() +export class CurrentIssuesEccmReportService { + private eccmConfig: EccmConfig.Config; + private logger = new Logger(CurrentIssuesEccmReportService.name); + + constructor( + private configService: ConfigService, + private issuesDatasource: Issues, + private redminePublicUrlConverter: RedminePublicUrlConverter, + private issuesService: IssuesService, + ) { + this.eccmConfig = this.configService.get('redmineEccm'); + } + + async getData( + options?: CurrentIssuesEccmReport.Options, + ): Promise { + const datasource = await this.issuesDatasource.getDatasource(); + + const query: nano.MangoQuery = { + selector: { + 'status.name': { + $in: CurrentIssuesEccmReport.Defaults.statuses, + }, + 'fixed_version.name': { + $in: this.eccmConfig.currentVersions, + }, + 'project.name': + options?.project || CurrentIssuesEccmReport.Defaults.projectName, + }, + limit: UNLIMITED, + }; + + if (options && options.fields) { + query.fields = options.fields; + } + if (options && options.statuses) { + query.selector['status.name'] = { + $in: options.statuses, + }; + } + if (options && options.versions) { + query.selector['fixed_version.name'] = { + $in: options.versions, + }; + } + if (options && options.userIds) { + query.selector['current_user.id'] = { + $in: options.userIds, + }; + } else { + query.selector['current_user.id'] = { + $exists: true, + }; + } + + this.logger.debug(`Query for get report data: ${JSON.stringify(query)}`); + + const data = await this.issuesService.find(query); + this.logger.debug(`Found issues for report: ${JSON.stringify(data.length)}`); + + return data; + } + + async getReport(options?: CurrentIssuesEccmReport.Options): Promise { + this.logger.debug(`Options for report: ${JSON.stringify(options || null)}`); + const data = await this.getData(options); + this.logger.debug(`Data for report ${JSON.stringify(data)}`); + const report = new CurrentIssuesEccmReport.UsersReport( + this.redminePublicUrlConverter, + this.configService.get('redmineUrlPublic'), + ); + data.forEach((item) => report.push(item)); + 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..8721885 --- /dev/null +++ b/src/reports/daily-eccm.report.controller.ts @@ -0,0 +1,37 @@ +import { Controller, Get, Query, Render } from '@nestjs/common'; +import { + DailyEccmReport, + DailyEccmReportService, +} from './daily-eccm.report.service'; + +@Controller('daily-eccm') +export class DailyEccmReportController { + constructor(private dailyEccmReportService: DailyEccmReportService) {} + + @Get() + @Render('daily-eccm-report') + async getReport( + @Query('from') from: string, + @Query('to') to: string, + ): Promise { + const now = new Date().toISOString(); + return await this.dailyEccmReportService.generateReport({ + from: from, + to: to, + reportDate: now, + }); + } + + @Get('/raw') + async getReportRawData( + @Query('from') from: string, + @Query('to') to: string, + ): Promise { + const now = new Date().toISOString(); + return await this.dailyEccmReportService.generateReport({ + from: from, + to: to, + reportDate: now, + }); + } +} diff --git a/src/reports/daily-eccm.report.service.ts b/src/reports/daily-eccm.report.service.ts new file mode 100644 index 0000000..f249cd8 --- /dev/null +++ b/src/reports/daily-eccm.report.service.ts @@ -0,0 +1,522 @@ +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; + 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: '', + 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; + } + 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, + ) { + 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) => { + 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; + } +} diff --git a/src/telegram-bot/handlers/current-issues.bot-handler.service.ts b/src/telegram-bot/handlers/current-issues.bot-handler.service.ts new file mode 100644 index 0000000..78f9e60 --- /dev/null +++ b/src/telegram-bot/handlers/current-issues.bot-handler.service.ts @@ -0,0 +1,47 @@ +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 { 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'; + +@Injectable() +export class CurrentIssuesBotHandlerService { + private forName = /\/current_issues_eccm (.+) (.+)/; + private forCurrentUser = /\/current_issues_eccm/; + private service: TelegramBotService; + private eccmConfig: EccmConfig.Config; + private logger = new Logger(CurrentIssuesBotHandlerService.name); + + constructor( + private configService: ConfigService, + private currentIssuesEccmReportService: CurrentIssuesEccmReportService, + private userMetaInfoService: UserMetaInfoService, + ) { + this.eccmConfig = this.configService.get('redmineEccm'); + } + + async init(service: TelegramBotService, bot: TelegramBot): Promise { + if (!this.service) { + this.service = service; + } + bot.onText(/\/current_issues_eccm/, async (msg) => { + const userMetaInfo = await this.userMetaInfoService.findByTelegramId( + msg.chat.id, + ); + const redmineUserId = userMetaInfo.user_id; + const report = await this.currentIssuesEccmReportService.getReport({ + userIds: [redmineUserId], + 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', { + parse_mode: 'HTML', + }); + }); + } +} diff --git a/src/telegram-bot/telegram-bot.service.ts b/src/telegram-bot/telegram-bot.service.ts index 659f0da..37da5fa 100644 --- a/src/telegram-bot/telegram-bot.service.ts +++ b/src/telegram-bot/telegram-bot.service.ts @@ -5,13 +5,13 @@ import TelegramBot from 'node-telegram-bot-api'; import { UserMetaInfoService } from 'src/user-meta-info/user-meta-info.service'; import axios from 'axios'; import { UserMetaInfoModel } from 'src/models/user-meta-info.model'; +import { CurrentIssuesBotHandlerService } from './handlers/current-issues.bot-handler.service'; @Injectable() export class TelegramBotService { private logger = new Logger(TelegramBotService.name); private bot: TelegramBot; private telegramBotToken: string; - private redmineApiUrlPrefix: string; private redminePublicUrlPrefix: string; private registerRe = /\/register (\d+) (.+)/; @@ -20,10 +20,9 @@ export class TelegramBotService { private userMetaInfoService: UserMetaInfoService, private usersService: UsersService, private configService: ConfigService, + private currentIssuesBotHandlerService: CurrentIssuesBotHandlerService, ) { this.telegramBotToken = this.configService.get('telegramBotToken'); - this.redmineApiUrlPrefix = - this.configService.get('redmineUrlPrefix'); this.redminePublicUrlPrefix = this.configService.get('redmineUrlPublic'); this.initTelegramBot(); @@ -44,6 +43,7 @@ export class TelegramBotService { this.bot.onText(/\/leave/, async (msg) => { await this.leave(msg); }); + this.currentIssuesBotHandlerService.init(this, this.bot); } private async showHelpMessage(msg: TelegramBot.Message): Promise { diff --git a/views/daily-eccm-report.hbs b/views/daily-eccm-report.hbs new file mode 100644 index 0000000..6b3d305 --- /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