Compare commits

..

No commits in common. "f3a8cded97bfaa083c68837a73a1063c1a3305c2" and "159e2ea17aa9b10d7461c2172f2d4f7dfabb4131" have entirely different histories.

6 changed files with 20 additions and 508 deletions

View file

@ -9,25 +9,20 @@ export class CouchDb {
private static logger = new Logger(CouchDb.name);
private static couchdb: nano.ServerScope | null = null;
private static initialized = false;
private static url: string | null = null;
static getCouchDb(): nano.ServerScope | null {
if (CouchDb.initialized) {
return CouchDb.couchdb;
}
CouchDb.initialized = true;
this.url = config.couchDb?.url;
if (!this.url) {
const url = config.couchDb?.url;
if (!url) {
return null;
} else {
const n = nano(this.url);
CouchDb.logger.log(`CouchDb connected by url ${this.url} ...`);
const n = nano(url);
CouchDb.logger.log(`CouchDb connected by url ${url} ...`);
CouchDb.couchdb = n;
return n;
}
}
static getCouchDbUrl(): string | null {
return this.url;
}
}

View file

@ -51,8 +51,6 @@ export module RedmineTypes {
done_ratio: number;
spent_hours: number;
total_spent_hours: number;
estimated_hours: number;
total_estimated_hours: number;
custom_fields: CustomField[];
created_on: string;
updated_on?: string;
@ -91,8 +89,6 @@ export module RedmineTypes {
done_ratio: num,
spent_hours: num,
total_spent_hours: num,
estimated_hours: num,
total_estimated_hours: num,
custom_fields: [],
created_on: date,
};

View file

@ -1,12 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { CouchDb } from '@app/event-emitter/couchdb-datasources/couchdb';
import nano from 'nano';
import axios from 'axios';
const DB_NAME = 'eccm_daily_reports_v2';
const DATETIME_INDEX_ASC = 'datetime-json-index';
type Report = any; // TODO fix this type
import { Report } from 'src/reports/daily-eccm-v2-report-task-runner.service';
@Injectable()
export class DailyEccmReportsV2Datasource {
@ -20,61 +15,15 @@ export class DailyEccmReportsV2Datasource {
}
DailyEccmReportsV2Datasource.initilized = true;
const n = CouchDb.getCouchDb();
const dbName = DB_NAME;
const dbName = 'eccm_daily_reports_v2';
const dbs = await n.db.list();
if (!dbs.includes(dbName)) {
await n.db.create(dbName);
}
DailyEccmReportsV2Datasource.db = await n.db.use(dbName);
await this.checkAndCreateIndex();
DailyEccmReportsV2Datasource.logger.log(
`Connected to daily reports db - ${dbName}`,
);
return DailyEccmReportsV2Datasource.db;
}
static async checkAndCreateIndex(): Promise<void> {
const couchDbUrl = CouchDb.getCouchDbUrl();
const indexes = await DailyEccmReportsV2Datasource.getIndexes(couchDbUrl);
const index = indexes.find(
(index: any) => index.name === DATETIME_INDEX_ASC,
);
if (!index) {
await DailyEccmReportsV2Datasource.createIndex(couchDbUrl);
}
}
private static async getIndexes(couchDbUrl: string): Promise<any[]> {
const response = await axios.get(
`${couchDbUrl}/${DB_NAME}/_index?skip=0&limit=999999`,
{
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
},
);
this.logger.debug(`Indexes: ${JSON.stringify(response.data.indexes)}`);
return response.data.indexes;
}
private static async createIndex(couchDbUrl: string): Promise<boolean> {
this.logger.debug(`Creating index ${DATETIME_INDEX_ASC}`);
const body = {
index: { fields: [{ datetime: 'asc' }] },
name: DATETIME_INDEX_ASC,
type: 'json',
};
const response = await axios.post(`${couchDbUrl}/${DB_NAME}/_index`, body, {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
});
this.logger.debug(
`Index ${DATETIME_INDEX_ASC} created ` +
`with response: ${JSON.stringify(response.data)}`,
);
return true;
}
}

View file

@ -1,77 +0,0 @@
import { Logger } from '@nestjs/common';
import { IssuesService } from '@app/event-emitter/issues/issues.service';
export type Params = {
query: any; // TODO: add type
schedule: string;
};
export type Report = {
id: string;
name: string;
datetime: number;
datetimeFormatted: string;
jobInfo: JobInfo;
params: Params; // query?, schedule?, etc TODO
data: any;
};
export type JobInfo = {
jobId: string;
dashboardId: string;
widgetId: string;
};
export class DailyEccmV2ReportTaskHandlerService {
private logger = new Logger(DailyEccmV2ReportTaskHandlerService.name);
private issuesService: IssuesService | null = null;
constructor(public params: Params, public jobInfo: JobInfo) {
return;
}
setIssuesService(issuesService: IssuesService): void {
this.issuesService = issuesService;
}
getIssuesService(): IssuesService | null {
if (!this.issuesService) {
this.logger.warn(
'DailyEccmV2ReportTaskHandlerService is not initialized, issuesService is null',
);
return null;
}
return this.issuesService;
}
async createReport(): Promise<void> {
try {
const report = await this.prepareReportData();
await this.saveNewReport(report);
} catch (error) {
this.logger.error(error);
}
}
async prepareReportData(): Promise<Report> {
const issuesService = this.getIssuesService();
if (!issuesService) {
throw new Error(
'Cannot create report without issuesService, DailyEccmV2ReportTaskHandlerService is not initialized',
);
}
const issues = await this.issuesService.mergedTreesAndFind(
this.params.query,
);
return {} as Report;
}
async saveNewReport(report: Report): Promise<boolean> {
return true;
}
async updatePreviousReport(report: Report): Promise<boolean> {
return true;
}
}

View file

@ -8,19 +8,26 @@ import { DailyEccmReportsV2Datasource } from 'src/couchdb-datasources/daily-eccm
import { randomUUID } from 'crypto';
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
import { DateTime } from 'luxon';
import { Params } from './daily-eccm-v2-report-task-handler';
export type Params = {
query: any; // TODO: add type
schedule: string;
};
export type Report = {
id: string;
name: string;
datetime: number;
datetimeFormatted: string;
params: Params; // query?, schedule?, etc TODO
data: any;
};
export type Job = {
id: string;
params: Params;
};
export type JobHandlerParams = {
job: Job;
dashboard: Dashboard;
widget: Widget;
};
export const WIDGET_TYPE = 'daily_eccm_v2';
export const JOB_PREFIX = 'daily_eccm_v2';
@ -153,333 +160,6 @@ export class DailyEccmV2ReportTaskRunnerService {
};
}
private async analyzeIssueMetrics(
dashboard: Dashboard,
widget: Widget,
issues: RedmineTypes.Issue[],
previousIssues?: RedmineTypes.Issue[],
previousMetrics?: Record<string, any>,
): Promise<Record<string, any>> {
this.logger.log(
`Analyzing issues metrics for dashboard ${dashboard.id}, widget ${widget.id}`,
);
// ШАГ 1. Подсчет количества задач по статусам
this.logger.debug(`Step 1. Calculating issues by status`);
const issuesByStatusCount = issues.reduce((acc, issue) => {
const status = issue.status?.name;
if (status) {
if (!acc[status]) {
acc[status] = {
count: 0,
issueIds: [],
byVersion: {} as Record<
string,
{ count: number; issueIds: number[] }
>,
};
}
acc[status].count = acc[status].count + 1;
acc[status].issueIds.push(issue.id);
const version = issue.fixed_version?.name;
if (version) {
if (!acc[status].byVersion[version]) {
acc[status].byVersion[version] = { count: 0, issueIds: [] };
acc[status].byVersion[version].count++;
acc[status].byVersion[version].issueIds.push(issue.id);
}
}
}
return acc;
}, {});
this.logger.debug(
'Step 1. Calculating issues by status - done',
JSON.stringify(issuesByStatusCount),
);
// ШАГ 2. Подсчет количества задач по версиям
this.logger.debug('Step 2. Calculating issues by versions');
const issuesByVersionsCount = issues.reduce((acc, issue) => {
const version = issue.fixed_version?.name;
if (version) {
if (!acc[version]) {
acc[version] = {
count: 0,
issueIds: [],
byStatus: {} as Record<
string,
{ count: number; issueIds: number[] }
>,
};
}
acc[version].count = acc[version].count + 1;
acc[version].issueIds.push(issue.id);
const status = issue.status?.name;
if (status) {
if (!acc[version].byStatus[status]) {
acc[version].byStatus[status] = { count: 0, issueIds: [] };
}
acc[version].byStatus[status].count += 1;
acc[version].byStatus[status].issueIds.push(issue.id);
}
}
return acc;
}, {});
this.logger.debug(
'Step 2. Calculating issues by versions - done',
JSON.stringify(issuesByVersionsCount),
);
// ШАГ 3. Подсчет количества внутренних изменений
this.logger.debug('Step 3. Calculating internal changes');
const changesInterval =
(widget?.dataLoaderParams['changesInterval'] as number) ??
24 * 60 * 60 * 1000;
const changesCount = {
totalChanges: 0,
totalComments: 0,
byIssue: {} as Record<number, { changes: number; comments: number }>,
byStatus: {} as Record<string, { changes: number; comments: number }>,
byVersion: {} as Record<string, { changes: number; comments: number }>,
byUserName: {} as Record<string, { changes: number; comments: number }>,
};
const now = DateTime.now().toMillis();
if (
typeof changesInterval === 'number' &&
changesInterval > 0 &&
issues?.length > 0
) {
issues.forEach((issue) => {
const status = issue.status?.name ?? 'no_status';
const version = issue.fixed_version?.name ?? 'no_version';
const changes = issue.journals?.reduce(
(acc, journal) => {
const createdOnTimestamp = DateTime.fromISO(
journal.created_on,
).toMillis();
const currentUser = journal?.user?.name ?? 'unknown';
if (
createdOnTimestamp > now - changesInterval &&
createdOnTimestamp <= now
) {
if (!changesCount.byStatus[status]) {
changesCount.byStatus[status] = { changes: 0, comments: 0 };
}
if (!changesCount.byVersion[version]) {
changesCount.byVersion[version] = { changes: 0, comments: 0 };
}
if (!changesCount.byUserName[currentUser]) {
changesCount.byUserName[currentUser] = {
changes: 0,
comments: 0,
};
}
acc.changes += 1;
changesCount.totalChanges += 1;
changesCount.byStatus[status].changes += 1;
changesCount.byVersion[version].changes += 1;
changesCount.byUserName[currentUser].changes += 1;
if (
typeof journal.notes === 'string' &&
journal.notes.length > 0
) {
acc.comments += 1;
changesCount.totalComments += 1;
changesCount.byStatus[status].comments += 1;
changesCount.byVersion[version].comments += 1;
changesCount.byUserName[currentUser].comments += 1;
}
}
return acc;
},
{
changes: 0,
comments: 0,
},
);
changesCount.byIssue[issue.id] = changes;
});
}
this.logger.debug(
'Step 3. Calculating internal changes - done',
JSON.stringify(changesCount),
);
// ШАГ 4: Количество задач с оценками трудозатрат
this.logger.debug('Step 4. Counting issues with estimates and spent hours');
const issuesWithEstimatesAndSpenthoursCount = {
withEstimates: { count: 0, issueIds: [] },
withoutEstimates: { count: 0, issueIds: [] },
withSpentHoursOverEstimates: { count: 0, issueIds: [] },
byVersion: {} as Record<
string,
{
withEstimates: { count: number; issueIds: number[] };
withoutEstimates: { count: number; issueIds: number[] };
withSpentHoursOverEstimates: { count: number; issueIds: number[] };
}
>,
byStatus: {} as Record<
string,
{
withEstimates: { count: number; issueIds: number[] };
withoutEstimates: { count: number; issueIds: number[] };
withSpentHoursOverEstimates: { count: number; issueIds: number[] };
}
>,
};
issues.forEach((issue) => {
const version = issue.fixed_version?.name;
if (
version &&
!issuesWithEstimatesAndSpenthoursCount.byVersion[version]
) {
issuesWithEstimatesAndSpenthoursCount.byVersion[version] = {
withEstimates: { count: 0, issueIds: [] },
withoutEstimates: { count: 0, issueIds: [] },
withSpentHoursOverEstimates: { count: 0, issueIds: [] },
};
}
const status = issue.status?.name;
if (status && !issuesWithEstimatesAndSpenthoursCount.byStatus[status]) {
issuesWithEstimatesAndSpenthoursCount.byStatus[status] = {
withEstimates: { count: 0, issueIds: [] },
withoutEstimates: { count: 0, issueIds: [] },
withSpentHoursOverEstimates: { count: 0, issueIds: [] },
};
}
if (
typeof issue.estimated_hours === 'number' &&
issue.estimated_hours > 0
) {
issuesWithEstimatesAndSpenthoursCount.withEstimates.count += 1;
issuesWithEstimatesAndSpenthoursCount.withEstimates.issueIds.push(
issue.id,
);
// eslint-disable-next-line prettier/prettier
issuesWithEstimatesAndSpenthoursCount.byStatus[status].withEstimates.count += 1;
// eslint-disable-next-line prettier/prettier
issuesWithEstimatesAndSpenthoursCount.byStatus[status].withEstimates.issueIds.push(issue.id);
// eslint-disable-next-line prettier/prettier
issuesWithEstimatesAndSpenthoursCount.byVersion[version].withEstimates.count += 1;
// eslint-disable-next-line prettier/prettier
issuesWithEstimatesAndSpenthoursCount.byVersion[version].withEstimates.issueIds.push(issue.id);
} else {
issuesWithEstimatesAndSpenthoursCount.withoutEstimates.count += 1;
issuesWithEstimatesAndSpenthoursCount.withoutEstimates.issueIds.push(
issue.id,
);
// eslint-disable-next-line prettier/prettier
issuesWithEstimatesAndSpenthoursCount.byStatus[status].withoutEstimates.count += 1;
// eslint-disable-next-line prettier/prettier
issuesWithEstimatesAndSpenthoursCount.byStatus[status].withoutEstimates.issueIds.push(issue.id);
// eslint-disable-next-line prettier/prettier
issuesWithEstimatesAndSpenthoursCount.byVersion[version].withoutEstimates.count += 1;
// eslint-disable-next-line prettier/prettier
issuesWithEstimatesAndSpenthoursCount.byVersion[version].withoutEstimates.issueIds.push(issue.id);
}
if (
typeof issue.spent_hours === 'number' &&
typeof issue.estimated_hours === 'number' &&
issue.spent_hours > issue.estimated_hours
) {
issuesWithEstimatesAndSpenthoursCount.withSpentHoursOverEstimates.count += 1;
issuesWithEstimatesAndSpenthoursCount.withSpentHoursOverEstimates.issueIds.push(
issue.id,
);
// eslint-disable-next-line prettier/prettier
issuesWithEstimatesAndSpenthoursCount.byStatus[status].withSpentHoursOverEstimates.count += 1;
// eslint-disable-next-line prettier/prettier
issuesWithEstimatesAndSpenthoursCount.byStatus[status].withSpentHoursOverEstimates.issueIds.push(issue.id);
// eslint-disable-next-line prettier/prettier
issuesWithEstimatesAndSpenthoursCount.byVersion[version].withSpentHoursOverEstimates.count += 1;
// eslint-disable-next-line prettier/prettier
issuesWithEstimatesAndSpenthoursCount.byVersion[version].withSpentHoursOverEstimates.issueIds.push(issue.id);
}
});
this.logger.debug(
'Step 4. Counting issues with estimates and spent hours - done',
JSON.stringify(issuesWithEstimatesAndSpenthoursCount),
);
// ШАГ 5: Счётчики сравнения с предыдущим отчётом
this.logger.debug('Step 5: Calculating differences with previous report');
const differencesCount = {
newIssues: { count: 0, issueIds: [] },
lostIssues: { count: 0, issueIds: [] },
reopenedIssues: { count: 0, issueIds: [] },
closedIssues: { count: 0, issueIds: [] },
};
issues.forEach((issue) => {
const issueIntoPreviousReport = previousIssues.find(
(prevIssue) => prevIssue.id === issue.id,
);
if (!issueIntoPreviousReport) {
differencesCount.newIssues.count += 1;
differencesCount.newIssues.issueIds.push(issue.id);
}
});
previousIssues.forEach((prevIssue) => {
const issueIntoCurrentReport = issues.find(
(currIssue) => currIssue.id === prevIssue.id,
);
if (!issueIntoCurrentReport) {
differencesCount.lostIssues.count += 1;
differencesCount.lostIssues.issueIds.push(prevIssue.id);
}
});
issues.forEach((issue) => {
const issueStatus: any = issue.status;
if (issueStatus.is_closed) {
const prevReportClosedIssue = previousIssues.find((prevIssue) => {
return (
prevIssue.id === issue.id &&
prevIssue.status &&
(prevIssue.status as any).is_closed
);
});
if (!prevReportClosedIssue) {
differencesCount.closedIssues.count += 1;
differencesCount.closedIssues.issueIds.push(issue.id);
}
} else {
const prevReportOpenIssue = previousIssues.find((prevIssue) => {
return (
prevIssue.id === issue.id &&
prevIssue.status &&
!(prevIssue.status as any)?.is_closed
);
});
if (!prevReportOpenIssue) {
differencesCount.reopenedIssues.count += 1;
differencesCount.reopenedIssues.issueIds.push(issue.id);
}
}
});
this.logger.debug(
'Step 5: Calculating differences with previous report - done',
JSON.stringify(differencesCount),
);
const metrics = {
issuesByStatusCount: issuesByStatusCount,
issuesByVersionsCount: issuesByVersionsCount,
changesCount: changesCount,
issuesWithEstimatesAndSpenthoursCount:
issuesWithEstimatesAndSpenthoursCount,
differencesCount: differencesCount,
};
this.logger.log(
`Metrics generated for dashboard ${dashboard.id}, widget ${widget.id}`,
);
return metrics;
}
private async saveReport(
dashboard: Dashboard,
widget: Widget,
@ -500,7 +180,6 @@ export class DailyEccmV2ReportTaskRunnerService {
updated_on: issue.updated_on,
closed_on: issue.closed_on,
status: issue.status,
fixed_version: issue.fixed_version,
priority: issue.priority,
author: issue.author,
assigned_to: issue.assigned_to,
@ -513,33 +192,9 @@ export class DailyEccmV2ReportTaskRunnerService {
due_date: issue.due_date,
done_ratio: issue.done_ratio,
estimated_hours: issue.estimated_hours,
total_estimated_hours: issue.total_estimated_hours,
spent_hours: issue.spent_hours,
total_spent_hours: issue.total_spent_hours,
};
});
const prevDataResponse = await datasource.find({
selector: {
dashboardId: dashboardId,
widgetId: widget.id,
latest: true,
},
limit: 1,
});
const prevData = prevDataResponse.docs[0];
const prevIssues = prevData ? prevData.reportIssues : [];
const prevMetrics = prevData ? prevData.issuesMetrics : {};
if (prevData) {
prevData.latest = false;
await datasource.insert(prevData);
}
const issuesMetrics = await this.analyzeIssueMetrics(
dashboard,
widget,
issues,
prevIssues,
prevMetrics,
);
const item: any = {
_id: id,
id: id,
@ -548,8 +203,6 @@ export class DailyEccmV2ReportTaskRunnerService {
datetime,
datetimeFormatted,
reportIssues,
issuesMetrics,
latest: true,
};
await datasource.insert(item);
this.logger.debug(

View file

@ -1,4 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class DailyEccmV2ReportService {}