diff --git a/libs/event-emitter/src/event-emitter.module.ts b/libs/event-emitter/src/event-emitter.module.ts index 02bd75d..7b212d1 100644 --- a/libs/event-emitter/src/event-emitter.module.ts +++ b/libs/event-emitter/src/event-emitter.module.ts @@ -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], }; diff --git a/libs/event-emitter/src/project-dashboard/widget-interface.ts b/libs/event-emitter/src/project-dashboard/widget-interface.ts new file mode 100644 index 0000000..2f00e79 --- /dev/null +++ b/libs/event-emitter/src/project-dashboard/widget-interface.ts @@ -0,0 +1,4 @@ +export interface WidgetInterface { + isMyConfig(widgetParams: W): boolean; + render(widgetParams: W, dashboardParams: D): Promise; +} diff --git a/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts b/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts new file mode 100644 index 0000000..894879e --- /dev/null +++ b/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts @@ -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 +{ + constructor(private issuesService: IssuesService) {} + + isMyConfig(): boolean { + return true; + } + + async render(widgetParams: Params): Promise { + 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> { + const issues = await this.issuesService.getIssues(ids); + const res = {} as Record; + for (let i = 0; i < issues.length; i++) { + const issue = issues[i]; + res[issue.id] = issue; + } + return res; + } +} diff --git a/libs/event-emitter/src/utils/flat-issues-store.ts b/libs/event-emitter/src/utils/flat-issues-store.ts new file mode 100644 index 0000000..a9b07a3 --- /dev/null +++ b/libs/event-emitter/src/utils/flat-issues-store.ts @@ -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>; + + 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 = {}; + + 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 { + 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 { + const res = {} as Record; + 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 { + const res = {} as Record; + 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 { + 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; + } +} diff --git a/libs/event-emitter/src/utils/tree-issues-store.ts b/libs/event-emitter/src/utils/tree-issues-store.ts new file mode 100644 index 0000000..8a1bc25 --- /dev/null +++ b/libs/event-emitter/src/utils/tree-issues-store.ts @@ -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; + 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 { + 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 { + 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; + } +} diff --git a/src/app.module.ts b/src/app.module.ts index 6f3a43d..810ba7f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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, diff --git a/src/dashboards/eccm110-dashboard.controller.ts b/src/dashboards/eccm110-dashboard.controller.ts new file mode 100644 index 0000000..09eaddd --- /dev/null +++ b/src/dashboards/eccm110-dashboard.controller.ts @@ -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 { + 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', + ], + }); + } +}