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/*
|
||||
configs/redmine-statuses-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);
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
`Error load issues: ${e.message} for issues: ${issueNumbers}`,
|
||||
`Error load issues: ${e.message} ` +
|
||||
`for issues: ${JSON.stringify(issueNumbers)}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
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 { RedmineEventsGateway } from '../events/redmine-events.gateway';
|
||||
import { RedmineIssuesCacheWriterService } from '../issue-cache-writer/redmine-issues-cache-writer.service';
|
||||
import { RedmineDataLoader } from '../redmine-data-loader/redmine-data-loader';
|
||||
import { MemoryCache } from '../utils/memory-cache';
|
||||
import nano from 'nano';
|
||||
import { UNLIMITED } from '../consts/consts';
|
||||
import { GetParentsHint } from '../utils/get-parents-hint';
|
||||
|
||||
export const ISSUE_MEMORY_CACHE_LIFETIME = 30 * 1000;
|
||||
const ISSUE_MEMORY_CACHE_AUTOCLEAN_INTERVAL = 1000 * 60 * 5;
|
||||
|
|
@ -32,6 +34,49 @@ export class IssuesService {
|
|||
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(
|
||||
issueId: number,
|
||||
force = false,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,15 @@ export module RedmineTypes {
|
|||
details?: JournalDetail[];
|
||||
};
|
||||
|
||||
export type ChildIssue = {
|
||||
id: number;
|
||||
tracker: IdAndName;
|
||||
subject: string;
|
||||
children?: Children;
|
||||
};
|
||||
|
||||
export type Children = ChildIssue[];
|
||||
|
||||
export type Issue = {
|
||||
id: number;
|
||||
project: IdAndName;
|
||||
|
|
@ -48,6 +57,8 @@ export module RedmineTypes {
|
|||
closed_on?: string;
|
||||
relations?: Record<string, any>[];
|
||||
journals?: Journal[];
|
||||
children?: Children;
|
||||
parent?: { id: number };
|
||||
};
|
||||
|
||||
// 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> {
|
||||
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) {
|
||||
this.logger.error(
|
||||
`Failed to load issue from redmine, issueNumber = ${issueNumber}`,
|
||||
|
|
@ -34,7 +41,15 @@ export class RedmineDataLoader {
|
|||
this.logger.debug(
|
||||
`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)[]> {
|
||||
|
|
@ -43,8 +58,19 @@ export class RedmineDataLoader {
|
|||
}
|
||||
|
||||
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 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) {
|
||||
this.logger.error(
|
||||
`Failed to load user from redmine, userNumber = ${userNumber}`,
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export class UsersService {
|
|||
},
|
||||
limit: 1,
|
||||
});
|
||||
if (!res && !res.docs && !res.docs[0]) {
|
||||
if (!res || !res.docs || !res.docs[0]) {
|
||||
return null;
|
||||
}
|
||||
const userFromDb = res.docs[0];
|
||||
|
|
@ -72,6 +72,15 @@ export class UsersService {
|
|||
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(
|
||||
userId: number,
|
||||
): 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",
|
||||
"cache-manager": "^4.1.0",
|
||||
"handlebars": "^4.7.7",
|
||||
"hbs": "^4.2.0",
|
||||
"imap-simple": "^5.1.0",
|
||||
"nano": "^10.0.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": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
|
||||
|
|
@ -5343,6 +5349,19 @@
|
|||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz",
|
||||
|
|
@ -9899,6 +9918,14 @@
|
|||
"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": {
|
||||
"version": "1.0.8",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
|
||||
|
|
@ -14346,6 +14378,15 @@
|
|||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz",
|
||||
|
|
@ -17740,6 +17781,14 @@
|
|||
"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": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
"axios": "^0.27.2",
|
||||
"cache-manager": "^4.1.0",
|
||||
"handlebars": "^4.7.7",
|
||||
"hbs": "^4.2.0",
|
||||
"imap-simple": "^5.1.0",
|
||||
"nano": "^10.0.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 { MainController } from '@app/event-emitter/main/main.controller';
|
||||
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 { AppController } from './app.controller';
|
||||
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 { UserMetaInfo } from './couchdb-datasources/user-meta-info';
|
||||
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 { 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({
|
||||
imports: [
|
||||
|
|
@ -35,7 +40,12 @@ import { StatusChangeAdapterService } from './notifications/adapters/status-chan
|
|||
isGlobal: true,
|
||||
}),
|
||||
],
|
||||
controllers: [AppController, MainController],
|
||||
controllers: [
|
||||
AppController,
|
||||
MainController,
|
||||
CurrentIssuesEccmReportController,
|
||||
DailyEccmReportController,
|
||||
],
|
||||
providers: [
|
||||
AppService,
|
||||
CustomFieldsEnhancer,
|
||||
|
|
@ -49,8 +59,11 @@ import { StatusChangeAdapterService } from './notifications/adapters/status-chan
|
|||
UserMetaInfoService,
|
||||
UserMetaInfo,
|
||||
PersonalNotificationAdapterService,
|
||||
PublicUrlAdapterService,
|
||||
StatusChangeAdapterService,
|
||||
CurrentIssuesEccmReportService,
|
||||
CurrentIssuesBotHandlerService,
|
||||
DailyEccmReportService,
|
||||
ChangesService,
|
||||
],
|
||||
})
|
||||
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 RedmineStatusesConfigLoader from './statuses.config';
|
||||
import RedmineStatusChangesConfigLoader from './status-changes.config';
|
||||
import RedmineEccmConfig from './eccm.config';
|
||||
import RedmineCurrentUserRulesConfig from './current-user-rules.config';
|
||||
|
||||
const redmineIssueEventEmitterConfig = RedmineIssueEventEmitterConfigLoader();
|
||||
const redmineStatusesConfig = RedmineStatusesConfigLoader();
|
||||
const redmineStatusChanges = RedmineStatusChangesConfigLoader();
|
||||
const redmineEccm = RedmineEccmConfig();
|
||||
const redmineCurrentUserRules = RedmineCurrentUserRulesConfig();
|
||||
|
||||
let appConfig: AppConfig;
|
||||
|
||||
|
|
@ -30,6 +34,8 @@ export default (): AppConfig => {
|
|||
redmineStatuses: redmineStatusesConfig,
|
||||
redmineIssueEventEmitterConfig: redmineIssueEventEmitterConfig,
|
||||
redmineStatusChanges: redmineStatusChanges,
|
||||
redmineEccm: redmineEccm,
|
||||
redmineCurrentUserRules: redmineCurrentUserRules,
|
||||
};
|
||||
|
||||
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 { ConfigService } from '@nestjs/config';
|
||||
|
||||
|
|
@ -14,4 +15,13 @@ export class RedminePublicUrlConverter {
|
|||
convert(issueId: number | string): string {
|
||||
return `${this.redminePublicUrlPrefix}/issues/${issueId}`;
|
||||
}
|
||||
|
||||
getUrl(issueId: number | string): string {
|
||||
return this.convert(issueId);
|
||||
}
|
||||
|
||||
getHtmlHref(issue: RedmineTypes.Issue): string {
|
||||
const url = this.getUrl(issue.id);
|
||||
return `<a href="${url}">${issue.tracker.name} #${issue.id}</a>`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,23 @@
|
|||
import { IssueEnhancerInterface } from '@app/event-emitter/issue-enhancers/issue-enhancer-interface';
|
||||
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()
|
||||
export class CurrentUserEnhancer implements IssueEnhancerInterface {
|
||||
name = 'current-user';
|
||||
|
||||
// TODO: Переместить правила в конфиг
|
||||
private rules: Record<string, string>;
|
||||
private logger = new Logger(CurrentUserEnhancer.name);
|
||||
|
||||
private rules = {
|
||||
New: 'dev',
|
||||
'In Progress': 'dev',
|
||||
'Re-opened': 'dev',
|
||||
'Code Review': 'cr',
|
||||
Resolved: 'qa',
|
||||
Testing: 'qa',
|
||||
'Wait Release': 'dev',
|
||||
Pending: 'dev',
|
||||
Feedback: 'qa',
|
||||
Closed: 'dev',
|
||||
Rejected: 'dev',
|
||||
};
|
||||
constructor(private configService: ConfigService) {
|
||||
this.rules = this.configService.get<Record<string, string>>(
|
||||
'redmineCurrentUserRules',
|
||||
);
|
||||
this.logger.debug(
|
||||
`Loaded rules for current user enhancer - ${JSON.stringify(this.rules)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async enhance(
|
||||
issue: RedmineTypes.Issue,
|
||||
|
|
@ -29,7 +26,7 @@ export class CurrentUserEnhancer implements IssueEnhancerInterface {
|
|||
|
||||
const status = issue.status.name;
|
||||
|
||||
const fieldName = this.rules[status];
|
||||
const fieldName = this.rules[status] || this.rules['*'] || null;
|
||||
if (fieldName) {
|
||||
res.current_user = { ...res[fieldName] };
|
||||
}
|
||||
|
|
|
|||
22
src/main.ts
22
src/main.ts
|
|
@ -1,12 +1,30 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { join } from 'path';
|
||||
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() {
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
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);
|
||||
}
|
||||
bootstrap();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { MainConfigModel } from '@app/event-emitter/models/main-config-model';
|
||||
import { EccmConfig } from './eccm-config.model';
|
||||
import { StatusChangesConfig } from './status-changes-config.model';
|
||||
import { StatusesConfig } from './statuses-config.model';
|
||||
|
||||
|
|
@ -6,6 +7,8 @@ export type AppConfig = {
|
|||
redmineIssueEventEmitterConfig: MainConfigModel;
|
||||
redmineStatuses: StatusesConfig.Config;
|
||||
redmineStatusChanges: StatusChangesConfig.Config;
|
||||
redmineEccm: EccmConfig.Config;
|
||||
redmineCurrentUserRules: Record<string, string>;
|
||||
couchDb: {
|
||||
dbs: {
|
||||
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 { TelegramBotService } from 'src/telegram-bot/telegram-bot.service';
|
||||
import Handlebars from 'handlebars';
|
||||
import { PublicUrlAdapterService } from '../public-url.adapter.service';
|
||||
import { RedminePublicUrlConverter } from 'src/converters/redmine-public-url.converter';
|
||||
|
||||
@Injectable()
|
||||
export class PersonalNotificationAdapterService {
|
||||
|
|
@ -14,7 +14,7 @@ export class PersonalNotificationAdapterService {
|
|||
private telegramBotService: TelegramBotService,
|
||||
private configService: ConfigService,
|
||||
private usersService: UsersService,
|
||||
private publicUrlAdapterService: PublicUrlAdapterService,
|
||||
private redminePublicUrlConverter: RedminePublicUrlConverter,
|
||||
) {
|
||||
const template = this.configService.get<string>('personalMessageTemplate');
|
||||
this.personalMessageTemplate = Handlebars.compile(template);
|
||||
|
|
@ -26,7 +26,7 @@ export class PersonalNotificationAdapterService {
|
|||
const promises = issueAndMessages.personalParsedMessage.recipients.map(
|
||||
async (recipient) => {
|
||||
const redmineId = recipient;
|
||||
const issueUrlHtml = this.publicUrlAdapterService.getHtmlHref(
|
||||
const issueUrlHtml = this.redminePublicUrlConverter.getHtmlHref(
|
||||
issueAndMessages.issue,
|
||||
);
|
||||
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 axios from 'axios';
|
||||
import { UserMetaInfoModel } from 'src/models/user-meta-info.model';
|
||||
import { CurrentIssuesBotHandlerService } from './handlers/current-issues.bot-handler.service';
|
||||
|
||||
@Injectable()
|
||||
export class TelegramBotService {
|
||||
private logger = new Logger(TelegramBotService.name);
|
||||
private bot: TelegramBot;
|
||||
private telegramBotToken: string;
|
||||
private redmineApiUrlPrefix: string;
|
||||
private redminePublicUrlPrefix: string;
|
||||
|
||||
private registerRe = /\/register (\d+) (.+)/;
|
||||
|
|
@ -20,10 +20,9 @@ export class TelegramBotService {
|
|||
private userMetaInfoService: UserMetaInfoService,
|
||||
private usersService: UsersService,
|
||||
private configService: ConfigService,
|
||||
private currentIssuesBotHandlerService: CurrentIssuesBotHandlerService,
|
||||
) {
|
||||
this.telegramBotToken = this.configService.get<string>('telegramBotToken');
|
||||
this.redmineApiUrlPrefix =
|
||||
this.configService.get<string>('redmineUrlPrefix');
|
||||
this.redminePublicUrlPrefix =
|
||||
this.configService.get<string>('redmineUrlPublic');
|
||||
this.initTelegramBot();
|
||||
|
|
@ -44,6 +43,7 @@ export class TelegramBotService {
|
|||
this.bot.onText(/\/leave/, async (msg) => {
|
||||
await this.leave(msg);
|
||||
});
|
||||
this.currentIssuesBotHandlerService.init(this, this.bot);
|
||||
}
|
||||
|
||||
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