Добавлен tag-manager для групповой обработки тегов
This commit is contained in:
parent
2e33e6b665
commit
b0a65d56ef
8 changed files with 368 additions and 2 deletions
|
|
@ -9,5 +9,10 @@
|
|||
},
|
||||
"telegramBotToken": "",
|
||||
"personalMessageTemplate": "",
|
||||
"periodValidityNotification": 43200 // 12h
|
||||
"periodValidityNotification": 43200000, // 12h
|
||||
"tagManager": {
|
||||
"updateInterval": 15000,
|
||||
"updateItemsLimit": 3,
|
||||
"tagsCustomFieldName": "Tags"
|
||||
}
|
||||
}
|
||||
|
|
@ -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],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -21,4 +21,9 @@ export type AppConfig = {
|
|||
};
|
||||
telegramBotToken: string;
|
||||
periodValidityNotification: number;
|
||||
tagManager: {
|
||||
updateInterval: number;
|
||||
updateItemsLimit: number;
|
||||
tagsCustomFieldName: string;
|
||||
};
|
||||
};
|
||||
|
|
|
|||
23
src/tags-manager/tags-manager.controller.ts
Normal file
23
src/tags-manager/tags-manager.controller.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
268
src/tags-manager/tags-manager.service.ts
Normal file
268
src/tags-manager/tags-manager.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue