1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

feat: expose lifecycle stage in project overview search (#7017)

This commit is contained in:
Mateusz Kwasniewski 2024-05-09 10:50:51 +02:00 committed by GitHub
parent 28a7797aea
commit 97d702afeb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 102 additions and 5 deletions

View File

@ -44,6 +44,7 @@ import { InactiveUsersStore } from '../users/inactive/inactive-users-store';
import { TrafficDataUsageStore } from '../features/traffic-data-usage/traffic-data-usage-store';
import { SegmentReadModel } from '../features/segment/segment-read-model';
import { ProjectOwnersReadModel } from '../features/project/project-owners-read-model';
import { FeatureLifecycleStore } from '../features/feature-lifecycle/feature-lifecycle-store';
export const createStores = (
config: IUnleashConfig,
@ -145,11 +146,17 @@ export const createStores = (
privateProjectStore: new PrivateProjectStore(db, getLogger),
dependentFeaturesStore: new DependentFeaturesStore(db),
lastSeenStore: new LastSeenStore(db, eventBus, getLogger),
featureSearchStore: new FeatureSearchStore(db, eventBus, getLogger),
featureSearchStore: new FeatureSearchStore(
db,
eventBus,
getLogger,
config.flagResolver,
),
inactiveUsersStore: new InactiveUsersStore(db, eventBus, getLogger),
trafficDataUsageStore: new TrafficDataUsageStore(db, getLogger),
segmentReadModel: new SegmentReadModel(db),
projectOwnersReadModel: new ProjectOwnersReadModel(db),
featureLifecycleStore: new FeatureLifecycleStore(db),
};
};

View File

@ -13,6 +13,7 @@ export const createFeatureSearchService =
db,
eventBus,
getLogger,
flagResolver,
);
return new FeatureSearchService(

View File

@ -7,6 +7,7 @@ import type {
IFeatureOverview,
IFeatureSearchOverview,
IFeatureSearchStore,
IFlagResolver,
ITag,
} from '../../types';
import FeatureToggleStore from '../feature-toggle/feature-toggle-store';
@ -40,9 +41,17 @@ class FeatureSearchStore implements IFeatureSearchStore {
private readonly timer: Function;
constructor(db: Db, eventBus: EventEmitter, getLogger: LogProvider) {
private flagResolver: IFlagResolver;
constructor(
db: Db,
eventBus: EventEmitter,
getLogger: LogProvider,
flagResolver: IFlagResolver,
) {
this.db = db;
this.logger = getLogger('feature-search-store.ts');
this.flagResolver = flagResolver;
this.timer = (action) =>
metricsHelper.wrapTimer(eventBus, DB_TIME, {
store: 'feature-search',
@ -65,6 +74,20 @@ class FeatureSearchStore implements IFeatureSearchStore {
};
}
private getLatestLifecycleStageQuery() {
return this.db('feature_lifecycles')
.select(
'feature as stage_feature',
'stage as latest_stage',
'created_at as entered_stage_at',
)
.distinctOn('stage_feature')
.orderBy([
'stage_feature',
{ column: 'entered_stage_at', order: 'desc' },
]);
}
async searchFeatures(
{
userId,
@ -86,6 +109,9 @@ class FeatureSearchStore implements IFeatureSearchStore {
const validatedSortOrder =
sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc';
const featureLifecycleEnabled =
this.flagResolver.isEnabled('featureLifecycle');
const finalQuery = this.db
.with('ranked_features', (query) => {
query.from('features');
@ -235,6 +261,7 @@ class FeatureSearchStore implements IFeatureSearchStore {
.select(selectColumns)
.denseRank('rank', this.db.raw(rankingSql));
})
.with('lifecycle', this.getLatestLifecycleStageQuery())
.with(
'final_ranks',
this.db.raw(
@ -290,10 +317,20 @@ class FeatureSearchStore implements IFeatureSearchStore {
.joinRaw('CROSS JOIN total_features')
.whereBetween('final_rank', [offset + 1, offset + limit])
.orderBy('final_rank');
if (featureLifecycleEnabled) {
finalQuery.leftJoin(
'lifecycle',
'ranked_features.feature_name',
'lifecycle.stage_feature',
);
}
const rows = await finalQuery;
stopTimer();
if (rows.length > 0) {
const overview = this.getAggregatedSearchData(rows);
const overview = this.getAggregatedSearchData(
rows,
featureLifecycleEnabled,
);
const features = sortEnvironments(overview);
return {
features,
@ -349,7 +386,10 @@ class FeatureSearchStore implements IFeatureSearchStore {
return rankingSql;
}
getAggregatedSearchData(rows): IFeatureSearchOverview[] {
getAggregatedSearchData(
rows,
featureLifecycleEnabled: boolean,
): IFeatureSearchOverview[] {
const entriesMap: Map<string, IFeatureSearchOverview> = new Map();
const orderedEntries: IFeatureSearchOverview[] = [];
@ -372,6 +412,14 @@ class FeatureSearchStore implements IFeatureSearchStore {
environments: [],
segments: row.segment_name ? [row.segment_name] : [],
};
if (featureLifecycleEnabled) {
entry.lifecycle = row.latest_stage
? {
stage: row.latest_stage,
enteredStageAt: row.entered_stage_at,
}
: undefined;
}
entriesMap.set(row.feature_name, entry);
orderedEntries.push(entry);
}

View File

@ -21,6 +21,7 @@ beforeAll(async () => {
experimental: {
flags: {
strictSchemaValidation: true,
featureLifecycle: true,
},
},
},
@ -924,7 +925,7 @@ test('should filter features by combined operators', async () => {
});
});
test('should return environment usage metrics', async () => {
test('should return environment usage metrics and lifecycle', async () => {
await app.createFeature({
name: 'my_feature_b',
createdAt: '2023-01-29T15:21:39.975Z',
@ -957,6 +958,13 @@ test('should return environment usage metrics', async () => {
},
]);
await stores.featureLifecycleStore.insert([
{ feature: 'my_feature_b', stage: 'initial' },
]);
await stores.featureLifecycleStore.insert([
{ feature: 'my_feature_b', stage: 'pre-live' },
]);
const { body } = await searchFeatures({
query: 'my_feature_b',
});
@ -964,6 +972,7 @@ test('should return environment usage metrics', async () => {
features: [
{
name: 'my_feature_b',
lifecycle: { stage: 'pre-live' },
environments: [
{
name: 'default',

View File

@ -143,6 +143,32 @@ export const featureSearchResponseSchema = {
nullable: true,
description: 'The list of feature tags',
},
lifecycle: {
type: 'object',
description: 'Current lifecycle stage of the feature',
additionalProperties: false,
required: ['stage', 'enteredStageAt'],
properties: {
stage: {
description: 'The name of the current lifecycle stage',
type: 'string',
enum: [
'initial',
'pre-live',
'live',
'completed',
'archived',
],
example: 'initial',
},
enteredStageAt: {
description: 'When the feature entered this stage',
type: 'string',
format: 'date-time',
example: '2023-01-28T15:21:39.975Z',
},
},
},
},
components: {
schemas: {

View File

@ -237,6 +237,7 @@ export interface IFeatureOverview {
createdAt: Date;
lastSeenAt: Date;
environments: IEnvironmentOverview[];
lifecycle?: IFeatureLifecycleStage;
}
export type IFeatureSearchOverview = Exclude<

View File

@ -41,6 +41,7 @@ import type { IInactiveUsersStore } from '../users/inactive/types/inactive-users
import { ITrafficDataUsageStore } from '../features/traffic-data-usage/traffic-data-usage-store-type';
import { ISegmentReadModel } from '../features/segment/segment-read-model-type';
import { IProjectOwnersReadModel } from '../features/project/project-owners-read-model.type';
import { IFeatureLifecycleStore } from '../features/feature-lifecycle/feature-lifecycle-store-type';
export interface IUnleashStores {
accessStore: IAccessStore;
@ -86,6 +87,7 @@ export interface IUnleashStores {
trafficDataUsageStore: ITrafficDataUsageStore;
segmentReadModel: ISegmentReadModel;
projectOwnersReadModel: IProjectOwnersReadModel;
featureLifecycleStore: IFeatureLifecycleStore;
}
export {
@ -130,4 +132,5 @@ export {
ITrafficDataUsageStore,
ISegmentReadModel,
IProjectOwnersReadModel,
IFeatureLifecycleStore,
};

View File

@ -44,6 +44,7 @@ import { FakeInactiveUsersStore } from '../../lib/users/inactive/fakes/fake-inac
import { FakeTrafficDataUsageStore } from '../../lib/features/traffic-data-usage/fake-traffic-data-usage-store';
import { FakeSegmentReadModel } from '../../lib/features/segment/fake-segment-read-model';
import { FakeProjectOwnersReadModel } from '../../lib/features/project/fake-project-owners-read-model';
import { FakeFeatureLifecycleStore } from '../../lib/features/feature-lifecycle/fake-feature-lifecycle-store';
const db = {
select: () => ({
@ -97,6 +98,7 @@ const createStores: () => IUnleashStores = () => {
trafficDataUsageStore: new FakeTrafficDataUsageStore(),
segmentReadModel: new FakeSegmentReadModel(),
projectOwnersReadModel: new FakeProjectOwnersReadModel(),
featureLifecycleStore: new FakeFeatureLifecycleStore(),
};
};