diff --git a/angular.json b/angular.json index c7a34fbf..ac0b1ea4 100644 --- a/angular.json +++ b/angular.json @@ -2191,6 +2191,38 @@ "style": "scss" } } + }, + "server-repository-util": { + "projectType": "library", + "root": "libs/server/repository/util", + "sourceRoot": "libs/server/repository/util/src", + "prefix": "pimp-my-pr", + "architect": { + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": [ + "libs/server/repository/util/tsconfig.lib.json", + "libs/server/repository/util/tsconfig.spec.json" + ], + "exclude": ["**/node_modules/**", "!libs/server/repository/util/**"] + } + }, + "test": { + "builder": "@nrwl/jest:jest", + "options": { + "jestConfig": "libs/server/repository/util/jest.config.js", + "tsConfig": "libs/server/repository/util/tsconfig.spec.json", + "passWithNoTests": true, + "setupFile": "libs/server/repository/util/src/test-setup.ts" + } + } + }, + "schematics": { + "@nrwl/angular:component": { + "style": "scss" + } + } } }, "cli": { diff --git a/apps/pmp-api/src/main.ts b/apps/pmp-api/src/main.ts index d3f24f9d..f6e48319 100644 --- a/apps/pmp-api/src/main.ts +++ b/apps/pmp-api/src/main.ts @@ -9,7 +9,9 @@ async function bootstrap(): Promise { const globalPrefix = 'api'; app.setGlobalPrefix(globalPrefix); - app.useGlobalPipes(new ValidationPipe()); + app.useGlobalPipes( + new ValidationPipe({ transform: true, transformOptions: { enableImplicitConversion: true } }) + ); const port = process.env.port || 3333; diff --git a/libs/server/repository/api-rest/src/lib/controllers/timeline.controller.ts b/libs/server/repository/api-rest/src/lib/controllers/timeline.controller.ts new file mode 100644 index 00000000..3fd8502e --- /dev/null +++ b/libs/server/repository/api-rest/src/lib/controllers/timeline.controller.ts @@ -0,0 +1,40 @@ +import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { Controller, Get, Param, Query, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common'; +import { AuthGuard, Credentials, RequestCredentials } from '@pimp-my-pr/server/auth/public'; +import { + GetPrTimelineQuery, + PrTimelineReadModel, + TimelineFacade +} from '@pimp-my-pr/server/repository/core/application-services'; +import { GenerateTimelineDto } from '../dtos/generate-timeline.dto'; +import { decreaseDateByTimelineStep } from '@pimp-my-pr/server/repository/util'; +import { TimelineStep } from '@pimp-my-pr/shared/domain'; + +@ApiTags('timeline') +@UseGuards(AuthGuard) +@ApiBearerAuth() +@Controller('timeline') +export class TimelineController { + constructor(private timelineFacade: TimelineFacade) {} + + @ApiOkResponse({ type: [PrTimelineReadModel] }) + @Get('pr/:repositoryId') + generateTimeline( + @Credentials() credentials: RequestCredentials, + @Param('repositoryId') repositoryId: string, + @Query() query: GenerateTimelineDto + ): Promise { + return this.timelineFacade.getPrTimeLine( + new GetPrTimelineQuery( + query.step, + query.timelineFrom, + query.timelineTo, + credentials.token, + repositoryId, + credentials.platform, + // TODO: Find a better solution to handle this problem + decreaseDateByTimelineStep(query.timelineFrom, TimelineStep.MONTH, 3) + ) + ); + } +} diff --git a/libs/server/repository/api-rest/src/lib/dtos/generate-timeline.dto.ts b/libs/server/repository/api-rest/src/lib/dtos/generate-timeline.dto.ts new file mode 100644 index 00000000..b7a0ff91 --- /dev/null +++ b/libs/server/repository/api-rest/src/lib/dtos/generate-timeline.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsDate, IsIn } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { TimelineStep } from '@pimp-my-pr/shared/domain'; + +export class GenerateTimelineDto { + @ApiProperty({ enum: TimelineStep }) + @IsIn(Object.values(TimelineStep)) + step: TimelineStep; + + @ApiProperty({ + description: 'Beginning of the tracked period - every subsequent record is older' + }) + @IsDate() + @Transform(value => new Date(value)) + timelineFrom: Date; + + @ApiProperty({ + description: 'Beginning of the tracked period - every subsequent record is older' + }) + @IsDate() + @Transform(value => new Date(value)) + timelineTo: Date; +} diff --git a/libs/server/repository/api-rest/src/lib/server-repository-api-rest.module.ts b/libs/server/repository/api-rest/src/lib/server-repository-api-rest.module.ts index ed645482..2077fd03 100644 --- a/libs/server/repository/api-rest/src/lib/server-repository-api-rest.module.ts +++ b/libs/server/repository/api-rest/src/lib/server-repository-api-rest.module.ts @@ -3,10 +3,11 @@ import { ServerRepositoryShellModule } from '@pimp-my-pr/server/repository/shell import { RepositoryController } from './controllers/repository.controller'; import { StatisticsController } from './controllers/statistics.controller'; import { UserRepositoryGuard } from './guards/user-repository.guard'; +import { TimelineController } from './controllers/timeline.controller'; @Module({ imports: [ServerRepositoryShellModule], - controllers: [RepositoryController, StatisticsController], + controllers: [RepositoryController, StatisticsController, TimelineController], providers: [UserRepositoryGuard] }) export class ServerRepositoryApiRestModule {} diff --git a/libs/server/repository/core/application-services/src/index.ts b/libs/server/repository/core/application-services/src/index.ts index e5803662..daae11b2 100644 --- a/libs/server/repository/core/application-services/src/index.ts +++ b/libs/server/repository/core/application-services/src/index.ts @@ -12,3 +12,6 @@ export * from './lib/queries/list-repositories/list-repositories.read-model'; export * from './lib/commands/edit-repository/edit-repository.command'; export * from './lib/queries/get-single-repository-data/get-single-repository-data.handler'; export * from './lib/queries/get-single-repository-data/single-repository-data.read-model'; +export * from './lib/queries/get-pr-timeline/get-pr-timeline.query'; +export * from './lib/queries/get-pr-timeline/pr-timeline.read-model'; +export * from './lib/timeline.facade'; diff --git a/libs/server/repository/core/application-services/src/lib/queries/get-pr-timeline/get-pr-timeline.handler.ts b/libs/server/repository/core/application-services/src/lib/queries/get-pr-timeline/get-pr-timeline.handler.ts new file mode 100644 index 00000000..a72800ec --- /dev/null +++ b/libs/server/repository/core/application-services/src/lib/queries/get-pr-timeline/get-pr-timeline.handler.ts @@ -0,0 +1,49 @@ +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import { GetPrTimelineQuery } from './get-pr-timeline.query'; +import { + PrRepository, + prRepositoryFactoryToken, + RepositoryRepository +} from '@pimp-my-pr/server/repository/core/domain-services'; +import { PrTimelineReadModel } from '@pimp-my-pr/server/repository/core/application-services'; +import { + getTimeLineHistory, + InvalidTimelineParametersException, + PrEntity, + PrState +} from '@pimp-my-pr/server/repository/core/domain'; +import { prTimelineModelFactory } from '../../read-models/factories/pr-timeline-model.factory'; +import { Inject } from '@nestjs/common'; +import { Platform } from '@pimp-my-pr/shared/domain'; +import { getStepsCount, traversePagesUntil } from '@pimp-my-pr/server/repository/util'; + +@QueryHandler(GetPrTimelineQuery) +export class GetPrTimelineHandler implements IQueryHandler { + constructor( + @Inject(prRepositoryFactoryToken) + private prRepositoryFactory: (platform: Platform) => PrRepository, + private repoRepository: RepositoryRepository + ) {} + + async execute(query: GetPrTimelineQuery): Promise { + const { repositoryId, step, timelineFrom, timelineTo, token, platform, createdAfter } = query; + const { fullName } = await this.repoRepository.getById(repositoryId); + const prRepository = this.prRepositoryFactory(platform); + + const prs = await traversePagesUntil( + async page => + await prRepository.findByRepositoryId(fullName, token, { + prState: PrState.ALL, + page, + onPage: 100 + }), + 100, + createdAfter + ); + const stepsCount = getStepsCount(timelineFrom, timelineTo, step); + if (stepsCount === 0) throw new InvalidTimelineParametersException('timelineFrom'); + + const records = getTimeLineHistory(prs, step, timelineTo, stepsCount); + return prTimelineModelFactory(records, query, prs.length); + } +} diff --git a/libs/server/repository/core/application-services/src/lib/queries/get-pr-timeline/get-pr-timeline.query.ts b/libs/server/repository/core/application-services/src/lib/queries/get-pr-timeline/get-pr-timeline.query.ts new file mode 100644 index 00000000..849b65ff --- /dev/null +++ b/libs/server/repository/core/application-services/src/lib/queries/get-pr-timeline/get-pr-timeline.query.ts @@ -0,0 +1,14 @@ +import { IQuery } from '@nestjs/cqrs'; +import { Platform, TimelineStep } from '@pimp-my-pr/shared/domain'; + +export class GetPrTimelineQuery implements IQuery { + constructor( + public step: TimelineStep, + public timelineFrom: Date, + public timelineTo: Date, + public token: string, + public repositoryId: string, + public platform: Platform, + public createdAfter: Date + ) {} +} diff --git a/libs/server/repository/core/application-services/src/lib/queries/get-pr-timeline/pr-timeline.read-model.ts b/libs/server/repository/core/application-services/src/lib/queries/get-pr-timeline/pr-timeline.read-model.ts new file mode 100644 index 00000000..caa02df2 --- /dev/null +++ b/libs/server/repository/core/application-services/src/lib/queries/get-pr-timeline/pr-timeline.read-model.ts @@ -0,0 +1,29 @@ +import { TimelineRecord } from '@pimp-my-pr/server/repository/core/domain'; +import { ApiProperty } from '@nestjs/swagger'; +import { TimelineStep } from '@pimp-my-pr/shared/domain'; + +export class PrTimelineReadModel { + @ApiProperty({ + enum: TimelineStep + }) + step: TimelineStep; + + @ApiProperty() + dateFrom: Date; + + @ApiProperty() + dateTo: Date; + + @ApiProperty({ + description: 'Pull requests were taken into account that were created after this date' + }) + createdAfter: Date; + + @ApiProperty({ + type: [TimelineRecord] + }) + data: TimelineRecord[]; + + @ApiProperty() + totalPrs: number; +} diff --git a/libs/server/repository/core/application-services/src/lib/read-models/factories/pr-timeline-model.factory.ts b/libs/server/repository/core/application-services/src/lib/read-models/factories/pr-timeline-model.factory.ts new file mode 100644 index 00000000..6d60e344 --- /dev/null +++ b/libs/server/repository/core/application-services/src/lib/read-models/factories/pr-timeline-model.factory.ts @@ -0,0 +1,20 @@ +import { + GetPrTimelineQuery, + PrTimelineReadModel +} from '@pimp-my-pr/server/repository/core/application-services'; +import { TimelineRecord } from '@pimp-my-pr/server/repository/core/domain'; + +export const prTimelineModelFactory = ( + prRecords: TimelineRecord[], + query: GetPrTimelineQuery, + totalPrs: number +): PrTimelineReadModel => { + return { + data: prRecords, + step: query.step, + dateFrom: query.timelineFrom, + dateTo: query.timelineTo, + createdAfter: query.createdAfter, + totalPrs + }; +}; diff --git a/libs/server/repository/core/application-services/src/lib/server-repository-core-application-services.module.ts b/libs/server/repository/core/application-services/src/lib/server-repository-core-application-services.module.ts index 29be5f11..f81e9a19 100644 --- a/libs/server/repository/core/application-services/src/lib/server-repository-core-application-services.module.ts +++ b/libs/server/repository/core/application-services/src/lib/server-repository-core-application-services.module.ts @@ -10,6 +10,8 @@ import { RepositoryFacade } from './repository.facade'; import { DeleteRepositoryHandler } from './commands/delete-repository/delete-repository.handler'; import { EditRepositoryHandler } from './commands/edit-repository/edit-repository.handler'; import { GetSingleRepositoryDataHandler } from './queries/get-single-repository-data/get-single-repository-data.handler'; +import { GetPrTimelineHandler } from './queries/get-pr-timeline/get-pr-timeline.handler'; +import { TimelineFacade } from './timeline.facade'; const QueryHandlers = [ AddRepositoryHandler, @@ -20,12 +22,13 @@ const QueryHandlers = [ GetSingleRepositoryDataHandler, ListRepositoriesStatisticsHandler, ListReviewersStatisticsHandler, - ListRepositoriesHandler + ListRepositoriesHandler, + GetPrTimelineHandler ]; @Module({ imports: [CqrsModule], - providers: [RepositoryFacade, ...QueryHandlers], - exports: [RepositoryFacade] + providers: [RepositoryFacade, TimelineFacade, ...QueryHandlers], + exports: [RepositoryFacade, TimelineFacade] }) export class ServerRepositoryCoreApplicationServicesModule {} diff --git a/libs/server/repository/core/application-services/src/lib/timeline.facade.ts b/libs/server/repository/core/application-services/src/lib/timeline.facade.ts new file mode 100644 index 00000000..0492cf32 --- /dev/null +++ b/libs/server/repository/core/application-services/src/lib/timeline.facade.ts @@ -0,0 +1,13 @@ +import { QueryBus } from '@nestjs/cqrs'; +import { GetPrTimelineQuery } from './queries/get-pr-timeline/get-pr-timeline.query'; +import { PrTimelineReadModel } from './queries/get-pr-timeline/pr-timeline.read-model'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class TimelineFacade { + constructor(private queryBus: QueryBus) {} + + getPrTimeLine(query: GetPrTimelineQuery): Promise { + return this.queryBus.execute(query); + } +} diff --git a/libs/server/repository/core/domain-services/src/lib/repositories/pr.repository.ts b/libs/server/repository/core/domain-services/src/lib/repositories/pr.repository.ts index ef9c8013..5ad9232d 100644 --- a/libs/server/repository/core/domain-services/src/lib/repositories/pr.repository.ts +++ b/libs/server/repository/core/domain-services/src/lib/repositories/pr.repository.ts @@ -1,7 +1,12 @@ import { PrEntity } from '@pimp-my-pr/server/repository/core/domain'; +import { PrRepositoryFetchParams } from '@pimp-my-pr/server/repository/core/domain'; export const prRepositoryFactoryToken = Symbol('prRepositoryFactory'); export abstract class PrRepository { - abstract findByRepositoryId(repositoryId: string, token: string): Promise; + abstract findByRepositoryId( + repositoryId: string, + token: string, + params?: PrRepositoryFetchParams + ): Promise; } diff --git a/libs/server/repository/core/domain/src/index.ts b/libs/server/repository/core/domain/src/index.ts index e4e74253..4346f2ff 100644 --- a/libs/server/repository/core/domain/src/index.ts +++ b/libs/server/repository/core/domain/src/index.ts @@ -3,4 +3,14 @@ export * from './lib/entities/pr.entity'; export * from './lib/entities/repository.entity'; export * from './lib/entities/reviewer.entity'; export * from './lib/exceptions/repository-not-found.exception'; +export * from './lib/exceptions/invalid-timeline-parameters.exception'; +export * from './lib/interfaces/i-time-trackable.interface'; +export * from './lib/interfaces/timeline-bucket-item.interface'; +export * from './lib/interfaces/timeline-bucket.interface'; +export * from './lib/interfaces/timeline-date-range.interface'; +export * from './lib/interfaces/timeline-division-base.interface'; +export * from './lib/interfaces/pr-repository-fetch-params.interface'; export * from './lib/utils/repository-name-extract.util'; +export * from './lib/entities/timeline-record.entity'; +export * from './lib/enums/pr-state.enum'; +export * from './lib/utils/get-timeline-history'; diff --git a/libs/server/repository/core/domain/src/lib/entities/pr.entity.ts b/libs/server/repository/core/domain/src/lib/entities/pr.entity.ts index 1e5792fe..91ecccd8 100644 --- a/libs/server/repository/core/domain/src/lib/entities/pr.entity.ts +++ b/libs/server/repository/core/domain/src/lib/entities/pr.entity.ts @@ -1,8 +1,9 @@ import { f } from '@marcj/marshal'; import { AuthorEntity } from './author.entity'; import { ReviewerEntity } from './reviewer.entity'; +import { ITimeTrackable } from '../interfaces/i-time-trackable.interface'; -export class PrEntity { +export class PrEntity implements ITimeTrackable { @f additions: number; @f diff --git a/libs/server/repository/core/domain/src/lib/entities/timeline-record.entity.ts b/libs/server/repository/core/domain/src/lib/entities/timeline-record.entity.ts new file mode 100644 index 00000000..becf175f --- /dev/null +++ b/libs/server/repository/core/domain/src/lib/entities/timeline-record.entity.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class TimelineRecord { + @ApiProperty() + dataFrom: Date; + @ApiProperty() + sumCount: number; + @ApiProperty() + avgCount: number; + @ApiProperty() + avgWaitingTime: number; + /** + * This property is required to calculate total prs in any period on timeline + */ + @ApiProperty() + closedBefore: number; + /** + * This property is required to calculate total prs in any period on timeline + */ + @ApiProperty() + openedAfter: number; +} diff --git a/libs/server/repository/core/domain/src/lib/enums/pr-state.enum.ts b/libs/server/repository/core/domain/src/lib/enums/pr-state.enum.ts new file mode 100644 index 00000000..092558e1 --- /dev/null +++ b/libs/server/repository/core/domain/src/lib/enums/pr-state.enum.ts @@ -0,0 +1,5 @@ +export enum PrState { + OPEN = 'OPEN', + CLOSED = 'CLOSED', + ALL = 'ALL' +} diff --git a/libs/server/repository/core/domain/src/lib/exceptions/invalid-timeline-parameters.exception.ts b/libs/server/repository/core/domain/src/lib/exceptions/invalid-timeline-parameters.exception.ts new file mode 100644 index 00000000..3a984b88 --- /dev/null +++ b/libs/server/repository/core/domain/src/lib/exceptions/invalid-timeline-parameters.exception.ts @@ -0,0 +1,7 @@ +import { CoreUnprocessableEntityException } from '@pimp-my-pr/server/shared/domain'; + +export class InvalidTimelineParametersException extends CoreUnprocessableEntityException { + constructor(paramName: string) { + super(`Invalid ${paramName}`); + } +} diff --git a/libs/server/repository/core/domain/src/lib/interfaces/i-time-trackable.interface.ts b/libs/server/repository/core/domain/src/lib/interfaces/i-time-trackable.interface.ts new file mode 100644 index 00000000..49c48b39 --- /dev/null +++ b/libs/server/repository/core/domain/src/lib/interfaces/i-time-trackable.interface.ts @@ -0,0 +1,4 @@ +export interface ITimeTrackable { + createdAt: Date; + closedAt?: Date; +} diff --git a/libs/server/repository/core/domain/src/lib/interfaces/pr-repository-fetch-params.interface.ts b/libs/server/repository/core/domain/src/lib/interfaces/pr-repository-fetch-params.interface.ts new file mode 100644 index 00000000..63b9c1c9 --- /dev/null +++ b/libs/server/repository/core/domain/src/lib/interfaces/pr-repository-fetch-params.interface.ts @@ -0,0 +1,7 @@ +import { PrState } from '../enums/pr-state.enum'; + +export interface PrRepositoryFetchParams { + prState?: PrState; + page?: number; + onPage?: number; +} diff --git a/libs/server/repository/core/domain/src/lib/interfaces/timeline-bucket-item.interface.ts b/libs/server/repository/core/domain/src/lib/interfaces/timeline-bucket-item.interface.ts new file mode 100644 index 00000000..4aea8d97 --- /dev/null +++ b/libs/server/repository/core/domain/src/lib/interfaces/timeline-bucket-item.interface.ts @@ -0,0 +1,4 @@ +export interface TimelineBucketItem { + entity: T; + timeIn: number; +} diff --git a/libs/server/repository/core/domain/src/lib/interfaces/timeline-bucket.interface.ts b/libs/server/repository/core/domain/src/lib/interfaces/timeline-bucket.interface.ts new file mode 100644 index 00000000..8af1193b --- /dev/null +++ b/libs/server/repository/core/domain/src/lib/interfaces/timeline-bucket.interface.ts @@ -0,0 +1,5 @@ +import { TimelineBucketItem } from './timeline-bucket-item.interface'; + +export interface TimelineBuckets { + [label: number]: TimelineBucketItem[]; +} diff --git a/libs/server/repository/core/domain/src/lib/interfaces/timeline-date-range.interface.ts b/libs/server/repository/core/domain/src/lib/interfaces/timeline-date-range.interface.ts new file mode 100644 index 00000000..b7b3922b --- /dev/null +++ b/libs/server/repository/core/domain/src/lib/interfaces/timeline-date-range.interface.ts @@ -0,0 +1,4 @@ +export interface TimelineDateRange { + dateFrom: Date; + dateTo: Date; +} diff --git a/libs/server/repository/core/domain/src/lib/interfaces/timeline-division-base.interface.ts b/libs/server/repository/core/domain/src/lib/interfaces/timeline-division-base.interface.ts new file mode 100644 index 00000000..393e6a84 --- /dev/null +++ b/libs/server/repository/core/domain/src/lib/interfaces/timeline-division-base.interface.ts @@ -0,0 +1,5 @@ +import { TimelineDateRange } from './timeline-date-range.interface'; + +export interface TimelineDivisionBase { + [timestamp: number]: TimelineDateRange; +} diff --git a/libs/server/repository/core/domain/src/lib/utils/divide-items-into-buckets.util.ts b/libs/server/repository/core/domain/src/lib/utils/divide-items-into-buckets.util.ts new file mode 100644 index 00000000..8d93b3b2 --- /dev/null +++ b/libs/server/repository/core/domain/src/lib/utils/divide-items-into-buckets.util.ts @@ -0,0 +1,41 @@ +import { PrEntity, TimelineDivisionBase } from '@pimp-my-pr/server/repository/core/domain'; +import { TimelineBuckets } from '@pimp-my-pr/server/repository/core/domain'; +import { generateTimelineBuckets } from './generate-timeline-buckets.util'; +import { wrapInBucketItem } from './wrap-in-bucket-item.util'; +import { ITimeTrackable } from '@pimp-my-pr/server/repository/core/domain'; + +export function divideItemsIntoBuckets( + bucketRanges: TimelineDivisionBase, + items: T[] +): TimelineBuckets { + const buckets = generateTimelineBuckets(bucketRanges); + + items + .map(pr => wrapInBucketItem(pr)) + .forEach(current => { + Object.entries(bucketRanges) + .map(([ts, record]) => { + const lowerBound = Math.max( + record.dateFrom.getTime(), + current.entity.createdAt.getTime() + ); + const upperBound = current.entity.closedAt + ? Math.min(record.dateTo.getTime(), current.entity.closedAt.getTime()) + : record.dateTo.getTime(); + return { + labelTimestamp: Number(ts), + timeIn: upperBound - lowerBound, + rangeSize: record.dateTo.getTime() - record.dateFrom.getTime() + }; + }) + .filter(row => row.timeIn > 0) + .forEach(currentEntry => { + buckets[currentEntry.labelTimestamp].push({ + timeIn: currentEntry.timeIn, + entity: current.entity + }); + }); + }); + + return buckets; +} diff --git a/libs/server/repository/core/domain/src/lib/utils/generate-stats.util.ts b/libs/server/repository/core/domain/src/lib/utils/generate-stats.util.ts new file mode 100644 index 00000000..c4a9b98b --- /dev/null +++ b/libs/server/repository/core/domain/src/lib/utils/generate-stats.util.ts @@ -0,0 +1,48 @@ +import { TimelineBuckets } from '@pimp-my-pr/server/repository/core/domain'; +import { TimelineDivisionBase } from '@pimp-my-pr/server/repository/core/domain'; +import { TimelineRecord } from '@pimp-my-pr/server/repository/core/domain'; +import { ITimeTrackable } from '@pimp-my-pr/server/repository/core/domain'; + +export function generateStats( + buckets: TimelineBuckets, + bucketRanges: TimelineDivisionBase, + raw: T[] +): TimelineRecord[] { + return Object.entries(buckets).map( + ([ts, entry]): TimelineRecord => { + const periodEnd = bucketRanges[ts].dateTo.getTime(); + const periodStart = bucketRanges[ts].dateFrom.getTime(); + const entityCount = entry.length; + const { closedBefore, openedAfter } = raw.reduce( + (total, current) => ({ + closedBefore: + total.closedBefore + + Number(current.closedAt && current.closedAt.getTime() < periodStart), + openedAfter: total.openedAfter + Number(current.createdAt.getTime() > periodEnd) + }), + { closedBefore: 0, openedAfter: 0 } + ); + const { entitySumTimeIn, entitySumTimeOpened } = entry.reduce( + (total, current) => ({ + entitySumTimeOpened: total.entitySumTimeOpened + current.timeIn, + entitySumTimeIn: + total.entitySumTimeIn + + (current.entity.closedAt + ? Math.min(current.entity.closedAt.getTime(), periodEnd) + : periodEnd) - + current.entity.createdAt.getTime() + }), + { entitySumTimeIn: 0, entitySumTimeOpened: 0 } + ); + const avgWaitingTime = entityCount ? entitySumTimeOpened / entityCount : 0; + return { + dataFrom: new Date(Number(ts)), + sumCount: entityCount, + avgCount: entitySumTimeIn / (periodEnd - periodStart), + avgWaitingTime: avgWaitingTime, + closedBefore, + openedAfter + }; + } + ); +} diff --git a/libs/server/repository/core/domain/src/lib/utils/generate-timeline-bucket-ranges.util.ts b/libs/server/repository/core/domain/src/lib/utils/generate-timeline-bucket-ranges.util.ts new file mode 100644 index 00000000..f337d3b0 --- /dev/null +++ b/libs/server/repository/core/domain/src/lib/utils/generate-timeline-bucket-ranges.util.ts @@ -0,0 +1,22 @@ +import { TimelineDivisionBase } from '@pimp-my-pr/server/repository/core/domain'; +import { decreaseDateByTimelineStep } from '@pimp-my-pr/server/repository/util'; +import { TimelineStep } from '@pimp-my-pr/shared/domain'; + +export const generateTimelineBucketRanges = ( + date: Date, + entriesCount: number, + step: TimelineStep +): TimelineDivisionBase => { + return Array.from(Array(entriesCount).keys()) + .map((key: number) => decreaseDateByTimelineStep(date, step, key + 1)) + .reduce( + (result, thisDate, index, array) => ({ + ...result, + [thisDate.getTime()]: { + dateFrom: thisDate, + dateTo: index ? array[index - 1] : date + } + }), + {} + ); +}; diff --git a/libs/server/repository/core/domain/src/lib/utils/generate-timeline-buckets.util.ts b/libs/server/repository/core/domain/src/lib/utils/generate-timeline-buckets.util.ts new file mode 100644 index 00000000..a413c477 --- /dev/null +++ b/libs/server/repository/core/domain/src/lib/utils/generate-timeline-buckets.util.ts @@ -0,0 +1,12 @@ +import { TimelineDivisionBase } from '@pimp-my-pr/server/repository/core/domain'; +import { TimelineBuckets } from '@pimp-my-pr/server/repository/core/domain'; + +export function generateTimelineBuckets(div: TimelineDivisionBase): TimelineBuckets { + return Object.entries(div).reduce( + (total, [timestamp, _]) => ({ + ...total, + [Number(timestamp)]: [] + }), + {} + ); +} diff --git a/libs/server/repository/core/domain/src/lib/utils/get-timeline-history.ts b/libs/server/repository/core/domain/src/lib/utils/get-timeline-history.ts new file mode 100644 index 00000000..fefb4ef5 --- /dev/null +++ b/libs/server/repository/core/domain/src/lib/utils/get-timeline-history.ts @@ -0,0 +1,19 @@ +import { TimelineRecord } from '@pimp-my-pr/server/repository/core/domain'; +import { generateTimelineBucketRanges } from './generate-timeline-bucket-ranges.util'; +import { divideItemsIntoBuckets } from './divide-items-into-buckets.util'; +import { generateStats } from './generate-stats.util'; +import { ITimeTrackable } from '@pimp-my-pr/server/repository/core/domain'; +import { getStepBeginning } from '@pimp-my-pr/server/repository/util'; +import { TimelineStep } from '@pimp-my-pr/shared/domain'; + +export function getTimeLineHistory( + items: T[], + step: TimelineStep, + date: Date, + bucketsCount: number +): TimelineRecord[] { + const stepBegin = getStepBeginning(date, step); + const bucketRanges = generateTimelineBucketRanges(stepBegin, bucketsCount, step); + const buckets = divideItemsIntoBuckets(bucketRanges, items); + return generateStats(buckets, bucketRanges, items); +} diff --git a/libs/server/repository/core/domain/src/lib/utils/wrap-in-bucket-item.util.ts b/libs/server/repository/core/domain/src/lib/utils/wrap-in-bucket-item.util.ts new file mode 100644 index 00000000..7da2b280 --- /dev/null +++ b/libs/server/repository/core/domain/src/lib/utils/wrap-in-bucket-item.util.ts @@ -0,0 +1,8 @@ +import { TimelineBucketItem } from '@pimp-my-pr/server/repository/core/domain'; + +export function wrapInBucketItem(item: T): TimelineBucketItem { + return { + timeIn: 0, + entity: item + }; +} diff --git a/libs/server/repository/infrastructure/src/lib/bitbucket/domain/enums/bitbucket-pr-state.enum.ts b/libs/server/repository/infrastructure/src/lib/bitbucket/domain/enums/bitbucket-pr-state.enum.ts new file mode 100644 index 00000000..a267ccd7 --- /dev/null +++ b/libs/server/repository/infrastructure/src/lib/bitbucket/domain/enums/bitbucket-pr-state.enum.ts @@ -0,0 +1,5 @@ +export enum BitbucketPrState { + ALL = '', + CLOSED = 'MERGED', + OPEN = 'OPEN' +} diff --git a/libs/server/repository/infrastructure/src/lib/bitbucket/repositories/bitbucket-pr.repository.ts b/libs/server/repository/infrastructure/src/lib/bitbucket/repositories/bitbucket-pr.repository.ts index f9a7095c..b20f16a3 100644 --- a/libs/server/repository/infrastructure/src/lib/bitbucket/repositories/bitbucket-pr.repository.ts +++ b/libs/server/repository/infrastructure/src/lib/bitbucket/repositories/bitbucket-pr.repository.ts @@ -1,5 +1,5 @@ import { HttpService, Injectable } from '@nestjs/common'; -import { PrEntity } from '@pimp-my-pr/server/repository/core/domain'; +import { PrEntity, PrState } from '@pimp-my-pr/server/repository/core/domain'; import { PrRepository } from '@pimp-my-pr/server/repository/core/domain-services'; import { bitbucketConfig } from '@pimp-my-pr/server/shared/config'; import { catchRequestExceptions } from '@pimp-my-pr/server/shared/util-exception'; @@ -12,6 +12,8 @@ import { BitbucketPrDiffEntity } from '../domain/entities/bitbucket-pr-diff.enti import { BitbucketPrEntity } from '../domain/entities/bitbucket-pr.entity'; import { BitbucketPaginatedResponse } from '../domain/interfaces/bitbucket-paginated-response.interface'; import { mapBitbucketPrDetails } from '../mappers/map-bitbucket-pr-details'; +import { urlWithQueryParams } from '@pimp-my-pr/shared/domain'; +import { BitbucketPrState } from '../domain/enums/bitbucket-pr-state.enum'; @Injectable() export class BitbucketPrRepository extends PrRepository { @@ -26,11 +28,23 @@ export class BitbucketPrRepository extends PrRepository { super(); } - findByRepositoryId(repositoryId: string, token: string): Promise { + findByRepositoryId( + repositoryId: string, + token: string, + { prState = PrState.OPEN, page = 1, onPage = 50 } = { + prState: PrState.OPEN, + page: 1, + onPage: 50 + } + ): Promise { return this.getDataFromAllPages( this.httpService .get>( - this.endpoints.getRepositoryPrs.url({ repositoryId }), + urlWithQueryParams(this.endpoints.getRepositoryPrs.url({ repositoryId }), { + page: page, + pagelen: onPage, + state: BitbucketPrState[prState] + }), { headers: { Authorization: `Bearer ${token}` } } ) .pipe(map(res => res.data)), diff --git a/libs/server/repository/infrastructure/src/lib/github/domain/enums/github-pr-status.enum.ts b/libs/server/repository/infrastructure/src/lib/github/domain/enums/github-pr-status.enum.ts index b2a417f6..b7c03a75 100644 --- a/libs/server/repository/infrastructure/src/lib/github/domain/enums/github-pr-status.enum.ts +++ b/libs/server/repository/infrastructure/src/lib/github/domain/enums/github-pr-status.enum.ts @@ -1,4 +1,5 @@ -export enum GithubPrStatus { - closed = 'closed', - open = 'open' +export enum GithubPrState { + CLOSED = 'closed', + OPEN = 'open', + ALL = 'all' } diff --git a/libs/server/repository/infrastructure/src/lib/github/repositories/github-pr.repository.ts b/libs/server/repository/infrastructure/src/lib/github/repositories/github-pr.repository.ts index 80337a57..4417c583 100644 --- a/libs/server/repository/infrastructure/src/lib/github/repositories/github-pr.repository.ts +++ b/libs/server/repository/infrastructure/src/lib/github/repositories/github-pr.repository.ts @@ -1,5 +1,5 @@ import { HttpService, Injectable } from '@nestjs/common'; -import { PrEntity } from '@pimp-my-pr/server/repository/core/domain'; +import { PrEntity, PrState } from '@pimp-my-pr/server/repository/core/domain'; import { PrRepository } from '@pimp-my-pr/server/repository/core/domain-services'; import { githubConfig } from '@pimp-my-pr/server/shared/config'; import { catchRequestExceptions } from '@pimp-my-pr/server/shared/util-exception'; @@ -9,6 +9,8 @@ import { map, switchMap } from 'rxjs/operators'; import { GithubPrDetailsEntity } from '../domain/entities/github-pr-details.entity'; import { GithubPrEntity } from '../domain/entities/github-pr.entity'; import { mapGithubPr } from '../mappers/map-github-pr'; +import { urlWithQueryParams } from '@pimp-my-pr/shared/domain'; +import { GithubPrState } from '../domain/enums/github-pr-status.enum'; @Injectable() export class GithubPrRepository extends PrRepository { @@ -37,14 +39,33 @@ export class GithubPrRepository extends PrRepository { .toPromise(); } - findByRepositoryId(repositoryId: string, token: string): Promise { + findByRepositoryId( + repositoryId: string, + token: string, + { prState = PrState.OPEN, page = 1, onPage = 50 } = { + prState: PrState.OPEN, + page: 1, + onPage: 50 + } + ): Promise { return this.httpService - .get(this.endpoints.getRepositoryPrs.url({ fullName: repositoryId }), { - headers: { Authorization: `token ${token}` } - }) + .get( + urlWithQueryParams( + this.endpoints.getRepositoryPrs.url({ + fullName: repositoryId + }), + { + page: page, + state: GithubPrState[prState], + per_page: onPage + } + ), + { + headers: { Authorization: `token ${token}` } + } + ) .pipe( map(res => res.data), - switchMap(prs => prs.length ? forkJoin(prs.map(pr => this.get(repositoryId, pr.number, token))) : of([]) ) diff --git a/libs/server/repository/infrastructure/src/lib/gitlab/domain/enum/gitlab-pr-state.enum.ts b/libs/server/repository/infrastructure/src/lib/gitlab/domain/enum/gitlab-pr-state.enum.ts new file mode 100644 index 00000000..737f8aa7 --- /dev/null +++ b/libs/server/repository/infrastructure/src/lib/gitlab/domain/enum/gitlab-pr-state.enum.ts @@ -0,0 +1,5 @@ +export enum GitlabPrState { + ALL = 'all', + OPEN = 'opened', + CLOSED = 'closed' +} diff --git a/libs/server/repository/infrastructure/src/lib/gitlab/repositories/gitlab-pr.repository.ts b/libs/server/repository/infrastructure/src/lib/gitlab/repositories/gitlab-pr.repository.ts index 815eba16..84c3df72 100644 --- a/libs/server/repository/infrastructure/src/lib/gitlab/repositories/gitlab-pr.repository.ts +++ b/libs/server/repository/infrastructure/src/lib/gitlab/repositories/gitlab-pr.repository.ts @@ -1,5 +1,5 @@ import { HttpService, Injectable } from '@nestjs/common'; -import { PrEntity } from '@pimp-my-pr/server/repository/core/domain'; +import { PrEntity, PrState } from '@pimp-my-pr/server/repository/core/domain'; import { PrRepository } from '@pimp-my-pr/server/repository/core/domain-services'; import { gitlabConfig } from '@pimp-my-pr/server/shared/config'; import { catchRequestExceptions } from '@pimp-my-pr/server/shared/util-exception'; @@ -9,6 +9,8 @@ import { map, switchMap } from 'rxjs/operators'; import { GitlabPrEntity } from '../domain/entities/gitlab-pr.entity'; import { mapGitlabPr } from '../mappers/map-gitalb-pr'; import { GitlabPrDetailsEntity } from '../domain/entities/gitlab-pr-details.entity'; +import { urlWithQueryParams } from '@pimp-my-pr/shared/domain'; +import { GithubPrState } from '../../github/domain/enums/github-pr-status.enum'; @Injectable() export class GitlabPrRepository extends PrRepository { @@ -43,10 +45,25 @@ export class GitlabPrRepository extends PrRepository { .toPromise(); } - findByRepositoryId(repositoryId: string, token: string): Promise { + findByRepositoryId( + repositoryId: string, + token: string, + { prState = PrState.OPEN, page = 1, onPage = 50 } = { + prState: PrState.OPEN, + page: 1, + onPage: 50 + } + ): Promise { return this.httpService .get( - this.endpoints.getRepositoryPrs.url({ fullName: encodeURIComponent(repositoryId) }), + urlWithQueryParams( + this.endpoints.getRepositoryPrs.url({ fullName: encodeURIComponent(repositoryId) }), + { + page: page, + per_page: onPage, + state: GithubPrState[prState] + } + ), { headers: { Authorization: `Bearer ${token}` } } diff --git a/libs/server/repository/util/README.md b/libs/server/repository/util/README.md new file mode 100644 index 00000000..8f3c9183 --- /dev/null +++ b/libs/server/repository/util/README.md @@ -0,0 +1,7 @@ +# server-repository-timeline-util + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test server-repository-timeline-util` to execute the unit tests. diff --git a/libs/server/repository/util/jest.config.js b/libs/server/repository/util/jest.config.js new file mode 100644 index 00000000..19339328 --- /dev/null +++ b/libs/server/repository/util/jest.config.js @@ -0,0 +1,10 @@ +module.exports = { + name: 'server-repository-util', + preset: '../../../../jest.config.js', + coverageDirectory: '../../../../coverage/libs/server/repository/util', + snapshotSerializers: [ + 'jest-preset-angular/build/AngularNoNgAttributesSnapshotSerializer.js', + 'jest-preset-angular/build/AngularSnapshotSerializer.js', + 'jest-preset-angular/build/HTMLCommentSerializer.js' + ] +}; diff --git a/libs/server/repository/util/src/index.ts b/libs/server/repository/util/src/index.ts new file mode 100644 index 00000000..b6586ce5 --- /dev/null +++ b/libs/server/repository/util/src/index.ts @@ -0,0 +1,5 @@ +export * from './lib/traverse-pages-until.util'; +export * from './lib/date/get-steps-count.util'; +export * from './lib/date/decrease-date-by-timeline-step.util'; +export * from './lib/date/decrease-date-by-timeline-step.util'; +export * from './lib/date/get-step-beginning.util'; diff --git a/libs/server/repository/util/src/lib/date/decrease-date-by-timeline-step.util.spec.ts b/libs/server/repository/util/src/lib/date/decrease-date-by-timeline-step.util.spec.ts new file mode 100644 index 00000000..90432c0d --- /dev/null +++ b/libs/server/repository/util/src/lib/date/decrease-date-by-timeline-step.util.spec.ts @@ -0,0 +1,33 @@ +import { decreaseDateByTimelineStep } from './decrease-date-by-timeline-step.util'; + +describe('#decreaseDateByTimelineStep', () => { + it('should subtract n days', () => { + expect(decreaseDateByTimelineStep(new Date('1995-12-17T00:00:00'), 'day', 7)).toEqual( + new Date('1995-12-10T00:00:00') + ); + }); + it('should subtract n weeks', () => { + expect(decreaseDateByTimelineStep(new Date('1995-12-17T00:00:00'), 'week', 2)).toEqual( + new Date('1995-12-03T00:00:00') + ); + }); + it('should subtract n months', () => { + expect(decreaseDateByTimelineStep(new Date('1995-12-17T00:00:00'), 'month', 7)).toEqual( + new Date('1995-05-01T00:00:00') + ); + }); + it('should subtract n quarters', () => { + expect(decreaseDateByTimelineStep(new Date('1995-12-17T00:00:00'), 'quarter', 2)).toEqual( + new Date('1995-06-01T00:00:00') + ); + }); + it('should subtract n days', () => { + expect(decreaseDateByTimelineStep(new Date('2000-12-17T00:00:00'), 'year', 5)).toEqual( + new Date('1995-12-01T00:00:00') + ); + }); + it('should return new object', () => { + const date = new Date('1995-12-17T00:00:00'); + expect(decreaseDateByTimelineStep(date, 'day', 5)).not.toBe(date); + }); +}); diff --git a/libs/server/repository/util/src/lib/date/decrease-date-by-timeline-step.util.ts b/libs/server/repository/util/src/lib/date/decrease-date-by-timeline-step.util.ts new file mode 100644 index 00000000..65ad8e6a --- /dev/null +++ b/libs/server/repository/util/src/lib/date/decrease-date-by-timeline-step.util.ts @@ -0,0 +1,22 @@ +import { subtractDaysFromDate } from './subtract-days-from-date.util'; +import { subtractMonthsFromDate } from './subtract-months-from-date.util'; +import { TimelineStep } from '@pimp-my-pr/shared/domain'; + +export const decreaseDateByTimelineStep = ( + date: Date, + step: TimelineStep, + numSteps: number +): Date => { + switch (step) { + case TimelineStep.DAY: + return subtractDaysFromDate(date, numSteps); + case TimelineStep.WEEK: + return subtractDaysFromDate(date, 7 * numSteps); + case TimelineStep.MONTH: + return subtractMonthsFromDate(date, numSteps); + case TimelineStep.QUARTER: + return subtractMonthsFromDate(date, 3 * numSteps); + case TimelineStep.YEAR: + return subtractMonthsFromDate(date, 12 * numSteps); + } +}; diff --git a/libs/server/repository/util/src/lib/date/get-step-beginning.util.spec.ts b/libs/server/repository/util/src/lib/date/get-step-beginning.util.spec.ts new file mode 100644 index 00000000..ad942255 --- /dev/null +++ b/libs/server/repository/util/src/lib/date/get-step-beginning.util.spec.ts @@ -0,0 +1,39 @@ +import { getStepBeginning } from './get-step-beginning.util'; +import { TimelineStep } from '@pimp-my-pr/server/repository/core/domain'; + +describe('#getStepBeginning', () => { + it('should return day beginning', () => { + expect(getStepBeginning(new Date('1995-12-10T12:12:32'), TimelineStep.DAY)).toEqual( + new Date('1995-12-10T00:00:00') + ); + }); + + it('should return week beginning', () => { + expect(getStepBeginning(new Date('1995-12-06T12:12:32'), TimelineStep.WEEK)).toEqual( + new Date('1995-12-03T00:00:00') + ); + }); + + it('should return month beginning', () => { + expect(getStepBeginning(new Date('1995-12-10T12:12:32'), TimelineStep.MONTH)).toEqual( + new Date('1995-12-01T00:00:00') + ); + }); + + it('should return quarter beginning', () => { + expect(getStepBeginning(new Date('1995-12-10T12:12:32'), TimelineStep.QUARTER)).toEqual( + new Date('1995-09-01T00:00:00') + ); + }); + + it('should return year beginning', () => { + expect(getStepBeginning(new Date('1995-12-10T12:12:32'), TimelineStep.YEAR)).toEqual( + new Date('1995-01-01T00:00:00') + ); + }); + + it('should return new object', () => { + const date = new Date('1995-12-17T00:00:00'); + expect(getStepBeginning(date, TimelineStep.DAY)).not.toBe(date); + }); +}); diff --git a/libs/server/repository/util/src/lib/date/get-step-beginning.util.ts b/libs/server/repository/util/src/lib/date/get-step-beginning.util.ts new file mode 100644 index 00000000..84b3be91 --- /dev/null +++ b/libs/server/repository/util/src/lib/date/get-step-beginning.util.ts @@ -0,0 +1,22 @@ +import { subtractDaysFromDate } from './subtract-days-from-date.util'; +import { TimelineStep } from '@pimp-my-pr/shared/domain'; + +export const getStepBeginning = (date: Date, step: TimelineStep): Date => { + let dateNew = new Date(date); + switch (step) { + case TimelineStep.YEAR: + dateNew.setMonth(0, 1); + break; + case TimelineStep.QUARTER: + dateNew.setMonth(Math.floor(dateNew.getMonth() / 3) * 3 - 1, 1); + break; + case TimelineStep.MONTH: + dateNew.setDate(1); + break; + case TimelineStep.WEEK: + dateNew = subtractDaysFromDate(dateNew, dateNew.getDay()); + break; + } + dateNew.setHours(0, 0, 0, 0); + return dateNew; +}; diff --git a/libs/server/repository/util/src/lib/date/get-steps-count.util.spec.ts b/libs/server/repository/util/src/lib/date/get-steps-count.util.spec.ts new file mode 100644 index 00000000..1e91c1e4 --- /dev/null +++ b/libs/server/repository/util/src/lib/date/get-steps-count.util.spec.ts @@ -0,0 +1,14 @@ +import { getStepsCount } from './get-steps-count.util'; +import { TimelineStep } from '@pimp-my-pr/server/repository/core/domain'; + +describe('#getStepsCount', () => { + it('should return a correct number of steps', () => { + expect( + getStepsCount( + new Date('1995-12-02T12:12:32'), + new Date('1995-12-06T12:12:32'), + TimelineStep.DAY + ) + ).toBe(4); + }); +}); diff --git a/libs/server/repository/util/src/lib/date/get-steps-count.util.ts b/libs/server/repository/util/src/lib/date/get-steps-count.util.ts new file mode 100644 index 00000000..9e7d39e0 --- /dev/null +++ b/libs/server/repository/util/src/lib/date/get-steps-count.util.ts @@ -0,0 +1,14 @@ +import { getStepBeginning } from './get-step-beginning.util'; +import { decreaseDateByTimelineStep } from './decrease-date-by-timeline-step.util'; +import { TimelineStep } from '@pimp-my-pr/shared/domain'; + +export const getStepsCount = (dateFrom: Date, dateTo: Date, step: TimelineStep): number => { + let stepsCount = 0; + const fromStep = getStepBeginning(dateFrom, step); + let toStep = getStepBeginning(dateTo, step); + while (toStep.getTime() > fromStep.getTime()) { + toStep = decreaseDateByTimelineStep(toStep, step, 1); + stepsCount++; + } + return stepsCount ? stepsCount - 1 : 0; +}; diff --git a/libs/server/repository/util/src/lib/date/subtract-days-from-date.util.spec.ts b/libs/server/repository/util/src/lib/date/subtract-days-from-date.util.spec.ts new file mode 100644 index 00000000..2ffa9d13 --- /dev/null +++ b/libs/server/repository/util/src/lib/date/subtract-days-from-date.util.spec.ts @@ -0,0 +1,13 @@ +import { subtractDaysFromDate } from './subtract-days-from-date.util'; + +describe('#subtractDaysFromDate', () => { + it('should subtract n days', () => { + expect(subtractDaysFromDate(new Date('1995-12-17T00:00:00'), 7)).toEqual( + new Date('1995-12-10T00:00:00') + ); + }); + it('should return new object', () => { + const date = new Date('1995-12-17T00:00:00'); + expect(subtractDaysFromDate(date, 5)).not.toBe(date); + }); +}); diff --git a/libs/server/repository/util/src/lib/date/subtract-days-from-date.util.ts b/libs/server/repository/util/src/lib/date/subtract-days-from-date.util.ts new file mode 100644 index 00000000..9796958a --- /dev/null +++ b/libs/server/repository/util/src/lib/date/subtract-days-from-date.util.ts @@ -0,0 +1,4 @@ +export const subtractDaysFromDate = (date: Date, days: number): Date => { + const day = 24 * 60 * 60 * 1000; + return new Date(date.getTime() - day * days); +}; diff --git a/libs/server/repository/util/src/lib/date/subtract-months-from-date.util.spec.ts b/libs/server/repository/util/src/lib/date/subtract-months-from-date.util.spec.ts new file mode 100644 index 00000000..45c09757 --- /dev/null +++ b/libs/server/repository/util/src/lib/date/subtract-months-from-date.util.spec.ts @@ -0,0 +1,18 @@ +import { subtractMonthsFromDate } from './subtract-months-from-date.util'; + +describe('#subtractMonthsFromDate', () => { + it('should subtract n months if there is no year-switch necessary', () => { + expect(subtractMonthsFromDate(new Date('1995-12-17T00:00:00'), 5)).toEqual( + new Date('1995-07-01T00:00:00') + ); + }); + it('should subtract n months if there is an year-switch necessary', () => { + expect(subtractMonthsFromDate(new Date('1995-01-17T00:00:00'), 2)).toEqual( + new Date('1994-11-01T00:00:00') + ); + }); + it('should return new object', () => { + const date = new Date('1995-12-17T00:00:00'); + expect(subtractMonthsFromDate(date, 5)).not.toBe(date); + }); +}); diff --git a/libs/server/repository/util/src/lib/date/subtract-months-from-date.util.ts b/libs/server/repository/util/src/lib/date/subtract-months-from-date.util.ts new file mode 100644 index 00000000..77440e35 --- /dev/null +++ b/libs/server/repository/util/src/lib/date/subtract-months-from-date.util.ts @@ -0,0 +1,7 @@ +export const subtractMonthsFromDate = (date: Date, nofMonths: number): Date => { + const thisMonth = date.getMonth(); + const newDate = new Date(date); + newDate.setMonth(thisMonth - nofMonths); + newDate.setDate(1); + return newDate; +}; diff --git a/libs/server/repository/util/src/lib/pad-number-to-places.util.spec.ts b/libs/server/repository/util/src/lib/pad-number-to-places.util.spec.ts new file mode 100644 index 00000000..26d7035f --- /dev/null +++ b/libs/server/repository/util/src/lib/pad-number-to-places.util.spec.ts @@ -0,0 +1,11 @@ +import { padNumberToPlaces } from './pad-number-to-places.util'; + +describe('#padNumberToPlaces', () => { + it('should does not change input if it is longer than specified', () => { + expect(padNumberToPlaces(1000, 3)).toBe('1000'); + }); + + it('should add zeros at he beginning if the number is shorter', () => { + expect(padNumberToPlaces(56, 4)).toBe('0056'); + }); +}); diff --git a/libs/server/repository/util/src/lib/pad-number-to-places.util.ts b/libs/server/repository/util/src/lib/pad-number-to-places.util.ts new file mode 100644 index 00000000..e7c63121 --- /dev/null +++ b/libs/server/repository/util/src/lib/pad-number-to-places.util.ts @@ -0,0 +1,2 @@ +export const padNumberToPlaces = (num: number, places = 2): string => + String(num).padStart(places, '0'); diff --git a/libs/server/repository/util/src/lib/traverse-pages-until.util.ts b/libs/server/repository/util/src/lib/traverse-pages-until.util.ts new file mode 100644 index 00000000..97745529 --- /dev/null +++ b/libs/server/repository/util/src/lib/traverse-pages-until.util.ts @@ -0,0 +1,24 @@ +import { ITimeTrackable } from '@pimp-my-pr/server/repository/core/domain'; + +export type TraverseCallbackType = (page: number) => Promise; + +export async function traversePagesUntil( + callback: TraverseCallbackType, + onPage: number, + end: Date +): Promise { + let page = 1; + let result = []; + while (true) { + const response = await callback(page++); + const hasReachedLimit = response[response.length - 1].createdAt.getTime() < end.getTime(); + result = [ + ...result, + ...(hasReachedLimit + ? response.filter(entity => entity.createdAt.getTime() > end.getTime()) + : response) + ]; + if (response.length < onPage || hasReachedLimit) break; + } + return result; +} diff --git a/libs/server/repository/util/src/test-setup.ts b/libs/server/repository/util/src/test-setup.ts new file mode 100644 index 00000000..8d88704e --- /dev/null +++ b/libs/server/repository/util/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular'; diff --git a/libs/server/repository/util/tsconfig.json b/libs/server/repository/util/tsconfig.json new file mode 100644 index 00000000..a6233e13 --- /dev/null +++ b/libs/server/repository/util/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "types": ["node", "jest"] + }, + "include": ["**/*.ts"] +} diff --git a/libs/server/repository/util/tsconfig.lib.json b/libs/server/repository/util/tsconfig.lib.json new file mode 100644 index 00000000..290e7bc9 --- /dev/null +++ b/libs/server/repository/util/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "target": "es2015", + "declaration": true, + "inlineSources": true, + "types": [], + "lib": ["dom", "es2018"] + }, + "angularCompilerOptions": { + "skipTemplateCodegen": true, + "strictMetadataEmit": true, + "enableResourceInlining": true + }, + "exclude": ["src/test-setup.ts", "**/*.spec.ts"] +} diff --git a/libs/server/repository/util/tsconfig.spec.json b/libs/server/repository/util/tsconfig.spec.json new file mode 100644 index 00000000..aed68bc6 --- /dev/null +++ b/libs/server/repository/util/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": ["**/*.spec.ts", "**/*.d.ts"] +} diff --git a/libs/server/repository/util/tslint.json b/libs/server/repository/util/tslint.json new file mode 100644 index 00000000..b12cab6f --- /dev/null +++ b/libs/server/repository/util/tslint.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../../tslint.json", + "rules": { + "directive-selector": [true, "attribute", "pimpMyPr", "camelCase"], + "component-selector": [true, "element", "pimp-my-pr", "kebab-case"] + }, + "linterOptions": { + "exclude": ["!**/*"] + } +} diff --git a/libs/shared/domain/src/index.ts b/libs/shared/domain/src/index.ts index 9e04872a..f0ce553b 100644 --- a/libs/shared/domain/src/index.ts +++ b/libs/shared/domain/src/index.ts @@ -1,6 +1,7 @@ export * from './lib/enums/http-status-codes.enum'; export * from './lib/enums/platform.enum'; export * from './lib/enums/time-unit.enum'; +export * from './lib/enums/timeline-step.enum'; export * from './lib/interfaces/hal-link.interface'; export * from './lib/interfaces/hal-resource.interface'; export * from './lib/interfaces/login-success-payload'; @@ -20,3 +21,4 @@ export * from './lib/responses/login-success.response'; export * from './lib/responses/response.interface'; export * from './lib/responses/reviewer-statistics.response'; export * from './lib/responses/user-info.response'; +export * from './lib/util/url-with-query-params.util'; diff --git a/libs/shared/domain/src/lib/enums/timeline-step.enum.ts b/libs/shared/domain/src/lib/enums/timeline-step.enum.ts new file mode 100644 index 00000000..4958f108 --- /dev/null +++ b/libs/shared/domain/src/lib/enums/timeline-step.enum.ts @@ -0,0 +1,7 @@ +export enum TimelineStep { + DAY = 'day', + MONTH = 'month', + WEEK = 'week', + QUARTER = 'quarter', + YEAR = 'year' +} diff --git a/libs/shared/domain/src/lib/util/url-with-query-params.util.spec.ts b/libs/shared/domain/src/lib/util/url-with-query-params.util.spec.ts new file mode 100644 index 00000000..96e0a012 --- /dev/null +++ b/libs/shared/domain/src/lib/util/url-with-query-params.util.spec.ts @@ -0,0 +1,13 @@ +import { urlWithQueryParams } from '@pimp-my-pr/shared/domain'; + +describe('#urlWithQueryParams', () => { + it('should return correct url with params', () => { + expect( + urlWithQueryParams('url.com', { + a: 1, + b: 'test', + c: true + }) + ).toBe('url.com?a=1&b=test&c=true'); + }); +}); diff --git a/libs/shared/domain/src/lib/util/url-with-query-params.util.ts b/libs/shared/domain/src/lib/util/url-with-query-params.util.ts new file mode 100644 index 00000000..93768796 --- /dev/null +++ b/libs/shared/domain/src/lib/util/url-with-query-params.util.ts @@ -0,0 +1,11 @@ +export const urlWithQueryParams = ( + url: string, + queryParams: { [key: string]: number | string | boolean } +) => { + return Object.entries(queryParams).reduce((total, current, ind, arr) => { + if (ind === 0) total += '?'; + total += current.join('='); + if (ind !== arr.length - 1) total += '&'; + return total; + }, url); +}; diff --git a/nx.json b/nx.json index fc77bb76..326e5237 100644 --- a/nx.json +++ b/nx.json @@ -223,6 +223,9 @@ }, "pmp-web-shell": { "tags": ["platform:web", "scope:pmp-web", "type:shell"] + }, + "server-repository-util": { + "tags": ["scope:repository", "type:util", "platform:api"] } }, "tasksRunnerOptions": { diff --git a/tsconfig.json b/tsconfig.json index d0e2a6ce..735c86f5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -152,7 +152,8 @@ "@pimp-my-pr/pmp-web/shared/styles": ["libs/pmp-web/shared/styles/src/index.ts"], "@pimp-my-pr/pmp-web/shared/util-google-analytics": [ "libs/pmp-web/shared/util-google-analytics/src/index.ts" - ] + ], + "@pimp-my-pr/server/repository/util": ["libs/server/repository/util/src/index.ts"] } }, "exclude": ["node_modules", "tmp"]