1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-10-27 11:02:16 +01:00

feat: move release plans to feature environments (#10746)

This commit is contained in:
Mateusz Kwasniewski 2025-10-08 09:39:37 +02:00 committed by GitHub
parent 183d436e59
commit f2115cc3db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 334 additions and 0 deletions

View File

@ -66,6 +66,8 @@ import {
createFeatureLinkService,
} from '../feature-links/createFeatureLinkService.js';
import { ResourceLimitsService } from '../resource-limits/resource-limits-service.js';
import { ReleasePlanReadModel } from '../release-plans/release-plan-read-model.js';
import { FakeReleasePlanReadModel } from '../../../test/fixtures/fake/fake-release-plan-read-model.js';
export const createFeatureToggleService = (
db: Db,
@ -141,6 +143,8 @@ export const createFeatureToggleService = (
const resourceLimitsService = new ResourceLimitsService(config);
const releasePlanReadModel = new ReleasePlanReadModel(db);
const featureToggleService = new FeatureToggleService(
{
featureStrategiesStore,
@ -166,6 +170,7 @@ export const createFeatureToggleService = (
featureLinksReadModel,
featureLinkService,
resourceLimitsService,
releasePlanReadModel,
},
);
return featureToggleService;
@ -211,6 +216,7 @@ export const createFakeFeatureToggleService = (config: IUnleashConfig) => {
const { featureLinkService } = createFakeFeatureLinkService(config);
const resourceLimitsService = new ResourceLimitsService(config);
const releasePlanReadModel = new FakeReleasePlanReadModel();
const featureToggleService = new FeatureToggleService(
{
@ -241,6 +247,7 @@ export const createFakeFeatureToggleService = (config: IUnleashConfig) => {
featureLinksReadModel,
featureLinkService,
resourceLimitsService,
releasePlanReadModel,
},
);
return {

View File

@ -19,6 +19,7 @@ import {
type IAuditUser,
type IConstraint,
type IDependency,
type IEnvironmentDetail,
type IFeatureCollaboratorsReadModel,
type IFeatureEnvironmentInfo,
type IFeatureEnvironmentStore,
@ -117,6 +118,8 @@ import { sortStrategies } from '../../util/sortStrategies.js';
import type FeatureLinkService from '../feature-links/feature-link-service.js';
import type { IFeatureLink } from '../feature-links/feature-links-read-model-type.js';
import type { ResourceLimitsService } from '../resource-limits/resource-limits-service.js';
import type { IReleasePlanReadModel } from '../release-plans/release-plan-read-model-type.js';
import type { ReleasePlan } from '../release-plans/release-plan.js';
interface IFeatureContext {
featureName: string;
projectId: string;
@ -177,6 +180,7 @@ export type ServicesAndReadModels = {
featureLinkService: FeatureLinkService;
featureLinksReadModel: IFeatureLinksReadModel;
resourceLimitsService: ResourceLimitsService;
releasePlanReadModel: IReleasePlanReadModel;
};
export class FeatureToggleService {
@ -226,6 +230,8 @@ export class FeatureToggleService {
private resourceLimitsService: ResourceLimitsService;
private releasePlanReadModel: IReleasePlanReadModel;
constructor(
{
featureStrategiesStore,
@ -251,6 +257,7 @@ export class FeatureToggleService {
featureLinksReadModel,
featureLinkService,
resourceLimitsService,
releasePlanReadModel,
}: ServicesAndReadModels,
) {
this.logger = getLogger('services/feature-toggle-service.ts');
@ -276,6 +283,7 @@ export class FeatureToggleService {
this.featureLinkService = featureLinkService;
this.eventBus = eventBus;
this.resourceLimitsService = resourceLimitsService;
this.releasePlanReadModel = releasePlanReadModel;
}
async validateFeaturesContext(
@ -1151,8 +1159,16 @@ export class FeatureToggleService {
userId,
archived,
);
const environmentsWithReleasePlans =
await this.addReleasePlansToEnvironments(
featureName,
result.environments,
);
return {
...result,
environments: environmentsWithReleasePlans,
dependencies,
children,
lifecycle,
@ -1171,8 +1187,15 @@ export class FeatureToggleService {
archived,
);
const environmentsWithReleasePlans =
await this.addReleasePlansToEnvironments(
featureName,
result.environments,
);
return {
...result,
environments: environmentsWithReleasePlans,
dependencies,
children,
lifecycle,
@ -1182,6 +1205,27 @@ export class FeatureToggleService {
}
}
private async addReleasePlansToEnvironments(
featureName: string,
environments: IEnvironmentDetail[],
): Promise<(IEnvironmentDetail & { releasePlans?: ReleasePlan[] })[]> {
if (!this.flagResolver.isEnabled('milestoneProgression')) {
return environments;
}
const environmentNames = environments.map((env) => env.name);
const releasePlansByEnvironment =
await this.releasePlanReadModel.getReleasePlans(
featureName,
environmentNames,
);
return environments.map((env) => ({
...env,
releasePlans: releasePlansByEnvironment[env.name] || [],
}));
}
async getVariantsForEnv(
featureName: string,
environment: string,

View File

@ -0,0 +1,8 @@
import type { ReleasePlan } from './release-plan.js';
export interface IReleasePlanReadModel {
getReleasePlans(
featureName: string,
environments: string[],
): Promise<Record<string, ReleasePlan[]>>;
}

View File

@ -0,0 +1,184 @@
import type { Db } from '../../db/db.js';
import type { IReleasePlanReadModel } from './release-plan-read-model-type.js';
import type { ReleasePlan } from './release-plan.js';
const TABLE = 'release_plan_definitions';
const selectColumns = [
'rpd.id AS planId',
'rpd.discriminator AS planDiscriminator',
'rpd.name AS planName',
'rpd.description as planDescription',
'rpd.feature_name as planFeatureName',
'rpd.environment as planEnvironment',
'rpd.created_by_user_id as planCreatedByUserId',
'rpd.created_at as planCreatedAt',
'rpd.active_milestone_id as planActiveMilestoneId',
'rpd.release_plan_template_id as planTemplateId',
'mi.id AS milestoneId',
'mi.name AS milestoneName',
'mi.sort_order AS milestoneSortOrder',
'ms.id AS strategyId',
'ms.sort_order AS strategySortOrder',
'ms.title AS strategyTitle',
'ms.strategy_name AS strategyName',
'ms.parameters AS strategyParameters',
'ms.constraints AS strategyConstraints',
'ms.variants AS strategyVariants',
'mss.segment_id AS segmentId',
];
const processReleasePlanRows = (templateRows): ReleasePlan[] =>
templateRows.reduce(
(
acc: ReleasePlan[],
{
planId,
planDiscriminator,
planName,
planDescription,
planFeatureName,
planEnvironment,
planCreatedByUserId,
planCreatedAt,
planActiveMilestoneId,
planTemplateId,
milestoneId,
milestoneName,
milestoneSortOrder,
strategyId,
strategySortOrder,
strategyTitle,
strategyName,
strategyParameters,
strategyConstraints,
strategyVariants,
segmentId,
},
) => {
let plan = acc.find(({ id }) => id === planId);
if (!plan) {
plan = {
id: planId,
discriminator: planDiscriminator,
name: planName,
description: planDescription,
featureName: planFeatureName,
environment: planEnvironment,
createdByUserId: planCreatedByUserId,
createdAt: planCreatedAt,
activeMilestoneId: planActiveMilestoneId,
releasePlanTemplateId: planTemplateId,
milestones: [],
};
acc.push(plan);
}
if (!milestoneId) {
return acc;
}
let milestone = plan.milestones.find(
({ id }) => id === milestoneId,
);
if (!milestone) {
milestone = {
id: milestoneId,
name: milestoneName,
sortOrder: milestoneSortOrder,
strategies: [],
releasePlanDefinitionId: planId,
};
plan.milestones.push(milestone);
}
if (!strategyId) {
return acc;
}
let strategy = milestone.strategies?.find(
({ id }) => id === strategyId,
);
if (!strategy) {
strategy = {
id: strategyId,
milestoneId: milestoneId,
sortOrder: strategySortOrder,
title: strategyTitle,
strategyName: strategyName,
parameters: strategyParameters ?? {},
constraints: strategyConstraints,
variants: strategyVariants ?? [],
segments: [],
};
milestone.strategies = [
...(milestone.strategies || []),
strategy,
];
}
if (segmentId) {
strategy.segments = [...(strategy.segments || []), segmentId];
}
return acc;
},
[],
);
export class ReleasePlanReadModel implements IReleasePlanReadModel {
private db: Db;
constructor(db: Db) {
this.db = db;
}
async getReleasePlans(
featureName: string,
environments: string[],
): Promise<Record<string, ReleasePlan[]>> {
if (environments.length === 0) {
return {};
}
const planRows = await this.db(`${TABLE} AS rpd`)
.where('rpd.discriminator', 'plan')
.andWhere('rpd.feature_name', featureName)
.whereIn('rpd.environment', environments)
.leftJoin(
'milestones AS mi',
'mi.release_plan_definition_id',
'rpd.id',
)
.leftJoin('milestone_strategies AS ms', 'ms.milestone_id', 'mi.id')
.leftJoin(
'milestone_strategy_segments AS mss',
'mss.milestone_strategy_id',
'ms.id',
)
.orderBy('rpd.environment', 'asc')
.orderBy('mi.sort_order', 'asc')
.orderBy('ms.sort_order', 'asc')
.select(selectColumns);
const allPlans = processReleasePlanRows(planRows);
const plansByEnvironment: Record<string, ReleasePlan[]> = {};
for (const plan of allPlans) {
if (!plansByEnvironment[plan.environment]) {
plansByEnvironment[plan.environment] = [];
}
plansByEnvironment[plan.environment].push(plan);
}
for (const env of environments) {
if (!plansByEnvironment[env]) {
plansByEnvironment[env] = [];
}
}
return plansByEnvironment;
}
}

View File

@ -252,6 +252,10 @@ export class ReleasePlanStore extends CRUDStore<
}
return releasePlans[0];
}
// should be deprecated soon in favor of IReleasePlanReadModel.getReleasePlans
// this will remove read model responsibility from the store
// also we're moving release plans to feature environments (DB join) instead of separate API calls (browser join)
async getByFeatureFlagAndEnvironment(
featureName: string,
environment: string,

View File

@ -13,6 +13,11 @@ import { constraintSchema } from './constraint-schema.js';
import { tagTypeSchema } from './tag-type-schema.js';
import { strategyVariantSchema } from './strategy-variant-schema.js';
import { featureDependenciesSchema } from './feature-dependencies-schema.js';
import { releasePlanSchema } from './release-plan-schema.js';
import { releasePlanMilestoneSchema } from './release-plan-milestone-schema.js';
import { releasePlanMilestoneStrategySchema } from './release-plan-milestone-strategy-schema.js';
import { createFeatureStrategySchema } from './create-feature-strategy-schema.js';
import { createStrategyVariantSchema } from './create-strategy-variant-schema.js';
import { dependentFeatureSchema } from './dependent-feature-schema.js';
import { tagSchema } from './tag-schema.js';
import { featureLinksSchema } from './feature-links-schema.js';
@ -207,6 +212,11 @@ export const exportResultSchema = {
tagSchema,
featureLinksSchema,
featureLinkSchema,
releasePlanSchema,
releasePlanMilestoneSchema,
releasePlanMilestoneStrategySchema,
createFeatureStrategySchema,
createStrategyVariantSchema,
},
},
} as const;

View File

@ -4,6 +4,11 @@ import { parametersSchema } from './parameters-schema.js';
import { featureStrategySchema } from './feature-strategy-schema.js';
import { variantSchema } from './variant-schema.js';
import { strategyVariantSchema } from './strategy-variant-schema.js';
import { releasePlanSchema } from './release-plan-schema.js';
import { releasePlanMilestoneSchema } from './release-plan-milestone-schema.js';
import { releasePlanMilestoneStrategySchema } from './release-plan-milestone-strategy-schema.js';
import { createFeatureStrategySchema } from './create-feature-strategy-schema.js';
import { createStrategyVariantSchema } from './create-strategy-variant-schema.js';
export const featureEnvironmentSchema = {
$id: '#/components/schemas/featureEnvironmentSchema',
@ -106,6 +111,14 @@ export const featureEnvironmentSchema = {
description:
'Whether the feature has any enabled strategies defined.',
},
releasePlans: {
type: 'array',
description:
'Release plans for this feature environment (only available when milestoneProgression feature flag is enabled)',
items: {
$ref: '#/components/schemas/releasePlanSchema',
},
},
},
components: {
schemas: {
@ -114,6 +127,11 @@ export const featureEnvironmentSchema = {
featureStrategySchema,
strategyVariantSchema,
variantSchema,
releasePlanSchema,
releasePlanMilestoneSchema,
releasePlanMilestoneStrategySchema,
createFeatureStrategySchema,
createStrategyVariantSchema,
},
},
} as const;

View File

@ -7,6 +7,11 @@ import { featureStrategySchema } from './feature-strategy-schema.js';
import { tagSchema } from './tag-schema.js';
import { featureEnvironmentSchema } from './feature-environment-schema.js';
import { strategyVariantSchema } from './strategy-variant-schema.js';
import { releasePlanSchema } from './release-plan-schema.js';
import { releasePlanMilestoneSchema } from './release-plan-milestone-schema.js';
import { releasePlanMilestoneStrategySchema } from './release-plan-milestone-strategy-schema.js';
import { createFeatureStrategySchema } from './create-feature-strategy-schema.js';
import { createStrategyVariantSchema } from './create-strategy-variant-schema.js';
export const featureSchema = {
$id: '#/components/schemas/featureSchema',
@ -288,6 +293,11 @@ export const featureSchema = {
parametersSchema,
variantSchema,
tagSchema,
releasePlanSchema,
releasePlanMilestoneSchema,
releasePlanMilestoneStrategySchema,
createFeatureStrategySchema,
createStrategyVariantSchema,
},
},
} as const;

View File

@ -5,6 +5,11 @@ import { featureStrategySchema } from './feature-strategy-schema.js';
import { variantSchema } from './variant-schema.js';
import { strategyVariantSchema } from './strategy-variant-schema.js';
import { featureEnvironmentSchema } from './feature-environment-schema.js';
import { releasePlanSchema } from './release-plan-schema.js';
import { releasePlanMilestoneSchema } from './release-plan-milestone-schema.js';
import { releasePlanMilestoneStrategySchema } from './release-plan-milestone-strategy-schema.js';
import { createFeatureStrategySchema } from './create-feature-strategy-schema.js';
import { createStrategyVariantSchema } from './create-strategy-variant-schema.js';
export const featureSearchEnvironmentSchema = {
$id: '#/components/schemas/featureSearchEnvironmentSchema',
@ -37,6 +42,11 @@ export const featureSearchEnvironmentSchema = {
strategyVariantSchema,
featureEnvironmentSchema,
variantSchema,
releasePlanSchema,
releasePlanMilestoneSchema,
releasePlanMilestoneStrategySchema,
createFeatureStrategySchema,
createStrategyVariantSchema,
},
},
} as const;

View File

@ -10,6 +10,9 @@ import { featureEnvironmentSchema } from './feature-environment-schema.js';
import { projectStatsSchema } from './project-stats-schema.js';
import { createFeatureStrategySchema } from './create-feature-strategy-schema.js';
import { projectEnvironmentSchema } from './project-environment-schema.js';
import { releasePlanSchema } from './release-plan-schema.js';
import { releasePlanMilestoneSchema } from './release-plan-milestone-schema.js';
import { releasePlanMilestoneStrategySchema } from './release-plan-milestone-strategy-schema.js';
import { createStrategyVariantSchema } from './create-strategy-variant-schema.js';
import { strategyVariantSchema } from './strategy-variant-schema.js';
import { createFeatureNamingPatternSchema } from './create-feature-naming-pattern-schema.js';
@ -144,6 +147,9 @@ export const healthOverviewSchema = {
overrideSchema,
parametersSchema,
featureStrategySchema,
releasePlanSchema,
releasePlanMilestoneSchema,
releasePlanMilestoneStrategySchema,
strategyVariantSchema,
variantSchema,
projectStatsSchema,

View File

@ -13,6 +13,11 @@ import { parametersSchema } from './parameters-schema.js';
import { legalValueSchema } from './legal-value-schema.js';
import { tagTypeSchema } from './tag-type-schema.js';
import { featureEnvironmentSchema } from './feature-environment-schema.js';
import { releasePlanSchema } from './release-plan-schema.js';
import { releasePlanMilestoneSchema } from './release-plan-milestone-schema.js';
import { releasePlanMilestoneStrategySchema } from './release-plan-milestone-strategy-schema.js';
import { createFeatureStrategySchema } from './create-feature-strategy-schema.js';
import { createStrategyVariantSchema } from './create-strategy-variant-schema.js';
import { strategyVariantSchema } from './strategy-variant-schema.js';
import { featureDependenciesSchema } from './feature-dependencies-schema.js';
import { dependentFeatureSchema } from './dependent-feature-schema.js';
@ -53,6 +58,11 @@ export const importTogglesSchema = {
contextFieldSchema,
featureTagSchema,
segmentSchema,
releasePlanSchema,
releasePlanMilestoneSchema,
releasePlanMilestoneStrategySchema,
createFeatureStrategySchema,
createStrategyVariantSchema,
variantsSchema,
variantSchema,
overrideSchema,

View File

@ -15,6 +15,9 @@ import { strategyVariantSchema } from './strategy-variant-schema.js';
import { createFeatureNamingPatternSchema } from './create-feature-naming-pattern-schema.js';
import { featureTypeCountSchema } from './feature-type-count-schema.js';
import { projectLinkTemplateSchema } from './project-link-template-schema.js';
import { releasePlanSchema } from './release-plan-schema.js';
import { releasePlanMilestoneSchema } from './release-plan-milestone-schema.js';
import { releasePlanMilestoneStrategySchema } from './release-plan-milestone-strategy-schema.js';
export const projectOverviewSchema = {
$id: '#/components/schemas/projectOverviewSchema',
@ -202,6 +205,9 @@ export const projectOverviewSchema = {
featureStrategySchema,
strategyVariantSchema,
variantSchema,
releasePlanSchema,
releasePlanMilestoneSchema,
releasePlanMilestoneStrategySchema,
projectStatsSchema,
createFeatureNamingPatternSchema,
featureTypeCountSchema,

View File

@ -0,0 +1,17 @@
import type { IReleasePlanReadModel } from '../../../lib/features/release-plans/release-plan-read-model-type.js';
import type { ReleasePlan } from '../../../lib/features/release-plans/release-plan.js';
export class FakeReleasePlanReadModel implements IReleasePlanReadModel {
private releasePlans: Record<string, ReleasePlan[]>;
constructor(releasePlans: Record<string, ReleasePlan[]> = {}) {
this.releasePlans = releasePlans;
}
async getReleasePlans(
_featureName: string,
_environments: string[],
): Promise<Record<string, ReleasePlan[]>> {
return this.releasePlans;
}
}