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-config.jsonc
|
||||
configs/current-user-rules.jsonc
|
||||
configs/simple-kanban-board-config.jsonc
|
||||
configs/kanban-boards/
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
"dbs": {
|
||||
"changes": "",
|
||||
"userMetaInfo": "",
|
||||
"eccmDailyReports": ""
|
||||
"eccmDailyReports": "",
|
||||
"eccmDailyReportsUserComments": ""
|
||||
}
|
||||
},
|
||||
"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 { TimestampEnhancer } from './issue-enhancers/timestamps-enhancer';
|
||||
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({})
|
||||
export class EventEmitterModule implements OnModuleInit {
|
||||
|
|
@ -40,6 +49,15 @@ export class EventEmitterModule implements OnModuleInit {
|
|||
IssuesService,
|
||||
TimestampEnhancer,
|
||||
EnhancerService,
|
||||
ProjectDashboardService,
|
||||
RootIssueSubTreesWidgetService,
|
||||
DynamicLoader,
|
||||
RedminePublicUrlConverter,
|
||||
IssueUrlEnhancer,
|
||||
ListIssuesByUsersWidgetService,
|
||||
ListIssuesByUsersLikeJiraWidgetService,
|
||||
TimePassedHighlightEnhancer,
|
||||
ListIssuesByFieldsWidgetService,
|
||||
],
|
||||
exports: [
|
||||
EventEmitterService,
|
||||
|
|
@ -54,6 +72,15 @@ export class EventEmitterModule implements OnModuleInit {
|
|||
IssuesService,
|
||||
TimestampEnhancer,
|
||||
EnhancerService,
|
||||
ProjectDashboardService,
|
||||
RootIssueSubTreesWidgetService,
|
||||
DynamicLoader,
|
||||
RedminePublicUrlConverter,
|
||||
IssueUrlEnhancer,
|
||||
ListIssuesByUsersWidgetService,
|
||||
ListIssuesByUsersLikeJiraWidgetService,
|
||||
TimePassedHighlightEnhancer,
|
||||
ListIssuesByFieldsWidgetService,
|
||||
],
|
||||
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 { CacheTTL, Injectable, Logger } from '@nestjs/common';
|
||||
import { Issues } from '../couchdb-datasources/issues';
|
||||
|
|
@ -8,10 +9,17 @@ import { MemoryCache } from '../utils/memory-cache';
|
|||
import nano from 'nano';
|
||||
import { UNLIMITED } from '../consts/consts';
|
||||
import { GetParentsHint } from '../utils/get-parents-hint';
|
||||
import { TreeIssuesStore } from '../utils/tree-issues-store';
|
||||
|
||||
export const ISSUE_MEMORY_CACHE_LIFETIME = 30 * 1000;
|
||||
const ISSUE_MEMORY_CACHE_AUTOCLEAN_INTERVAL = 1000 * 60 * 5;
|
||||
|
||||
export namespace IssuesServiceNs {
|
||||
export type IssuesLoader = (
|
||||
ids: number[],
|
||||
) => Promise<Record<number, RedmineTypes.Issue | null>>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class IssuesService {
|
||||
private logger = new Logger(IssuesService.name);
|
||||
|
|
@ -143,4 +151,29 @@ export class IssuesService {
|
|||
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;
|
||||
assigned_to?: IdAndName;
|
||||
category: IdAndName;
|
||||
fixed_version: IdAndName;
|
||||
fixed_version?: IdAndName;
|
||||
subject: string;
|
||||
description: string;
|
||||
start_date: string;
|
||||
|
|
@ -61,6 +61,8 @@ export module RedmineTypes {
|
|||
parent?: { id: number };
|
||||
};
|
||||
|
||||
export type ExtendedIssue = Issue & Record<string, any>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-namespace-keyword, @typescript-eslint/no-namespace
|
||||
export module Unknown {
|
||||
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",
|
||||
"hbs": "^4.2.0",
|
||||
"imap-simple": "^5.1.0",
|
||||
"jsonc-parser": "^3.2.0",
|
||||
"luxon": "^3.1.0",
|
||||
"nano": "^10.0.0",
|
||||
"node-telegram-bot-api": "^0.59.0",
|
||||
|
|
@ -198,6 +199,12 @@
|
|||
"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": {
|
||||
"version": "6.6.7",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz",
|
||||
|
|
@ -1707,6 +1714,12 @@
|
|||
"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": {
|
||||
"version": "6.6.7",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz",
|
||||
|
|
@ -7091,10 +7104,9 @@
|
|||
}
|
||||
},
|
||||
"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
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz",
|
||||
"integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w=="
|
||||
},
|
||||
"node_modules/jsonfile": {
|
||||
"version": "6.1.0",
|
||||
|
|
@ -10448,6 +10460,12 @@
|
|||
"rxjs": "6.6.7"
|
||||
},
|
||||
"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": {
|
||||
"version": "6.6.7",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz",
|
||||
|
|
@ -11625,6 +11643,12 @@
|
|||
"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": {
|
||||
"version": "6.6.7",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz",
|
||||
|
|
@ -15740,10 +15764,9 @@
|
|||
"dev": true
|
||||
},
|
||||
"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
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz",
|
||||
"integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w=="
|
||||
},
|
||||
"jsonfile": {
|
||||
"version": "6.1.0",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
"handlebars": "^4.7.7",
|
||||
"hbs": "^4.2.0",
|
||||
"imap-simple": "^5.1.0",
|
||||
"jsonc-parser": "^3.2.0",
|
||||
"luxon": "^3.1.0",
|
||||
"nano": "^10.0.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 { TimestampEnhancer } from '@app/event-emitter/issue-enhancers/timestamps-enhancer';
|
||||
import { MainController } from '@app/event-emitter/main/main.controller';
|
||||
import { CacheModule, Logger, Module, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import {
|
||||
CacheModule,
|
||||
Inject,
|
||||
Logger,
|
||||
Module,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { switchMap, tap } from 'rxjs';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import configuration from './configs/app';
|
||||
import { RedminePublicUrlConverter } from './converters/redmine-public-url.converter';
|
||||
import { Changes } from './couchdb-datasources/changes';
|
||||
import { CurrentUserEnhancer } from './issue-enhancers/current-user-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 { ScheduleModule } from '@nestjs/schedule';
|
||||
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({
|
||||
imports: [
|
||||
|
|
@ -49,6 +62,7 @@ import { DailyEccmReportTask } from './reports/daily-eccm.report.task';
|
|||
MainController,
|
||||
CurrentIssuesEccmReportController,
|
||||
DailyEccmReportController,
|
||||
SimpleKanbanBoardController,
|
||||
],
|
||||
providers: [
|
||||
AppService,
|
||||
|
|
@ -57,7 +71,6 @@ import { DailyEccmReportTask } from './reports/daily-eccm.report.task';
|
|||
PersonalNotificationsService,
|
||||
StatusChangeNotificationsService,
|
||||
Changes,
|
||||
RedminePublicUrlConverter,
|
||||
ChangesCacheWriterService,
|
||||
TelegramBotService,
|
||||
UserMetaInfoService,
|
||||
|
|
@ -70,6 +83,21 @@ import { DailyEccmReportTask } from './reports/daily-eccm.report.task';
|
|||
ChangesService,
|
||||
DailyEccmReportsDatasource,
|
||||
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 {
|
||||
|
|
@ -82,11 +110,17 @@ export class AppModule implements OnModuleInit {
|
|||
private timestampEnhancer: TimestampEnhancer,
|
||||
private customFieldsEnhancer: CustomFieldsEnhancer,
|
||||
private currentUserEnhancer: CurrentUserEnhancer,
|
||||
private issueUrlEnhancer: IssueUrlEnhancer,
|
||||
private statusChangeNotificationsService: StatusChangeNotificationsService,
|
||||
private changesCacheWriterService: ChangesCacheWriterService,
|
||||
private telegramBotService: TelegramBotService,
|
||||
private personalNotificationAdapterService: PersonalNotificationAdapterService,
|
||||
private statusChangeAdapterService: StatusChangeAdapterService,
|
||||
private dailyEccmUserCommentsService: DailyEccmUserCommentsService,
|
||||
private setDailyEccmUserCommentBotHandlerService: SetDailyEccmUserCommentBotHandlerService,
|
||||
|
||||
@Inject('CATEGORY_MERGE_TO_TAGS_ENHANCER')
|
||||
private categoryMergeToTagsEnhancer: CategoryMergeToTagsEnhancer,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
|
|
@ -95,11 +129,14 @@ export class AppModule implements OnModuleInit {
|
|||
Changes.getDatasource();
|
||||
UserMetaInfo.getDatasource();
|
||||
DailyEccmReportsDatasource.getDatasource();
|
||||
DailyEccmReportsUserCommentsDatasource.getDatasource();
|
||||
|
||||
this.enhancerService.addEnhancer([
|
||||
this.timestampEnhancer,
|
||||
this.customFieldsEnhancer,
|
||||
this.currentUserEnhancer,
|
||||
this.issueUrlEnhancer,
|
||||
this.categoryMergeToTagsEnhancer,
|
||||
]);
|
||||
|
||||
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}`,
|
||||
);
|
||||
});
|
||||
|
||||
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 RedmineEccmConfig from './eccm.config';
|
||||
import RedmineCurrentUserRulesConfig from './current-user-rules.config';
|
||||
import SimpleKanbanBoardConfig from './simple-kanban-board.config';
|
||||
|
||||
const redmineIssueEventEmitterConfig = RedmineIssueEventEmitterConfigLoader();
|
||||
const redmineStatusesConfig = RedmineStatusesConfigLoader();
|
||||
const redmineStatusChanges = RedmineStatusChangesConfigLoader();
|
||||
const redmineEccm = RedmineEccmConfig();
|
||||
const redmineCurrentUserRules = RedmineCurrentUserRulesConfig();
|
||||
const simpleKanbanBoard = SimpleKanbanBoardConfig();
|
||||
|
||||
let appConfig: AppConfig;
|
||||
|
||||
|
|
@ -36,6 +38,7 @@ export default (): AppConfig => {
|
|||
redmineStatusChanges: redmineStatusChanges,
|
||||
redmineEccm: redmineEccm,
|
||||
redmineCurrentUserRules: redmineCurrentUserRules,
|
||||
simpleKanbanBoard: simpleKanbanBoard,
|
||||
};
|
||||
|
||||
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');
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
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 { StatusChangesConfig } from './status-changes-config.model';
|
||||
import { StatusesConfig } from './statuses-config.model';
|
||||
|
|
@ -9,11 +10,13 @@ export type AppConfig = {
|
|||
redmineStatusChanges: StatusChangesConfig.Config;
|
||||
redmineEccm: EccmConfig.Config;
|
||||
redmineCurrentUserRules: Record<string, string>;
|
||||
simpleKanbanBoard: SimpleKanbanBoardConfig;
|
||||
couchDb: {
|
||||
dbs: {
|
||||
changes: string;
|
||||
userMetaInfo: string;
|
||||
eccmDailyReports: string;
|
||||
eccmDailyReportsUserComments: string;
|
||||
};
|
||||
};
|
||||
telegramBotToken: string;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { ConfigService } from '@nestjs/config';
|
|||
import { IssueAndPersonalParsedMessageModel } from 'src/models/issue-and-personal-parsed-message.model';
|
||||
import { TelegramBotService } from 'src/telegram-bot/telegram-bot.service';
|
||||
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()
|
||||
export class PersonalNotificationAdapterService {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import TelegramBot from 'node-telegram-bot-api';
|
||||
import { Change } from 'src/models/change.model';
|
||||
|
|
@ -18,6 +18,7 @@ namespace StatusChangeAdapter {
|
|||
|
||||
@Injectable()
|
||||
export class StatusChangeAdapterService {
|
||||
private logger = new Logger(StatusChangeAdapterService.name);
|
||||
private periodValidityNotification: number;
|
||||
|
||||
constructor(
|
||||
|
|
@ -54,6 +55,7 @@ export class StatusChangeAdapterService {
|
|||
item.options = { parse_mode: 'HTML' };
|
||||
return item;
|
||||
});
|
||||
this.logger.debug(`Change messages for sending to telegram - ${JSON.stringify(messages)}`);
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
await this.telegramBotService.sendMessageByRedmineId(
|
||||
|
|
@ -74,6 +76,12 @@ export class StatusChangeAdapterService {
|
|||
nowTimestamp - change.created_on_timestamp >
|
||||
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;
|
||||
}
|
||||
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 { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { RedminePublicUrlConverter } from 'src/converters/redmine-public-url.converter';
|
||||
import { Change } from 'src/models/change.model';
|
||||
import { StatusChangesConfig } from 'src/models/status-changes-config.model';
|
||||
import { StatusesConfig } from 'src/models/statuses-config.model';
|
||||
import Handlebars from 'handlebars';
|
||||
import { ChangeMessage } from 'src/models/change-message.model';
|
||||
import { Subject } from 'rxjs';
|
||||
import { RedminePublicUrlConverter } from '@app/event-emitter/converters/redmine-public-url.converter';
|
||||
|
||||
@Injectable()
|
||||
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 nano from 'nano';
|
||||
import Handlebars from 'handlebars';
|
||||
import { RedminePublicUrlConverter } from 'src/converters/redmine-public-url.converter';
|
||||
import { EccmConfig } from 'src/models/eccm-config.model';
|
||||
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
|
||||
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 { DailyEccmWithExtraDataService } from './daily-eccm-with-extra-data.service';
|
||||
import {
|
||||
DailyEccmReport,
|
||||
DailyEccmReportService,
|
||||
|
|
@ -6,7 +7,10 @@ import {
|
|||
|
||||
@Controller('daily-eccm')
|
||||
export class DailyEccmReportController {
|
||||
constructor(private dailyEccmReportService: DailyEccmReportService) {}
|
||||
constructor(
|
||||
private dailyEccmReportService: DailyEccmReportService,
|
||||
private dailyEccmWithExtraDataService: DailyEccmWithExtraDataService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@Render('daily-eccm-report')
|
||||
|
|
@ -67,4 +71,19 @@ export class DailyEccmReportController {
|
|||
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 { TimestampNowFill } from '@app/event-emitter/utils/timestamp-now-fill';
|
||||
import { DateTime } from 'luxon';
|
||||
import { ISO_DATE_FORMAT } from 'src/consts/date-time.consts';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
export namespace DailyEccmReport {
|
||||
|
|
@ -413,7 +414,7 @@ export class DailyEccmReportService {
|
|||
if (!toDate.isValid) throw new Error('to is invalid date');
|
||||
let nameValue: string | null = name || null;
|
||||
if (!nameValue) {
|
||||
nameValue = DateTime.now().toFormat('yyyy-MM-dd');
|
||||
nameValue = DateTime.now().toFormat(ISO_DATE_FORMAT);
|
||||
}
|
||||
return {
|
||||
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> {
|
||||
this.logger.log(`Generate daily eccm report by cron task started`);
|
||||
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 { CurrentIssuesEccmBotHandlerService } from './handlers/current-issues-eccm.bot-handler.service';
|
||||
import { TelegramBotHandlerInterface } from './telegram.bot-handler.interface';
|
||||
import { SetDailyEccmUserCommentBotHandlerService } from './handlers/set-daily-eccm-user-comment.bot-handler.service';
|
||||
|
||||
@Injectable()
|
||||
export class TelegramBotService {
|
||||
|
|
@ -24,17 +25,24 @@ export class TelegramBotService {
|
|||
private usersService: UsersService,
|
||||
private configService: ConfigService,
|
||||
private currentIssuesBotHandlerService: CurrentIssuesEccmBotHandlerService,
|
||||
private setDailyEccmUserCommentBotHandlerService: SetDailyEccmUserCommentBotHandlerService,
|
||||
) {
|
||||
this.telegramBotToken = this.configService.get<string>('telegramBotToken');
|
||||
this.redminePublicUrlPrefix =
|
||||
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.setDailyEccmUserCommentBotHandlerService);
|
||||
}
|
||||
|
||||
private async initTelegramBot(): Promise<void> {
|
||||
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.logger.debug('Telegram bot instance created');
|
||||
this.bot.onText(/\/start/, async (msg) => {
|
||||
await this.showHelpMessage(msg);
|
||||
});
|
||||
|
|
@ -47,10 +55,20 @@ export class TelegramBotService {
|
|||
this.bot.onText(/\/leave/, async (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> {
|
||||
if (!this.telegramBotToken) return;
|
||||
const userMetaInfo = await this.userMetaInfoService.findByTelegramId(
|
||||
msg.chat.id,
|
||||
);
|
||||
|
|
@ -75,7 +93,11 @@ export class TelegramBotService {
|
|||
`Sent help message for telegramChatId = ${msg.chat.id}, ` +
|
||||
`message = ${helpMessage}`,
|
||||
);
|
||||
this.bot.sendMessage(msg.chat.id, helpMessage);
|
||||
try {
|
||||
this.bot.sendMessage(msg.chat.id, helpMessage);
|
||||
} catch (ex) {
|
||||
this.logger.error(`Error at send help message - ${ex?.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessageByRedmineId(
|
||||
|
|
@ -83,6 +105,7 @@ export class TelegramBotService {
|
|||
msg: string,
|
||||
options?: TelegramBot.SendMessageOptions,
|
||||
): Promise<boolean> {
|
||||
if (!this.telegramBotToken) return false;
|
||||
const userMetaInfo = await this.userMetaInfoService.findByRedmineId(
|
||||
redmineId,
|
||||
);
|
||||
|
|
@ -104,6 +127,7 @@ export class TelegramBotService {
|
|||
msg: string,
|
||||
options?: TelegramBot.SendMessageOptions,
|
||||
): Promise<boolean> {
|
||||
if (!this.telegramBotToken) return false;
|
||||
const user = await this.usersService.findUserByName(firstname, lastname);
|
||||
if (!user) return false;
|
||||
return await this.sendMessageByRedmineId(user.id, msg, options);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "es2017",
|
||||
"target": "es2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"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