Анализ списков задач, анализ дерева задач, тестовый контроллер

This commit is contained in:
Pavel Gnedov 2023-02-02 11:48:52 +07:00
parent 7cde09c895
commit ce413b8cad
7 changed files with 487 additions and 0 deletions

View file

@ -18,6 +18,7 @@ import { IssuesService } from './issues/issues.service';
import { IssuesController } from './issues/issues.controller';
import { TimestampEnhancer } from './issue-enhancers/timestamps-enhancer';
import { EnhancerService } from './issue-enhancers/enhancer.service';
import { RootIssueSubTreesWidgetService } from './project-dashboard/widgets/root-issue-subtrees.widget.service';
@Module({})
export class EventEmitterModule implements OnModuleInit {
@ -40,6 +41,7 @@ export class EventEmitterModule implements OnModuleInit {
IssuesService,
TimestampEnhancer,
EnhancerService,
RootIssueSubTreesWidgetService,
],
exports: [
EventEmitterService,
@ -54,6 +56,7 @@ export class EventEmitterModule implements OnModuleInit {
IssuesService,
TimestampEnhancer,
EnhancerService,
RootIssueSubTreesWidgetService,
],
controllers: [MainController, UsersController, IssuesController],
};

View file

@ -0,0 +1,4 @@
export interface WidgetInterface<W, D, R> {
isMyConfig(widgetParams: W): boolean;
render(widgetParams: W, dashboardParams: D): Promise<R>;
}

View file

@ -0,0 +1,82 @@
/* eslint-disable @typescript-eslint/no-namespace */
import { IssuesService } from '@app/event-emitter/issues/issues.service';
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
import {
TreeIssuesStore,
TreeIssuesStoreNs,
} from '@app/event-emitter/utils/tree-issues-store';
import { Injectable } from '@nestjs/common';
import { WidgetInterface } from '../widget-interface';
export namespace RootIssueSubTreesWidgetNs {
export namespace Models {
export type Params = {
rootIssueId: number;
parentsAsGroups?: boolean;
groups?: GroupCfg;
statuses: string[];
};
export type GroupCfg = {
fromIssues: Group[];
fromIssuesIncluded: boolean;
showOthers: boolean;
};
export type Group = {
name: string;
issueId: number;
};
}
}
type Params = RootIssueSubTreesWidgetNs.Models.Params;
@Injectable()
export class RootIssueSubTreesWidgetService
implements WidgetInterface<Params, any, any>
{
constructor(private issuesService: IssuesService) {}
isMyConfig(): boolean {
return true;
}
async render(widgetParams: Params): Promise<any> {
const treeStore = new TreeIssuesStore();
const rootIssue = await this.issuesService.getIssue(
widgetParams.rootIssueId,
);
treeStore.setRootIssue(rootIssue);
await treeStore.fillData(this.issuesLoader.bind(this));
let stories: TreeIssuesStoreNs.Models.GetFlatStories.Result;
if (widgetParams.parentsAsGroups) {
stories = treeStore.getFlatStoriesByParents();
} else if (widgetParams.groups) {
const fromIssues = widgetParams.groups.fromIssues.map((g) => g.issueId);
stories = treeStore.getFlatStories(
fromIssues,
widgetParams.groups.fromIssuesIncluded,
widgetParams.groups.showOthers,
);
}
return stories.map((s) => {
return {
data: s.store.groupByStatus(widgetParams.statuses),
metainfo: s.metainfo,
};
});
}
private async issuesLoader(
ids: number[],
): Promise<Record<number, RedmineTypes.Issue | null>> {
const issues = await this.issuesService.getIssues(ids);
const res = {} as Record<number, RedmineTypes.Issue | null>;
for (let i = 0; i < issues.length; i++) {
const issue = issues[i];
res[issue.id] = issue;
}
return res;
}
}

View file

@ -0,0 +1,181 @@
/* eslint-disable @typescript-eslint/no-namespace */
import { RedmineTypes } from '../models/redmine-types';
export namespace FlatIssuesStoreNs {
export type IssuesLoader = (
ids: number[],
) => Promise<Record<number, RedmineTypes.Issue | null>>;
export namespace Models {
export type ByStatus = {
status: string;
count: number;
issues: RedmineTypes.Issue[];
};
export type ByStatuses = ByStatus[];
export enum FindErrors {
NOT_FOUND = 'NOT_FOUND',
NOT_LOADED = 'NOT_LOADED',
}
export type FindResult = {
data?: RedmineTypes.Issue;
error?: FindErrors;
};
}
}
export class FlatIssuesStore {
private issues: Record<number, RedmineTypes.Issue | null> = {};
push(issue: number | string | RedmineTypes.Issue): void {
let id: any;
let data: any;
if (
typeof issue === 'number' ||
(typeof issue === 'string' && Number.isFinite(Number(issue)))
) {
id = Number(issue);
data = null;
} else if (typeof issue === 'object' && typeof issue['id'] === 'number') {
id = issue['id'];
data = issue;
}
if (
typeof id === 'number' &&
!Object.prototype.hasOwnProperty.call(this.issues, id)
) {
this.issues[id] = data;
}
}
async fillData(loader: FlatIssuesStoreNs.IssuesLoader): Promise<void> {
const ids = [] as number[];
for (const id in this.issues) {
if (Object.prototype.hasOwnProperty.call(this.issues, id)) {
const issue = this.issues[id];
if (!issue) {
ids.push(Number(id));
}
}
}
const data = await loader(ids);
for (const id in data) {
if (Object.prototype.hasOwnProperty.call(data, id)) {
const issue = data[id];
if (issue) {
this.issues[id] = issue;
}
}
}
return;
}
getIds(): number[] {
return Object.keys(this.issues).map((i) => Number(i));
}
getIssues(): RedmineTypes.Issue[] {
return Object.values(this.issues).filter((i) => !!i);
}
hasIssue(id: number): boolean {
return Object.prototype.hasOwnProperty.call(this.issues, id);
}
loadedIssue(id: number): boolean {
return this.hasIssue(id) && !!this.issues[id];
}
getIssue(id: number): FlatIssuesStoreNs.Models.FindResult {
if (!this.hasIssue(id)) {
return { error: FlatIssuesStoreNs.Models.FindErrors.NOT_FOUND };
} else if (!this.issues[id]) {
return { error: FlatIssuesStoreNs.Models.FindErrors.NOT_LOADED };
} else {
return { data: this.issues[id] };
}
}
isFullLoaded(): boolean {
return Object.values(this.issues).indexOf(null) < 0;
}
groupBy(
iteratee: (issue: RedmineTypes.Issue) => string | number,
): Record<string | number, RedmineTypes.Issue[]> {
const res = {} as Record<string | number, RedmineTypes.Issue[]>;
const items = this.getIssues();
for (let i = 0; i < items.length; i++) {
const issue = items[i];
const key = iteratee(issue);
if (!Object.prototype.hasOwnProperty.call(res, key)) {
res[key] = [];
}
res[key].push(issue);
}
return res;
}
groupByToStories(
iteratee: (issue: RedmineTypes.Issue) => string | number,
): Record<string | number, FlatIssuesStore> {
const res = {} as Record<string | number, FlatIssuesStore>;
const rawData = this.groupBy(iteratee);
for (const key in rawData) {
if (Object.prototype.hasOwnProperty.call(rawData, key)) {
const issues = rawData[key];
res[key] = new FlatIssuesStore();
for (let i = 0; i < issues.length; i++) {
const issue = issues[i];
res[key].push(issue);
}
}
}
return res;
}
groupByStatus(statuses: string[]): FlatIssuesStoreNs.Models.ByStatuses {
const res = [] as FlatIssuesStoreNs.Models.ByStatuses;
for (let i = 0; i < statuses.length; i++) {
const status = statuses[i];
res.push({ status: status, count: 0, issues: [] });
}
const groupedIssues = this.groupBy((issue) => issue.status.name);
for (const status in groupedIssues) {
if (Object.prototype.hasOwnProperty.call(groupedIssues, status)) {
const issues = groupedIssues[status];
const foundItem = res.find((i) => i.status === status);
if (!foundItem) continue;
foundItem.issues.push(...issues);
}
}
for (let i = 0; i < res.length; i++) {
const item = res[i];
item.count = item.issues.length;
}
return res;
}
groupByStatusWithExtra(
iteratee: (issue: RedmineTypes.Issue) => string | number,
statuses: string[],
): Record<string | number, FlatIssuesStoreNs.Models.ByStatuses> {
const res = {} as Record<
string | number,
FlatIssuesStoreNs.Models.ByStatuses
>;
const groupedIssues = this.groupByToStories(iteratee);
for (const key in groupedIssues) {
if (Object.prototype.hasOwnProperty.call(groupedIssues, key)) {
const store = groupedIssues[key];
res[key] = store.groupByStatus(statuses);
}
}
return res;
}
}

View file

@ -0,0 +1,183 @@
/* eslint-disable @typescript-eslint/no-namespace */
import { RedmineTypes } from '../models/redmine-types';
import { FlatIssuesStore, FlatIssuesStoreNs } from './flat-issues-store';
export namespace TreeIssuesStoreNs {
export namespace Models {
export namespace GetFlatStories {
export type Item = {
metainfo: Record<string, any>;
store: FlatIssuesStore;
};
export type Result = Item[];
}
}
}
export class TreeIssuesStore {
private rootIssue: RedmineTypes.Issue;
private flatStore: FlatIssuesStore;
setRootIssue(issue: RedmineTypes.Issue): void {
this.rootIssue = issue;
this.prepareFlatIssuesStore();
}
async fillData(loader: FlatIssuesStoreNs.IssuesLoader): Promise<void> {
await this.flatStore.fillData(loader);
}
getFlatStore(): FlatIssuesStore {
return this.flatStore;
}
private prepareFlatIssuesStore(): void {
this.flatStore = new FlatIssuesStore();
this.flatStore.push(this.rootIssue);
if (this.rootIssue.children && this.rootIssue.children.length > 0) {
this.fillChildrenFlatIssuesStore(this.rootIssue.children);
}
}
private fillChildrenFlatIssuesStore(
childrenIssues: RedmineTypes.Children,
): void {
for (let i = 0; i < childrenIssues.length; i++) {
const issue = childrenIssues[i];
this.flatStore.push(issue.id);
if (issue.children && issue.children.length > 0) {
this.fillChildrenFlatIssuesStore(issue.children);
}
}
}
isFullLoaded(): boolean {
return this.flatStore.isFullLoaded();
}
getFlatStories(
fromIssues: number[],
fromIssuesIncluded: boolean,
showOthers: boolean,
): TreeIssuesStoreNs.Models.GetFlatStories.Result {
const res = [] as TreeIssuesStoreNs.Models.GetFlatStories.Result;
for (let i = 0; i < fromIssues.length; i++) {
const fromIssue = this.flatStore.getIssue(fromIssues[i]);
if (!fromIssue.data) continue;
const store = new FlatIssuesStore();
this.putIssuesToStore(
store,
fromIssue.data,
fromIssues,
fromIssuesIncluded,
);
res.push({
store: store,
metainfo: this.createMetaInfo(fromIssue.data),
});
}
if (showOthers) {
const othersStore = new FlatIssuesStore();
res.push({
store: othersStore,
metainfo: this.createMetaInfo(this.rootIssue),
});
}
return res;
}
private putIssuesToStore(
store: FlatIssuesStore,
rootIssue: RedmineTypes.Issue,
fromIssues: number[],
fromIssuesIncluded: boolean,
): void {
if (fromIssuesIncluded) {
store.push(rootIssue);
}
if (!rootIssue.children || rootIssue.children.length <= 0) {
return;
}
for (let i = 0; i < rootIssue.children.length; i++) {
const childIssue = rootIssue.children[i];
const issueData = this.flatStore.getIssue(childIssue.id);
if (!issueData.data) continue;
if (fromIssues.indexOf(issueData.data.id) < 0) {
this.putIssuesToStore(
store,
issueData.data,
fromIssues,
fromIssuesIncluded,
);
} else {
if (fromIssuesIncluded == false) {
store.push(issueData.data);
}
}
}
}
private createMetaInfo(issue: RedmineTypes.Issue): Record<string, any> {
return {
title: `${issue.tracker.name} #${issue.id} - ${issue.subject}`,
rootIssue: {
id: issue.id,
tracker: issue.tracker,
subject: issue.subject,
},
};
}
getParents(issue: RedmineTypes.Issue): RedmineTypes.Issue[] {
const res = [] as RedmineTypes.Issue[];
let parentId: number;
let parentIssueData: FlatIssuesStoreNs.Models.FindResult;
let parentIssue: RedmineTypes.Issue;
parentId = issue.parent?.id;
while (parentId) {
parentIssueData = this.flatStore.getIssue(parentId);
parentIssue = parentIssueData.data;
if (!parentIssue) break;
res.push(parentIssue);
parentId = parentIssue.parent?.id;
}
return res;
}
getFlatStoriesByParents(): TreeIssuesStoreNs.Models.GetFlatStories.Result {
const stories = [] as TreeIssuesStoreNs.Models.GetFlatStories.Result;
return this.fillFlatStoriesByParents(stories, this.rootIssue);
}
private fillFlatStoriesByParents(
stories: TreeIssuesStoreNs.Models.GetFlatStories.Result,
rootIssue: RedmineTypes.Issue,
): TreeIssuesStoreNs.Models.GetFlatStories.Result {
if (!stories) {
stories = [] as TreeIssuesStoreNs.Models.GetFlatStories.Result;
}
if (!rootIssue) {
rootIssue = this.rootIssue;
}
if (!rootIssue.children || rootIssue.children.length <= 0) {
return stories;
}
const store = new FlatIssuesStore();
for (let i = 0; i < rootIssue.children.length; i++) {
const child = rootIssue.children[i];
const childIssueData = this.flatStore.getIssue(child.id);
if (!childIssueData.data) continue;
store.push(childIssueData.data);
this.fillFlatStoriesByParents(stories, childIssueData.data);
}
stories.push({
store: store,
metainfo: this.createMetaInfo(rootIssue),
});
return stories;
}
}

View file

@ -36,6 +36,7 @@ import { DailyEccmReportsUserCommentsDatasource } from './couchdb-datasources/da
import { DailyEccmUserCommentsService } from './reports/daily-eccm-user-comments.service';
import { SetDailyEccmUserCommentBotHandlerService } from './telegram-bot/handlers/set-daily-eccm-user-comment.bot-handler.service';
import { DailyEccmWithExtraDataService } from './reports/daily-eccm-with-extra-data.service';
import { Eccm110DashboardController } from './dashboards/eccm110-dashboard.controller';
@Module({
imports: [
@ -53,6 +54,7 @@ import { DailyEccmWithExtraDataService } from './reports/daily-eccm-with-extra-d
MainController,
CurrentIssuesEccmReportController,
DailyEccmReportController,
Eccm110DashboardController,
],
providers: [
AppService,

View file

@ -0,0 +1,32 @@
import { RootIssueSubTreesWidgetService } from '@app/event-emitter/project-dashboard/widgets/root-issue-subtrees.widget.service';
import { Controller, Get } from '@nestjs/common';
@Controller('eccm-1.10-dashboard')
export class Eccm110DashboardController {
constructor(
private rootIssueSubTreesWidgetService: RootIssueSubTreesWidgetService,
) {}
// TODO: code for Eccm110DashboardController
@Get('/raw')
async getRawData(): Promise<any> {
return await this.rootIssueSubTreesWidgetService.render({
rootIssueId: 2,
parentsAsGroups: true,
statuses: [
'New',
'Closed',
'In Progress',
'Re-opened',
'Code Review',
'Resolved',
'Testing',
'Wait Release',
'Pending',
'Feedback',
'Rejected',
],
});
}
}