From 3a3366e10f22d395fcad0e2486194d4795064eca Mon Sep 17 00:00:00 2001 From: Gnedov Pavel Date: Fri, 17 Mar 2023 15:00:33 +0700 Subject: [PATCH 1/4] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=B2=D1=8B=D0=B2=D0=BE=D0=B4=20=D0=B4=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BE=20=D0=BF=D1=80=D0=BE=D0=B3?= =?UTF-8?q?=D1=80=D0=B5=D1=81=D1=81=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- views/simple-kanban-board.hbs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/views/simple-kanban-board.hbs b/views/simple-kanban-board.hbs index 29c397a..a867939 100644 --- a/views/simple-kanban-board.hbs +++ b/views/simple-kanban-board.hbs @@ -82,7 +82,7 @@
Исп.: {{this.current_user.name}}
-
Прогресс: {{this.done_ration}}
+
Прогресс: {{this.done_ratio}}
Трудозатраты: {{this.total_spent_hours}} / {{this.total_estimated_hours}}
{{#if this.styledTags}}
From 2e33e6b6658cd899ef41cef0b5abd2d95eadbf8b Mon Sep 17 00:00:00 2001 From: Pavel Gnedov Date: Mon, 27 Mar 2023 12:02:34 +0700 Subject: [PATCH 2/4] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=BE=20=D0=BF=D1=80=D0=B5=D0=B4=D1=81=D1=82=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87?= =?UTF-8?q?=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B0=D0=BC=D0=B8=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D1=82=D0=B5=D0=B3=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-issues-list.controller.ts | 34 ++++++ views/simple-issues-list.hbs | 114 ++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 src/dashboards/simple-issues-list.controller.ts create mode 100644 views/simple-issues-list.hbs diff --git a/src/app.module.ts b/src/app.module.ts index 6f28444..04c5bd6 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -45,6 +45,7 @@ import { SimpleKanbanBoardController } from './dashboards/simple-kanban-board.co 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'; +import { SimpleIssuesListController } from './dashboards/simple-issues-list.controller'; @Module({ imports: [ @@ -63,6 +64,7 @@ import { CategoryMergeToTagsEnhancer } from './issue-enhancers/category-merge-to CurrentIssuesEccmReportController, DailyEccmReportController, SimpleKanbanBoardController, + SimpleIssuesListController, ], providers: [ AppService, diff --git a/src/dashboards/simple-issues-list.controller.ts b/src/dashboards/simple-issues-list.controller.ts new file mode 100644 index 0000000..5a91041 --- /dev/null +++ b/src/dashboards/simple-issues-list.controller.ts @@ -0,0 +1,34 @@ +import { DynamicLoader } from '@app/event-emitter/configs/dynamic-loader'; +import { Controller, Get, Param, Render } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { IssuesByTagsWidgetService } from './widgets/issues-by-tags.widget.service'; +import { parse } from 'jsonc-parser'; + +@Controller('simple-issues-list') +export class SimpleIssuesListController { + private path: string; + + constructor( + private issuesByTagsWidgetService: IssuesByTagsWidgetService, + private dynamicLoader: DynamicLoader, + private configService: ConfigService, + ) { + this.path = this.configService.get('simpleKanbanBoard.path'); + } + + @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-issues-list') + async getByTags(@Param('name') name: string): Promise { + return await this.getByTagsRawData(name); + } +} diff --git a/views/simple-issues-list.hbs b/views/simple-issues-list.hbs new file mode 100644 index 0000000..6fe5b00 --- /dev/null +++ b/views/simple-issues-list.hbs @@ -0,0 +1,114 @@ + + + + + + Simple Issues List + + + + + {{#each this}} + {{#if this.metainfo}} +

{{this.metainfo.title}} #

+
+ {{#each this.data}} + + {{#each this.issues}} +
+
+ + {{this.tracker.name}} #{{this.id}} - {{this.subject}} + | {{this.status.name}} + | {{this.total_spent_hours}} / {{this.total_estimated_hours}} +
+
+ {{#if this.styledTags}} + {{#each this.styledTags}} + {{this.tag}} + {{/each}} + {{/if}} +
+
+ {{/each}} + + {{/each}} +
+ {{/if}} + {{/each}} + + + \ No newline at end of file From b0a65d56efd233d45eac48908e9f3752f76022f4 Mon Sep 17 00:00:00 2001 From: Pavel Gnedov Date: Thu, 6 Apr 2023 17:16:49 +0700 Subject: [PATCH 3/4] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20tag-manager=20=D0=B4=D0=BB=D1=8F=20=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=BF=D0=BF=D0=BE=D0=B2=D0=BE=D0=B9=20=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=BA=D0=B8=20=D1=82=D0=B5=D0=B3=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- configs/main-config.jsonc.dist | 7 +- .../event-emitter/src/event-emitter.module.ts | 16 +- .../issues-updater/issues-updater.service.ts | 42 +++ libs/event-emitter/src/queue/queue.ts | 5 + src/app.module.ts | 4 + src/models/app-config.model.ts | 5 + src/tags-manager/tags-manager.controller.ts | 23 ++ src/tags-manager/tags-manager.service.ts | 268 ++++++++++++++++++ 8 files changed, 368 insertions(+), 2 deletions(-) create mode 100644 libs/event-emitter/src/issues-updater/issues-updater.service.ts create mode 100644 src/tags-manager/tags-manager.controller.ts create mode 100644 src/tags-manager/tags-manager.service.ts diff --git a/configs/main-config.jsonc.dist b/configs/main-config.jsonc.dist index 4d5c643..e51d60d 100644 --- a/configs/main-config.jsonc.dist +++ b/configs/main-config.jsonc.dist @@ -9,5 +9,10 @@ }, "telegramBotToken": "", "personalMessageTemplate": "", - "periodValidityNotification": 43200 // 12h + "periodValidityNotification": 43200000, // 12h + "tagManager": { + "updateInterval": 15000, + "updateItemsLimit": 3, + "tagsCustomFieldName": "Tags" + } } \ No newline at end of file diff --git a/libs/event-emitter/src/event-emitter.module.ts b/libs/event-emitter/src/event-emitter.module.ts index f5b16f2..679e6a3 100644 --- a/libs/event-emitter/src/event-emitter.module.ts +++ b/libs/event-emitter/src/event-emitter.module.ts @@ -2,7 +2,7 @@ import { DynamicModule, Logger, Module, OnModuleInit } from '@nestjs/common'; import { EventEmitterService } from './event-emitter.service'; import { RedmineEventsGateway } from './events/redmine-events.gateway'; import MainConfig from './configs/main-config'; -import { ConfigModule } from '@nestjs/config'; +import { ConfigModule, ConfigService } from '@nestjs/config'; import { RedmineDataLoader } from './redmine-data-loader/redmine-data-loader'; import { MainController } from './main/main.controller'; import { ModuleParams } from './models/module-params'; @@ -27,6 +27,7 @@ import { ListIssuesByUsersWidgetService } from './project-dashboard/widgets/list 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'; +import { IssuesUpdaterService } from './issues-updater/issues-updater.service'; @Module({}) export class EventEmitterModule implements OnModuleInit { @@ -58,6 +59,15 @@ export class EventEmitterModule implements OnModuleInit { ListIssuesByUsersLikeJiraWidgetService, TimePassedHighlightEnhancer, ListIssuesByFieldsWidgetService, + { + provide: 'ISSUES_UPDATER_SERVICE', + useFactory: (configService: ConfigService) => { + const redminePublicUrl = + configService.get('redmineUrlPublic'); + return new IssuesUpdaterService(redminePublicUrl); + }, + inject: [ConfigService], + }, ], exports: [ EventEmitterService, @@ -81,6 +91,10 @@ export class EventEmitterModule implements OnModuleInit { ListIssuesByUsersLikeJiraWidgetService, TimePassedHighlightEnhancer, ListIssuesByFieldsWidgetService, + { + provide: 'ISSUES_UPDATER_SERVICE', + useExisting: 'ISSUES_UPDATER_SERVICE', + }, ], controllers: [MainController, UsersController, IssuesController], }; diff --git a/libs/event-emitter/src/issues-updater/issues-updater.service.ts b/libs/event-emitter/src/issues-updater/issues-updater.service.ts new file mode 100644 index 0000000..70e3324 --- /dev/null +++ b/libs/event-emitter/src/issues-updater/issues-updater.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; +import axios from 'axios'; + +class SimpleIssueUpdater { + constructor( + private userApiKey: string, + private updater: IssuesUpdaterService, + ) {} + + async updateIssue( + issueId: number, + issue: Record, + ): Promise { + return await this.updater.updateIssue(issueId, issue, this.userApiKey); + } +} + +@Injectable() +export class IssuesUpdaterService { + constructor(private redminePublicUrl: string) {} + + createSimpleUpdater(userApiKey: string): SimpleIssueUpdater { + return new SimpleIssueUpdater(userApiKey, this); + } + + async updateIssue( + issueId: number, + issue: Record, + userApiKey: string, + ): Promise { + const url = this.getUrl(issueId); + const data = { issue: issue }; + const resp = await axios.put(url, data, { + headers: { 'X-Redmine-API-Key': userApiKey }, + }); + return Boolean(resp); + } + + private getUrl(issueId: number): string { + return `${this.redminePublicUrl}/issues/${issueId}.json`; + } +} diff --git a/libs/event-emitter/src/queue/queue.ts b/libs/event-emitter/src/queue/queue.ts index c217e80..a877816 100644 --- a/libs/event-emitter/src/queue/queue.ts +++ b/libs/event-emitter/src/queue/queue.ts @@ -5,6 +5,8 @@ export class Queue { queue: Subject = new Subject(); + finished: Subject = new Subject(); + constructor( private updateInterval: number, private itemsLimit: number, @@ -43,6 +45,9 @@ export class Queue { const items = this.items.splice(0, this.itemsLimit); const transformedItems = await this.transformationFn(items); this.queue.next(transformedItems); + if (this.items.length <= 0) { + this.finished.next(true); + } } this.updateTimeout = setTimeout(() => { this.update(); diff --git a/src/app.module.ts b/src/app.module.ts index 04c5bd6..35bf682 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -46,6 +46,8 @@ import { IssueUrlEnhancer } from '@app/event-emitter/issue-enhancers/issue-url-e import { IssuesByTagsWidgetService } from './dashboards/widgets/issues-by-tags.widget.service'; import { CategoryMergeToTagsEnhancer } from './issue-enhancers/category-merge-to-tags-enhancer'; import { SimpleIssuesListController } from './dashboards/simple-issues-list.controller'; +import { TagsManagerController } from './tags-manager/tags-manager.controller'; +import { CreateTagManagerServiceProvider } from './tags-manager/tags-manager.service'; @Module({ imports: [ @@ -65,6 +67,7 @@ import { SimpleIssuesListController } from './dashboards/simple-issues-list.cont DailyEccmReportController, SimpleKanbanBoardController, SimpleIssuesListController, + TagsManagerController, ], providers: [ AppService, @@ -100,6 +103,7 @@ import { SimpleIssuesListController } from './dashboards/simple-issues-list.cont }, inject: [ConfigService], }, + CreateTagManagerServiceProvider('TAG_MANAGER_SERVICE'), ], }) export class AppModule implements OnModuleInit { diff --git a/src/models/app-config.model.ts b/src/models/app-config.model.ts index 4ff91ba..38f3873 100644 --- a/src/models/app-config.model.ts +++ b/src/models/app-config.model.ts @@ -21,4 +21,9 @@ export type AppConfig = { }; telegramBotToken: string; periodValidityNotification: number; + tagManager: { + updateInterval: number; + updateItemsLimit: number; + tagsCustomFieldName: string; + }; }; diff --git a/src/tags-manager/tags-manager.controller.ts b/src/tags-manager/tags-manager.controller.ts new file mode 100644 index 0000000..85a0ee1 --- /dev/null +++ b/src/tags-manager/tags-manager.controller.ts @@ -0,0 +1,23 @@ +import { Body, Controller, Inject, Post } from '@nestjs/common'; +import { TagsManagerService, UpdateRule } from './tags-manager.service'; + +type UpdateParams = { + userApiKey: string; + updateRules: UpdateRule[]; +}; + +@Controller('tags-manager') +export class TagsManagerController { + constructor( + @Inject('TAG_MANAGER_SERVICE') + private tagsManagerService: TagsManagerService, + ) {} + + @Post('/update') + async update(@Body() params: UpdateParams): Promise { + return await this.tagsManagerService.updateTags( + params.userApiKey, + params.updateRules, + ); + } +} diff --git a/src/tags-manager/tags-manager.service.ts b/src/tags-manager/tags-manager.service.ts new file mode 100644 index 0000000..5ee5ea4 --- /dev/null +++ b/src/tags-manager/tags-manager.service.ts @@ -0,0 +1,268 @@ +import { IssuesUpdaterService } from '@app/event-emitter/issues-updater/issues-updater.service'; +import { IssuesService } from '@app/event-emitter/issues/issues.service'; +import { RedmineTypes } from '@app/event-emitter/models/redmine-types'; +import { Queue } from '@app/event-emitter/queue/queue'; +import { FlatIssuesStore } from '@app/event-emitter/utils/flat-issues-store'; +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export type UpdateRule = { + issueIds: number[]; + add: string[]; + remove: string[]; +}; + +type UpdateRuleSingleIssue = { + issueId: number; + issueData: RedmineTypes.Issue | null; + userApiKey: string; + add: string[]; + remove: string[]; +}; + +type ModifyResult = { + value: string; + modified: boolean; +}; + +type ModifyCustomField = { + field?: RedmineTypes.CustomField; + modified?: boolean; + success: boolean; +}; + +type UpdateResult = { + issue?: RedmineTypes.Issue | null; + delta?: Record; + modified?: boolean; + success: boolean; +}; + +export function CreateTagManagerServiceProvider(providerName: string): any { + return { + provide: providerName, + useFactory: ( + configService: ConfigService, + issuesUpdaterService: IssuesUpdaterService, + issuesService: IssuesService, + ) => { + const updateInverval = configService.get( + 'tagManager.updateInverval', + ); + const updateItemsLimit = configService.get( + 'tagManager.updateItemsLimit', + ); + const tagsCustomFieldName = configService.get( + 'tagManager.tagsCustomFieldName', + ); + return new TagsManagerService( + issuesUpdaterService, + updateInverval, + updateItemsLimit, + tagsCustomFieldName, + issuesService, + ); + }, + inject: [ConfigService, 'ISSUES_UPDATER_SERVICE', IssuesService], + }; +} + +@Injectable() +export class TagsManagerService { + private logger = new Logger(TagsManagerService.name); + private queue: Queue; + + constructor( + private issuesUpdaterService: IssuesUpdaterService, + private updateInterval: number, + private updateItemsLimit: number, + private tagsCustomFieldName: string, + private issuesService: IssuesService, + ) {} + + async updateTags( + userApiKey: string, + updateRules: UpdateRule[], + ): Promise { + this.logger.debug(`Params for tags updates - ${updateRules}`); + const rules = await this.createUpdateRulesSingleIssue( + userApiKey, + updateRules, + ); + const queue = this.getQueue(); + queue.add(rules); + } + + private getQueue(): Queue { + if (!this.queue) { + this.queue = new Queue( + this.updateInterval, + this.updateItemsLimit, + async (rules: UpdateRuleSingleIssue[]) => { + const results = [] as UpdateResult[]; + for (let i = 0; i < rules.length; i++) { + const rule = rules[i]; + let result: UpdateResult; + try { + result = await this.updateIssue(rule); + } catch (ex) { + this.logger.error( + `Error at execution update task for issueId = ${rule.issueId}, ` + + `add = ${JSON.stringify(rule.add)}, ` + + `remove = ${JSON.stringify(rule.remove)}, ` + + `error = ${ex.message}`, + ); + result = { success: false }; + } + results.push(result); + } + return results; + }, + ); + this.queue.start(); + } + return this.queue; + } + + private async updateIssue( + rule: UpdateRuleSingleIssue, + ): Promise { + if (!rule.issueData) { + return { + success: false, + }; + } + if (rule.add.length == 0 && rule.remove.length == 0) { + return { + success: true, + delta: {}, + issue: rule.issueData, + modified: false, + }; + } + const result = this.modifyTagsForIssue(rule); + if (result.modified) { + const delta = { custom_fields: [result.field] }; + const updateResult = await this.issuesUpdaterService.updateIssue( + rule.issueId, + delta, + rule.userApiKey, + ); + return { + success: updateResult, + delta: delta, + issue: rule.issueData, + modified: result.modified, + }; + } else { + return { + success: true, + delta: {}, + issue: rule.issueData, + modified: result.modified, + }; + } + } + + private async createIssuesStore( + updateRules: UpdateRule[], + ): Promise { + const issuesStore = new FlatIssuesStore(); + for (let i = 0; i < updateRules.length; i++) { + const updateRule = updateRules[i]; + for (let j = 0; j < updateRule.issueIds.length; j++) { + const issueId = updateRule.issueIds[j]; + issuesStore.push(issueId); + } + } + + const loader = this.issuesService.createDynamicIssuesLoader(); + await issuesStore.fillData(loader); + + return issuesStore; + } + + private getTagsCustomField( + issue: RedmineTypes.Issue, + ): RedmineTypes.CustomField | null { + if (!issue.custom_fields) return null; + const customFields = issue.custom_fields; + return ( + customFields.find((cf) => cf.name == this.tagsCustomFieldName) || null + ); + } + + private getTags(tags: string): string[] { + return tags + .split(/[ ,;]/) + .map((s) => s.trim()) + .filter((s) => !!s); + } + + private modifyTags( + tags: string, + updateRule: UpdateRuleSingleIssue, + ): ModifyResult { + const t = this.getTags(tags); + let modified = false; + for (let i = 0; i < updateRule.remove.length; i++) { + const tagForRemoving = updateRule.remove[i]; + const tagIndex = t.indexOf(tagForRemoving); + if (tagIndex >= 0) { + t.splice(tagIndex, 1); + modified = true; + } + } + for (let i = 0; i < updateRule.add.length; i++) { + const tagForAdding = updateRule.add[i]; + const tagIndex = t.indexOf(tagForAdding); + if (tagIndex < 0) { + t.push(tagForAdding); + modified = true; + } + } + const result = modified ? t.join(' ') : tags; + return { value: result, modified: modified }; + } + + private modifyTagsForIssue( + updateRule: UpdateRuleSingleIssue, + ): ModifyCustomField { + const cf = this.getTagsCustomField(updateRule.issueData); + if (!cf) return { success: false }; + const modifyResult = this.modifyTags(cf.value, updateRule); + if (modifyResult.modified) cf.value = modifyResult.value; + return { success: true, modified: modifyResult.modified, field: cf }; + } + + private async createUpdateRulesSingleIssue( + userApiKey: string, + items: UpdateRule[], + ): Promise { + const res = [] as UpdateRuleSingleIssue[]; + const store = await this.createIssuesStore(items); + for (let i = 0; i < items.length; i++) { + const item = items[i]; + for (let j = 0; j < item.issueIds.length; j++) { + const issueId = item.issueIds[j]; + const issue = this.getIssue(store, issueId); + res.push({ + issueId: issueId, + issueData: issue, + userApiKey: userApiKey, + add: item.add, + remove: item.remove, + }); + } + } + return res; + } + + private getIssue( + store: FlatIssuesStore, + id: number, + ): RedmineTypes.Issue | null { + const result = store.getIssue(id); + return result.data ? result.data : null; + } +} From dbea46e0a37fea86071f0876272b6e1470de53c1 Mon Sep 17 00:00:00 2001 From: Gnedov Pavel Date: Mon, 24 Apr 2023 18:34:29 +0700 Subject: [PATCH 4/4] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=B2=D1=8B=D0=B2=D0=BE=D0=B4=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D1=82=D0=B5=D1=82=D0=B0=20=D0=B8=20=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=81=D0=B8=D0=B8=20=D0=B2=20=D0=BA=D0=B0=D1=80?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BA=D0=B5=20=D0=BD=D0=B0=20=D0=B4=D0=BE?= =?UTF-8?q?=D1=81=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- views/simple-kanban-board.hbs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/views/simple-kanban-board.hbs b/views/simple-kanban-board.hbs index a867939..7cef547 100644 --- a/views/simple-kanban-board.hbs +++ b/views/simple-kanban-board.hbs @@ -82,6 +82,8 @@
Исп.: {{this.current_user.name}}
+
Прио.: {{this.priority.name}}
+
Версия: {{this.fixed_version.name}}
Прогресс: {{this.done_ratio}}
Трудозатраты: {{this.total_spent_hours}} / {{this.total_estimated_hours}}
{{#if this.styledTags}}