Генерация отчёта для дейли
This commit is contained in:
parent
ebd630b25a
commit
7d94350ae0
19 changed files with 909 additions and 37 deletions
|
|
@ -1,5 +1,11 @@
|
||||||
{
|
{
|
||||||
"currentVersions": [],
|
"currentVersions": [],
|
||||||
"projectName": "",
|
"projectName": "",
|
||||||
"currentIssuesStatuses": []
|
"currentIssuesStatuses": [],
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"name": "",
|
||||||
|
"people": [""]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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}`,
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -25,6 +25,10 @@ import { PersonalNotificationAdapterService } from './notifications/adapters/per
|
||||||
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 { CurrentIssuesEccmReportService } from './reports/current-issues-eccm.report.service';
|
||||||
import { CurrentIssuesBotHandlerService } from './telegram-bot/handlers/current-issues.bot-handler.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: [
|
||||||
|
|
@ -36,7 +40,12 @@ import { CurrentIssuesBotHandlerService } from './telegram-bot/handlers/current-
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AppController, MainController],
|
controllers: [
|
||||||
|
AppController,
|
||||||
|
MainController,
|
||||||
|
CurrentIssuesEccmReportController,
|
||||||
|
DailyEccmReportController,
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AppService,
|
AppService,
|
||||||
CustomFieldsEnhancer,
|
CustomFieldsEnhancer,
|
||||||
|
|
@ -53,6 +62,8 @@ import { CurrentIssuesBotHandlerService } from './telegram-bot/handlers/current-
|
||||||
StatusChangeAdapterService,
|
StatusChangeAdapterService,
|
||||||
CurrentIssuesEccmReportService,
|
CurrentIssuesEccmReportService,
|
||||||
CurrentIssuesBotHandlerService,
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
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,8 +1,14 @@
|
||||||
/* eslint-disable @typescript-eslint/no-namespace */
|
/* eslint-disable @typescript-eslint/no-namespace */
|
||||||
export namespace EccmConfig {
|
export namespace EccmConfig {
|
||||||
|
export type UserGroup = {
|
||||||
|
name: string;
|
||||||
|
people: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type Config = {
|
export type Config = {
|
||||||
currentVersions: string[];
|
currentVersions: string[];
|
||||||
projectName: string;
|
projectName: string;
|
||||||
currentIssuesStatuses: string[];
|
currentIssuesStatuses: string[];
|
||||||
|
groups: UserGroup[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,8 @@ import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
|
||||||
import nano from 'nano';
|
import nano from 'nano';
|
||||||
import Handlebars from 'handlebars';
|
import Handlebars from 'handlebars';
|
||||||
import { RedminePublicUrlConverter } from 'src/converters/redmine-public-url.converter';
|
import { 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
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||||
export namespace CurrentIssuesEccmReport {
|
export namespace CurrentIssuesEccmReport {
|
||||||
|
|
@ -15,6 +17,8 @@ export namespace CurrentIssuesEccmReport {
|
||||||
versions?: string[];
|
versions?: string[];
|
||||||
userIds?: number[];
|
userIds?: number[];
|
||||||
project?: string;
|
project?: string;
|
||||||
|
template?: string;
|
||||||
|
fields?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||||
|
|
@ -29,6 +33,22 @@ export namespace CurrentIssuesEccmReport {
|
||||||
];
|
];
|
||||||
|
|
||||||
export const projectName = 'ECCM';
|
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 = {
|
export type User = {
|
||||||
|
|
@ -55,7 +75,6 @@ export namespace CurrentIssuesEccmReport {
|
||||||
export class UsersReport {
|
export class UsersReport {
|
||||||
private report: UserReport[] = [];
|
private report: UserReport[] = [];
|
||||||
|
|
||||||
private reportFormatter: HandlebarsTemplateDelegate<UserReport[]>;
|
|
||||||
private reportTemplate = [
|
private reportTemplate = [
|
||||||
// context - this.report: UserReport[]
|
// context - this.report: UserReport[]
|
||||||
'{{#each this}}',
|
'{{#each this}}',
|
||||||
|
|
@ -78,7 +97,6 @@ export namespace CurrentIssuesEccmReport {
|
||||||
private redminePublicUrlConverter: RedminePublicUrlConverter,
|
private redminePublicUrlConverter: RedminePublicUrlConverter,
|
||||||
private redminePublicUrl: string,
|
private redminePublicUrl: string,
|
||||||
) {
|
) {
|
||||||
this.reportFormatter = Handlebars.compile(this.reportTemplate);
|
|
||||||
Handlebars.registerPartial(
|
Handlebars.registerPartial(
|
||||||
'redmineIssueAHref',
|
'redmineIssueAHref',
|
||||||
`<a href="${this.redminePublicUrl}/issues/{{issue.id}}">{{issue.tracker.name}} #{{issue.id}}</a>`,
|
`<a href="${this.redminePublicUrl}/issues/{{issue.id}}">{{issue.tracker.name}} #{{issue.id}}</a>`,
|
||||||
|
|
@ -128,23 +146,25 @@ export namespace CurrentIssuesEccmReport {
|
||||||
return issuesAndStatus;
|
return issuesAndStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
getAllUsersReportByTemplate(): string {
|
getAllUsersReportByTemplate(template?: string): string {
|
||||||
return this.reportFormatter(this.report);
|
const reportFormatter = Handlebars.compile(template || this.reportTemplate);
|
||||||
|
return reportFormatter(this.report);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CurrentIssuesEccmReportService {
|
export class CurrentIssuesEccmReportService {
|
||||||
private eccmVersions: string[];
|
private eccmConfig: EccmConfig.Config;
|
||||||
private logger = new Logger(CurrentIssuesEccmReportService.name);
|
private logger = new Logger(CurrentIssuesEccmReportService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private issuesDatasource: Issues,
|
private issuesDatasource: Issues,
|
||||||
private redminePublicUrlConverter: RedminePublicUrlConverter,
|
private redminePublicUrlConverter: RedminePublicUrlConverter,
|
||||||
|
private issuesService: IssuesService,
|
||||||
) {
|
) {
|
||||||
this.eccmVersions = this.configService.get<string[]>('redmineEccmVersions');
|
this.eccmConfig = this.configService.get<EccmConfig.Config>('redmineEccm');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getData(
|
async getData(
|
||||||
|
|
@ -158,29 +178,17 @@ export class CurrentIssuesEccmReportService {
|
||||||
$in: CurrentIssuesEccmReport.Defaults.statuses,
|
$in: CurrentIssuesEccmReport.Defaults.statuses,
|
||||||
},
|
},
|
||||||
'fixed_version.name': {
|
'fixed_version.name': {
|
||||||
$in: this.eccmVersions,
|
$in: this.eccmConfig.currentVersions,
|
||||||
},
|
},
|
||||||
'project.name':
|
'project.name':
|
||||||
options?.project || CurrentIssuesEccmReport.Defaults.projectName,
|
options?.project || CurrentIssuesEccmReport.Defaults.projectName,
|
||||||
},
|
},
|
||||||
fields: [
|
|
||||||
'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',
|
|
||||||
],
|
|
||||||
limit: UNLIMITED,
|
limit: UNLIMITED,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (options && options.fields) {
|
||||||
|
query.selector.fields = options.fields;
|
||||||
|
}
|
||||||
if (options && options.statuses) {
|
if (options && options.statuses) {
|
||||||
query.selector['status.name'] = {
|
query.selector['status.name'] = {
|
||||||
$in: options.statuses,
|
$in: options.statuses,
|
||||||
|
|
@ -203,9 +211,9 @@ export class CurrentIssuesEccmReportService {
|
||||||
|
|
||||||
this.logger.debug(`Query for get report data: ${JSON.stringify(query)}`);
|
this.logger.debug(`Query for get report data: ${JSON.stringify(query)}`);
|
||||||
|
|
||||||
const rawData = await datasource.find(query);
|
const data = await this.issuesService.find(query);
|
||||||
this.logger.debug(`Raw data for report: ${JSON.stringify(rawData)}`);
|
this.logger.debug(`Found issues for report: ${JSON.stringify(data.length)}`);
|
||||||
const data = rawData.docs as RedmineTypes.Issue[];
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -218,6 +226,6 @@ export class CurrentIssuesEccmReportService {
|
||||||
this.configService.get<string>('redmineUrlPublic'),
|
this.configService.get<string>('redmineUrlPublic'),
|
||||||
);
|
);
|
||||||
data.forEach((item) => report.push(item));
|
data.forEach((item) => report.push(item));
|
||||||
return report.getAllUsersReportByTemplate();
|
return report.getAllUsersReportByTemplate(options?.template);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
src/reports/daily-eccm.report.controller.ts
Normal file
31
src/reports/daily-eccm.report.controller.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { Controller, Get, Logger, Render } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
DailyEccmReport,
|
||||||
|
DailyEccmReportService,
|
||||||
|
} from './daily-eccm.report.service';
|
||||||
|
|
||||||
|
@Controller('daily-eccm')
|
||||||
|
export class DailyEccmReportController {
|
||||||
|
constructor(private dailyEccmReportService: DailyEccmReportService) {}
|
||||||
|
|
||||||
|
// TODO: Заменить хардкоды на параметры
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Render('daily-eccm-report')
|
||||||
|
async getReport(): Promise<DailyEccmReport.Models.Report> {
|
||||||
|
return await this.dailyEccmReportService.generateReport({
|
||||||
|
from: '2022-09-01T00:00:00+07:00',
|
||||||
|
to: '2022-11-04T00:00:00+07:00',
|
||||||
|
reportDate: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/raw')
|
||||||
|
async getReportRawData(): Promise<DailyEccmReport.Models.Report> {
|
||||||
|
return await this.dailyEccmReportService.generateReport({
|
||||||
|
from: '2022-09-01T00:00:00+07:00',
|
||||||
|
to: '2022-11-04T00:00:00+07:00',
|
||||||
|
reportDate: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
511
src/reports/daily-eccm.report.service.ts
Normal file
511
src/reports/daily-eccm.report.service.ts
Normal file
|
|
@ -0,0 +1,511 @@
|
||||||
|
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;
|
||||||
|
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) => {
|
||||||
|
const u: Models.User = {
|
||||||
|
id: user.id,
|
||||||
|
lastname: user.lastname,
|
||||||
|
firstname: user.firstname,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
user: u,
|
||||||
|
activities: [],
|
||||||
|
issuesGroupedByStatus: [],
|
||||||
|
} as Models.UserReport;
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
issueAndChanges = {
|
||||||
|
issue: issue,
|
||||||
|
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) => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import TelegramBot from 'node-telegram-bot-api';
|
import TelegramBot from 'node-telegram-bot-api';
|
||||||
import { EccmConfig } from 'src/models/eccm-config.model';
|
import { EccmConfig } from 'src/models/eccm-config.model';
|
||||||
import { CurrentIssuesEccmReportService } from 'src/reports/current-issues-eccm.report.service';
|
import { CurrentIssuesEccmReport, CurrentIssuesEccmReportService } from 'src/reports/current-issues-eccm.report.service';
|
||||||
import { UserMetaInfoService } from 'src/user-meta-info/user-meta-info.service';
|
import { UserMetaInfoService } from 'src/user-meta-info/user-meta-info.service';
|
||||||
import { TelegramBotService } from '../telegram-bot.service';
|
import { TelegramBotService } from '../telegram-bot.service';
|
||||||
|
|
||||||
|
|
@ -36,6 +36,7 @@ export class CurrentIssuesBotHandlerService {
|
||||||
project: this.eccmConfig.projectName,
|
project: this.eccmConfig.projectName,
|
||||||
versions: this.eccmConfig.currentVersions,
|
versions: this.eccmConfig.currentVersions,
|
||||||
statuses: this.eccmConfig.currentIssuesStatuses,
|
statuses: this.eccmConfig.currentIssuesStatuses,
|
||||||
|
fields: CurrentIssuesEccmReport.Defaults.currentIssuesFields,
|
||||||
});
|
});
|
||||||
this.logger.debug(`Current issues eccm report: ${report}`);
|
this.logger.debug(`Current issues eccm report: ${report}`);
|
||||||
bot.sendMessage(msg.chat.id, report || 'empty report', {
|
bot.sendMessage(msg.chat.id, report || 'empty report', {
|
||||||
|
|
|
||||||
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>
|
||||||
|
{{>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