Skip to content

Commit

Permalink
feat(api-repository): add backend for timeline statistics
Browse files Browse the repository at this point in the history
-add endpoint for acquiring statistics
-upgrade github/gitlab/bitbucket remote repositories
to handle pagination and state filters
-add util for processing prs into timeline staticstics,
currently the following fields are available:
{
    sumCount: all PR that were open in subperiod
    avgCount: avg count of PR that were open in subperiod
    avgWaitingTime: avg time taht prs were waiting since creation
}

resolve valueadd-poland#160 - backend
  • Loading branch information
maciejBart99 committed Sep 21, 2020
1 parent be06135 commit b7a8ee1
Show file tree
Hide file tree
Showing 49 changed files with 766 additions and 18 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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,
TimelineFacade
} from '@pimp-my-pr/server/repository/core/application-services';
import { PrTimelineReadModel } from '@pimp-my-pr/server/repository/core/application-services';
import { GenerateTimelineDto } from '../dtos/generate-timeline.dto';
import { decreaseDateByTimelineStep } from '@pimp-my-pr/server/repository/core/domain';

@ApiTags('timeline')
@UseGuards(AuthGuard)
@ApiBearerAuth()
@Controller('timeline')
export class TimelineController {
constructor(private timelineFacade: TimelineFacade) {}

@ApiOkResponse({ type: [PrTimelineReadModel] })
@UsePipes(
new ValidationPipe({ transform: true, transformOptions: { enableImplicitConversion: true } })
)
@Get('pr/:repositoryId')
generateTimeline(
@Credentials() credentials: RequestCredentials,
@Param('repositoryId') repositoryId: string,
@Query() query: GenerateTimelineDto
): Promise<PrTimelineReadModel> {
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, 'month', 3)
)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { TimelineStepType } from '@pimp-my-pr/server/repository/core/domain';
import { ApiProperty } from '@nestjs/swagger';
import { IsDate, IsIn } from 'class-validator';
import { Transform } from 'class-transformer';

const STEPS = ['day', 'week', 'month', 'quarter', 'year'];

