Merge branch 'dev'
This commit is contained in:
commit
fff5077344
22 changed files with 680 additions and 20 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -36,3 +36,5 @@ lerna-debug.log*
|
|||
configs/main-config.jsonc
|
||||
configs/issue-event-emitter-config.jsonc
|
||||
tmp/*
|
||||
configs/redmine-statuses-config.jsonc
|
||||
configs/redmine-status-changes-config.jsonc
|
||||
|
|
|
|||
7
configs/main-config.jsonc.dist
Normal file
7
configs/main-config.jsonc.dist
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"couchDb": {
|
||||
"dbs": {
|
||||
"changes": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
17
configs/redmine-status-changes-config.jsonc.dist
Normal file
17
configs/redmine-status-changes-config.jsonc.dist
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[
|
||||
{
|
||||
"default": false,
|
||||
"from": "New",
|
||||
"to": "In Progress",
|
||||
"messages": [
|
||||
{
|
||||
"recipient": "<field_name>",
|
||||
// Handlebars - template engine
|
||||
"changes_message": "{{qa.name}} got issue #{{issue_id}} after development {{dev.name}}",
|
||||
"notification_message": "<a href=\"{{ issue_url }}\">{{ issue_tracker }} #{{ issue_id }}</a> {{ issue_subject }}:\n{{dev.name}} finished development. You can test issue.\n\n{{journal_note}}"
|
||||
}
|
||||
// ...
|
||||
]
|
||||
}
|
||||
// ...
|
||||
]
|
||||
16
configs/redmine-statuses-config.jsonc.dist
Normal file
16
configs/redmine-statuses-config.jsonc.dist
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "New"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "In Progress"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Closed",
|
||||
"is_closed": true
|
||||
}
|
||||
// ...
|
||||
]
|
||||
7
libs/event-emitter/LICENSE
Normal file
7
libs/event-emitter/LICENSE
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
Copyright 2022 Gnedov Pavel <pavel@gnedov.info>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
9
libs/event-emitter/src/utils/timestamp-converter.ts
Normal file
9
libs/event-emitter/src/utils/timestamp-converter.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/* eslint-disable @typescript-eslint/no-namespace */
|
||||
export namespace TimestampConverter {
|
||||
export function toTimestamp(date: string): number {
|
||||
return new Date(date).getTime();
|
||||
}
|
||||
export function toString(date: number): string {
|
||||
return new Date(date).toISOString();
|
||||
}
|
||||
}
|
||||
69
package-lock.json
generated
69
package-lock.json
generated
|
|
@ -17,6 +17,7 @@
|
|||
"@nestjs/serve-static": "^2.2.2",
|
||||
"@nestjs/websockets": "^8.4.4",
|
||||
"axios": "^0.27.2",
|
||||
"handlebars": "^4.7.7",
|
||||
"imap-simple": "^5.1.0",
|
||||
"nano": "^10.0.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
|
|
@ -4372,6 +4373,26 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/handlebars": {
|
||||
"version": "4.7.7",
|
||||
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz",
|
||||
"integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.5",
|
||||
"neo-async": "^2.6.0",
|
||||
"source-map": "^0.6.1",
|
||||
"wordwrap": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"handlebars": "bin/handlebars"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.7"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"uglify-js": "^3.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/has": {
|
||||
"version": "1.0.3",
|
||||
"license": "MIT",
|
||||
|
|
@ -5987,7 +6008,6 @@
|
|||
},
|
||||
"node_modules/neo-async": {
|
||||
"version": "2.6.2",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-emoji": {
|
||||
|
|
@ -6984,7 +7004,6 @@
|
|||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
|
|
@ -7624,6 +7643,18 @@
|
|||
"node": ">=4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uglify-js": {
|
||||
"version": "3.16.3",
|
||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.16.3.tgz",
|
||||
"integrity": "sha512-uVbFqx9vvLhQg0iBaau9Z75AxWJ8tqM9AV890dIZCLApF4rTcyHwmAvLeEdYRs+BzYWu8Iw81F79ah0EfTXbaw==",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"uglifyjs": "bin/uglifyjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "2.0.0",
|
||||
"dev": true,
|
||||
|
|
@ -7946,6 +7977,11 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wordwrap": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
|
||||
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"dev": true,
|
||||
|
|
@ -10922,6 +10958,18 @@
|
|||
"version": "4.2.10",
|
||||
"dev": true
|
||||
},
|
||||
"handlebars": {
|
||||
"version": "4.7.7",
|
||||
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz",
|
||||
"integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==",
|
||||
"requires": {
|
||||
"minimist": "^1.2.5",
|
||||
"neo-async": "^2.6.0",
|
||||
"source-map": "^0.6.1",
|
||||
"uglify-js": "^3.1.4",
|
||||
"wordwrap": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"has": {
|
||||
"version": "1.0.3",
|
||||
"requires": {
|
||||
|
|
@ -12013,8 +12061,7 @@
|
|||
"version": "0.6.3"
|
||||
},
|
||||
"neo-async": {
|
||||
"version": "2.6.2",
|
||||
"dev": true
|
||||
"version": "2.6.2"
|
||||
},
|
||||
"node-emoji": {
|
||||
"version": "1.11.0",
|
||||
|
|
@ -12610,8 +12657,7 @@
|
|||
}
|
||||
},
|
||||
"source-map": {
|
||||
"version": "0.6.1",
|
||||
"dev": true
|
||||
"version": "0.6.1"
|
||||
},
|
||||
"source-map-support": {
|
||||
"version": "0.5.21",
|
||||
|
|
@ -12989,6 +13035,12 @@
|
|||
"version": "4.6.3",
|
||||
"dev": true
|
||||
},
|
||||
"uglify-js": {
|
||||
"version": "3.16.3",
|
||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.16.3.tgz",
|
||||
"integrity": "sha512-uVbFqx9vvLhQg0iBaau9Z75AxWJ8tqM9AV890dIZCLApF4rTcyHwmAvLeEdYRs+BzYWu8Iw81F79ah0EfTXbaw==",
|
||||
"optional": true
|
||||
},
|
||||
"universalify": {
|
||||
"version": "2.0.0",
|
||||
"dev": true
|
||||
|
|
@ -13199,6 +13251,11 @@
|
|||
"version": "1.2.3",
|
||||
"dev": true
|
||||
},
|
||||
"wordwrap": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
|
||||
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="
|
||||
},
|
||||
"wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"dev": true,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
"@nestjs/serve-static": "^2.2.2",
|
||||
"@nestjs/websockets": "^8.4.4",
|
||||
"axios": "^0.27.2",
|
||||
"handlebars": "^4.7.7",
|
||||
"imap-simple": "^5.1.0",
|
||||
"nano": "^10.0.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
|
|
|
|||
|
|
@ -5,12 +5,19 @@ import { TimestampEnhancer } from '@app/event-emitter/issue-enhancers/timestamps
|
|||
import { MainController } from '@app/event-emitter/main/main.controller';
|
||||
import { Logger, Module, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { switchMap } from 'rxjs';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import configuration from './configs/app';
|
||||
import { RedminePublicUrlConverter } from './converters/redmine-public-url.converter';
|
||||
import { Changes } from './couchdb-datasources/changes';
|
||||
import { CurrentUserEnhancer } from './issue-enhancers/current-user-enhancer';
|
||||
import { CustomFieldsEnhancer } from './issue-enhancers/custom-fields-enhancer';
|
||||
import { PersonalNotificationsService } from './notifications/personal-notifications.service';
|
||||
import { StatusChangeNotificationsService } from './notifications/status-change-notifications.service';
|
||||
import { ChangesCacheWriterService } from './changes-cache-writer/changes-cache-writer.service';
|
||||
import { Issues } from '@app/event-emitter/couchdb-datasources/issues';
|
||||
import { Users } from '@app/event-emitter/couchdb-datasources/users';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -25,6 +32,10 @@ import { PersonalNotificationsService } from './notifications/personal-notificat
|
|||
CustomFieldsEnhancer,
|
||||
CurrentUserEnhancer,
|
||||
PersonalNotificationsService,
|
||||
StatusChangeNotificationsService,
|
||||
Changes,
|
||||
RedminePublicUrlConverter,
|
||||
ChangesCacheWriterService,
|
||||
],
|
||||
})
|
||||
export class AppModule implements OnModuleInit {
|
||||
|
|
@ -37,22 +48,80 @@ export class AppModule implements OnModuleInit {
|
|||
private timestampEnhancer: TimestampEnhancer,
|
||||
private customFieldsEnhancer: CustomFieldsEnhancer,
|
||||
private currentUserEnhancer: CurrentUserEnhancer,
|
||||
private statusChangeNotificationsService: StatusChangeNotificationsService,
|
||||
private changesCacheWriterService: ChangesCacheWriterService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
Issues.getDatasource();
|
||||
Users.getDatasource();
|
||||
Changes.getDatasource();
|
||||
|
||||
this.enhancerService.addEnhancer([
|
||||
this.timestampEnhancer,
|
||||
this.customFieldsEnhancer,
|
||||
this.currentUserEnhancer,
|
||||
]);
|
||||
|
||||
this.personalNotificationsService.$messages.subscribe((message) => {
|
||||
// eslint-disable-next-line prettier/prettier
|
||||
this.logger.log(`Get personal message ${JSON.stringify(message.message)} for recipients ${JSON.stringify(message.recipients)}`);
|
||||
});
|
||||
this.statusChangeNotificationsService.$changes.subscribe((change) => {
|
||||
this.logger.log(
|
||||
`Get status changes messages for ` +
|
||||
`issue_id = ${change.issue_id}, ` +
|
||||
`messages = ${JSON.stringify(
|
||||
change.messages.map((m) => m.change_message).filter((m) => !!m),
|
||||
)}`,
|
||||
);
|
||||
});
|
||||
|
||||
this.redmineIssuesCacheWriterService.subject.subscribe(
|
||||
async (saveResult) => {
|
||||
await this.personalNotificationsService.analize(saveResult);
|
||||
},
|
||||
);
|
||||
|
||||
this.redmineIssuesCacheWriterService.subject
|
||||
.pipe(
|
||||
switchMap(async (saveResult) => {
|
||||
this.logger.debug(
|
||||
`Save result process started, issue_id = ${saveResult.current.id}`,
|
||||
);
|
||||
return saveResult;
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
switchMap(async (saveResult) => {
|
||||
this.logger.debug(`personalNotificationsService.analize started`);
|
||||
await this.personalNotificationsService.analize(saveResult);
|
||||
this.logger.debug('personalNotificationsService.analize successed');
|
||||
return saveResult;
|
||||
}),
|
||||
switchMap(async (saveResult) => {
|
||||
// eslint-disable-next-line prettier/prettier
|
||||
this.logger.debug(`statusChangeNotificationsService.getChanges started`);
|
||||
const changes =
|
||||
await this.statusChangeNotificationsService.getChanges(saveResult);
|
||||
// eslint-disable-next-line prettier/prettier
|
||||
this.logger.debug(`statusChangeNotificationsService.getChanges successed`);
|
||||
return { changes, saveResult };
|
||||
}),
|
||||
switchMap(async (args) => {
|
||||
this.logger.debug(`Save changes in couchdb started`);
|
||||
const promises = args.changes.map((c) =>
|
||||
this.changesCacheWriterService.saveChange(c),
|
||||
);
|
||||
await Promise.all(promises);
|
||||
this.logger.debug('Save changes in couchdb successed');
|
||||
return args;
|
||||
}),
|
||||
)
|
||||
.subscribe(async (args) => {
|
||||
this.logger.debug(
|
||||
`Save result process success finished, issue_id = ${args.saveResult.current.id}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
54
src/changes-cache-writer/changes-cache-writer.service.ts
Normal file
54
src/changes-cache-writer/changes-cache-writer.service.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import nano from 'nano';
|
||||
import { Subject } from 'rxjs';
|
||||
import { Changes } from 'src/couchdb-datasources/changes';
|
||||
import { Change } from 'src/models/change.model';
|
||||
|
||||
@Injectable()
|
||||
export class ChangesCacheWriterService {
|
||||
private logger = new Logger(ChangesCacheWriterService.name);
|
||||
|
||||
subject = new Subject<Change>();
|
||||
|
||||
constructor(private changes: Changes) {}
|
||||
|
||||
async saveChange(change: Change): Promise<void> {
|
||||
this.logger.debug(
|
||||
`saveChange started, ` +
|
||||
`issue_id = ${change.issue_id}, ` +
|
||||
`initiator.name = ${change.initiator.name}`,
|
||||
);
|
||||
if (!change) {
|
||||
this.logger.debug(`saveChange successed, no data for saving`);
|
||||
return;
|
||||
}
|
||||
const changesDb = await this.changes.getDatasource();
|
||||
if (!changesDb) {
|
||||
this.logger.error(`saveChange failed, changesDb is undefined or null`);
|
||||
return;
|
||||
}
|
||||
const item: Change & nano.MaybeDocument = { ...change };
|
||||
item._id = this.getId();
|
||||
if (!item) {
|
||||
this.logger.debug(`saveChange successed, no data for saving`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await changesDb.insert(item);
|
||||
} catch (ex) {
|
||||
this.logger.error(`saveChange failed, error = ${ex}`);
|
||||
return;
|
||||
}
|
||||
this.subject.next(change);
|
||||
this.logger.debug(
|
||||
`saveChange successed, ` +
|
||||
`issue_id = ${change.issue_id}, ` +
|
||||
`initiator.name = ${change.initiator.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
private getId(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +1,36 @@
|
|||
import RedmineIssueEventEmitterConfigLoader from '@app/event-emitter/configs/main-config';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { parse } from 'jsonc-parser';
|
||||
import { AppConfig } from 'src/models/app-config.model';
|
||||
import RedmineStatusesConfigLoader from './statuses.config';
|
||||
import RedmineStatusChangesConfigLoader from './status-changes.config';
|
||||
|
||||
const redmineIssueEventEmitterConfig = RedmineIssueEventEmitterConfigLoader();
|
||||
const redmineStatusesConfig = RedmineStatusesConfigLoader();
|
||||
const redmineStatusChanges = RedmineStatusChangesConfigLoader();
|
||||
|
||||
const appConfig = {
|
||||
couchDbUrl:
|
||||
process.env['ELTEX_REDMINE_HELPER_COUCHDB_URL'] || 'http://localhost:5984',
|
||||
let appConfig: AppConfig;
|
||||
|
||||
dbs: {
|
||||
issues:
|
||||
process.env['ELTEX_REDMINE_HELPER_COUCHDB_ISSUES_DB_NAME'] ||
|
||||
'redmine_issues',
|
||||
users:
|
||||
process.env['ELTEX_REDMINE_HELPER_COUCHDB_USERS_DB_NAME'] ||
|
||||
'redmine_users',
|
||||
},
|
||||
export default (): AppConfig => {
|
||||
if (appConfig) {
|
||||
return appConfig;
|
||||
}
|
||||
|
||||
redmineIssueEventEmitterConfig: redmineIssueEventEmitterConfig,
|
||||
};
|
||||
const userDefinedConfigPath = process.env['ELTEX_REDMINE_HELPER_CONFIG_PATH'];
|
||||
const defaultConfigPath = join('configs', 'main-config.jsonc');
|
||||
const configPath = userDefinedConfigPath || defaultConfigPath;
|
||||
|
||||
const rawData = readFileSync(configPath, { encoding: 'utf-8' });
|
||||
|
||||
const data = parse(rawData);
|
||||
|
||||
appConfig = {
|
||||
...data,
|
||||
redmineStatuses: redmineStatusesConfig,
|
||||
redmineIssueEventEmitterConfig: redmineIssueEventEmitterConfig,
|
||||
redmineStatusChanges: redmineStatusChanges,
|
||||
};
|
||||
|
||||
export default () => {
|
||||
return appConfig;
|
||||
};
|
||||
|
|
|
|||
26
src/configs/status-changes.config.ts
Normal file
26
src/configs/status-changes.config.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { StatusChangesConfig } from 'src/models/status-changes-config.model';
|
||||
import { parse } from 'jsonc-parser';
|
||||
|
||||
let statusChanges: StatusChangesConfig.Config;
|
||||
|
||||
export default (): StatusChangesConfig.Config => {
|
||||
if (statusChanges) {
|
||||
return statusChanges;
|
||||
}
|
||||
|
||||
const userDefinedConfigPath =
|
||||
process.env['ELTEX_REDMINE_HELPER_STATUS_CHANGES_CONFIG_PATH'];
|
||||
const defaultConfigPath = join(
|
||||
'configs',
|
||||
'redmine-status-changes-config.jsonc',
|
||||
);
|
||||
const configPath = userDefinedConfigPath || defaultConfigPath;
|
||||
|
||||
const rawData = readFileSync(configPath, { encoding: 'utf-8' });
|
||||
|
||||
statusChanges = parse(rawData);
|
||||
|
||||
return statusChanges;
|
||||
};
|
||||
23
src/configs/statuses.config.ts
Normal file
23
src/configs/statuses.config.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { StatusesConfig } from 'src/models/statuses-config.model';
|
||||
import { parse } from 'jsonc-parser';
|
||||
import { join } from 'path';
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
let redmineStatues: StatusesConfig.Config;
|
||||
|
||||
export default (): StatusesConfig.Config => {
|
||||
if (redmineStatues) {
|
||||
return redmineStatues;
|
||||
}
|
||||
|
||||
const userDefinedConfigPath =
|
||||
process.env['ELTEX_REDMINE_HELPER_STATUSES_CONFIG_PATH'];
|
||||
const defaultConfigPath = join('configs', 'redmine-statuses-config.jsonc');
|
||||
const configPath = userDefinedConfigPath || defaultConfigPath;
|
||||
|
||||
const rawData = readFileSync(configPath, { encoding: 'utf-8' });
|
||||
|
||||
redmineStatues = parse(rawData);
|
||||
|
||||
return redmineStatues;
|
||||
};
|
||||
17
src/converters/redmine-public-url.converter.ts
Normal file
17
src/converters/redmine-public-url.converter.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class RedminePublicUrlConverter {
|
||||
private redminePublicUrlPrefix: string;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.redminePublicUrlPrefix = this.configService.get<string>(
|
||||
'redmineIssueEventEmitterConfig.redmineUrlPublic',
|
||||
);
|
||||
}
|
||||
|
||||
convert(issueId: number | string): string {
|
||||
return `${this.redminePublicUrlPrefix}/issues/${issueId}`;
|
||||
}
|
||||
}
|
||||
35
src/couchdb-datasources/changes.ts
Normal file
35
src/couchdb-datasources/changes.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { CouchDb } from '@app/event-emitter/couchdb-datasources/couchdb';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import nano from 'nano';
|
||||
import { Change } from 'src/models/change.model';
|
||||
import configuration from '../configs/app';
|
||||
|
||||
const config = configuration();
|
||||
|
||||
@Injectable()
|
||||
export class Changes {
|
||||
private static logger = new Logger(Changes.name);
|
||||
private static changesDb = null;
|
||||
private static initilized = false;
|
||||
|
||||
static async getDatasource(): Promise<nano.DocumentScope<Change>> {
|
||||
if (Changes.initilized) {
|
||||
return Changes.changesDb;
|
||||
}
|
||||
Changes.initilized = true;
|
||||
const n = CouchDb.getCouchDb();
|
||||
const changesDbName = config.couchDb.dbs.changes;
|
||||
const dbs = await n.db.list();
|
||||
if (!dbs.includes(changesDbName)) {
|
||||
await n.db.create(changesDbName);
|
||||
}
|
||||
Changes.changesDb = await n.db.use(changesDbName);
|
||||
Changes.initilized = true;
|
||||
Changes.logger.log(`Connected to changes db - ${changesDbName}`);
|
||||
return Changes.changesDb;
|
||||
}
|
||||
|
||||
async getDatasource(): Promise<nano.DocumentScope<Change>> {
|
||||
return await Changes.getDatasource();
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,8 @@ import { Injectable } from '@nestjs/common';
|
|||
export class CurrentUserEnhancer implements IssueEnhancerInterface {
|
||||
name = 'current-user';
|
||||
|
||||
// TODO: Переместить правила в конфиг
|
||||
|
||||
private rules = {
|
||||
New: 'dev',
|
||||
'In Progress': 'dev',
|
||||
|
|
|
|||
14
src/models/app-config.model.ts
Normal file
14
src/models/app-config.model.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { MainConfigModel } from '@app/event-emitter/models/main-config-model';
|
||||
import { StatusChangesConfig } from './status-changes-config.model';
|
||||
import { StatusesConfig } from './statuses-config.model';
|
||||
|
||||
export type AppConfig = {
|
||||
redmineIssueEventEmitterConfig: MainConfigModel;
|
||||
redmineStatuses: StatusesConfig.Config;
|
||||
redmineStatusChanges: StatusChangesConfig.Config;
|
||||
couchDb: {
|
||||
dbs: {
|
||||
changes: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
7
src/models/change-message.model.ts
Normal file
7
src/models/change-message.model.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
|
||||
|
||||
export type ChangeMessage = {
|
||||
change_message?: string | null;
|
||||
notification_message?: string | null;
|
||||
recipient?: RedmineTypes.PublicUser | null;
|
||||
};
|
||||
27
src/models/change.model.ts
Normal file
27
src/models/change.model.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
|
||||
import { ChangeMessage } from './change-message.model';
|
||||
|
||||
export class 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;
|
||||
messages: ChangeMessage[];
|
||||
}
|
||||
18
src/models/status-changes-config.model.ts
Normal file
18
src/models/status-changes-config.model.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/* eslint-disable @typescript-eslint/no-namespace */
|
||||
export namespace StatusChangesConfig {
|
||||
export type Message = {
|
||||
recipient: string;
|
||||
changes_message: string;
|
||||
notification_message: string;
|
||||
};
|
||||
|
||||
export type Item = {
|
||||
default: boolean;
|
||||
new_issue?: boolean;
|
||||
from: string;
|
||||
to: string;
|
||||
messages: Message[];
|
||||
};
|
||||
|
||||
export type Config = Item[];
|
||||
}
|
||||
10
src/models/statuses-config.model.ts
Normal file
10
src/models/statuses-config.model.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/* eslint-disable @typescript-eslint/no-namespace */
|
||||
export namespace StatusesConfig {
|
||||
export type Item = {
|
||||
id: number;
|
||||
name: string;
|
||||
is_closed?: boolean;
|
||||
};
|
||||
|
||||
export type Config = Item[];
|
||||
}
|
||||
229
src/notifications/status-change-notifications.service.ts
Normal file
229
src/notifications/status-change-notifications.service.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
|
||||
import { SaveResponse } from '@app/event-emitter/models/save-response';
|
||||
import { UsersService } from '@app/event-emitter/users/users.service';
|
||||
import { TimestampConverter } from '@app/event-emitter/utils/timestamp-converter';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { RedminePublicUrlConverter } from 'src/converters/redmine-public-url.converter';
|
||||
import { Change } from 'src/models/change.model';
|
||||
import { StatusChangesConfig } from 'src/models/status-changes-config.model';
|
||||
import { StatusesConfig } from 'src/models/statuses-config.model';
|
||||
import Handlebars from 'handlebars';
|
||||
import { ChangeMessage } from 'src/models/change-message.model';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class StatusChangeNotificationsService {
|
||||
private logger = new Logger(StatusChangeNotificationsService.name);
|
||||
private statuses: StatusesConfig.Config;
|
||||
private statusChanges: StatusChangesConfig.Config;
|
||||
|
||||
$changes = new Subject<Change>();
|
||||
|
||||
constructor(
|
||||
private usersService: UsersService,
|
||||
private config: ConfigService,
|
||||
private redminePublicUrlConverter: RedminePublicUrlConverter,
|
||||
) {
|
||||
this.statuses = this.config.get<StatusesConfig.Config>('redmineStatuses');
|
||||
this.statusChanges = this.config.get<StatusChangesConfig.Config>(
|
||||
'redmineStatusChanges',
|
||||
);
|
||||
this.logger.debug(
|
||||
`StatusChangeNotificationsService created, ` +
|
||||
`statuses = ${JSON.stringify(this.statuses.map((s) => s.name))}, ` +
|
||||
`statusChanges.length = ${this.statusChanges.length}, ` +
|
||||
`statusChanges = ` +
|
||||
// eslint-disable-next-line prettier/prettier
|
||||
`${JSON.stringify(this.statusChanges.map((c) => `${c.from} -> ${c.to}`))}`,
|
||||
);
|
||||
}
|
||||
|
||||
async getChanges(saveResponse: SaveResponse): Promise<Change[]> {
|
||||
this.logger.debug(
|
||||
`Analize change statuses for issue #${saveResponse.current.id} ` +
|
||||
`(${saveResponse.current.subject}) for journalsDiff.length = ${saveResponse.journalsDiff.length} start`,
|
||||
);
|
||||
|
||||
const changes: Change[] = [];
|
||||
|
||||
const issue = saveResponse.current;
|
||||
|
||||
if (!saveResponse.prev) {
|
||||
const newChange = await this.getMessagesForNewIssue(issue);
|
||||
changes.push(newChange);
|
||||
}
|
||||
|
||||
for (let i = 0; i < saveResponse.journalsDiff.length; i++) {
|
||||
const journal = saveResponse.journalsDiff[i];
|
||||
const change = await this.getMessagesForChangeStatus(issue, journal);
|
||||
if (change) {
|
||||
changes.push(change);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Analize change statuses for issue #${saveResponse.current.id} ` +
|
||||
`(${saveResponse.current.subject}) for journalsDiff.length = ${saveResponse.journalsDiff.length} finished`,
|
||||
);
|
||||
|
||||
changes.forEach((c) => this.$changes.next(c));
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
private getStatusChangeDetails(
|
||||
journal: RedmineTypes.Journal,
|
||||
): RedmineTypes.JournalDetail | null {
|
||||
if (!journal?.details || journal?.details.length <= 0) return null;
|
||||
const details: RedmineTypes.JournalDetail[] = journal?.details;
|
||||
return (
|
||||
details.find((d) => {
|
||||
return d.name === 'status_id';
|
||||
}) || null
|
||||
);
|
||||
}
|
||||
|
||||
private async getMessagesForChangeStatus(
|
||||
issue: RedmineTypes.Issue & Record<string, any>,
|
||||
journal: RedmineTypes.Journal,
|
||||
): Promise<Change | null> {
|
||||
const statusChangeDetails = this.getStatusChangeDetails(journal);
|
||||
if (!statusChangeDetails) return null;
|
||||
const change: Change = {
|
||||
initiator: await this.usersService.getUser(journal?.user?.id),
|
||||
dev: await this.usersService.getUser(issue?.dev?.id),
|
||||
qa: await this.usersService.getUser(issue?.qa?.id),
|
||||
cr: await this.usersService.getUser(issue?.cr?.id),
|
||||
current_user: await this.usersService.getUser(issue?.current_user?.id),
|
||||
author: await this.usersService.getUser(issue?.author?.id),
|
||||
old_status: this.findStatusById(statusChangeDetails.old_value),
|
||||
new_status: this.findStatusById(statusChangeDetails.new_value),
|
||||
issue_id: issue.id,
|
||||
issue_url: this.redminePublicUrlConverter.convert(issue.id),
|
||||
issue_tracker: issue.tracker?.name || '',
|
||||
issue_subject: issue.subject || '',
|
||||
created_on: journal.created_on,
|
||||
created_on_timestamp: TimestampConverter.toTimestamp(journal.created_on),
|
||||
journal_note: journal.notes || '',
|
||||
messages: [],
|
||||
};
|
||||
const messages = await this.generateMessages(statusChangeDetails, change);
|
||||
if (messages) {
|
||||
change.messages = messages;
|
||||
}
|
||||
return change;
|
||||
}
|
||||
|
||||
private async getMessagesForNewIssue(
|
||||
issue: RedmineTypes.Issue & Record<string, any>,
|
||||
): Promise<Change> {
|
||||
const changeParams = this.statusChanges.find((p) => p.new_issue);
|
||||
const change: Change = {
|
||||
initiator: await this.usersService.getUser(issue.author.id),
|
||||
dev: await this.usersService.getUser(issue?.dev?.id),
|
||||
qa: await this.usersService.getUser(issue?.qa?.id),
|
||||
cr: await this.usersService.getUser(issue?.cr?.id),
|
||||
current_user: await this.usersService.getUser(issue?.current_user?.id),
|
||||
author: await this.usersService.getUser(issue?.author?.id),
|
||||
old_status: this.findStatusById(issue.status.id),
|
||||
new_status: this.findStatusById(issue.status.id),
|
||||
issue_id: issue.id,
|
||||
issue_url: this.redminePublicUrlConverter.convert(issue.id),
|
||||
issue_tracker: issue.tracker?.name || '',
|
||||
issue_subject: issue.subject || '',
|
||||
created_on: issue.created_on,
|
||||
created_on_timestamp: TimestampConverter.toTimestamp(issue.created_on),
|
||||
journal_note: '',
|
||||
messages: [],
|
||||
};
|
||||
const filledMessages = await Promise.all(
|
||||
changeParams.messages.map(async (messageParams: any) => {
|
||||
return await this.generateMessage(messageParams, change);
|
||||
}),
|
||||
);
|
||||
change.messages = filledMessages.filter((m) => Boolean(m));
|
||||
return change;
|
||||
}
|
||||
|
||||
private findStatusById(id: number | string): StatusesConfig.Item | null {
|
||||
if (
|
||||
typeof id === 'string' &&
|
||||
!Number.isNaN(id) &&
|
||||
Number.isFinite(Number(id))
|
||||
) {
|
||||
id = Number(id);
|
||||
}
|
||||
return this.statuses.find((s) => s.id === id) || null;
|
||||
}
|
||||
|
||||
private async generateMessages(
|
||||
detail: RedmineTypes.JournalDetail,
|
||||
change: Change,
|
||||
): Promise<ChangeMessage[]> {
|
||||
const oldStatus = this.findStatusById(detail.old_value);
|
||||
const newStatus = this.findStatusById(detail.new_value);
|
||||
if (!oldStatus || !newStatus) return null;
|
||||
const changeParams = this.findChangeParams(oldStatus.name, newStatus.name);
|
||||
if (!changeParams || !changeParams.messages) return null;
|
||||
const filledMessages = await Promise.all(
|
||||
changeParams.messages.map(async (messageParams: any) => {
|
||||
return await this.generateMessage(messageParams, change);
|
||||
}),
|
||||
);
|
||||
return filledMessages.filter((m) => Boolean(m));
|
||||
}
|
||||
|
||||
private findChangeParams(
|
||||
oldStatus: string,
|
||||
newStatus: string,
|
||||
): StatusChangesConfig.Item | null {
|
||||
let foundParam: StatusChangesConfig.Item | null = null;
|
||||
foundParam = this.statusChanges.find(
|
||||
(p) => p.from == oldStatus && p.to == newStatus,
|
||||
);
|
||||
if (!foundParam) {
|
||||
foundParam = this.statusChanges.find((p) => !p.from && p.to == newStatus);
|
||||
}
|
||||
if (!foundParam) {
|
||||
foundParam = this.statusChanges.find((p) => p.from == newStatus && !p.to);
|
||||
}
|
||||
if (!foundParam) {
|
||||
foundParam = this.statusChanges.find((p) => p.default);
|
||||
}
|
||||
return foundParam;
|
||||
}
|
||||
|
||||
private async generateMessage(
|
||||
messageParams: StatusChangesConfig.Message,
|
||||
change: Change,
|
||||
): Promise<ChangeMessage | null> {
|
||||
if (!messageParams) return null;
|
||||
const recipientUser = await this.usersService.getUser(
|
||||
change[messageParams.recipient]?.id,
|
||||
);
|
||||
if (!recipientUser) return null;
|
||||
|
||||
let changeMessage = null;
|
||||
if (messageParams.changes_message) {
|
||||
const changeMessageTemplate = Handlebars.compile(
|
||||
messageParams.changes_message,
|
||||
);
|
||||
changeMessage = changeMessageTemplate(change);
|
||||
}
|
||||
|
||||
let notificationMessage = null;
|
||||
if (messageParams.notification_message) {
|
||||
const notificationMessageTemplate = Handlebars.compile(
|
||||
messageParams.notification_message,
|
||||
);
|
||||
notificationMessage = notificationMessageTemplate(change);
|
||||
}
|
||||
|
||||
return {
|
||||
recipient: recipientUser,
|
||||
change_message: changeMessage,
|
||||
notification_message: notificationMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue