Merge branch 'dev' into 'master'
This commit is contained in:
commit
0d1d351a3d
47 changed files with 2371 additions and 53 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -41,3 +41,5 @@ configs/redmine-status-changes-config.jsonc
|
||||||
configs/eccm-versions-config.jsonc
|
configs/eccm-versions-config.jsonc
|
||||||
configs/eccm-config.jsonc
|
configs/eccm-config.jsonc
|
||||||
configs/current-user-rules.jsonc
|
configs/current-user-rules.jsonc
|
||||||
|
configs/simple-kanban-board-config.jsonc
|
||||||
|
configs/kanban-boards/
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
"dbs": {
|
"dbs": {
|
||||||
"changes": "",
|
"changes": "",
|
||||||
"userMetaInfo": "",
|
"userMetaInfo": "",
|
||||||
"eccmDailyReports": ""
|
"eccmDailyReports": "",
|
||||||
|
"eccmDailyReportsUserComments": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"telegramBotToken": "",
|
"telegramBotToken": "",
|
||||||
|
|
|
||||||
3
configs/simple-kanban-board-config.jsonc.dist
Normal file
3
configs/simple-kanban-board-config.jsonc.dist
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"path": ""
|
||||||
|
}
|
||||||
27
libs/event-emitter/src/configs/dynamic-loader.ts
Normal file
27
libs/event-emitter/src/configs/dynamic-loader.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { CacheTTL, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { readFileSync, existsSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DynamicLoader {
|
||||||
|
private logger = new Logger(DynamicLoader.name);
|
||||||
|
|
||||||
|
@CacheTTL(60)
|
||||||
|
load(
|
||||||
|
file: string,
|
||||||
|
opts: { path: string; ext: string; parser: (raw: any) => any },
|
||||||
|
): any {
|
||||||
|
const fullFileName = join(opts.path, `${file}.${opts.ext}`);
|
||||||
|
if (!existsSync(fullFileName)) return null;
|
||||||
|
let rawData: any;
|
||||||
|
let data: any;
|
||||||
|
try {
|
||||||
|
rawData = readFileSync(fullFileName, { encoding: 'utf-8' });
|
||||||
|
data = opts.parser(rawData);
|
||||||
|
} catch (ex) {
|
||||||
|
this.logger.error(`Error at config read - ${ex}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RedminePublicUrlConverter {
|
||||||
|
private redminePublicUrlPrefix: string;
|
||||||
|
|
||||||
|
constructor(private configService: ConfigService) {
|
||||||
|
this.redminePublicUrlPrefix =
|
||||||
|
this.configService.get<string>('redmineUrlPublic');
|
||||||
|
}
|
||||||
|
|
||||||
|
convert(issueId: number | string): string {
|
||||||
|
return `${this.redminePublicUrlPrefix}/issues/${issueId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getUrl(issueId: number | string): string {
|
||||||
|
return this.convert(issueId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getHtmlHref(issue: RedmineTypes.Issue): string {
|
||||||
|
const url = this.getUrl(issue.id);
|
||||||
|
return `<a href="${url}">${issue.tracker.name} #${issue.id}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMinHtmlHref(issueId: number | string): string {
|
||||||
|
const url = this.getUrl(issueId);
|
||||||
|
return `<a href="${url}">#${issueId}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обогащение текста с идентификаторами задач html ссылками на эти задачи
|
||||||
|
*
|
||||||
|
* Например текст `"Эта ошибка будет решена в рамках задач #123 и #456"`
|
||||||
|
* будет заменён на `"Эта ошибка будет решена в рамках задач
|
||||||
|
* <a href="http://redmine.example.org/issues/123">#123</a> и
|
||||||
|
* <a href="http://redmine.example.org/issues/456">#456</a>"`
|
||||||
|
*
|
||||||
|
* @param text
|
||||||
|
* @param linkGenerator функция замены отдельного идентификатора на html ссылку. По умолчанию
|
||||||
|
* будет использоваться собственная функция this.getMinHtmlHref
|
||||||
|
* @see convert
|
||||||
|
*/
|
||||||
|
enrichTextWithIssues(
|
||||||
|
text: string,
|
||||||
|
linkGenerator?: (issueId: number | string) => string,
|
||||||
|
): string {
|
||||||
|
const generator = linkGenerator
|
||||||
|
? linkGenerator
|
||||||
|
: (issueId) => this.getMinHtmlHref(issueId);
|
||||||
|
|
||||||
|
const regexp = /^\d+/;
|
||||||
|
|
||||||
|
const parts = text.split('#');
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
let part = parts[i];
|
||||||
|
const match = part.match(regexp);
|
||||||
|
if (!match) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const issueId = match[0];
|
||||||
|
const replacment = generator(issueId);
|
||||||
|
part = part.replace(new RegExp(`^${issueId}`), replacment);
|
||||||
|
parts[i] = part;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,15 @@ import { IssuesService } from './issues/issues.service';
|
||||||
import { IssuesController } from './issues/issues.controller';
|
import { IssuesController } from './issues/issues.controller';
|
||||||
import { TimestampEnhancer } from './issue-enhancers/timestamps-enhancer';
|
import { TimestampEnhancer } from './issue-enhancers/timestamps-enhancer';
|
||||||
import { EnhancerService } from './issue-enhancers/enhancer.service';
|
import { EnhancerService } from './issue-enhancers/enhancer.service';
|
||||||
|
import { ProjectDashboardService } from './project-dashboard/project-dashboard.service';
|
||||||
|
import { RootIssueSubTreesWidgetService } from './project-dashboard/widgets/root-issue-subtrees.widget.service';
|
||||||
|
import { DynamicLoader } from './configs/dynamic-loader';
|
||||||
|
import { RedminePublicUrlConverter } from './converters/redmine-public-url.converter';
|
||||||
|
import { IssueUrlEnhancer } from './issue-enhancers/issue-url-enhancer';
|
||||||
|
import { ListIssuesByUsersWidgetService } from './project-dashboard/widgets/list-issues-by-users.widget.service';
|
||||||
|
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';
|
||||||
|
|
||||||
@Module({})
|
@Module({})
|
||||||
export class EventEmitterModule implements OnModuleInit {
|
export class EventEmitterModule implements OnModuleInit {
|
||||||
|
|
@ -40,6 +49,15 @@ export class EventEmitterModule implements OnModuleInit {
|
||||||
IssuesService,
|
IssuesService,
|
||||||
TimestampEnhancer,
|
TimestampEnhancer,
|
||||||
EnhancerService,
|
EnhancerService,
|
||||||
|
ProjectDashboardService,
|
||||||
|
RootIssueSubTreesWidgetService,
|
||||||
|
DynamicLoader,
|
||||||
|
RedminePublicUrlConverter,
|
||||||
|
IssueUrlEnhancer,
|
||||||
|
ListIssuesByUsersWidgetService,
|
||||||
|
ListIssuesByUsersLikeJiraWidgetService,
|
||||||
|
TimePassedHighlightEnhancer,
|
||||||
|
ListIssuesByFieldsWidgetService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
EventEmitterService,
|
EventEmitterService,
|
||||||
|
|
@ -54,6 +72,15 @@ export class EventEmitterModule implements OnModuleInit {
|
||||||
IssuesService,
|
IssuesService,
|
||||||
TimestampEnhancer,
|
TimestampEnhancer,
|
||||||
EnhancerService,
|
EnhancerService,
|
||||||
|
ProjectDashboardService,
|
||||||
|
RootIssueSubTreesWidgetService,
|
||||||
|
DynamicLoader,
|
||||||
|
RedminePublicUrlConverter,
|
||||||
|
IssueUrlEnhancer,
|
||||||
|
ListIssuesByUsersWidgetService,
|
||||||
|
ListIssuesByUsersLikeJiraWidgetService,
|
||||||
|
TimePassedHighlightEnhancer,
|
||||||
|
ListIssuesByFieldsWidgetService,
|
||||||
],
|
],
|
||||||
controllers: [MainController, UsersController, IssuesController],
|
controllers: [MainController, UsersController, IssuesController],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
24
libs/event-emitter/src/issue-enhancers/issue-url-enhancer.ts
Normal file
24
libs/event-emitter/src/issue-enhancers/issue-url-enhancer.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { RedminePublicUrlConverter } from '../converters/redmine-public-url.converter';
|
||||||
|
import { RedmineTypes } from '../models/redmine-types';
|
||||||
|
import { IssueEnhancerInterface } from './issue-enhancer-interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class IssueUrlEnhancer implements IssueEnhancerInterface {
|
||||||
|
name = 'issue-url';
|
||||||
|
|
||||||
|
constructor(private redminePublicUrlConverter: RedminePublicUrlConverter) {}
|
||||||
|
|
||||||
|
async enhance(
|
||||||
|
issue: RedmineTypes.Issue,
|
||||||
|
): Promise<RedmineTypes.Issue & Record<string, any>> {
|
||||||
|
const res: RedmineTypes.Issue & Record<string, any> = issue;
|
||||||
|
if (!issue || !issue.id) return issue;
|
||||||
|
res['url'] = {
|
||||||
|
url: this.redminePublicUrlConverter.getUrl(issue.id),
|
||||||
|
fullHref: this.redminePublicUrlConverter.getHtmlHref(issue),
|
||||||
|
minHref: this.redminePublicUrlConverter.getMinHtmlHref(issue.id),
|
||||||
|
};
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-namespace */
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { RedmineTypes } from '../models/redmine-types';
|
||||||
|
import { IssueEnhancerInterface } from './issue-enhancer-interface';
|
||||||
|
|
||||||
|
export namespace TagStyledEnhancerNs {
|
||||||
|
/**
|
||||||
|
* * key - tag name,
|
||||||
|
* * value - css style for tag
|
||||||
|
*/
|
||||||
|
export type Styles = Record<string, string>;
|
||||||
|
|
||||||
|
export type StyledTag = {
|
||||||
|
tag: string;
|
||||||
|
style: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TagsParams = {
|
||||||
|
tagsKeyName: string;
|
||||||
|
styles: Styles;
|
||||||
|
defaultStyle: string;
|
||||||
|
styledTagsKeyName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConfigWithTagsStyles = {
|
||||||
|
tags?: TagsParams;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CreateTagStyledEnhancerForConfig(
|
||||||
|
cfg: ConfigWithTagsStyles,
|
||||||
|
): TagStyledEnhancer | null {
|
||||||
|
if (!cfg || !cfg.tags) return null;
|
||||||
|
return new TagStyledEnhancer(
|
||||||
|
(issue: RedmineTypes.ExtendedIssue) => {
|
||||||
|
if (!issue) return [];
|
||||||
|
if (
|
||||||
|
typeof issue[cfg.tags.tagsKeyName] === 'object' &&
|
||||||
|
issue[cfg.tags.tagsKeyName].length > 0
|
||||||
|
) {
|
||||||
|
return issue[cfg.tags.tagsKeyName];
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cfg.tags.styles,
|
||||||
|
cfg.tags.defaultStyle,
|
||||||
|
cfg.tags.styledTagsKeyName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TagStyledEnhancer implements IssueEnhancerInterface {
|
||||||
|
name = 'tag-styled';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private tagsGetter: (issue: RedmineTypes.ExtendedIssue) => string[],
|
||||||
|
private styles: TagStyledEnhancerNs.Styles,
|
||||||
|
private defaultStyle: string,
|
||||||
|
private keyName: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async enhance(
|
||||||
|
issue: RedmineTypes.ExtendedIssue,
|
||||||
|
): Promise<RedmineTypes.ExtendedIssue> {
|
||||||
|
if (!issue) return issue;
|
||||||
|
const tags = this.tagsGetter(issue);
|
||||||
|
const styles = [] as TagStyledEnhancerNs.StyledTag[];
|
||||||
|
for (let i = 0; i < tags.length; i++) {
|
||||||
|
const tagName = tags[i];
|
||||||
|
if (this.styles[tagName]) {
|
||||||
|
styles.push({
|
||||||
|
tag: tagName,
|
||||||
|
style: this.styles[tagName],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
styles.push({
|
||||||
|
tag: tagName,
|
||||||
|
style: this.defaultStyle,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
issue[this.keyName] = styles;
|
||||||
|
return issue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-namespace */
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { RedmineTypes } from '../models/redmine-types';
|
||||||
|
import { TimestampConverter } from '../utils/timestamp-converter';
|
||||||
|
import { IssueEnhancerInterface } from './issue-enhancer-interface';
|
||||||
|
|
||||||
|
export namespace TimePassedHighlightEnhancerNs {
|
||||||
|
export type PriorityRules = {
|
||||||
|
/** time in seconds */
|
||||||
|
timePassed: number;
|
||||||
|
priority: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TimePassedHighlightEnhancer implements IssueEnhancerInterface {
|
||||||
|
name = 'activity-to-priority';
|
||||||
|
|
||||||
|
private rules: TimePassedHighlightEnhancerNs.PriorityRules[] = [
|
||||||
|
{
|
||||||
|
timePassed: 60 * 60, // 1 час
|
||||||
|
priority: 'hot',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timePassed: 24 * 60 * 60, // 1 день,
|
||||||
|
priority: 'warm',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timePassed: 7 * 24 * 60 * 60, // 1 неделя
|
||||||
|
priority: 'comfort',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timePassed: 14 * 24 * 60 * 60, // 2 недели
|
||||||
|
priority: 'breezy',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
private otherPriority = 'cold';
|
||||||
|
|
||||||
|
private keyNameForCssClass = 'timePassedClass';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.rules = this.rules.sort((a, b) => {
|
||||||
|
return a.timePassed - b.timePassed;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async enhance(
|
||||||
|
issue: RedmineTypes.ExtendedIssue,
|
||||||
|
): Promise<RedmineTypes.ExtendedIssue> {
|
||||||
|
const nowTimestamp = new Date().getTime();
|
||||||
|
if (!issue?.updated_on) return issue;
|
||||||
|
for (let i = 0; i < this.rules.length; i++) {
|
||||||
|
const rule = this.rules[i];
|
||||||
|
if (
|
||||||
|
nowTimestamp - TimestampConverter.toTimestamp(issue.updated_on) <=
|
||||||
|
rule.timePassed * 1000
|
||||||
|
) {
|
||||||
|
issue[this.keyNameForCssClass] = rule.priority;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!issue[this.keyNameForCssClass]) {
|
||||||
|
issue[this.keyNameForCssClass] = this.otherPriority;
|
||||||
|
}
|
||||||
|
return issue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-namespace */
|
||||||
import { RedmineTypes } from '../models/redmine-types';
|
import { RedmineTypes } from '../models/redmine-types';
|
||||||
import { CacheTTL, Injectable, Logger } from '@nestjs/common';
|
import { CacheTTL, Injectable, Logger } from '@nestjs/common';
|
||||||
import { Issues } from '../couchdb-datasources/issues';
|
import { Issues } from '../couchdb-datasources/issues';
|
||||||
|
|
@ -8,10 +9,17 @@ import { MemoryCache } from '../utils/memory-cache';
|
||||||
import nano from 'nano';
|
import nano from 'nano';
|
||||||
import { UNLIMITED } from '../consts/consts';
|
import { UNLIMITED } from '../consts/consts';
|
||||||
import { GetParentsHint } from '../utils/get-parents-hint';
|
import { GetParentsHint } from '../utils/get-parents-hint';
|
||||||
|
import { TreeIssuesStore } from '../utils/tree-issues-store';
|
||||||
|
|
||||||
export const ISSUE_MEMORY_CACHE_LIFETIME = 30 * 1000;
|
export const ISSUE_MEMORY_CACHE_LIFETIME = 30 * 1000;
|
||||||
const ISSUE_MEMORY_CACHE_AUTOCLEAN_INTERVAL = 1000 * 60 * 5;
|
const ISSUE_MEMORY_CACHE_AUTOCLEAN_INTERVAL = 1000 * 60 * 5;
|
||||||
|
|
||||||
|
export namespace IssuesServiceNs {
|
||||||
|
export type IssuesLoader = (
|
||||||
|
ids: number[],
|
||||||
|
) => Promise<Record<number, RedmineTypes.Issue | null>>;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class IssuesService {
|
export class IssuesService {
|
||||||
private logger = new Logger(IssuesService.name);
|
private logger = new Logger(IssuesService.name);
|
||||||
|
|
@ -143,4 +151,29 @@ export class IssuesService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createDynamicIssuesLoader(): IssuesServiceNs.IssuesLoader {
|
||||||
|
const fn = async (
|
||||||
|
ids: number[],
|
||||||
|
): Promise<Record<number, RedmineTypes.Issue>> => {
|
||||||
|
const issues = await this.getIssues(ids);
|
||||||
|
const res = {} as Record<number, RedmineTypes.Issue | null>;
|
||||||
|
for (let i = 0; i < issues.length; i++) {
|
||||||
|
const issue = issues[i];
|
||||||
|
res[issue.id] = issue;
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
return fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getIssuesWithChildren(
|
||||||
|
rootIssue: RedmineTypes.Issue,
|
||||||
|
): Promise<RedmineTypes.Issue[]> {
|
||||||
|
const treeIssuesStore = new TreeIssuesStore();
|
||||||
|
treeIssuesStore.setRootIssue(rootIssue);
|
||||||
|
const loader = this.createDynamicIssuesLoader();
|
||||||
|
await treeIssuesStore.fillData(loader);
|
||||||
|
return treeIssuesStore.getIssuesWithChildren();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ export module RedmineTypes {
|
||||||
author: IdAndName;
|
author: IdAndName;
|
||||||
assigned_to?: IdAndName;
|
assigned_to?: IdAndName;
|
||||||
category: IdAndName;
|
category: IdAndName;
|
||||||
fixed_version: IdAndName;
|
fixed_version?: IdAndName;
|
||||||
subject: string;
|
subject: string;
|
||||||
description: string;
|
description: string;
|
||||||
start_date: string;
|
start_date: string;
|
||||||
|
|
@ -61,6 +61,8 @@ export module RedmineTypes {
|
||||||
parent?: { id: number };
|
parent?: { id: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ExtendedIssue = Issue & Record<string, any>;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/prefer-namespace-keyword, @typescript-eslint/no-namespace
|
// eslint-disable-next-line @typescript-eslint/prefer-namespace-keyword, @typescript-eslint/no-namespace
|
||||||
export module Unknown {
|
export module Unknown {
|
||||||
export const num = -1;
|
export const num = -1;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-namespace */
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { RedmineTypes } from '../models/redmine-types';
|
||||||
|
|
||||||
|
export namespace ProjectDashboard {
|
||||||
|
export namespace Models {
|
||||||
|
export type Params = {
|
||||||
|
projectName: string;
|
||||||
|
workers: Worker[];
|
||||||
|
filter: FilterDefination[];
|
||||||
|
statuses: Status[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Worker = {
|
||||||
|
id?: number;
|
||||||
|
firstname?: string;
|
||||||
|
lastname?: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum FilterTypes {
|
||||||
|
TREE = 'TREE',
|
||||||
|
LIST = 'LIST',
|
||||||
|
DYNAMIC_LIST = 'DYNAMIC_LIST',
|
||||||
|
VERSION = 'VERSION',
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace FilterParams {
|
||||||
|
export type Tree = {
|
||||||
|
rootIssueId: number;
|
||||||
|
subtrees: SubTree[];
|
||||||
|
showOthers: boolean;
|
||||||
|
};
|
||||||
|
export type SubTree = {
|
||||||
|
title: string;
|
||||||
|
issueId: number;
|
||||||
|
};
|
||||||
|
export type List = {
|
||||||
|
issueIds: number[];
|
||||||
|
};
|
||||||
|
export type Version = {
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
export type DynamicList = {
|
||||||
|
selector: Record<string, any>;
|
||||||
|
};
|
||||||
|
export type AnyFilterParams = Tree | List | DynamicList | Version;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FilterParams = Record<string, any>;
|
||||||
|
|
||||||
|
export namespace FilterResults {
|
||||||
|
export type Tree = List;
|
||||||
|
export type List = { status: string; issues: RedmineTypes.Issue[] }[];
|
||||||
|
export type DynamicList = List;
|
||||||
|
export type Version = List;
|
||||||
|
export type AnyFilterResults = List | Tree | DynamicList | Version;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FilterResult = Record<string, any>[];
|
||||||
|
|
||||||
|
export type AnalyticFunction = {
|
||||||
|
functionName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FilterDefination = {
|
||||||
|
type: FilterTypes;
|
||||||
|
title: string;
|
||||||
|
params: FilterParams.AnyFilterParams;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FilterWithResults = {
|
||||||
|
params: FilterDefination;
|
||||||
|
results: FilterResults.AnyFilterResults;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Status = {
|
||||||
|
name: string;
|
||||||
|
closed: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CheckWorker(worker: Models.Worker): boolean {
|
||||||
|
return Boolean(
|
||||||
|
(typeof worker.id === 'number' && worker.id >= 0) ||
|
||||||
|
(worker.firstname && worker.lastname) ||
|
||||||
|
worker.name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SingleProject {
|
||||||
|
// TODO: code for SingleProject
|
||||||
|
constructor(private params: Models.Params) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace Widgets {
|
||||||
|
// Чё будет делать виджет?
|
||||||
|
// * рендер - из параметров будет создавать данные с какими-либо расчётами
|
||||||
|
|
||||||
|
export interface Widget {
|
||||||
|
render(
|
||||||
|
filterParams: Models.FilterParams,
|
||||||
|
dashboardParams: Models.Params,
|
||||||
|
): Models.FilterResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class List implements Widget {
|
||||||
|
render(
|
||||||
|
filterParams: Models.FilterParams,
|
||||||
|
dashboardParams: Models.Params,
|
||||||
|
): Models.FilterResult {
|
||||||
|
throw new Error('Method not implemented.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DynamicList implements Widget {
|
||||||
|
render(
|
||||||
|
filterParams: Models.FilterParams,
|
||||||
|
dashboardParams: Models.Params,
|
||||||
|
): Models.FilterResult {
|
||||||
|
throw new Error('Method not implemented.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Tree implements Widget {
|
||||||
|
render(
|
||||||
|
filterParams: Models.FilterParams,
|
||||||
|
dashboardParams: Models.Params,
|
||||||
|
): Models.FilterResult {
|
||||||
|
throw new Error('Method not implemented.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Version implements Widget {
|
||||||
|
render(
|
||||||
|
filterParams: Models.FilterParams,
|
||||||
|
dashboardParams: Models.Params,
|
||||||
|
): Models.FilterResult {
|
||||||
|
throw new Error('Method not implemented.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProjectDashboardService {
|
||||||
|
constructor() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export interface WidgetInterface<W, D, R> {
|
||||||
|
isMyConfig(widgetParams: W): boolean;
|
||||||
|
render(widgetParams: W, dashboardParams: D): Promise<R>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-namespace */
|
||||||
|
import { IssueUrlEnhancer } from '@app/event-emitter/issue-enhancers/issue-url-enhancer';
|
||||||
|
import { TagStyledEnhancerNs } from '@app/event-emitter/issue-enhancers/tag-styled-enhancer';
|
||||||
|
import { TimePassedHighlightEnhancer } from '@app/event-emitter/issue-enhancers/time-passed-highlight-enhancer';
|
||||||
|
import {
|
||||||
|
IssuesService,
|
||||||
|
IssuesServiceNs,
|
||||||
|
} from '@app/event-emitter/issues/issues.service';
|
||||||
|
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
|
||||||
|
import { FlatIssuesStore } from '@app/event-emitter/utils/flat-issues-store';
|
||||||
|
import { GetValueFromObjectByKey } from '@app/event-emitter/utils/get-value-from-object-by-key';
|
||||||
|
import { TreeIssuesStore } from '@app/event-emitter/utils/tree-issues-store';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import nano from 'nano';
|
||||||
|
import { WidgetInterface } from '../widget-interface';
|
||||||
|
|
||||||
|
export namespace ListIssuesByFieldsWidgetNs {
|
||||||
|
export type Params = {
|
||||||
|
fromRootIssueId?: number;
|
||||||
|
fromQuery?: nano.MangoQuery;
|
||||||
|
fields: Field[];
|
||||||
|
delimiter: string;
|
||||||
|
sort?: boolean;
|
||||||
|
statuses: string[];
|
||||||
|
} & TagStyledEnhancerNs.ConfigWithTagsStyles;
|
||||||
|
|
||||||
|
export type Field = {
|
||||||
|
path: string;
|
||||||
|
default: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type Params = ListIssuesByFieldsWidgetNs.Params;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ListIssuesByFieldsWidgetService
|
||||||
|
implements WidgetInterface<Params, any, any>
|
||||||
|
{
|
||||||
|
private logger = new Logger(ListIssuesByFieldsWidgetService.name);
|
||||||
|
private issuesLoader: IssuesServiceNs.IssuesLoader;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private issuesService: IssuesService,
|
||||||
|
private timePassedHighlightEnhancer: TimePassedHighlightEnhancer,
|
||||||
|
private issueUrlEnhancer: IssueUrlEnhancer,
|
||||||
|
) {
|
||||||
|
this.issuesLoader = this.issuesService.createDynamicIssuesLoader();
|
||||||
|
}
|
||||||
|
|
||||||
|
isMyConfig(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async render(widgetParams: Params): Promise<any> {
|
||||||
|
let store: FlatIssuesStore;
|
||||||
|
if (widgetParams.fromRootIssueId) {
|
||||||
|
store = await this.getListFromRoot(widgetParams.fromRootIssueId);
|
||||||
|
} else if (widgetParams.fromQuery) {
|
||||||
|
store = await this.getListByQuery(widgetParams.fromQuery);
|
||||||
|
} else {
|
||||||
|
const errMsg = `Wrong widgetParams value`;
|
||||||
|
this.logger.error(errMsg);
|
||||||
|
throw new Error(errMsg);
|
||||||
|
}
|
||||||
|
await store.enhanceIssues([
|
||||||
|
this.timePassedHighlightEnhancer,
|
||||||
|
this.issueUrlEnhancer,
|
||||||
|
TagStyledEnhancerNs.CreateTagStyledEnhancerForConfig(widgetParams),
|
||||||
|
]);
|
||||||
|
const grouped = store.groupByStatusWithExtra((issue) => {
|
||||||
|
return this.getGroupFromIssue(issue, widgetParams);
|
||||||
|
}, widgetParams.statuses);
|
||||||
|
let res = [] as any[];
|
||||||
|
for (const key in grouped) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(grouped, key)) {
|
||||||
|
const data = grouped[key];
|
||||||
|
res.push({
|
||||||
|
data: data,
|
||||||
|
metainfo: this.createMetaInfo(key),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (widgetParams.sort) {
|
||||||
|
res = res.sort((a, b) => {
|
||||||
|
return a.metainfo.title.localeCompare(b.metainfo.title);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getListFromRoot(issueId: number): Promise<FlatIssuesStore> {
|
||||||
|
const treeStore = new TreeIssuesStore();
|
||||||
|
const rootIssue = await this.issuesService.getIssue(issueId);
|
||||||
|
treeStore.setRootIssue(rootIssue);
|
||||||
|
await treeStore.fillData(this.issuesLoader);
|
||||||
|
return treeStore.getFlatStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getListByQuery(
|
||||||
|
query: nano.MangoQuery,
|
||||||
|
): Promise<FlatIssuesStore> {
|
||||||
|
const rawData = await this.issuesService.find(query);
|
||||||
|
const store = new FlatIssuesStore();
|
||||||
|
for (let i = 0; i < rawData.length; i++) {
|
||||||
|
const issue = rawData[i];
|
||||||
|
store.push(issue);
|
||||||
|
}
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getGroupFromIssue(
|
||||||
|
issue: RedmineTypes.ExtendedIssue,
|
||||||
|
params: Params,
|
||||||
|
): string | null {
|
||||||
|
if (!issue) return null;
|
||||||
|
const values = [] as string[];
|
||||||
|
for (let i = 0; i < params.fields.length; i++) {
|
||||||
|
const field = params.fields[i];
|
||||||
|
const valueResult = GetValueFromObjectByKey(issue, field.path);
|
||||||
|
const value: string = valueResult.result
|
||||||
|
? valueResult.result
|
||||||
|
: field.default;
|
||||||
|
values.push(value);
|
||||||
|
}
|
||||||
|
return values.join(params.delimiter);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createMetaInfo(title: string): Record<string, any> {
|
||||||
|
return {
|
||||||
|
title: title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-namespace */
|
||||||
|
import { IssueUrlEnhancer } from '@app/event-emitter/issue-enhancers/issue-url-enhancer';
|
||||||
|
import { TagStyledEnhancerNs } from '@app/event-emitter/issue-enhancers/tag-styled-enhancer';
|
||||||
|
import { TimePassedHighlightEnhancer } from '@app/event-emitter/issue-enhancers/time-passed-highlight-enhancer';
|
||||||
|
import {
|
||||||
|
IssuesService,
|
||||||
|
IssuesServiceNs,
|
||||||
|
} from '@app/event-emitter/issues/issues.service';
|
||||||
|
import { FlatIssuesStore } from '@app/event-emitter/utils/flat-issues-store';
|
||||||
|
import { GetValueFromObjectByKey } from '@app/event-emitter/utils/get-value-from-object-by-key';
|
||||||
|
import { TreeIssuesStore } from '@app/event-emitter/utils/tree-issues-store';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import nano from 'nano';
|
||||||
|
import { WidgetInterface } from '../widget-interface';
|
||||||
|
|
||||||
|
export namespace ListIssuesByUsersLikeJiraWidgetNs {
|
||||||
|
export namespace Models {
|
||||||
|
export type Params = {
|
||||||
|
fromRootIssueId?: number;
|
||||||
|
fromQuery?: nano.MangoQuery;
|
||||||
|
userKeys: string[];
|
||||||
|
userSort?: boolean;
|
||||||
|
statuses: string[];
|
||||||
|
} & TagStyledEnhancerNs.ConfigWithTagsStyles;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Params = ListIssuesByUsersLikeJiraWidgetNs.Models.Params;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ListIssuesByUsersLikeJiraWidgetService
|
||||||
|
implements WidgetInterface<Params, any, any>
|
||||||
|
{
|
||||||
|
private logger = new Logger(ListIssuesByUsersLikeJiraWidgetService.name);
|
||||||
|
private issuesLoader: IssuesServiceNs.IssuesLoader;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private issuesService: IssuesService,
|
||||||
|
private timePassedHighlightEnhancer: TimePassedHighlightEnhancer,
|
||||||
|
private issueUrlEnhancer: IssueUrlEnhancer,
|
||||||
|
) {
|
||||||
|
this.issuesLoader = this.issuesService.createDynamicIssuesLoader();
|
||||||
|
}
|
||||||
|
|
||||||
|
isMyConfig(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async render(widgetParams: Params): Promise<any> {
|
||||||
|
let store: FlatIssuesStore;
|
||||||
|
if (widgetParams.fromRootIssueId) {
|
||||||
|
store = await this.getListFromRoot(widgetParams.fromRootIssueId);
|
||||||
|
} else if (widgetParams.fromQuery) {
|
||||||
|
store = await this.getListByQuery(widgetParams.fromQuery);
|
||||||
|
} else {
|
||||||
|
const errMsg = `Wrong widgetParams value`;
|
||||||
|
this.logger.error(errMsg);
|
||||||
|
throw new Error(errMsg);
|
||||||
|
}
|
||||||
|
await store.enhanceIssues([
|
||||||
|
this.timePassedHighlightEnhancer,
|
||||||
|
this.issueUrlEnhancer,
|
||||||
|
TagStyledEnhancerNs.CreateTagStyledEnhancerForConfig(widgetParams),
|
||||||
|
]);
|
||||||
|
const grouped = store.groupByStatusWithExtraToMultipleStories((issue) => {
|
||||||
|
const users = [] as string[];
|
||||||
|
for (let i = 0; i < widgetParams.userKeys.length; i++) {
|
||||||
|
const userKey = widgetParams.userKeys[i];
|
||||||
|
const userValue = GetValueFromObjectByKey(issue, userKey);
|
||||||
|
if (userValue.result) {
|
||||||
|
users.push(userValue.result);
|
||||||
|
} else {
|
||||||
|
users.push('Unknown Unknown');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return users;
|
||||||
|
}, widgetParams.statuses);
|
||||||
|
let res = [] as any[];
|
||||||
|
for (const user in grouped) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(grouped, user)) {
|
||||||
|
const data = grouped[user];
|
||||||
|
res.push({
|
||||||
|
data: data,
|
||||||
|
metainfo: this.createMetaInfo(user),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (widgetParams.userSort) {
|
||||||
|
res = res.sort((a, b) => {
|
||||||
|
return a.metainfo.title.localeCompare(b.metainfo.title);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getListFromRoot(issueId: number): Promise<FlatIssuesStore> {
|
||||||
|
const treeStore = new TreeIssuesStore();
|
||||||
|
const rootIssue = await this.issuesService.getIssue(issueId);
|
||||||
|
treeStore.setRootIssue(rootIssue);
|
||||||
|
await treeStore.fillData(this.issuesLoader);
|
||||||
|
return treeStore.getFlatStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getListByQuery(
|
||||||
|
query: nano.MangoQuery,
|
||||||
|
): Promise<FlatIssuesStore> {
|
||||||
|
const rawData = await this.issuesService.find(query);
|
||||||
|
const store = new FlatIssuesStore();
|
||||||
|
for (let i = 0; i < rawData.length; i++) {
|
||||||
|
const issue = rawData[i];
|
||||||
|
store.push(issue);
|
||||||
|
}
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createMetaInfo(user: string): Record<string, any> {
|
||||||
|
return {
|
||||||
|
title: user,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-namespace */
|
||||||
|
import { IssueUrlEnhancer } from '@app/event-emitter/issue-enhancers/issue-url-enhancer';
|
||||||
|
import { TagStyledEnhancerNs } from '@app/event-emitter/issue-enhancers/tag-styled-enhancer';
|
||||||
|
import { TimePassedHighlightEnhancer } from '@app/event-emitter/issue-enhancers/time-passed-highlight-enhancer';
|
||||||
|
import {
|
||||||
|
IssuesService,
|
||||||
|
IssuesServiceNs,
|
||||||
|
} from '@app/event-emitter/issues/issues.service';
|
||||||
|
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
|
||||||
|
import { FlatIssuesStore } from '@app/event-emitter/utils/flat-issues-store';
|
||||||
|
import { GetValueFromObjectByKey } from '@app/event-emitter/utils/get-value-from-object-by-key';
|
||||||
|
import { TreeIssuesStore } from '@app/event-emitter/utils/tree-issues-store';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import nano from 'nano';
|
||||||
|
import { WidgetInterface } from '../widget-interface';
|
||||||
|
|
||||||
|
export namespace ListIssuesByUsersWidgetNs {
|
||||||
|
export namespace Models {
|
||||||
|
export type Params = {
|
||||||
|
fromRootIssueId?: number;
|
||||||
|
fromQuery?: nano.MangoQuery;
|
||||||
|
userKey: string;
|
||||||
|
userSort?: boolean;
|
||||||
|
statuses: string[];
|
||||||
|
} & TagStyledEnhancerNs.ConfigWithTagsStyles;
|
||||||
|
|
||||||
|
export type FindResult = {
|
||||||
|
result?: any;
|
||||||
|
error?: FindErrors;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum FindErrors {
|
||||||
|
NOT_FOUND = 'NOT_FOUND',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Params = ListIssuesByUsersWidgetNs.Models.Params;
|
||||||
|
type ExtendedIssue = RedmineTypes.Issue & Record<string, any>;
|
||||||
|
type FindResult = ListIssuesByUsersWidgetNs.Models.FindResult;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ListIssuesByUsersWidgetService
|
||||||
|
implements WidgetInterface<Params, any, any>
|
||||||
|
{
|
||||||
|
private logger = new Logger(ListIssuesByUsersWidgetService.name);
|
||||||
|
private issuesLoader: IssuesServiceNs.IssuesLoader;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private issuesService: IssuesService,
|
||||||
|
private timePassedHighlightEnhancer: TimePassedHighlightEnhancer,
|
||||||
|
private issueUrlEnhancer: IssueUrlEnhancer,
|
||||||
|
) {
|
||||||
|
this.issuesLoader = this.issuesService.createDynamicIssuesLoader();
|
||||||
|
}
|
||||||
|
|
||||||
|
isMyConfig(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async render(widgetParams: Params): Promise<any> {
|
||||||
|
let store: FlatIssuesStore;
|
||||||
|
if (widgetParams.fromRootIssueId) {
|
||||||
|
store = await this.getListFromRoot(widgetParams.fromRootIssueId);
|
||||||
|
} else if (widgetParams.fromQuery) {
|
||||||
|
store = await this.getListByQuery(widgetParams.fromQuery);
|
||||||
|
} else {
|
||||||
|
const errMsg = `Wrong widgetParams value`;
|
||||||
|
this.logger.error(errMsg);
|
||||||
|
throw new Error(errMsg);
|
||||||
|
}
|
||||||
|
await store.enhanceIssues([
|
||||||
|
this.timePassedHighlightEnhancer,
|
||||||
|
this.issueUrlEnhancer,
|
||||||
|
TagStyledEnhancerNs.CreateTagStyledEnhancerForConfig(widgetParams),
|
||||||
|
]);
|
||||||
|
const grouped = store.groupByStatusWithExtra((issue) => {
|
||||||
|
const res = this.getUserValueByKey(issue, widgetParams.userKey);
|
||||||
|
return res.result || 'Unknown';
|
||||||
|
}, widgetParams.statuses);
|
||||||
|
let res = [] as any[];
|
||||||
|
for (const user in grouped) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(grouped, user)) {
|
||||||
|
const data = grouped[user];
|
||||||
|
res.push({
|
||||||
|
data: data,
|
||||||
|
metainfo: this.createMetaInfo(user),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (widgetParams.userSort) {
|
||||||
|
res = res.sort((a, b) => {
|
||||||
|
return a.metainfo.title.localeCompare(b.metainfo.title);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getListFromRoot(issueId: number): Promise<FlatIssuesStore> {
|
||||||
|
const treeStore = new TreeIssuesStore();
|
||||||
|
const rootIssue = await this.issuesService.getIssue(issueId);
|
||||||
|
treeStore.setRootIssue(rootIssue);
|
||||||
|
await treeStore.fillData(this.issuesLoader);
|
||||||
|
return treeStore.getFlatStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getListByQuery(
|
||||||
|
query: nano.MangoQuery,
|
||||||
|
): Promise<FlatIssuesStore> {
|
||||||
|
const rawData = await this.issuesService.find(query);
|
||||||
|
const store = new FlatIssuesStore();
|
||||||
|
for (let i = 0; i < rawData.length; i++) {
|
||||||
|
const issue = rawData[i];
|
||||||
|
store.push(issue);
|
||||||
|
}
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getUserValueByKey(issue: ExtendedIssue, key: string): FindResult {
|
||||||
|
const value = GetValueFromObjectByKey(issue, key);
|
||||||
|
if (value.result) {
|
||||||
|
return { result: value.result };
|
||||||
|
} else {
|
||||||
|
return { error: ListIssuesByUsersWidgetNs.Models.FindErrors.NOT_FOUND };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createMetaInfo(user: string): Record<string, any> {
|
||||||
|
return {
|
||||||
|
title: user,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-namespace */
|
||||||
|
import { IssueUrlEnhancer } from '@app/event-emitter/issue-enhancers/issue-url-enhancer';
|
||||||
|
import { TagStyledEnhancerNs } from '@app/event-emitter/issue-enhancers/tag-styled-enhancer';
|
||||||
|
import { TimePassedHighlightEnhancer } from '@app/event-emitter/issue-enhancers/time-passed-highlight-enhancer';
|
||||||
|
import {
|
||||||
|
IssuesService,
|
||||||
|
IssuesServiceNs,
|
||||||
|
} from '@app/event-emitter/issues/issues.service';
|
||||||
|
import {
|
||||||
|
TreeIssuesStore,
|
||||||
|
TreeIssuesStoreNs,
|
||||||
|
} from '@app/event-emitter/utils/tree-issues-store';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { WidgetInterface } from '../widget-interface';
|
||||||
|
|
||||||
|
export namespace RootIssueSubTreesWidgetNs {
|
||||||
|
export namespace Models {
|
||||||
|
export type Params = {
|
||||||
|
rootIssueId: number;
|
||||||
|
parentsAsGroups?: boolean;
|
||||||
|
groups?: GroupCfg;
|
||||||
|
statuses: string[];
|
||||||
|
} & TagStyledEnhancerNs.ConfigWithTagsStyles;
|
||||||
|
|
||||||
|
export type GroupCfg = {
|
||||||
|
fromIssues: Group[];
|
||||||
|
fromIssuesIncluded: boolean;
|
||||||
|
showOthers: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Group = {
|
||||||
|
name: string;
|
||||||
|
issueId: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Params = RootIssueSubTreesWidgetNs.Models.Params;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RootIssueSubTreesWidgetService
|
||||||
|
implements WidgetInterface<Params, any, any>
|
||||||
|
{
|
||||||
|
private issuesLoader: IssuesServiceNs.IssuesLoader;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private issuesService: IssuesService,
|
||||||
|
private timePassedHighlightEnhancer: TimePassedHighlightEnhancer,
|
||||||
|
private issueUrlEnhancer: IssueUrlEnhancer,
|
||||||
|
) {
|
||||||
|
this.issuesLoader = this.issuesService.createDynamicIssuesLoader();
|
||||||
|
}
|
||||||
|
|
||||||
|
isMyConfig(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async render(widgetParams: Params): Promise<any> {
|
||||||
|
const treeStore = new TreeIssuesStore();
|
||||||
|
const rootIssue = await this.issuesService.getIssue(
|
||||||
|
widgetParams.rootIssueId,
|
||||||
|
);
|
||||||
|
treeStore.setRootIssue(rootIssue);
|
||||||
|
await treeStore.fillData(this.issuesLoader);
|
||||||
|
await treeStore.enhanceIssues([
|
||||||
|
this.timePassedHighlightEnhancer,
|
||||||
|
this.issueUrlEnhancer,
|
||||||
|
TagStyledEnhancerNs.CreateTagStyledEnhancerForConfig(widgetParams),
|
||||||
|
]);
|
||||||
|
let stories: TreeIssuesStoreNs.Models.GetFlatStories.Result;
|
||||||
|
if (widgetParams.parentsAsGroups) {
|
||||||
|
stories = treeStore.getFlatStoriesByParents();
|
||||||
|
} else if (widgetParams.groups) {
|
||||||
|
const fromIssues = widgetParams.groups.fromIssues.map((g) => g.issueId);
|
||||||
|
stories = treeStore.getFlatStories(
|
||||||
|
fromIssues,
|
||||||
|
widgetParams.groups.fromIssuesIncluded,
|
||||||
|
widgetParams.groups.showOthers,
|
||||||
|
);
|
||||||
|
for (let i = 0; i < stories.length; i++) {
|
||||||
|
const store = stories[i];
|
||||||
|
const fromIssueInfo = widgetParams.groups.fromIssues.find((i) => {
|
||||||
|
return i.issueId == store.metainfo?.rootIssue?.id;
|
||||||
|
});
|
||||||
|
if (fromIssueInfo) {
|
||||||
|
store.metainfo.title = `${fromIssueInfo.name}: ${store.metainfo.title}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stories.map((s) => {
|
||||||
|
return {
|
||||||
|
data: s.store.groupByStatus(widgetParams.statuses),
|
||||||
|
metainfo: s.metainfo,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
234
libs/event-emitter/src/utils/flat-issues-store.ts
Normal file
234
libs/event-emitter/src/utils/flat-issues-store.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-namespace */
|
||||||
|
import { IssueEnhancerInterface } from '../issue-enhancers/issue-enhancer-interface';
|
||||||
|
import { IssuesServiceNs } from '../issues/issues.service';
|
||||||
|
import { RedmineTypes } from '../models/redmine-types';
|
||||||
|
|
||||||
|
export namespace FlatIssuesStoreNs {
|
||||||
|
export type IssuesLoader = IssuesServiceNs.IssuesLoader;
|
||||||
|
|
||||||
|
export namespace Models {
|
||||||
|
export type ByStatus = {
|
||||||
|
status: string;
|
||||||
|
count: number;
|
||||||
|
issues: RedmineTypes.Issue[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ByStatuses = ByStatus[];
|
||||||
|
|
||||||
|
export enum FindErrors {
|
||||||
|
NOT_FOUND = 'NOT_FOUND',
|
||||||
|
NOT_LOADED = 'NOT_LOADED',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FindResult = {
|
||||||
|
data?: RedmineTypes.Issue;
|
||||||
|
error?: FindErrors;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FlatIssuesStore {
|
||||||
|
private issues: Record<number, RedmineTypes.Issue | null> = {};
|
||||||
|
|
||||||
|
push(issue: number | string | RedmineTypes.Issue): void {
|
||||||
|
let id: any;
|
||||||
|
let data: any;
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof issue === 'number' ||
|
||||||
|
(typeof issue === 'string' && Number.isFinite(Number(issue)))
|
||||||
|
) {
|
||||||
|
id = Number(issue);
|
||||||
|
data = null;
|
||||||
|
} else if (typeof issue === 'object' && typeof issue['id'] === 'number') {
|
||||||
|
id = issue['id'];
|
||||||
|
data = issue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof id === 'number' &&
|
||||||
|
!Object.prototype.hasOwnProperty.call(this.issues, id)
|
||||||
|
) {
|
||||||
|
this.issues[id] = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fillData(loader: FlatIssuesStoreNs.IssuesLoader): Promise<void> {
|
||||||
|
const ids = [] as number[];
|
||||||
|
for (const id in this.issues) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(this.issues, id)) {
|
||||||
|
const issue = this.issues[id];
|
||||||
|
if (!issue) {
|
||||||
|
ids.push(Number(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const data = await loader(ids);
|
||||||
|
for (const id in data) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(data, id)) {
|
||||||
|
const issue = data[id];
|
||||||
|
if (issue) {
|
||||||
|
this.issues[id] = issue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async enhanceIssues(
|
||||||
|
enhancers: (IssueEnhancerInterface | null)[],
|
||||||
|
): Promise<void> {
|
||||||
|
for (const issueId in this.issues) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(this.issues, issueId)) {
|
||||||
|
let issue = this.issues[issueId];
|
||||||
|
for (let i = 0; i < enhancers.length; i++) {
|
||||||
|
const enhancer = enhancers[i];
|
||||||
|
if (!enhancer) continue;
|
||||||
|
issue = await enhancer.enhance(issue);
|
||||||
|
this.issues[issueId] = issue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getIds(): number[] {
|
||||||
|
return Object.keys(this.issues).map((i) => Number(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
getIssues(): RedmineTypes.Issue[] {
|
||||||
|
return Object.values(this.issues).filter((i) => !!i);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasIssue(id: number): boolean {
|
||||||
|
return Object.prototype.hasOwnProperty.call(this.issues, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedIssue(id: number): boolean {
|
||||||
|
return this.hasIssue(id) && !!this.issues[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
getIssue(id: number): FlatIssuesStoreNs.Models.FindResult {
|
||||||
|
if (!this.hasIssue(id)) {
|
||||||
|
return { error: FlatIssuesStoreNs.Models.FindErrors.NOT_FOUND };
|
||||||
|
} else if (!this.issues[id]) {
|
||||||
|
return { error: FlatIssuesStoreNs.Models.FindErrors.NOT_LOADED };
|
||||||
|
} else {
|
||||||
|
return { data: this.issues[id] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isFullLoaded(): boolean {
|
||||||
|
return Object.values(this.issues).indexOf(null) < 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
groupBy(
|
||||||
|
iteratee: (issue: RedmineTypes.Issue) => string | number,
|
||||||
|
): Record<string | number, RedmineTypes.Issue[]> {
|
||||||
|
const res = {} as Record<string | number, RedmineTypes.Issue[]>;
|
||||||
|
const items = this.getIssues();
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const issue = items[i];
|
||||||
|
const key = iteratee(issue);
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(res, key)) {
|
||||||
|
res[key] = [];
|
||||||
|
}
|
||||||
|
res[key].push(issue);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
groupByToStories(
|
||||||
|
iteratee: (issue: RedmineTypes.Issue) => string | number,
|
||||||
|
): Record<string | number, FlatIssuesStore> {
|
||||||
|
const res = {} as Record<string | number, FlatIssuesStore>;
|
||||||
|
const rawData = this.groupBy(iteratee);
|
||||||
|
for (const key in rawData) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(rawData, key)) {
|
||||||
|
const issues = rawData[key];
|
||||||
|
res[key] = new FlatIssuesStore();
|
||||||
|
for (let i = 0; i < issues.length; i++) {
|
||||||
|
const issue = issues[i];
|
||||||
|
res[key].push(issue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
groupByStatus(statuses: string[]): FlatIssuesStoreNs.Models.ByStatuses {
|
||||||
|
const res = [] as FlatIssuesStoreNs.Models.ByStatuses;
|
||||||
|
for (let i = 0; i < statuses.length; i++) {
|
||||||
|
const status = statuses[i];
|
||||||
|
res.push({ status: status, count: 0, issues: [] });
|
||||||
|
}
|
||||||
|
const groupedIssues = this.groupBy((issue) => issue.status.name);
|
||||||
|
for (const status in groupedIssues) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(groupedIssues, status)) {
|
||||||
|
const issues = groupedIssues[status];
|
||||||
|
const foundItem = res.find((i) => i.status === status);
|
||||||
|
if (!foundItem) continue;
|
||||||
|
foundItem.issues.push(...issues);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = 0; i < res.length; i++) {
|
||||||
|
const item = res[i];
|
||||||
|
item.count = item.issues.length;
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
groupByStatusWithExtra(
|
||||||
|
iteratee: (issue: RedmineTypes.Issue) => string | number,
|
||||||
|
statuses: string[],
|
||||||
|
): Record<string | number, FlatIssuesStoreNs.Models.ByStatuses> {
|
||||||
|
const res = {} as Record<
|
||||||
|
string | number,
|
||||||
|
FlatIssuesStoreNs.Models.ByStatuses
|
||||||
|
>;
|
||||||
|
const groupedIssues = this.groupByToStories(iteratee);
|
||||||
|
for (const key in groupedIssues) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(groupedIssues, key)) {
|
||||||
|
const store = groupedIssues[key];
|
||||||
|
res[key] = store.groupByStatus(statuses);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
groupByToMultipleStories(
|
||||||
|
iteratee: (issue: RedmineTypes.Issue) => (string | number)[],
|
||||||
|
): Record<string | number, FlatIssuesStore> {
|
||||||
|
const res = {} as Record<string | number, FlatIssuesStore>;
|
||||||
|
const items = this.getIssues();
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const issue = items[i];
|
||||||
|
const keys = iteratee(issue);
|
||||||
|
for (let j = 0; j < keys.length; j++) {
|
||||||
|
const key = keys[j];
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(res, key)) {
|
||||||
|
res[key] = new FlatIssuesStore();
|
||||||
|
}
|
||||||
|
res[key].push(issue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
groupByStatusWithExtraToMultipleStories(
|
||||||
|
iteratee: (issue: RedmineTypes.Issue) => (string | number)[],
|
||||||
|
statuses: string[],
|
||||||
|
): Record<string | number, FlatIssuesStoreNs.Models.ByStatuses> {
|
||||||
|
const res = {} as Record<
|
||||||
|
string | number,
|
||||||
|
FlatIssuesStoreNs.Models.ByStatuses
|
||||||
|
>;
|
||||||
|
const groupedIssues = this.groupByToMultipleStories(iteratee);
|
||||||
|
for (const key in groupedIssues) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(groupedIssues, key)) {
|
||||||
|
const store = groupedIssues[key];
|
||||||
|
res[key] = store.groupByStatus(statuses);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
libs/event-emitter/src/utils/get-value-from-object-by-key.ts
Normal file
15
libs/event-emitter/src/utils/get-value-from-object-by-key.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
export function GetValueFromObjectByKey(
|
||||||
|
obj: any,
|
||||||
|
key: string,
|
||||||
|
): { result?: any; error?: string } {
|
||||||
|
const keys = key.split('.');
|
||||||
|
let res: any = obj;
|
||||||
|
for (let i = 0; i < keys.length; i++) {
|
||||||
|
const k = keys[i];
|
||||||
|
if (!res.hasOwnProperty(k)) {
|
||||||
|
return { error: 'NOT_FOUND' };
|
||||||
|
}
|
||||||
|
res = res[k];
|
||||||
|
}
|
||||||
|
return { result: res };
|
||||||
|
}
|
||||||
213
libs/event-emitter/src/utils/tree-issues-store.ts
Normal file
213
libs/event-emitter/src/utils/tree-issues-store.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-namespace */
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { IssueEnhancerInterface } from '../issue-enhancers/issue-enhancer-interface';
|
||||||
|
import { RedmineTypes } from '../models/redmine-types';
|
||||||
|
import { FlatIssuesStore, FlatIssuesStoreNs } from './flat-issues-store';
|
||||||
|
|
||||||
|
export namespace TreeIssuesStoreNs {
|
||||||
|
export namespace Models {
|
||||||
|
export namespace GetFlatStories {
|
||||||
|
export type Item = {
|
||||||
|
metainfo: Record<string, any>;
|
||||||
|
store: FlatIssuesStore;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Result = Item[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TreeIssuesStore {
|
||||||
|
private logger = new Logger(TreeIssuesStore.name);
|
||||||
|
private rootIssue: RedmineTypes.Issue;
|
||||||
|
private flatStore: FlatIssuesStore;
|
||||||
|
|
||||||
|
setRootIssue(issue: RedmineTypes.Issue): void {
|
||||||
|
this.rootIssue = issue;
|
||||||
|
this.prepareFlatIssuesStore();
|
||||||
|
this.logger.debug(`Set root issue_id - ${JSON.stringify(issue.id)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fillData(loader: FlatIssuesStoreNs.IssuesLoader): Promise<void> {
|
||||||
|
await this.flatStore.fillData(loader);
|
||||||
|
}
|
||||||
|
|
||||||
|
async enhanceIssues(enhancers: IssueEnhancerInterface[]): Promise<void> {
|
||||||
|
await this.flatStore.enhanceIssues(enhancers);
|
||||||
|
}
|
||||||
|
|
||||||
|
getFlatStore(): FlatIssuesStore {
|
||||||
|
return this.flatStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
private prepareFlatIssuesStore(): void {
|
||||||
|
this.flatStore = new FlatIssuesStore();
|
||||||
|
this.flatStore.push(this.rootIssue);
|
||||||
|
if (this.rootIssue.children && this.rootIssue.children.length > 0) {
|
||||||
|
this.fillChildrenFlatIssuesStore(this.rootIssue.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fillChildrenFlatIssuesStore(
|
||||||
|
childrenIssues: RedmineTypes.Children,
|
||||||
|
): void {
|
||||||
|
for (let i = 0; i < childrenIssues.length; i++) {
|
||||||
|
const issue = childrenIssues[i];
|
||||||
|
this.flatStore.push(issue.id);
|
||||||
|
if (issue.children && issue.children.length > 0) {
|
||||||
|
this.fillChildrenFlatIssuesStore(issue.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isFullLoaded(): boolean {
|
||||||
|
return this.flatStore.isFullLoaded();
|
||||||
|
}
|
||||||
|
|
||||||
|
getFlatStories(
|
||||||
|
fromIssues: number[],
|
||||||
|
fromIssuesIncluded: boolean,
|
||||||
|
showOthers: boolean,
|
||||||
|
): TreeIssuesStoreNs.Models.GetFlatStories.Result {
|
||||||
|
const res = [] as TreeIssuesStoreNs.Models.GetFlatStories.Result;
|
||||||
|
|
||||||
|
for (let i = 0; i < fromIssues.length; i++) {
|
||||||
|
const fromIssue = this.flatStore.getIssue(fromIssues[i]);
|
||||||
|
if (!fromIssue.data) continue;
|
||||||
|
const store = new FlatIssuesStore();
|
||||||
|
this.putIssuesToStore(
|
||||||
|
store,
|
||||||
|
fromIssue.data,
|
||||||
|
fromIssues,
|
||||||
|
fromIssuesIncluded,
|
||||||
|
);
|
||||||
|
res.push({
|
||||||
|
store: store,
|
||||||
|
metainfo: this.createMetaInfo(fromIssue.data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showOthers) {
|
||||||
|
const othersStore = new FlatIssuesStore();
|
||||||
|
res.push({
|
||||||
|
store: othersStore,
|
||||||
|
metainfo: this.createMetaInfo(this.rootIssue),
|
||||||
|
});
|
||||||
|
this.putIssuesToStore(
|
||||||
|
othersStore,
|
||||||
|
this.rootIssue,
|
||||||
|
fromIssues,
|
||||||
|
fromIssuesIncluded,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
private putIssuesToStore(
|
||||||
|
store: FlatIssuesStore,
|
||||||
|
rootIssue: RedmineTypes.Issue,
|
||||||
|
fromIssues: number[],
|
||||||
|
fromIssuesIncluded: boolean,
|
||||||
|
): void {
|
||||||
|
if (fromIssuesIncluded) {
|
||||||
|
store.push(rootIssue);
|
||||||
|
}
|
||||||
|
if (!rootIssue.children || rootIssue.children.length <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < rootIssue.children.length; i++) {
|
||||||
|
const childIssue = rootIssue.children[i];
|
||||||
|
const issueData = this.flatStore.getIssue(childIssue.id);
|
||||||
|
if (!issueData.data) continue;
|
||||||
|
if (fromIssues.indexOf(issueData.data.id) < 0) {
|
||||||
|
store.push(issueData.data);
|
||||||
|
this.putIssuesToStore(
|
||||||
|
store,
|
||||||
|
issueData.data,
|
||||||
|
fromIssues,
|
||||||
|
fromIssuesIncluded,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (fromIssuesIncluded == false) {
|
||||||
|
store.push(issueData.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createMetaInfo(issue: RedmineTypes.Issue): Record<string, any> {
|
||||||
|
return {
|
||||||
|
title: `${issue.tracker.name} #${issue.id} - ${issue.subject}`,
|
||||||
|
rootIssue: {
|
||||||
|
id: issue.id,
|
||||||
|
tracker: issue.tracker,
|
||||||
|
subject: issue.subject,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getParents(issue: RedmineTypes.Issue): RedmineTypes.Issue[] {
|
||||||
|
const res = [] as RedmineTypes.Issue[];
|
||||||
|
let parentId: number;
|
||||||
|
let parentIssueData: FlatIssuesStoreNs.Models.FindResult;
|
||||||
|
let parentIssue: RedmineTypes.Issue;
|
||||||
|
parentId = issue.parent?.id;
|
||||||
|
while (parentId) {
|
||||||
|
parentIssueData = this.flatStore.getIssue(parentId);
|
||||||
|
parentIssue = parentIssueData.data;
|
||||||
|
if (!parentIssue) break;
|
||||||
|
res.push(parentIssue);
|
||||||
|
parentId = parentIssue.parent?.id;
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
getFlatStoriesByParents(): TreeIssuesStoreNs.Models.GetFlatStories.Result {
|
||||||
|
const stories = [] as TreeIssuesStoreNs.Models.GetFlatStories.Result;
|
||||||
|
return this.fillFlatStoriesByParents(stories, this.rootIssue);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fillFlatStoriesByParents(
|
||||||
|
stories: TreeIssuesStoreNs.Models.GetFlatStories.Result,
|
||||||
|
rootIssue: RedmineTypes.Issue,
|
||||||
|
): TreeIssuesStoreNs.Models.GetFlatStories.Result {
|
||||||
|
if (!rootIssue.children || rootIssue.children.length <= 0) {
|
||||||
|
return stories;
|
||||||
|
}
|
||||||
|
const store = new FlatIssuesStore();
|
||||||
|
stories.push({
|
||||||
|
store: store,
|
||||||
|
metainfo: this.createMetaInfo(rootIssue),
|
||||||
|
});
|
||||||
|
for (let i = 0; i < rootIssue.children.length; i++) {
|
||||||
|
const child = rootIssue.children[i];
|
||||||
|
const childIssueData = this.flatStore.getIssue(child.id);
|
||||||
|
if (!childIssueData.data) continue;
|
||||||
|
store.push(childIssueData.data);
|
||||||
|
this.fillFlatStoriesByParents(stories, childIssueData.data);
|
||||||
|
}
|
||||||
|
return stories;
|
||||||
|
}
|
||||||
|
|
||||||
|
getIssuesWithChildren(): RedmineTypes.Issue[] {
|
||||||
|
return this.fillIssuesWithChildren(this.rootIssue, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fillIssuesWithChildren(
|
||||||
|
issue: RedmineTypes.Issue,
|
||||||
|
data: RedmineTypes.Issue[],
|
||||||
|
): RedmineTypes.Issue[] {
|
||||||
|
if (!issue || !issue.children || issue.children.length <= 0) return;
|
||||||
|
data.push(issue);
|
||||||
|
for (let i = 0; i < issue.children.length; i++) {
|
||||||
|
const childIssueResult = this.getFlatStore().getIssue(
|
||||||
|
issue.children[i].id,
|
||||||
|
);
|
||||||
|
if (!childIssueResult || !childIssueResult.data) continue;
|
||||||
|
const childIssue = childIssueResult.data;
|
||||||
|
this.fillIssuesWithChildren(childIssue, data);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
package-lock.json
generated
39
package-lock.json
generated
|
|
@ -22,6 +22,7 @@
|
||||||
"handlebars": "^4.7.7",
|
"handlebars": "^4.7.7",
|
||||||
"hbs": "^4.2.0",
|
"hbs": "^4.2.0",
|
||||||
"imap-simple": "^5.1.0",
|
"imap-simple": "^5.1.0",
|
||||||
|
"jsonc-parser": "^3.2.0",
|
||||||
"luxon": "^3.1.0",
|
"luxon": "^3.1.0",
|
||||||
"nano": "^10.0.0",
|
"nano": "^10.0.0",
|
||||||
"node-telegram-bot-api": "^0.59.0",
|
"node-telegram-bot-api": "^0.59.0",
|
||||||
|
|
@ -198,6 +199,12 @@
|
||||||
"node": ">=8.0.0"
|
"node": ">=8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@angular-devkit/schematics/node_modules/jsonc-parser": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/@angular-devkit/schematics/node_modules/rxjs": {
|
"node_modules/@angular-devkit/schematics/node_modules/rxjs": {
|
||||||
"version": "6.6.7",
|
"version": "6.6.7",
|
||||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz",
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz",
|
||||||
|
|
@ -1707,6 +1714,12 @@
|
||||||
"yarn": ">= 1.13.0"
|
"yarn": ">= 1.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/schematics/node_modules/jsonc-parser": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/@nestjs/schematics/node_modules/rxjs": {
|
"node_modules/@nestjs/schematics/node_modules/rxjs": {
|
||||||
"version": "6.6.7",
|
"version": "6.6.7",
|
||||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz",
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz",
|
||||||
|
|
@ -7091,10 +7104,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jsonc-parser": {
|
"node_modules/jsonc-parser": {
|
||||||
"version": "3.0.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz",
|
||||||
"integrity": "sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA==",
|
"integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/jsonfile": {
|
"node_modules/jsonfile": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
|
|
@ -10448,6 +10460,12 @@
|
||||||
"rxjs": "6.6.7"
|
"rxjs": "6.6.7"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"jsonc-parser": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"rxjs": {
|
"rxjs": {
|
||||||
"version": "6.6.7",
|
"version": "6.6.7",
|
||||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz",
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz",
|
||||||
|
|
@ -11625,6 +11643,12 @@
|
||||||
"rxjs": "6.6.7"
|
"rxjs": "6.6.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"jsonc-parser": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"rxjs": {
|
"rxjs": {
|
||||||
"version": "6.6.7",
|
"version": "6.6.7",
|
||||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz",
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz",
|
||||||
|
|
@ -15740,10 +15764,9 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"jsonc-parser": {
|
"jsonc-parser": {
|
||||||
"version": "3.0.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz",
|
||||||
"integrity": "sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA==",
|
"integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"jsonfile": {
|
"jsonfile": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@
|
||||||
"handlebars": "^4.7.7",
|
"handlebars": "^4.7.7",
|
||||||
"hbs": "^4.2.0",
|
"hbs": "^4.2.0",
|
||||||
"imap-simple": "^5.1.0",
|
"imap-simple": "^5.1.0",
|
||||||
|
"jsonc-parser": "^3.2.0",
|
||||||
"luxon": "^3.1.0",
|
"luxon": "^3.1.0",
|
||||||
"nano": "^10.0.0",
|
"nano": "^10.0.0",
|
||||||
"node-telegram-bot-api": "^0.59.0",
|
"node-telegram-bot-api": "^0.59.0",
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,18 @@ import { RedmineIssuesCacheWriterService } from '@app/event-emitter/issue-cache-
|
||||||
import { EnhancerService } from '@app/event-emitter/issue-enhancers/enhancer.service';
|
import { EnhancerService } from '@app/event-emitter/issue-enhancers/enhancer.service';
|
||||||
import { TimestampEnhancer } from '@app/event-emitter/issue-enhancers/timestamps-enhancer';
|
import { TimestampEnhancer } from '@app/event-emitter/issue-enhancers/timestamps-enhancer';
|
||||||
import { MainController } from '@app/event-emitter/main/main.controller';
|
import { MainController } from '@app/event-emitter/main/main.controller';
|
||||||
import { CacheModule, Logger, Module, OnModuleInit } from '@nestjs/common';
|
import {
|
||||||
import { ConfigModule } from '@nestjs/config';
|
CacheModule,
|
||||||
|
Inject,
|
||||||
|
Logger,
|
||||||
|
Module,
|
||||||
|
OnModuleInit,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { switchMap, tap } from 'rxjs';
|
import { switchMap, tap } from 'rxjs';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
import configuration from './configs/app';
|
import configuration from './configs/app';
|
||||||
import { RedminePublicUrlConverter } from './converters/redmine-public-url.converter';
|
|
||||||
import { Changes } from './couchdb-datasources/changes';
|
import { Changes } from './couchdb-datasources/changes';
|
||||||
import { CurrentUserEnhancer } from './issue-enhancers/current-user-enhancer';
|
import { CurrentUserEnhancer } from './issue-enhancers/current-user-enhancer';
|
||||||
import { CustomFieldsEnhancer } from './issue-enhancers/custom-fields-enhancer';
|
import { CustomFieldsEnhancer } from './issue-enhancers/custom-fields-enhancer';
|
||||||
|
|
@ -32,6 +37,14 @@ import { ChangesService } from './changes/changes.service';
|
||||||
import { DailyEccmReportsDatasource } from './couchdb-datasources/daily-eccm-reports.datasource';
|
import { DailyEccmReportsDatasource } from './couchdb-datasources/daily-eccm-reports.datasource';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { DailyEccmReportTask } from './reports/daily-eccm.report.task';
|
import { DailyEccmReportTask } from './reports/daily-eccm.report.task';
|
||||||
|
import { DailyEccmReportsUserCommentsDatasource } from './couchdb-datasources/daily-eccm-reports-user-comments.datasource';
|
||||||
|
import { DailyEccmUserCommentsService } from './reports/daily-eccm-user-comments.service';
|
||||||
|
import { SetDailyEccmUserCommentBotHandlerService } from './telegram-bot/handlers/set-daily-eccm-user-comment.bot-handler.service';
|
||||||
|
import { DailyEccmWithExtraDataService } from './reports/daily-eccm-with-extra-data.service';
|
||||||
|
import { SimpleKanbanBoardController } from './dashboards/simple-kanban-board.controller';
|
||||||
|
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';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -49,6 +62,7 @@ import { DailyEccmReportTask } from './reports/daily-eccm.report.task';
|
||||||
MainController,
|
MainController,
|
||||||
CurrentIssuesEccmReportController,
|
CurrentIssuesEccmReportController,
|
||||||
DailyEccmReportController,
|
DailyEccmReportController,
|
||||||
|
SimpleKanbanBoardController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AppService,
|
AppService,
|
||||||
|
|
@ -57,7 +71,6 @@ import { DailyEccmReportTask } from './reports/daily-eccm.report.task';
|
||||||
PersonalNotificationsService,
|
PersonalNotificationsService,
|
||||||
StatusChangeNotificationsService,
|
StatusChangeNotificationsService,
|
||||||
Changes,
|
Changes,
|
||||||
RedminePublicUrlConverter,
|
|
||||||
ChangesCacheWriterService,
|
ChangesCacheWriterService,
|
||||||
TelegramBotService,
|
TelegramBotService,
|
||||||
UserMetaInfoService,
|
UserMetaInfoService,
|
||||||
|
|
@ -70,6 +83,21 @@ import { DailyEccmReportTask } from './reports/daily-eccm.report.task';
|
||||||
ChangesService,
|
ChangesService,
|
||||||
DailyEccmReportsDatasource,
|
DailyEccmReportsDatasource,
|
||||||
DailyEccmReportTask,
|
DailyEccmReportTask,
|
||||||
|
DailyEccmReportsUserCommentsDatasource,
|
||||||
|
DailyEccmUserCommentsService,
|
||||||
|
SetDailyEccmUserCommentBotHandlerService,
|
||||||
|
DailyEccmWithExtraDataService,
|
||||||
|
IssuesByTagsWidgetService,
|
||||||
|
{
|
||||||
|
provide: 'CATEGORY_MERGE_TO_TAGS_ENHANCER',
|
||||||
|
useFactory: (configService: ConfigService) => {
|
||||||
|
const eccmProjectName = configService.get<string>(
|
||||||
|
'redmineEccm.projectName',
|
||||||
|
);
|
||||||
|
return new CategoryMergeToTagsEnhancer([eccmProjectName]);
|
||||||
|
},
|
||||||
|
inject: [ConfigService],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements OnModuleInit {
|
export class AppModule implements OnModuleInit {
|
||||||
|
|
@ -82,11 +110,17 @@ export class AppModule implements OnModuleInit {
|
||||||
private timestampEnhancer: TimestampEnhancer,
|
private timestampEnhancer: TimestampEnhancer,
|
||||||
private customFieldsEnhancer: CustomFieldsEnhancer,
|
private customFieldsEnhancer: CustomFieldsEnhancer,
|
||||||
private currentUserEnhancer: CurrentUserEnhancer,
|
private currentUserEnhancer: CurrentUserEnhancer,
|
||||||
|
private issueUrlEnhancer: IssueUrlEnhancer,
|
||||||
private statusChangeNotificationsService: StatusChangeNotificationsService,
|
private statusChangeNotificationsService: StatusChangeNotificationsService,
|
||||||
private changesCacheWriterService: ChangesCacheWriterService,
|
private changesCacheWriterService: ChangesCacheWriterService,
|
||||||
private telegramBotService: TelegramBotService,
|
private telegramBotService: TelegramBotService,
|
||||||
private personalNotificationAdapterService: PersonalNotificationAdapterService,
|
private personalNotificationAdapterService: PersonalNotificationAdapterService,
|
||||||
private statusChangeAdapterService: StatusChangeAdapterService,
|
private statusChangeAdapterService: StatusChangeAdapterService,
|
||||||
|
private dailyEccmUserCommentsService: DailyEccmUserCommentsService,
|
||||||
|
private setDailyEccmUserCommentBotHandlerService: SetDailyEccmUserCommentBotHandlerService,
|
||||||
|
|
||||||
|
@Inject('CATEGORY_MERGE_TO_TAGS_ENHANCER')
|
||||||
|
private categoryMergeToTagsEnhancer: CategoryMergeToTagsEnhancer,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
|
|
@ -95,11 +129,14 @@ export class AppModule implements OnModuleInit {
|
||||||
Changes.getDatasource();
|
Changes.getDatasource();
|
||||||
UserMetaInfo.getDatasource();
|
UserMetaInfo.getDatasource();
|
||||||
DailyEccmReportsDatasource.getDatasource();
|
DailyEccmReportsDatasource.getDatasource();
|
||||||
|
DailyEccmReportsUserCommentsDatasource.getDatasource();
|
||||||
|
|
||||||
this.enhancerService.addEnhancer([
|
this.enhancerService.addEnhancer([
|
||||||
this.timestampEnhancer,
|
this.timestampEnhancer,
|
||||||
this.customFieldsEnhancer,
|
this.customFieldsEnhancer,
|
||||||
this.currentUserEnhancer,
|
this.currentUserEnhancer,
|
||||||
|
this.issueUrlEnhancer,
|
||||||
|
this.categoryMergeToTagsEnhancer,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
this.personalNotificationsService.$messages.subscribe((resp) => {
|
this.personalNotificationsService.$messages.subscribe((resp) => {
|
||||||
|
|
@ -158,5 +195,19 @@ export class AppModule implements OnModuleInit {
|
||||||
`Save result process success finished, issue_id = ${args.saveResult.current.id}`,
|
`Save result process success finished, issue_id = ${args.saveResult.current.id}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.initDailyEccmUserCommentsPipeline();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initDailyEccmUserCommentsPipeline(): void {
|
||||||
|
this.setDailyEccmUserCommentBotHandlerService.$messages.subscribe(
|
||||||
|
(data) => {
|
||||||
|
this.dailyEccmUserCommentsService.setComment(
|
||||||
|
data.userId,
|
||||||
|
data.date,
|
||||||
|
data.comment,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,14 @@ import RedmineStatusesConfigLoader from './statuses.config';
|
||||||
import RedmineStatusChangesConfigLoader from './status-changes.config';
|
import RedmineStatusChangesConfigLoader from './status-changes.config';
|
||||||
import RedmineEccmConfig from './eccm.config';
|
import RedmineEccmConfig from './eccm.config';
|
||||||
import RedmineCurrentUserRulesConfig from './current-user-rules.config';
|
import RedmineCurrentUserRulesConfig from './current-user-rules.config';
|
||||||
|
import SimpleKanbanBoardConfig from './simple-kanban-board.config';
|
||||||
|
|
||||||
const redmineIssueEventEmitterConfig = RedmineIssueEventEmitterConfigLoader();
|
const redmineIssueEventEmitterConfig = RedmineIssueEventEmitterConfigLoader();
|
||||||
const redmineStatusesConfig = RedmineStatusesConfigLoader();
|
const redmineStatusesConfig = RedmineStatusesConfigLoader();
|
||||||
const redmineStatusChanges = RedmineStatusChangesConfigLoader();
|
const redmineStatusChanges = RedmineStatusChangesConfigLoader();
|
||||||
const redmineEccm = RedmineEccmConfig();
|
const redmineEccm = RedmineEccmConfig();
|
||||||
const redmineCurrentUserRules = RedmineCurrentUserRulesConfig();
|
const redmineCurrentUserRules = RedmineCurrentUserRulesConfig();
|
||||||
|
const simpleKanbanBoard = SimpleKanbanBoardConfig();
|
||||||
|
|
||||||
let appConfig: AppConfig;
|
let appConfig: AppConfig;
|
||||||
|
|
||||||
|
|
@ -36,6 +38,7 @@ export default (): AppConfig => {
|
||||||
redmineStatusChanges: redmineStatusChanges,
|
redmineStatusChanges: redmineStatusChanges,
|
||||||
redmineEccm: redmineEccm,
|
redmineEccm: redmineEccm,
|
||||||
redmineCurrentUserRules: redmineCurrentUserRules,
|
redmineCurrentUserRules: redmineCurrentUserRules,
|
||||||
|
simpleKanbanBoard: simpleKanbanBoard,
|
||||||
};
|
};
|
||||||
|
|
||||||
return appConfig;
|
return appConfig;
|
||||||
|
|
|
||||||
27
src/configs/simple-kanban-board.config.ts
Normal file
27
src/configs/simple-kanban-board.config.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { parse } from 'jsonc-parser';
|
||||||
|
|
||||||
|
export type SimpleKanbanBoardConfig = {
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let config: SimpleKanbanBoardConfig;
|
||||||
|
|
||||||
|
export default (): SimpleKanbanBoardConfig => {
|
||||||
|
if (config) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userDefinedConfigPath =
|
||||||
|
process.env['ELTEX_REDMINE_HELPER_KANBAN_SIMPLE_BOARD_CONFIG_PATH'];
|
||||||
|
const defaultConfigPath = join('configs', 'simple-kanban-board-config.jsonc');
|
||||||
|
|
||||||
|
const configPath = userDefinedConfigPath || defaultConfigPath;
|
||||||
|
|
||||||
|
const rawData = readFileSync(configPath, { encoding: 'utf-8' });
|
||||||
|
|
||||||
|
config = parse(rawData);
|
||||||
|
|
||||||
|
return config;
|
||||||
|
};
|
||||||
1
src/consts/date-time.consts.ts
Normal file
1
src/consts/date-time.consts.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export const ISO_DATE_FORMAT = 'yyyy-MM-dd';
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class RedminePublicUrlConverter {
|
|
||||||
private redminePublicUrlPrefix: string;
|
|
||||||
|
|
||||||
constructor(private configService: ConfigService) {
|
|
||||||
this.redminePublicUrlPrefix = this.configService.get<string>(
|
|
||||||
'redmineIssueEventEmitterConfig.redmineUrlPublic',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
convert(issueId: number | string): string {
|
|
||||||
return `${this.redminePublicUrlPrefix}/issues/${issueId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
getUrl(issueId: number | string): string {
|
|
||||||
return this.convert(issueId);
|
|
||||||
}
|
|
||||||
|
|
||||||
getHtmlHref(issue: RedmineTypes.Issue): string {
|
|
||||||
const url = this.getUrl(issue.id);
|
|
||||||
return `<a href="${url}">${issue.tracker.name} #${issue.id}</a>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { CouchDb } from '@app/event-emitter/couchdb-datasources/couchdb';
|
||||||
|
import nano from 'nano';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import configuration from '../configs/app';
|
||||||
|
import { DailyEccmUserComments } from 'src/reports/daily-eccm-user-comments.service';
|
||||||
|
|
||||||
|
const config = configuration();
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DailyEccmReportsUserCommentsDatasource {
|
||||||
|
private static logger = new Logger(
|
||||||
|
DailyEccmReportsUserCommentsDatasource.name,
|
||||||
|
);
|
||||||
|
private static db = null;
|
||||||
|
private static initilized = false;
|
||||||
|
|
||||||
|
static async getDatasource(): Promise<
|
||||||
|
nano.DocumentScope<DailyEccmUserComments.Models.Item>
|
||||||
|
> {
|
||||||
|
if (DailyEccmReportsUserCommentsDatasource.initilized) {
|
||||||
|
return DailyEccmReportsUserCommentsDatasource.db;
|
||||||
|
}
|
||||||
|
DailyEccmReportsUserCommentsDatasource.initilized = true;
|
||||||
|
const n = CouchDb.getCouchDb();
|
||||||
|
const dbName = config.couchDb.dbs.eccmDailyReportsUserComments;
|
||||||
|
const dbs = await n.db.list();
|
||||||
|
if (!dbs.includes(dbName)) {
|
||||||
|
await n.db.create(dbName);
|
||||||
|
}
|
||||||
|
DailyEccmReportsUserCommentsDatasource.db = await n.db.use(dbName);
|
||||||
|
DailyEccmReportsUserCommentsDatasource.initilized = true;
|
||||||
|
DailyEccmReportsUserCommentsDatasource.logger.log(
|
||||||
|
`Connected to eccm_daily_reports_user_comments db - ${dbName}`,
|
||||||
|
);
|
||||||
|
return DailyEccmReportsUserCommentsDatasource.db;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDatasource(): Promise<
|
||||||
|
nano.DocumentScope<DailyEccmUserComments.Models.Item>
|
||||||
|
> {
|
||||||
|
return await DailyEccmReportsUserCommentsDatasource.getDatasource();
|
||||||
|
}
|
||||||
|
}
|
||||||
132
src/dashboards/simple-kanban-board.controller.ts
Normal file
132
src/dashboards/simple-kanban-board.controller.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { DynamicLoader } from '@app/event-emitter/configs/dynamic-loader';
|
||||||
|
import { RedmineEventsGateway } from '@app/event-emitter/events/redmine-events.gateway';
|
||||||
|
import { IssuesService } from '@app/event-emitter/issues/issues.service';
|
||||||
|
import { ListIssuesByFieldsWidgetService } from '@app/event-emitter/project-dashboard/widgets/list-issues-by-fields.widget.service';
|
||||||
|
import { ListIssuesByUsersLikeJiraWidgetService } from '@app/event-emitter/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service';
|
||||||
|
import { ListIssuesByUsersWidgetService } from '@app/event-emitter/project-dashboard/widgets/list-issues-by-users.widget.service';
|
||||||
|
import {
|
||||||
|
RootIssueSubTreesWidgetNs,
|
||||||
|
RootIssueSubTreesWidgetService,
|
||||||
|
} from '@app/event-emitter/project-dashboard/widgets/root-issue-subtrees.widget.service';
|
||||||
|
import { TreeIssuesStore } from '@app/event-emitter/utils/tree-issues-store';
|
||||||
|
import { Controller, Get, Logger, Param, Render } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { parse } from 'jsonc-parser';
|
||||||
|
import { IssuesByTagsWidgetService } from './widgets/issues-by-tags.widget.service';
|
||||||
|
|
||||||
|
@Controller('simple-kanban-board')
|
||||||
|
export class SimpleKanbanBoardController {
|
||||||
|
private logger = new Logger(SimpleKanbanBoardController.name);
|
||||||
|
private path: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private rootIssueSubTreesWidgetService: RootIssueSubTreesWidgetService,
|
||||||
|
private dynamicLoader: DynamicLoader,
|
||||||
|
private configService: ConfigService,
|
||||||
|
private listIssuesByUsersWidgetService: ListIssuesByUsersWidgetService,
|
||||||
|
private listIssuesByUsersLikeJiraWidgetService: ListIssuesByUsersLikeJiraWidgetService,
|
||||||
|
private issuesByTagsWidgetService: IssuesByTagsWidgetService,
|
||||||
|
private redmineEventsGateway: RedmineEventsGateway,
|
||||||
|
private listIssuesByFieldsWidgetService: ListIssuesByFieldsWidgetService,
|
||||||
|
private issuesService: IssuesService,
|
||||||
|
) {
|
||||||
|
this.path = this.configService.get<string>('simpleKanbanBoard.path');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/tree/:name/raw')
|
||||||
|
async getRawData(@Param('name') name: string): Promise<any> {
|
||||||
|
const cfg = this.dynamicLoader.load(name, {
|
||||||
|
path: this.path,
|
||||||
|
ext: 'jsonc',
|
||||||
|
parser: parse,
|
||||||
|
});
|
||||||
|
return await this.rootIssueSubTreesWidgetService.render(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/tree/:name')
|
||||||
|
@Render('simple-kanban-board')
|
||||||
|
async get(@Param('name') name: string): Promise<any> {
|
||||||
|
return await this.getRawData(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/tree/:name/refresh')
|
||||||
|
async refreshTree(@Param('name') name: string): Promise<any> {
|
||||||
|
const cfg = this.dynamicLoader.load(name, {
|
||||||
|
path: this.path,
|
||||||
|
ext: 'jsonc',
|
||||||
|
parser: parse,
|
||||||
|
});
|
||||||
|
const rootIssue = await this.issuesService.getIssueFromCache(
|
||||||
|
cfg.rootIssueId,
|
||||||
|
);
|
||||||
|
const issues = await this.issuesService.getIssuesWithChildren(rootIssue);
|
||||||
|
const issuesIds = issues.map((i) => i.id);
|
||||||
|
this.logger.debug(`Issues for tree refresh - ${issuesIds}`);
|
||||||
|
this.redmineEventsGateway.addIssues(issuesIds);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/by-users/:name/raw')
|
||||||
|
async getByUsersRawData(@Param('name') name: string): Promise<any> {
|
||||||
|
const cfg = this.dynamicLoader.load(name, {
|
||||||
|
path: this.path,
|
||||||
|
ext: 'jsonc',
|
||||||
|
parser: parse,
|
||||||
|
});
|
||||||
|
return await this.listIssuesByUsersWidgetService.render(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/by-users/:name')
|
||||||
|
@Render('simple-kanban-board')
|
||||||
|
async getByUsers(@Param('name') name: string): Promise<any> {
|
||||||
|
return await this.getByUsersRawData(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/by-users-like-jira/:name/raw')
|
||||||
|
async getByUsersLikeJiraRawData(@Param('name') name: string): Promise<any> {
|
||||||
|
const cfg = this.dynamicLoader.load(name, {
|
||||||
|
path: this.path,
|
||||||
|
ext: 'jsonc',
|
||||||
|
parser: parse,
|
||||||
|
});
|
||||||
|
return await this.listIssuesByUsersLikeJiraWidgetService.render(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/by-users-like-jira/:name')
|
||||||
|
@Render('simple-kanban-board')
|
||||||
|
async getByUsersLikeJira(@Param('name') name: string): Promise<any> {
|
||||||
|
return await this.getByUsersLikeJiraRawData(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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-kanban-board')
|
||||||
|
async getByTags(@Param('name') name: string): Promise<any> {
|
||||||
|
return await this.getByTagsRawData(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/by-fields/:name/raw')
|
||||||
|
async getByFieldsRawData(@Param('name') name: string): Promise<any> {
|
||||||
|
const cfg = this.dynamicLoader.load(name, {
|
||||||
|
path: this.path,
|
||||||
|
ext: 'jsonc',
|
||||||
|
parser: parse,
|
||||||
|
});
|
||||||
|
return await this.listIssuesByFieldsWidgetService.render(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/by-fields/:name')
|
||||||
|
@Render('simple-kanban-board')
|
||||||
|
async getByFields(@Param('name') name: string): Promise<any> {
|
||||||
|
return await this.getByFieldsRawData(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
src/dashboards/widgets/issues-by-tags.widget.service.ts
Normal file
129
src/dashboards/widgets/issues-by-tags.widget.service.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-namespace */
|
||||||
|
import { IssueUrlEnhancer } from '@app/event-emitter/issue-enhancers/issue-url-enhancer';
|
||||||
|
import { TagStyledEnhancerNs } from '@app/event-emitter/issue-enhancers/tag-styled-enhancer';
|
||||||
|
import { TimePassedHighlightEnhancer } from '@app/event-emitter/issue-enhancers/time-passed-highlight-enhancer';
|
||||||
|
import {
|
||||||
|
IssuesService,
|
||||||
|
IssuesServiceNs,
|
||||||
|
} from '@app/event-emitter/issues/issues.service';
|
||||||
|
import { WidgetInterface } from '@app/event-emitter/project-dashboard/widget-interface';
|
||||||
|
import { FlatIssuesStore } from '@app/event-emitter/utils/flat-issues-store';
|
||||||
|
import { GetValueFromObjectByKey } from '@app/event-emitter/utils/get-value-from-object-by-key';
|
||||||
|
import { TreeIssuesStore } from '@app/event-emitter/utils/tree-issues-store';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import nano from 'nano';
|
||||||
|
|
||||||
|
export namespace IssuesByTagsWidgetNs {
|
||||||
|
export type Params = {
|
||||||
|
fromRootIssueId?: number;
|
||||||
|
fromQuery?: nano.MangoQuery;
|
||||||
|
tagsSort?: boolean;
|
||||||
|
showEmptyTags?: string;
|
||||||
|
statuses: string[];
|
||||||
|
} & TagStyledEnhancerNs.ConfigWithTagsStyles;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Params = IssuesByTagsWidgetNs.Params;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class IssuesByTagsWidgetService
|
||||||
|
implements WidgetInterface<Params, any, any>
|
||||||
|
{
|
||||||
|
private logger = new Logger(IssuesByTagsWidgetService.name);
|
||||||
|
private issuesLoader: IssuesServiceNs.IssuesLoader;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private issuesService: IssuesService,
|
||||||
|
private timePassedHighlightEnhancer: TimePassedHighlightEnhancer,
|
||||||
|
private issueUrlEnhancer: IssueUrlEnhancer,
|
||||||
|
) {
|
||||||
|
this.issuesLoader = this.issuesService.createDynamicIssuesLoader();
|
||||||
|
}
|
||||||
|
|
||||||
|
isMyConfig(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async render(widgetParams: Params): Promise<any> {
|
||||||
|
let store: FlatIssuesStore;
|
||||||
|
if (widgetParams.fromRootIssueId) {
|
||||||
|
store = await this.getListFromRoot(widgetParams.fromRootIssueId);
|
||||||
|
} else if (widgetParams.fromQuery) {
|
||||||
|
store = await this.getListByQuery(widgetParams.fromQuery);
|
||||||
|
} else {
|
||||||
|
const errMsg = `Wrong widgetParams value`;
|
||||||
|
this.logger.error(errMsg);
|
||||||
|
throw new Error(errMsg);
|
||||||
|
}
|
||||||
|
await store.enhanceIssues([
|
||||||
|
this.timePassedHighlightEnhancer,
|
||||||
|
this.issueUrlEnhancer,
|
||||||
|
TagStyledEnhancerNs.CreateTagStyledEnhancerForConfig(widgetParams),
|
||||||
|
]);
|
||||||
|
const grouped = store.groupByStatusWithExtraToMultipleStories((issue) => {
|
||||||
|
if (!issue || !widgetParams.tags || !widgetParams.tags.tagsKeyName) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const tagsResult = GetValueFromObjectByKey(
|
||||||
|
issue,
|
||||||
|
widgetParams.tags.tagsKeyName,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
(tagsResult.error == 'NOT_FOUND' ||
|
||||||
|
(tagsResult.result && tagsResult.result.length <= 0)) &&
|
||||||
|
widgetParams.showEmptyTags
|
||||||
|
) {
|
||||||
|
return [widgetParams.showEmptyTags];
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof tagsResult.result !== 'object' ||
|
||||||
|
tagsResult.result.length <= 0
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return tagsResult.result;
|
||||||
|
}, widgetParams.statuses);
|
||||||
|
let res = [] as any[];
|
||||||
|
for (const tag in grouped) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(grouped, tag)) {
|
||||||
|
const data = grouped[tag];
|
||||||
|
res.push({
|
||||||
|
data: data,
|
||||||
|
metainfo: this.createMetaInfo(tag),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (widgetParams.tagsSort) {
|
||||||
|
res = res.sort((a, b) => {
|
||||||
|
return a.metainfo.title.localeCompare(b.metainfo.title);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getListFromRoot(issueId: number): Promise<FlatIssuesStore> {
|
||||||
|
const treeStore = new TreeIssuesStore();
|
||||||
|
const rootIssue = await this.issuesService.getIssue(issueId);
|
||||||
|
treeStore.setRootIssue(rootIssue);
|
||||||
|
await treeStore.fillData(this.issuesLoader);
|
||||||
|
return treeStore.getFlatStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getListByQuery(
|
||||||
|
query: nano.MangoQuery,
|
||||||
|
): Promise<FlatIssuesStore> {
|
||||||
|
const rawData = await this.issuesService.find(query);
|
||||||
|
const store = new FlatIssuesStore();
|
||||||
|
for (let i = 0; i < rawData.length; i++) {
|
||||||
|
const issue = rawData[i];
|
||||||
|
store.push(issue);
|
||||||
|
}
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createMetaInfo(tag: string): Record<string, any> {
|
||||||
|
return {
|
||||||
|
title: tag,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/issue-enhancers/category-merge-to-tags-enhancer.ts
Normal file
34
src/issue-enhancers/category-merge-to-tags-enhancer.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { IssueEnhancerInterface } from '@app/event-emitter/issue-enhancers/issue-enhancer-interface';
|
||||||
|
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CategoryMergeToTagsEnhancer implements IssueEnhancerInterface {
|
||||||
|
name = 'category-merge-to-tags';
|
||||||
|
|
||||||
|
private logger = new Logger(CategoryMergeToTagsEnhancer.name);
|
||||||
|
|
||||||
|
constructor(private forProjects: string[]) {
|
||||||
|
this.logger.debug(`Enhancer created for ${forProjects}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async enhance(
|
||||||
|
issue: RedmineTypes.ExtendedIssue,
|
||||||
|
): Promise<RedmineTypes.ExtendedIssue> {
|
||||||
|
if (!issue || !issue?.project?.name || this.forProjects.indexOf(issue.project.name) < 0) {
|
||||||
|
return issue;
|
||||||
|
}
|
||||||
|
if (!issue.tags || !this.isArray(issue.tags)) {
|
||||||
|
issue.tags = [];
|
||||||
|
}
|
||||||
|
const category = issue?.category?.name?.toLowerCase()?.replaceAll(' ', '_');
|
||||||
|
if (category && issue.tags.indexOf(category) < 0) {
|
||||||
|
issue.tags.push(category);
|
||||||
|
}
|
||||||
|
return issue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isArray(a: any): boolean {
|
||||||
|
return typeof a == 'object' && typeof a.length === 'number';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -50,7 +50,10 @@ export class CustomFieldsEnhancer implements IssueEnhancerInterface {
|
||||||
|
|
||||||
const tags = customFields.find((cf) => cf.name === 'Tags');
|
const tags = customFields.find((cf) => cf.name === 'Tags');
|
||||||
if (tags && tags.value) {
|
if (tags && tags.value) {
|
||||||
res.tags = tags.value.split(/[ ,;]/);
|
res.tags = tags.value
|
||||||
|
.split(/[ ,;]/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => !!s);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sp = customFields.find(
|
const sp = customFields.find(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { MainConfigModel } from '@app/event-emitter/models/main-config-model';
|
import { MainConfigModel } from '@app/event-emitter/models/main-config-model';
|
||||||
|
import { SimpleKanbanBoardConfig } from 'src/configs/simple-kanban-board.config';
|
||||||
import { EccmConfig } from './eccm-config.model';
|
import { EccmConfig } from './eccm-config.model';
|
||||||
import { StatusChangesConfig } from './status-changes-config.model';
|
import { StatusChangesConfig } from './status-changes-config.model';
|
||||||
import { StatusesConfig } from './statuses-config.model';
|
import { StatusesConfig } from './statuses-config.model';
|
||||||
|
|
@ -9,11 +10,13 @@ export type AppConfig = {
|
||||||
redmineStatusChanges: StatusChangesConfig.Config;
|
redmineStatusChanges: StatusChangesConfig.Config;
|
||||||
redmineEccm: EccmConfig.Config;
|
redmineEccm: EccmConfig.Config;
|
||||||
redmineCurrentUserRules: Record<string, string>;
|
redmineCurrentUserRules: Record<string, string>;
|
||||||
|
simpleKanbanBoard: SimpleKanbanBoardConfig;
|
||||||
couchDb: {
|
couchDb: {
|
||||||
dbs: {
|
dbs: {
|
||||||
changes: string;
|
changes: string;
|
||||||
userMetaInfo: string;
|
userMetaInfo: string;
|
||||||
eccmDailyReports: string;
|
eccmDailyReports: string;
|
||||||
|
eccmDailyReportsUserComments: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
telegramBotToken: string;
|
telegramBotToken: string;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { ConfigService } from '@nestjs/config';
|
||||||
import { IssueAndPersonalParsedMessageModel } from 'src/models/issue-and-personal-parsed-message.model';
|
import { IssueAndPersonalParsedMessageModel } from 'src/models/issue-and-personal-parsed-message.model';
|
||||||
import { TelegramBotService } from 'src/telegram-bot/telegram-bot.service';
|
import { TelegramBotService } from 'src/telegram-bot/telegram-bot.service';
|
||||||
import Handlebars from 'handlebars';
|
import Handlebars from 'handlebars';
|
||||||
import { RedminePublicUrlConverter } from 'src/converters/redmine-public-url.converter';
|
import { RedminePublicUrlConverter } from '@app/event-emitter/converters/redmine-public-url.converter';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PersonalNotificationAdapterService {
|
export class PersonalNotificationAdapterService {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import TelegramBot from 'node-telegram-bot-api';
|
import TelegramBot from 'node-telegram-bot-api';
|
||||||
import { Change } from 'src/models/change.model';
|
import { Change } from 'src/models/change.model';
|
||||||
|
|
@ -18,6 +18,7 @@ namespace StatusChangeAdapter {
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StatusChangeAdapterService {
|
export class StatusChangeAdapterService {
|
||||||
|
private logger = new Logger(StatusChangeAdapterService.name);
|
||||||
private periodValidityNotification: number;
|
private periodValidityNotification: number;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
|
@ -54,6 +55,7 @@ export class StatusChangeAdapterService {
|
||||||
item.options = { parse_mode: 'HTML' };
|
item.options = { parse_mode: 'HTML' };
|
||||||
return item;
|
return item;
|
||||||
});
|
});
|
||||||
|
this.logger.debug(`Change messages for sending to telegram - ${JSON.stringify(messages)}`);
|
||||||
for (let i = 0; i < messages.length; i++) {
|
for (let i = 0; i < messages.length; i++) {
|
||||||
const message = messages[i];
|
const message = messages[i];
|
||||||
await this.telegramBotService.sendMessageByRedmineId(
|
await this.telegramBotService.sendMessageByRedmineId(
|
||||||
|
|
@ -74,6 +76,12 @@ export class StatusChangeAdapterService {
|
||||||
nowTimestamp - change.created_on_timestamp >
|
nowTimestamp - change.created_on_timestamp >
|
||||||
this.periodValidityNotification
|
this.periodValidityNotification
|
||||||
) {
|
) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Skipping sending due to the prescription ` +
|
||||||
|
`of the origin of the event - ` +
|
||||||
|
`issue_id = ${change.issue_id}, ` +
|
||||||
|
`messages = ${change.messages.map((m) => m.notification_message).filter((m) => !!m)}`
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (let j = 0; j < change.messages.length; j++) {
|
for (let j = 0; j < change.messages.length; j++) {
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,13 @@ import { UsersService } from '@app/event-emitter/users/users.service';
|
||||||
import { TimestampConverter } from '@app/event-emitter/utils/timestamp-converter';
|
import { TimestampConverter } from '@app/event-emitter/utils/timestamp-converter';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { RedminePublicUrlConverter } from 'src/converters/redmine-public-url.converter';
|
|
||||||
import { Change } from 'src/models/change.model';
|
import { Change } from 'src/models/change.model';
|
||||||
import { StatusChangesConfig } from 'src/models/status-changes-config.model';
|
import { StatusChangesConfig } from 'src/models/status-changes-config.model';
|
||||||
import { StatusesConfig } from 'src/models/statuses-config.model';
|
import { StatusesConfig } from 'src/models/statuses-config.model';
|
||||||
import Handlebars from 'handlebars';
|
import Handlebars from 'handlebars';
|
||||||
import { ChangeMessage } from 'src/models/change-message.model';
|
import { ChangeMessage } from 'src/models/change-message.model';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
|
import { RedminePublicUrlConverter } from '@app/event-emitter/converters/redmine-public-url.converter';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StatusChangeNotificationsService {
|
export class StatusChangeNotificationsService {
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ import { UNLIMITED } from '@app/event-emitter/consts/consts';
|
||||||
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
|
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
|
||||||
import nano from 'nano';
|
import nano from 'nano';
|
||||||
import Handlebars from 'handlebars';
|
import Handlebars from 'handlebars';
|
||||||
import { RedminePublicUrlConverter } from 'src/converters/redmine-public-url.converter';
|
|
||||||
import { EccmConfig } from 'src/models/eccm-config.model';
|
import { EccmConfig } from 'src/models/eccm-config.model';
|
||||||
import { IssuesService } from '@app/event-emitter/issues/issues.service';
|
import { IssuesService } from '@app/event-emitter/issues/issues.service';
|
||||||
|
import { RedminePublicUrlConverter } from '@app/event-emitter/converters/redmine-public-url.converter';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||||
export namespace CurrentIssuesEccmReport {
|
export namespace CurrentIssuesEccmReport {
|
||||||
|
|
|
||||||
93
src/reports/daily-eccm-user-comments.service.ts
Normal file
93
src/reports/daily-eccm-user-comments.service.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-namespace */
|
||||||
|
import { UNLIMITED } from '@app/event-emitter/consts/consts';
|
||||||
|
import { Timestamped } from '@app/event-emitter/models/timestamped';
|
||||||
|
import { TimestampNowFill } from '@app/event-emitter/utils/timestamp-now-fill';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import nano from 'nano';
|
||||||
|
import { DailyEccmReportsUserCommentsDatasource } from 'src/couchdb-datasources/daily-eccm-reports-user-comments.datasource';
|
||||||
|
|
||||||
|
export namespace DailyEccmUserComments {
|
||||||
|
export namespace Models {
|
||||||
|
export type Item = {
|
||||||
|
userId: number;
|
||||||
|
date: string;
|
||||||
|
comment: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CouchDbItem = Item & Timestamped & nano.DocumentGetResponse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DailyEccmUserCommentsService {
|
||||||
|
constructor(private datasource: DailyEccmReportsUserCommentsDatasource) {}
|
||||||
|
|
||||||
|
async setComment(
|
||||||
|
userId: number,
|
||||||
|
date: string,
|
||||||
|
comment: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const key = this.getKey(userId, date);
|
||||||
|
const ds = await this.datasource.getDatasource();
|
||||||
|
let existsItem: any;
|
||||||
|
try {
|
||||||
|
existsItem = await ds.get(key);
|
||||||
|
} catch (ex) {
|
||||||
|
existsItem = null;
|
||||||
|
}
|
||||||
|
const item: DailyEccmUserComments.Models.CouchDbItem = TimestampNowFill({
|
||||||
|
userId: userId,
|
||||||
|
date: date,
|
||||||
|
comment: comment,
|
||||||
|
_id: key,
|
||||||
|
_rev: existsItem?._rev,
|
||||||
|
});
|
||||||
|
await ds.insert(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadComment(userId: number, date: string): Promise<string | null> {
|
||||||
|
const key = this.getKey(userId, date);
|
||||||
|
const ds = await this.datasource.getDatasource();
|
||||||
|
try {
|
||||||
|
const res: any = await ds.get(key);
|
||||||
|
return res.comment;
|
||||||
|
} catch (ex) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadComments(
|
||||||
|
userIds: number[],
|
||||||
|
date: string,
|
||||||
|
): Promise<Record<number, string>> {
|
||||||
|
const query: nano.MangoQuery = {
|
||||||
|
limit: UNLIMITED,
|
||||||
|
selector: {
|
||||||
|
userId: {
|
||||||
|
$in: userIds,
|
||||||
|
},
|
||||||
|
date: {
|
||||||
|
$eq: date,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ds = await this.datasource.getDatasource();
|
||||||
|
const resp = await ds.find(query);
|
||||||
|
if (!resp || !resp.docs || resp.docs.length <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const items = resp.docs;
|
||||||
|
const res: Record<number, string> = {};
|
||||||
|
for (const key in items) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(items, key)) {
|
||||||
|
const item = items[key];
|
||||||
|
res[item.userId] = item.comment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getKey(userId: number, date: string): string {
|
||||||
|
return `${date} - ${userId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/reports/daily-eccm-with-extra-data.service.ts
Normal file
43
src/reports/daily-eccm-with-extra-data.service.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { RedminePublicUrlConverter } from '@app/event-emitter/converters/redmine-public-url.converter';
|
||||||
|
import { Timestamped } from '@app/event-emitter/models/timestamped';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import nano from 'nano';
|
||||||
|
import { DailyEccmUserCommentsService } from './daily-eccm-user-comments.service';
|
||||||
|
import {
|
||||||
|
DailyEccmReport,
|
||||||
|
DailyEccmReportService,
|
||||||
|
} from './daily-eccm.report.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DailyEccmWithExtraDataService {
|
||||||
|
constructor(
|
||||||
|
private dailyEccmReportService: DailyEccmReportService,
|
||||||
|
private dailyEccmUserCommentsService: DailyEccmUserCommentsService,
|
||||||
|
private redminePublicUrlConverter: RedminePublicUrlConverter,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async loadReport(
|
||||||
|
name: string,
|
||||||
|
): Promise<
|
||||||
|
| (DailyEccmReport.Models.Report & nano.DocumentGetResponse & Timestamped)
|
||||||
|
| null
|
||||||
|
> {
|
||||||
|
const baseReportData = await this.dailyEccmReportService.loadReport(name);
|
||||||
|
if (!baseReportData) return null;
|
||||||
|
const userIds = baseReportData.byUsers.map((item) => item.user.id);
|
||||||
|
const userComments = await this.dailyEccmUserCommentsService.loadComments(
|
||||||
|
userIds,
|
||||||
|
name,
|
||||||
|
);
|
||||||
|
for (let i = 0; i < baseReportData.byUsers.length; i++) {
|
||||||
|
const byUser = baseReportData.byUsers[i];
|
||||||
|
if (userComments[byUser.user.id]) {
|
||||||
|
byUser.dailyMessage =
|
||||||
|
this.redminePublicUrlConverter.enrichTextWithIssues(
|
||||||
|
userComments[byUser.user.id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return baseReportData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Controller, Get, Param, Query, Render } from '@nestjs/common';
|
import { Controller, Get, Param, Query, Render } from '@nestjs/common';
|
||||||
|
import { DailyEccmWithExtraDataService } from './daily-eccm-with-extra-data.service';
|
||||||
import {
|
import {
|
||||||
DailyEccmReport,
|
DailyEccmReport,
|
||||||
DailyEccmReportService,
|
DailyEccmReportService,
|
||||||
|
|
@ -6,7 +7,10 @@ import {
|
||||||
|
|
||||||
@Controller('daily-eccm')
|
@Controller('daily-eccm')
|
||||||
export class DailyEccmReportController {
|
export class DailyEccmReportController {
|
||||||
constructor(private dailyEccmReportService: DailyEccmReportService) {}
|
constructor(
|
||||||
|
private dailyEccmReportService: DailyEccmReportService,
|
||||||
|
private dailyEccmWithExtraDataService: DailyEccmWithExtraDataService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Render('daily-eccm-report')
|
@Render('daily-eccm-report')
|
||||||
|
|
@ -67,4 +71,19 @@ export class DailyEccmReportController {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('/load/:name/extended/raw')
|
||||||
|
async loadExtendedReportRawData(
|
||||||
|
@Param('name') name: string,
|
||||||
|
): Promise<DailyEccmReport.Models.Report> {
|
||||||
|
return await this.dailyEccmWithExtraDataService.loadReport(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/load/:name/extended')
|
||||||
|
@Render('daily-eccm-report-extended')
|
||||||
|
async loadExtendedReport(
|
||||||
|
@Param('name') name: string,
|
||||||
|
): Promise<DailyEccmReport.Models.Report> {
|
||||||
|
return await this.dailyEccmWithExtraDataService.loadReport(name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { DailyEccmReportsDatasource } from 'src/couchdb-datasources/daily-eccm-r
|
||||||
import { Timestamped } from '@app/event-emitter/models/timestamped';
|
import { Timestamped } from '@app/event-emitter/models/timestamped';
|
||||||
import { TimestampNowFill } from '@app/event-emitter/utils/timestamp-now-fill';
|
import { TimestampNowFill } from '@app/event-emitter/utils/timestamp-now-fill';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
import { ISO_DATE_FORMAT } from 'src/consts/date-time.consts';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||||
export namespace DailyEccmReport {
|
export namespace DailyEccmReport {
|
||||||
|
|
@ -413,7 +414,7 @@ export class DailyEccmReportService {
|
||||||
if (!toDate.isValid) throw new Error('to is invalid date');
|
if (!toDate.isValid) throw new Error('to is invalid date');
|
||||||
let nameValue: string | null = name || null;
|
let nameValue: string | null = name || null;
|
||||||
if (!nameValue) {
|
if (!nameValue) {
|
||||||
nameValue = DateTime.now().toFormat('yyyy-MM-dd');
|
nameValue = DateTime.now().toFormat(ISO_DATE_FORMAT);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
from: fromDate.toISO(),
|
from: fromDate.toISO(),
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export class DailyEccmReportTask {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Cron('25 9,10 1-5 * *')
|
@Cron('25 9,10 * * 1-5')
|
||||||
async generateReport(): Promise<void> {
|
async generateReport(): Promise<void> {
|
||||||
this.logger.log(`Generate daily eccm report by cron task started`);
|
this.logger.log(`Generate daily eccm report by cron task started`);
|
||||||
const now = DateTime.now();
|
const now = DateTime.now();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-namespace */
|
||||||
|
import { TelegramBotService } from '../telegram-bot.service';
|
||||||
|
import { TelegramBotHandlerInterface } from '../telegram.bot-handler.interface';
|
||||||
|
import TelegramBot from 'node-telegram-bot-api';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { UserMetaInfoService } from 'src/user-meta-info/user-meta-info.service';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { ISO_DATE_FORMAT } from 'src/consts/date-time.consts';
|
||||||
|
|
||||||
|
export namespace SetDailyEccmUserCommentBotHandler {
|
||||||
|
export namespace Models {
|
||||||
|
export type SetDailyEccmUserComment = {
|
||||||
|
userId: number;
|
||||||
|
date: string;
|
||||||
|
comment: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SetDailyEccmUserCommentBotHandlerService
|
||||||
|
implements TelegramBotHandlerInterface
|
||||||
|
{
|
||||||
|
private service: TelegramBotService;
|
||||||
|
private regexp = /\/set_daily_eccm_user_comment.*/g;
|
||||||
|
private logger = new Logger(SetDailyEccmUserCommentBotHandlerService.name);
|
||||||
|
|
||||||
|
$messages =
|
||||||
|
new Subject<SetDailyEccmUserCommentBotHandler.Models.SetDailyEccmUserComment>();
|
||||||
|
|
||||||
|
constructor(private userMetaInfoService: UserMetaInfoService) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(service: TelegramBotService, bot: TelegramBot): Promise<void> {
|
||||||
|
if (!this.service) {
|
||||||
|
this.service = service;
|
||||||
|
}
|
||||||
|
bot.onText(this.regexp, async (msg) => {
|
||||||
|
const userMetaInfo = await this.userMetaInfoService.findByTelegramId(
|
||||||
|
msg.chat.id,
|
||||||
|
);
|
||||||
|
if (!userMetaInfo) {
|
||||||
|
this.logger.error(`User for telegram id = ${msg.chat.id} not found`);
|
||||||
|
}
|
||||||
|
const redmineUserId = userMetaInfo.user_id;
|
||||||
|
const data = this.parseData(msg.text);
|
||||||
|
if (data) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Setting user comment for daily eccm ` +
|
||||||
|
`by redmineUserId = ${redmineUserId}, ` +
|
||||||
|
`full text - ${msg.text}, ` +
|
||||||
|
`parsed data - ${JSON.stringify(data)}`,
|
||||||
|
);
|
||||||
|
this.$messages.next({
|
||||||
|
userId: redmineUserId,
|
||||||
|
date: data.date,
|
||||||
|
comment: data.msg,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.logger.error(
|
||||||
|
`For some reason, it was not possible to get data from an incoming message - ${msg.text}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getHelpMsg(): string {
|
||||||
|
return (
|
||||||
|
`/set_daily_eccm_user_comment ` +
|
||||||
|
`[date=yyyy-MM-dd] <комментарий> ` +
|
||||||
|
`- дополнительный комментарий для дейли`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseData(src: string): { date: string; msg: string } | null {
|
||||||
|
let text = src;
|
||||||
|
|
||||||
|
text = text.replace('/set_daily_eccm_user_comment', '').trim();
|
||||||
|
if (!text) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateMatch = text.match(/^date=[\d-]+/);
|
||||||
|
if (!dateMatch) {
|
||||||
|
return { date: DateTime.now().toFormat(ISO_DATE_FORMAT), msg: text };
|
||||||
|
}
|
||||||
|
|
||||||
|
const datePart = dateMatch[0];
|
||||||
|
let dateRaw = datePart.replace('date=', '');
|
||||||
|
text = text.replace('date=', '').trim();
|
||||||
|
const date = DateTime.fromFormat(dateRaw, ISO_DATE_FORMAT);
|
||||||
|
if (!date.isValid) {
|
||||||
|
this.logger.error(`Wrong date in message - ${src}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
text = text.replace(dateRaw, '').trim();
|
||||||
|
dateRaw = date.toFormat(ISO_DATE_FORMAT);
|
||||||
|
return { date: dateRaw, msg: text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import axios from 'axios';
|
||||||
import { UserMetaInfoModel } from 'src/models/user-meta-info.model';
|
import { UserMetaInfoModel } from 'src/models/user-meta-info.model';
|
||||||
import { CurrentIssuesEccmBotHandlerService } from './handlers/current-issues-eccm.bot-handler.service';
|
import { CurrentIssuesEccmBotHandlerService } from './handlers/current-issues-eccm.bot-handler.service';
|
||||||
import { TelegramBotHandlerInterface } from './telegram.bot-handler.interface';
|
import { TelegramBotHandlerInterface } from './telegram.bot-handler.interface';
|
||||||
|
import { SetDailyEccmUserCommentBotHandlerService } from './handlers/set-daily-eccm-user-comment.bot-handler.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TelegramBotService {
|
export class TelegramBotService {
|
||||||
|
|
@ -24,17 +25,24 @@ export class TelegramBotService {
|
||||||
private usersService: UsersService,
|
private usersService: UsersService,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private currentIssuesBotHandlerService: CurrentIssuesEccmBotHandlerService,
|
private currentIssuesBotHandlerService: CurrentIssuesEccmBotHandlerService,
|
||||||
|
private setDailyEccmUserCommentBotHandlerService: SetDailyEccmUserCommentBotHandlerService,
|
||||||
) {
|
) {
|
||||||
this.telegramBotToken = this.configService.get<string>('telegramBotToken');
|
this.telegramBotToken = this.configService.get<string>('telegramBotToken');
|
||||||
this.redminePublicUrlPrefix =
|
this.redminePublicUrlPrefix =
|
||||||
this.configService.get<string>('redmineUrlPublic');
|
this.configService.get<string>('redmineUrlPublic');
|
||||||
this.initTelegramBot();
|
this.initTelegramBot().catch((ex) => {
|
||||||
|
this.logger.error(`Error at init telegram bot - ${ex}`);
|
||||||
|
});
|
||||||
this.handlers.push(this.currentIssuesBotHandlerService);
|
this.handlers.push(this.currentIssuesBotHandlerService);
|
||||||
|
this.handlers.push(this.setDailyEccmUserCommentBotHandlerService);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async initTelegramBot(): Promise<void> {
|
private async initTelegramBot(): Promise<void> {
|
||||||
const Telegram = await require('node-telegram-bot-api');
|
const Telegram = await require('node-telegram-bot-api');
|
||||||
|
if (!this.telegramBotToken) return;
|
||||||
|
this.logger.debug('Telegram bot instance creation ... ');
|
||||||
this.bot = new Telegram(this.telegramBotToken, { polling: true });
|
this.bot = new Telegram(this.telegramBotToken, { polling: true });
|
||||||
|
this.logger.debug('Telegram bot instance created');
|
||||||
this.bot.onText(/\/start/, async (msg) => {
|
this.bot.onText(/\/start/, async (msg) => {
|
||||||
await this.showHelpMessage(msg);
|
await this.showHelpMessage(msg);
|
||||||
});
|
});
|
||||||
|
|
@ -47,10 +55,20 @@ export class TelegramBotService {
|
||||||
this.bot.onText(/\/leave/, async (msg) => {
|
this.bot.onText(/\/leave/, async (msg) => {
|
||||||
await this.leave(msg);
|
await this.leave(msg);
|
||||||
});
|
});
|
||||||
this.currentIssuesBotHandlerService.init(this, this.bot);
|
this.bot.on('polling_error', (error) => {
|
||||||
|
this.logger.error(`polling_error from telegram bot instance - ${error}`);
|
||||||
|
});
|
||||||
|
this.bot.on('webhook_error', (error) => {
|
||||||
|
this.logger.error(`webhook_error from telegram bot instance - ${error}`);
|
||||||
|
});
|
||||||
|
for (let i = 0; i < this.handlers.length; i++) {
|
||||||
|
const handler = this.handlers[i];
|
||||||
|
await handler.init(this, this.bot);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async showHelpMessage(msg: TelegramBot.Message): Promise<void> {
|
private async showHelpMessage(msg: TelegramBot.Message): Promise<void> {
|
||||||
|
if (!this.telegramBotToken) return;
|
||||||
const userMetaInfo = await this.userMetaInfoService.findByTelegramId(
|
const userMetaInfo = await this.userMetaInfoService.findByTelegramId(
|
||||||
msg.chat.id,
|
msg.chat.id,
|
||||||
);
|
);
|
||||||
|
|
@ -75,7 +93,11 @@ export class TelegramBotService {
|
||||||
`Sent help message for telegramChatId = ${msg.chat.id}, ` +
|
`Sent help message for telegramChatId = ${msg.chat.id}, ` +
|
||||||
`message = ${helpMessage}`,
|
`message = ${helpMessage}`,
|
||||||
);
|
);
|
||||||
|
try {
|
||||||
this.bot.sendMessage(msg.chat.id, helpMessage);
|
this.bot.sendMessage(msg.chat.id, helpMessage);
|
||||||
|
} catch (ex) {
|
||||||
|
this.logger.error(`Error at send help message - ${ex?.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendMessageByRedmineId(
|
async sendMessageByRedmineId(
|
||||||
|
|
@ -83,6 +105,7 @@ export class TelegramBotService {
|
||||||
msg: string,
|
msg: string,
|
||||||
options?: TelegramBot.SendMessageOptions,
|
options?: TelegramBot.SendMessageOptions,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
if (!this.telegramBotToken) return false;
|
||||||
const userMetaInfo = await this.userMetaInfoService.findByRedmineId(
|
const userMetaInfo = await this.userMetaInfoService.findByRedmineId(
|
||||||
redmineId,
|
redmineId,
|
||||||
);
|
);
|
||||||
|
|
@ -104,6 +127,7 @@ export class TelegramBotService {
|
||||||
msg: string,
|
msg: string,
|
||||||
options?: TelegramBot.SendMessageOptions,
|
options?: TelegramBot.SendMessageOptions,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
if (!this.telegramBotToken) return false;
|
||||||
const user = await this.usersService.findUserByName(firstname, lastname);
|
const user = await this.usersService.findUserByName(firstname, lastname);
|
||||||
if (!user) return false;
|
if (!user) return false;
|
||||||
return await this.sendMessageByRedmineId(user.id, msg, options);
|
return await this.sendMessageByRedmineId(user.id, msg, options);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"target": "es2017",
|
"target": "es2021",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
|
|
|
||||||
57
views/daily-eccm-report-extended.hbs
Normal file
57
views/daily-eccm-report-extended.hbs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Daily Eccm Report</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<details>
|
||||||
|
<summary>Параметры отчёта</summary>
|
||||||
|
<ul>
|
||||||
|
<li>От - {{this.params.from}}</li>
|
||||||
|
<li>До - {{this.params.to}}</li>
|
||||||
|
<li>Имя отчёта - {{this.params.name}}</li>
|
||||||
|
<li>Имя проекта - {{this.params.project}}</li>
|
||||||
|
<li>Версии - {{this.params.versions}}</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
<h1>Отчёт по работникам</h1>
|
||||||
|
{{#each this.byUsers}}
|
||||||
|
|
||||||
|
<h2>{{this.user.firstname}} {{this.user.lastname}}</h2>
|
||||||
|
|
||||||
|
{{#if this.dailyMessage}}
|
||||||
|
<h3>Комментарий</h3>
|
||||||
|
<pre>{{{this.dailyMessage}}}</pre>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<h3>Текущие задачи</h3>
|
||||||
|
<ul>
|
||||||
|
{{#each this.issuesGroupedByStatus}}
|
||||||
|
<li>
|
||||||
|
{{this.status.name}}:
|
||||||
|
<ul>
|
||||||
|
{{#each this.issues}}
|
||||||
|
<li title="{{this.parents}}">{{>redmineIssueAHref issue=this.issue}} (приоритет {{this.issue.priority.name}}; версия {{this.issue.fixed_version.name}}) - {{this.issue.subject}}</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Активности за период</h3>
|
||||||
|
<ul>
|
||||||
|
{{#each this.activities}}
|
||||||
|
<li title="{{this.parents}}">
|
||||||
|
{{>redmineIssueAHref issue=this.issue}} (приоритет {{this.issue.priority.name}}; версия {{this.issue.fixed_version.name}}; статус {{this.issue.status.name}}) - {{this.issue.subject}}
|
||||||
|
<ul>
|
||||||
|
{{#each this.changes}}
|
||||||
|
<li>{{this.created_on}}: {{this.change_message}}</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
{{/each}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
103
views/simple-kanban-board.hbs
Normal file
103
views/simple-kanban-board.hbs
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>ECCM 1.10 Kanban board</title>
|
||||||
|
<style>
|
||||||
|
.kanban-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.kanban-column {
|
||||||
|
width: 200px;
|
||||||
|
background-color: rgb(225, 225, 225);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.kanban-header {
|
||||||
|
text-align: center;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
.kanban-card {
|
||||||
|
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-color: black;*/
|
||||||
|
border-style: solid;
|
||||||
|
margin: 2px;
|
||||||
|
padding: 3px;
|
||||||
|
width: 190px;
|
||||||
|
/*display: flex;*/
|
||||||
|
box-shadow: 0 0 3px rgba(0,0,0,0.5);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
.kanban-card-tag {
|
||||||
|
font-size: 8pt;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
</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="kanban-container">
|
||||||
|
{{#each this.data}}
|
||||||
|
<div class="kanban-column">
|
||||||
|
<div class="kanban-header">{{this.status}}</div>
|
||||||
|
{{#each this.issues}}
|
||||||
|
<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.total_spent_hours}} / {{this.total_estimated_hours}}</div>
|
||||||
|
{{#if this.styledTags}}
|
||||||
|
<div>
|
||||||
|
Tags:
|
||||||
|
{{#each this.styledTags}}
|
||||||
|
<span class="kanban-card-tag" style="{{{this.style}}}">{{this.tag}}</span>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
{{/each}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in a new issue