Merge branch 'dev'
Генерация отчёта для дейли для ECCM
This commit is contained in:
commit
2558908aed
31 changed files with 1278 additions and 55 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -38,3 +38,6 @@ configs/issue-event-emitter-config.jsonc
|
||||||
tmp/*
|
tmp/*
|
||||||
configs/redmine-statuses-config.jsonc
|
configs/redmine-statuses-config.jsonc
|
||||||
configs/redmine-status-changes-config.jsonc
|
configs/redmine-status-changes-config.jsonc
|
||||||
|
configs/eccm-versions-config.jsonc
|
||||||
|
configs/eccm-config.jsonc
|
||||||
|
configs/current-user-rules.jsonc
|
||||||
|
|
|
||||||
4
configs/current-user-rules.jsonc.dist
Normal file
4
configs/current-user-rules.jsonc.dist
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"New": "",
|
||||||
|
"*": ""
|
||||||
|
}
|
||||||
11
configs/eccm-config.jsonc.dist
Normal file
11
configs/eccm-config.jsonc.dist
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"currentVersions": [],
|
||||||
|
"projectName": "",
|
||||||
|
"currentIssuesStatuses": [],
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"name": "",
|
||||||
|
"people": [""]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1
libs/event-emitter/src/consts/consts.ts
Normal file
1
libs/event-emitter/src/consts/consts.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export const UNLIMITED = 999999;
|
||||||
|
|
@ -65,7 +65,8 @@ export class RedmineEventsGateway {
|
||||||
res = await this.redmineDataLoader.loadIssues(issueNumbers);
|
res = await this.redmineDataLoader.loadIssues(issueNumbers);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Error load issues: ${e.message} for issues: ${issueNumbers}`,
|
`Error load issues: ${e.message} ` +
|
||||||
|
`for issues: ${JSON.stringify(issueNumbers)}`,
|
||||||
);
|
);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { RedmineTypes } from '../models/redmine-types';
|
import { RedmineTypes } from '../models/redmine-types';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { CacheTTL, Injectable, Logger } from '@nestjs/common';
|
||||||
import { Issues } from '../couchdb-datasources/issues';
|
import { Issues } from '../couchdb-datasources/issues';
|
||||||
import { RedmineEventsGateway } from '../events/redmine-events.gateway';
|
import { RedmineEventsGateway } from '../events/redmine-events.gateway';
|
||||||
import { RedmineIssuesCacheWriterService } from '../issue-cache-writer/redmine-issues-cache-writer.service';
|
import { RedmineIssuesCacheWriterService } from '../issue-cache-writer/redmine-issues-cache-writer.service';
|
||||||
import { RedmineDataLoader } from '../redmine-data-loader/redmine-data-loader';
|
import { RedmineDataLoader } from '../redmine-data-loader/redmine-data-loader';
|
||||||
import { MemoryCache } from '../utils/memory-cache';
|
import { MemoryCache } from '../utils/memory-cache';
|
||||||
import nano from 'nano';
|
import nano from 'nano';
|
||||||
|
import { UNLIMITED } from '../consts/consts';
|
||||||
|
import { GetParentsHint } from '../utils/get-parents-hint';
|
||||||
|
|
||||||
export const ISSUE_MEMORY_CACHE_LIFETIME = 30 * 1000;
|
export const ISSUE_MEMORY_CACHE_LIFETIME = 30 * 1000;
|
||||||
const ISSUE_MEMORY_CACHE_AUTOCLEAN_INTERVAL = 1000 * 60 * 5;
|
const ISSUE_MEMORY_CACHE_AUTOCLEAN_INTERVAL = 1000 * 60 * 5;
|
||||||
|
|
@ -32,6 +34,49 @@ export class IssuesService {
|
||||||
return res.docs;
|
return res.docs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@CacheTTL(60)
|
||||||
|
async getIssues(ids: number[]): Promise<RedmineTypes.Issue[]> {
|
||||||
|
const issueDb = await this.issues.getDatasource();
|
||||||
|
try {
|
||||||
|
const docs = await issueDb.find({
|
||||||
|
selector: {
|
||||||
|
id: {
|
||||||
|
$in: ids,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
limit: UNLIMITED,
|
||||||
|
});
|
||||||
|
return docs.docs;
|
||||||
|
} catch (ex) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@CacheTTL(120)
|
||||||
|
async getParents(
|
||||||
|
issueId: number,
|
||||||
|
count?: number,
|
||||||
|
): Promise<RedmineTypes.Issue[]> {
|
||||||
|
let index = 0;
|
||||||
|
const res: RedmineTypes.Issue[] = [];
|
||||||
|
let currentIssue = await this.getIssue(issueId);
|
||||||
|
res.unshift(currentIssue);
|
||||||
|
let parentIssueId = currentIssue.parent?.id || null;
|
||||||
|
while (
|
||||||
|
parentIssueId !== null &&
|
||||||
|
currentIssue.id >= 0 &&
|
||||||
|
(typeof count === 'undefined' || count === null || index < count)
|
||||||
|
) {
|
||||||
|
currentIssue = await this.getIssue(parentIssueId);
|
||||||
|
res.unshift(currentIssue);
|
||||||
|
parentIssueId = currentIssue.parent?.id || null;
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
const parentsHint = GetParentsHint(res);
|
||||||
|
this.logger.debug(`Parents for issue #${issueId} - ${parentsHint}`);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
async getIssue(
|
async getIssue(
|
||||||
issueId: number,
|
issueId: number,
|
||||||
force = false,
|
force = false,
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,15 @@ export module RedmineTypes {
|
||||||
details?: JournalDetail[];
|
details?: JournalDetail[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChildIssue = {
|
||||||
|
id: number;
|
||||||
|
tracker: IdAndName;
|
||||||
|
subject: string;
|
||||||
|
children?: Children;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Children = ChildIssue[];
|
||||||
|
|
||||||
export type Issue = {
|
export type Issue = {
|
||||||
id: number;
|
id: number;
|
||||||
project: IdAndName;
|
project: IdAndName;
|
||||||
|
|
@ -48,6 +57,8 @@ export module RedmineTypes {
|
||||||
closed_on?: string;
|
closed_on?: string;
|
||||||
relations?: Record<string, any>[];
|
relations?: Record<string, any>[];
|
||||||
journals?: Journal[];
|
journals?: Journal[];
|
||||||
|
children?: Children;
|
||||||
|
parent?: { id: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/prefer-namespace-keyword, @typescript-eslint/no-namespace
|
// eslint-disable-next-line @typescript-eslint/prefer-namespace-keyword, @typescript-eslint/no-namespace
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,14 @@ export class RedmineDataLoader {
|
||||||
|
|
||||||
async loadIssue(issueNumber: number): Promise<RedmineTypes.Issue | null> {
|
async loadIssue(issueNumber: number): Promise<RedmineTypes.Issue | null> {
|
||||||
const url = this.getIssueUrl(issueNumber);
|
const url = this.getIssueUrl(issueNumber);
|
||||||
const resp = await axios.get(url);
|
let resp;
|
||||||
|
try {
|
||||||
|
resp = await axios.get(url);
|
||||||
|
} catch (ex) {
|
||||||
|
const errorMsg = ex.message || 'Unknown error';
|
||||||
|
this.logger.error(`${errorMsg} for url = ${url}`);
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
if (!resp || !resp.data || !resp.data.issue) {
|
if (!resp || !resp.data || !resp.data.issue) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to load issue from redmine, issueNumber = ${issueNumber}`,
|
`Failed to load issue from redmine, issueNumber = ${issueNumber}`,
|
||||||
|
|
@ -34,7 +41,15 @@ export class RedmineDataLoader {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Loaded issue, issueNumber = ${issueNumber}, subject = ${resp.data.issue.subject}`,
|
`Loaded issue, issueNumber = ${issueNumber}, subject = ${resp.data.issue.subject}`,
|
||||||
);
|
);
|
||||||
return await this.enhancerService.enhanceIssue(resp.data.issue);
|
let enhancedIssue;
|
||||||
|
try {
|
||||||
|
enhancedIssue = await this.enhancerService.enhanceIssue(resp.data.issue);
|
||||||
|
} catch (ex) {
|
||||||
|
const errorMsg = ex.message || 'Unknown error';
|
||||||
|
this.logger.error(`${errorMsg} at enhance issue #${issueNumber}`);
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
return enhancedIssue;
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadUsers(users: number[]): Promise<(RedmineTypes.User | null)[]> {
|
async loadUsers(users: number[]): Promise<(RedmineTypes.User | null)[]> {
|
||||||
|
|
@ -43,8 +58,19 @@ export class RedmineDataLoader {
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadUser(userNumber: number): Promise<RedmineTypes.User | null> {
|
async loadUser(userNumber: number): Promise<RedmineTypes.User | null> {
|
||||||
|
if (userNumber <= 0) {
|
||||||
|
this.logger.warn(`Invalid userNumber = ${userNumber}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const url = this.getUserUrl(userNumber);
|
const url = this.getUserUrl(userNumber);
|
||||||
const resp = await axios.get(url);
|
let resp;
|
||||||
|
try {
|
||||||
|
resp = await axios.get(url);
|
||||||
|
} catch (ex) {
|
||||||
|
const errorMsg = ex.message || 'Unknown error';
|
||||||
|
this.logger.error(`${errorMsg} at load user by url ${url}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (!resp || !resp.data?.user) {
|
if (!resp || !resp.data?.user) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to load user from redmine, userNumber = ${userNumber}`,
|
`Failed to load user from redmine, userNumber = ${userNumber}`,
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export class UsersService {
|
||||||
},
|
},
|
||||||
limit: 1,
|
limit: 1,
|
||||||
});
|
});
|
||||||
if (!res && !res.docs && !res.docs[0]) {
|
if (!res || !res.docs || !res.docs[0]) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const userFromDb = res.docs[0];
|
const userFromDb = res.docs[0];
|
||||||
|
|
@ -72,6 +72,15 @@ export class UsersService {
|
||||||
return RedmineTypes.CreateUser(userFromDb);
|
return RedmineTypes.CreateUser(userFromDb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findUserByFullname(
|
||||||
|
fullname: string,
|
||||||
|
): Promise<RedmineTypes.User | null> {
|
||||||
|
const parts = fullname.split(' ').map((item) => item.trim());
|
||||||
|
const lastname = parts.splice(parts.length - 1, 1).join(' ');
|
||||||
|
const firstname = parts.join(' ');
|
||||||
|
return await this.findUserByName(firstname, lastname);
|
||||||
|
}
|
||||||
|
|
||||||
private async getUserFromRedmine(
|
private async getUserFromRedmine(
|
||||||
userId: number,
|
userId: number,
|
||||||
): Promise<RedmineTypes.User | null> {
|
): Promise<RedmineTypes.User | null> {
|
||||||
|
|
|
||||||
13
libs/event-emitter/src/utils/get-parents-hint.ts
Normal file
13
libs/event-emitter/src/utils/get-parents-hint.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { RedmineTypes } from '../models/redmine-types';
|
||||||
|
|
||||||
|
export function GetParentsHint(issues: RedmineTypes.Issue[]): string {
|
||||||
|
const parentsHint = issues
|
||||||
|
.map((issue) => {
|
||||||
|
if (issue.id < 0) {
|
||||||
|
return '...';
|
||||||
|
}
|
||||||
|
return `${issue.tracker.name} #${issue.id} (${issue.subject})`;
|
||||||
|
})
|
||||||
|
.join(' > ');
|
||||||
|
return parentsHint;
|
||||||
|
}
|
||||||
49
package-lock.json
generated
49
package-lock.json
generated
|
|
@ -19,6 +19,7 @@
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
"cache-manager": "^4.1.0",
|
"cache-manager": "^4.1.0",
|
||||||
"handlebars": "^4.7.7",
|
"handlebars": "^4.7.7",
|
||||||
|
"hbs": "^4.2.0",
|
||||||
"imap-simple": "^5.1.0",
|
"imap-simple": "^5.1.0",
|
||||||
"nano": "^10.0.0",
|
"nano": "^10.0.0",
|
||||||
"node-telegram-bot-api": "^0.59.0",
|
"node-telegram-bot-api": "^0.59.0",
|
||||||
|
|
@ -4864,6 +4865,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/foreachasync": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/foreachasync/-/foreachasync-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-J+ler7Ta54FwwNcx6wQRDhTIbNeyDcARMkOcguEqnEdtm0jKvN3Li3PDAb2Du3ubJYEWfYL83XMROXdsXAXycw=="
|
||||||
|
},
|
||||||
"node_modules/forever-agent": {
|
"node_modules/forever-agent": {
|
||||||
"version": "0.6.1",
|
"version": "0.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
|
||||||
|
|
@ -5343,6 +5349,19 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hbs": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/hbs/-/hbs-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-dQwHnrfWlTk5PvG9+a45GYpg0VpX47ryKF8dULVd6DtwOE6TEcYQXQ5QM6nyOx/h7v3bvEQbdn19EDAcfUAgZg==",
|
||||||
|
"dependencies": {
|
||||||
|
"handlebars": "4.7.7",
|
||||||
|
"walk": "2.3.15"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8",
|
||||||
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hexoid": {
|
"node_modules/hexoid": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz",
|
||||||
|
|
@ -9899,6 +9918,14 @@
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/walk": {
|
||||||
|
"version": "2.3.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/walk/-/walk-2.3.15.tgz",
|
||||||
|
"integrity": "sha512-4eRTBZljBfIISK1Vnt69Gvr2w/wc3U6Vtrw7qiN5iqYJPH7LElcYh/iU4XWhdCy2dZqv1ToMyYlybDylfG/5Vg==",
|
||||||
|
"dependencies": {
|
||||||
|
"foreachasync": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/walker": {
|
"node_modules/walker": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
|
||||||
|
|
@ -14010,6 +14037,11 @@
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz",
|
||||||
"integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA=="
|
"integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA=="
|
||||||
},
|
},
|
||||||
|
"foreachasync": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/foreachasync/-/foreachasync-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-J+ler7Ta54FwwNcx6wQRDhTIbNeyDcARMkOcguEqnEdtm0jKvN3Li3PDAb2Du3ubJYEWfYL83XMROXdsXAXycw=="
|
||||||
|
},
|
||||||
"forever-agent": {
|
"forever-agent": {
|
||||||
"version": "0.6.1",
|
"version": "0.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
|
||||||
|
|
@ -14346,6 +14378,15 @@
|
||||||
"has-symbols": "^1.0.2"
|
"has-symbols": "^1.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"hbs": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/hbs/-/hbs-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-dQwHnrfWlTk5PvG9+a45GYpg0VpX47ryKF8dULVd6DtwOE6TEcYQXQ5QM6nyOx/h7v3bvEQbdn19EDAcfUAgZg==",
|
||||||
|
"requires": {
|
||||||
|
"handlebars": "4.7.7",
|
||||||
|
"walk": "2.3.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
"hexoid": {
|
"hexoid": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz",
|
||||||
|
|
@ -17740,6 +17781,14 @@
|
||||||
"xml-name-validator": "^3.0.0"
|
"xml-name-validator": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"walk": {
|
||||||
|
"version": "2.3.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/walk/-/walk-2.3.15.tgz",
|
||||||
|
"integrity": "sha512-4eRTBZljBfIISK1Vnt69Gvr2w/wc3U6Vtrw7qiN5iqYJPH7LElcYh/iU4XWhdCy2dZqv1ToMyYlybDylfG/5Vg==",
|
||||||
|
"requires": {
|
||||||
|
"foreachasync": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"walker": {
|
"walker": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
"cache-manager": "^4.1.0",
|
"cache-manager": "^4.1.0",
|
||||||
"handlebars": "^4.7.7",
|
"handlebars": "^4.7.7",
|
||||||
|
"hbs": "^4.2.0",
|
||||||
"imap-simple": "^5.1.0",
|
"imap-simple": "^5.1.0",
|
||||||
"nano": "^10.0.0",
|
"nano": "^10.0.0",
|
||||||
"node-telegram-bot-api": "^0.59.0",
|
"node-telegram-bot-api": "^0.59.0",
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { EnhancerService } from '@app/event-emitter/issue-enhancers/enhancer.ser
|
||||||
import { TimestampEnhancer } from '@app/event-emitter/issue-enhancers/timestamps-enhancer';
|
import { TimestampEnhancer } from '@app/event-emitter/issue-enhancers/timestamps-enhancer';
|
||||||
import { MainController } from '@app/event-emitter/main/main.controller';
|
import { MainController } from '@app/event-emitter/main/main.controller';
|
||||||
import { CacheModule, Logger, Module, OnModuleInit } from '@nestjs/common';
|
import { CacheModule, Logger, Module, OnModuleInit } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { switchMap, tap } from 'rxjs';
|
import { switchMap, tap } from 'rxjs';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
|
|
@ -22,8 +22,13 @@ import { TelegramBotService } from './telegram-bot/telegram-bot.service';
|
||||||
import { UserMetaInfoService } from './user-meta-info/user-meta-info.service';
|
import { UserMetaInfoService } from './user-meta-info/user-meta-info.service';
|
||||||
import { UserMetaInfo } from './couchdb-datasources/user-meta-info';
|
import { UserMetaInfo } from './couchdb-datasources/user-meta-info';
|
||||||
import { PersonalNotificationAdapterService } from './notifications/adapters/personal-notification.adapter/personal-notification.adapter.service';
|
import { PersonalNotificationAdapterService } from './notifications/adapters/personal-notification.adapter/personal-notification.adapter.service';
|
||||||
import { PublicUrlAdapterService } from './notifications/adapters/public-url.adapter.service';
|
|
||||||
import { StatusChangeAdapterService } from './notifications/adapters/status-change.adapter.service';
|
import { StatusChangeAdapterService } from './notifications/adapters/status-change.adapter.service';
|
||||||
|
import { CurrentIssuesEccmReportService } from './reports/current-issues-eccm.report.service';
|
||||||
|
import { CurrentIssuesBotHandlerService } from './telegram-bot/handlers/current-issues.bot-handler.service';
|
||||||
|
import { CurrentIssuesEccmReportController } from './reports/current-issues-eccm.report.controller';
|
||||||
|
import { DailyEccmReportController } from './reports/daily-eccm.report.controller';
|
||||||
|
import { DailyEccmReportService } from './reports/daily-eccm.report.service';
|
||||||
|
import { ChangesService } from './changes/changes.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -35,7 +40,12 @@ import { StatusChangeAdapterService } from './notifications/adapters/status-chan
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AppController, MainController],
|
controllers: [
|
||||||
|
AppController,
|
||||||
|
MainController,
|
||||||
|
CurrentIssuesEccmReportController,
|
||||||
|
DailyEccmReportController,
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AppService,
|
AppService,
|
||||||
CustomFieldsEnhancer,
|
CustomFieldsEnhancer,
|
||||||
|
|
@ -49,8 +59,11 @@ import { StatusChangeAdapterService } from './notifications/adapters/status-chan
|
||||||
UserMetaInfoService,
|
UserMetaInfoService,
|
||||||
UserMetaInfo,
|
UserMetaInfo,
|
||||||
PersonalNotificationAdapterService,
|
PersonalNotificationAdapterService,
|
||||||
PublicUrlAdapterService,
|
|
||||||
StatusChangeAdapterService,
|
StatusChangeAdapterService,
|
||||||
|
CurrentIssuesEccmReportService,
|
||||||
|
CurrentIssuesBotHandlerService,
|
||||||
|
DailyEccmReportService,
|
||||||
|
ChangesService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements OnModuleInit {
|
export class AppModule implements OnModuleInit {
|
||||||
|
|
|
||||||
15
src/changes/changes.service.ts
Normal file
15
src/changes/changes.service.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Changes } from 'src/couchdb-datasources/changes';
|
||||||
|
import nano from 'nano';
|
||||||
|
import { Change } from 'src/models/change.model';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ChangesService {
|
||||||
|
constructor(private changes: Changes) {}
|
||||||
|
|
||||||
|
async find(query: nano.MangoQuery): Promise<Change[]> {
|
||||||
|
const changesDb = await this.changes.getDatasource();
|
||||||
|
const res = await changesDb.find(query);
|
||||||
|
return res.docs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,10 +5,14 @@ import { parse } from 'jsonc-parser';
|
||||||
import { AppConfig } from 'src/models/app-config.model';
|
import { AppConfig } from 'src/models/app-config.model';
|
||||||
import RedmineStatusesConfigLoader from './statuses.config';
|
import RedmineStatusesConfigLoader from './statuses.config';
|
||||||
import RedmineStatusChangesConfigLoader from './status-changes.config';
|
import RedmineStatusChangesConfigLoader from './status-changes.config';
|
||||||
|
import RedmineEccmConfig from './eccm.config';
|
||||||
|
import RedmineCurrentUserRulesConfig from './current-user-rules.config';
|
||||||
|
|
||||||
const redmineIssueEventEmitterConfig = RedmineIssueEventEmitterConfigLoader();
|
const redmineIssueEventEmitterConfig = RedmineIssueEventEmitterConfigLoader();
|
||||||
const redmineStatusesConfig = RedmineStatusesConfigLoader();
|
const redmineStatusesConfig = RedmineStatusesConfigLoader();
|
||||||
const redmineStatusChanges = RedmineStatusChangesConfigLoader();
|
const redmineStatusChanges = RedmineStatusChangesConfigLoader();
|
||||||
|
const redmineEccm = RedmineEccmConfig();
|
||||||
|
const redmineCurrentUserRules = RedmineCurrentUserRulesConfig();
|
||||||
|
|
||||||
let appConfig: AppConfig;
|
let appConfig: AppConfig;
|
||||||
|
|
||||||
|
|
@ -30,6 +34,8 @@ export default (): AppConfig => {
|
||||||
redmineStatuses: redmineStatusesConfig,
|
redmineStatuses: redmineStatusesConfig,
|
||||||
redmineIssueEventEmitterConfig: redmineIssueEventEmitterConfig,
|
redmineIssueEventEmitterConfig: redmineIssueEventEmitterConfig,
|
||||||
redmineStatusChanges: redmineStatusChanges,
|
redmineStatusChanges: redmineStatusChanges,
|
||||||
|
redmineEccm: redmineEccm,
|
||||||
|
redmineCurrentUserRules: redmineCurrentUserRules,
|
||||||
};
|
};
|
||||||
|
|
||||||
return appConfig;
|
return appConfig;
|
||||||
|
|
|
||||||
23
src/configs/current-user-rules.config.ts
Normal file
23
src/configs/current-user-rules.config.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { join } from 'path';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { parse } from 'jsonc-parser';
|
||||||
|
|
||||||
|
let currentUserRules: Record<string, string>;
|
||||||
|
|
||||||
|
export default (): Record<string, string> => {
|
||||||
|
if (currentUserRules) {
|
||||||
|
return currentUserRules;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userDefinedConfigPath =
|
||||||
|
process.env['ELTEX_REDMINE_HELPER_CURRENT_USER_RULES_CONFIG_PATH'];
|
||||||
|
const defaultConfigPath = join('configs', 'current-user-rules.jsonc');
|
||||||
|
|
||||||
|
const configPath = userDefinedConfigPath || defaultConfigPath;
|
||||||
|
|
||||||
|
const rawData = readFileSync(configPath, { encoding: 'utf-8' });
|
||||||
|
|
||||||
|
currentUserRules = parse(rawData);
|
||||||
|
|
||||||
|
return currentUserRules;
|
||||||
|
};
|
||||||
24
src/configs/eccm.config.ts
Normal file
24
src/configs/eccm.config.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { EccmConfig } from 'src/models/eccm-config.model';
|
||||||
|
import { parse } from 'jsonc-parser';
|
||||||
|
|
||||||
|
let eccmVersion: EccmConfig.Config;
|
||||||
|
|
||||||
|
export default (): EccmConfig.Config => {
|
||||||
|
if (eccmVersion) {
|
||||||
|
return eccmVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userDefinedConfigPath =
|
||||||
|
process.env['ELTEX_REDMINE_HELPER_ECCM_VERSIONS_CONFIG_PATH'];
|
||||||
|
const defaultConfigPath = join('configs', 'eccm-config.jsonc');
|
||||||
|
|
||||||
|
const configPath = userDefinedConfigPath || defaultConfigPath;
|
||||||
|
|
||||||
|
const rawData = readFileSync(configPath, { encoding: 'utf-8' });
|
||||||
|
|
||||||
|
eccmVersion = parse(rawData);
|
||||||
|
|
||||||
|
return eccmVersion;
|
||||||
|
};
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
|
@ -14,4 +15,13 @@ export class RedminePublicUrlConverter {
|
||||||
convert(issueId: number | string): string {
|
convert(issueId: number | string): string {
|
||||||
return `${this.redminePublicUrlPrefix}/issues/${issueId}`;
|
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>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,23 @@
|
||||||
import { IssueEnhancerInterface } from '@app/event-emitter/issue-enhancers/issue-enhancer-interface';
|
import { IssueEnhancerInterface } from '@app/event-emitter/issue-enhancers/issue-enhancer-interface';
|
||||||
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
|
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CurrentUserEnhancer implements IssueEnhancerInterface {
|
export class CurrentUserEnhancer implements IssueEnhancerInterface {
|
||||||
name = 'current-user';
|
name = 'current-user';
|
||||||
|
|
||||||
// TODO: Переместить правила в конфиг
|
private rules: Record<string, string>;
|
||||||
|
private logger = new Logger(CurrentUserEnhancer.name);
|
||||||
|
|
||||||
private rules = {
|
constructor(private configService: ConfigService) {
|
||||||
New: 'dev',
|
this.rules = this.configService.get<Record<string, string>>(
|
||||||
'In Progress': 'dev',
|
'redmineCurrentUserRules',
|
||||||
'Re-opened': 'dev',
|
);
|
||||||
'Code Review': 'cr',
|
this.logger.debug(
|
||||||
Resolved: 'qa',
|
`Loaded rules for current user enhancer - ${JSON.stringify(this.rules)}`,
|
||||||
Testing: 'qa',
|
);
|
||||||
'Wait Release': 'dev',
|
}
|
||||||
Pending: 'dev',
|
|
||||||
Feedback: 'qa',
|
|
||||||
Closed: 'dev',
|
|
||||||
Rejected: 'dev',
|
|
||||||
};
|
|
||||||
|
|
||||||
async enhance(
|
async enhance(
|
||||||
issue: RedmineTypes.Issue,
|
issue: RedmineTypes.Issue,
|
||||||
|
|
@ -29,7 +26,7 @@ export class CurrentUserEnhancer implements IssueEnhancerInterface {
|
||||||
|
|
||||||
const status = issue.status.name;
|
const status = issue.status.name;
|
||||||
|
|
||||||
const fieldName = this.rules[status];
|
const fieldName = this.rules[status] || this.rules['*'] || null;
|
||||||
if (fieldName) {
|
if (fieldName) {
|
||||||
res.current_user = { ...res[fieldName] };
|
res.current_user = { ...res[fieldName] };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
22
src/main.ts
22
src/main.ts
|
|
@ -1,12 +1,30 @@
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
import { join } from 'path';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
import * as hbs from 'hbs';
|
||||||
|
import configuration from './configs/app';
|
||||||
|
|
||||||
process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = "0";
|
process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule, {
|
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||||
logger: ['debug', 'error', 'warn', 'log', 'verbose'],
|
logger: ['debug', 'error', 'warn', 'log', 'verbose'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.setBaseViewsDir(join(__dirname, '..', 'views'));
|
||||||
|
app.setViewEngine('hbs');
|
||||||
|
|
||||||
|
// TODO: Продумать как правильно иницировать partial-ы в handlebars
|
||||||
|
// Возможно подойдёт решение описанное тут - https://www.makeuseof.com/handlebars-nestjs-templating/
|
||||||
|
// Низкоуровневое решение по исходному коду hbs:
|
||||||
|
const redminePublicUrl =
|
||||||
|
configuration().redmineIssueEventEmitterConfig.redmineUrlPublic;
|
||||||
|
hbs.registerPartial(
|
||||||
|
'redmineIssueAHref',
|
||||||
|
`<a href="${redminePublicUrl}/issues/{{issue.id}}">{{issue.tracker.name}} #{{issue.id}}</a>`,
|
||||||
|
);
|
||||||
|
|
||||||
await app.listen(process.env['PORT'] || 3000);
|
await app.listen(process.env['PORT'] || 3000);
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { MainConfigModel } from '@app/event-emitter/models/main-config-model';
|
import { MainConfigModel } from '@app/event-emitter/models/main-config-model';
|
||||||
|
import { EccmConfig } from './eccm-config.model';
|
||||||
import { StatusChangesConfig } from './status-changes-config.model';
|
import { StatusChangesConfig } from './status-changes-config.model';
|
||||||
import { StatusesConfig } from './statuses-config.model';
|
import { StatusesConfig } from './statuses-config.model';
|
||||||
|
|
||||||
|
|
@ -6,6 +7,8 @@ export type AppConfig = {
|
||||||
redmineIssueEventEmitterConfig: MainConfigModel;
|
redmineIssueEventEmitterConfig: MainConfigModel;
|
||||||
redmineStatuses: StatusesConfig.Config;
|
redmineStatuses: StatusesConfig.Config;
|
||||||
redmineStatusChanges: StatusChangesConfig.Config;
|
redmineStatusChanges: StatusChangesConfig.Config;
|
||||||
|
redmineEccm: EccmConfig.Config;
|
||||||
|
redmineCurrentUserRules: Record<string, string>;
|
||||||
couchDb: {
|
couchDb: {
|
||||||
dbs: {
|
dbs: {
|
||||||
changes: string;
|
changes: string;
|
||||||
|
|
|
||||||
14
src/models/eccm-config.model.ts
Normal file
14
src/models/eccm-config.model.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-namespace */
|
||||||
|
export namespace EccmConfig {
|
||||||
|
export type UserGroup = {
|
||||||
|
name: string;
|
||||||
|
people: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Config = {
|
||||||
|
currentVersions: string[];
|
||||||
|
projectName: string;
|
||||||
|
currentIssuesStatuses: string[];
|
||||||
|
groups: UserGroup[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ import { ConfigService } from '@nestjs/config';
|
||||||
import { IssueAndPersonalParsedMessageModel } from 'src/models/issue-and-personal-parsed-message.model';
|
import { IssueAndPersonalParsedMessageModel } from 'src/models/issue-and-personal-parsed-message.model';
|
||||||
import { TelegramBotService } from 'src/telegram-bot/telegram-bot.service';
|
import { TelegramBotService } from 'src/telegram-bot/telegram-bot.service';
|
||||||
import Handlebars from 'handlebars';
|
import Handlebars from 'handlebars';
|
||||||
import { PublicUrlAdapterService } from '../public-url.adapter.service';
|
import { RedminePublicUrlConverter } from 'src/converters/redmine-public-url.converter';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PersonalNotificationAdapterService {
|
export class PersonalNotificationAdapterService {
|
||||||
|
|
@ -14,7 +14,7 @@ export class PersonalNotificationAdapterService {
|
||||||
private telegramBotService: TelegramBotService,
|
private telegramBotService: TelegramBotService,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private usersService: UsersService,
|
private usersService: UsersService,
|
||||||
private publicUrlAdapterService: PublicUrlAdapterService,
|
private redminePublicUrlConverter: RedminePublicUrlConverter,
|
||||||
) {
|
) {
|
||||||
const template = this.configService.get<string>('personalMessageTemplate');
|
const template = this.configService.get<string>('personalMessageTemplate');
|
||||||
this.personalMessageTemplate = Handlebars.compile(template);
|
this.personalMessageTemplate = Handlebars.compile(template);
|
||||||
|
|
@ -26,7 +26,7 @@ export class PersonalNotificationAdapterService {
|
||||||
const promises = issueAndMessages.personalParsedMessage.recipients.map(
|
const promises = issueAndMessages.personalParsedMessage.recipients.map(
|
||||||
async (recipient) => {
|
async (recipient) => {
|
||||||
const redmineId = recipient;
|
const redmineId = recipient;
|
||||||
const issueUrlHtml = this.publicUrlAdapterService.getHtmlHref(
|
const issueUrlHtml = this.redminePublicUrlConverter.getHtmlHref(
|
||||||
issueAndMessages.issue,
|
issueAndMessages.issue,
|
||||||
);
|
);
|
||||||
const sender = await this.usersService.getUser(
|
const sender = await this.usersService.getUser(
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class PublicUrlAdapterService {
|
|
||||||
private publicUrl: string;
|
|
||||||
|
|
||||||
constructor(private configService: ConfigService) {
|
|
||||||
this.publicUrl = this.configService.get<string>('redmineUrlPublic');
|
|
||||||
}
|
|
||||||
|
|
||||||
getUrl(issueId: number): string {
|
|
||||||
return `${this.publicUrl}/issues/${issueId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
getHtmlHref(issue: RedmineTypes.Issue): string {
|
|
||||||
const url = this.getUrl(issue.id);
|
|
||||||
return `<a href="${url}">${issue.tracker.name} #${issue.id}</a>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
60
src/reports/current-issues-eccm.report.controller.ts
Normal file
60
src/reports/current-issues-eccm.report.controller.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { Controller, Get, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { EccmConfig } from 'src/models/eccm-config.model';
|
||||||
|
import {
|
||||||
|
CurrentIssuesEccmReport,
|
||||||
|
CurrentIssuesEccmReportService,
|
||||||
|
} from './current-issues-eccm.report.service';
|
||||||
|
|
||||||
|
@Controller('current-issues-eccm')
|
||||||
|
export class CurrentIssuesEccmReportController {
|
||||||
|
private logger = new Logger(CurrentIssuesEccmReportController.name);
|
||||||
|
private eccmConfig: EccmConfig.Config;
|
||||||
|
|
||||||
|
/* eslint-disable */
|
||||||
|
private reportTemplate = [
|
||||||
|
// context - this.report: UserReport[]
|
||||||
|
'{{#each this}}',
|
||||||
|
'<div>',
|
||||||
|
// context - UserReport
|
||||||
|
'<p>{{user.firstname}} {{user.lastname}}:</p>',
|
||||||
|
'<ul>',
|
||||||
|
'{{#each issuesGroupedByStatus}}',
|
||||||
|
'<li>',
|
||||||
|
// context - IssuesAndStatus
|
||||||
|
'{{status.name}}:',
|
||||||
|
'<ul>',
|
||||||
|
'{{#each issues}}',
|
||||||
|
'<li>',
|
||||||
|
// context - RedmineTypes.Issue
|
||||||
|
`{{>redmineIssueAHref issue=.}}: {{subject}} (прио - {{priority.name}}, версия - {{fixed_version.name}})`,
|
||||||
|
'</li>',
|
||||||
|
'{{/each}}',
|
||||||
|
'</ul>',
|
||||||
|
'</li>',
|
||||||
|
'{{/each}}',
|
||||||
|
'</ul>',
|
||||||
|
'</div>',
|
||||||
|
'{{/each}}',
|
||||||
|
].join('\n');
|
||||||
|
/* eslint-enable */
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private currentIssuesEccmReportService: CurrentIssuesEccmReportService,
|
||||||
|
private configService: ConfigService,
|
||||||
|
) {
|
||||||
|
this.eccmConfig = this.configService.get<EccmConfig.Config>('redmineEccm');
|
||||||
|
this.logger.debug(`Eccm config - ${JSON.stringify(this.eccmConfig)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async getReport(): Promise<string> {
|
||||||
|
return await this.currentIssuesEccmReportService.getReport({
|
||||||
|
project: this.eccmConfig.projectName,
|
||||||
|
versions: this.eccmConfig.currentVersions,
|
||||||
|
statuses: this.eccmConfig.currentIssuesStatuses,
|
||||||
|
template: this.reportTemplate,
|
||||||
|
fields: CurrentIssuesEccmReport.Defaults.currentIssuesFields,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
231
src/reports/current-issues-eccm.report.service.ts
Normal file
231
src/reports/current-issues-eccm.report.service.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
/* eslint-disable prettier/prettier */
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Issues } from '@app/event-emitter/couchdb-datasources/issues';
|
||||||
|
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';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||||
|
export namespace CurrentIssuesEccmReport {
|
||||||
|
export type Options = {
|
||||||
|
statuses?: string[];
|
||||||
|
versions?: string[];
|
||||||
|
userIds?: number[];
|
||||||
|
project?: string;
|
||||||
|
template?: string;
|
||||||
|
fields?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||||
|
export namespace Defaults {
|
||||||
|
export const statuses = [
|
||||||
|
'In Progress',
|
||||||
|
'Feedback',
|
||||||
|
'Re-opened',
|
||||||
|
'Code Review',
|
||||||
|
'Resolved',
|
||||||
|
'Testing',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const projectName = 'ECCM';
|
||||||
|
|
||||||
|
export const currentIssuesFields = [
|
||||||
|
'id',
|
||||||
|
'tracker.name',
|
||||||
|
'status.id',
|
||||||
|
'status.name',
|
||||||
|
'priority.id',
|
||||||
|
'priority.name',
|
||||||
|
'fixed_version.name',
|
||||||
|
'subject',
|
||||||
|
'updated_on',
|
||||||
|
'updated_on_timestamp',
|
||||||
|
'current_user.id',
|
||||||
|
'current_user.firstname',
|
||||||
|
'current_user.lastname',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type User = {
|
||||||
|
id: number;
|
||||||
|
firstname: string;
|
||||||
|
lastname: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Status = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IssuesAndStatus = {
|
||||||
|
status: Status;
|
||||||
|
issues: RedmineTypes.Issue[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserReport = {
|
||||||
|
user: User;
|
||||||
|
issuesGroupedByStatus: IssuesAndStatus[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export class UsersReport {
|
||||||
|
private report: UserReport[] = [];
|
||||||
|
|
||||||
|
private reportTemplate = [
|
||||||
|
// context - this.report: UserReport[]
|
||||||
|
'{{#each this}}',
|
||||||
|
// context - UserReport
|
||||||
|
'{{user.firstname}} {{user.lastname}}:',
|
||||||
|
'',
|
||||||
|
'{{#each issuesGroupedByStatus}}',
|
||||||
|
// context - IssuesAndStatus
|
||||||
|
'{{status.name}}:',
|
||||||
|
'{{#each issues}}',
|
||||||
|
// context - RedmineTypes.Issue
|
||||||
|
` - {{>redmineIssueAHref issue=.}}: {{subject}} (прио - {{priority.name}}, версия - {{fixed_version.name}})`,
|
||||||
|
'{{/each}}',
|
||||||
|
'{{/each}}',
|
||||||
|
'',
|
||||||
|
'{{/each}}',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private redminePublicUrlConverter: RedminePublicUrlConverter,
|
||||||
|
private redminePublicUrl: string,
|
||||||
|
) {
|
||||||
|
Handlebars.registerPartial(
|
||||||
|
'redmineIssueAHref',
|
||||||
|
`<a href="${this.redminePublicUrl}/issues/{{issue.id}}">{{issue.tracker.name}} #{{issue.id}}</a>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
push(item: any): void {
|
||||||
|
const user: User = {
|
||||||
|
id: item.current_user.id,
|
||||||
|
firstname: item.current_user.firstname,
|
||||||
|
lastname: item.current_user.lastname,
|
||||||
|
};
|
||||||
|
const status: Status = {
|
||||||
|
id: item.status.id,
|
||||||
|
name: item.status.name,
|
||||||
|
};
|
||||||
|
const issuesAndStatus = this.getOrCreateIssuesAndStatus(user, status);
|
||||||
|
issuesAndStatus.issues.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
getOrCreateUserReport(user: User): UserReport {
|
||||||
|
let userReport: UserReport;
|
||||||
|
userReport = this.report.find((r) => r.user.id == user.id);
|
||||||
|
if (!userReport) {
|
||||||
|
userReport = {
|
||||||
|
user: user,
|
||||||
|
issuesGroupedByStatus: [],
|
||||||
|
};
|
||||||
|
this.report.push(userReport);
|
||||||
|
}
|
||||||
|
return userReport;
|
||||||
|
}
|
||||||
|
|
||||||
|
getOrCreateIssuesAndStatus(user: User, status: Status): IssuesAndStatus {
|
||||||
|
const userReport = this.getOrCreateUserReport(user);
|
||||||
|
let issuesAndStatus: IssuesAndStatus;
|
||||||
|
issuesAndStatus = userReport.issuesGroupedByStatus.find(
|
||||||
|
(i) => i.status.id == status.id,
|
||||||
|
);
|
||||||
|
if (!issuesAndStatus) {
|
||||||
|
issuesAndStatus = {
|
||||||
|
issues: [],
|
||||||
|
status: status,
|
||||||
|
};
|
||||||
|
userReport.issuesGroupedByStatus.push(issuesAndStatus);
|
||||||
|
}
|
||||||
|
return issuesAndStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllUsersReportByTemplate(template?: string): string {
|
||||||
|
const reportFormatter = Handlebars.compile(template || this.reportTemplate);
|
||||||
|
return reportFormatter(this.report);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CurrentIssuesEccmReportService {
|
||||||
|
private eccmConfig: EccmConfig.Config;
|
||||||
|
private logger = new Logger(CurrentIssuesEccmReportService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private configService: ConfigService,
|
||||||
|
private issuesDatasource: Issues,
|
||||||
|
private redminePublicUrlConverter: RedminePublicUrlConverter,
|
||||||
|
private issuesService: IssuesService,
|
||||||
|
) {
|
||||||
|
this.eccmConfig = this.configService.get<EccmConfig.Config>('redmineEccm');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getData(
|
||||||
|
options?: CurrentIssuesEccmReport.Options,
|
||||||
|
): Promise<RedmineTypes.Issue[]> {
|
||||||
|
const datasource = await this.issuesDatasource.getDatasource();
|
||||||
|
|
||||||
|
const query: nano.MangoQuery = {
|
||||||
|
selector: {
|
||||||
|
'status.name': {
|
||||||
|
$in: CurrentIssuesEccmReport.Defaults.statuses,
|
||||||
|
},
|
||||||
|
'fixed_version.name': {
|
||||||
|
$in: this.eccmConfig.currentVersions,
|
||||||
|
},
|
||||||
|
'project.name':
|
||||||
|
options?.project || CurrentIssuesEccmReport.Defaults.projectName,
|
||||||
|
},
|
||||||
|
limit: UNLIMITED,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options && options.fields) {
|
||||||
|
query.fields = options.fields;
|
||||||
|
}
|
||||||
|
if (options && options.statuses) {
|
||||||
|
query.selector['status.name'] = {
|
||||||
|
$in: options.statuses,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (options && options.versions) {
|
||||||
|
query.selector['fixed_version.name'] = {
|
||||||
|
$in: options.versions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (options && options.userIds) {
|
||||||
|
query.selector['current_user.id'] = {
|
||||||
|
$in: options.userIds,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
query.selector['current_user.id'] = {
|
||||||
|
$exists: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(`Query for get report data: ${JSON.stringify(query)}`);
|
||||||
|
|
||||||
|
const data = await this.issuesService.find(query);
|
||||||
|
this.logger.debug(`Found issues for report: ${JSON.stringify(data.length)}`);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getReport(options?: CurrentIssuesEccmReport.Options): Promise<string> {
|
||||||
|
this.logger.debug(`Options for report: ${JSON.stringify(options || null)}`);
|
||||||
|
const data = await this.getData(options);
|
||||||
|
this.logger.debug(`Data for report ${JSON.stringify(data)}`);
|
||||||
|
const report = new CurrentIssuesEccmReport.UsersReport(
|
||||||
|
this.redminePublicUrlConverter,
|
||||||
|
this.configService.get<string>('redmineUrlPublic'),
|
||||||
|
);
|
||||||
|
data.forEach((item) => report.push(item));
|
||||||
|
return report.getAllUsersReportByTemplate(options?.template);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/reports/daily-eccm.report.controller.ts
Normal file
37
src/reports/daily-eccm.report.controller.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { Controller, Get, Query, Render } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
DailyEccmReport,
|
||||||
|
DailyEccmReportService,
|
||||||
|
} from './daily-eccm.report.service';
|
||||||
|
|
||||||
|
@Controller('daily-eccm')
|
||||||
|
export class DailyEccmReportController {
|
||||||
|
constructor(private dailyEccmReportService: DailyEccmReportService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Render('daily-eccm-report')
|
||||||
|
async getReport(
|
||||||
|
@Query('from') from: string,
|
||||||
|
@Query('to') to: string,
|
||||||
|
): Promise<DailyEccmReport.Models.Report> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
return await this.dailyEccmReportService.generateReport({
|
||||||
|
from: from,
|
||||||
|
to: to,
|
||||||
|
reportDate: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/raw')
|
||||||
|
async getReportRawData(
|
||||||
|
@Query('from') from: string,
|
||||||
|
@Query('to') to: string,
|
||||||
|
): Promise<DailyEccmReport.Models.Report> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
return await this.dailyEccmReportService.generateReport({
|
||||||
|
from: from,
|
||||||
|
to: to,
|
||||||
|
reportDate: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
522
src/reports/daily-eccm.report.service.ts
Normal file
522
src/reports/daily-eccm.report.service.ts
Normal file
|
|
@ -0,0 +1,522 @@
|
||||||
|
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { ChangesService } from 'src/changes/changes.service';
|
||||||
|
import { EccmConfig } from 'src/models/eccm-config.model';
|
||||||
|
import { CurrentIssuesEccmReportService } from './current-issues-eccm.report.service';
|
||||||
|
import nano from 'nano';
|
||||||
|
import { UNLIMITED } from '@app/event-emitter/consts/consts';
|
||||||
|
import { TimestampConverter } from '@app/event-emitter/utils/timestamp-converter';
|
||||||
|
import { IssuesService } from '@app/event-emitter/issues/issues.service';
|
||||||
|
import { UsersService } from '@app/event-emitter/users/users.service';
|
||||||
|
import { GetParentsHint } from '@app/event-emitter/utils/get-parents-hint';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||||
|
export namespace DailyEccmReport {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||||
|
export namespace Models {
|
||||||
|
export type User = {
|
||||||
|
id: number;
|
||||||
|
firstname: string;
|
||||||
|
lastname: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Status = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IssuesAndStatus = {
|
||||||
|
status: Status;
|
||||||
|
issues: IssueWithParents[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserReport = {
|
||||||
|
user: User;
|
||||||
|
dailyMessage?: string;
|
||||||
|
teamleadMessage?: string;
|
||||||
|
issuesGroupedByStatus: IssuesAndStatus[];
|
||||||
|
activities: IssueAndChanges[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NewIssues = {
|
||||||
|
newIssues: IssueWithParents[];
|
||||||
|
newIssuesCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IssuesByStatuses = {
|
||||||
|
status: string;
|
||||||
|
issues: IssueWithParents[];
|
||||||
|
issuesCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MetricsByVersion = {
|
||||||
|
version: string;
|
||||||
|
newIssues: NewIssues;
|
||||||
|
issuesByStatuses: IssuesByStatuses[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MetricsByParents = {
|
||||||
|
parentIssueId: number;
|
||||||
|
parentIssue: RedmineTypes.Issue;
|
||||||
|
issuesByStatuses: IssuesByStatuses[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Метрики сгруппированные по версиям
|
||||||
|
*/
|
||||||
|
export type Metrics = {
|
||||||
|
byVersions: MetricsByVersion[];
|
||||||
|
byParents: MetricsByParents[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Все данные для отчёта
|
||||||
|
*/
|
||||||
|
export type Report = {
|
||||||
|
byUsers: UserReport[];
|
||||||
|
metrics?: Metrics;
|
||||||
|
/** Переданные параметры для создания отчёта */
|
||||||
|
params: Params;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Данные по задаче + для hint-а подсказка о родительских задачах
|
||||||
|
*
|
||||||
|
* Полезно будет для восстановления контекста
|
||||||
|
*/
|
||||||
|
export type IssueWithParents = {
|
||||||
|
issue: RedmineTypes.Issue;
|
||||||
|
parents: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Параметры отчёта
|
||||||
|
*/
|
||||||
|
export type Params = {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
reportDate: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IssueAndChanges = {
|
||||||
|
issue: RedmineTypes.Issue;
|
||||||
|
parents: string;
|
||||||
|
changes: Change[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Change = {
|
||||||
|
initiator?: RedmineTypes.PublicUser;
|
||||||
|
dev?: RedmineTypes.PublicUser;
|
||||||
|
cr?: RedmineTypes.PublicUser;
|
||||||
|
qa?: RedmineTypes.PublicUser;
|
||||||
|
current_user?: RedmineTypes.PublicUser;
|
||||||
|
author?: RedmineTypes.PublicUser;
|
||||||
|
issue_id: number;
|
||||||
|
issue_url: string;
|
||||||
|
issue_tracker: string;
|
||||||
|
issue_subject: string;
|
||||||
|
journal_note?: string;
|
||||||
|
old_status?: {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
} | null;
|
||||||
|
new_status?: {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
created_on: string;
|
||||||
|
created_on_timestamp: number | null;
|
||||||
|
change_message: string;
|
||||||
|
recipient: RedmineTypes.PublicUser;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Report {
|
||||||
|
private report: Models.Report;
|
||||||
|
private logger = new Logger(Report.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private eccmUsers: RedmineTypes.User[],
|
||||||
|
private issuesService: IssuesService,
|
||||||
|
) {
|
||||||
|
const users: Models.UserReport[] = this.eccmUsers.map((user) => {
|
||||||
|
if (!user) {
|
||||||
|
this.logger.error(`Not found user data`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const u: Models.User = {
|
||||||
|
id: user.id,
|
||||||
|
lastname: user.lastname,
|
||||||
|
firstname: user.firstname,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
user: u,
|
||||||
|
activities: [],
|
||||||
|
issuesGroupedByStatus: [],
|
||||||
|
} as Models.UserReport;
|
||||||
|
}).filter((user) => Boolean(user));
|
||||||
|
this.report = {
|
||||||
|
byUsers: users,
|
||||||
|
params: {
|
||||||
|
from: '',
|
||||||
|
to: '',
|
||||||
|
reportDate: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get(): Models.Report {
|
||||||
|
return this.report;
|
||||||
|
}
|
||||||
|
|
||||||
|
setParams(params: Models.Params): void {
|
||||||
|
this.report.params.from = params.from;
|
||||||
|
this.report.params.to = params.to;
|
||||||
|
this.report.params.reportDate = params.reportDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setCurrentIssues(issues: RedmineTypes.Issue[]): Promise<void> {
|
||||||
|
for (let i = 0; i < issues.length; i++) {
|
||||||
|
const issue = issues[i];
|
||||||
|
await this.appendCurrentIssue(issue);
|
||||||
|
}
|
||||||
|
this.logger.debug(`Issues for report loaded successed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setActivities(activities: Models.Change[]): Promise<void> {
|
||||||
|
for (let i = 0; i < activities.length; i++) {
|
||||||
|
const activity = activities[i];
|
||||||
|
await this.appendActivity(activity);
|
||||||
|
}
|
||||||
|
this.logger.debug(`Activities for report loaded successed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async appendCurrentIssue(
|
||||||
|
issue: RedmineTypes.Issue & Record<string, any>,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const currentUser: RedmineTypes.PublicUser = issue.current_user;
|
||||||
|
if (!currentUser) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const byUser = this.report.byUsers.find(
|
||||||
|
(item) => item.user.id == currentUser.id,
|
||||||
|
);
|
||||||
|
if (!byUser) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let byStatus: Models.IssuesAndStatus = byUser.issuesGroupedByStatus.find(
|
||||||
|
(item) => item.status.name == issue.status.name,
|
||||||
|
);
|
||||||
|
if (!byStatus) {
|
||||||
|
byStatus = {
|
||||||
|
status: { ...issue.status },
|
||||||
|
issues: [],
|
||||||
|
};
|
||||||
|
byUser.issuesGroupedByStatus.push(byStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existsIssue = byStatus.issues.find(
|
||||||
|
(item) => item.issue.id == issue.id,
|
||||||
|
);
|
||||||
|
if (existsIssue) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parents = await this.issuesService.getParents(issue.id);
|
||||||
|
// parents.pop();
|
||||||
|
const parentsHint = GetParentsHint(parents);
|
||||||
|
byStatus.issues.push({
|
||||||
|
issue: issue,
|
||||||
|
parents: parentsHint,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async appendActivity(activity: Models.Change): Promise<boolean> {
|
||||||
|
const recipient = activity.recipient;
|
||||||
|
if (!recipient) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Recipient is empty for activity ` +
|
||||||
|
`created_on ${activity.created_on} ` +
|
||||||
|
`for issue #${activity.issue_id} (${activity.issue_subject})`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const recipientId = recipient.id;
|
||||||
|
|
||||||
|
const byUser = this.report.byUsers.find(
|
||||||
|
(byUser) => byUser.user.id == recipientId,
|
||||||
|
);
|
||||||
|
if (!byUser) {
|
||||||
|
this.logger.log(`Not interested user ${recipient.name}`);
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
this.logger.debug(
|
||||||
|
`Found user section in report data ` +
|
||||||
|
`user=${JSON.stringify(byUser.user)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let issueAndChanges: Models.IssueAndChanges = byUser.activities.find(
|
||||||
|
(issueAndChanges) => {
|
||||||
|
return issueAndChanges.issue.id == activity.issue_id;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!issueAndChanges) {
|
||||||
|
const issue = await this.issuesService.getIssue(activity.issue_id);
|
||||||
|
if (!issue) {
|
||||||
|
this.logger.error(`Not found issue data for #${activity.issue_id}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const parents = await this.issuesService.getParents(activity.issue_id);
|
||||||
|
const parentsHint = GetParentsHint(parents);
|
||||||
|
issueAndChanges = {
|
||||||
|
issue: issue,
|
||||||
|
parents: parentsHint,
|
||||||
|
changes: [],
|
||||||
|
};
|
||||||
|
this.logger.debug(
|
||||||
|
`Created issue section in report data ` +
|
||||||
|
`user=${JSON.stringify(byUser.user)} ` +
|
||||||
|
`issue=${JSON.stringify({
|
||||||
|
id: issueAndChanges.issue.id,
|
||||||
|
subject: issueAndChanges.issue.subject,
|
||||||
|
})}`,
|
||||||
|
);
|
||||||
|
byUser.activities.push(issueAndChanges);
|
||||||
|
} else {
|
||||||
|
this.logger.debug(
|
||||||
|
`Found issue section in report data ` +
|
||||||
|
`user=${JSON.stringify(byUser.user)} ` +
|
||||||
|
`issue=${JSON.stringify({
|
||||||
|
id: issueAndChanges.issue.id,
|
||||||
|
subject: issueAndChanges.issue.subject,
|
||||||
|
})}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
issueAndChanges.changes.push(activity);
|
||||||
|
this.logger.debug(
|
||||||
|
`Activity pushed into report ` +
|
||||||
|
`user=${JSON.stringify(byUser.user)} ` +
|
||||||
|
`issue=${JSON.stringify({
|
||||||
|
id: issueAndChanges.issue.id,
|
||||||
|
subject: issueAndChanges.issue.subject,
|
||||||
|
})} ` +
|
||||||
|
`activity=${JSON.stringify({
|
||||||
|
created_on: activity.created_on,
|
||||||
|
change_message: activity.change_message,
|
||||||
|
})}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
sortUsers(): void {
|
||||||
|
this.report.byUsers.sort((a, b) => {
|
||||||
|
const nameA = `${a.user.firstname} ${a.user.lastname}`;
|
||||||
|
const nameB = `${b.user.firstname} ${b.user.lastname}`;
|
||||||
|
if (nameA > nameB) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (nameA < nameB) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sortActivities(): void {
|
||||||
|
for (let i = 0; i < this.report.byUsers.length; i++) {
|
||||||
|
const byUser = this.report.byUsers[i];
|
||||||
|
for (let j = 0; j < byUser.activities.length; j++) {
|
||||||
|
const activity = byUser.activities[j];
|
||||||
|
activity.changes.sort((a, b) => {
|
||||||
|
return a.created_on_timestamp - b.created_on_timestamp;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DailyEccmReportService {
|
||||||
|
private logger = new Logger(DailyEccmReportService.name);
|
||||||
|
private eccmConfig: EccmConfig.Config;
|
||||||
|
private report: DailyEccmReport.Report;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private configService: ConfigService,
|
||||||
|
private currentIssuesEccmReportService: CurrentIssuesEccmReportService,
|
||||||
|
private changesService: ChangesService,
|
||||||
|
private issuesService: IssuesService,
|
||||||
|
private usersService: UsersService,
|
||||||
|
) {
|
||||||
|
this.eccmConfig = this.configService.get<EccmConfig.Config>('redmineEccm');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getReport(): Promise<DailyEccmReport.Report> {
|
||||||
|
if (!this.report) {
|
||||||
|
await this.createEmptyReport();
|
||||||
|
}
|
||||||
|
return this.report;
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateReport(
|
||||||
|
params: DailyEccmReport.Models.Params,
|
||||||
|
): Promise<DailyEccmReport.Models.Report> {
|
||||||
|
await this.createEmptyReport();
|
||||||
|
const report = this.report;
|
||||||
|
report.setParams(params);
|
||||||
|
|
||||||
|
const currentIssues = await this.getCurrentIssues();
|
||||||
|
this.logger.log(`Current issues count - ${currentIssues.length}`);
|
||||||
|
await report.setCurrentIssues(currentIssues);
|
||||||
|
|
||||||
|
const activities = await this.getActivities(params);
|
||||||
|
this.logger.log(`Activities count - ${activities.length}`);
|
||||||
|
await report.setActivities(activities);
|
||||||
|
|
||||||
|
report.sortUsers();
|
||||||
|
report.sortActivities();
|
||||||
|
|
||||||
|
this.logger.log(`Daily eccm report created successed`);
|
||||||
|
|
||||||
|
return report.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createEmptyReport(): Promise<DailyEccmReport.Report> {
|
||||||
|
const usersList: string[] = this.eccmConfig.groups.reduce((acc, group) => {
|
||||||
|
group.people.forEach((fullname) => {
|
||||||
|
acc.push(fullname);
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
this.logger.debug(
|
||||||
|
`Create new daily report for users - ${JSON.stringify(usersList)}`,
|
||||||
|
);
|
||||||
|
const usersPromises = usersList.map((fullname) => {
|
||||||
|
return this.usersService.findUserByFullname(fullname);
|
||||||
|
});
|
||||||
|
const users = await Promise.all(usersPromises);
|
||||||
|
this.report = new DailyEccmReport.Report(users, this.issuesService);
|
||||||
|
return this.report;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCurrentIssues(): Promise<RedmineTypes.Issue[]> {
|
||||||
|
return await this.currentIssuesEccmReportService.getData({
|
||||||
|
project: this.eccmConfig.projectName,
|
||||||
|
statuses: this.eccmConfig.currentIssuesStatuses,
|
||||||
|
versions: this.eccmConfig.currentVersions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getActivities(
|
||||||
|
params: DailyEccmReport.Models.Params,
|
||||||
|
): Promise<DailyEccmReport.Models.Change[]> {
|
||||||
|
const fromTimestamp = TimestampConverter.toTimestamp(params.from);
|
||||||
|
const toTimestamp = TimestampConverter.toTimestamp(params.to);
|
||||||
|
const query: nano.MangoQuery = {
|
||||||
|
selector: {
|
||||||
|
$and: [
|
||||||
|
{
|
||||||
|
created_on_timestamp: {
|
||||||
|
$gte: fromTimestamp,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
created_on_timestamp: {
|
||||||
|
$lt: toTimestamp,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
limit: UNLIMITED,
|
||||||
|
};
|
||||||
|
this.logger.debug(
|
||||||
|
`Query for get activities data - ${JSON.stringify(query)}`,
|
||||||
|
);
|
||||||
|
const changes = await this.changesService.find(query);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Loaded changes for daily report - count = ${changes.length}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const uniqIssueIds: number[] = [];
|
||||||
|
changes.forEach((change) => {
|
||||||
|
const issueId = change.issue_id;
|
||||||
|
if (!uniqIssueIds.includes(issueId)) {
|
||||||
|
uniqIssueIds.push(issueId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`Uniq issues - ${JSON.stringify(uniqIssueIds)}`);
|
||||||
|
|
||||||
|
const issues = await this.issuesService.getIssues(uniqIssueIds);
|
||||||
|
this.logger.debug(`Loaded data for uniq issues - count = ${issues.length}`);
|
||||||
|
const issuesMap: Record<number, RedmineTypes.Issue> = {};
|
||||||
|
issues.forEach((issue) => {
|
||||||
|
if (!issue || !issue.fixed_version) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const version = issue.fixed_version.name;
|
||||||
|
if (this.eccmConfig.currentVersions.includes(version)) {
|
||||||
|
issuesMap[issue.id] = issue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const reportChanges: DailyEccmReport.Models.Change[] = changes.reduce(
|
||||||
|
(acc: DailyEccmReport.Models.Change[], change) => {
|
||||||
|
change.messages.forEach((message) => {
|
||||||
|
if (!message || !message.recipient || !message.change_message) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const c: DailyEccmReport.Models.Change = {
|
||||||
|
initiator: change.initiator,
|
||||||
|
dev: change.dev,
|
||||||
|
cr: change.cr,
|
||||||
|
qa: change.qa,
|
||||||
|
current_user: change.current_user,
|
||||||
|
author: change.author,
|
||||||
|
issue_id: change.issue_id,
|
||||||
|
issue_url: change.issue_url,
|
||||||
|
issue_tracker: change.issue_tracker,
|
||||||
|
issue_subject: change.issue_subject,
|
||||||
|
journal_note: change.journal_note,
|
||||||
|
old_status: change.old_status && {
|
||||||
|
id: change.old_status.id,
|
||||||
|
name: change.old_status.name,
|
||||||
|
},
|
||||||
|
new_status: change.new_status && {
|
||||||
|
id: change.new_status.id,
|
||||||
|
name: change.new_status.name,
|
||||||
|
},
|
||||||
|
created_on: change.created_on,
|
||||||
|
created_on_timestamp: change.created_on_timestamp,
|
||||||
|
recipient: {
|
||||||
|
firstname: message.recipient.firstname,
|
||||||
|
lastname: message.recipient.lastname,
|
||||||
|
id: message.recipient.id,
|
||||||
|
login: message.recipient.login,
|
||||||
|
name: message.recipient.name,
|
||||||
|
},
|
||||||
|
change_message: message.change_message,
|
||||||
|
};
|
||||||
|
acc.push(c);
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
[] as DailyEccmReport.Models.Change[],
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Prepared activities for report - count = ${reportChanges.length}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return reportChanges;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import TelegramBot from 'node-telegram-bot-api';
|
||||||
|
import { EccmConfig } from 'src/models/eccm-config.model';
|
||||||
|
import { CurrentIssuesEccmReport, CurrentIssuesEccmReportService } from 'src/reports/current-issues-eccm.report.service';
|
||||||
|
import { UserMetaInfoService } from 'src/user-meta-info/user-meta-info.service';
|
||||||
|
import { TelegramBotService } from '../telegram-bot.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CurrentIssuesBotHandlerService {
|
||||||
|
private forName = /\/current_issues_eccm (.+) (.+)/;
|
||||||
|
private forCurrentUser = /\/current_issues_eccm/;
|
||||||
|
private service: TelegramBotService;
|
||||||
|
private eccmConfig: EccmConfig.Config;
|
||||||
|
private logger = new Logger(CurrentIssuesBotHandlerService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private configService: ConfigService,
|
||||||
|
private currentIssuesEccmReportService: CurrentIssuesEccmReportService,
|
||||||
|
private userMetaInfoService: UserMetaInfoService,
|
||||||
|
) {
|
||||||
|
this.eccmConfig = this.configService.get<EccmConfig.Config>('redmineEccm');
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(service: TelegramBotService, bot: TelegramBot): Promise<void> {
|
||||||
|
if (!this.service) {
|
||||||
|
this.service = service;
|
||||||
|
}
|
||||||
|
bot.onText(/\/current_issues_eccm/, async (msg) => {
|
||||||
|
const userMetaInfo = await this.userMetaInfoService.findByTelegramId(
|
||||||
|
msg.chat.id,
|
||||||
|
);
|
||||||
|
const redmineUserId = userMetaInfo.user_id;
|
||||||
|
const report = await this.currentIssuesEccmReportService.getReport({
|
||||||
|
userIds: [redmineUserId],
|
||||||
|
project: this.eccmConfig.projectName,
|
||||||
|
versions: this.eccmConfig.currentVersions,
|
||||||
|
statuses: this.eccmConfig.currentIssuesStatuses,
|
||||||
|
fields: CurrentIssuesEccmReport.Defaults.currentIssuesFields,
|
||||||
|
});
|
||||||
|
this.logger.debug(`Current issues eccm report: ${report}`);
|
||||||
|
bot.sendMessage(msg.chat.id, report || 'empty report', {
|
||||||
|
parse_mode: 'HTML',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,13 +5,13 @@ import TelegramBot from 'node-telegram-bot-api';
|
||||||
import { UserMetaInfoService } from 'src/user-meta-info/user-meta-info.service';
|
import { UserMetaInfoService } from 'src/user-meta-info/user-meta-info.service';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { UserMetaInfoModel } from 'src/models/user-meta-info.model';
|
import { UserMetaInfoModel } from 'src/models/user-meta-info.model';
|
||||||
|
import { CurrentIssuesBotHandlerService } from './handlers/current-issues.bot-handler.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TelegramBotService {
|
export class TelegramBotService {
|
||||||
private logger = new Logger(TelegramBotService.name);
|
private logger = new Logger(TelegramBotService.name);
|
||||||
private bot: TelegramBot;
|
private bot: TelegramBot;
|
||||||
private telegramBotToken: string;
|
private telegramBotToken: string;
|
||||||
private redmineApiUrlPrefix: string;
|
|
||||||
private redminePublicUrlPrefix: string;
|
private redminePublicUrlPrefix: string;
|
||||||
|
|
||||||
private registerRe = /\/register (\d+) (.+)/;
|
private registerRe = /\/register (\d+) (.+)/;
|
||||||
|
|
@ -20,10 +20,9 @@ export class TelegramBotService {
|
||||||
private userMetaInfoService: UserMetaInfoService,
|
private userMetaInfoService: UserMetaInfoService,
|
||||||
private usersService: UsersService,
|
private usersService: UsersService,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
|
private currentIssuesBotHandlerService: CurrentIssuesBotHandlerService,
|
||||||
) {
|
) {
|
||||||
this.telegramBotToken = this.configService.get<string>('telegramBotToken');
|
this.telegramBotToken = this.configService.get<string>('telegramBotToken');
|
||||||
this.redmineApiUrlPrefix =
|
|
||||||
this.configService.get<string>('redmineUrlPrefix');
|
|
||||||
this.redminePublicUrlPrefix =
|
this.redminePublicUrlPrefix =
|
||||||
this.configService.get<string>('redmineUrlPublic');
|
this.configService.get<string>('redmineUrlPublic');
|
||||||
this.initTelegramBot();
|
this.initTelegramBot();
|
||||||
|
|
@ -44,6 +43,7 @@ export class TelegramBotService {
|
||||||
this.bot.onText(/\/leave/, async (msg) => {
|
this.bot.onText(/\/leave/, async (msg) => {
|
||||||
await this.leave(msg);
|
await this.leave(msg);
|
||||||
});
|
});
|
||||||
|
this.currentIssuesBotHandlerService.init(this, this.bot);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async showHelpMessage(msg: TelegramBot.Message): Promise<void> {
|
private async showHelpMessage(msg: TelegramBot.Message): Promise<void> {
|
||||||
|
|
|
||||||
50
views/daily-eccm-report.hbs
Normal file
50
views/daily-eccm-report.hbs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
<!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.reportDate}}</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
<h1>Отчёт по работникам</h1>
|
||||||
|
{{#each this.byUsers}}
|
||||||
|
|
||||||
|
<h2>{{this.user.firstname}} {{this.user.lastname}}</h2>
|
||||||
|
|
||||||
|
<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.subject}}
|
||||||
|
<ul>
|
||||||
|
{{#each this.changes}}
|
||||||
|
<li>{{this.created_on}}: {{this.change_message}}</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
{{/each}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in a new issue