mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feat: read change requests for insights (#6651)
This commit is contained in:
		
							parent
							
								
									fb88048acf
								
							
						
					
					
						commit
						8080a1d907
					
				@ -12,6 +12,8 @@ import FeatureTypeStore from '../../db/feature-type-store';
 | 
				
			|||||||
import FakeFeatureTypeStore from '../../../test/fixtures/fake-feature-type-store';
 | 
					import FakeFeatureTypeStore from '../../../test/fixtures/fake-feature-type-store';
 | 
				
			||||||
import { ProjectInsightsService } from './project-insights-service';
 | 
					import { ProjectInsightsService } from './project-insights-service';
 | 
				
			||||||
import ProjectStore from '../project/project-store';
 | 
					import ProjectStore from '../project/project-store';
 | 
				
			||||||
 | 
					import { ProjectInsightsReadModel } from './project-insights-read-model';
 | 
				
			||||||
 | 
					import { FakeProjectInsightsReadModel } from './fake-project-insights-read-model';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const createProjectInsightsService = (
 | 
					export const createProjectInsightsService = (
 | 
				
			||||||
    db: Db,
 | 
					    db: Db,
 | 
				
			||||||
@ -34,6 +36,7 @@ export const createProjectInsightsService = (
 | 
				
			|||||||
    const featureTypeStore = new FeatureTypeStore(db, getLogger);
 | 
					    const featureTypeStore = new FeatureTypeStore(db, getLogger);
 | 
				
			||||||
    const projectStatsStore = new ProjectStatsStore(db, eventBus, getLogger);
 | 
					    const projectStatsStore = new ProjectStatsStore(db, eventBus, getLogger);
 | 
				
			||||||
    const featureToggleService = createFeatureToggleService(db, config);
 | 
					    const featureToggleService = createFeatureToggleService(db, config);
 | 
				
			||||||
 | 
					    const projectInsightsReadModel = new ProjectInsightsReadModel(db);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return new ProjectInsightsService(
 | 
					    return new ProjectInsightsService(
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
@ -43,6 +46,7 @@ export const createProjectInsightsService = (
 | 
				
			|||||||
            projectStatsStore,
 | 
					            projectStatsStore,
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        featureToggleService,
 | 
					        featureToggleService,
 | 
				
			||||||
 | 
					        projectInsightsReadModel,
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -54,6 +58,7 @@ export const createFakeProjectInsightsService = (
 | 
				
			|||||||
    const featureTypeStore = new FakeFeatureTypeStore();
 | 
					    const featureTypeStore = new FakeFeatureTypeStore();
 | 
				
			||||||
    const projectStatsStore = new FakeProjectStatsStore();
 | 
					    const projectStatsStore = new FakeProjectStatsStore();
 | 
				
			||||||
    const featureToggleService = createFakeFeatureToggleService(config);
 | 
					    const featureToggleService = createFakeFeatureToggleService(config);
 | 
				
			||||||
 | 
					    const projectInsightsReadModel = new FakeProjectInsightsReadModel();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return new ProjectInsightsService(
 | 
					    return new ProjectInsightsService(
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
@ -63,5 +68,6 @@ export const createFakeProjectInsightsService = (
 | 
				
			|||||||
            projectStatsStore,
 | 
					            projectStatsStore,
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        featureToggleService,
 | 
					        featureToggleService,
 | 
				
			||||||
 | 
					        projectInsightsReadModel,
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
@ -0,0 +1,19 @@
 | 
				
			|||||||
 | 
					import type {
 | 
				
			||||||
 | 
					    ChangeRequestCounts,
 | 
				
			||||||
 | 
					    IProjectInsightsReadModel,
 | 
				
			||||||
 | 
					} from './project-insights-read-model-type';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const changeRequestCounts: ChangeRequestCounts = {
 | 
				
			||||||
 | 
					    total: 0,
 | 
				
			||||||
 | 
					    approved: 0,
 | 
				
			||||||
 | 
					    applied: 0,
 | 
				
			||||||
 | 
					    rejected: 0,
 | 
				
			||||||
 | 
					    reviewRequired: 10,
 | 
				
			||||||
 | 
					    scheduled: 0,
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export class FakeProjectInsightsReadModel implements IProjectInsightsReadModel {
 | 
				
			||||||
 | 
					    async getChangeRequests(projectId: string): Promise<ChangeRequestCounts> {
 | 
				
			||||||
 | 
					        return changeRequestCounts;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -0,0 +1,12 @@
 | 
				
			|||||||
 | 
					export type ChangeRequestCounts = {
 | 
				
			||||||
 | 
					    total: number;
 | 
				
			||||||
 | 
					    approved: number;
 | 
				
			||||||
 | 
					    applied: number;
 | 
				
			||||||
 | 
					    rejected: number;
 | 
				
			||||||
 | 
					    reviewRequired: number;
 | 
				
			||||||
 | 
					    scheduled: number;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface IProjectInsightsReadModel {
 | 
				
			||||||
 | 
					    getChangeRequests(projectId: string): Promise<ChangeRequestCounts>;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -0,0 +1,59 @@
 | 
				
			|||||||
 | 
					import type {
 | 
				
			||||||
 | 
					    ChangeRequestCounts,
 | 
				
			||||||
 | 
					    IProjectInsightsReadModel,
 | 
				
			||||||
 | 
					} from './project-insights-read-model-type';
 | 
				
			||||||
 | 
					import type { Db } from '../../db/db';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type ChangeRequestDBState =
 | 
				
			||||||
 | 
					    | 'Approved'
 | 
				
			||||||
 | 
					    | 'In review'
 | 
				
			||||||
 | 
					    | 'Applied'
 | 
				
			||||||
 | 
					    | 'Scheduled'
 | 
				
			||||||
 | 
					    | 'Rejected';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export class ProjectInsightsReadModel implements IProjectInsightsReadModel {
 | 
				
			||||||
 | 
					    private db: Db;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    constructor(db: Db) {
 | 
				
			||||||
 | 
					        this.db = db;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    async getChangeRequests(projectId: string): Promise<ChangeRequestCounts> {
 | 
				
			||||||
 | 
					        const changeRequestCounts: ChangeRequestCounts = {
 | 
				
			||||||
 | 
					            total: 0,
 | 
				
			||||||
 | 
					            approved: 0,
 | 
				
			||||||
 | 
					            applied: 0,
 | 
				
			||||||
 | 
					            rejected: 0,
 | 
				
			||||||
 | 
					            reviewRequired: 0,
 | 
				
			||||||
 | 
					            scheduled: 0,
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const rows: Array<{ state: ChangeRequestDBState; count: string }> =
 | 
				
			||||||
 | 
					            await this.db('change_requests')
 | 
				
			||||||
 | 
					                .select('state')
 | 
				
			||||||
 | 
					                .count('* as count')
 | 
				
			||||||
 | 
					                .where('project', '=', projectId)
 | 
				
			||||||
 | 
					                .groupBy('state');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return rows.reduce((acc, current) => {
 | 
				
			||||||
 | 
					            if (current.state === 'Applied') {
 | 
				
			||||||
 | 
					                acc.applied = Number(current.count);
 | 
				
			||||||
 | 
					                acc.total += Number(current.count);
 | 
				
			||||||
 | 
					            } else if (current.state === 'Approved') {
 | 
				
			||||||
 | 
					                acc.approved = Number(current.count);
 | 
				
			||||||
 | 
					                acc.total += Number(current.count);
 | 
				
			||||||
 | 
					            } else if (current.state === 'Rejected') {
 | 
				
			||||||
 | 
					                acc.rejected = Number(current.count);
 | 
				
			||||||
 | 
					                acc.total += Number(current.count);
 | 
				
			||||||
 | 
					            } else if (current.state === 'In review') {
 | 
				
			||||||
 | 
					                acc.reviewRequired = Number(current.count);
 | 
				
			||||||
 | 
					                acc.total += Number(current.count);
 | 
				
			||||||
 | 
					            } else if (current.state === 'Scheduled') {
 | 
				
			||||||
 | 
					                acc.scheduled = Number(current.count);
 | 
				
			||||||
 | 
					                acc.total += Number(current.count);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            return acc;
 | 
				
			||||||
 | 
					        }, changeRequestCounts);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -10,6 +10,7 @@ import { calculateAverageTimeToProd } from '../feature-toggle/time-to-production
 | 
				
			|||||||
import type { IProjectStatsStore } from '../../types/stores/project-stats-store-type';
 | 
					import type { IProjectStatsStore } from '../../types/stores/project-stats-store-type';
 | 
				
			||||||
import type { ProjectDoraMetricsSchema } from '../../openapi';
 | 
					import type { ProjectDoraMetricsSchema } from '../../openapi';
 | 
				
			||||||
import { calculateProjectHealth } from '../../domain/project-health/project-health';
 | 
					import { calculateProjectHealth } from '../../domain/project-health/project-health';
 | 
				
			||||||
 | 
					import type { IProjectInsightsReadModel } from './project-insights-read-model-type';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export class ProjectInsightsService {
 | 
					export class ProjectInsightsService {
 | 
				
			||||||
    private projectStore: IProjectStore;
 | 
					    private projectStore: IProjectStore;
 | 
				
			||||||
@ -22,6 +23,8 @@ export class ProjectInsightsService {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    private projectStatsStore: IProjectStatsStore;
 | 
					    private projectStatsStore: IProjectStatsStore;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private projectInsightsReadModel: IProjectInsightsReadModel;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    constructor(
 | 
					    constructor(
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            projectStore,
 | 
					            projectStore,
 | 
				
			||||||
@ -36,12 +39,14 @@ export class ProjectInsightsService {
 | 
				
			|||||||
            | 'featureTypeStore'
 | 
					            | 'featureTypeStore'
 | 
				
			||||||
        >,
 | 
					        >,
 | 
				
			||||||
        featureToggleService: FeatureToggleService,
 | 
					        featureToggleService: FeatureToggleService,
 | 
				
			||||||
 | 
					        projectInsightsReadModel: IProjectInsightsReadModel,
 | 
				
			||||||
    ) {
 | 
					    ) {
 | 
				
			||||||
        this.projectStore = projectStore;
 | 
					        this.projectStore = projectStore;
 | 
				
			||||||
        this.featureToggleStore = featureToggleStore;
 | 
					        this.featureToggleStore = featureToggleStore;
 | 
				
			||||||
        this.featureTypeStore = featureTypeStore;
 | 
					        this.featureTypeStore = featureTypeStore;
 | 
				
			||||||
        this.featureToggleService = featureToggleService;
 | 
					        this.featureToggleService = featureToggleService;
 | 
				
			||||||
        this.projectStatsStore = projectStatsStore;
 | 
					        this.projectStatsStore = projectStatsStore;
 | 
				
			||||||
 | 
					        this.projectInsightsReadModel = projectInsightsReadModel;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private async getDoraMetrics(
 | 
					    private async getDoraMetrics(
 | 
				
			||||||
@ -103,27 +108,28 @@ export class ProjectInsightsService {
 | 
				
			|||||||
                inactive: 3,
 | 
					                inactive: 3,
 | 
				
			||||||
                totalPreviousMonth: 15,
 | 
					                totalPreviousMonth: 15,
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
            changeRequests: {
 | 
					 | 
				
			||||||
                total: 24,
 | 
					 | 
				
			||||||
                approved: 5,
 | 
					 | 
				
			||||||
                applied: 2,
 | 
					 | 
				
			||||||
                rejected: 4,
 | 
					 | 
				
			||||||
                reviewRequired: 10,
 | 
					 | 
				
			||||||
                scheduled: 3,
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        const [stats, featureTypeCounts, health, leadTime] = await Promise.all([
 | 
					        const [stats, featureTypeCounts, health, leadTime, changeRequests] =
 | 
				
			||||||
            this.projectStatsStore.getProjectStats(projectId),
 | 
					            await Promise.all([
 | 
				
			||||||
            this.featureToggleService.getFeatureTypeCounts({
 | 
					                this.projectStatsStore.getProjectStats(projectId),
 | 
				
			||||||
                projectId,
 | 
					                this.featureToggleService.getFeatureTypeCounts({
 | 
				
			||||||
                archived: false,
 | 
					                    projectId,
 | 
				
			||||||
            }),
 | 
					                    archived: false,
 | 
				
			||||||
            this.getHealthInsights(projectId),
 | 
					                }),
 | 
				
			||||||
            this.getDoraMetrics(projectId),
 | 
					                this.getHealthInsights(projectId),
 | 
				
			||||||
        ]);
 | 
					                this.getDoraMetrics(projectId),
 | 
				
			||||||
 | 
					                this.projectInsightsReadModel.getChangeRequests(projectId),
 | 
				
			||||||
 | 
					            ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return { ...result, stats, featureTypeCounts, health, leadTime };
 | 
					        return {
 | 
				
			||||||
 | 
					            ...result,
 | 
				
			||||||
 | 
					            stats,
 | 
				
			||||||
 | 
					            featureTypeCounts,
 | 
				
			||||||
 | 
					            health,
 | 
				
			||||||
 | 
					            leadTime,
 | 
				
			||||||
 | 
					            changeRequests,
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private async getProjectHealth(
 | 
					    private async getProjectHealth(
 | 
				
			||||||
 | 
				
			|||||||
@ -34,6 +34,8 @@ test('project insights happy path', async () => {
 | 
				
			|||||||
        .expect('Content-Type', /json/)
 | 
					        .expect('Content-Type', /json/)
 | 
				
			||||||
        .expect(200);
 | 
					        .expect(200);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    console.log(body);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    expect(body).toMatchObject({
 | 
					    expect(body).toMatchObject({
 | 
				
			||||||
        stats: {
 | 
					        stats: {
 | 
				
			||||||
            avgTimeToProdCurrentWindow: 0,
 | 
					            avgTimeToProdCurrentWindow: 0,
 | 
				
			||||||
@ -53,5 +55,13 @@ test('project insights happy path', async () => {
 | 
				
			|||||||
            staleCount: 0,
 | 
					            staleCount: 0,
 | 
				
			||||||
            rating: 100,
 | 
					            rating: 100,
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
 | 
					        changeRequests: {
 | 
				
			||||||
 | 
					            total: 0,
 | 
				
			||||||
 | 
					            approved: 0,
 | 
				
			||||||
 | 
					            applied: 0,
 | 
				
			||||||
 | 
					            rejected: 0,
 | 
				
			||||||
 | 
					            reviewRequired: 0,
 | 
				
			||||||
 | 
					            scheduled: 0,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
				
			|||||||
@ -76,7 +76,6 @@ import type {
 | 
				
			|||||||
    IProjectEnterpriseSettingsUpdate,
 | 
					    IProjectEnterpriseSettingsUpdate,
 | 
				
			||||||
    IProjectQuery,
 | 
					    IProjectQuery,
 | 
				
			||||||
} from './project-store-type';
 | 
					} from './project-store-type';
 | 
				
			||||||
import { calculateProjectHealth } from '../../domain/project-health/project-health';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getCreatedBy = (user: IUser) => user.email || user.username || 'unknown';
 | 
					const getCreatedBy = (user: IUser) => user.email || user.username || 'unknown';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -1240,62 +1239,6 @@ export default class ProjectService {
 | 
				
			|||||||
        };
 | 
					        };
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private async getHealthInsights(projectId: string) {
 | 
					 | 
				
			||||||
        const [overview, featureTypes] = await Promise.all([
 | 
					 | 
				
			||||||
            this.getProjectHealth(projectId, false, undefined),
 | 
					 | 
				
			||||||
            this.featureTypeStore.getAll(),
 | 
					 | 
				
			||||||
        ]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        const { activeCount, potentiallyStaleCount, staleCount } =
 | 
					 | 
				
			||||||
            calculateProjectHealth(overview.features, featureTypes);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return {
 | 
					 | 
				
			||||||
            activeCount,
 | 
					 | 
				
			||||||
            potentiallyStaleCount,
 | 
					 | 
				
			||||||
            staleCount,
 | 
					 | 
				
			||||||
            rating: overview.health,
 | 
					 | 
				
			||||||
        };
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    async getProjectInsights(projectId: string) {
 | 
					 | 
				
			||||||
        const result = {
 | 
					 | 
				
			||||||
            leadTime: {
 | 
					 | 
				
			||||||
                projectAverage: 17.1,
 | 
					 | 
				
			||||||
                features: [
 | 
					 | 
				
			||||||
                    { name: 'feature1', timeToProduction: 120 },
 | 
					 | 
				
			||||||
                    { name: 'feature2', timeToProduction: 0 },
 | 
					 | 
				
			||||||
                    { name: 'feature3', timeToProduction: 33 },
 | 
					 | 
				
			||||||
                    { name: 'feature4', timeToProduction: 131 },
 | 
					 | 
				
			||||||
                    { name: 'feature5', timeToProduction: 2 },
 | 
					 | 
				
			||||||
                ],
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
            members: {
 | 
					 | 
				
			||||||
                active: 20,
 | 
					 | 
				
			||||||
                inactive: 3,
 | 
					 | 
				
			||||||
                totalPreviousMonth: 15,
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
            changeRequests: {
 | 
					 | 
				
			||||||
                total: 24,
 | 
					 | 
				
			||||||
                approved: 5,
 | 
					 | 
				
			||||||
                applied: 2,
 | 
					 | 
				
			||||||
                rejected: 4,
 | 
					 | 
				
			||||||
                reviewRequired: 10,
 | 
					 | 
				
			||||||
                scheduled: 3,
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
        };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        const [stats, featureTypeCounts, health] = await Promise.all([
 | 
					 | 
				
			||||||
            this.projectStatsStore.getProjectStats(projectId),
 | 
					 | 
				
			||||||
            this.featureToggleService.getFeatureTypeCounts({
 | 
					 | 
				
			||||||
                projectId,
 | 
					 | 
				
			||||||
                archived: false,
 | 
					 | 
				
			||||||
            }),
 | 
					 | 
				
			||||||
            this.getHealthInsights(projectId),
 | 
					 | 
				
			||||||
        ]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return { ...result, stats, featureTypeCounts, health };
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    async getProjectHealth(
 | 
					    async getProjectHealth(
 | 
				
			||||||
        projectId: string,
 | 
					        projectId: string,
 | 
				
			||||||
        archived: boolean = false,
 | 
					        archived: boolean = false,
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user