Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,8 @@ coverage
dist

helm/environments/**/secrets.yaml

# Claude Code files
CLAUDE.md
llm-docs/
llm-specs/
1 change: 1 addition & 0 deletions src/server/db/migrations/001_seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,7 @@ export async function up(knex: Knex): Promise<any> {
INSERT INTO global_config (key, config, "createdAt", "updatedAt", "deletedAt", description) VALUES ('features', '{"namespace":true}', now(), now(), null, 'Configuration for feature flags controlled from database');
INSERT INTO global_config (key, config, "createdAt", "updatedAt", "deletedAt", description) VALUES ('serviceAccount', '{"name": "default","role":"replace_me"}', now(), now(), null, 'Default IAM role name to be used to annotate service account');
INSERT INTO global_config (key, config, "createdAt", "updatedAt", "deletedAt", description) VALUES ('app_setup', '{"state":"","created":false,"installed":false,"restarted":false,"org":"","url":"","name":""}', now(), now(), null, 'Application setup state');
INSERT INTO global_config (key, config, "createdAt", "updatedAt", "deletedAt", description) VALUES ('labels', '{"deploy":["lifecycle-deploy!"],"disabled":["lifecycle-disabled!"],"statusComments":["lifecycle-status-comments!"],"defaultStatusComments":true}', now(), now(), null, 'Configurable PR labels for deploy, disabled, and status comments');
`);

await knex.schema.raw(`
Expand Down
198 changes: 197 additions & 1 deletion src/server/lib/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,20 @@
* limitations under the License.
*/

import { exec, generateDeployTag, waitUntil, enableKillSwitch } from 'server/lib/utils';
import {
exec,
generateDeployTag,
waitUntil,
enableKillSwitch,
hasDeployLabel,
hasDisabledLabel,
hasStatusCommentLabel,
getDeployLabel,
getDisabledLabel,
getStatusCommentLabel,
isDefaultStatusCommentsEnabled,
} from 'server/lib/utils';
import GlobalConfigService from 'server/services/globalConfig';

jest.mock('server/services/globalConfig', () => {
return {
Expand All @@ -28,6 +41,12 @@ jest.mock('server/services/globalConfig', () => {
},
},
}),
getLabels: jest.fn().mockResolvedValue({
deploy: ['lifecycle-deploy!', 'custom-deploy!'],
disabled: ['lifecycle-disabled!', 'no-deploy!'],
statusComments: ['lifecycle-status-comments!', 'show-status!'],
defaultStatusComments: true,
}),
}),
};
});
Expand Down Expand Up @@ -215,3 +234,180 @@ describe('enableKillSwitch', () => {
expect(result).toEqual(false);
});
});

describe('hasDeployLabel', () => {
afterEach(() => {
jest.clearAllMocks();
});

test('returns true when PR has a configured deploy label', async () => {
const result = await hasDeployLabel(['lifecycle-deploy!', 'other-label']);
expect(result).toBe(true);
expect(GlobalConfigService.getInstance().getLabels).toHaveBeenCalled();
});

test('returns true when PR has multiple configured deploy labels', async () => {
const result = await hasDeployLabel(['custom-deploy!', 'other-label']);
expect(result).toBe(true);
expect(GlobalConfigService.getInstance().getLabels).toHaveBeenCalled();
});

test('returns false when PR has no deploy labels', async () => {
const result = await hasDeployLabel(['other-label', 'another-label']);
expect(result).toBe(false);
expect(GlobalConfigService.getInstance().getLabels).toHaveBeenCalled();
});

test('returns false when labels array is empty', async () => {
const result = await hasDeployLabel([]);
expect(result).toBe(false);
expect(GlobalConfigService.getInstance().getLabels).not.toHaveBeenCalled();
});

test('returns false when deploy config is missing', async () => {
const mockService = GlobalConfigService.getInstance() as jest.Mocked<GlobalConfigService>;
mockService.getLabels.mockResolvedValueOnce({
disabled: ['lifecycle-disabled!'],
statusComments: ['lifecycle-status-comments!'],
defaultStatusComments: true,
} as any);
const result = await hasDeployLabel(['some-label']);
expect(result).toBe(false);
});

test('returns false when deploy config is empty array', async () => {
const mockService = GlobalConfigService.getInstance() as jest.Mocked<GlobalConfigService>;
mockService.getLabels.mockResolvedValueOnce({
deploy: [],
disabled: ['lifecycle-disabled!'],
statusComments: ['lifecycle-status-comments!'],
defaultStatusComments: true,
});
const result = await hasDeployLabel(['some-label']);
expect(result).toBe(false);
});
});

