diff --git a/.gitignore b/.gitignore index dc85d4c8..f862210e 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,8 @@ coverage dist helm/environments/**/secrets.yaml + +# Claude Code files +CLAUDE.md +llm-docs/ +llm-specs/ diff --git a/src/server/db/migrations/001_seed.ts b/src/server/db/migrations/001_seed.ts index 50c2ea82..cddca357 100644 --- a/src/server/db/migrations/001_seed.ts +++ b/src/server/db/migrations/001_seed.ts @@ -463,6 +463,7 @@ export async function up(knex: Knex): Promise { 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(` diff --git a/src/server/lib/__tests__/utils.test.ts b/src/server/lib/__tests__/utils.test.ts index f3391b79..b8708413 100644 --- a/src/server/lib/__tests__/utils.test.ts +++ b/src/server/lib/__tests__/utils.test.ts @@ -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 { @@ -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, + }), }), }; }); @@ -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; + 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; + 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; + 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; + 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; + mockService.getLabels.mockResolvedValueOnce({ + deploy: ['lifecycle-deploy!'], + disabled: ['lifecycle-disabled!'], + statusComments: ['lifecycle-status-comments!'], + } as any); + const result = await isDefaultStatusCommentsEnabled(); + expect(result).toBe(true); + }); +}); diff --git a/src/server/lib/utils.ts b/src/server/lib/utils.ts index c8d983ac..b3c3db37 100644 --- a/src/server/lib/utils.ts +++ b/src/server/lib/utils.ts @@ -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'; @@ -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; } @@ -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 True if PR has deploy label + */ +export const hasDeployLabel = async (labels: string[]): Promise => { + 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 True if PR has disabled label + */ +export const hasDisabledLabel = async (labels: string[]): Promise => { + 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 True if PR has status comment label + */ +export const hasStatusCommentLabel = async (labels: string[]): Promise => { + 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 First deploy label from config + */ +export const getDeployLabel = async (): Promise => { + 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 First disabled label from config + */ +export const getDisabledLabel = async (): Promise => { + 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 First status comment label from config + */ +export const getStatusCommentLabel = async (): Promise => { + const labelsConfig = await GlobalConfigService.getInstance().getLabels(); + return labelsConfig?.statusComments?.[0] || FallbackLabels.STATUS_COMMENTS; +}; + +/** + * Check if status comments should be enabled by default + * @returns Promise True if status comments are enabled by default + */ +export const isDefaultStatusCommentsEnabled = async (): Promise => { + const labelsConfig = await GlobalConfigService.getInstance().getLabels(); + return labelsConfig.defaultStatusComments ?? true; +}; diff --git a/src/server/models/PullRequest.ts b/src/server/models/PullRequest.ts index 8f9e3f84..01123329 100644 --- a/src/server/models/PullRequest.ts +++ b/src/server/models/PullRequest.ts @@ -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'; diff --git a/src/server/models/yaml/types.ts b/src/server/models/yaml/types.ts index f5bb3e4d..63ecfb25 100644 --- a/src/server/models/yaml/types.ts +++ b/src/server/models/yaml/types.ts @@ -21,7 +21,6 @@ export type LifecycleYamlConfigEnvironment = { optionalServices?: YamlService[]; webhooks?: YamlWebhook[]; enabledFeatures?: string[]; - hasGithubStatusComment?: boolean; }; export type LifecycleYamlConfigOptions = { diff --git a/src/server/services/__tests__/globalConfig.test.ts b/src/server/services/__tests__/globalConfig.test.ts index e1959d91..4e8d9bc0 100644 --- a/src/server/services/__tests__/globalConfig.test.ts +++ b/src/server/services/__tests__/globalConfig.test.ts @@ -82,6 +82,62 @@ describe('GlobalConfigService', () => { }); }); + describe('getLabels', () => { + it('should return labels configuration from global config', async () => { + const mockLabelsConfig = { + deploy: ['lifecycle-deploy!', 'custom-deploy!'], + disabled: ['lifecycle-disabled!', 'no-deploy!'], + statusComments: ['lifecycle-status-comments!', 'show-status!'], + defaultStatusComments: true, + }; + + const mockGetAllConfigs = jest.spyOn(service, 'getAllConfigs').mockResolvedValueOnce({ + labels: mockLabelsConfig, + }); + + const result = await service.getLabels(); + + expect(result).toEqual(mockLabelsConfig); + expect(mockGetAllConfigs).toHaveBeenCalled(); + + mockGetAllConfigs.mockRestore(); + }); + + it('should return fallback defaults when labels config does not exist', async () => { + const mockGetAllConfigs = jest.spyOn(service, 'getAllConfigs').mockResolvedValueOnce({ + // no labels config + }); + + const result = await service.getLabels(); + + expect(result).toEqual({ + deploy: ['lifecycle-deploy!'], + disabled: ['lifecycle-disabled!'], + statusComments: ['lifecycle-status-comments!'], + defaultStatusComments: true, + }); + expect(mockGetAllConfigs).toHaveBeenCalled(); + + mockGetAllConfigs.mockRestore(); + }); + + it('should return fallback defaults when getAllConfigs throws an error', async () => { + const mockGetAllConfigs = jest.spyOn(service, 'getAllConfigs').mockRejectedValueOnce(new Error('DB error')); + + const result = await service.getLabels(); + + expect(result).toEqual({ + deploy: ['lifecycle-deploy!'], + disabled: ['lifecycle-disabled!'], + statusComments: ['lifecycle-status-comments!'], + defaultStatusComments: true, + }); + expect(mockGetAllConfigs).toHaveBeenCalled(); + + mockGetAllConfigs.mockRestore(); + }); + }); + afterEach(() => { jest.clearAllMocks(); }); diff --git a/src/server/services/activityStream.ts b/src/server/services/activityStream.ts index 2a86f398..e61367fe 100644 --- a/src/server/services/activityStream.ts +++ b/src/server/services/activityStream.ts @@ -27,12 +27,21 @@ import { BuildStatus, DeployStatus, CommentParser, - Labels, DeployTypes, CLIDeployTypes, PullRequestStatus, } from 'shared/constants'; -import { flattenObject, enableKillSwitch, isStaging } from 'server/lib/utils'; +import { + flattenObject, + enableKillSwitch, + isStaging, + hasStatusCommentLabel, + hasDeployLabel, + getDeployLabel, + getDisabledLabel, + getStatusCommentLabel, + isDefaultStatusCommentsEnabled, +} from 'server/lib/utils'; import Fastly from 'server/lib/fastly'; import { nanoid } from 'nanoid'; import { redisClient } from 'server/lib/dependencies'; @@ -44,7 +53,11 @@ const logger = rootLogger.child({ filename: 'services/activityStream.ts', }); -const TO_DEPLOY_THIS_ENV = `To deploy this environment, just add a \`${Labels.DEPLOY}\` label. Add a \`${Labels.DISABLED}\` to do the opposite. ↗️\n\n`; +const createDeployMessage = async () => { + const deployLabel = await getDeployLabel(); + const disabledLabel = await getDisabledLabel(); + return `To deploy this environment, just add a \`${deployLabel}\` label. Add a \`${disabledLabel}\` to do the opposite. ↗️\n\n`; +}; const COMMENT_EDIT_DESCRIPTION = `You can use the section below to redeploy and update the dev environment for this pull request.\n\n\n`; const GIT_SERVICE_URL = 'https://github.com'; @@ -404,14 +417,10 @@ export default class ActivityStream extends BaseService { const prefix = `[BUILD ${uuid}][updatePullRequestActivityStream]`; const suffix = `for ${fullName}/${branchName}`; const isStatic = build?.isStatic ?? false; - const enabledFeatures = build?.enabledFeatures || []; const labels = pullRequest?.labels || []; - const hasUseDeprecatedStatusComment = labels?.includes(Labels.ENABLE_LIFECYCLE_STATUS_COMMENTS); - const hasGithubStatusCommentEnabled = enabledFeatures.includes('hasGithubStatusComment'); - const isDeployed = build?.status === BuildStatus.DEPLOYED; - const hasPurgeFastlyServiceCachLabel = labels?.includes(Labels.PURGE_FASTLY_SERVICE_CACHE); - const isPurgingFastlyServiceCache = hasPurgeFastlyServiceCachLabel && isDeployed; - const isShowingStatusComment = isStatic || hasUseDeprecatedStatusComment || hasGithubStatusCommentEnabled; + const hasStatusComment = await hasStatusCommentLabel(labels); + const isDefaultStatusEnabled = await isDefaultStatusCommentsEnabled(); + const isShowingStatusComment = isStatic || hasStatusComment || isDefaultStatusEnabled; if (!buildId) { logger.error(`${prefix}[buidIdError] No build ID found ${suffix}`); throw new Error('No build ID found for this build!'); @@ -437,7 +446,6 @@ export default class ActivityStream extends BaseService { .child({ error }) .warn(`${prefix} (Full YAML: ${isFullYaml}) Unable to update ${queued} mission control comment ${suffix}`); }); - if (isPurgingFastlyServiceCache) await this.purgeFastlyServiceCache(uuid); } if (updateStatus && isShowingStatusComment) { @@ -477,7 +485,8 @@ export default class ActivityStream extends BaseService { */ private async editCommentForBuild(build: Build, deploys: Deploy[]) { let message = ``; - const enableLifecycleStatusComments = `Add \`${Labels.ENABLE_LIFECYCLE_STATUS_COMMENTS}\``; + const statusCommentLabel = await getStatusCommentLabel(); + const enableLifecycleStatusComments = `Add \`${statusCommentLabel}\``; message += `## ✏️ Environment Overrides\n`; message += '
\n'; message += 'Usage\n\n'; @@ -645,7 +654,7 @@ export default class ActivityStream extends BaseService { ].includes(buildStatus as BuildStatus); const isDeployed = buildStatus === BuildStatus.DEPLOYED; let deployStatus; - const hasDeployLabel = labels?.includes(Labels.DEPLOY); + const hasDeployLabelPresent = await hasDeployLabel(labels); const tags = { uuid, repositoryName, branchName, env: 'prd', service: 'lifecycle-job', statsEvent: 'deployment' }; const eventDetails = { title: 'Deployment Finished', @@ -698,8 +707,8 @@ export default class ActivityStream extends BaseService { metrics.increment('total', tags).event(eventDetails.title, eventDetails.description); } message = `### 💻✨ Your environment ${deployStatus}.\n`; - if (!hasDeployLabel && !isBot && isPending && isOpen) { - message += TO_DEPLOY_THIS_ENV; + if (!hasDeployLabelPresent && !isBot && isPending && isOpen) { + message += await createDeployMessage(); } message += await this.editCommentForBuild(build, deploys).catch((error) => { @@ -818,9 +827,11 @@ export default class ActivityStream extends BaseService { message += '## ⏳ Pending\n'; message += `Lifecycle Environment either has been torn down or does not exist.`; if (isBot) { - message += `\n\n**This PR is created by a bot user, add ${Labels.DEPLOY} to build environment**`; + const deployLabel = await getDeployLabel(); + message += `\n\n**This PR is created by a bot user, add ${deployLabel} to build environment**`; } else { - message += `\n\n*Note: If ${Labels.DISABLED} label present, remove to build environment*`; + const disabledLabel = await getDisabledLabel(); + message += `\n\n*Note: If ${disabledLabel} label present, remove to build environment*`; } } else if (isBuilding) { message += '## 🏗️ Building\n'; @@ -844,7 +855,7 @@ export default class ActivityStream extends BaseService { }); if (pullRequest.deployOnUpdate === false) { - message += TO_DEPLOY_THIS_ENV; + message += await createDeployMessage(); } else { message += `\nWe'll deploy your code once we've finished this build step.`; } @@ -880,7 +891,7 @@ export default class ActivityStream extends BaseService { ); return ''; }); - message += TO_DEPLOY_THIS_ENV; + message += await createDeployMessage(); } else if (pullRequest.deployOnUpdate) { message = ''; if (build.status === BuildStatus.ERROR) { diff --git a/src/server/services/build.ts b/src/server/services/build.ts index 4d521bb5..3c632f5d 100644 --- a/src/server/services/build.ts +++ b/src/server/services/build.ts @@ -523,7 +523,6 @@ export default class BuildService extends BaseService { const env = lifecycleConfig?.environment; const enabledFeatures = env?.enabledFeatures || []; const githubDeployments = env?.githubDeployments || false; - const hasGithubStatusComment = env?.hasGithubStatusComment || false; const build = (await this.db.models.Build.query() .where('pullRequestId', options.pullRequestId) @@ -539,7 +538,6 @@ export default class BuildService extends BaseService { enableFullYaml: this.db.services.Environment.enableFullYamlSupport(environment), enabledFeatures: JSON.stringify(enabledFeatures), githubDeployments, - hasGithubStatusComment, namespace: `env-${uuid}`, })); logger.info(`[BUILD ${build.uuid}] Created build for pull request branch: ${options.repositoryBranchName}`); diff --git a/src/server/services/github.ts b/src/server/services/github.ts index 4cc5428b..af87b7b4 100644 --- a/src/server/services/github.ts +++ b/src/server/services/github.ts @@ -19,14 +19,14 @@ import _ from 'lodash'; import Service from './_service'; import rootLogger from 'server/lib/logger'; import { IssueCommentEvent, PullRequestEvent, PushEvent } from '@octokit/webhooks-types'; -import { GithubPullRequestActions, GithubWebhookTypes, PullRequestStatus, Labels } from 'shared/constants'; +import { GithubPullRequestActions, GithubWebhookTypes, PullRequestStatus, FallbackLabels } from 'shared/constants'; import { JOB_VERSION } from 'shared/config'; import { NextApiRequest } from 'next'; import * as github from 'server/lib/github'; import { Environment, Repository, Build, PullRequest } from 'server/models'; import { LifecycleYamlConfigOptions } from 'server/models/yaml/types'; import { createOrUpdateGithubDeployment, deleteGithubDeploymentAndEnvironment } from 'server/lib/github/deployments'; -import { enableKillSwitch, isStaging } from 'server/lib/utils'; +import { enableKillSwitch, isStaging, hasDeployLabel, getDeployLabel } from 'server/lib/utils'; import { redisClient } from 'server/lib/dependencies'; const logger = rootLogger.child({ @@ -141,13 +141,15 @@ export default class GithubService extends Service { }); // if auto deploy, add deploy label` - if (isDeploy) + if (isDeploy) { + const deployLabel = await getDeployLabel(); await github.updatePullRequestLabels({ installationId, pullRequestNumber: number, fullName, - labels: labels.map((l) => l.name).concat(['lifecycle-deploy!']), + labels: labels.map((l) => l.name).concat([deployLabel]), }); + } } else if (isClosed) { build = await this.db.models.Build.findOne({ pullRequestId, @@ -157,12 +159,14 @@ export default class GithubService extends Service { return; } await this.db.services.BuildService.deleteBuild(build); - // remove lifecycle-deploy! label on PR close + // remove deploy labels on PR close + const globalConfig = await this.db.services.GlobalConfig.getLabels(); + const deployLabels = globalConfig.deploy; await github.updatePullRequestLabels({ installationId, pullRequestNumber: number, fullName, - labels: labels.map((l) => l.name).filter((v) => v !== Labels.DEPLOY), + labels: labels.map((l) => l.name).filter((labelName) => !deployLabels.includes(labelName)), }); } } catch (error) { @@ -217,7 +221,7 @@ export default class GithubService extends Service { try { // this is a hacky way to force deploy by adding a label const labelNames = labels.map(({ name }) => name.toLowerCase()) || []; - const shouldDeploy = isStaging() && labelNames.includes(Labels.DEPLOY_STG); + const shouldDeploy = isStaging() && labelNames.includes(FallbackLabels.DEPLOY_STG); if (shouldDeploy) { // we overwrite the action so the handlePullRequestHook can handle the cretion body.action = GithubPullRequestActions.OPENED; @@ -396,7 +400,7 @@ export default class GithubService extends Service { case GithubWebhookTypes.PULL_REQUEST: try { const labelNames = body.pull_request.labels.map(({ name }) => name.toLowerCase()) || []; - if (isStaging() && !labelNames.includes(Labels.DEPLOY_STG)) { + if (isStaging() && !labelNames.includes(FallbackLabels.DEPLOY_STG)) { logger.debug(`[GITHUB] STAGING RUN DETECTED - Skipping processing of this event`); return; } @@ -481,8 +485,8 @@ export default class GithubService extends Service { const branch = pullRequest?.branchName; try { const isBot = await this.db.services.BotUser.isBotUser(user); - const hasDeployLabel = labelNames.includes(Labels.DEPLOY); - const isDeploy = hasDeployLabel || autoDeploy; + const deployLabelPresent = await hasDeployLabel(labelNames); + const isDeploy = deployLabelPresent || autoDeploy; const isKillSwitch = await enableKillSwitch({ isBotUser: isBot, fullName, diff --git a/src/server/services/globalConfig.ts b/src/server/services/globalConfig.ts index 81f3c869..7d205312 100644 --- a/src/server/services/globalConfig.ts +++ b/src/server/services/globalConfig.ts @@ -17,7 +17,7 @@ import { createAppAuth } from '@octokit/auth-app'; import rootLogger from 'server/lib/logger'; import BaseService from './_service'; -import { GlobalConfig } from './types/globalConfig'; +import { GlobalConfig, LabelsConfig } from './types/globalConfig'; import { GITHUB_APP_INSTALLATION_ID, APP_AUTH, APP_ENV } from 'shared/config'; import { Metrics } from 'server/lib/metrics'; import { redisClient } from 'server/lib/dependencies'; @@ -110,6 +110,27 @@ export default class GlobalConfigService extends BaseService { return Boolean(features[name]); } + /** + * Retrieves labels configuration from global config with fallback defaults + * @returns Promise The labels configuration + */ + async getLabels(): Promise { + try { + const { labels } = await this.getAllConfigs(); + if (!labels) throw new Error('Labels configuration not found in global config'); + return labels; + } catch (error) { + logger.error('Error retrieving labels configuration, using fallback defaults', error); + // Return fallback defaults on error + return { + deploy: ['lifecycle-deploy!'], + disabled: ['lifecycle-disabled!'], + statusComments: ['lifecycle-status-comments!'], + defaultStatusComments: true, + }; + } + } + private deserialize(config: unknown): GlobalConfig { const deserializedConfigs = {}; for (const [key, value] of Object.entries(config)) { diff --git a/src/server/services/pullRequest.ts b/src/server/services/pullRequest.ts index 21c229cc..0718966e 100644 --- a/src/server/services/pullRequest.ts +++ b/src/server/services/pullRequest.ts @@ -21,7 +21,7 @@ import { UniqueViolationError } from 'objection'; import _ from 'lodash'; import * as github from 'server/lib/github'; import { JOB_VERSION } from 'shared/config'; -import { Labels } from 'shared/constants'; +import GlobalConfigService from './globalConfig'; import { redisClient } from 'server/lib/dependencies'; export interface PullRequestOptions { @@ -106,12 +106,13 @@ export default class PullRequestService extends BaseService { try { await pullRequest.$fetchGraph('repository'); + const labelsConfig = await GlobalConfigService.getInstance().getLabels(); const hasLabel = await this.pullRequestHasLabelsAndState( pullRequest.pullRequestNumber, pullRequest.repository.githubInstallationId, pullRequest.repository.fullName.split('/')[0], pullRequest.repository.fullName.split('/')[1], - [Labels.DEPLOY], + labelsConfig.deploy, 'open' ); return hasLabel; diff --git a/src/server/services/types/globalConfig.ts b/src/server/services/types/globalConfig.ts index ca33f05a..1e7df95e 100644 --- a/src/server/services/types/globalConfig.ts +++ b/src/server/services/types/globalConfig.ts @@ -36,6 +36,7 @@ export type GlobalConfig = { serviceAccount: RoleSettings; features: Record; app_setup: AppSetup; + labels: LabelsConfig; }; export type AppSetup = { @@ -135,3 +136,10 @@ export type ResourceRequirements = { requests?: Record; limits?: Record; }; + +export type LabelsConfig = { + deploy: string[]; + disabled: string[]; + statusComments: string[]; + defaultStatusComments: boolean; +}; diff --git a/src/shared/constants.ts b/src/shared/constants.ts index d43576cd..b1dc2855 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -116,12 +116,11 @@ export enum CommentParser { FOOTER = `----EDIT ABOVE THIS LINE----`, } -export enum Labels { +export enum FallbackLabels { DEPLOY = 'lifecycle-deploy!', DISABLED = 'lifecycle-disabled!', DEPLOY_STG = 'lifecycle-stg-deploy!', - ENABLE_LIFECYCLE_STATUS_COMMENTS = 'lifecycle-status-comments!', - PURGE_FASTLY_SERVICE_CACHE = 'lifecycle-cache-purge!', + STATUS_COMMENTS = 'lifecycle-status-comments!', } export enum FeatureFlags {