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

feat: persist env required approvals (#9616)

This commit is contained in:
Mateusz Kwasniewski 2025-03-25 16:04:14 +01:00 committed by GitHub
parent 497cbcdef2
commit 4677b28aee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 69 additions and 9 deletions

View File

@ -3,6 +3,7 @@ import { createTestConfig } from '../../../test/config/test-config';
import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init'; import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init';
import NotFoundError from '../../error/notfound-error'; import NotFoundError from '../../error/notfound-error';
import { import {
type IExperimentalOptions,
type IUnleashStores, type IUnleashStores,
SYSTEM_USER, SYSTEM_USER,
SYSTEM_USER_AUDIT, SYSTEM_USER_AUDIT,
@ -17,9 +18,15 @@ let service: EnvironmentService;
let eventService: EventService; let eventService: EventService;
beforeAll(async () => { beforeAll(async () => {
const config = createTestConfig(); const flags: Partial<IExperimentalOptions> = {
flags: { globalChangeRequestConfig: true },
};
const config = createTestConfig({
experimental: flags,
});
db = await dbInit('environment_service_serial', config.getLogger, { db = await dbInit('environment_service_serial', config.getLogger, {
dbInitMethod: 'legacy' as const, dbInitMethod: 'legacy' as const,
experimental: flags,
}); });
stores = db.stores; stores = db.stores;
eventService = createEventsService(db.rawDatabase, config); eventService = createEventsService(db.rawDatabase, config);
@ -49,6 +56,34 @@ test('Can get all', async () => {
expect(environments).toHaveLength(3); // the one we created plus 'default' expect(environments).toHaveLength(3); // the one we created plus 'default'
}); });
test('Can manage required approvals', async () => {
const created = await db.stores.environmentStore.create({
name: 'approval_env',
type: 'production',
requiredApprovals: 1,
});
const retrieved = await service.get('approval_env');
await db.stores.environmentStore.update(
{
type: 'production',
protected: false,
requiredApprovals: 2,
},
'approval_env',
);
const updated = await service.get('approval_env');
const groupRetrieved = (await service.getAll()).find(
(env) => env.name === 'approval_env',
);
expect(retrieved).toEqual(created);
expect(updated).toEqual({ ...created, requiredApprovals: 2 });
expect(groupRetrieved).toMatchObject({ ...created, requiredApprovals: 2 });
});
test('Can connect environment to project', async () => { test('Can connect environment to project', async () => {
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: 'test-connection', name: 'test-connection',

View File

@ -9,7 +9,7 @@ export interface IEnvironmentStore extends Store<IEnvironment, string> {
exists(name: string): Promise<boolean>; exists(name: string): Promise<boolean>;
create(env: IEnvironmentCreate): Promise<IEnvironment>; create(env: IEnvironmentCreate): Promise<IEnvironment>;
update( update(
env: Pick<IEnvironment, 'type' | 'protected'>, env: Pick<IEnvironment, 'type' | 'protected' | 'requiredApprovals'>,
name: string, name: string,
): Promise<IEnvironment>; ): Promise<IEnvironment>;
updateProperty( updateProperty(

View File

@ -12,7 +12,7 @@ import NotFoundError from '../../error/notfound-error';
import type { IEnvironmentStore } from './environment-store-type'; import type { IEnvironmentStore } from './environment-store-type';
import { snakeCaseKeys } from '../../util/snakeCase'; import { snakeCaseKeys } from '../../util/snakeCase';
import type { CreateFeatureStrategySchema } from '../../openapi'; import type { CreateFeatureStrategySchema } from '../../openapi';
import type { IUnleashConfig } from '../../types'; import type { IFlagResolver, IUnleashConfig } from '../../types';
interface IEnvironmentsTable { interface IEnvironmentsTable {
name: string; name: string;
@ -21,6 +21,7 @@ interface IEnvironmentsTable {
sort_order: number; sort_order: number;
enabled: boolean; enabled: boolean;
protected: boolean; protected: boolean;
required_approvals?: number;
} }
interface IEnvironmentsWithCountsTable extends IEnvironmentsTable { interface IEnvironmentsWithCountsTable extends IEnvironmentsTable {
@ -51,6 +52,7 @@ function mapRow(row: IEnvironmentsTable): IEnvironment {
sortOrder: row.sort_order, sortOrder: row.sort_order,
enabled: row.enabled, enabled: row.enabled,
protected: row.protected, protected: row.protected,
requiredApprovals: row.required_approvals,
}; };
} }
@ -95,6 +97,7 @@ function fieldToRow(env: IEnvironment): IEnvironmentsTable {
sort_order: env.sortOrder, sort_order: env.sortOrder,
enabled: env.enabled, enabled: env.enabled,
protected: env.protected, protected: env.protected,
required_approvals: env.requiredApprovals,
}; };
} }
@ -103,6 +106,8 @@ const TABLE = 'environments';
export default class EnvironmentStore implements IEnvironmentStore { export default class EnvironmentStore implements IEnvironmentStore {
private logger: Logger; private logger: Logger;
private flagResolver: IFlagResolver;
private db: Db; private db: Db;
private isOss: boolean; private isOss: boolean;
@ -112,11 +117,16 @@ export default class EnvironmentStore implements IEnvironmentStore {
constructor( constructor(
db: Db, db: Db,
eventBus: EventEmitter, eventBus: EventEmitter,
{ getLogger, isOss }: Pick<IUnleashConfig, 'getLogger' | 'isOss'>, {
getLogger,
isOss,
flagResolver,
}: Pick<IUnleashConfig, 'getLogger' | 'isOss' | 'flagResolver'>,
) { ) {
this.db = db; this.db = db;
this.logger = getLogger('db/environment-store.ts'); this.logger = getLogger('db/environment-store.ts');
this.isOss = isOss; this.isOss = isOss;
this.flagResolver = flagResolver;
this.timer = (action) => this.timer = (action) =>
metricsHelper.wrapTimer(eventBus, DB_TIME, { metricsHelper.wrapTimer(eventBus, DB_TIME, {
store: 'environment', store: 'environment',
@ -124,12 +134,18 @@ export default class EnvironmentStore implements IEnvironmentStore {
}); });
} }
private allColumns() {
return this.flagResolver.isEnabled('globalChangeRequestConfig')
? [...COLUMNS, 'required_approvals']
: COLUMNS;
}
async importEnvironments( async importEnvironments(
environments: IEnvironment[], environments: IEnvironment[],
): Promise<IEnvironment[]> { ): Promise<IEnvironment[]> {
const rows = await this.db(TABLE) const rows = await this.db(TABLE)
.insert(environments.map(fieldToRow)) .insert(environments.map(fieldToRow))
.returning(COLUMNS) .returning(this.allColumns())
.onConflict('name') .onConflict('name')
.ignore(); .ignore();
@ -318,13 +334,13 @@ export default class EnvironmentStore implements IEnvironmentStore {
} }
async update( async update(
env: Pick<IEnvironment, 'type' | 'protected'>, env: Pick<IEnvironment, 'type' | 'protected' | 'requiredApprovals'>,
name: string, name: string,
): Promise<IEnvironment> { ): Promise<IEnvironment> {
const updatedEnv = await this.db<IEnvironmentsTable>(TABLE) const updatedEnv = await this.db<IEnvironmentsTable>(TABLE)
.update(snakeCaseKeys(env)) .update(snakeCaseKeys(env))
.where({ name, protected: false }) .where({ name, protected: false })
.returning<IEnvironmentsTable>(COLUMNS); .returning<IEnvironmentsTable>(this.allColumns());
return mapRow(updatedEnv[0]); return mapRow(updatedEnv[0]);
} }
@ -332,7 +348,7 @@ export default class EnvironmentStore implements IEnvironmentStore {
async create(env: IEnvironmentCreate): Promise<IEnvironment> { async create(env: IEnvironmentCreate): Promise<IEnvironment> {
const row = await this.db<IEnvironmentsTable>(TABLE) const row = await this.db<IEnvironmentsTable>(TABLE)
.insert(snakeCaseKeys(env)) .insert(snakeCaseKeys(env))
.returning<IEnvironmentsTable>(COLUMNS); .returning<IEnvironmentsTable>(this.allColumns());
return mapRow(row[0]); return mapRow(row[0]);
} }

View File

@ -60,7 +60,7 @@ export default class FakeEnvironmentStore implements IEnvironmentStore {
} }
async update( async update(
env: Pick<IEnvironment, 'type' | 'protected'>, env: Pick<IEnvironment, 'type' | 'protected' | 'requiredApprovals'>,
name: string, name: string,
): Promise<IEnvironment> { ): Promise<IEnvironment> {
const found = this.environments.find( const found = this.environments.find(

View File

@ -58,6 +58,14 @@ export const environmentSchema = {
description: description:
'The number of enabled toggles for the project environment', 'The number of enabled toggles for the project environment',
}, },
requiredApprovals: {
type: 'integer',
nullable: true,
description:
'Experimental field. The number of approvals required before a change request can be applied in this environment.',
minimum: 1,
example: 3,
},
}, },
components: {}, components: {},
} as const; } as const;

View File

@ -44,6 +44,7 @@ test('Can list all existing environments', async () => {
sortOrder: 1, sortOrder: 1,
type: 'production', type: 'production',
protected: true, protected: true,
requiredApprovals: null,
projectCount: 1, projectCount: 1,
apiTokenCount: 0, apiTokenCount: 0,
enabledToggleCount: 0, enabledToggleCount: 0,