Добавлен tag-manager для групповой обработки тегов

This commit is contained in:
Pavel Gnedov 2023-04-06 17:16:49 +07:00
parent 2e33e6b665
commit b0a65d56ef
8 changed files with 368 additions and 2 deletions

View file

@ -9,5 +9,10 @@
},
"telegramBotToken": "",
"personalMessageTemplate": "",
"periodValidityNotification": 43200 // 12h
"periodValidityNotification": 43200000, // 12h
"tagManager": {
"updateInterval": 15000,
"updateItemsLimit": 3,
"tagsCustomFieldName": "Tags"
}
}

View file

@ -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<string>('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],
};

View file

@ -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<string, any>,
): Promise<boolean> {
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<string, any>,
userApiKey: string,
): Promise<boolean> {
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`;
}
}

View file

@ -5,6 +5,8 @@ export class Queue<T, NT> {
queue: Subject<NT[]> = new Subject<NT[]>();
finished: Subject<boolean> = new Subject();
constructor(
private updateInterval: number,
private itemsLimit: number,
@ -43,6 +45,9 @@ export class Queue<T, NT> {
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();

View file

@ -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 {

View file

@ -21,4 +21,9 @@ export type AppConfig = {
};
telegramBotToken: string;
periodValidityNotification: number;
tagManager: {
updateInterval: number;
updateItemsLimit: number;
tagsCustomFieldName: string;
};
};

View file

@ -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<any> {
return await this.tagsManagerService.updateTags(
params.userApiKey,
params.updateRules,
);
}
}

View file

@ -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<string, any>;
modified?: boolean;
success: boolean;
};
export function CreateTagManagerServiceProvider(providerName: string): any {
return {
provide: providerName,
useFactory: (
configService: ConfigService,
issuesUpdaterService: IssuesUpdaterService,
issuesService: IssuesService,
) => {
const updateInverval = configService.get<number>(
'tagManager.updateInverval',
);
const updateItemsLimit = configService.get<number>(
'tagManager.updateItemsLimit',
);
const tagsCustomFieldName = configService.get<string>(
'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<UpdateRuleSingleIssue, UpdateResult>;
constructor(
private issuesUpdaterService: IssuesUpdaterService,
private updateInterval: number,
private updateItemsLimit: number,
private tagsCustomFieldName: string,
private issuesService: IssuesService,
) {}
async updateTags(
userApiKey: string,
updateRules: UpdateRule[],
): Promise<void> {
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<UpdateRuleSingleIssue, UpdateResult> {
if (!this.queue) {
this.queue = new Queue<UpdateRuleSingleIssue, UpdateResult>(
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<UpdateResult> {
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<FlatIssuesStore> {
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<UpdateRuleSingleIssue[]> {
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;
}
}