describe('hasDisabledLabel', () => {
afterEach(() => {
jest.clearAllMocks();
});

test('returns true when PR has a configured disabled label', async () => {
const result = await hasDisabledLabel(['lifecycle-disabled!', 'other-label']);
expect(result).toBe(true);
expect(GlobalConfigService.getInstance().getLabels).toHaveBeenCalled();
});

test('returns false when PR has no disabled labels', async () => {
const result = await hasDisabledLabel(['other-label', 'another-label']);
expect(result).toBe(false);
expect(GlobalConfigService.getInstance().getLabels).toHaveBeenCalled();
});

test('returns false when labels array is empty', async () => {
const result = await hasDisabledLabel([]);
expect(result).toBe(false);
expect(GlobalConfigService.getInstance().getLabels).not.toHaveBeenCalled();
});
});

describe('hasStatusCommentLabel', () => {
afterEach(() => {
jest.clearAllMocks();
});

test('returns true when PR has a configured status comment label', async () => {
const result = await hasStatusCommentLabel(['lifecycle-status-comments!', 'other-label']);
expect(result).toBe(true);
expect(GlobalConfigService.getInstance().getLabels).toHaveBeenCalled();
});

test('returns false when PR has no status comment labels', async () => {
const result = await hasStatusCommentLabel(['other-label', 'another-label']);
expect(result).toBe(false);
expect(GlobalConfigService.getInstance().getLabels).toHaveBeenCalled();
});
});

describe('getDeployLabel', () => {
afterEach(() => {
jest.clearAllMocks();
});

test('returns first deploy label from configuration', async () => {
const result = await getDeployLabel();
expect(result).toBe('lifecycle-deploy!');
expect(GlobalConfigService.getInstance().getLabels).toHaveBeenCalled();
});

test('returns hardcoded fallback when deploy config is missing', async () => {
const mockService = GlobalConfigService.getInstance() as jest.Mocked<GlobalConfigService>;
mockService.getLabels.mockResolvedValueOnce({
disabled: ['lifecycle-disabled!'],
statusComments: ['lifecycle-status-comments!'],
defaultStatusComments: true,
} as any);
const result = await getDeployLabel();
expect(result).toBe('lifecycle-deploy!');
});

test('returns hardcoded fallback when deploy config is empty array', async () => {
const mockService = GlobalConfigService.getInstance() as jest.Mocked<GlobalConfigService>;
mockService.getLabels.mockResolvedValueOnce({
deploy: [],
disabled: ['lifecycle-disabled!'],
statusComments: ['lifecycle-status-comments!'],
defaultStatusComments: true,
});
const result = await getDeployLabel();
expect(result).toBe('lifecycle-deploy!');
});
});

describe('getDisabledLabel', () => {
afterEach(() => {
jest.clearAllMocks();
});

test('returns first disabled label from configuration', async () => {
const result = await getDisabledLabel();
expect(result).toBe('lifecycle-disabled!');
expect(GlobalConfigService.getInstance().getLabels).toHaveBeenCalled();
});
});

describe('getStatusCommentLabel', () => {
afterEach(() => {
jest.clearAllMocks();
});

test('returns first status comment label from configuration', async () => {
const result = await getStatusCommentLabel();
expect(result).toBe('lifecycle-status-comments!');
expect(GlobalConfigService.getInstance().getLabels).toHaveBeenCalled();
});
});

describe('isDefaultStatusCommentsEnabled', () => {
afterEach(() => {
jest.clearAllMocks();
});

test('returns defaultStatusComments setting from configuration', async () => {
const result = await isDefaultStatusCommentsEnabled();
expect(result).toBe(true);
expect(GlobalConfigService.getInstance().getLabels).toHaveBeenCalled();
});

test('returns true when defaultStatusComments is missing', async () => {
const mockService = GlobalConfigService.getInstance() as jest.Mocked<GlobalConfigService>;
mockService.getLabels.mockResolvedValueOnce({
deploy: ['lifecycle-deploy!'],
disabled: ['lifecycle-disabled!'],
statusComments: ['lifecycle-status-comments!'],
} as any);
const result = await isDefaultStatusCommentsEnabled();
expect(result).toBe(true);
});
});
78 changes: 75 additions & 3 deletions src/server/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import { execFile } from 'child_process';
import { promisify } from 'util';
import { GithubPullRequestActions, PullRequestStatus, Labels } from 'shared/constants';
import { GithubPullRequestActions, PullRequestStatus, FallbackLabels } from 'shared/constants';
import GlobalConfigService from 'server/services/globalConfig';
import { GenerateDeployTagOptions, WaitUntilOptions, EnableKillswitchOptions } from 'server/lib/types';

Expand Down Expand Up @@ -145,11 +145,11 @@ export const enableKillSwitch = async ({
action as GithubPullRequestActions
);
const isClosed = status === PullRequestStatus.CLOSED && !isOpened;
const isDisabled = labels.includes(Labels.DISABLED);
const isDisabled = await hasDisabledLabel(labels);
if (isClosed || isDisabled) {
return true;
}
const isForceDeploy = labels.includes(Labels.DEPLOY);
const isForceDeploy = await hasDeployLabel(labels);
if (isForceDeploy) {
return false;
}
Expand Down Expand Up @@ -184,3 +184,75 @@ export const enableKillSwitch = async ({
export const isStaging = () => {
return ENVIRONMENT === 'staging';
};

/**
* Check if PR has any deploy labels from configuration
* @param labels Array of PR labels
* @returns Promise<boolean> True if PR has deploy label
*/
export const hasDeployLabel = async (labels: string[]): Promise<boolean> => {
if (!labels || labels.length === 0) return false;
const labelsConfig = await GlobalConfigService.getInstance().getLabels();
const deployLabels = labelsConfig.deploy || [];
return deployLabels.some((deployLabel) => labels.includes(deployLabel));
};

/**
* Check if PR has any disabled labels fr m configuration
* @param labels Array of PR labels
* @returns Promise<boolean> True if PR has disabled label
*/
export const hasDisabledLabel = async (labels: string[]): Promise<boolean> => {
if (!labels || labels.length === 0) return false;
const labelsConfig = await GlobalConfigService.getInstance().getLabels();
const disabledLabels = labelsConfig.disabled || [];
return disabledLabels.some((disabledLabel) => labels.includes(disabledLabel));
};

/**
* Check if PR has any status comment labels from configuration
* @param labels Array of PR labels
* @returns Promise<boolean> True if PR has status comment label
*/
export const hasStatusCommentLabel = async (labels: string[]): Promise<boolean> => {
if (!labels || labels.length === 0) return false;
const labelsConfig = await GlobalConfigService.getInstance().getLabels();
const statusCommentLabels = labelsConfig.statusComments || [];
return statusCommentLabels.some((statusLabel) => labels.includes(statusLabel));
};

/**
* Get the first deploy label from configuration for user-facing messages
* @returns Promise<string> First deploy label from config
*/
export const getDeployLabel = async (): Promise<string> => {
const labelsConfig = await GlobalConfigService.getInstance().getLabels();
return labelsConfig?.deploy?.[0] || FallbackLabels.DEPLOY;
};

/**
* Get the first disabled label from configuration for user-facing messages
* @returns Promise<string> First disabled label from config
*/
export const getDisabledLabel = async (): Promise<string> => {
const labelsConfig = await GlobalConfigService.getInstance().getLabels();
return labelsConfig?.disabled?.[0] || FallbackLabels.DISABLED;
};

/**
* Get the first status comment label from configuration for user-facing messages
* @returns Promise<string> First status comment label from config
*/
export const getStatusCommentLabel = async (): Promise<string> => {
const labelsConfig = await GlobalConfigService.getInstance().getLabels();
return labelsConfig?.statusComments?.[0] || FallbackLabels.STATUS_COMMENTS;
};

/**
* Check if status comments should be enabled by default
* @returns Promise<boolean> True if status comments are enabled by default
*/
export const isDefaultStatusCommentsEnabled = async (): Promise<boolean> => {
const labelsConfig = await GlobalConfigService.getInstance().getLabels();
return labelsConfig.defaultStatusComments ?? true;
};
2 changes: 1 addition & 1 deletion src/server/models/PullRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export default class PullRequest extends Model {
githubLogin: string;

branchName: string;
labels: string[] | string;
labels: string[];
latestCommit: string;

static tableName = 'pull_requests';
Expand Down
1 change: 0 additions & 1 deletion src/server/models/yaml/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ export type LifecycleYamlConfigEnvironment = {
optionalServices?: YamlService[];
webhooks?: YamlWebhook[];
enabledFeatures?: string[];
hasGithubStatusComment?: boolean;
};

export type LifecycleYamlConfigOptions = {
Expand Down
Loading