Анализ списков задач, анализ дерева задач, тестовый контроллер
This commit is contained in:
parent
7cde09c895
commit
ce413b8cad
7 changed files with 487 additions and 0 deletions
|
|
@ -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],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
export interface WidgetInterface<W, D, R> {
|
||||
isMyConfig(widgetParams: W): boolean;
|
||||
render(widgetParams: W, dashboardParams: D): Promise<R>;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
181
libs/event-emitter/src/utils/flat-issues-store.ts
Normal file
181
libs/event-emitter/src/utils/flat-issues-store.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
183
libs/event-emitter/src/utils/tree-issues-store.ts
Normal file
183
libs/event-emitter/src/utils/tree-issues-store.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
32
src/dashboards/eccm110-dashboard.controller.ts
Normal file
32
src/dashboards/eccm110-dashboard.controller.ts
Normal 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',
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue