Merge branch 'dev' into feature/react3-with-mobx

This commit is contained in:
Pavel Gnedov 2023-05-14 22:21:50 +07:00
commit 92e56177d9
11 changed files with 521 additions and 3 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

@ -47,6 +47,9 @@ import { IssuesByTagsWidgetService } from './dashboards/widgets/issues-by-tags.w
import { CategoryMergeToTagsEnhancer } from './issue-enhancers/category-merge-to-tags-enhancer';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
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: [
@ -68,6 +71,8 @@ import { join } from 'path';
CurrentIssuesEccmReportController,
DailyEccmReportController,
SimpleKanbanBoardController,
SimpleIssuesListController,
TagsManagerController,
],
providers: [
AppService,
@ -103,6 +108,7 @@ import { join } from 'path';
},
inject: [ConfigService],
},
CreateTagManagerServiceProvider('TAG_MANAGER_SERVICE'),
],
})
export class AppModule implements OnModuleInit {

View file

@ -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<string>('simpleKanbanBoard.path');
}
@Get('/by-tags/:name/raw')
async getByTagsRawData(@Param('name') name: string): Promise<any> {
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<any> {
return await this.getByTagsRawData(name);
}
}

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

View file

@ -0,0 +1,114 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Simple Issues List</title>
<style>
.list-container {
display: flex;
flex-direction: column;
width: 100%;
background-color: rgb(225, 225, 225);
}
.list-item {
margin: 5px;
width: 100%;
display: flex;
flex-direction: column;
}
.list-item-description {
background-color: rgb(196, 196, 196);
border-width: 1px;
border-color: rgba(255, 255, 255, 0.2) rgba(96, 96, 96, 0.2) rgba(96, 96, 96, 0.2) rgba(255, 255, 255, 0.2);
border-style: solid;
margin: 2px;
padding: 3px;
width: 190px;
/*display: flex;*/
border-radius: 3px;
z-index: 100;
}
.kanban-card div {
font-size: small;
}
.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);
}
.issue-tag {
font-size: 8pt;
border-radius: 4px;
padding: 2px;
}
.tags-container {
padding-left: 14px;
}
</style>
</head>
<body>
{{#each this}}
{{#if this.metainfo}}
<h1 id="{{this.metainfo.title}}">{{this.metainfo.title}} <a href="#{{this.metainfo.title}}">#</a></h1>
<div class="list-container">
{{#each this.data}}
{{#each this.issues}}
<div class="list-item">
<div>
<span class="timepassed-dot {{this.timePassedClass}}"></span>
<span class="issue-subject"><a href="{{{this.url.url}}}">{{this.tracker.name}} #{{this.id}} - {{this.subject}}</a></span>
<span class="issue-status">| {{this.status.name}}</span>
<span class="issue-time">| {{this.total_spent_hours}} / {{this.total_estimated_hours}}</span>
</div>
<div class="tags-container">
{{#if this.styledTags}}
{{#each this.styledTags}}
<span class="issue-tag" style="{{{this.style}}}">{{this.tag}}</span>
{{/each}}
{{/if}}
</div>
</div>
{{/each}}
{{/each}}
</div>
{{/if}}
{{/each}}
</body>
</html>

View file

@ -82,7 +82,9 @@
<div class="kanban-card">
<div class="kanban-card-title"><span class="timepassed-dot {{this.timePassedClass}}"></span> <a href="{{{this.url.url}}}">{{this.tracker.name}} #{{this.id}} - {{this.subject}}</a></div>
<div>Исп.: {{this.current_user.name}}</div>
<div>Прогресс: {{this.done_ration}}</div>
<div>Прио.: {{this.priority.name}}</div>
<div>Версия: {{this.fixed_version.name}}</div>
<div>Прогресс: {{this.done_ratio}}</div>
<div>Трудозатраты: {{this.total_spent_hours}} / {{this.total_estimated_hours}}</div>
{{#if this.styledTags}}
<div>