Merge branch 'dev'

This commit is contained in:
Pavel Gnedov 2022-08-09 10:11:40 +07:00
commit fff5077344
22 changed files with 680 additions and 20 deletions

2
.gitignore vendored
View file

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

View file

@ -0,0 +1,7 @@
{
"couchDb": {
"dbs": {
"changes": ""
}
}
}

View 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}}"
}
// ...
]
}
// ...
]

View file

@ -0,0 +1,16 @@
[
{
"id": 1,
"name": "New"
},
{
"id": 2,
"name": "In Progress"
},
{
"id": 3,
"name": "Closed",
"is_closed": true
}
// ...
]

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

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

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

View file

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

View file

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

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

View file

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

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

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

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

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

View file

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

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

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

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

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

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

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