From addff66c8cd37fa0685952c461eefffbe93600fa Mon Sep 17 00:00:00 2001 From: Pavel Gnedov Date: Tue, 15 Nov 2022 13:19:03 +0700 Subject: [PATCH 01/30] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=B2=20=D1=80=D0=B0=D1=81=D0=BF=D0=B8=D1=81=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B0=20=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/reports/daily-eccm.report.task.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reports/daily-eccm.report.task.ts b/src/reports/daily-eccm.report.task.ts index b986936..dc3f16b 100644 --- a/src/reports/daily-eccm.report.task.ts +++ b/src/reports/daily-eccm.report.task.ts @@ -26,7 +26,7 @@ export class DailyEccmReportTask { ); } - @Cron('25 9,10 1-5 * *') + @Cron('25 9,10 * * 1-5') async generateReport(): Promise { this.logger.log(`Generate daily eccm report by cron task started`); const now = DateTime.now(); From e71491b6378e0f895c2e0be3bd43e8d8552168ed Mon Sep 17 00:00:00 2001 From: Pavel Gnedov Date: Thu, 8 Dec 2022 13:17:13 +0700 Subject: [PATCH 02/30] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=87?= =?UTF-8?q?=D0=B8=D0=BA=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B4=D0=BB=D1=8F=20=D0=B4=D0=B5=D0=B9=D0=BB?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- configs/main-config.jsonc.dist | 3 +- src/app.module.ts | 7 ++ src/consts/date-time.consts.ts | 1 + ...y-eccm-reports-user-comments.datasource.ts | 43 +++++++++ src/models/app-config.model.ts | 1 + .../daily-eccm-user-comments.service.ts | 93 +++++++++++++++++++ src/reports/daily-eccm.report.service.ts | 3 +- ...y-eccm-user-comment.bot-handler.service.ts | 88 ++++++++++++++++++ src/telegram-bot/telegram-bot.service.ts | 8 +- 9 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 src/consts/date-time.consts.ts create mode 100644 src/couchdb-datasources/daily-eccm-reports-user-comments.datasource.ts create mode 100644 src/reports/daily-eccm-user-comments.service.ts create mode 100644 src/telegram-bot/handlers/set-daily-eccm-user-comment.bot-handler.service.ts diff --git a/configs/main-config.jsonc.dist b/configs/main-config.jsonc.dist index 9e26c4d..4d5c643 100644 --- a/configs/main-config.jsonc.dist +++ b/configs/main-config.jsonc.dist @@ -3,7 +3,8 @@ "dbs": { "changes": "", "userMetaInfo": "", - "eccmDailyReports": "" + "eccmDailyReports": "", + "eccmDailyReportsUserComments": "" } }, "telegramBotToken": "", diff --git a/src/app.module.ts b/src/app.module.ts index 34a48ae..5f24362 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -32,6 +32,9 @@ import { ChangesService } from './changes/changes.service'; import { DailyEccmReportsDatasource } from './couchdb-datasources/daily-eccm-reports.datasource'; import { ScheduleModule } from '@nestjs/schedule'; import { DailyEccmReportTask } from './reports/daily-eccm.report.task'; +import { DailyEccmReportsUserCommentsDatasource } from './couchdb-datasources/daily-eccm-reports-user-comments.datasource'; +import { DailyEccmUserCommentsService } from './reports/daily-eccm-user-comments.service'; +import { SetDailyEccmUserCommentBotHandlerService } from './telegram-bot/handlers/set-daily-eccm-user-comment.bot-handler.service'; @Module({ imports: [ @@ -70,6 +73,9 @@ import { DailyEccmReportTask } from './reports/daily-eccm.report.task'; ChangesService, DailyEccmReportsDatasource, DailyEccmReportTask, + DailyEccmReportsUserCommentsDatasource, + DailyEccmUserCommentsService, + SetDailyEccmUserCommentBotHandlerService, ], }) export class AppModule implements OnModuleInit { @@ -95,6 +101,7 @@ export class AppModule implements OnModuleInit { Changes.getDatasource(); UserMetaInfo.getDatasource(); DailyEccmReportsDatasource.getDatasource(); + DailyEccmReportsUserCommentsDatasource.getDatasource(); this.enhancerService.addEnhancer([ this.timestampEnhancer, diff --git a/src/consts/date-time.consts.ts b/src/consts/date-time.consts.ts new file mode 100644 index 0000000..ef91cba --- /dev/null +++ b/src/consts/date-time.consts.ts @@ -0,0 +1 @@ +export const ISO_DATE_FORMAT = 'yyyy-MM-dd'; diff --git a/src/couchdb-datasources/daily-eccm-reports-user-comments.datasource.ts b/src/couchdb-datasources/daily-eccm-reports-user-comments.datasource.ts new file mode 100644 index 0000000..57b15b5 --- /dev/null +++ b/src/couchdb-datasources/daily-eccm-reports-user-comments.datasource.ts @@ -0,0 +1,43 @@ +import { CouchDb } from '@app/event-emitter/couchdb-datasources/couchdb'; +import nano from 'nano'; +import { Injectable, Logger } from '@nestjs/common'; +import configuration from '../configs/app'; +import { DailyEccmUserComments } from 'src/reports/daily-eccm-user-comments.service'; + +const config = configuration(); + +@Injectable() +export class DailyEccmReportsUserCommentsDatasource { + private static logger = new Logger( + DailyEccmReportsUserCommentsDatasource.name, + ); + private static db = null; + private static initilized = false; + + static async getDatasource(): Promise< + nano.DocumentScope + > { + if (DailyEccmReportsUserCommentsDatasource.initilized) { + return DailyEccmReportsUserCommentsDatasource.db; + } + DailyEccmReportsUserCommentsDatasource.initilized = true; + const n = CouchDb.getCouchDb(); + const dbName = config.couchDb.dbs.eccmDailyReportsUserComments; + const dbs = await n.db.list(); + if (!dbs.includes(dbName)) { + await n.db.create(dbName); + } + DailyEccmReportsUserCommentsDatasource.db = await n.db.use(dbName); + DailyEccmReportsUserCommentsDatasource.initilized = true; + DailyEccmReportsUserCommentsDatasource.logger.log( + `Connected to eccm_daily_reports_user_comments db - ${dbName}`, + ); + return DailyEccmReportsUserCommentsDatasource.db; + } + + async getDatasource(): Promise< + nano.DocumentScope + > { + return await DailyEccmReportsUserCommentsDatasource.getDatasource(); + } +} diff --git a/src/models/app-config.model.ts b/src/models/app-config.model.ts index b1352af..141d191 100644 --- a/src/models/app-config.model.ts +++ b/src/models/app-config.model.ts @@ -14,6 +14,7 @@ export type AppConfig = { changes: string; userMetaInfo: string; eccmDailyReports: string; + eccmDailyReportsUserComments: string; }; }; telegramBotToken: string; diff --git a/src/reports/daily-eccm-user-comments.service.ts b/src/reports/daily-eccm-user-comments.service.ts new file mode 100644 index 0000000..e5d2a09 --- /dev/null +++ b/src/reports/daily-eccm-user-comments.service.ts @@ -0,0 +1,93 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +import { UNLIMITED } from '@app/event-emitter/consts/consts'; +import { Timestamped } from '@app/event-emitter/models/timestamped'; +import { TimestampNowFill } from '@app/event-emitter/utils/timestamp-now-fill'; +import { Injectable } from '@nestjs/common'; +import nano from 'nano'; +import { DailyEccmReportsUserCommentsDatasource } from 'src/couchdb-datasources/daily-eccm-reports-user-comments.datasource'; + +export namespace DailyEccmUserComments { + export namespace Models { + export type Item = { + userId: number; + date: string; + comment: string; + }; + + export type CouchDbItem = Item & Timestamped & nano.DocumentGetResponse; + } +} + +@Injectable() +export class DailyEccmUserCommentsService { + constructor(private datasource: DailyEccmReportsUserCommentsDatasource) {} + + async setComment( + userId: number, + date: string, + comment: string, + ): Promise { + const key = this.getKey(userId, date); + const ds = await this.datasource.getDatasource(); + let existsItem: any; + try { + existsItem = await ds.get(key); + } catch (ex) { + existsItem = null; + } + const item: DailyEccmUserComments.Models.CouchDbItem = TimestampNowFill({ + userId: userId, + date: date, + comment: comment, + _id: key, + _rev: existsItem?._rev, + }); + await ds.insert(item); + } + + async loadComment(userId: number, date: string): Promise { + const key = this.getKey(userId, date); + const ds = await this.datasource.getDatasource(); + try { + const res: any = await ds.get(key); + return res.comment; + } catch (ex) { + return null; + } + } + + async loadComments( + userIds: number[], + date: string, + ): Promise> { + const query: nano.MangoQuery = { + limit: UNLIMITED, + selector: { + userId: { + $in: userIds, + }, + date: { + $eq: date, + }, + }, + }; + const ds = await this.datasource.getDatasource(); + const resp = await ds.find(query); + if (!resp || !resp.docs || resp.docs.length <= 0) { + return []; + } + const items = resp.docs; + const res: Record = {}; + for (const key in items) { + if (Object.prototype.hasOwnProperty.call(items, key)) { + const item = items[key]; + res[item.userId] = item.comment; + } + } + return res; + } + + private getKey(userId: number, date: string): string { + return `${date} - ${userId}`; + } +} diff --git a/src/reports/daily-eccm.report.service.ts b/src/reports/daily-eccm.report.service.ts index 45010e6..b17051e 100644 --- a/src/reports/daily-eccm.report.service.ts +++ b/src/reports/daily-eccm.report.service.ts @@ -14,6 +14,7 @@ import { DailyEccmReportsDatasource } from 'src/couchdb-datasources/daily-eccm-r import { Timestamped } from '@app/event-emitter/models/timestamped'; import { TimestampNowFill } from '@app/event-emitter/utils/timestamp-now-fill'; import { DateTime } from 'luxon'; +import { ISO_DATE_FORMAT } from 'src/consts/date-time.consts'; // eslint-disable-next-line @typescript-eslint/no-namespace export namespace DailyEccmReport { @@ -413,7 +414,7 @@ export class DailyEccmReportService { if (!toDate.isValid) throw new Error('to is invalid date'); let nameValue: string | null = name || null; if (!nameValue) { - nameValue = DateTime.now().toFormat('yyyy-MM-dd'); + nameValue = DateTime.now().toFormat(ISO_DATE_FORMAT); } return { from: fromDate.toISO(), diff --git a/src/telegram-bot/handlers/set-daily-eccm-user-comment.bot-handler.service.ts b/src/telegram-bot/handlers/set-daily-eccm-user-comment.bot-handler.service.ts new file mode 100644 index 0000000..ba8ab6f --- /dev/null +++ b/src/telegram-bot/handlers/set-daily-eccm-user-comment.bot-handler.service.ts @@ -0,0 +1,88 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +import { TelegramBotService } from '../telegram-bot.service'; +import { TelegramBotHandlerInterface } from '../telegram.bot-handler.interface'; +import TelegramBot from 'node-telegram-bot-api'; +import { Injectable, Logger } from '@nestjs/common'; +import { UserMetaInfoService } from 'src/user-meta-info/user-meta-info.service'; +import { Subject } from 'rxjs'; +import { DateTime } from 'luxon'; +import { ISO_DATE_FORMAT } from 'src/consts/date-time.consts'; + +export namespace SetDailyEccmUserCommentBotHandler { + export namespace Models { + export type SetDailyEccmUserComment = { + userId: number; + date: string; + comment: string; + }; + } +} + +@Injectable() +export class SetDailyEccmUserCommentBotHandlerService + implements TelegramBotHandlerInterface +{ + private service: TelegramBotService; + private regexp = /\/set_daily_eccm_user_comment (.+)/; + private logger = new Logger(SetDailyEccmUserCommentBotHandlerService.name); + private dateParamRegexp = /^date=([\d\-]+) (.+)$/; + + $messages = + new Subject(); + + constructor(private userMetaInfoService: UserMetaInfoService) { + return; + } + + async init(service: TelegramBotService, bot: TelegramBot): Promise { + if (!this.service) { + this.service = service; + } + bot.onText(this.regexp, async (msg) => { + const userMetaInfo = await this.userMetaInfoService.findByTelegramId( + msg.chat.id, + ); + const redmineUserId = userMetaInfo.user_id; + const data = this.parseDate(msg.text); + this.logger.debug( + `Setting user comment for daily eccm ` + + `by redmineUserId = ${redmineUserId}, ` + + `full text - ${msg.text}, ` + + `parsed data - ${JSON.stringify(data)}`, + ); + this.$messages.next({ + userId: redmineUserId, + date: data.date, + comment: data.msg, + }); + return; + }); + } + + getHelpMsg(): string { + return ( + `/set_daily_eccm_user_comment ` + + `[дата=yyyy-MM-dd] <комментарий> ` + + `- дополнительный комментарий для дейли` + ); + } + + private parseDate(src: string): { date: string; msg: string } | null { + let msgWithoutCommand: any = src.match(this.regexp); + if (!msgWithoutCommand || !msgWithoutCommand[1]) { + return null; + } + msgWithoutCommand = msgWithoutCommand[1]; + const msgWithDate: any = msgWithoutCommand.match(this.dateParamRegexp); + let date: any = ''; + if (msgWithDate && msgWithDate[1] && msgWithDate[2]) { + date = msgWithDate[1]; + if (DateTime.fromFormat(date, ISO_DATE_FORMAT).isValid) { + msgWithoutCommand = msgWithDate[2]; + } else { + date = ''; + } + } + return { date: date, msg: msgWithoutCommand }; + } +} diff --git a/src/telegram-bot/telegram-bot.service.ts b/src/telegram-bot/telegram-bot.service.ts index 5c5dfe2..5f80b07 100644 --- a/src/telegram-bot/telegram-bot.service.ts +++ b/src/telegram-bot/telegram-bot.service.ts @@ -7,6 +7,7 @@ import axios from 'axios'; import { UserMetaInfoModel } from 'src/models/user-meta-info.model'; import { CurrentIssuesEccmBotHandlerService } from './handlers/current-issues-eccm.bot-handler.service'; import { TelegramBotHandlerInterface } from './telegram.bot-handler.interface'; +import { SetDailyEccmUserCommentBotHandlerService } from './handlers/set-daily-eccm-user-comment.bot-handler.service'; @Injectable() export class TelegramBotService { @@ -24,12 +25,14 @@ export class TelegramBotService { private usersService: UsersService, private configService: ConfigService, private currentIssuesBotHandlerService: CurrentIssuesEccmBotHandlerService, + private setDailyEccmUserCommentBotHandlerService: SetDailyEccmUserCommentBotHandlerService, ) { this.telegramBotToken = this.configService.get('telegramBotToken'); this.redminePublicUrlPrefix = this.configService.get('redmineUrlPublic'); this.initTelegramBot(); this.handlers.push(this.currentIssuesBotHandlerService); + this.handlers.push(this.setDailyEccmUserCommentBotHandlerService); } private async initTelegramBot(): Promise { @@ -47,7 +50,10 @@ export class TelegramBotService { this.bot.onText(/\/leave/, async (msg) => { await this.leave(msg); }); - this.currentIssuesBotHandlerService.init(this, this.bot); + for (let i = 0; i < this.handlers.length; i++) { + const handler = this.handlers[i]; + await handler.init(this, this.bot); + } } private async showHelpMessage(msg: TelegramBot.Message): Promise { From 9b17d703ed694ec1e021d35c1c769035a62dd5e2 Mon Sep 17 00:00:00 2001 From: Pavel Gnedov Date: Thu, 15 Dec 2022 19:18:31 +0700 Subject: [PATCH 03/30] =?UTF-8?q?=D0=92=D1=8B=D0=B2=D0=BE=D0=B4=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B5?= =?UTF-8?q?=D0=B2=20=D0=B2=20=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=B5=20=D0=B4?= =?UTF-8?q?=D0=B5=D0=B9=D0=BB=D0=B8=20=D1=81=D0=BE=20=D1=81=D1=81=D1=8B?= =?UTF-8?q?=D0=BB=D0=BA=D0=B0=D0=BC=D0=B8=20=D0=BD=D0=B0=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B4=D0=B0=D1=87=D0=B8=20=D0=B2=20redmine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.module.ts | 18 +++++ .../redmine-public-url.converter.ts | 46 +++++++++++++ .../daily-eccm-with-extra-data.service.ts | 43 ++++++++++++ src/reports/daily-eccm.report.controller.ts | 21 +++++- ...y-eccm-user-comment.bot-handler.service.ts | 69 +++++++++++-------- tsconfig.json | 2 +- views/daily-eccm-report-extended.hbs | 57 +++++++++++++++ 7 files changed, 225 insertions(+), 31 deletions(-) create mode 100644 src/reports/daily-eccm-with-extra-data.service.ts create mode 100644 views/daily-eccm-report-extended.hbs diff --git a/src/app.module.ts b/src/app.module.ts index 5f24362..6f3a43d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -35,6 +35,7 @@ import { DailyEccmReportTask } from './reports/daily-eccm.report.task'; import { DailyEccmReportsUserCommentsDatasource } from './couchdb-datasources/daily-eccm-reports-user-comments.datasource'; import { DailyEccmUserCommentsService } from './reports/daily-eccm-user-comments.service'; import { SetDailyEccmUserCommentBotHandlerService } from './telegram-bot/handlers/set-daily-eccm-user-comment.bot-handler.service'; +import { DailyEccmWithExtraDataService } from './reports/daily-eccm-with-extra-data.service'; @Module({ imports: [ @@ -76,6 +77,7 @@ import { SetDailyEccmUserCommentBotHandlerService } from './telegram-bot/handler DailyEccmReportsUserCommentsDatasource, DailyEccmUserCommentsService, SetDailyEccmUserCommentBotHandlerService, + DailyEccmWithExtraDataService, ], }) export class AppModule implements OnModuleInit { @@ -93,6 +95,8 @@ export class AppModule implements OnModuleInit { private telegramBotService: TelegramBotService, private personalNotificationAdapterService: PersonalNotificationAdapterService, private statusChangeAdapterService: StatusChangeAdapterService, + private dailyEccmUserCommentsService: DailyEccmUserCommentsService, + private setDailyEccmUserCommentBotHandlerService: SetDailyEccmUserCommentBotHandlerService, ) {} onModuleInit() { @@ -165,5 +169,19 @@ export class AppModule implements OnModuleInit { `Save result process success finished, issue_id = ${args.saveResult.current.id}`, ); }); + + this.initDailyEccmUserCommentsPipeline(); + } + + private initDailyEccmUserCommentsPipeline(): void { + this.setDailyEccmUserCommentBotHandlerService.$messages.subscribe( + (data) => { + this.dailyEccmUserCommentsService.setComment( + data.userId, + data.date, + data.comment, + ); + }, + ); } } diff --git a/src/converters/redmine-public-url.converter.ts b/src/converters/redmine-public-url.converter.ts index da7b4d2..245d979 100644 --- a/src/converters/redmine-public-url.converter.ts +++ b/src/converters/redmine-public-url.converter.ts @@ -2,6 +2,8 @@ import { RedmineTypes } from '@app/event-emitter/models/redmine-types'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +// TODO: Этот сервис возможно перенести в lib event-emitter + @Injectable() export class RedminePublicUrlConverter { private redminePublicUrlPrefix: string; @@ -24,4 +26,48 @@ export class RedminePublicUrlConverter { const url = this.getUrl(issue.id); return `${issue.tracker.name} #${issue.id}`; } + + getMinHtmlHref(issueId: number | string): string { + const url = this.getUrl(issueId); + return `#${issueId}`; + } + + /** + * Обогащение текста с идентификаторами задач html ссылками на эти задачи + * + * Например текст `"Эта ошибка будет решена в рамках задач #123 и #456"` + * будет заменён на `"Эта ошибка будет решена в рамках задач + * #123 и + * #456"` + * + * @param text + * @param linkGenerator функция замены отдельного идентификатора на html ссылку. По умолчанию + * будет использоваться собственная функция this.getMinHtmlHref + * @see convert + */ + enrichTextWithIssues( + text: string, + linkGenerator?: (issueId: number | string) => string, + ): string { + const generator = linkGenerator + ? linkGenerator + : (issueId) => this.getMinHtmlHref(issueId); + + const regexp = /^\d+/; + + const parts = text.split('#'); + for (let i = 0; i < parts.length; i++) { + let part = parts[i]; + const match = part.match(regexp); + if (!match) { + continue; + } + const issueId = match[0]; + const replacment = generator(issueId); + part = part.replace(new RegExp(`^${issueId}`), replacment); + parts[i] = part; + } + + return parts.join(''); + } } diff --git a/src/reports/daily-eccm-with-extra-data.service.ts b/src/reports/daily-eccm-with-extra-data.service.ts new file mode 100644 index 0000000..c798a4c --- /dev/null +++ b/src/reports/daily-eccm-with-extra-data.service.ts @@ -0,0 +1,43 @@ +import { Timestamped } from '@app/event-emitter/models/timestamped'; +import { Injectable } from '@nestjs/common'; +import nano from 'nano'; +import { RedminePublicUrlConverter } from 'src/converters/redmine-public-url.converter'; +import { DailyEccmUserCommentsService } from './daily-eccm-user-comments.service'; +import { + DailyEccmReport, + DailyEccmReportService, +} from './daily-eccm.report.service'; + +@Injectable() +export class DailyEccmWithExtraDataService { + constructor( + private dailyEccmReportService: DailyEccmReportService, + private dailyEccmUserCommentsService: DailyEccmUserCommentsService, + private redminePublicUrlConverter: RedminePublicUrlConverter, + ) {} + + async loadReport( + name: string, + ): Promise< + | (DailyEccmReport.Models.Report & nano.DocumentGetResponse & Timestamped) + | null + > { + const baseReportData = await this.dailyEccmReportService.loadReport(name); + if (!baseReportData) return null; + const userIds = baseReportData.byUsers.map((item) => item.user.id); + const userComments = await this.dailyEccmUserCommentsService.loadComments( + userIds, + name, + ); + for (let i = 0; i < baseReportData.byUsers.length; i++) { + const byUser = baseReportData.byUsers[i]; + if (userComments[byUser.user.id]) { + byUser.dailyMessage = + this.redminePublicUrlConverter.enrichTextWithIssues( + userComments[byUser.user.id], + ); + } + } + return baseReportData; + } +} diff --git a/src/reports/daily-eccm.report.controller.ts b/src/reports/daily-eccm.report.controller.ts index 3c9f971..1c05b14 100644 --- a/src/reports/daily-eccm.report.controller.ts +++ b/src/reports/daily-eccm.report.controller.ts @@ -1,4 +1,5 @@ import { Controller, Get, Param, Query, Render } from '@nestjs/common'; +import { DailyEccmWithExtraDataService } from './daily-eccm-with-extra-data.service'; import { DailyEccmReport, DailyEccmReportService, @@ -6,7 +7,10 @@ import { @Controller('daily-eccm') export class DailyEccmReportController { - constructor(private dailyEccmReportService: DailyEccmReportService) {} + constructor( + private dailyEccmReportService: DailyEccmReportService, + private dailyEccmWithExtraDataService: DailyEccmWithExtraDataService, + ) {} @Get() @Render('daily-eccm-report') @@ -67,4 +71,19 @@ export class DailyEccmReportController { return data; } } + + @Get('/load/:name/extended/raw') + async loadExtendedReportRawData( + @Param('name') name: string, + ): Promise { + return await this.dailyEccmWithExtraDataService.loadReport(name); + } + + @Get('/load/:name/extended') + @Render('daily-eccm-report-extended') + async loadExtendedReport( + @Param('name') name: string, + ): Promise { + return await this.dailyEccmWithExtraDataService.loadReport(name); + } } diff --git a/src/telegram-bot/handlers/set-daily-eccm-user-comment.bot-handler.service.ts b/src/telegram-bot/handlers/set-daily-eccm-user-comment.bot-handler.service.ts index ba8ab6f..3a188b2 100644 --- a/src/telegram-bot/handlers/set-daily-eccm-user-comment.bot-handler.service.ts +++ b/src/telegram-bot/handlers/set-daily-eccm-user-comment.bot-handler.service.ts @@ -25,7 +25,6 @@ export class SetDailyEccmUserCommentBotHandlerService private service: TelegramBotService; private regexp = /\/set_daily_eccm_user_comment (.+)/; private logger = new Logger(SetDailyEccmUserCommentBotHandlerService.name); - private dateParamRegexp = /^date=([\d\-]+) (.+)$/; $messages = new Subject(); @@ -43,46 +42,58 @@ export class SetDailyEccmUserCommentBotHandlerService msg.chat.id, ); const redmineUserId = userMetaInfo.user_id; - const data = this.parseDate(msg.text); - this.logger.debug( - `Setting user comment for daily eccm ` + - `by redmineUserId = ${redmineUserId}, ` + - `full text - ${msg.text}, ` + - `parsed data - ${JSON.stringify(data)}`, - ); - this.$messages.next({ - userId: redmineUserId, - date: data.date, - comment: data.msg, - }); - return; + const data = this.parseData(msg.text); + if (data) { + this.logger.debug( + `Setting user comment for daily eccm ` + + `by redmineUserId = ${redmineUserId}, ` + + `full text - ${msg.text}, ` + + `parsed data - ${JSON.stringify(data)}`, + ); + this.$messages.next({ + userId: redmineUserId, + date: data.date, + comment: data.msg, + }); + } else { + this.logger.error( + `For some reason, it was not possible to get data from an incoming message - ${msg.text}`, + ); + } }); } getHelpMsg(): string { return ( `/set_daily_eccm_user_comment ` + - `[дата=yyyy-MM-dd] <комментарий> ` + + `[date=yyyy-MM-dd] <комментарий> ` + `- дополнительный комментарий для дейли` ); } - private parseDate(src: string): { date: string; msg: string } | null { - let msgWithoutCommand: any = src.match(this.regexp); - if (!msgWithoutCommand || !msgWithoutCommand[1]) { + private parseData(src: string): { date: string; msg: string } | null { + let text = src; + + text = text.replace('/set_daily_eccm_user_comment', '').trim(); + if (!text) { return null; } - msgWithoutCommand = msgWithoutCommand[1]; - const msgWithDate: any = msgWithoutCommand.match(this.dateParamRegexp); - let date: any = ''; - if (msgWithDate && msgWithDate[1] && msgWithDate[2]) { - date = msgWithDate[1]; - if (DateTime.fromFormat(date, ISO_DATE_FORMAT).isValid) { - msgWithoutCommand = msgWithDate[2]; - } else { - date = ''; - } + + const dateMatch = text.match(/^date=[\d-]+/); + if (!dateMatch) { + return { date: DateTime.now().toFormat(ISO_DATE_FORMAT), msg: text }; } - return { date: date, msg: msgWithoutCommand }; + + const datePart = dateMatch[0]; + let dateRaw = datePart.replace('date=', ''); + text = text.replace('date=', '').trim(); + const date = DateTime.fromFormat(dateRaw, ISO_DATE_FORMAT); + if (!date.isValid) { + this.logger.error(`Wrong date in message - ${src}`); + return null; + } + text = text.replace(dateRaw, '').trim(); + dateRaw = date.toFormat(ISO_DATE_FORMAT); + return { date: dateRaw, msg: text }; } } diff --git a/tsconfig.json b/tsconfig.json index 85879f4..0408fb2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,7 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "allowSyntheticDefaultImports": true, - "target": "es2017", + "target": "es2021", "sourceMap": true, "outDir": "./dist", "baseUrl": "./", diff --git a/views/daily-eccm-report-extended.hbs b/views/daily-eccm-report-extended.hbs new file mode 100644 index 0000000..ec346da --- /dev/null +++ b/views/daily-eccm-report-extended.hbs @@ -0,0 +1,57 @@ + + + + + Daily Eccm Report + + +
+ Параметры отчёта +
    +
  • От - {{this.params.from}}
  • +
  • До - {{this.params.to}}
  • +
  • Имя отчёта - {{this.params.name}}
  • +
  • Имя проекта - {{this.params.project}}
  • +
  • Версии - {{this.params.versions}}
  • +
+
+

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

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

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

+ + {{#if this.dailyMessage}} +

Комментарий

+
{{{this.dailyMessage}}}
+ {{/if}} + +

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

+
    + {{#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.status.name}}) - {{this.issue.subject}} +
      + {{#each this.changes}} +
    • {{this.created_on}}: {{this.change_message}}
    • + {{/each}} +
    +
  • + {{/each}} +
+ {{/each}} + + \ No newline at end of file From 10cc06ec8d2014022e1bb406a15bea66ca5d43c2 Mon Sep 17 00:00:00 2001 From: Pavel Gnedov Date: Thu, 15 Dec 2022 23:32:24 +0700 Subject: [PATCH 04/30] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D1=80=D0=B5=D0=B3=D1=83=D0=BB=D1=8F=D1=80=D0=BD?= =?UTF-8?q?=D0=BE=D0=B5=20=D0=B2=D1=8B=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B4=D0=BB=D1=8F=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD?= =?UTF-8?q?=D0=B4=D1=8B=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D0=B5=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../set-daily-eccm-user-comment.bot-handler.service.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/telegram-bot/handlers/set-daily-eccm-user-comment.bot-handler.service.ts b/src/telegram-bot/handlers/set-daily-eccm-user-comment.bot-handler.service.ts index 3a188b2..4c67092 100644 --- a/src/telegram-bot/handlers/set-daily-eccm-user-comment.bot-handler.service.ts +++ b/src/telegram-bot/handlers/set-daily-eccm-user-comment.bot-handler.service.ts @@ -23,7 +23,7 @@ export class SetDailyEccmUserCommentBotHandlerService implements TelegramBotHandlerInterface { private service: TelegramBotService; - private regexp = /\/set_daily_eccm_user_comment (.+)/; + private regexp = /\/set_daily_eccm_user_comment.*/g; private logger = new Logger(SetDailyEccmUserCommentBotHandlerService.name); $messages = @@ -41,6 +41,9 @@ export class SetDailyEccmUserCommentBotHandlerService const userMetaInfo = await this.userMetaInfoService.findByTelegramId( msg.chat.id, ); + if (!userMetaInfo) { + this.logger.error(`User for telegram id = ${msg.chat.id} not found`); + } const redmineUserId = userMetaInfo.user_id; const data = this.parseData(msg.text); if (data) { From 7cde09c89570bedadf96a48f1e0a26ce4f3fe004 Mon Sep 17 00:00:00 2001 From: Gnedov Pavel Date: Tue, 7 Feb 2023 08:28:23 +0700 Subject: [PATCH 05/30] =?UTF-8?q?=D0=9E=D0=B1=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D0=BA=20tel?= =?UTF-8?q?egram?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/telegram-bot/telegram-bot.service.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/telegram-bot/telegram-bot.service.ts b/src/telegram-bot/telegram-bot.service.ts index 5f80b07..2254fa3 100644 --- a/src/telegram-bot/telegram-bot.service.ts +++ b/src/telegram-bot/telegram-bot.service.ts @@ -30,14 +30,19 @@ export class TelegramBotService { this.telegramBotToken = this.configService.get('telegramBotToken'); this.redminePublicUrlPrefix = this.configService.get('redmineUrlPublic'); - this.initTelegramBot(); + this.initTelegramBot().catch((ex) => { + this.logger.error(`Error at init telegram bot - ${ex}`); + }); this.handlers.push(this.currentIssuesBotHandlerService); this.handlers.push(this.setDailyEccmUserCommentBotHandlerService); } private async initTelegramBot(): Promise { const Telegram = await require('node-telegram-bot-api'); + if (!this.telegramBotToken) return; + this.logger.debug('Telegram bot instance creation ... '); this.bot = new Telegram(this.telegramBotToken, { polling: true }); + this.logger.debug('Telegram bot instance created'); this.bot.onText(/\/start/, async (msg) => { await this.showHelpMessage(msg); }); @@ -50,6 +55,12 @@ export class TelegramBotService { this.bot.onText(/\/leave/, async (msg) => { await this.leave(msg); }); + this.bot.on('polling_error', (error) => { + this.logger.error(`polling_error from telegram bot instance - ${error}`); + }); + this.bot.on('webhook_error', (error) => { + this.logger.error(`webhook_error from telegram bot instance - ${error}`); + }); for (let i = 0; i < this.handlers.length; i++) { const handler = this.handlers[i]; await handler.init(this, this.bot); @@ -57,6 +68,7 @@ export class TelegramBotService { } private async showHelpMessage(msg: TelegramBot.Message): Promise { + if (!this.telegramBotToken) return; const userMetaInfo = await this.userMetaInfoService.findByTelegramId( msg.chat.id, ); @@ -81,7 +93,11 @@ export class TelegramBotService { `Sent help message for telegramChatId = ${msg.chat.id}, ` + `message = ${helpMessage}`, ); - this.bot.sendMessage(msg.chat.id, helpMessage); + try { + this.bot.sendMessage(msg.chat.id, helpMessage); + } catch (ex) { + this.logger.error(`Error at send help message - ${ex?.message}`); + } } async sendMessageByRedmineId( @@ -89,6 +105,7 @@ export class TelegramBotService { msg: string, options?: TelegramBot.SendMessageOptions, ): Promise { + if (!this.telegramBotToken) return false; const userMetaInfo = await this.userMetaInfoService.findByRedmineId( redmineId, ); @@ -110,6 +127,7 @@ export class TelegramBotService { msg: string, options?: TelegramBot.SendMessageOptions, ): Promise { + if (!this.telegramBotToken) return false; const user = await this.usersService.findUserByName(firstname, lastname); if (!user) return false; return await this.sendMessageByRedmineId(user.id, msg, options); From ce413b8cad3c242fd53b4930c89d878bdc6a9fd1 Mon Sep 17 00:00:00 2001 From: Pavel Gnedov Date: Thu, 2 Feb 2023 11:48:52 +0700 Subject: [PATCH 06/30] =?UTF-8?q?=D0=90=D0=BD=D0=B0=D0=BB=D0=B8=D0=B7=20?= =?UTF-8?q?=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=BE=D0=B2=20=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D1=87,=20=D0=B0=D0=BD=D0=B0=D0=BB=D0=B8=D0=B7=20=D0=B4?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B2=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87?= =?UTF-8?q?,=20=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2=D1=8B=D0=B9=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=82=D1=80=D0=BE=D0=BB=D0=BB=D0=B5=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event-emitter/src/event-emitter.module.ts | 3 + .../src/project-dashboard/widget-interface.ts | 4 + .../root-issue-subtrees.widget.service.ts | 82 ++++++++ .../src/utils/flat-issues-store.ts | 181 +++++++++++++++++ .../src/utils/tree-issues-store.ts | 183 ++++++++++++++++++ src/app.module.ts | 2 + .../eccm110-dashboard.controller.ts | 32 +++ 7 files changed, 487 insertions(+) create mode 100644 libs/event-emitter/src/project-dashboard/widget-interface.ts create mode 100644 libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts create mode 100644 libs/event-emitter/src/utils/flat-issues-store.ts create mode 100644 libs/event-emitter/src/utils/tree-issues-store.ts create mode 100644 src/dashboards/eccm110-dashboard.controller.ts diff --git a/libs/event-emitter/src/event-emitter.module.ts b/libs/event-emitter/src/event-emitter.module.ts index 02bd75d..7b212d1 100644 --- a/libs/event-emitter/src/event-emitter.module.ts +++ b/libs/event-emitter/src/event-emitter.module.ts @@ -18,6 +18,7 @@ import { IssuesService } from './issues/issues.service'; import { IssuesController } from './issues/issues.controller'; import { TimestampEnhancer } from './issue-enhancers/timestamps-enhancer'; import { EnhancerService } from './issue-enhancers/enhancer.service'; +import { RootIssueSubTreesWidgetService } from './project-dashboard/widgets/root-issue-subtrees.widget.service'; @Module({}) export class EventEmitterModule implements OnModuleInit { @@ -40,6 +41,7 @@ export class EventEmitterModule implements OnModuleInit { IssuesService, TimestampEnhancer, EnhancerService, + RootIssueSubTreesWidgetService, ], exports: [ EventEmitterService, @@ -54,6 +56,7 @@ export class EventEmitterModule implements OnModuleInit { IssuesService, TimestampEnhancer, EnhancerService, + RootIssueSubTreesWidgetService, ], controllers: [MainController, UsersController, IssuesController], }; diff --git a/libs/event-emitter/src/project-dashboard/widget-interface.ts b/libs/event-emitter/src/project-dashboard/widget-interface.ts new file mode 100644 index 0000000..2f00e79 --- /dev/null +++ b/libs/event-emitter/src/project-dashboard/widget-interface.ts @@ -0,0 +1,4 @@ +export interface WidgetInterface { + isMyConfig(widgetParams: W): boolean; + render(widgetParams: W, dashboardParams: D): Promise; +} diff --git a/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts b/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts new file mode 100644 index 0000000..894879e --- /dev/null +++ b/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts @@ -0,0 +1,82 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +import { IssuesService } from '@app/event-emitter/issues/issues.service'; +import { RedmineTypes } from '@app/event-emitter/models/redmine-types'; +import { + TreeIssuesStore, + TreeIssuesStoreNs, +} from '@app/event-emitter/utils/tree-issues-store'; +import { Injectable } from '@nestjs/common'; +import { WidgetInterface } from '../widget-interface'; + +export namespace RootIssueSubTreesWidgetNs { + export namespace Models { + export type Params = { + rootIssueId: number; + parentsAsGroups?: boolean; + groups?: GroupCfg; + statuses: string[]; + }; + + export type GroupCfg = { + fromIssues: Group[]; + fromIssuesIncluded: boolean; + showOthers: boolean; + }; + + export type Group = { + name: string; + issueId: number; + }; + } +} + +type Params = RootIssueSubTreesWidgetNs.Models.Params; + +@Injectable() +export class RootIssueSubTreesWidgetService + implements WidgetInterface +{ + constructor(private issuesService: IssuesService) {} + + isMyConfig(): boolean { + return true; + } + + async render(widgetParams: Params): Promise { + const treeStore = new TreeIssuesStore(); + const rootIssue = await this.issuesService.getIssue( + widgetParams.rootIssueId, + ); + treeStore.setRootIssue(rootIssue); + await treeStore.fillData(this.issuesLoader.bind(this)); + let stories: TreeIssuesStoreNs.Models.GetFlatStories.Result; + if (widgetParams.parentsAsGroups) { + stories = treeStore.getFlatStoriesByParents(); + } else if (widgetParams.groups) { + const fromIssues = widgetParams.groups.fromIssues.map((g) => g.issueId); + stories = treeStore.getFlatStories( + fromIssues, + widgetParams.groups.fromIssuesIncluded, + widgetParams.groups.showOthers, + ); + } + return stories.map((s) => { + return { + data: s.store.groupByStatus(widgetParams.statuses), + metainfo: s.metainfo, + }; + }); + } + + private async issuesLoader( + ids: number[], + ): Promise> { + const issues = await this.issuesService.getIssues(ids); + const res = {} as Record; + for (let i = 0; i < issues.length; i++) { + const issue = issues[i]; + res[issue.id] = issue; + } + return res; + } +} diff --git a/libs/event-emitter/src/utils/flat-issues-store.ts b/libs/event-emitter/src/utils/flat-issues-store.ts new file mode 100644 index 0000000..a9b07a3 --- /dev/null +++ b/libs/event-emitter/src/utils/flat-issues-store.ts @@ -0,0 +1,181 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +import { RedmineTypes } from '../models/redmine-types'; + +export namespace FlatIssuesStoreNs { + export type IssuesLoader = ( + ids: number[], + ) => Promise>; + + export namespace Models { + export type ByStatus = { + status: string; + count: number; + issues: RedmineTypes.Issue[]; + }; + + export type ByStatuses = ByStatus[]; + + export enum FindErrors { + NOT_FOUND = 'NOT_FOUND', + NOT_LOADED = 'NOT_LOADED', + } + + export type FindResult = { + data?: RedmineTypes.Issue; + error?: FindErrors; + }; + } +} + +export class FlatIssuesStore { + private issues: Record = {}; + + push(issue: number | string | RedmineTypes.Issue): void { + let id: any; + let data: any; + + if ( + typeof issue === 'number' || + (typeof issue === 'string' && Number.isFinite(Number(issue))) + ) { + id = Number(issue); + data = null; + } else if (typeof issue === 'object' && typeof issue['id'] === 'number') { + id = issue['id']; + data = issue; + } + + if ( + typeof id === 'number' && + !Object.prototype.hasOwnProperty.call(this.issues, id) + ) { + this.issues[id] = data; + } + } + + async fillData(loader: FlatIssuesStoreNs.IssuesLoader): Promise { + const ids = [] as number[]; + for (const id in this.issues) { + if (Object.prototype.hasOwnProperty.call(this.issues, id)) { + const issue = this.issues[id]; + if (!issue) { + ids.push(Number(id)); + } + } + } + const data = await loader(ids); + for (const id in data) { + if (Object.prototype.hasOwnProperty.call(data, id)) { + const issue = data[id]; + if (issue) { + this.issues[id] = issue; + } + } + } + return; + } + + getIds(): number[] { + return Object.keys(this.issues).map((i) => Number(i)); + } + + getIssues(): RedmineTypes.Issue[] { + return Object.values(this.issues).filter((i) => !!i); + } + + hasIssue(id: number): boolean { + return Object.prototype.hasOwnProperty.call(this.issues, id); + } + + loadedIssue(id: number): boolean { + return this.hasIssue(id) && !!this.issues[id]; + } + + getIssue(id: number): FlatIssuesStoreNs.Models.FindResult { + if (!this.hasIssue(id)) { + return { error: FlatIssuesStoreNs.Models.FindErrors.NOT_FOUND }; + } else if (!this.issues[id]) { + return { error: FlatIssuesStoreNs.Models.FindErrors.NOT_LOADED }; + } else { + return { data: this.issues[id] }; + } + } + + isFullLoaded(): boolean { + return Object.values(this.issues).indexOf(null) < 0; + } + + groupBy( + iteratee: (issue: RedmineTypes.Issue) => string | number, + ): Record { + const res = {} as Record; + const items = this.getIssues(); + for (let i = 0; i < items.length; i++) { + const issue = items[i]; + const key = iteratee(issue); + if (!Object.prototype.hasOwnProperty.call(res, key)) { + res[key] = []; + } + res[key].push(issue); + } + return res; + } + + groupByToStories( + iteratee: (issue: RedmineTypes.Issue) => string | number, + ): Record { + const res = {} as Record; + const rawData = this.groupBy(iteratee); + for (const key in rawData) { + if (Object.prototype.hasOwnProperty.call(rawData, key)) { + const issues = rawData[key]; + res[key] = new FlatIssuesStore(); + for (let i = 0; i < issues.length; i++) { + const issue = issues[i]; + res[key].push(issue); + } + } + } + return res; + } + + groupByStatus(statuses: string[]): FlatIssuesStoreNs.Models.ByStatuses { + const res = [] as FlatIssuesStoreNs.Models.ByStatuses; + for (let i = 0; i < statuses.length; i++) { + const status = statuses[i]; + res.push({ status: status, count: 0, issues: [] }); + } + const groupedIssues = this.groupBy((issue) => issue.status.name); + for (const status in groupedIssues) { + if (Object.prototype.hasOwnProperty.call(groupedIssues, status)) { + const issues = groupedIssues[status]; + const foundItem = res.find((i) => i.status === status); + if (!foundItem) continue; + foundItem.issues.push(...issues); + } + } + for (let i = 0; i < res.length; i++) { + const item = res[i]; + item.count = item.issues.length; + } + return res; + } + + groupByStatusWithExtra( + iteratee: (issue: RedmineTypes.Issue) => string | number, + statuses: string[], + ): Record { + const res = {} as Record< + string | number, + FlatIssuesStoreNs.Models.ByStatuses + >; + const groupedIssues = this.groupByToStories(iteratee); + for (const key in groupedIssues) { + if (Object.prototype.hasOwnProperty.call(groupedIssues, key)) { + const store = groupedIssues[key]; + res[key] = store.groupByStatus(statuses); + } + } + return res; + } +} diff --git a/libs/event-emitter/src/utils/tree-issues-store.ts b/libs/event-emitter/src/utils/tree-issues-store.ts new file mode 100644 index 0000000..8a1bc25 --- /dev/null +++ b/libs/event-emitter/src/utils/tree-issues-store.ts @@ -0,0 +1,183 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +import { RedmineTypes } from '../models/redmine-types'; +import { FlatIssuesStore, FlatIssuesStoreNs } from './flat-issues-store'; + +export namespace TreeIssuesStoreNs { + export namespace Models { + export namespace GetFlatStories { + export type Item = { + metainfo: Record; + store: FlatIssuesStore; + }; + + export type Result = Item[]; + } + } +} + +export class TreeIssuesStore { + private rootIssue: RedmineTypes.Issue; + private flatStore: FlatIssuesStore; + + setRootIssue(issue: RedmineTypes.Issue): void { + this.rootIssue = issue; + this.prepareFlatIssuesStore(); + } + + async fillData(loader: FlatIssuesStoreNs.IssuesLoader): Promise { + await this.flatStore.fillData(loader); + } + + getFlatStore(): FlatIssuesStore { + return this.flatStore; + } + + private prepareFlatIssuesStore(): void { + this.flatStore = new FlatIssuesStore(); + this.flatStore.push(this.rootIssue); + if (this.rootIssue.children && this.rootIssue.children.length > 0) { + this.fillChildrenFlatIssuesStore(this.rootIssue.children); + } + } + + private fillChildrenFlatIssuesStore( + childrenIssues: RedmineTypes.Children, + ): void { + for (let i = 0; i < childrenIssues.length; i++) { + const issue = childrenIssues[i]; + this.flatStore.push(issue.id); + if (issue.children && issue.children.length > 0) { + this.fillChildrenFlatIssuesStore(issue.children); + } + } + } + + isFullLoaded(): boolean { + return this.flatStore.isFullLoaded(); + } + + getFlatStories( + fromIssues: number[], + fromIssuesIncluded: boolean, + showOthers: boolean, + ): TreeIssuesStoreNs.Models.GetFlatStories.Result { + const res = [] as TreeIssuesStoreNs.Models.GetFlatStories.Result; + + for (let i = 0; i < fromIssues.length; i++) { + const fromIssue = this.flatStore.getIssue(fromIssues[i]); + if (!fromIssue.data) continue; + const store = new FlatIssuesStore(); + this.putIssuesToStore( + store, + fromIssue.data, + fromIssues, + fromIssuesIncluded, + ); + res.push({ + store: store, + metainfo: this.createMetaInfo(fromIssue.data), + }); + } + + if (showOthers) { + const othersStore = new FlatIssuesStore(); + res.push({ + store: othersStore, + metainfo: this.createMetaInfo(this.rootIssue), + }); + } + + return res; + } + + private putIssuesToStore( + store: FlatIssuesStore, + rootIssue: RedmineTypes.Issue, + fromIssues: number[], + fromIssuesIncluded: boolean, + ): void { + if (fromIssuesIncluded) { + store.push(rootIssue); + } + if (!rootIssue.children || rootIssue.children.length <= 0) { + return; + } + for (let i = 0; i < rootIssue.children.length; i++) { + const childIssue = rootIssue.children[i]; + const issueData = this.flatStore.getIssue(childIssue.id); + if (!issueData.data) continue; + if (fromIssues.indexOf(issueData.data.id) < 0) { + this.putIssuesToStore( + store, + issueData.data, + fromIssues, + fromIssuesIncluded, + ); + } else { + if (fromIssuesIncluded == false) { + store.push(issueData.data); + } + } + } + } + + private createMetaInfo(issue: RedmineTypes.Issue): Record { + return { + title: `${issue.tracker.name} #${issue.id} - ${issue.subject}`, + rootIssue: { + id: issue.id, + tracker: issue.tracker, + subject: issue.subject, + }, + }; + } + + getParents(issue: RedmineTypes.Issue): RedmineTypes.Issue[] { + const res = [] as RedmineTypes.Issue[]; + let parentId: number; + let parentIssueData: FlatIssuesStoreNs.Models.FindResult; + let parentIssue: RedmineTypes.Issue; + parentId = issue.parent?.id; + while (parentId) { + parentIssueData = this.flatStore.getIssue(parentId); + parentIssue = parentIssueData.data; + if (!parentIssue) break; + res.push(parentIssue); + parentId = parentIssue.parent?.id; + } + return res; + } + + getFlatStoriesByParents(): TreeIssuesStoreNs.Models.GetFlatStories.Result { + const stories = [] as TreeIssuesStoreNs.Models.GetFlatStories.Result; + return this.fillFlatStoriesByParents(stories, this.rootIssue); + } + + private fillFlatStoriesByParents( + stories: TreeIssuesStoreNs.Models.GetFlatStories.Result, + rootIssue: RedmineTypes.Issue, + ): TreeIssuesStoreNs.Models.GetFlatStories.Result { + if (!stories) { + stories = [] as TreeIssuesStoreNs.Models.GetFlatStories.Result; + } + if (!rootIssue) { + rootIssue = this.rootIssue; + } + if (!rootIssue.children || rootIssue.children.length <= 0) { + return stories; + } + const store = new FlatIssuesStore(); + for (let i = 0; i < rootIssue.children.length; i++) { + const child = rootIssue.children[i]; + const childIssueData = this.flatStore.getIssue(child.id); + if (!childIssueData.data) continue; + store.push(childIssueData.data); + this.fillFlatStoriesByParents(stories, childIssueData.data); + } + stories.push({ + store: store, + metainfo: this.createMetaInfo(rootIssue), + }); + return stories; + } +} diff --git a/src/app.module.ts b/src/app.module.ts index 6f3a43d..810ba7f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -36,6 +36,7 @@ import { DailyEccmReportsUserCommentsDatasource } from './couchdb-datasources/da import { DailyEccmUserCommentsService } from './reports/daily-eccm-user-comments.service'; import { SetDailyEccmUserCommentBotHandlerService } from './telegram-bot/handlers/set-daily-eccm-user-comment.bot-handler.service'; import { DailyEccmWithExtraDataService } from './reports/daily-eccm-with-extra-data.service'; +import { Eccm110DashboardController } from './dashboards/eccm110-dashboard.controller'; @Module({ imports: [ @@ -53,6 +54,7 @@ import { DailyEccmWithExtraDataService } from './reports/daily-eccm-with-extra-d MainController, CurrentIssuesEccmReportController, DailyEccmReportController, + Eccm110DashboardController, ], providers: [ AppService, diff --git a/src/dashboards/eccm110-dashboard.controller.ts b/src/dashboards/eccm110-dashboard.controller.ts new file mode 100644 index 0000000..09eaddd --- /dev/null +++ b/src/dashboards/eccm110-dashboard.controller.ts @@ -0,0 +1,32 @@ +import { RootIssueSubTreesWidgetService } from '@app/event-emitter/project-dashboard/widgets/root-issue-subtrees.widget.service'; +import { Controller, Get } from '@nestjs/common'; + +@Controller('eccm-1.10-dashboard') +export class Eccm110DashboardController { + constructor( + private rootIssueSubTreesWidgetService: RootIssueSubTreesWidgetService, + ) {} + + // TODO: code for Eccm110DashboardController + + @Get('/raw') + async getRawData(): Promise { + return await this.rootIssueSubTreesWidgetService.render({ + rootIssueId: 2, + parentsAsGroups: true, + statuses: [ + 'New', + 'Closed', + 'In Progress', + 'Re-opened', + 'Code Review', + 'Resolved', + 'Testing', + 'Wait Release', + 'Pending', + 'Feedback', + 'Rejected', + ], + }); + } +} From 85f4483845b891f871302bc329d3eb135c565d3a Mon Sep 17 00:00:00 2001 From: Pavel Gnedov Date: Thu, 2 Feb 2023 11:52:30 +0700 Subject: [PATCH 07/30] =?UTF-8?q?=D0=97=D0=B0=D0=B3=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D0=B0=20=D0=B4=D0=BB=D1=8F=20=D0=B4=D0=B0=D1=88?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=D0=B4=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event-emitter/src/event-emitter.module.ts | 3 + .../project-dashboard.service.ts | 152 ++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 libs/event-emitter/src/project-dashboard/project-dashboard.service.ts diff --git a/libs/event-emitter/src/event-emitter.module.ts b/libs/event-emitter/src/event-emitter.module.ts index 7b212d1..d99c5c0 100644 --- a/libs/event-emitter/src/event-emitter.module.ts +++ b/libs/event-emitter/src/event-emitter.module.ts @@ -18,6 +18,7 @@ import { IssuesService } from './issues/issues.service'; import { IssuesController } from './issues/issues.controller'; import { TimestampEnhancer } from './issue-enhancers/timestamps-enhancer'; import { EnhancerService } from './issue-enhancers/enhancer.service'; +import { ProjectDashboardService } from './project-dashboard/project-dashboard.service'; import { RootIssueSubTreesWidgetService } from './project-dashboard/widgets/root-issue-subtrees.widget.service'; @Module({}) @@ -41,6 +42,7 @@ export class EventEmitterModule implements OnModuleInit { IssuesService, TimestampEnhancer, EnhancerService, + ProjectDashboardService, RootIssueSubTreesWidgetService, ], exports: [ @@ -56,6 +58,7 @@ export class EventEmitterModule implements OnModuleInit { IssuesService, TimestampEnhancer, EnhancerService, + ProjectDashboardService, RootIssueSubTreesWidgetService, ], controllers: [MainController, UsersController, IssuesController], diff --git a/libs/event-emitter/src/project-dashboard/project-dashboard.service.ts b/libs/event-emitter/src/project-dashboard/project-dashboard.service.ts new file mode 100644 index 0000000..78446ff --- /dev/null +++ b/libs/event-emitter/src/project-dashboard/project-dashboard.service.ts @@ -0,0 +1,152 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +import { Injectable } from '@nestjs/common'; +import { RedmineTypes } from '../models/redmine-types'; + +export namespace ProjectDashboard { + export namespace Models { + export type Params = { + projectName: string; + workers: Worker[]; + filter: FilterDefination[]; + statuses: Status[]; + }; + + export type Worker = { + id?: number; + firstname?: string; + lastname?: string; + name?: string; + }; + + export enum FilterTypes { + TREE = 'TREE', + LIST = 'LIST', + DYNAMIC_LIST = 'DYNAMIC_LIST', + VERSION = 'VERSION', + } + + export namespace FilterParams { + export type Tree = { + rootIssueId: number; + subtrees: SubTree[]; + showOthers: boolean; + }; + export type SubTree = { + title: string; + issueId: number; + }; + export type List = { + issueIds: number[]; + }; + export type Version = { + version: string; + }; + export type DynamicList = { + selector: Record; + }; + export type AnyFilterParams = Tree | List | DynamicList | Version; + } + + export type FilterParams = Record; + + export namespace FilterResults { + export type Tree = List; + export type List = { status: string; issues: RedmineTypes.Issue[] }[]; + export type DynamicList = List; + export type Version = List; + export type AnyFilterResults = List | Tree | DynamicList | Version; + } + + export type FilterResult = Record[]; + + export type AnalyticFunction = { + functionName: string; + }; + + export type FilterDefination = { + type: FilterTypes; + title: string; + params: FilterParams.AnyFilterParams; + }; + + export type FilterWithResults = { + params: FilterDefination; + results: FilterResults.AnyFilterResults; + }; + + export type Status = { + name: string; + closed: boolean; + }; + } + + export function CheckWorker(worker: Models.Worker): boolean { + return Boolean( + (typeof worker.id === 'number' && worker.id >= 0) || + (worker.firstname && worker.lastname) || + worker.name, + ); + } + + export class SingleProject { + // TODO: code for SingleProject + constructor(private params: Models.Params) { + return; + } + } + + export namespace Widgets { + // Чё будет делать виджет? + // * рендер - из параметров будет создавать данные с какими-либо расчётами + + export interface Widget { + render( + filterParams: Models.FilterParams, + dashboardParams: Models.Params, + ): Models.FilterResult; + } + + export class List implements Widget { + render( + filterParams: Models.FilterParams, + dashboardParams: Models.Params, + ): Models.FilterResult { + throw new Error('Method not implemented.'); + } + } + + export class DynamicList implements Widget { + render( + filterParams: Models.FilterParams, + dashboardParams: Models.Params, + ): Models.FilterResult { + throw new Error('Method not implemented.'); + } + } + + export class Tree implements Widget { + render( + filterParams: Models.FilterParams, + dashboardParams: Models.Params, + ): Models.FilterResult { + throw new Error('Method not implemented.'); + } + } + + export class Version implements Widget { + render( + filterParams: Models.FilterParams, + dashboardParams: Models.Params, + ): Models.FilterResult { + throw new Error('Method not implemented.'); + } + } + } +} + +@Injectable() +export class ProjectDashboardService { + constructor() { + return; + } +} From c3ad1df1b68361fd3e39fbfba254eedcac0ed1c2 Mon Sep 17 00:00:00 2001 From: Pavel Gnedov Date: Mon, 6 Feb 2023 20:17:57 +0700 Subject: [PATCH 08/30] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B2=20tree-issue-store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../root-issue-subtrees.widget.service.ts | 9 ++++++++ .../src/utils/tree-issues-store.ts | 21 ++++++++++--------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts b/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts index 894879e..f7fe98c 100644 --- a/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts +++ b/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts @@ -59,6 +59,15 @@ export class RootIssueSubTreesWidgetService widgetParams.groups.fromIssuesIncluded, widgetParams.groups.showOthers, ); + for (let i = 0; i < stories.length; i++) { + const store = stories[i]; + const fromIssueInfo = widgetParams.groups.fromIssues.find((i) => { + return i.issueId == store.metainfo?.rootIssue?.id; + }); + if (fromIssueInfo) { + store.metainfo.title = `${fromIssueInfo.name}: ${store.metainfo.title}`; + } + } } return stories.map((s) => { return { diff --git a/libs/event-emitter/src/utils/tree-issues-store.ts b/libs/event-emitter/src/utils/tree-issues-store.ts index 8a1bc25..9f6cffe 100644 --- a/libs/event-emitter/src/utils/tree-issues-store.ts +++ b/libs/event-emitter/src/utils/tree-issues-store.ts @@ -85,6 +85,12 @@ export class TreeIssuesStore { store: othersStore, metainfo: this.createMetaInfo(this.rootIssue), }); + this.putIssuesToStore( + othersStore, + this.rootIssue, + fromIssues, + fromIssuesIncluded, + ); } return res; @@ -107,6 +113,7 @@ export class TreeIssuesStore { const issueData = this.flatStore.getIssue(childIssue.id); if (!issueData.data) continue; if (fromIssues.indexOf(issueData.data.id) < 0) { + store.push(issueData.data); this.putIssuesToStore( store, issueData.data, @@ -157,16 +164,14 @@ export class TreeIssuesStore { stories: TreeIssuesStoreNs.Models.GetFlatStories.Result, rootIssue: RedmineTypes.Issue, ): TreeIssuesStoreNs.Models.GetFlatStories.Result { - if (!stories) { - stories = [] as TreeIssuesStoreNs.Models.GetFlatStories.Result; - } - if (!rootIssue) { - rootIssue = this.rootIssue; - } if (!rootIssue.children || rootIssue.children.length <= 0) { return stories; } const store = new FlatIssuesStore(); + stories.push({ + store: store, + metainfo: this.createMetaInfo(rootIssue), + }); for (let i = 0; i < rootIssue.children.length; i++) { const child = rootIssue.children[i]; const childIssueData = this.flatStore.getIssue(child.id); @@ -174,10 +179,6 @@ export class TreeIssuesStore { store.push(childIssueData.data); this.fillFlatStoriesByParents(stories, childIssueData.data); } - stories.push({ - store: store, - metainfo: this.createMetaInfo(rootIssue), - }); return stories; } } From 99b31164cfb61b490a8b385639cd138ee638bf45 Mon Sep 17 00:00:00 2001 From: Pavel Gnedov Date: Tue, 7 Feb 2023 13:04:49 +0700 Subject: [PATCH 09/30] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=B4=D0=B8=D0=BD=D0=B0=D0=BC=D0=B8=D1=87?= =?UTF-8?q?=D0=B5=D1=81=D0=BA=D0=B0=D1=8F=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=B7=D0=BA=D0=B0=20=D0=B4=D0=BE=D1=81=D0=BE=D0=BA=20kanban?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + configs/simple-kanban-board-config.jsonc.dist | 3 + .../src/configs/dynamic-loader.ts | 27 ++++++++ .../event-emitter/src/event-emitter.module.ts | 3 + package-lock.json | 39 ++++++++--- package.json | 1 + src/app.module.ts | 4 +- src/configs/app.ts | 3 + src/configs/simple-kanban-board.config.ts | 27 ++++++++ .../eccm110-dashboard.controller.ts | 32 --------- .../simple-kanban-board.controller.ts | 34 ++++++++++ src/models/app-config.model.ts | 2 + views/simple-kanban-board.hbs | 66 +++++++++++++++++++ 13 files changed, 201 insertions(+), 42 deletions(-) create mode 100644 configs/simple-kanban-board-config.jsonc.dist create mode 100644 libs/event-emitter/src/configs/dynamic-loader.ts create mode 100644 src/configs/simple-kanban-board.config.ts delete mode 100644 src/dashboards/eccm110-dashboard.controller.ts create mode 100644 src/dashboards/simple-kanban-board.controller.ts create mode 100644 views/simple-kanban-board.hbs diff --git a/.gitignore b/.gitignore index 1dce6ce..43607ab 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,5 @@ configs/redmine-status-changes-config.jsonc configs/eccm-versions-config.jsonc configs/eccm-config.jsonc configs/current-user-rules.jsonc +configs/simple-kanban-board-config.jsonc +configs/kanban-boards/ diff --git a/configs/simple-kanban-board-config.jsonc.dist b/configs/simple-kanban-board-config.jsonc.dist new file mode 100644 index 0000000..d1f1c8a --- /dev/null +++ b/configs/simple-kanban-board-config.jsonc.dist @@ -0,0 +1,3 @@ +{ + "path": "" +} \ No newline at end of file diff --git a/libs/event-emitter/src/configs/dynamic-loader.ts b/libs/event-emitter/src/configs/dynamic-loader.ts new file mode 100644 index 0000000..b357d00 --- /dev/null +++ b/libs/event-emitter/src/configs/dynamic-loader.ts @@ -0,0 +1,27 @@ +import { CacheTTL, Injectable, Logger } from '@nestjs/common'; +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; + +@Injectable() +export class DynamicLoader { + private logger = new Logger(DynamicLoader.name); + + @CacheTTL(60) + load( + file: string, + opts: { path: string; ext: string; parser: (raw: any) => any }, + ): any { + const fullFileName = join(opts.path, `${file}.${opts.ext}`); + if (!existsSync(fullFileName)) return null; + let rawData: any; + let data: any; + try { + rawData = readFileSync(fullFileName, { encoding: 'utf-8' }); + data = opts.parser(rawData); + } catch (ex) { + this.logger.error(`Error at config read - ${ex}`); + return null; + } + return data; + } +} diff --git a/libs/event-emitter/src/event-emitter.module.ts b/libs/event-emitter/src/event-emitter.module.ts index d99c5c0..ce4c1cf 100644 --- a/libs/event-emitter/src/event-emitter.module.ts +++ b/libs/event-emitter/src/event-emitter.module.ts @@ -20,6 +20,7 @@ import { TimestampEnhancer } from './issue-enhancers/timestamps-enhancer'; import { EnhancerService } from './issue-enhancers/enhancer.service'; import { ProjectDashboardService } from './project-dashboard/project-dashboard.service'; import { RootIssueSubTreesWidgetService } from './project-dashboard/widgets/root-issue-subtrees.widget.service'; +import { DynamicLoader } from './configs/dynamic-loader'; @Module({}) export class EventEmitterModule implements OnModuleInit { @@ -44,6 +45,7 @@ export class EventEmitterModule implements OnModuleInit { EnhancerService, ProjectDashboardService, RootIssueSubTreesWidgetService, + DynamicLoader, ], exports: [ EventEmitterService, @@ -60,6 +62,7 @@ export class EventEmitterModule implements OnModuleInit { EnhancerService, ProjectDashboardService, RootIssueSubTreesWidgetService, + DynamicLoader, ], controllers: [MainController, UsersController, IssuesController], }; diff --git a/package-lock.json b/package-lock.json index 63fd4b1..1c17d5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "handlebars": "^4.7.7", "hbs": "^4.2.0", "imap-simple": "^5.1.0", + "jsonc-parser": "^3.2.0", "luxon": "^3.1.0", "nano": "^10.0.0", "node-telegram-bot-api": "^0.59.0", @@ -198,6 +199,12 @@ "node": ">=8.0.0" } }, + "node_modules/@angular-devkit/schematics/node_modules/jsonc-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.0.0.tgz", + "integrity": "sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA==", + "dev": true + }, "node_modules/@angular-devkit/schematics/node_modules/rxjs": { "version": "6.6.7", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", @@ -1707,6 +1714,12 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@nestjs/schematics/node_modules/jsonc-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.0.0.tgz", + "integrity": "sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA==", + "dev": true + }, "node_modules/@nestjs/schematics/node_modules/rxjs": { "version": "6.6.7", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", @@ -7091,10 +7104,9 @@ } }, "node_modules/jsonc-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.0.0.tgz", - "integrity": "sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA==", - "dev": true + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", + "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==" }, "node_modules/jsonfile": { "version": "6.1.0", @@ -10448,6 +10460,12 @@ "rxjs": "6.6.7" }, "dependencies": { + "jsonc-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.0.0.tgz", + "integrity": "sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA==", + "dev": true + }, "rxjs": { "version": "6.6.7", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", @@ -11625,6 +11643,12 @@ "rxjs": "6.6.7" } }, + "jsonc-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.0.0.tgz", + "integrity": "sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA==", + "dev": true + }, "rxjs": { "version": "6.6.7", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", @@ -15740,10 +15764,9 @@ "dev": true }, "jsonc-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.0.0.tgz", - "integrity": "sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA==", - "dev": true + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", + "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==" }, "jsonfile": { "version": "6.1.0", diff --git a/package.json b/package.json index c2455af..a871bf2 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "handlebars": "^4.7.7", "hbs": "^4.2.0", "imap-simple": "^5.1.0", + "jsonc-parser": "^3.2.0", "luxon": "^3.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 810ba7f..950481f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -36,7 +36,7 @@ import { DailyEccmReportsUserCommentsDatasource } from './couchdb-datasources/da import { DailyEccmUserCommentsService } from './reports/daily-eccm-user-comments.service'; import { SetDailyEccmUserCommentBotHandlerService } from './telegram-bot/handlers/set-daily-eccm-user-comment.bot-handler.service'; import { DailyEccmWithExtraDataService } from './reports/daily-eccm-with-extra-data.service'; -import { Eccm110DashboardController } from './dashboards/eccm110-dashboard.controller'; +import { SimpleKanbanBoardController } from './dashboards/simple-kanban-board.controller'; @Module({ imports: [ @@ -54,7 +54,7 @@ import { Eccm110DashboardController } from './dashboards/eccm110-dashboard.contr MainController, CurrentIssuesEccmReportController, DailyEccmReportController, - Eccm110DashboardController, + SimpleKanbanBoardController, ], providers: [ AppService, diff --git a/src/configs/app.ts b/src/configs/app.ts index 9919b6c..89c3e6c 100644 --- a/src/configs/app.ts +++ b/src/configs/app.ts @@ -7,12 +7,14 @@ import RedmineStatusesConfigLoader from './statuses.config'; import RedmineStatusChangesConfigLoader from './status-changes.config'; import RedmineEccmConfig from './eccm.config'; import RedmineCurrentUserRulesConfig from './current-user-rules.config'; +import SimpleKanbanBoardConfig from './simple-kanban-board.config'; const redmineIssueEventEmitterConfig = RedmineIssueEventEmitterConfigLoader(); const redmineStatusesConfig = RedmineStatusesConfigLoader(); const redmineStatusChanges = RedmineStatusChangesConfigLoader(); const redmineEccm = RedmineEccmConfig(); const redmineCurrentUserRules = RedmineCurrentUserRulesConfig(); +const simpleKanbanBoard = SimpleKanbanBoardConfig(); let appConfig: AppConfig; @@ -36,6 +38,7 @@ export default (): AppConfig => { redmineStatusChanges: redmineStatusChanges, redmineEccm: redmineEccm, redmineCurrentUserRules: redmineCurrentUserRules, + simpleKanbanBoard: simpleKanbanBoard, }; return appConfig; diff --git a/src/configs/simple-kanban-board.config.ts b/src/configs/simple-kanban-board.config.ts new file mode 100644 index 0000000..311a20c --- /dev/null +++ b/src/configs/simple-kanban-board.config.ts @@ -0,0 +1,27 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { parse } from 'jsonc-parser'; + +export type SimpleKanbanBoardConfig = { + path: string; +}; + +let config: SimpleKanbanBoardConfig; + +export default (): SimpleKanbanBoardConfig => { + if (config) { + return config; + } + + const userDefinedConfigPath = + process.env['ELTEX_REDMINE_HELPER_KANBAN_SIMPLE_BOARD_CONFIG_PATH']; + const defaultConfigPath = join('configs', 'simple-kanban-board-config.jsonc'); + + const configPath = userDefinedConfigPath || defaultConfigPath; + + const rawData = readFileSync(configPath, { encoding: 'utf-8' }); + + config = parse(rawData); + + return config; +}; diff --git a/src/dashboards/eccm110-dashboard.controller.ts b/src/dashboards/eccm110-dashboard.controller.ts deleted file mode 100644 index 09eaddd..0000000 --- a/src/dashboards/eccm110-dashboard.controller.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { RootIssueSubTreesWidgetService } from '@app/event-emitter/project-dashboard/widgets/root-issue-subtrees.widget.service'; -import { Controller, Get } from '@nestjs/common'; - -@Controller('eccm-1.10-dashboard') -export class Eccm110DashboardController { - constructor( - private rootIssueSubTreesWidgetService: RootIssueSubTreesWidgetService, - ) {} - - // TODO: code for Eccm110DashboardController - - @Get('/raw') - async getRawData(): Promise { - return await this.rootIssueSubTreesWidgetService.render({ - rootIssueId: 2, - parentsAsGroups: true, - statuses: [ - 'New', - 'Closed', - 'In Progress', - 'Re-opened', - 'Code Review', - 'Resolved', - 'Testing', - 'Wait Release', - 'Pending', - 'Feedback', - 'Rejected', - ], - }); - } -} diff --git a/src/dashboards/simple-kanban-board.controller.ts b/src/dashboards/simple-kanban-board.controller.ts new file mode 100644 index 0000000..51309a9 --- /dev/null +++ b/src/dashboards/simple-kanban-board.controller.ts @@ -0,0 +1,34 @@ +import { DynamicLoader } from '@app/event-emitter/configs/dynamic-loader'; +import { RootIssueSubTreesWidgetService } from '@app/event-emitter/project-dashboard/widgets/root-issue-subtrees.widget.service'; +import { Controller, Get, Param, Render } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { parse } from 'jsonc-parser'; + +@Controller('simple-kanban-board') +export class SimpleKanbanBoardController { + private path: string; + + constructor( + private rootIssueSubTreesWidgetService: RootIssueSubTreesWidgetService, + private dynamicLoader: DynamicLoader, + private configService: ConfigService, + ) { + this.path = this.configService.get('simpleKanbanBoard.path'); + } + + @Get('/tree/:name/raw') + async getRawData(@Param('name') name: string): Promise { + const cfg = this.dynamicLoader.load(name, { + path: this.path, + ext: 'jsonc', + parser: parse, + }); + return await this.rootIssueSubTreesWidgetService.render(cfg); + } + + @Get('/tree/:name') + @Render('simple-kanban-board') + async get(@Param('name') name: string): Promise { + return await this.getRawData(name); + } +} diff --git a/src/models/app-config.model.ts b/src/models/app-config.model.ts index 141d191..4ff91ba 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 { SimpleKanbanBoardConfig } from 'src/configs/simple-kanban-board.config'; import { EccmConfig } from './eccm-config.model'; import { StatusChangesConfig } from './status-changes-config.model'; import { StatusesConfig } from './statuses-config.model'; @@ -9,6 +10,7 @@ export type AppConfig = { redmineStatusChanges: StatusChangesConfig.Config; redmineEccm: EccmConfig.Config; redmineCurrentUserRules: Record; + simpleKanbanBoard: SimpleKanbanBoardConfig; couchDb: { dbs: { changes: string; diff --git a/views/simple-kanban-board.hbs b/views/simple-kanban-board.hbs new file mode 100644 index 0000000..d391d30 --- /dev/null +++ b/views/simple-kanban-board.hbs @@ -0,0 +1,66 @@ + + + + + + ECCM 1.10 Kanban board + + + + + {{#each this}} +

{{this.metainfo.title}}

+
+ {{#each this.data}} +
+
{{this.status}}
+ {{#each this.issues}} +
+
{{this.tracker.name}} #{{this.id}} - {{this.subject}}
+
Исп.: {{this.current_user.name}}
+
Прогресс: {{this.done_ration}}
+
Трудозатраты: {{this.total_spent_hours}} / {{this.total_estimated_hours}}
+
+ {{/each}} +
+ {{/each}} +
+ {{/each}} + + \ No newline at end of file From 32bc8ace7e76863e11cc990828c80329522eee2a Mon Sep 17 00:00:00 2001 From: Pavel Gnedov Date: Thu, 9 Feb 2023 16:09:14 +0700 Subject: [PATCH 10/30] =?UTF-8?q?RedminePublicUrlConverter=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=BD=D0=B5=D1=81=D1=91=D0=BD=20=D0=B2=20=D0=B1?= =?UTF-8?q?=D0=B8=D0=B1=D0=BB=D0=B8=D0=BE=D1=82=D0=B5=D0=BA=D1=83=20event-?= =?UTF-8?q?emitter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src}/converters/redmine-public-url.converter.ts | 7 ++----- libs/event-emitter/src/event-emitter.module.ts | 3 +++ src/app.module.ts | 2 -- .../personal-notification.adapter.service.ts | 2 +- src/notifications/status-change-notifications.service.ts | 2 +- src/reports/current-issues-eccm.report.service.ts | 2 +- src/reports/daily-eccm-with-extra-data.service.ts | 2 +- 7 files changed, 9 insertions(+), 11 deletions(-) rename {src => libs/event-emitter/src}/converters/redmine-public-url.converter.ts (91%) diff --git a/src/converters/redmine-public-url.converter.ts b/libs/event-emitter/src/converters/redmine-public-url.converter.ts similarity index 91% rename from src/converters/redmine-public-url.converter.ts rename to libs/event-emitter/src/converters/redmine-public-url.converter.ts index 245d979..596e74d 100644 --- a/src/converters/redmine-public-url.converter.ts +++ b/libs/event-emitter/src/converters/redmine-public-url.converter.ts @@ -2,16 +2,13 @@ import { RedmineTypes } from '@app/event-emitter/models/redmine-types'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -// TODO: Этот сервис возможно перенести в lib event-emitter - @Injectable() export class RedminePublicUrlConverter { private redminePublicUrlPrefix: string; constructor(private configService: ConfigService) { - this.redminePublicUrlPrefix = this.configService.get( - 'redmineIssueEventEmitterConfig.redmineUrlPublic', - ); + this.redminePublicUrlPrefix = + this.configService.get('redmineUrlPublic'); } convert(issueId: number | string): string { diff --git a/libs/event-emitter/src/event-emitter.module.ts b/libs/event-emitter/src/event-emitter.module.ts index ce4c1cf..d33511c 100644 --- a/libs/event-emitter/src/event-emitter.module.ts +++ b/libs/event-emitter/src/event-emitter.module.ts @@ -21,6 +21,7 @@ import { EnhancerService } from './issue-enhancers/enhancer.service'; import { ProjectDashboardService } from './project-dashboard/project-dashboard.service'; import { RootIssueSubTreesWidgetService } from './project-dashboard/widgets/root-issue-subtrees.widget.service'; import { DynamicLoader } from './configs/dynamic-loader'; +import { RedminePublicUrlConverter } from './converters/redmine-public-url.converter'; @Module({}) export class EventEmitterModule implements OnModuleInit { @@ -46,6 +47,7 @@ export class EventEmitterModule implements OnModuleInit { ProjectDashboardService, RootIssueSubTreesWidgetService, DynamicLoader, + RedminePublicUrlConverter, ], exports: [ EventEmitterService, @@ -63,6 +65,7 @@ export class EventEmitterModule implements OnModuleInit { ProjectDashboardService, RootIssueSubTreesWidgetService, DynamicLoader, + RedminePublicUrlConverter, ], controllers: [MainController, UsersController, IssuesController], }; diff --git a/src/app.module.ts b/src/app.module.ts index 950481f..c7d9adc 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,7 +9,6 @@ import { switchMap, tap } from 'rxjs'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import configuration from './configs/app'; -import { RedminePublicUrlConverter } from './converters/redmine-public-url.converter'; import { Changes } from './couchdb-datasources/changes'; import { CurrentUserEnhancer } from './issue-enhancers/current-user-enhancer'; import { CustomFieldsEnhancer } from './issue-enhancers/custom-fields-enhancer'; @@ -63,7 +62,6 @@ import { SimpleKanbanBoardController } from './dashboards/simple-kanban-board.co PersonalNotificationsService, StatusChangeNotificationsService, Changes, - RedminePublicUrlConverter, ChangesCacheWriterService, TelegramBotService, UserMetaInfoService, 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 6d946ea..a581dac 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 { RedminePublicUrlConverter } from 'src/converters/redmine-public-url.converter'; +import { RedminePublicUrlConverter } from '@app/event-emitter/converters/redmine-public-url.converter'; @Injectable() export class PersonalNotificationAdapterService { diff --git a/src/notifications/status-change-notifications.service.ts b/src/notifications/status-change-notifications.service.ts index 7c9e525..b1ca1b9 100644 --- a/src/notifications/status-change-notifications.service.ts +++ b/src/notifications/status-change-notifications.service.ts @@ -4,13 +4,13 @@ import { UsersService } from '@app/event-emitter/users/users.service'; import { TimestampConverter } from '@app/event-emitter/utils/timestamp-converter'; import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { RedminePublicUrlConverter } from 'src/converters/redmine-public-url.converter'; import { Change } from 'src/models/change.model'; import { StatusChangesConfig } from 'src/models/status-changes-config.model'; import { StatusesConfig } from 'src/models/statuses-config.model'; import Handlebars from 'handlebars'; import { ChangeMessage } from 'src/models/change-message.model'; import { Subject } from 'rxjs'; +import { RedminePublicUrlConverter } from '@app/event-emitter/converters/redmine-public-url.converter'; @Injectable() export class StatusChangeNotificationsService { diff --git a/src/reports/current-issues-eccm.report.service.ts b/src/reports/current-issues-eccm.report.service.ts index 13489ac..ce3b0c7 100644 --- a/src/reports/current-issues-eccm.report.service.ts +++ b/src/reports/current-issues-eccm.report.service.ts @@ -6,9 +6,9 @@ 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'; +import { RedminePublicUrlConverter } from '@app/event-emitter/converters/redmine-public-url.converter'; // eslint-disable-next-line @typescript-eslint/no-namespace export namespace CurrentIssuesEccmReport { diff --git a/src/reports/daily-eccm-with-extra-data.service.ts b/src/reports/daily-eccm-with-extra-data.service.ts index c798a4c..35ebaf7 100644 --- a/src/reports/daily-eccm-with-extra-data.service.ts +++ b/src/reports/daily-eccm-with-extra-data.service.ts @@ -1,7 +1,7 @@ +import { RedminePublicUrlConverter } from '@app/event-emitter/converters/redmine-public-url.converter'; import { Timestamped } from '@app/event-emitter/models/timestamped'; import { Injectable } from '@nestjs/common'; import nano from 'nano'; -import { RedminePublicUrlConverter } from 'src/converters/redmine-public-url.converter'; import { DailyEccmUserCommentsService } from './daily-eccm-user-comments.service'; import { DailyEccmReport, From a51b8dae7c47ffb2d96c2c6bf035529852ce9b47 Mon Sep 17 00:00:00 2001 From: Pavel Gnedov Date: Thu, 9 Feb 2023 16:35:50 +0700 Subject: [PATCH 11/30] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20IssueUrlEnhancer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event-emitter/src/event-emitter.module.ts | 3 +++ .../src/issue-enhancers/issue-url-enhancer.ts | 23 +++++++++++++++++++ src/app.module.ts | 3 +++ 3 files changed, 29 insertions(+) create mode 100644 libs/event-emitter/src/issue-enhancers/issue-url-enhancer.ts diff --git a/libs/event-emitter/src/event-emitter.module.ts b/libs/event-emitter/src/event-emitter.module.ts index d33511c..abbd5f0 100644 --- a/libs/event-emitter/src/event-emitter.module.ts +++ b/libs/event-emitter/src/event-emitter.module.ts @@ -22,6 +22,7 @@ import { ProjectDashboardService } from './project-dashboard/project-dashboard.s import { RootIssueSubTreesWidgetService } from './project-dashboard/widgets/root-issue-subtrees.widget.service'; import { DynamicLoader } from './configs/dynamic-loader'; import { RedminePublicUrlConverter } from './converters/redmine-public-url.converter'; +import { IssueUrlEnhancer } from './issue-enhancers/issue-url-enhancer'; @Module({}) export class EventEmitterModule implements OnModuleInit { @@ -48,6 +49,7 @@ export class EventEmitterModule implements OnModuleInit { RootIssueSubTreesWidgetService, DynamicLoader, RedminePublicUrlConverter, + IssueUrlEnhancer, ], exports: [ EventEmitterService, @@ -66,6 +68,7 @@ export class EventEmitterModule implements OnModuleInit { RootIssueSubTreesWidgetService, DynamicLoader, RedminePublicUrlConverter, + IssueUrlEnhancer, ], controllers: [MainController, UsersController, IssuesController], }; diff --git a/libs/event-emitter/src/issue-enhancers/issue-url-enhancer.ts b/libs/event-emitter/src/issue-enhancers/issue-url-enhancer.ts new file mode 100644 index 0000000..aac1d8b --- /dev/null +++ b/libs/event-emitter/src/issue-enhancers/issue-url-enhancer.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; +import { RedminePublicUrlConverter } from '../converters/redmine-public-url.converter'; +import { RedmineTypes } from '../models/redmine-types'; +import { IssueEnhancerInterface } from './issue-enhancer-interface'; + +@Injectable() +export class IssueUrlEnhancer implements IssueEnhancerInterface { + name = 'issue-url'; + + constructor(private redminePublicUrlConverter: RedminePublicUrlConverter) {} + + async enhance( + issue: RedmineTypes.Issue, + ): Promise> { + const res: RedmineTypes.Issue & Record = issue; + res['url'] = { + url: this.redminePublicUrlConverter.getUrl(issue.id), + fullHref: this.redminePublicUrlConverter.getHtmlHref(issue), + minHref: this.redminePublicUrlConverter.getMinHtmlHref(issue.id), + }; + return res; + } +} diff --git a/src/app.module.ts b/src/app.module.ts index c7d9adc..05dd508 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -36,6 +36,7 @@ import { DailyEccmUserCommentsService } from './reports/daily-eccm-user-comments import { SetDailyEccmUserCommentBotHandlerService } from './telegram-bot/handlers/set-daily-eccm-user-comment.bot-handler.service'; import { DailyEccmWithExtraDataService } from './reports/daily-eccm-with-extra-data.service'; import { SimpleKanbanBoardController } from './dashboards/simple-kanban-board.controller'; +import { IssueUrlEnhancer } from '@app/event-emitter/issue-enhancers/issue-url-enhancer'; @Module({ imports: [ @@ -90,6 +91,7 @@ export class AppModule implements OnModuleInit { private timestampEnhancer: TimestampEnhancer, private customFieldsEnhancer: CustomFieldsEnhancer, private currentUserEnhancer: CurrentUserEnhancer, + private issueUrlEnhancer: IssueUrlEnhancer, private statusChangeNotificationsService: StatusChangeNotificationsService, private changesCacheWriterService: ChangesCacheWriterService, private telegramBotService: TelegramBotService, @@ -111,6 +113,7 @@ export class AppModule implements OnModuleInit { this.timestampEnhancer, this.customFieldsEnhancer, this.currentUserEnhancer, + this.issueUrlEnhancer, ]); this.personalNotificationsService.$messages.subscribe((resp) => { From 4509a729cbf20a0e459d67bfb8b9578c8d0cd514 Mon Sep 17 00:00:00 2001 From: Pavel Gnedov Date: Thu, 9 Feb 2023 19:04:32 +0700 Subject: [PATCH 12/30] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=B4=D0=BE=D1=81=D0=BA=D0=B0=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B4=D0=B0=D1=87=20=D1=81=D0=B3=D1=80=D1=83=D0=BF=D0=BF?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D0=B0=D1=8F=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BD=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event-emitter/src/event-emitter.module.ts | 3 + .../list-issues-by-users.widget.service.ts | 127 ++++++++++++++++++ .../simple-kanban-board.controller.ts | 18 +++ views/simple-kanban-board.hbs | 2 +- 4 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts diff --git a/libs/event-emitter/src/event-emitter.module.ts b/libs/event-emitter/src/event-emitter.module.ts index abbd5f0..c2113c3 100644 --- a/libs/event-emitter/src/event-emitter.module.ts +++ b/libs/event-emitter/src/event-emitter.module.ts @@ -23,6 +23,7 @@ import { RootIssueSubTreesWidgetService } from './project-dashboard/widgets/root import { DynamicLoader } from './configs/dynamic-loader'; import { RedminePublicUrlConverter } from './converters/redmine-public-url.converter'; import { IssueUrlEnhancer } from './issue-enhancers/issue-url-enhancer'; +import { ListIssuesByUsersWidgetService } from './project-dashboard/widgets/list-issues-by-users.widget.service'; @Module({}) export class EventEmitterModule implements OnModuleInit { @@ -50,6 +51,7 @@ export class EventEmitterModule implements OnModuleInit { DynamicLoader, RedminePublicUrlConverter, IssueUrlEnhancer, + ListIssuesByUsersWidgetService, ], exports: [ EventEmitterService, @@ -69,6 +71,7 @@ export class EventEmitterModule implements OnModuleInit { DynamicLoader, RedminePublicUrlConverter, IssueUrlEnhancer, + ListIssuesByUsersWidgetService, ], controllers: [MainController, UsersController, IssuesController], }; diff --git a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts new file mode 100644 index 0000000..dd380e1 --- /dev/null +++ b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts @@ -0,0 +1,127 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +import { IssuesService } from '@app/event-emitter/issues/issues.service'; +import { RedmineTypes } from '@app/event-emitter/models/redmine-types'; +import { FlatIssuesStore } from '@app/event-emitter/utils/flat-issues-store'; +import { TreeIssuesStore } from '@app/event-emitter/utils/tree-issues-store'; +import { Injectable, Logger } from '@nestjs/common'; +import nano from 'nano'; +import { WidgetInterface } from '../widget-interface'; + +export namespace ListIssuesByUsersWidgetNs { + export namespace Models { + export type Params = { + fromRootIssueId?: number; + fromQuery?: nano.MangoQuery; + userKey: string; + statuses: string[]; + }; + + export type FindResult = { + result?: any; + error?: FindErrors; + }; + + export enum FindErrors { + NOT_FOUND = 'NOT_FOUND', + } + } +} + +type Params = ListIssuesByUsersWidgetNs.Models.Params; +type ExtendedIssue = RedmineTypes.Issue & Record; +type FindResult = ListIssuesByUsersWidgetNs.Models.FindResult; + +@Injectable() +export class ListIssuesByUsersWidgetService + implements WidgetInterface +{ + private logger = new Logger(ListIssuesByUsersWidgetService.name); + + constructor(private issuesService: IssuesService) { + return; + } + + isMyConfig(): boolean { + return true; + } + + async render(widgetParams: Params): Promise { + // if (widgetParams) + let store: FlatIssuesStore; + if (widgetParams.fromRootIssueId) { + store = await this.getListFromRoot(widgetParams.fromRootIssueId); + } else if (widgetParams.fromQuery) { + store = await this.getListByQuery(widgetParams.fromQuery); + } else { + const errMsg = `Wrong widgetParams value`; + this.logger.error(`Wrong widgetParams value`); + throw new Error(errMsg); + } + const grouped = store.groupByStatusWithExtra((issue) => { + const res = this.getUserValueByKey(issue, widgetParams.userKey); + return res.result || 'Unknown'; + }, widgetParams.statuses); + const res = [] as any[]; + for (const user in grouped) { + if (Object.prototype.hasOwnProperty.call(grouped, user)) { + const data = grouped[user]; + res.push({ + data: data, + metainfo: this.createMetaInfo(user), + }); + } + } + return res; + } + + private async getListFromRoot(issueId: number): Promise { + const treeStore = new TreeIssuesStore(); + const rootIssue = await this.issuesService.getIssue(issueId); + treeStore.setRootIssue(rootIssue); + await treeStore.fillData(this.issuesLoader.bind(this)); + return treeStore.getFlatStore(); + } + + private async getListByQuery( + query: nano.MangoQuery, + ): Promise { + const rawData = await this.issuesService.find(query); + const store = new FlatIssuesStore(); + for (let i = 0; i < rawData.length; i++) { + const issue = rawData[i]; + store.push(issue); + } + return store; + } + + private async issuesLoader( + ids: number[], + ): Promise> { + const issues = await this.issuesService.getIssues(ids); + const res = {} as Record; + for (let i = 0; i < issues.length; i++) { + const issue = issues[i]; + res[issue.id] = issue; + } + return res; + } + + private getUserValueByKey(issue: ExtendedIssue, key: string): FindResult { + const keys = key.split('.'); + let res: any = issue; + for (let i = 0; i < keys.length; i++) { + const k = keys[i]; + if (!res.hasOwnProperty(k)) { + return { error: ListIssuesByUsersWidgetNs.Models.FindErrors.NOT_FOUND }; + } + res = res[k]; + } + return { result: res }; + } + + private createMetaInfo(user: string): Record { + return { + title: user, + }; + } +} diff --git a/src/dashboards/simple-kanban-board.controller.ts b/src/dashboards/simple-kanban-board.controller.ts index 51309a9..f8fb993 100644 --- a/src/dashboards/simple-kanban-board.controller.ts +++ b/src/dashboards/simple-kanban-board.controller.ts @@ -1,4 +1,5 @@ import { DynamicLoader } from '@app/event-emitter/configs/dynamic-loader'; +import { ListIssuesByUsersWidgetService } from '@app/event-emitter/project-dashboard/widgets/list-issues-by-users.widget.service'; import { RootIssueSubTreesWidgetService } from '@app/event-emitter/project-dashboard/widgets/root-issue-subtrees.widget.service'; import { Controller, Get, Param, Render } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -12,6 +13,7 @@ export class SimpleKanbanBoardController { private rootIssueSubTreesWidgetService: RootIssueSubTreesWidgetService, private dynamicLoader: DynamicLoader, private configService: ConfigService, + private listIssuesByUsersWidgetService: ListIssuesByUsersWidgetService, ) { this.path = this.configService.get('simpleKanbanBoard.path'); } @@ -31,4 +33,20 @@ export class SimpleKanbanBoardController { async get(@Param('name') name: string): Promise { return await this.getRawData(name); } + + @Get('/by-users/:name/raw') + async getByUsersRawData(@Param('name') name: string): Promise { + const cfg = this.dynamicLoader.load(name, { + path: this.path, + ext: 'jsonc', + parser: parse, + }); + return await this.listIssuesByUsersWidgetService.render(cfg); + } + + @Get('/by-users/:name') + @Render('simple-kanban-board') + async getByUsers(@Param('name') name: string): Promise { + return await this.getByUsersRawData(name); + } } diff --git a/views/simple-kanban-board.hbs b/views/simple-kanban-board.hbs index d391d30..8688ffb 100644 --- a/views/simple-kanban-board.hbs +++ b/views/simple-kanban-board.hbs @@ -52,7 +52,7 @@
{{this.status}}
{{#each this.issues}}
-
{{this.tracker.name}} #{{this.id}} - {{this.subject}}
+
Исп.: {{this.current_user.name}}
Прогресс: {{this.done_ration}}
Трудозатраты: {{this.total_spent_hours}} / {{this.total_estimated_hours}}
From eacbad3a02a2b84500bcd59aab12bf8fa2259f82 Mon Sep 17 00:00:00 2001 From: Pavel Gnedov Date: Thu, 9 Feb 2023 20:06:43 +0700 Subject: [PATCH 13/30] =?UTF-8?q?=D0=A1=D0=BE=D1=80=D1=82=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=B4=D0=BE=D1=81=D0=BE=D0=BA=20lis?= =?UTF-8?q?t-issues-by-users?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/list-issues-by-users.widget.service.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts index dd380e1..9f643ed 100644 --- a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts +++ b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts @@ -13,6 +13,7 @@ export namespace ListIssuesByUsersWidgetNs { fromRootIssueId?: number; fromQuery?: nano.MangoQuery; userKey: string; + userSort?: boolean; statuses: string[]; }; @@ -46,7 +47,6 @@ export class ListIssuesByUsersWidgetService } async render(widgetParams: Params): Promise { - // if (widgetParams) let store: FlatIssuesStore; if (widgetParams.fromRootIssueId) { store = await this.getListFromRoot(widgetParams.fromRootIssueId); @@ -61,7 +61,7 @@ export class ListIssuesByUsersWidgetService const res = this.getUserValueByKey(issue, widgetParams.userKey); return res.result || 'Unknown'; }, widgetParams.statuses); - const res = [] as any[]; + let res = [] as any[]; for (const user in grouped) { if (Object.prototype.hasOwnProperty.call(grouped, user)) { const data = grouped[user]; @@ -71,6 +71,11 @@ export class ListIssuesByUsersWidgetService }); } } + if (widgetParams.userSort) { + res = res.sort((a, b) => { + return a.metainfo.title.localeCompare(b.metainfo.title); + }); + } return res; } From 1f3964f5cafe55c974097038af3f428aef66bf28 Mon Sep 17 00:00:00 2001 From: Pavel Gnedov Date: Mon, 13 Feb 2023 08:10:51 +0700 Subject: [PATCH 14/30] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=BD=D0=BE=D0=B2=D0=B0=D1=8F=20=D0=B4?= =?UTF-8?q?=D0=BE=D1=81=D0=BA=D0=B0=20=D0=BF=D0=BE=20=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=BD=D0=B8=D0=BA=D0=B0=D0=BC=20=D0=BA=D0=B0=D0=BA?= =?UTF-8?q?=20=D1=81=D1=82=D0=B0=D1=80=D0=B0=D1=8F,=20=D1=82=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=BA=D0=BE=20=D0=BA=D0=B0=D0=BA=20=D0=B2=20jira?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event-emitter/src/event-emitter.module.ts | 3 + .../src/issues/issues.service.ts | 22 ++++ ...ssues-by-users-like-jira.widget.service.ts | 109 ++++++++++++++++++ .../list-issues-by-users.widget.service.ts | 39 +++---- .../root-issue-subtrees.widget.service.ts | 26 ++--- .../src/utils/flat-issues-store.ts | 42 ++++++- .../src/utils/get-value-from-object-by-key.ts | 15 +++ .../simple-kanban-board.controller.ts | 18 +++ 8 files changed, 230 insertions(+), 44 deletions(-) create mode 100644 libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service.ts create mode 100644 libs/event-emitter/src/utils/get-value-from-object-by-key.ts diff --git a/libs/event-emitter/src/event-emitter.module.ts b/libs/event-emitter/src/event-emitter.module.ts index c2113c3..a177e88 100644 --- a/libs/event-emitter/src/event-emitter.module.ts +++ b/libs/event-emitter/src/event-emitter.module.ts @@ -24,6 +24,7 @@ import { DynamicLoader } from './configs/dynamic-loader'; import { RedminePublicUrlConverter } from './converters/redmine-public-url.converter'; import { IssueUrlEnhancer } from './issue-enhancers/issue-url-enhancer'; import { ListIssuesByUsersWidgetService } from './project-dashboard/widgets/list-issues-by-users.widget.service'; +import { ListIssuesByUsersLikeJiraWidgetService } from './project-dashboard/widgets/list-issues-by-users-like-jira.widget.service'; @Module({}) export class EventEmitterModule implements OnModuleInit { @@ -52,6 +53,7 @@ export class EventEmitterModule implements OnModuleInit { RedminePublicUrlConverter, IssueUrlEnhancer, ListIssuesByUsersWidgetService, + ListIssuesByUsersLikeJiraWidgetService, ], exports: [ EventEmitterService, @@ -72,6 +74,7 @@ export class EventEmitterModule implements OnModuleInit { RedminePublicUrlConverter, IssueUrlEnhancer, ListIssuesByUsersWidgetService, + ListIssuesByUsersLikeJiraWidgetService, ], controllers: [MainController, UsersController, IssuesController], }; diff --git a/libs/event-emitter/src/issues/issues.service.ts b/libs/event-emitter/src/issues/issues.service.ts index 6b04cd7..3795622 100644 --- a/libs/event-emitter/src/issues/issues.service.ts +++ b/libs/event-emitter/src/issues/issues.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-namespace */ import { RedmineTypes } from '../models/redmine-types'; import { CacheTTL, Injectable, Logger } from '@nestjs/common'; import { Issues } from '../couchdb-datasources/issues'; @@ -12,6 +13,12 @@ import { GetParentsHint } from '../utils/get-parents-hint'; export const ISSUE_MEMORY_CACHE_LIFETIME = 30 * 1000; const ISSUE_MEMORY_CACHE_AUTOCLEAN_INTERVAL = 1000 * 60 * 5; +export namespace IssuesServiceNs { + export type IssuesLoader = ( + ids: number[], + ) => Promise>; +} + @Injectable() export class IssuesService { private logger = new Logger(IssuesService.name); @@ -143,4 +150,19 @@ export class IssuesService { return null; } } + + createDynamicIssuesLoader(): IssuesServiceNs.IssuesLoader { + const fn = async ( + ids: number[], + ): Promise> => { + const issues = await this.getIssues(ids); + const res = {} as Record; + for (let i = 0; i < issues.length; i++) { + const issue = issues[i]; + res[issue.id] = issue; + } + return res; + }; + return fn; + } } diff --git a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service.ts b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service.ts new file mode 100644 index 0000000..42e0829 --- /dev/null +++ b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service.ts @@ -0,0 +1,109 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +import { + IssuesService, + IssuesServiceNs, +} from '@app/event-emitter/issues/issues.service'; +import { FlatIssuesStore } from '@app/event-emitter/utils/flat-issues-store'; +import { GetValueFromObjectByKey } from '@app/event-emitter/utils/get-value-from-object-by-key'; +import { TreeIssuesStore } from '@app/event-emitter/utils/tree-issues-store'; +import { Injectable, Logger } from '@nestjs/common'; +import nano from 'nano'; +import { WidgetInterface } from '../widget-interface'; + +export namespace ListIssuesByUsersLikeJiraWidgetNs { + export namespace Models { + export type Params = { + fromRootIssueId?: number; + fromQuery?: nano.MangoQuery; + userKeys: string[]; + userSort?: boolean; + statuses: string[]; + }; + } +} + +type Params = ListIssuesByUsersLikeJiraWidgetNs.Models.Params; + +@Injectable() +export class ListIssuesByUsersLikeJiraWidgetService + implements WidgetInterface +{ + private logger = new Logger(ListIssuesByUsersLikeJiraWidgetService.name); + private issuesLoader: IssuesServiceNs.IssuesLoader; + + constructor(private issuesService: IssuesService) { + this.issuesLoader = this.issuesService.createDynamicIssuesLoader(); + } + + isMyConfig(): boolean { + return true; + } + + async render(widgetParams: Params): Promise { + let store: FlatIssuesStore; + if (widgetParams.fromRootIssueId) { + store = await this.getListFromRoot(widgetParams.fromRootIssueId); + } else if (widgetParams.fromQuery) { + store = await this.getListByQuery(widgetParams.fromQuery); + } else { + const errMsg = `Wrong widgetParams value`; + this.logger.error(errMsg); + throw new Error(errMsg); + } + const grouped = store.groupByStatusWithExtraToMultipleStories((issue) => { + const users = [] as string[]; + for (let i = 0; i < widgetParams.userKeys.length; i++) { + const userKey = widgetParams.userKeys[i]; + const userValue = GetValueFromObjectByKey(issue, userKey); + if (userValue.result) { + users.push(userValue.result); + } else { + users.push('Unknown Unknown'); + } + } + return users; + }, widgetParams.statuses); + let res = [] as any[]; + for (const user in grouped) { + if (Object.prototype.hasOwnProperty.call(grouped, user)) { + const data = grouped[user]; + res.push({ + data: data, + metainfo: this.createMetaInfo(user), + }); + } + } + if (widgetParams.userSort) { + res = res.sort((a, b) => { + return a.metainfo.title.localeCompare(b.metainfo.title); + }); + } + return res; + } + + private async getListFromRoot(issueId: number): Promise { + const treeStore = new TreeIssuesStore(); + const rootIssue = await this.issuesService.getIssue(issueId); + treeStore.setRootIssue(rootIssue); + await treeStore.fillData(this.issuesLoader); + return treeStore.getFlatStore(); + } + + private async getListByQuery( + query: nano.MangoQuery, + ): Promise { + const rawData = await this.issuesService.find(query); + const store = new FlatIssuesStore(); + for (let i = 0; i < rawData.length; i++) { + const issue = rawData[i]; + store.push(issue); + } + return store; + } + + private createMetaInfo(user: string): Record { + return { + title: user, + }; + } +} diff --git a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts index 9f643ed..0ab5c4c 100644 --- a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts +++ b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts @@ -1,7 +1,11 @@ /* eslint-disable @typescript-eslint/no-namespace */ -import { IssuesService } from '@app/event-emitter/issues/issues.service'; +import { + IssuesService, + IssuesServiceNs, +} from '@app/event-emitter/issues/issues.service'; import { RedmineTypes } from '@app/event-emitter/models/redmine-types'; import { FlatIssuesStore } from '@app/event-emitter/utils/flat-issues-store'; +import { GetValueFromObjectByKey } from '@app/event-emitter/utils/get-value-from-object-by-key'; import { TreeIssuesStore } from '@app/event-emitter/utils/tree-issues-store'; import { Injectable, Logger } from '@nestjs/common'; import nano from 'nano'; @@ -37,9 +41,10 @@ export class ListIssuesByUsersWidgetService implements WidgetInterface { private logger = new Logger(ListIssuesByUsersWidgetService.name); + private issuesLoader: IssuesServiceNs.IssuesLoader; constructor(private issuesService: IssuesService) { - return; + this.issuesLoader = this.issuesService.createDynamicIssuesLoader(); } isMyConfig(): boolean { @@ -54,7 +59,7 @@ export class ListIssuesByUsersWidgetService store = await this.getListByQuery(widgetParams.fromQuery); } else { const errMsg = `Wrong widgetParams value`; - this.logger.error(`Wrong widgetParams value`); + this.logger.error(errMsg); throw new Error(errMsg); } const grouped = store.groupByStatusWithExtra((issue) => { @@ -83,7 +88,7 @@ export class ListIssuesByUsersWidgetService const treeStore = new TreeIssuesStore(); const rootIssue = await this.issuesService.getIssue(issueId); treeStore.setRootIssue(rootIssue); - await treeStore.fillData(this.issuesLoader.bind(this)); + await treeStore.fillData(this.issuesLoader); return treeStore.getFlatStore(); } @@ -99,29 +104,13 @@ export class ListIssuesByUsersWidgetService return store; } - private async issuesLoader( - ids: number[], - ): Promise> { - const issues = await this.issuesService.getIssues(ids); - const res = {} as Record; - for (let i = 0; i < issues.length; i++) { - const issue = issues[i]; - res[issue.id] = issue; - } - return res; - } - private getUserValueByKey(issue: ExtendedIssue, key: string): FindResult { - const keys = key.split('.'); - let res: any = issue; - for (let i = 0; i < keys.length; i++) { - const k = keys[i]; - if (!res.hasOwnProperty(k)) { - return { error: ListIssuesByUsersWidgetNs.Models.FindErrors.NOT_FOUND }; - } - res = res[k]; + const value = GetValueFromObjectByKey(issue, key); + if (value.result) { + return { result: value.result }; + } else { + return { error: ListIssuesByUsersWidgetNs.Models.FindErrors.NOT_FOUND }; } - return { result: res }; } private createMetaInfo(user: string): Record { diff --git a/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts b/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts index f7fe98c..a120c60 100644 --- a/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts +++ b/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts @@ -1,6 +1,8 @@ /* eslint-disable @typescript-eslint/no-namespace */ -import { IssuesService } from '@app/event-emitter/issues/issues.service'; -import { RedmineTypes } from '@app/event-emitter/models/redmine-types'; +import { + IssuesService, + IssuesServiceNs, +} from '@app/event-emitter/issues/issues.service'; import { TreeIssuesStore, TreeIssuesStoreNs, @@ -36,7 +38,11 @@ type Params = RootIssueSubTreesWidgetNs.Models.Params; export class RootIssueSubTreesWidgetService implements WidgetInterface { - constructor(private issuesService: IssuesService) {} + private issuesLoader: IssuesServiceNs.IssuesLoader; + + constructor(private issuesService: IssuesService) { + this.issuesLoader = this.issuesService.createDynamicIssuesLoader(); + } isMyConfig(): boolean { return true; @@ -48,7 +54,7 @@ export class RootIssueSubTreesWidgetService widgetParams.rootIssueId, ); treeStore.setRootIssue(rootIssue); - await treeStore.fillData(this.issuesLoader.bind(this)); + await treeStore.fillData(this.issuesLoader); let stories: TreeIssuesStoreNs.Models.GetFlatStories.Result; if (widgetParams.parentsAsGroups) { stories = treeStore.getFlatStoriesByParents(); @@ -76,16 +82,4 @@ export class RootIssueSubTreesWidgetService }; }); } - - private async issuesLoader( - ids: number[], - ): Promise> { - const issues = await this.issuesService.getIssues(ids); - const res = {} as Record; - for (let i = 0; i < issues.length; i++) { - const issue = issues[i]; - res[issue.id] = issue; - } - return res; - } } diff --git a/libs/event-emitter/src/utils/flat-issues-store.ts b/libs/event-emitter/src/utils/flat-issues-store.ts index a9b07a3..ab29f66 100644 --- a/libs/event-emitter/src/utils/flat-issues-store.ts +++ b/libs/event-emitter/src/utils/flat-issues-store.ts @@ -1,10 +1,9 @@ /* eslint-disable @typescript-eslint/no-namespace */ +import { IssuesServiceNs } from '../issues/issues.service'; import { RedmineTypes } from '../models/redmine-types'; export namespace FlatIssuesStoreNs { - export type IssuesLoader = ( - ids: number[], - ) => Promise>; + export type IssuesLoader = IssuesServiceNs.IssuesLoader; export namespace Models { export type ByStatus = { @@ -178,4 +177,41 @@ export class FlatIssuesStore { } return res; } + + groupByToMultipleStories( + iteratee: (issue: RedmineTypes.Issue) => (string | number)[], + ): Record { + const res = {} as Record; + const items = this.getIssues(); + for (let i = 0; i < items.length; i++) { + const issue = items[i]; + const keys = iteratee(issue); + for (let j = 0; j < keys.length; j++) { + const key = keys[j]; + if (!Object.prototype.hasOwnProperty.call(res, key)) { + res[key] = new FlatIssuesStore(); + } + res[key].push(issue); + } + } + return res; + } + + groupByStatusWithExtraToMultipleStories( + iteratee: (issue: RedmineTypes.Issue) => (string | number)[], + statuses: string[], + ): Record { + const res = {} as Record< + string | number, + FlatIssuesStoreNs.Models.ByStatuses + >; + const groupedIssues = this.groupByToMultipleStories(iteratee); + for (const key in groupedIssues) { + if (Object.prototype.hasOwnProperty.call(groupedIssues, key)) { + const store = groupedIssues[key]; + res[key] = store.groupByStatus(statuses); + } + } + return res; + } } diff --git a/libs/event-emitter/src/utils/get-value-from-object-by-key.ts b/libs/event-emitter/src/utils/get-value-from-object-by-key.ts new file mode 100644 index 0000000..412b845 --- /dev/null +++ b/libs/event-emitter/src/utils/get-value-from-object-by-key.ts @@ -0,0 +1,15 @@ +export function GetValueFromObjectByKey( + obj: any, + key: string, +): { result?: any; error?: string } { + const keys = key.split('.'); + let res: any = obj; + for (let i = 0; i < keys.length; i++) { + const k = keys[i]; + if (!res.hasOwnProperty(k)) { + return { error: 'NOT_FOUND' }; + } + res = res[k]; + } + return { result: res }; +} diff --git a/src/dashboards/simple-kanban-board.controller.ts b/src/dashboards/simple-kanban-board.controller.ts index f8fb993..09d7fc8 100644 --- a/src/dashboards/simple-kanban-board.controller.ts +++ b/src/dashboards/simple-kanban-board.controller.ts @@ -1,4 +1,5 @@ import { DynamicLoader } from '@app/event-emitter/configs/dynamic-loader'; +import { ListIssuesByUsersLikeJiraWidgetService } from '@app/event-emitter/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service'; import { ListIssuesByUsersWidgetService } from '@app/event-emitter/project-dashboard/widgets/list-issues-by-users.widget.service'; import { RootIssueSubTreesWidgetService } from '@app/event-emitter/project-dashboard/widgets/root-issue-subtrees.widget.service'; import { Controller, Get, Param, Render } from '@nestjs/common'; @@ -14,6 +15,7 @@ export class SimpleKanbanBoardController { private dynamicLoader: DynamicLoader, private configService: ConfigService, private listIssuesByUsersWidgetService: ListIssuesByUsersWidgetService, + private listIssuesByUsersLikeJiraWidgetService: ListIssuesByUsersLikeJiraWidgetService, ) { this.path = this.configService.get('simpleKanbanBoard.path'); } @@ -49,4 +51,20 @@ export class SimpleKanbanBoardController { async getByUsers(@Param('name') name: string): Promise { return await this.getByUsersRawData(name); } + + @Get('/by-users-like-jira/:name/raw') + async getByUsersLikeJiraRawData(@Param('name') name: string): Promise { + const cfg = this.dynamicLoader.load(name, { + path: this.path, + ext: 'jsonc', + parser: parse, + }); + return await this.listIssuesByUsersLikeJiraWidgetService.render(cfg); + } + + @Get('/by-users-like-jira/:name') + @Render('simple-kanban-board') + async getByUsersLikeJira(@Param('name') name: string): Promise { + return await this.getByUsersLikeJiraRawData(name); + } } From 2aac9ae94c689e452647fe09ae4c2f25b4c573bb Mon Sep 17 00:00:00 2001 From: Pavel Gnedov Date: Mon, 13 Feb 2023 21:09:36 +0700 Subject: [PATCH 15/30] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D1=86=D0=B2=D0=B5=D1=82=D0=BE=D0=B2=D0=BE=D0=B9?= =?UTF-8?q?=20=D0=B8=D0=BD=D0=B4=D0=B8=D0=BA=D0=B0=D1=82=D0=BE=D1=80=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=B8=20?= =?UTF-8?q?=D1=81=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=D0=B4=D0=BD=D0=B5=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event-emitter/src/event-emitter.module.ts | 3 + .../time-passed-highlight-enhancer.ts | 68 +++++++++++++++++++ .../event-emitter/src/models/redmine-types.ts | 2 + ...ssues-by-users-like-jira.widget.service.ts | 8 ++- .../list-issues-by-users.widget.service.ts | 8 ++- .../root-issue-subtrees.widget.service.ts | 7 +- .../src/utils/flat-issues-store.ts | 14 ++++ .../src/utils/tree-issues-store.ts | 5 ++ views/simple-kanban-board.hbs | 24 ++++++- 9 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 libs/event-emitter/src/issue-enhancers/time-passed-highlight-enhancer.ts diff --git a/libs/event-emitter/src/event-emitter.module.ts b/libs/event-emitter/src/event-emitter.module.ts index a177e88..60a308a 100644 --- a/libs/event-emitter/src/event-emitter.module.ts +++ b/libs/event-emitter/src/event-emitter.module.ts @@ -25,6 +25,7 @@ import { RedminePublicUrlConverter } from './converters/redmine-public-url.conve import { IssueUrlEnhancer } from './issue-enhancers/issue-url-enhancer'; import { ListIssuesByUsersWidgetService } from './project-dashboard/widgets/list-issues-by-users.widget.service'; import { ListIssuesByUsersLikeJiraWidgetService } from './project-dashboard/widgets/list-issues-by-users-like-jira.widget.service'; +import { TimePassedHighlightEnhancer } from './issue-enhancers/time-passed-highlight-enhancer'; @Module({}) export class EventEmitterModule implements OnModuleInit { @@ -54,6 +55,7 @@ export class EventEmitterModule implements OnModuleInit { IssueUrlEnhancer, ListIssuesByUsersWidgetService, ListIssuesByUsersLikeJiraWidgetService, + TimePassedHighlightEnhancer, ], exports: [ EventEmitterService, @@ -75,6 +77,7 @@ export class EventEmitterModule implements OnModuleInit { IssueUrlEnhancer, ListIssuesByUsersWidgetService, ListIssuesByUsersLikeJiraWidgetService, + TimePassedHighlightEnhancer, ], controllers: [MainController, UsersController, IssuesController], }; diff --git a/libs/event-emitter/src/issue-enhancers/time-passed-highlight-enhancer.ts b/libs/event-emitter/src/issue-enhancers/time-passed-highlight-enhancer.ts new file mode 100644 index 0000000..4bd6a17 --- /dev/null +++ b/libs/event-emitter/src/issue-enhancers/time-passed-highlight-enhancer.ts @@ -0,0 +1,68 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +import { Injectable } from '@nestjs/common'; +import { RedmineTypes } from '../models/redmine-types'; +import { TimestampConverter } from '../utils/timestamp-converter'; +import { IssueEnhancerInterface } from './issue-enhancer-interface'; + +export namespace TimePassedHighlightEnhancerNs { + export type PriorityRules = { + /** time in seconds */ + timePassed: number; + priority: string; + }; +} + +@Injectable() +export class TimePassedHighlightEnhancer implements IssueEnhancerInterface { + name = 'activity-to-priority'; + + private rules: TimePassedHighlightEnhancerNs.PriorityRules[] = [ + { + timePassed: 60 * 60, // 1 час + priority: 'hot', + }, + { + timePassed: 24 * 60 * 60, // 1 день, + priority: 'warm', + }, + { + timePassed: 7 * 24 * 60 * 60, // 1 неделя + priority: 'comfort', + }, + { + timePassed: 14 * 24 * 60 * 60, // 2 недели + priority: 'breezy', + }, + ]; + + private otherPriority = 'cold'; + + private keyNameForCssClass = 'timePassedClass'; + + constructor() { + this.rules = this.rules.sort((a, b) => { + return a.timePassed - b.timePassed; + }); + } + + async enhance( + issue: RedmineTypes.ExtendedIssue, + ): Promise { + const nowTimestamp = new Date().getTime(); + if (!issue?.updated_on) return issue; + for (let i = 0; i < this.rules.length; i++) { + const rule = this.rules[i]; + if ( + nowTimestamp - TimestampConverter.toTimestamp(issue.updated_on) <= + rule.timePassed * 1000 + ) { + issue[this.keyNameForCssClass] = rule.priority; + break; + } + } + if (!issue[this.keyNameForCssClass]) { + issue[this.keyNameForCssClass] = this.otherPriority; + } + return issue; + } +} diff --git a/libs/event-emitter/src/models/redmine-types.ts b/libs/event-emitter/src/models/redmine-types.ts index 12f1d9d..bbde0c7 100644 --- a/libs/event-emitter/src/models/redmine-types.ts +++ b/libs/event-emitter/src/models/redmine-types.ts @@ -61,6 +61,8 @@ export module RedmineTypes { parent?: { id: number }; }; + export type ExtendedIssue = Issue & Record; + // eslint-disable-next-line @typescript-eslint/prefer-namespace-keyword, @typescript-eslint/no-namespace export module Unknown { export const num = -1; diff --git a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service.ts b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service.ts index 42e0829..f40c90c 100644 --- a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service.ts +++ b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-namespace */ +import { TimePassedHighlightEnhancer } from '@app/event-emitter/issue-enhancers/time-passed-highlight-enhancer'; import { IssuesService, IssuesServiceNs, @@ -31,7 +32,10 @@ export class ListIssuesByUsersLikeJiraWidgetService private logger = new Logger(ListIssuesByUsersLikeJiraWidgetService.name); private issuesLoader: IssuesServiceNs.IssuesLoader; - constructor(private issuesService: IssuesService) { + constructor( + private issuesService: IssuesService, + private timePassedHighlightEnhancer: TimePassedHighlightEnhancer, + ) { this.issuesLoader = this.issuesService.createDynamicIssuesLoader(); } @@ -86,6 +90,7 @@ export class ListIssuesByUsersLikeJiraWidgetService const rootIssue = await this.issuesService.getIssue(issueId); treeStore.setRootIssue(rootIssue); await treeStore.fillData(this.issuesLoader); + await treeStore.enhanceIssues([this.timePassedHighlightEnhancer]); return treeStore.getFlatStore(); } @@ -98,6 +103,7 @@ export class ListIssuesByUsersLikeJiraWidgetService const issue = rawData[i]; store.push(issue); } + await store.enhanceIssues([this.timePassedHighlightEnhancer]); return store; } diff --git a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts index 0ab5c4c..34374fd 100644 --- a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts +++ b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-namespace */ +import { TimePassedHighlightEnhancer } from '@app/event-emitter/issue-enhancers/time-passed-highlight-enhancer'; import { IssuesService, IssuesServiceNs, @@ -43,7 +44,10 @@ export class ListIssuesByUsersWidgetService private logger = new Logger(ListIssuesByUsersWidgetService.name); private issuesLoader: IssuesServiceNs.IssuesLoader; - constructor(private issuesService: IssuesService) { + constructor( + private issuesService: IssuesService, + private timePassedHighlightEnhancer: TimePassedHighlightEnhancer, + ) { this.issuesLoader = this.issuesService.createDynamicIssuesLoader(); } @@ -89,6 +93,7 @@ export class ListIssuesByUsersWidgetService const rootIssue = await this.issuesService.getIssue(issueId); treeStore.setRootIssue(rootIssue); await treeStore.fillData(this.issuesLoader); + await treeStore.enhanceIssues([this.timePassedHighlightEnhancer]); return treeStore.getFlatStore(); } @@ -101,6 +106,7 @@ export class ListIssuesByUsersWidgetService const issue = rawData[i]; store.push(issue); } + await store.enhanceIssues([this.timePassedHighlightEnhancer]); return store; } diff --git a/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts b/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts index a120c60..62ea77e 100644 --- a/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts +++ b/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-namespace */ +import { TimePassedHighlightEnhancer } from '@app/event-emitter/issue-enhancers/time-passed-highlight-enhancer'; import { IssuesService, IssuesServiceNs, @@ -40,7 +41,10 @@ export class RootIssueSubTreesWidgetService { private issuesLoader: IssuesServiceNs.IssuesLoader; - constructor(private issuesService: IssuesService) { + constructor( + private issuesService: IssuesService, + private timePassedHighlightEnhancer: TimePassedHighlightEnhancer, + ) { this.issuesLoader = this.issuesService.createDynamicIssuesLoader(); } @@ -55,6 +59,7 @@ export class RootIssueSubTreesWidgetService ); treeStore.setRootIssue(rootIssue); await treeStore.fillData(this.issuesLoader); + await treeStore.enhanceIssues([this.timePassedHighlightEnhancer]); let stories: TreeIssuesStoreNs.Models.GetFlatStories.Result; if (widgetParams.parentsAsGroups) { stories = treeStore.getFlatStoriesByParents(); diff --git a/libs/event-emitter/src/utils/flat-issues-store.ts b/libs/event-emitter/src/utils/flat-issues-store.ts index ab29f66..21483ca 100644 --- a/libs/event-emitter/src/utils/flat-issues-store.ts +++ b/libs/event-emitter/src/utils/flat-issues-store.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-namespace */ +import { IssueEnhancerInterface } from '../issue-enhancers/issue-enhancer-interface'; import { IssuesServiceNs } from '../issues/issues.service'; import { RedmineTypes } from '../models/redmine-types'; @@ -74,6 +75,19 @@ export class FlatIssuesStore { return; } + async enhanceIssues(enhancers: IssueEnhancerInterface[]): Promise { + for (const issueId in this.issues) { + if (Object.prototype.hasOwnProperty.call(this.issues, issueId)) { + let issue = this.issues[issueId]; + for (let i = 0; i < enhancers.length; i++) { + const enhancer = enhancers[i]; + issue = await enhancer.enhance(issue); + this.issues[issueId] = issue; + } + } + } + } + getIds(): number[] { return Object.keys(this.issues).map((i) => Number(i)); } diff --git a/libs/event-emitter/src/utils/tree-issues-store.ts b/libs/event-emitter/src/utils/tree-issues-store.ts index 9f6cffe..ce2bf0d 100644 --- a/libs/event-emitter/src/utils/tree-issues-store.ts +++ b/libs/event-emitter/src/utils/tree-issues-store.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-namespace */ +import { IssueEnhancerInterface } from '../issue-enhancers/issue-enhancer-interface'; import { RedmineTypes } from '../models/redmine-types'; import { FlatIssuesStore, FlatIssuesStoreNs } from './flat-issues-store'; @@ -28,6 +29,10 @@ export class TreeIssuesStore { await this.flatStore.fillData(loader); } + async enhanceIssues(enhancers: IssueEnhancerInterface[]): Promise { + await this.flatStore.enhanceIssues(enhancers); + } + getFlatStore(): FlatIssuesStore { return this.flatStore; } diff --git a/views/simple-kanban-board.hbs b/views/simple-kanban-board.hbs index 8688ffb..93f7ccd 100644 --- a/views/simple-kanban-board.hbs +++ b/views/simple-kanban-board.hbs @@ -40,6 +40,28 @@ .kanban-card .kanban-card-title { font-weight: bold; } + .timepassed-dot { + height: 10px; + width: 10px; + background-color: #bbb; + border-radius: 50%; + display: inline-block; + } + .timepassed-dot.hot { + background-color: red; + } + .timepassed-dot.warm { + background-color: orange; + } + .timepassed-dot.comfort { + background-color: rgba(255, 255, 0, 0.4); + } + .timepassed-dot.breezy { + background-color: rgba(0, 255, 0, 0.4); + } + .timepassed-dot.cold { + background-color: rgba(0, 0, 255, 0.1); + } @@ -52,7 +74,7 @@
{{this.status}}
{{#each this.issues}}
- +
Исп.: {{this.current_user.name}}
Прогресс: {{this.done_ration}}
Трудозатраты: {{this.total_spent_hours}} / {{this.total_estimated_hours}}
From e0c6ac33565346511ffb97d63b0b00282db4ca0a Mon Sep 17 00:00:00 2001 From: Pavel Gnedov Date: Tue, 14 Feb 2023 17:50:59 +0700 Subject: [PATCH 16/30] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D1=87=D0=BD=D1=8B=D1=85=20url=20=D0=B2=20=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D1=87=D0=B0=D1=85=20=D0=BF=D1=80=D0=B8=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B5=20=D0=B4=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20=D0=B4=D0=BB=D1=8F=20=D0=B2=D0=B8=D0=B4=D0=B6?= =?UTF-8?q?=D0=B5=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../list-issues-by-users-like-jira.widget.service.ts | 7 ++++++- .../widgets/list-issues-by-users.widget.service.ts | 12 ++++++++++-- .../widgets/root-issue-subtrees.widget.service.ts | 7 ++++++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service.ts b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service.ts index f40c90c..c316b4f 100644 --- a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service.ts +++ b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-namespace */ +import { IssueUrlEnhancer } from '@app/event-emitter/issue-enhancers/issue-url-enhancer'; import { TimePassedHighlightEnhancer } from '@app/event-emitter/issue-enhancers/time-passed-highlight-enhancer'; import { IssuesService, @@ -35,6 +36,7 @@ export class ListIssuesByUsersLikeJiraWidgetService constructor( private issuesService: IssuesService, private timePassedHighlightEnhancer: TimePassedHighlightEnhancer, + private issueUrlEnhancer: IssueUrlEnhancer, ) { this.issuesLoader = this.issuesService.createDynamicIssuesLoader(); } @@ -90,7 +92,10 @@ export class ListIssuesByUsersLikeJiraWidgetService const rootIssue = await this.issuesService.getIssue(issueId); treeStore.setRootIssue(rootIssue); await treeStore.fillData(this.issuesLoader); - await treeStore.enhanceIssues([this.timePassedHighlightEnhancer]); + await treeStore.enhanceIssues([ + this.timePassedHighlightEnhancer, + this.issueUrlEnhancer, + ]); return treeStore.getFlatStore(); } diff --git a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts index 34374fd..7cb6202 100644 --- a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts +++ b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-namespace */ +import { IssueUrlEnhancer } from '@app/event-emitter/issue-enhancers/issue-url-enhancer'; import { TimePassedHighlightEnhancer } from '@app/event-emitter/issue-enhancers/time-passed-highlight-enhancer'; import { IssuesService, @@ -47,6 +48,7 @@ export class ListIssuesByUsersWidgetService constructor( private issuesService: IssuesService, private timePassedHighlightEnhancer: TimePassedHighlightEnhancer, + private issueUrlEnhancer: IssueUrlEnhancer, ) { this.issuesLoader = this.issuesService.createDynamicIssuesLoader(); } @@ -93,7 +95,10 @@ export class ListIssuesByUsersWidgetService const rootIssue = await this.issuesService.getIssue(issueId); treeStore.setRootIssue(rootIssue); await treeStore.fillData(this.issuesLoader); - await treeStore.enhanceIssues([this.timePassedHighlightEnhancer]); + await treeStore.enhanceIssues([ + this.timePassedHighlightEnhancer, + this.issueUrlEnhancer, + ]); return treeStore.getFlatStore(); } @@ -106,7 +111,10 @@ export class ListIssuesByUsersWidgetService const issue = rawData[i]; store.push(issue); } - await store.enhanceIssues([this.timePassedHighlightEnhancer]); + await store.enhanceIssues([ + this.timePassedHighlightEnhancer, + this.issueUrlEnhancer, + ]); return store; } diff --git a/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts b/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts index 62ea77e..258e52e 100644 --- a/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts +++ b/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-namespace */ +import { IssueUrlEnhancer } from '@app/event-emitter/issue-enhancers/issue-url-enhancer'; import { TimePassedHighlightEnhancer } from '@app/event-emitter/issue-enhancers/time-passed-highlight-enhancer'; import { IssuesService, @@ -44,6 +45,7 @@ export class RootIssueSubTreesWidgetService constructor( private issuesService: IssuesService, private timePassedHighlightEnhancer: TimePassedHighlightEnhancer, + private issueUrlEnhancer: IssueUrlEnhancer, ) { this.issuesLoader = this.issuesService.createDynamicIssuesLoader(); } @@ -59,7 +61,10 @@ export class RootIssueSubTreesWidgetService ); treeStore.setRootIssue(rootIssue); await treeStore.fillData(this.issuesLoader); - await treeStore.enhanceIssues([this.timePassedHighlightEnhancer]); + await treeStore.enhanceIssues([ + this.timePassedHighlightEnhancer, + this.issueUrlEnhancer, + ]); let stories: TreeIssuesStoreNs.Models.GetFlatStories.Result; if (widgetParams.parentsAsGroups) { stories = treeStore.getFlatStoriesByParents(); From a6241ce78641cfb455c6ab8e2bd92b614c87be79 Mon Sep 17 00:00:00 2001 From: Pavel Gnedov Date: Tue, 14 Feb 2023 18:45:26 +0700 Subject: [PATCH 17/30] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D1=87=D0=BD=D1=8B=D1=85=20url=20=D0=B2=20=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D1=87=D0=B0=D1=85=20=D0=BF=D1=80=D0=B8=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B5=20=D0=B4=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20=D0=B4=D0=BB=D1=8F=20=D0=B2=D0=B8=D0=B4=D0=B6?= =?UTF-8?q?=D0=B5=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/list-issues-by-users-like-jira.widget.service.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service.ts b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service.ts index c316b4f..490a127 100644 --- a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service.ts +++ b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service.ts @@ -108,7 +108,10 @@ export class ListIssuesByUsersLikeJiraWidgetService const issue = rawData[i]; store.push(issue); } - await store.enhanceIssues([this.timePassedHighlightEnhancer]); + await store.enhanceIssues([ + this.timePassedHighlightEnhancer, + this.issueUrlEnhancer, + ]); return store; } From 81966907acaf72065e6086712f9f7a4a6464f282 Mon Sep 17 00:00:00 2001 From: Pavel Gnedov Date: Tue, 14 Feb 2023 18:52:14 +0700 Subject: [PATCH 18/30] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D1=87=D0=BD=D1=8B=D1=85=20url=20=D0=B2=20=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D1=87=D0=B0=D1=85=20=D0=BF=D1=80=D0=B8=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B5=20=D0=B4=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20=D0=B4=D0=BB=D1=8F=20=D0=B2=D0=B8=D0=B4=D0=B6?= =?UTF-8?q?=D0=B5=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- libs/event-emitter/src/issue-enhancers/issue-url-enhancer.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/event-emitter/src/issue-enhancers/issue-url-enhancer.ts b/libs/event-emitter/src/issue-enhancers/issue-url-enhancer.ts index aac1d8b..8cebbf8 100644 --- a/libs/event-emitter/src/issue-enhancers/issue-url-enhancer.ts +++ b/libs/event-emitter/src/issue-enhancers/issue-url-enhancer.ts @@ -13,6 +13,7 @@ export class IssueUrlEnhancer implements IssueEnhancerInterface { issue: RedmineTypes.Issue, ): Promise> { const res: RedmineTypes.Issue & Record = issue; + if (!issue || !issue.id) return issue; res['url'] = { url: this.redminePublicUrlConverter.getUrl(issue.id), fullHref: this.redminePublicUrlConverter.getHtmlHref(issue), From 4f0355c09c3cdfab157b3d9d8f58a962d533a83f Mon Sep 17 00:00:00 2001 From: Pavel Gnedov Date: Wed, 15 Feb 2023 10:17:36 +0700 Subject: [PATCH 19/30] =?UTF-8?q?=D0=92=D1=8B=D0=B2=D0=BE=D0=B4=20=D1=82?= =?UTF-8?q?=D0=B5=D0=B3=D0=BE=D0=B2=20=D0=BD=D0=B0=20=D0=BA=D0=B0=D1=80?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BA=D0=B0=D1=85=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=20=D0=B2=20kanban-=D0=B4=D0=BE=D1=81=D0=BA=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../issue-enhancers/tag-styled-enhancer.ts | 87 +++++++++++++++++++ ...ssues-by-users-like-jira.widget.service.ts | 16 ++-- .../list-issues-by-users.widget.service.ts | 16 ++-- .../root-issue-subtrees.widget.service.ts | 4 +- .../src/utils/flat-issues-store.ts | 5 +- src/issue-enhancers/custom-fields-enhancer.ts | 5 +- views/simple-kanban-board.hbs | 13 +++ 7 files changed, 125 insertions(+), 21 deletions(-) create mode 100644 libs/event-emitter/src/issue-enhancers/tag-styled-enhancer.ts diff --git a/libs/event-emitter/src/issue-enhancers/tag-styled-enhancer.ts b/libs/event-emitter/src/issue-enhancers/tag-styled-enhancer.ts new file mode 100644 index 0000000..9b63b76 --- /dev/null +++ b/libs/event-emitter/src/issue-enhancers/tag-styled-enhancer.ts @@ -0,0 +1,87 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +import { Injectable, Logger } from '@nestjs/common'; +import { RedmineTypes } from '../models/redmine-types'; +import { IssueEnhancerInterface } from './issue-enhancer-interface'; + +export namespace TagStyledEnhancerNs { + /** + * * key - tag name, + * * value - css style for tag + */ + export type Styles = Record; + + export type StyledTag = { + tag: string; + style: string; + }; + + export type TagsParams = { + tagsKeyName: string; + styles: Styles; + defaultStyle: string; + styledTagsKeyName: string; + }; + + export type ConfigWithTagsStyles = { + tags?: TagsParams; + [key: string]: any; + }; + + export function CreateTagStyledEnhancerForConfig( + cfg: ConfigWithTagsStyles, + ): TagStyledEnhancer | null { + if (!cfg.tags) return null; + return new TagStyledEnhancer( + (issue: RedmineTypes.ExtendedIssue) => { + if ( + typeof issue[cfg.tags.tagsKeyName] === 'object' && + issue[cfg.tags.tagsKeyName].length > 0 + ) { + return issue[cfg.tags.tagsKeyName]; + } else { + return []; + } + }, + cfg.tags.styles, + cfg.tags.defaultStyle, + cfg.tags.styledTagsKeyName, + ); + } +} + +export class TagStyledEnhancer implements IssueEnhancerInterface { + private logger = new Logger(TagStyledEnhancer.name); + + name = 'tag-styled'; + + constructor( + private tagsGetter: (issue: RedmineTypes.ExtendedIssue) => string[], + private styles: TagStyledEnhancerNs.Styles, + private defaultStyle: string, + private keyName: string, + ) {} + + async enhance( + issue: RedmineTypes.ExtendedIssue, + ): Promise { + const tags = this.tagsGetter(issue); + this.logger.debug(`Found tags for issue_id = ${issue.id} - ${tags}`); + const styles = [] as TagStyledEnhancerNs.StyledTag[]; + for (let i = 0; i < tags.length; i++) { + const tagName = tags[i]; + if (this.styles[tagName]) { + styles.push({ + tag: tagName, + style: this.styles[tagName], + }); + } else { + styles.push({ + tag: tagName, + style: this.defaultStyle, + }); + } + } + issue[this.keyName] = styles; + return issue; + } +} diff --git a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service.ts b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service.ts index 490a127..173f447 100644 --- a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service.ts +++ b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-namespace */ import { IssueUrlEnhancer } from '@app/event-emitter/issue-enhancers/issue-url-enhancer'; +import { TagStyledEnhancerNs } from '@app/event-emitter/issue-enhancers/tag-styled-enhancer'; import { TimePassedHighlightEnhancer } from '@app/event-emitter/issue-enhancers/time-passed-highlight-enhancer'; import { IssuesService, @@ -20,7 +21,7 @@ export namespace ListIssuesByUsersLikeJiraWidgetNs { userKeys: string[]; userSort?: boolean; statuses: string[]; - }; + } & TagStyledEnhancerNs.ConfigWithTagsStyles; } } @@ -56,6 +57,11 @@ export class ListIssuesByUsersLikeJiraWidgetService this.logger.error(errMsg); throw new Error(errMsg); } + await store.enhanceIssues([ + this.timePassedHighlightEnhancer, + this.issueUrlEnhancer, + TagStyledEnhancerNs.CreateTagStyledEnhancerForConfig(widgetParams), + ]); const grouped = store.groupByStatusWithExtraToMultipleStories((issue) => { const users = [] as string[]; for (let i = 0; i < widgetParams.userKeys.length; i++) { @@ -92,10 +98,6 @@ export class ListIssuesByUsersLikeJiraWidgetService const rootIssue = await this.issuesService.getIssue(issueId); treeStore.setRootIssue(rootIssue); await treeStore.fillData(this.issuesLoader); - await treeStore.enhanceIssues([ - this.timePassedHighlightEnhancer, - this.issueUrlEnhancer, - ]); return treeStore.getFlatStore(); } @@ -108,10 +110,6 @@ export class ListIssuesByUsersLikeJiraWidgetService const issue = rawData[i]; store.push(issue); } - await store.enhanceIssues([ - this.timePassedHighlightEnhancer, - this.issueUrlEnhancer, - ]); return store; } diff --git a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts index 7cb6202..9d3fefe 100644 --- a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts +++ b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-namespace */ import { IssueUrlEnhancer } from '@app/event-emitter/issue-enhancers/issue-url-enhancer'; +import { TagStyledEnhancerNs } from '@app/event-emitter/issue-enhancers/tag-styled-enhancer'; import { TimePassedHighlightEnhancer } from '@app/event-emitter/issue-enhancers/time-passed-highlight-enhancer'; import { IssuesService, @@ -21,7 +22,7 @@ export namespace ListIssuesByUsersWidgetNs { userKey: string; userSort?: boolean; statuses: string[]; - }; + } & TagStyledEnhancerNs.ConfigWithTagsStyles; export type FindResult = { result?: any; @@ -68,6 +69,11 @@ export class ListIssuesByUsersWidgetService this.logger.error(errMsg); throw new Error(errMsg); } + await store.enhanceIssues([ + this.timePassedHighlightEnhancer, + this.issueUrlEnhancer, + TagStyledEnhancerNs.CreateTagStyledEnhancerForConfig(widgetParams), + ]); const grouped = store.groupByStatusWithExtra((issue) => { const res = this.getUserValueByKey(issue, widgetParams.userKey); return res.result || 'Unknown'; @@ -95,10 +101,6 @@ export class ListIssuesByUsersWidgetService const rootIssue = await this.issuesService.getIssue(issueId); treeStore.setRootIssue(rootIssue); await treeStore.fillData(this.issuesLoader); - await treeStore.enhanceIssues([ - this.timePassedHighlightEnhancer, - this.issueUrlEnhancer, - ]); return treeStore.getFlatStore(); } @@ -111,10 +113,6 @@ export class ListIssuesByUsersWidgetService const issue = rawData[i]; store.push(issue); } - await store.enhanceIssues([ - this.timePassedHighlightEnhancer, - this.issueUrlEnhancer, - ]); return store; } diff --git a/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts b/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts index 258e52e..a28f7ac 100644 --- a/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts +++ b/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-namespace */ import { IssueUrlEnhancer } from '@app/event-emitter/issue-enhancers/issue-url-enhancer'; +import { TagStyledEnhancerNs } from '@app/event-emitter/issue-enhancers/tag-styled-enhancer'; import { TimePassedHighlightEnhancer } from '@app/event-emitter/issue-enhancers/time-passed-highlight-enhancer'; import { IssuesService, @@ -19,7 +20,7 @@ export namespace RootIssueSubTreesWidgetNs { parentsAsGroups?: boolean; groups?: GroupCfg; statuses: string[]; - }; + } & TagStyledEnhancerNs.ConfigWithTagsStyles; export type GroupCfg = { fromIssues: Group[]; @@ -64,6 +65,7 @@ export class RootIssueSubTreesWidgetService await treeStore.enhanceIssues([ this.timePassedHighlightEnhancer, this.issueUrlEnhancer, + TagStyledEnhancerNs.CreateTagStyledEnhancerForConfig(widgetParams), ]); let stories: TreeIssuesStoreNs.Models.GetFlatStories.Result; if (widgetParams.parentsAsGroups) { diff --git a/libs/event-emitter/src/utils/flat-issues-store.ts b/libs/event-emitter/src/utils/flat-issues-store.ts index 21483ca..dd0d7f9 100644 --- a/libs/event-emitter/src/utils/flat-issues-store.ts +++ b/libs/event-emitter/src/utils/flat-issues-store.ts @@ -75,12 +75,15 @@ export class FlatIssuesStore { return; } - async enhanceIssues(enhancers: IssueEnhancerInterface[]): Promise { + async enhanceIssues( + enhancers: (IssueEnhancerInterface | null)[], + ): Promise { for (const issueId in this.issues) { if (Object.prototype.hasOwnProperty.call(this.issues, issueId)) { let issue = this.issues[issueId]; for (let i = 0; i < enhancers.length; i++) { const enhancer = enhancers[i]; + if (!enhancer) continue; issue = await enhancer.enhance(issue); this.issues[issueId] = issue; } diff --git a/src/issue-enhancers/custom-fields-enhancer.ts b/src/issue-enhancers/custom-fields-enhancer.ts index ac50d81..1694800 100644 --- a/src/issue-enhancers/custom-fields-enhancer.ts +++ b/src/issue-enhancers/custom-fields-enhancer.ts @@ -50,7 +50,10 @@ export class CustomFieldsEnhancer implements IssueEnhancerInterface { const tags = customFields.find((cf) => cf.name === 'Tags'); if (tags && tags.value) { - res.tags = tags.value.split(/[ ,;]/); + res.tags = tags.value + .split(/[ ,;]/) + .map((s) => s.trim()) + .filter((s) => !!s); } const sp = customFields.find( diff --git a/views/simple-kanban-board.hbs b/views/simple-kanban-board.hbs index 93f7ccd..c26e710 100644 --- a/views/simple-kanban-board.hbs +++ b/views/simple-kanban-board.hbs @@ -62,6 +62,11 @@ .timepassed-dot.cold { background-color: rgba(0, 0, 255, 0.1); } + .kanban-card-tag { + font-size: 8pt; + border-radius: 4px; + padding: 2px; + } @@ -78,6 +83,14 @@
Исп.: {{this.current_user.name}}
Прогресс: {{this.done_ration}}
Трудозатраты: {{this.total_spent_hours}} / {{this.total_estimated_hours}}
+ {{#if this.styledTags}} +
+ Tags: + {{#each this.styledTags}} + {{this.tag}} + {{/each}} +
+ {{/if}}
{{/each}}
From c543acbbef486e03f22ab8df7682255fefebf75c Mon Sep 17 00:00:00 2001 From: Gnedov Pavel Date: Wed, 15 Feb 2023 10:50:11 +0700 Subject: [PATCH 20/30] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B8=20=D0=BB=D0=BE=D0=B3=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/issue-enhancers/tag-styled-enhancer.ts | 4 +++- .../adapters/status-change.adapter.service.ts | 10 +++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/libs/event-emitter/src/issue-enhancers/tag-styled-enhancer.ts b/libs/event-emitter/src/issue-enhancers/tag-styled-enhancer.ts index 9b63b76..dbb8920 100644 --- a/libs/event-emitter/src/issue-enhancers/tag-styled-enhancer.ts +++ b/libs/event-emitter/src/issue-enhancers/tag-styled-enhancer.ts @@ -30,9 +30,10 @@ export namespace TagStyledEnhancerNs { export function CreateTagStyledEnhancerForConfig( cfg: ConfigWithTagsStyles, ): TagStyledEnhancer | null { - if (!cfg.tags) return null; + if (!cfg || !cfg.tags) return null; return new TagStyledEnhancer( (issue: RedmineTypes.ExtendedIssue) => { + if (!issue) return []; if ( typeof issue[cfg.tags.tagsKeyName] === 'object' && issue[cfg.tags.tagsKeyName].length > 0 @@ -64,6 +65,7 @@ export class TagStyledEnhancer implements IssueEnhancerInterface { async enhance( issue: RedmineTypes.ExtendedIssue, ): Promise { + if (!issue) return issue; const tags = this.tagsGetter(issue); this.logger.debug(`Found tags for issue_id = ${issue.id} - ${tags}`); const styles = [] as TagStyledEnhancerNs.StyledTag[]; diff --git a/src/notifications/adapters/status-change.adapter.service.ts b/src/notifications/adapters/status-change.adapter.service.ts index 9e9ecaa..b70271b 100644 --- a/src/notifications/adapters/status-change.adapter.service.ts +++ b/src/notifications/adapters/status-change.adapter.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import TelegramBot from 'node-telegram-bot-api'; import { Change } from 'src/models/change.model'; @@ -18,6 +18,7 @@ namespace StatusChangeAdapter { @Injectable() export class StatusChangeAdapterService { + private logger = new Logger(StatusChangeAdapterService.name); private periodValidityNotification: number; constructor( @@ -54,6 +55,7 @@ export class StatusChangeAdapterService { item.options = { parse_mode: 'HTML' }; return item; }); + this.logger.debug(`Change messages for sending to telegram - ${JSON.stringify(messages)}`); for (let i = 0; i < messages.length; i++) { const message = messages[i]; await this.telegramBotService.sendMessageByRedmineId( @@ -74,6 +76,12 @@ export class StatusChangeAdapterService { nowTimestamp - change.created_on_timestamp > this.periodValidityNotification ) { + this.logger.debug( + `Skipping sending due to the prescription ` + + `of the origin of the event - ` + + `issue_id = ${change.issue_id}, ` + + `messages = ${change.messages.map((m) => m.notification_message).filter((m) => !!m)}` + ); continue; } for (let j = 0; j < change.messages.length; j++) { From b26e0d88a160c12d9130299eb4bfb1c5991d106f Mon Sep 17 00:00:00 2001 From: Gnedov Pavel Date: Wed, 15 Feb 2023 13:55:57 +0700 Subject: [PATCH 21/30] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B8=20=D0=BB=D0=BE=D0=B3=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- libs/event-emitter/src/utils/tree-issues-store.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libs/event-emitter/src/utils/tree-issues-store.ts b/libs/event-emitter/src/utils/tree-issues-store.ts index ce2bf0d..99c5579 100644 --- a/libs/event-emitter/src/utils/tree-issues-store.ts +++ b/libs/event-emitter/src/utils/tree-issues-store.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-namespace */ +import { Logger } from '@nestjs/common'; import { IssueEnhancerInterface } from '../issue-enhancers/issue-enhancer-interface'; import { RedmineTypes } from '../models/redmine-types'; import { FlatIssuesStore, FlatIssuesStoreNs } from './flat-issues-store'; @@ -17,12 +18,14 @@ export namespace TreeIssuesStoreNs { } export class TreeIssuesStore { + private logger = new Logger(TreeIssuesStore.name); private rootIssue: RedmineTypes.Issue; private flatStore: FlatIssuesStore; setRootIssue(issue: RedmineTypes.Issue): void { this.rootIssue = issue; this.prepareFlatIssuesStore(); + this.logger.debug(`Set root issue_id - ${JSON.stringify(issue.id)}`); } async fillData(loader: FlatIssuesStoreNs.IssuesLoader): Promise { From 7b74f07b430bd8f82a7b27841b61da6c8d5fedd6 Mon Sep 17 00:00:00 2001 From: Gnedov Pavel Date: Thu, 16 Feb 2023 18:04:47 +0700 Subject: [PATCH 22/30] =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D0=BD=D1=8B=20?= =?UTF-8?q?=D0=BB=D0=B8=D1=88=D0=BD=D0=B8=D0=B5=20=D0=BB=D0=BE=D0=B3=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- libs/event-emitter/src/issue-enhancers/tag-styled-enhancer.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/libs/event-emitter/src/issue-enhancers/tag-styled-enhancer.ts b/libs/event-emitter/src/issue-enhancers/tag-styled-enhancer.ts index dbb8920..d938e4c 100644 --- a/libs/event-emitter/src/issue-enhancers/tag-styled-enhancer.ts +++ b/libs/event-emitter/src/issue-enhancers/tag-styled-enhancer.ts @@ -51,8 +51,6 @@ export namespace TagStyledEnhancerNs { } export class TagStyledEnhancer implements IssueEnhancerInterface { - private logger = new Logger(TagStyledEnhancer.name); - name = 'tag-styled'; constructor( @@ -67,7 +65,6 @@ export class TagStyledEnhancer implements IssueEnhancerInterface { ): Promise { if (!issue) return issue; const tags = this.tagsGetter(issue); - this.logger.debug(`Found tags for issue_id = ${issue.id} - ${tags}`); const styles = [] as TagStyledEnhancerNs.StyledTag[]; for (let i = 0; i < tags.length; i++) { const tagName = tags[i]; From d031105c9f4174fb103c364ca7ea1af9defa476d Mon Sep 17 00:00:00 2001 From: Pavel Gnedov Date: Fri, 17 Feb 2023 01:35:12 +0700 Subject: [PATCH 23/30] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=B2=D0=B8=D0=B4=D0=B6=D0=B5=D1=82=20kanban=20?= =?UTF-8?q?=D0=B4=D0=BE=D1=81=D0=BA=D0=B8=20=D1=81=20=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=BF=D0=BF=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=BE=D0=B9=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B4=D0=B0=D1=87=20=D0=BF=D0=BE=20=D1=82=D0=B5=D0=B3?= =?UTF-8?q?=D0=B0=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.module.ts | 2 + .../simple-kanban-board.controller.ts | 18 +++ .../widgets/issues-by-tags.widget.service.ts | 129 ++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 src/dashboards/widgets/issues-by-tags.widget.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 05dd508..5ae2661 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -37,6 +37,7 @@ import { SetDailyEccmUserCommentBotHandlerService } from './telegram-bot/handler import { DailyEccmWithExtraDataService } from './reports/daily-eccm-with-extra-data.service'; import { SimpleKanbanBoardController } from './dashboards/simple-kanban-board.controller'; import { IssueUrlEnhancer } from '@app/event-emitter/issue-enhancers/issue-url-enhancer'; +import { IssuesByTagsWidgetService } from './dashboards/widgets/issues-by-tags.widget.service'; @Module({ imports: [ @@ -79,6 +80,7 @@ import { IssueUrlEnhancer } from '@app/event-emitter/issue-enhancers/issue-url-e DailyEccmUserCommentsService, SetDailyEccmUserCommentBotHandlerService, DailyEccmWithExtraDataService, + IssuesByTagsWidgetService, ], }) export class AppModule implements OnModuleInit { diff --git a/src/dashboards/simple-kanban-board.controller.ts b/src/dashboards/simple-kanban-board.controller.ts index 09d7fc8..09c1961 100644 --- a/src/dashboards/simple-kanban-board.controller.ts +++ b/src/dashboards/simple-kanban-board.controller.ts @@ -5,6 +5,7 @@ import { RootIssueSubTreesWidgetService } from '@app/event-emitter/project-dashb import { Controller, Get, Param, Render } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { parse } from 'jsonc-parser'; +import { IssuesByTagsWidgetService } from './widgets/issues-by-tags.widget.service'; @Controller('simple-kanban-board') export class SimpleKanbanBoardController { @@ -16,6 +17,7 @@ export class SimpleKanbanBoardController { private configService: ConfigService, private listIssuesByUsersWidgetService: ListIssuesByUsersWidgetService, private listIssuesByUsersLikeJiraWidgetService: ListIssuesByUsersLikeJiraWidgetService, + private issuesByTagsWidgetService: IssuesByTagsWidgetService, ) { this.path = this.configService.get('simpleKanbanBoard.path'); } @@ -67,4 +69,20 @@ export class SimpleKanbanBoardController { async getByUsersLikeJira(@Param('name') name: string): Promise { return await this.getByUsersLikeJiraRawData(name); } + + @Get('/by-tags/:name/raw') + async getByTagsRawData(@Param('name') name: string): Promise { + const cfg = this.dynamicLoader.load(name, { + path: this.path, + ext: 'jsonc', + parser: parse, + }); + return await this.issuesByTagsWidgetService.render(cfg); + } + + @Get('/by-tags/:name') + @Render('simple-kanban-board') + async getByTags(@Param('name') name: string): Promise { + return await this.getByTagsRawData(name); + } } diff --git a/src/dashboards/widgets/issues-by-tags.widget.service.ts b/src/dashboards/widgets/issues-by-tags.widget.service.ts new file mode 100644 index 0000000..c199eb1 --- /dev/null +++ b/src/dashboards/widgets/issues-by-tags.widget.service.ts @@ -0,0 +1,129 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +import { IssueUrlEnhancer } from '@app/event-emitter/issue-enhancers/issue-url-enhancer'; +import { TagStyledEnhancerNs } from '@app/event-emitter/issue-enhancers/tag-styled-enhancer'; +import { TimePassedHighlightEnhancer } from '@app/event-emitter/issue-enhancers/time-passed-highlight-enhancer'; +import { + IssuesService, + IssuesServiceNs, +} from '@app/event-emitter/issues/issues.service'; +import { WidgetInterface } from '@app/event-emitter/project-dashboard/widget-interface'; +import { FlatIssuesStore } from '@app/event-emitter/utils/flat-issues-store'; +import { GetValueFromObjectByKey } from '@app/event-emitter/utils/get-value-from-object-by-key'; +import { TreeIssuesStore } from '@app/event-emitter/utils/tree-issues-store'; +import { Injectable, Logger } from '@nestjs/common'; +import nano from 'nano'; + +export namespace IssuesByTagsWidgetNs { + export type Params = { + fromRootIssueId?: number; + fromQuery?: nano.MangoQuery; + tagsSort?: boolean; + showEmptyTags?: string; + statuses: string[]; + } & TagStyledEnhancerNs.ConfigWithTagsStyles; +} + +type Params = IssuesByTagsWidgetNs.Params; + +@Injectable() +export class IssuesByTagsWidgetService + implements WidgetInterface +{ + private logger = new Logger(IssuesByTagsWidgetService.name); + private issuesLoader: IssuesServiceNs.IssuesLoader; + + constructor( + private issuesService: IssuesService, + private timePassedHighlightEnhancer: TimePassedHighlightEnhancer, + private issueUrlEnhancer: IssueUrlEnhancer, + ) { + this.issuesLoader = this.issuesService.createDynamicIssuesLoader(); + } + + isMyConfig(): boolean { + return true; + } + + async render(widgetParams: Params): Promise { + let store: FlatIssuesStore; + if (widgetParams.fromRootIssueId) { + store = await this.getListFromRoot(widgetParams.fromRootIssueId); + } else if (widgetParams.fromQuery) { + store = await this.getListByQuery(widgetParams.fromQuery); + } else { + const errMsg = `Wrong widgetParams value`; + this.logger.error(errMsg); + throw new Error(errMsg); + } + await store.enhanceIssues([ + this.timePassedHighlightEnhancer, + this.issueUrlEnhancer, + TagStyledEnhancerNs.CreateTagStyledEnhancerForConfig(widgetParams), + ]); + const grouped = store.groupByStatusWithExtraToMultipleStories((issue) => { + if (!issue || !widgetParams.tags || !widgetParams.tags.tagsKeyName) { + return []; + } + const tagsResult = GetValueFromObjectByKey( + issue, + widgetParams.tags.tagsKeyName, + ); + if ( + (tagsResult.error == 'NOT_FOUND' || + (tagsResult.result && tagsResult.result.length <= 0)) && + widgetParams.showEmptyTags + ) { + return [widgetParams.showEmptyTags]; + } + if ( + typeof tagsResult.result !== 'object' || + tagsResult.result.length <= 0 + ) { + return []; + } + return tagsResult.result; + }, widgetParams.statuses); + let res = [] as any[]; + for (const tag in grouped) { + if (Object.prototype.hasOwnProperty.call(grouped, tag)) { + const data = grouped[tag]; + res.push({ + data: data, + metainfo: this.createMetaInfo(tag), + }); + } + } + if (widgetParams.tagsSort) { + res = res.sort((a, b) => { + return a.metainfo.title.localeCompare(b.metainfo.title); + }); + } + return res; + } + + private async getListFromRoot(issueId: number): Promise { + const treeStore = new TreeIssuesStore(); + const rootIssue = await this.issuesService.getIssue(issueId); + treeStore.setRootIssue(rootIssue); + await treeStore.fillData(this.issuesLoader); + return treeStore.getFlatStore(); + } + + private async getListByQuery( + query: nano.MangoQuery, + ): Promise { + const rawData = await this.issuesService.find(query); + const store = new FlatIssuesStore(); + for (let i = 0; i < rawData.length; i++) { + const issue = rawData[i]; + store.push(issue); + } + return store; + } + + private createMetaInfo(tag: string): Record { + return { + title: tag, + }; + } +} From fccdee5cbce42cfa4b6642b8cfb7be83a3d488ad Mon Sep 17 00:00:00 2001 From: Pavel Gnedov Date: Fri, 17 Feb 2023 02:37:31 +0700 Subject: [PATCH 24/30] =?UTF-8?q?=D0=9F=D1=80=D0=B8=D0=BD=D1=83=D0=B4?= =?UTF-8?q?=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=D0=B5=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE?= =?UTF-8?q?=D1=80=D0=BD=D0=B5=D0=B2=D1=8B=D1=85=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=20=D0=B4=D0=BB=D1=8F=20=D0=B4=D0=BE=D1=81=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B4=D0=B5=D1=80=D0=B5=D0=B2=D0=B5=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B4=D0=B0=D1=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../simple-kanban-board.controller.ts | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/dashboards/simple-kanban-board.controller.ts b/src/dashboards/simple-kanban-board.controller.ts index 09c1961..aafc98f 100644 --- a/src/dashboards/simple-kanban-board.controller.ts +++ b/src/dashboards/simple-kanban-board.controller.ts @@ -1,7 +1,11 @@ import { DynamicLoader } from '@app/event-emitter/configs/dynamic-loader'; +import { RedmineEventsGateway } from '@app/event-emitter/events/redmine-events.gateway'; import { ListIssuesByUsersLikeJiraWidgetService } from '@app/event-emitter/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service'; import { ListIssuesByUsersWidgetService } from '@app/event-emitter/project-dashboard/widgets/list-issues-by-users.widget.service'; -import { RootIssueSubTreesWidgetService } from '@app/event-emitter/project-dashboard/widgets/root-issue-subtrees.widget.service'; +import { + RootIssueSubTreesWidgetNs, + RootIssueSubTreesWidgetService, +} from '@app/event-emitter/project-dashboard/widgets/root-issue-subtrees.widget.service'; import { Controller, Get, Param, Render } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { parse } from 'jsonc-parser'; @@ -18,6 +22,7 @@ export class SimpleKanbanBoardController { private listIssuesByUsersWidgetService: ListIssuesByUsersWidgetService, private listIssuesByUsersLikeJiraWidgetService: ListIssuesByUsersLikeJiraWidgetService, private issuesByTagsWidgetService: IssuesByTagsWidgetService, + private redmineEventsGateway: RedmineEventsGateway, ) { this.path = this.configService.get('simpleKanbanBoard.path'); } @@ -38,6 +43,25 @@ export class SimpleKanbanBoardController { return await this.getRawData(name); } + @Get('/tree/:name/refresh') + async refreshTree(@Param('name') name: string): Promise { + const cfg = this.dynamicLoader.load(name, { + path: this.path, + ext: 'jsonc', + parser: parse, + }); + const issues = [] as number[]; + issues.push(cfg.rootIssueId); + if (cfg.groups) { + const groups = cfg.groups as RootIssueSubTreesWidgetNs.Models.GroupCfg; + groups.fromIssues.forEach((group) => { + issues.push(group.issueId); + }); + } + this.redmineEventsGateway.addIssues(issues); + return { success: true }; + } + @Get('/by-users/:name/raw') async getByUsersRawData(@Param('name') name: string): Promise { const cfg = this.dynamicLoader.load(name, { From e9d808a19db56768e19088354fdd4e3114e6ffaa Mon Sep 17 00:00:00 2001 From: Pavel Gnedov Date: Mon, 20 Feb 2023 01:54:44 +0700 Subject: [PATCH 25/30] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=B4=D0=BE=D1=81=D0=BA=D0=B0=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8=20=D0=BF=D0=BE=20=D0=BF=D0=BE=D0=BB=D1=8F?= =?UTF-8?q?=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event-emitter/src/event-emitter.module.ts | 3 + .../event-emitter/src/models/redmine-types.ts | 2 +- .../list-issues-by-fields.widget.service.ts | 133 ++++++++++++++++++ .../simple-kanban-board.controller.ts | 18 +++ 4 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 libs/event-emitter/src/project-dashboard/widgets/list-issues-by-fields.widget.service.ts diff --git a/libs/event-emitter/src/event-emitter.module.ts b/libs/event-emitter/src/event-emitter.module.ts index 60a308a..f5b16f2 100644 --- a/libs/event-emitter/src/event-emitter.module.ts +++ b/libs/event-emitter/src/event-emitter.module.ts @@ -26,6 +26,7 @@ import { IssueUrlEnhancer } from './issue-enhancers/issue-url-enhancer'; import { ListIssuesByUsersWidgetService } from './project-dashboard/widgets/list-issues-by-users.widget.service'; import { ListIssuesByUsersLikeJiraWidgetService } from './project-dashboard/widgets/list-issues-by-users-like-jira.widget.service'; import { TimePassedHighlightEnhancer } from './issue-enhancers/time-passed-highlight-enhancer'; +import { ListIssuesByFieldsWidgetService } from './project-dashboard/widgets/list-issues-by-fields.widget.service'; @Module({}) export class EventEmitterModule implements OnModuleInit { @@ -56,6 +57,7 @@ export class EventEmitterModule implements OnModuleInit { ListIssuesByUsersWidgetService, ListIssuesByUsersLikeJiraWidgetService, TimePassedHighlightEnhancer, + ListIssuesByFieldsWidgetService, ], exports: [ EventEmitterService, @@ -78,6 +80,7 @@ export class EventEmitterModule implements OnModuleInit { ListIssuesByUsersWidgetService, ListIssuesByUsersLikeJiraWidgetService, TimePassedHighlightEnhancer, + ListIssuesByFieldsWidgetService, ], controllers: [MainController, UsersController, IssuesController], }; diff --git a/libs/event-emitter/src/models/redmine-types.ts b/libs/event-emitter/src/models/redmine-types.ts index bbde0c7..00471c3 100644 --- a/libs/event-emitter/src/models/redmine-types.ts +++ b/libs/event-emitter/src/models/redmine-types.ts @@ -44,7 +44,7 @@ export module RedmineTypes { author: IdAndName; assigned_to?: IdAndName; category: IdAndName; - fixed_version: IdAndName; + fixed_version?: IdAndName; subject: string; description: string; start_date: string; diff --git a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-fields.widget.service.ts b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-fields.widget.service.ts new file mode 100644 index 0000000..59d3792 --- /dev/null +++ b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-fields.widget.service.ts @@ -0,0 +1,133 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +import { IssueUrlEnhancer } from '@app/event-emitter/issue-enhancers/issue-url-enhancer'; +import { TagStyledEnhancerNs } from '@app/event-emitter/issue-enhancers/tag-styled-enhancer'; +import { TimePassedHighlightEnhancer } from '@app/event-emitter/issue-enhancers/time-passed-highlight-enhancer'; +import { + IssuesService, + IssuesServiceNs, +} from '@app/event-emitter/issues/issues.service'; +import { RedmineTypes } from '@app/event-emitter/models/redmine-types'; +import { FlatIssuesStore } from '@app/event-emitter/utils/flat-issues-store'; +import { GetValueFromObjectByKey } from '@app/event-emitter/utils/get-value-from-object-by-key'; +import { TreeIssuesStore } from '@app/event-emitter/utils/tree-issues-store'; +import { Injectable, Logger } from '@nestjs/common'; +import nano from 'nano'; +import { WidgetInterface } from '../widget-interface'; + +export namespace ListIssuesByFieldsWidgetNs { + export type Params = { + fromRootIssueId?: number; + fromQuery?: nano.MangoQuery; + fields: Field[]; + delimiter: string; + sort?: boolean; + statuses: string[]; + } & TagStyledEnhancerNs.ConfigWithTagsStyles; + + export type Field = { + path: string; + default: string; + }; +} + +type Params = ListIssuesByFieldsWidgetNs.Params; + +@Injectable() +export class ListIssuesByFieldsWidgetService + implements WidgetInterface +{ + private logger = new Logger(ListIssuesByFieldsWidgetService.name); + private issuesLoader: IssuesServiceNs.IssuesLoader; + + constructor( + private issuesService: IssuesService, + private timePassedHighlightEnhancer: TimePassedHighlightEnhancer, + private issueUrlEnhancer: IssueUrlEnhancer, + ) { + this.issuesLoader = this.issuesService.createDynamicIssuesLoader(); + } + + isMyConfig(): boolean { + return true; + } + + async render(widgetParams: Params): Promise { + let store: FlatIssuesStore; + if (widgetParams.fromRootIssueId) { + store = await this.getListFromRoot(widgetParams.fromRootIssueId); + } else if (widgetParams.fromQuery) { + store = await this.getListByQuery(widgetParams.fromQuery); + } else { + const errMsg = `Wrong widgetParams value`; + this.logger.error(errMsg); + throw new Error(errMsg); + } + await store.enhanceIssues([ + this.timePassedHighlightEnhancer, + this.issueUrlEnhancer, + TagStyledEnhancerNs.CreateTagStyledEnhancerForConfig(widgetParams), + ]); + const grouped = store.groupByStatusWithExtra((issue) => { + return this.getGroupFromIssue(issue, widgetParams); + }, widgetParams.statuses); + let res = [] as any[]; + for (const key in grouped) { + if (Object.prototype.hasOwnProperty.call(grouped, key)) { + const data = grouped[key]; + res.push({ + data: data, + metainfo: this.createMetaInfo(key), + }); + } + } + if (widgetParams.sort) { + res = res.sort((a, b) => { + return a.metainfo.title.localeCompare(b.metainfo.title); + }); + } + return res; + } + + private async getListFromRoot(issueId: number): Promise { + const treeStore = new TreeIssuesStore(); + const rootIssue = await this.issuesService.getIssue(issueId); + treeStore.setRootIssue(rootIssue); + await treeStore.fillData(this.issuesLoader); + return treeStore.getFlatStore(); + } + + private async getListByQuery( + query: nano.MangoQuery, + ): Promise { + const rawData = await this.issuesService.find(query); + const store = new FlatIssuesStore(); + for (let i = 0; i < rawData.length; i++) { + const issue = rawData[i]; + store.push(issue); + } + return store; + } + + private getGroupFromIssue( + issue: RedmineTypes.ExtendedIssue, + params: Params, + ): string | null { + if (!issue) return null; + const values = [] as string[]; + for (let i = 0; i < params.fields.length; i++) { + const field = params.fields[i]; + const valueResult = GetValueFromObjectByKey(issue, field.path); + const value: string = valueResult.result + ? valueResult.result + : field.default; + values.push(value); + } + return values.join(params.delimiter); + } + + private createMetaInfo(title: string): Record { + return { + title: title, + }; + } +} diff --git a/src/dashboards/simple-kanban-board.controller.ts b/src/dashboards/simple-kanban-board.controller.ts index aafc98f..867b11f 100644 --- a/src/dashboards/simple-kanban-board.controller.ts +++ b/src/dashboards/simple-kanban-board.controller.ts @@ -1,5 +1,6 @@ import { DynamicLoader } from '@app/event-emitter/configs/dynamic-loader'; import { RedmineEventsGateway } from '@app/event-emitter/events/redmine-events.gateway'; +import { ListIssuesByFieldsWidgetService } from '@app/event-emitter/project-dashboard/widgets/list-issues-by-fields.widget.service'; import { ListIssuesByUsersLikeJiraWidgetService } from '@app/event-emitter/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service'; import { ListIssuesByUsersWidgetService } from '@app/event-emitter/project-dashboard/widgets/list-issues-by-users.widget.service'; import { @@ -23,6 +24,7 @@ export class SimpleKanbanBoardController { private listIssuesByUsersLikeJiraWidgetService: ListIssuesByUsersLikeJiraWidgetService, private issuesByTagsWidgetService: IssuesByTagsWidgetService, private redmineEventsGateway: RedmineEventsGateway, + private listIssuesByFieldsWidgetService: ListIssuesByFieldsWidgetService, ) { this.path = this.configService.get('simpleKanbanBoard.path'); } @@ -109,4 +111,20 @@ export class SimpleKanbanBoardController { async getByTags(@Param('name') name: string): Promise { return await this.getByTagsRawData(name); } + + @Get('/by-fields/:name/raw') + async getByFieldsRawData(@Param('name') name: string): Promise { + const cfg = this.dynamicLoader.load(name, { + path: this.path, + ext: 'jsonc', + parser: parse, + }); + return await this.listIssuesByFieldsWidgetService.render(cfg); + } + + @Get('/by-fields/:name') + @Render('simple-kanban-board') + async getByFields(@Param('name') name: string): Promise { + return await this.getByFieldsRawData(name); + } } From 9568b12280bc83db13e88c4bc17fc9ae75f6240b Mon Sep 17 00:00:00 2001 From: Pavel Gnedov Date: Mon, 20 Feb 2023 17:53:00 +0700 Subject: [PATCH 26/30] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B4=D0=B5=D1=80=D0=B5=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/issues/issues.service.ts | 11 ++++++++++ .../src/utils/tree-issues-store.ts | 21 +++++++++++++++++++ .../simple-kanban-board.controller.ts | 20 +++++++++--------- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/libs/event-emitter/src/issues/issues.service.ts b/libs/event-emitter/src/issues/issues.service.ts index 3795622..2cc5ef1 100644 --- a/libs/event-emitter/src/issues/issues.service.ts +++ b/libs/event-emitter/src/issues/issues.service.ts @@ -9,6 +9,7 @@ import { MemoryCache } from '../utils/memory-cache'; import nano from 'nano'; import { UNLIMITED } from '../consts/consts'; import { GetParentsHint } from '../utils/get-parents-hint'; +import { TreeIssuesStore } from '../utils/tree-issues-store'; export const ISSUE_MEMORY_CACHE_LIFETIME = 30 * 1000; const ISSUE_MEMORY_CACHE_AUTOCLEAN_INTERVAL = 1000 * 60 * 5; @@ -165,4 +166,14 @@ export class IssuesService { }; return fn; } + + async getIssuesWithChildren( + rootIssue: RedmineTypes.Issue, + ): Promise { + const treeIssuesStore = new TreeIssuesStore(); + treeIssuesStore.setRootIssue(rootIssue); + const loader = this.createDynamicIssuesLoader(); + await treeIssuesStore.fillData(loader); + return treeIssuesStore.getIssuesWithChildren(); + } } diff --git a/libs/event-emitter/src/utils/tree-issues-store.ts b/libs/event-emitter/src/utils/tree-issues-store.ts index 99c5579..0425045 100644 --- a/libs/event-emitter/src/utils/tree-issues-store.ts +++ b/libs/event-emitter/src/utils/tree-issues-store.ts @@ -189,4 +189,25 @@ export class TreeIssuesStore { } return stories; } + + getIssuesWithChildren(): RedmineTypes.Issue[] { + return this.fillIssuesWithChildren(this.rootIssue, []); + } + + private fillIssuesWithChildren( + issue: RedmineTypes.Issue, + data: RedmineTypes.Issue[], + ): RedmineTypes.Issue[] { + if (!issue || !issue.children || issue.children.length <= 0) return; + for (let i = 0; i < issue.children.length; i++) { + const childIssueResult = this.getFlatStore().getIssue( + issue.children[i].id, + ); + if (!childIssueResult || !childIssueResult.data) continue; + const childIssue = childIssueResult.data; + data.push(childIssue); + this.fillIssuesWithChildren(childIssue, data); + } + return data; + } } diff --git a/src/dashboards/simple-kanban-board.controller.ts b/src/dashboards/simple-kanban-board.controller.ts index 867b11f..c372cbf 100644 --- a/src/dashboards/simple-kanban-board.controller.ts +++ b/src/dashboards/simple-kanban-board.controller.ts @@ -1,5 +1,6 @@ import { DynamicLoader } from '@app/event-emitter/configs/dynamic-loader'; import { RedmineEventsGateway } from '@app/event-emitter/events/redmine-events.gateway'; +import { IssuesService } from '@app/event-emitter/issues/issues.service'; import { ListIssuesByFieldsWidgetService } from '@app/event-emitter/project-dashboard/widgets/list-issues-by-fields.widget.service'; import { ListIssuesByUsersLikeJiraWidgetService } from '@app/event-emitter/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service'; import { ListIssuesByUsersWidgetService } from '@app/event-emitter/project-dashboard/widgets/list-issues-by-users.widget.service'; @@ -7,13 +8,15 @@ import { RootIssueSubTreesWidgetNs, RootIssueSubTreesWidgetService, } from '@app/event-emitter/project-dashboard/widgets/root-issue-subtrees.widget.service'; -import { Controller, Get, Param, Render } from '@nestjs/common'; +import { TreeIssuesStore } from '@app/event-emitter/utils/tree-issues-store'; +import { Controller, Get, Logger, Param, Render } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { parse } from 'jsonc-parser'; import { IssuesByTagsWidgetService } from './widgets/issues-by-tags.widget.service'; @Controller('simple-kanban-board') export class SimpleKanbanBoardController { + private logger = new Logger(SimpleKanbanBoardController.name); private path: string; constructor( @@ -25,6 +28,7 @@ export class SimpleKanbanBoardController { private issuesByTagsWidgetService: IssuesByTagsWidgetService, private redmineEventsGateway: RedmineEventsGateway, private listIssuesByFieldsWidgetService: ListIssuesByFieldsWidgetService, + private issuesService: IssuesService, ) { this.path = this.configService.get('simpleKanbanBoard.path'); } @@ -52,15 +56,11 @@ export class SimpleKanbanBoardController { ext: 'jsonc', parser: parse, }); - const issues = [] as number[]; - issues.push(cfg.rootIssueId); - if (cfg.groups) { - const groups = cfg.groups as RootIssueSubTreesWidgetNs.Models.GroupCfg; - groups.fromIssues.forEach((group) => { - issues.push(group.issueId); - }); - } - this.redmineEventsGateway.addIssues(issues); + const rootIssue = await this.issuesService.getIssueFromCache( + cfg.rootIssueId, + ); + const issues = await this.issuesService.getIssuesWithChildren(rootIssue); + this.logger.debug(`Issues for tree refresh - ${issues}`); // DEBUG return { success: true }; } From 82b63ef02e285eaeb4070e6e783c9da48b69208e Mon Sep 17 00:00:00 2001 From: Gnedov Pavel Date: Mon, 20 Feb 2023 17:59:47 +0700 Subject: [PATCH 27/30] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B4=D0=B5=D1=80=D0=B5=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- libs/event-emitter/src/utils/tree-issues-store.ts | 2 +- src/dashboards/simple-kanban-board.controller.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/libs/event-emitter/src/utils/tree-issues-store.ts b/libs/event-emitter/src/utils/tree-issues-store.ts index 0425045..53258e3 100644 --- a/libs/event-emitter/src/utils/tree-issues-store.ts +++ b/libs/event-emitter/src/utils/tree-issues-store.ts @@ -199,13 +199,13 @@ export class TreeIssuesStore { data: RedmineTypes.Issue[], ): RedmineTypes.Issue[] { if (!issue || !issue.children || issue.children.length <= 0) return; + data.push(issue); for (let i = 0; i < issue.children.length; i++) { const childIssueResult = this.getFlatStore().getIssue( issue.children[i].id, ); if (!childIssueResult || !childIssueResult.data) continue; const childIssue = childIssueResult.data; - data.push(childIssue); this.fillIssuesWithChildren(childIssue, data); } return data; diff --git a/src/dashboards/simple-kanban-board.controller.ts b/src/dashboards/simple-kanban-board.controller.ts index c372cbf..0bed6b5 100644 --- a/src/dashboards/simple-kanban-board.controller.ts +++ b/src/dashboards/simple-kanban-board.controller.ts @@ -60,7 +60,9 @@ export class SimpleKanbanBoardController { cfg.rootIssueId, ); const issues = await this.issuesService.getIssuesWithChildren(rootIssue); - this.logger.debug(`Issues for tree refresh - ${issues}`); // DEBUG + const issuesIds = issues.map((i) => i.id); + this.logger.debug(`Issues for tree refresh - ${issuesIds}`); + this.redmineEventsGateway.addIssues(issuesIds); return { success: true }; } From 6d5293f85a38c6e42e5dc9b14d0ad9b3867bf9cb Mon Sep 17 00:00:00 2001 From: Pavel Gnedov Date: Wed, 1 Mar 2023 15:02:29 +0700 Subject: [PATCH 28/30] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D1=8F=D0=BA=D0=BE=D1=80=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- views/simple-kanban-board.hbs | 50 ++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/views/simple-kanban-board.hbs b/views/simple-kanban-board.hbs index c26e710..377130b 100644 --- a/views/simple-kanban-board.hbs +++ b/views/simple-kanban-board.hbs @@ -72,30 +72,32 @@ {{#each this}} -

{{this.metainfo.title}}

-
- {{#each this.data}} -
-
{{this.status}}
- {{#each this.issues}} -
- -
Исп.: {{this.current_user.name}}
-
Прогресс: {{this.done_ration}}
-
Трудозатраты: {{this.total_spent_hours}} / {{this.total_estimated_hours}}
- {{#if this.styledTags}} -
- Tags: - {{#each this.styledTags}} - {{this.tag}} - {{/each}} -
- {{/if}} -
- {{/each}} -
- {{/each}} -
+ {{#if this.metainfo}} +

{{this.metainfo.title}} #

+
+ {{#each this.data}} +
+
{{this.status}}
+ {{#each this.issues}} +
+ +
Исп.: {{this.current_user.name}}
+
Прогресс: {{this.done_ration}}
+
Трудозатраты: {{this.total_spent_hours}} / {{this.total_estimated_hours}}
+ {{#if this.styledTags}} +
+ Tags: + {{#each this.styledTags}} + {{this.tag}} + {{/each}} +
+ {{/if}} +
+ {{/each}} +
+ {{/each}} +
+ {{/if}} {{/each}} \ No newline at end of file From e447b381e3797e8564517303badda5dca2a211e1 Mon Sep 17 00:00:00 2001 From: Pavel Gnedov Date: Fri, 3 Mar 2023 08:08:31 +0700 Subject: [PATCH 29/30] =?UTF-8?q?=D0=9A=D0=B0=D1=82=D0=B5=D0=B3=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=B8=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D1=8F?= =?UTF-8?q?=D1=8E=D1=82=D1=81=D1=8F=20=D0=BA=20=D1=82=D0=B5=D0=B3=D0=B0?= =?UTF-8?q?=D0=BC=20=D0=B2=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B5=20?= =?UTF-8?q?eccm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.module.ts | 25 ++++++++++++-- .../category-merge-to-tags-enhancer.ts | 34 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 src/issue-enhancers/category-merge-to-tags-enhancer.ts diff --git a/src/app.module.ts b/src/app.module.ts index 5ae2661..a6f0289 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -3,8 +3,14 @@ import { RedmineIssuesCacheWriterService } from '@app/event-emitter/issue-cache- import { EnhancerService } from '@app/event-emitter/issue-enhancers/enhancer.service'; 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 { + CacheModule, + Inject, + Logger, + Module, + OnModuleInit, +} from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; import { switchMap, tap } from 'rxjs'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @@ -38,6 +44,7 @@ import { DailyEccmWithExtraDataService } from './reports/daily-eccm-with-extra-d import { SimpleKanbanBoardController } from './dashboards/simple-kanban-board.controller'; import { IssueUrlEnhancer } from '@app/event-emitter/issue-enhancers/issue-url-enhancer'; import { IssuesByTagsWidgetService } from './dashboards/widgets/issues-by-tags.widget.service'; +import { CategoryMergeToTagsEnhancer } from './issue-enhancers/category-merge-to-tags-enhancer'; @Module({ imports: [ @@ -81,6 +88,16 @@ import { IssuesByTagsWidgetService } from './dashboards/widgets/issues-by-tags.w SetDailyEccmUserCommentBotHandlerService, DailyEccmWithExtraDataService, IssuesByTagsWidgetService, + { + provide: 'CATEGORY_MERGE_TO_TAGS_ENHANCER', + useFactory: (configService: ConfigService) => { + const eccmProjectName = configService.get( + 'redmineEccm.projectName', + ); + return new CategoryMergeToTagsEnhancer([eccmProjectName]); + }, + inject: [ConfigService], + }, ], }) export class AppModule implements OnModuleInit { @@ -101,6 +118,9 @@ export class AppModule implements OnModuleInit { private statusChangeAdapterService: StatusChangeAdapterService, private dailyEccmUserCommentsService: DailyEccmUserCommentsService, private setDailyEccmUserCommentBotHandlerService: SetDailyEccmUserCommentBotHandlerService, + + @Inject('CATEGORY_MERGE_TO_TAGS_ENHANCER') + private categoryMergeToTagsEnhancer: CategoryMergeToTagsEnhancer, ) {} onModuleInit() { @@ -116,6 +136,7 @@ export class AppModule implements OnModuleInit { this.customFieldsEnhancer, this.currentUserEnhancer, this.issueUrlEnhancer, + this.categoryMergeToTagsEnhancer, ]); this.personalNotificationsService.$messages.subscribe((resp) => { diff --git a/src/issue-enhancers/category-merge-to-tags-enhancer.ts b/src/issue-enhancers/category-merge-to-tags-enhancer.ts new file mode 100644 index 0000000..f6ef88d --- /dev/null +++ b/src/issue-enhancers/category-merge-to-tags-enhancer.ts @@ -0,0 +1,34 @@ +import { IssueEnhancerInterface } from '@app/event-emitter/issue-enhancers/issue-enhancer-interface'; +import { RedmineTypes } from '@app/event-emitter/models/redmine-types'; +import { Injectable, Logger } from '@nestjs/common'; + +@Injectable() +export class CategoryMergeToTagsEnhancer implements IssueEnhancerInterface { + name = 'category-merge-to-tags'; + + private logger = new Logger(CategoryMergeToTagsEnhancer.name); + + constructor(private forProjects: string[]) { + this.logger.debug(`Enhancer created for ${forProjects}`); + } + + async enhance( + issue: RedmineTypes.ExtendedIssue, + ): Promise { + if (this.forProjects.indexOf(issue.project.name) < 0) { + return issue; + } + if (!issue.tags || !this.isArray(issue.tags)) { + issue.tags = []; + } + const category = issue.category.name.toLowerCase().replaceAll(' ', ''); + if (issue.tags.indexOf(category) < 0) { + issue.tags.push(category); + } + return issue; + } + + private isArray(a: any): boolean { + return typeof a == 'object' && typeof a.length === 'number'; + } +} From 277815d1eb561aa1140237eb45dc74a1614a6834 Mon Sep 17 00:00:00 2001 From: Gnedov Pavel Date: Fri, 3 Mar 2023 09:34:46 +0700 Subject: [PATCH 30/30] =?UTF-8?q?=D0=9A=D0=B0=D1=82=D0=B5=D0=B3=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=B8=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D1=8F?= =?UTF-8?q?=D1=8E=D1=82=D1=81=D1=8F=20=D0=BA=20=D1=82=D0=B5=D0=B3=D0=B0?= =?UTF-8?q?=D0=BC=20=D0=B2=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B5=20?= =?UTF-8?q?eccm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/issue-enhancers/category-merge-to-tags-enhancer.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/issue-enhancers/category-merge-to-tags-enhancer.ts b/src/issue-enhancers/category-merge-to-tags-enhancer.ts index f6ef88d..989975e 100644 --- a/src/issue-enhancers/category-merge-to-tags-enhancer.ts +++ b/src/issue-enhancers/category-merge-to-tags-enhancer.ts @@ -15,14 +15,14 @@ export class CategoryMergeToTagsEnhancer implements IssueEnhancerInterface { async enhance( issue: RedmineTypes.ExtendedIssue, ): Promise { - if (this.forProjects.indexOf(issue.project.name) < 0) { + if (!issue || !issue?.project?.name || this.forProjects.indexOf(issue.project.name) < 0) { return issue; } if (!issue.tags || !this.isArray(issue.tags)) { issue.tags = []; } - const category = issue.category.name.toLowerCase().replaceAll(' ', ''); - if (issue.tags.indexOf(category) < 0) { + const category = issue?.category?.name?.toLowerCase()?.replaceAll(' ', '_'); + if (category && issue.tags.indexOf(category) < 0) { issue.tags.push(category); } return issue;