Merge branch 'dev' into 'master'

This commit is contained in:
Pavel Gnedov 2023-03-04 14:58:51 +07:00
commit 0d1d351a3d
47 changed files with 2371 additions and 53 deletions

2
.gitignore vendored
View file

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

View file

@ -3,7 +3,8 @@
"dbs": {
"changes": "",
"userMetaInfo": "",
"eccmDailyReports": ""
"eccmDailyReports": "",
"eccmDailyReportsUserComments": ""
}
},
"telegramBotToken": "",

View file

@ -0,0 +1,3 @@
{
"path": ""
}

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

View file

@ -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('');
}
}

View file

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

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

View file

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

View file

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

View file

@ -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();
}
}

View file

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

View file

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

View file

@ -0,0 +1,4 @@
export interface WidgetInterface<W, D, R> {
isMyConfig(widgetParams: W): boolean;
render(widgetParams: W, dashboardParams: D): Promise<R>;
}

View file

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

View file

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

View file

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

View file

@ -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,
};
});
}
}

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

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

View 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
View file

@ -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",

View file

@ -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",

View file

@ -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,
);
},
);
}
}

View file

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

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

View file

@ -0,0 +1 @@
export const ISO_DATE_FORMAT = 'yyyy-MM-dd';

View file

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

View file

@ -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();
}
}

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

@ -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(),

View file

@ -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();

View file

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

View file

@ -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}`,
);
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);

View file

@ -6,7 +6,7 @@
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "es2017",
"target": "es2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",

View 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>

View 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>