diff --git a/src/lib/features/feature-search/feature-search-store.ts b/src/lib/features/feature-search/feature-search-store.ts index c27baa7d78..a0116acdb5 100644 --- a/src/lib/features/feature-search/feature-search-store.ts +++ b/src/lib/features/feature-search/feature-search-store.ts @@ -71,6 +71,7 @@ class FeatureSearchStore implements IFeatureSearchStore { hasEnabledStrategies: r.has_enabled_strategies, yes: Number(r.yes) || 0, no: Number(r.no) || 0, + changeRequestIds: r.change_request_ids ?? [], }; } @@ -326,6 +327,43 @@ class FeatureSearchStore implements IFeatureSearchStore { 'ranked_features.feature_name', 'lifecycle.stage_feature', ); + if (this.flagResolver.isEnabled('flagsOverviewSearch')) { + finalQuery + .leftJoin( + this.db('change_request_events AS cre') + .join( + 'change_requests AS cr', + 'cre.change_request_id', + 'cr.id', + ) + .select('cre.feature') + .select( + this.db.raw( + 'array_agg(distinct cre.change_request_id) AS change_request_ids', + ), + ) + .select('cr.environment') + .groupBy('cre.feature', 'cr.environment') + .whereNotIn('cr.state', [ + 'Applied', + 'Cancelled', + 'Rejected', + ]) + .as('feature_cr'), + function () { + this.on( + 'feature_cr.feature', + '=', + 'ranked_features.feature_name', + ).andOn( + 'feature_cr.environment', + '=', + 'ranked_features.environment', + ); + }, + ) + .select('feature_cr.change_request_ids'); + } this.queryExtraData(finalQuery); const rows = await finalQuery; stopTimer(); diff --git a/src/lib/features/feature-search/feature.search.e2e.test.ts b/src/lib/features/feature-search/feature.search.e2e.test.ts index 976c826c90..5fbf07f8ce 100644 --- a/src/lib/features/feature-search/feature.search.e2e.test.ts +++ b/src/lib/features/feature-search/feature.search.e2e.test.ts @@ -29,6 +29,7 @@ beforeAll(async () => { flags: { strictSchemaValidation: true, anonymiseEventLog: true, + flagsOverviewSearch: true, }, }, }, @@ -1286,3 +1287,98 @@ test('should return tags with color information from tag type', async () => { ], }); }); + +const createChangeRequest = async ({ + id, + feature, + environment, + state, +}: { id: number; feature: string; environment: string; state: string }) => { + await db + .rawDatabase('change_requests') + .insert({ id, environment, state, project: 'default', created_by: 1 }); + await db.rawDatabase('change_request_events').insert({ + id, + feature, + action: 'updateEnabled', + created_by: 1, + change_request_id: id, + }); +}; + +test('should return change request ids per environment', async () => { + await app.createFeature('my_feature_a'); + await app.createFeature('my_feature_b'); + + await createChangeRequest({ + id: 1, + feature: 'my_feature_a', + environment: 'production', + state: 'In review', + }); + await createChangeRequest({ + id: 2, + feature: 'my_feature_a', + environment: 'production', + state: 'Applied', + }); + await createChangeRequest({ + id: 3, + feature: 'my_feature_a', + environment: 'production', + state: 'Cancelled', + }); + await createChangeRequest({ + id: 4, + feature: 'my_feature_a', + environment: 'production', + state: 'Rejected', + }); + await createChangeRequest({ + id: 5, + feature: 'my_feature_a', + environment: 'development', + state: 'Draft', + }); + await createChangeRequest({ + id: 6, + feature: 'my_feature_a', + environment: 'development', + state: 'Scheduled', + }); + await createChangeRequest({ + id: 7, + feature: 'my_feature_a', + environment: 'development', + state: 'Approved', + }); + await createChangeRequest({ + id: 8, + feature: 'my_feature_b', + environment: 'development', + state: 'Approved', + }); + + const { body } = await searchFeatures({}); + + expect(body).toMatchObject({ + features: [ + { + name: 'my_feature_a', + environments: [ + { name: 'default', changeRequestIds: [] }, + { name: 'development', changeRequestIds: [5, 6, 7] }, + { name: 'production', changeRequestIds: [1] }, + ], + }, + { + name: 'my_feature_b', + environments: [ + { name: 'default', changeRequestIds: [] }, + { name: 'development', changeRequestIds: [8] }, + { name: 'production', changeRequestIds: [] }, + ], + }, + ], + }); +}); diff --git a/src/lib/openapi/spec/feature-environment-schema.ts b/src/lib/openapi/spec/feature-environment-schema.ts index eca0f95d3c..aac5d48fec 100644 --- a/src/lib/openapi/spec/feature-environment-schema.ts +++ b/src/lib/openapi/spec/feature-environment-schema.ts @@ -63,6 +63,14 @@ export const featureEnvironmentSchema = { }, description: 'A list of variants for the feature environment', }, + changeRequestIds: { + type: 'array', + items: { + type: 'number', + }, + description: + 'Experimental. A list of change request identifiers for actionable change requests that are not Cancelled, Rejected or Approved.', + }, lastSeenAt: { type: 'string', format: 'date-time', diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index 63701070ab..eb70978d3a 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -66,7 +66,8 @@ export type IFlagKey = | 'tagTypeColor' | 'globalChangeRequestConfig' | 'addEditStrategy' - | 'newStrategyDropdown'; + | 'newStrategyDropdown' + | 'flagsOverviewSearch'; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; @@ -319,6 +320,10 @@ const flags: IFlags = { process.env.UNLEASH_EXPERIMENTAL_NEW_STRATEGY_DROPDOWN, false, ), + flagsOverviewSearch: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_FLAGS_OVERVIEW_SEARCH, + false, + ), }; export const defaultExperimentalOptions: IExperimentalOptions = {