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; + } +}