export class GenerateTimelineDto {
@ApiProperty({ enum: STEPS })
@IsIn(STEPS)
step: TimelineStepType;

@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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
3 changes: 3 additions & 0 deletions libs/server/repository/core/application-services/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { QueryHandler, IQueryHandler } 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 {
getStepsCount,
getTimeLineHistory,
InvalidTimelineParametersException,
PrEntity,
traversePagesUntil
} 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';

@QueryHandler(GetPrTimelineQuery)
export class GetPrTimelineHandler implements IQueryHandler<GetPrTimelineQuery> {
constructor(
@Inject(prRepositoryFactoryToken)
private prRepositoryFactory: (platform: Platform) => PrRepository,
private repoRepository: RepositoryRepository
) {}

async execute(query: GetPrTimelineQuery): Promise<PrTimelineReadModel> {
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<PrEntity>(
async page => await prRepository.findByRepositoryId(fullName, token, 'all', page, 100),
100,
createdAfter
);
const prsSum = prs.reduce(
(total, current) =>
(!current.closedAt || current.closedAt.getTime() > timelineFrom.getTime()) &&
current.createdAt.getTime() < timelineTo.getTime()
? total + 1
: total,
0
);
const stepsCount = getStepsCount(timelineFrom, timelineTo, step);
if (stepsCount === 0) throw new InvalidTimelineParametersException('timelineFrom');

const records = getTimeLineHistory(prs, step, timelineTo, stepsCount);
const avgCount = records.reduce((total, current) => total + current.avgCount, 0) / prsSum;
const avgWaitingTime =
records.reduce((total, current) => total + current.avgWaitingTime, 0) / prsSum;
return prTimelineModelFactory(records, query, prsSum, avgCount, avgWaitingTime);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { IQuery } from '@nestjs/cqrs';
import { TimelineStepType } from '@pimp-my-pr/server/repository/core/domain';
import { Platform } from '@pimp-my-pr/shared/domain';

export class GetPrTimelineQuery implements IQuery {
constructor(
public step: TimelineStepType,
public timelineFrom: Date,
public timelineTo: Date,
public token: string,
public repositoryId: string,
public platform: Platform,
public createdAfter: Date
) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { TimelineRecord, TimelineStepType } from '@pimp-my-pr/server/repository/core/domain';
import { ApiProperty } from '@nestjs/swagger';

export class PrTimelineReadModel {
@ApiProperty({
enum: ['day', 'week', 'month', 'quarter', 'year']
})
step: TimelineStepType;

@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;

@ApiProperty()
avgCount: number;

@ApiProperty()
avgWaitingTime: number;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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,
sum: number,
avgCount: number,
avgWaitingTime: number
): PrTimelineReadModel => {
return {
data: prRecords,
step: query.step,
dateFrom: query.timelineFrom,
dateTo: query.timelineTo,
createdAfter: query.createdAfter,
totalPrs: sum,
avgCount: avgCount,
avgWaitingTime: avgWaitingTime
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {}
Original file line number Diff line number Diff line change
@@ -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<PrTimelineReadModel> {
return this.queryBus.execute(query);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { PrEntity } from '@pimp-my-pr/server/repository/core/domain';
import { PrStateType } from '@pimp-my-pr/server/repository/core/domain';

export const prRepositoryFactoryToken = Symbol('prRepositoryFactory');

export abstract class PrRepository {
abstract findByRepositoryId(repositoryId: string, token: string): Promise<PrEntity[]>;
abstract findByRepositoryId(
repositoryId: string,
token: string,
prState?: PrStateType,
page?: number,
onPage?: number
): Promise<PrEntity[]>;
}
8 changes: 8 additions & 0 deletions libs/server/repository/core/domain/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,12 @@ 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/utils/repository-name-extract.util';
export * from './lib/utils/timeline/get-timeline-history';
export * from './lib/utils/traverse-pages-until.util';
export * from './lib/utils/timeline/get-steps-count.util';
export * from './lib/utils/timeline/decrease-date-by-timeline-step.util';
export * from './lib/entities/timeline-record.entity';
export * from './lib/types/pr-state.type';
export * from './lib/types/timeline-step.type';
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';

export class TimelineRecord {
@ApiProperty()
dataFrom: Date;
@ApiProperty()
sumCount: number;
@ApiProperty()
avgCount: number;
@ApiProperty()
avgWaitingTime: number;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { CoreUnprocessableEntityException } from '@pimp-my-pr/server/shared/domain';

export class InvalidTimelineParametersException extends CoreUnprocessableEntityException {
constructor(paramName: string) {
super(`Invalid ${paramName}`);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface ITimeTrackable {
createdAt: Date;
closedAt?: Date;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface TimelineBucketItem<T> {
entity: T;
timeIn: number;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { TimelineBucketItem } from './timeline-bucket-item.interface';

export interface TimelineBuckets<T> {
[label: number]: TimelineBucketItem<T>[];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface TimelineDateRange {
dateFrom: Date;
dateTo: Date;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { TimelineDateRange } from './timeline-date-range.interface';

export interface TimelineDivisionBase {
[timestamp: number]: TimelineDateRange;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type PrStateType = 'open' | 'closed' | 'all';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type TimelineStepType = 'day' | 'month' | 'week' | 'quarter' | 'year';
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export function iteratorToArray<T>(a: Iterator<T>): T[] {
const result: T[] = [];
let current = a.next();
while (!current.done) {
result.push(current.value);
current = a.next();
}
return result;
}

export async function asyncIteratorToArray<T>(a: AsyncIterator<T>): Promise<T[]> {
const result: T[] = [];
let current = await a.next();
while (!current.done) {
result.push(current.value);
current = await a.next();
}
return result;
}
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const padNumberToPlaces = (num: number, places = 2): string =>
String(num).padStart(places, '0');
Loading

0 comments on commit b7a8ee1

Please sign in to comment.