From e584dbc552df2674186d61c7a17c040dafade634 Mon Sep 17 00:00:00 2001 From: Adrian Veliz Date: Mon, 30 Oct 2023 06:57:13 -0400 Subject: [PATCH] Add support for GitHub Environments (#544) * Add support for configuring environments * Add null handling in mergeDeep * Add custom_branch_policies handling * Add unit testing for Environments plugin --- lib/mergeDeep.js | 2 +- lib/plugins/environments.js | 275 +++++++++++++++++++++ lib/settings.js | 3 +- test/unit/lib/plugins/environments.test.js | 274 ++++++++++++++++++++ 4 files changed, 552 insertions(+), 2 deletions(-) create mode 100644 lib/plugins/environments.js create mode 100644 test/unit/lib/plugins/environments.test.js diff --git a/lib/mergeDeep.js b/lib/mergeDeep.js index 4dd22193..69b5c548 100644 --- a/lib/mergeDeep.js +++ b/lib/mergeDeep.js @@ -80,7 +80,7 @@ class MergeDeep { } // If the target is empty, then all the source is added to additions - if (t === undefined || (this.isEmpty(t) && !this.isEmpty(s))) { + if (t === undefined || t === null || (this.isEmpty(t) && !this.isEmpty(s))) { additions = Object.assign(additions, s) return ({ additions, modifications, hasChanges: true }) } diff --git a/lib/plugins/environments.js b/lib/plugins/environments.js new file mode 100644 index 00000000..3435388e --- /dev/null +++ b/lib/plugins/environments.js @@ -0,0 +1,275 @@ +const Diffable = require('./diffable') + +module.exports = class Environments extends Diffable { + constructor(...args) { + super(...args) + + if (this.entries) { + // Force all names to lowercase to avoid comparison issues. + this.entries.forEach(environment => { + environment.name = environment.name.toLowerCase(); + if(environment.variables) { + environment.variables.forEach(variable => { + variable.name = variable.name.toLowerCase(); + }); + } + }) + } + } + + async find() { + const { data: { environments } } = await this.github.request('GET /repos/:org/:repo/environments', { + org: this.repo.owner, + repo: this.repo.repo + }); + + let environments_mapped = []; + + for(let environment of environments) { + const mapped = { + name: environment.name.toLowerCase(), + repo: this.repo.repo, + wait_timer: (environment.protection_rules.find(rule => rule.type === 'wait_timer') || { wait_timer: 0 }).wait_timer, + prevent_self_review: (environment.protection_rules.find(rule => rule.type === 'required_reviewers') || { prevent_self_review: false }).prevent_self_review, + reviewers: (environment.protection_rules.find(rule => rule.type === 'required_reviewers') || { reviewers: [] }).reviewers.map(reviewer => ({id: reviewer.reviewer.id, type: reviewer.type})), + deployment_branch_policy: environment.deployment_branch_policy === null ? null : { + protected_branches: (environment.deployment_branch_policy || { protected_branches: false }).protected_branches, + custom_branch_policies: (environment.deployment_branch_policy || { custom_branch_policies: false }).custom_branch_policies && (await this.github.request('GET /repos/:org/:repo/environments/:environment_name/deployment-branch-policies', { + org: this.repo.owner, + repo: this.repo.repo, + environment_name: environment.name + })).data.branch_policies.map(policy => ({ + name: policy.name + })) + }, + variables: (await this.github.request('GET /repos/:org/:repo/environments/:environment_name/variables', { + org: this.repo.owner, + repo: this.repo.repo, + environment_name: environment.name + })).data.variables.map(variable => ({name: variable.name.toLowerCase(), value: variable.value})), + deployment_protection_rules: (await this.github.request('GET /repos/:org/:repo/environments/:environment_name/deployment_protection_rules', { + org: this.repo.owner, + repo: this.repo.repo, + environment_name: environment.name + })).data.custom_deployment_protection_rules.map(rule => ({ + app_id: rule.app.id, + id: rule.id + })) + } + environments_mapped.push(mapped); + //console.log(mapped); + } + + return environments_mapped; + } + + comparator(existing, attrs) { + return existing.name === attrs.name + } + + getChanged(existing, attrs) { + if (!attrs.wait_timer) attrs.wait_timer = 0; + if (!attrs.prevent_self_review) attrs.prevent_self_review = false; + if (!attrs.reviewers) attrs.reviewers = []; + if (!attrs.deployment_branch_policy) attrs.deployment_branch_policy = null; + if(!attrs.variables) attrs.variables = []; + if(!attrs.deployment_protection_rules) attrs.deployment_protection_rules = []; + + const wait_timer = existing.wait_timer !== attrs.wait_timer; + const prevent_self_review = existing.prevent_self_review !== attrs.prevent_self_review; + const reviewers = JSON.stringify(existing.reviewers.sort((x1, x2) => x1.id - x2.id)) !== JSON.stringify(attrs.reviewers.sort((x1, x2) => x1.id - x2.id)); + + let existing_custom_branch_policies = existing.deployment_branch_policy === null ? null : existing.deployment_branch_policy.custom_branch_policies; + if(typeof(existing_custom_branch_policies) === 'object' && existing_custom_branch_policies !== null) { + existing_custom_branch_policies = existing_custom_branch_policies.sort(); + } + let attrs_custom_branch_policies = attrs.deployment_branch_policy === null ? null : attrs.deployment_branch_policy.custom_branch_policies; + if(typeof(attrs_custom_branch_policies) === 'object' && attrs_custom_branch_policies !== null) { + attrs_custom_branch_policies = attrs_custom_branch_policies.sort(); + } + let deployment_branch_policy; + if(existing.deployment_branch_policy === attrs.deployment_branch_policy) { + deployment_branch_policy = false; + } + else { + deployment_branch_policy = ( + (existing.deployment_branch_policy === null && attrs.deployment_branch_policy !== null) || + (existing.deployment_branch_policy !== null && attrs.deployment_branch_policy === null) || + (existing.deployment_branch_policy.protected_branches !== attrs.deployment_branch_policy.protected_branches) || + (JSON.stringify(existing_custom_branch_policies) !== JSON.stringify(attrs_custom_branch_policies)) + ); + } + + const variables = JSON.stringify(existing.variables.sort((x1, x2) => x1.name - x2.name)) !== JSON.stringify(attrs.variables.sort((x1, x2) => x1.name - x2.name)); + const deployment_protection_rules = JSON.stringify(existing.deployment_protection_rules.map(x => ({app_id: x.app_id})).sort((x1, x2) => x1.app_id - x2.app_id)) !== JSON.stringify(attrs.deployment_protection_rules.map(x => ({app_id: x.app_id})).sort((x1, x2) => x1.app_id - x2.app_id)); + + return {wait_timer, prevent_self_review, reviewers, deployment_branch_policy, variables, deployment_protection_rules}; + } + + changed(existing, attrs) { + const {wait_timer, prevent_self_review, reviewers, deployment_branch_policy, variables, deployment_protection_rules} = this.getChanged(existing, attrs); + + return wait_timer || prevent_self_review || reviewers || deployment_branch_policy || variables || deployment_protection_rules; + } + + async update(existing, attrs) { + const {wait_timer, prevent_self_review, reviewers, deployment_branch_policy, variables, deployment_protection_rules} = this.getChanged(existing, attrs); + + if(wait_timer || prevent_self_review || reviewers || deployment_branch_policy) { + await this.github.request(`PUT /repos/:org/:repo/environments/:environment_name`, { + org: this.repo.owner, + repo: this.repo.repo, + environment_name: attrs.name, + wait_timer: attrs.wait_timer, + prevent_self_review: attrs.prevent_self_review, + reviewers: attrs.reviewers, + deployment_branch_policy: attrs.deployment_branch_policy === null ? null : { + protected_branches: attrs.deployment_branch_policy.protected_branches, + custom_branch_policies: !!attrs.deployment_branch_policy.custom_branch_policies + } + }) + } + + if(deployment_branch_policy && attrs.deployment_branch_policy && attrs.deployment_branch_policy.custom_branch_policies) { + const existingPolicies = (await this.github.request('GET /repos/:org/:repo/environments/:environment_name/deployment-branch-policies', { + org: this.repo.owner, + repo: this.repo.repo, + environment_name: attrs.name + })).data.branch_policies; + + for(let policy of existingPolicies) { + await this.github.request('DELETE /repos/:org/:repo/environments/:environment_name/deployment-branch-policies/:branch_policy_id', { + org: this.repo.owner, + repo: this.repo.repo, + environment_name: attrs.name, + branch_policy_id: policy.id + }); + } + + for(let policy of attrs.deployment_branch_policy.custom_branch_policies) { + await this.github.request('POST /repos/:org/:repo/environments/:environment_name/deployment-branch-policies', { + org: this.repo.owner, + repo: this.repo.repo, + environment_name: attrs.name, + name: policy + }); + } + } + + if(variables) { + let existingVariables = [...existing.variables]; + for(let variable of attrs.variables) { + const existingVariable = existingVariables.find((_var) => _var.name === variable.name); + if(existingVariable) { + existingVariables = existingVariables.filter(_var => _var.name !== variable.name); + if(existingVariable.value !== variable.value) { + await this.github.request(`PATCH /repos/:org/:repo/environments/:environment_name/variables/:variable_name`, { + org: this.repo.owner, + repo: this.repo.repo, + environment_name: attrs.name, + variable_name: variable.name, + value: variable.value + }); + } + } + else { + await this.github.request(`POST /repos/:org/:repo/environments/:environment_name/variables`, { + org: this.repo.owner, + repo: this.repo.repo, + environment_name: attrs.name, + name: variable.name, + value: variable.value + }); + } + } + + for(let variable of existingVariables) { + await this.github.request('DELETE /repos/:org/:repo/environments/:environment_name/variables/:variable_name', { + org: this.repo.owner, + repo: this.repo.repo, + environment_name: attrs.name, + variable_name: variable.name + }); + } + } + + if(deployment_protection_rules) { + let existingRules = [...existing.deployment_protection_rules]; + for(let rule of attrs.deployment_protection_rules) { + const existingRule = existingRules.find((_rule) => _rule.id === rule.id); + + if(!existingRule) { + await this.github.request(`POST /repos/:org/:repo/environments/:environment_name/deployment_protection_rules`, { + org: this.repo.owner, + repo: this.repo.repo, + environment_name: attrs.name, + integration_id: rule.app_id + }); + } + } + + for(let rule of existingRules) { + await this.github.request('DELETE /repos/:org/:repo/environments/:environment_name/deployment_protection_rules/:rule_id', { + org: this.repo.owner, + repo: this.repo.repo, + environment_name: attrs.name, + rule_id: rule.id + }); + } + } + } + + async add(attrs) { + await this.github.request(`PUT /repos/:org/:repo/environments/:environment_name`, { + org: this.repo.owner, + repo: this.repo.repo, + environment_name: attrs.name, + wait_timer: attrs.wait_timer, + prevent_self_review: attrs.prevent_self_review, + reviewers: attrs.reviewers, + deployment_branch_policy: attrs.deployment_branch_policy === null ? null : { + protected_branches: attrs.deployment_branch_policy.protected_branches, + custom_branch_policies: !!attrs.deployment_branch_policy.custom_branch_policies + } + }); + + if(attrs.deployment_branch_policy && attrs.deployment_branch_policy.custom_branch_policies) { + for(let policy of attrs.deployment_branch_policy.custom_branch_policies) { + await this.github.request('POST /repos/:org/:repo/environments/:environment_name/deployment-branch-policies', { + org: this.repo.owner, + repo: this.repo.repo, + environment_name: attrs.name, + name: policy.name + }); + } + } + + + for(let variable of attrs.variables) { + await this.github.request(`POST /repos/:org/:repo/environments/:environment_name/variables`, { + org: this.repo.owner, + repo: this.repo.repo, + environment_name: attrs.name, + name: variable.name, + value: variable.value + }); + } + + for(let rule of attrs.deployment_protection_rules) { + await this.github.request(`POST /repos/:org/:repo/environments/:environment_name/deployment_protection_rules`, { + org: this.repo.owner, + repo: this.repo.repo, + environment_name: attrs.name, + integration_id: rule.app_id + }); + } + } + + async remove(existing) { + await this.github.request(`DELETE /repos/:org/:repo/environments/:environment_name`, { + org: this.repo.owner, + repo: this.repo.repo, + environment_name: existing.name + }); + } +} \ No newline at end of file diff --git a/lib/settings.js b/lib/settings.js index a7909e81..ebc2b2b4 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -786,7 +786,8 @@ Settings.PLUGINS = { branches: require('./plugins/branches'), autolinks: require('./plugins/autolinks'), validator: require('./plugins/validator'), - rulesets: require('./plugins/rulesets') + rulesets: require('./plugins/rulesets'), + environments: require('./plugins/environments') } module.exports = Settings diff --git a/test/unit/lib/plugins/environments.test.js b/test/unit/lib/plugins/environments.test.js new file mode 100644 index 00000000..e826e005 --- /dev/null +++ b/test/unit/lib/plugins/environments.test.js @@ -0,0 +1,274 @@ +const { when } = require('jest-when') +const Environments = require('../../../../lib/plugins/environments') + +describe('Environments', () => { + let github + const org = 'bkeepers' + const repo = 'test' + + function fillEnvironment(attrs) { + if (!attrs.wait_timer) attrs.wait_timer = 0; + if (!attrs.prevent_self_review) attrs.prevent_self_review = false; + if (!attrs.reviewers) attrs.reviewers = []; + if (!attrs.deployment_branch_policy) attrs.deployment_branch_policy = null; + if(!attrs.variables) attrs.variables = []; + if(!attrs.deployment_protection_rules) attrs.deployment_protection_rules = []; + if(!attrs.protection_rules) attrs.protection_rules = []; + + return attrs; + } + + beforeAll(() => { + github = { + request: jest.fn().mockReturnValue(Promise.resolve(true)) + } + }) + + it('sync', () => { + const plugin = new Environments(undefined, github, {owner: org, repo}, [ + { + name: 'wait-timer', + wait_timer: 1 + }, + { + name: 'reviewers', + reviewers: [ + { + type: 'User', + id: 1 + }, + { + type: 'Team', + id: 2 + } + ] + }, + { + name: 'prevent-self-review', + prevent_self_review: true + }, + { + name: 'deployment-branch-policy', + deployment_branch_policy: { + protected_branches: true, + custom_branch_policies: false + } + }, + { + name: 'deployment-branch-policy-custom', + deployment_branch_policy: { + protected_branches: false, + custom_branch_policies: [ + 'master', + 'dev' + ] + } + }, + { + name: 'variables', + variables: [ + { + name: 'test', + value: 'test' + } + ] + }, + { + name: 'deployment-protection-rules', + deployment_protection_rules: [ + { + app_id: 1 + } + ] + } + ], { + debug: function() {} + }); + + when(github.request) + .calledWith('GET /repos/:org/:repo/environments', { org, repo }) + .mockResolvedValue({ + data: { + environments: [ + fillEnvironment({ + name: 'wait-timer', + wait_timer: 0 + }), + fillEnvironment({ + name: 'reviewers', + reviewers: [] + }), + fillEnvironment({ + name: 'prevent-self-review', + prevent_self_review: false + }), + fillEnvironment({ + name: 'deployment-branch-policy', + deployment_branch_policy: null + }), + fillEnvironment({ + name: 'deployment-branch-policy-custom', + deployment_branch_policy: null + }), + fillEnvironment({ + name: 'variables', + variables: [] + }), + fillEnvironment({ + name: 'deployment-protection-rules', + deployment_protection_rules: [] + }) + ] + } + }); + + ['wait-timer', 'reviewers', 'prevent-self-review', 'deployment-branch-policy', 'deployment-branch-policy-custom', 'variables', 'deployment-protection-rules'].forEach((environment_name) => { + when(github.request) + .calledWith('GET /repos/:org/:repo/environments/:environment_name/variables', { org, repo, environment_name }) + .mockResolvedValue({ + data: { + variables: [] + } + }) + }); + + ['wait-timer', 'reviewers', 'prevent-self-review', 'deployment-branch-policy', 'deployment-branch-policy-custom', 'variables', 'deployment-protection-rules'].forEach((environment_name) => { + when(github.request) + .calledWith('GET /repos/:org/:repo/environments/:environment_name/deployment_protection_rules', { org, repo, environment_name }) + .mockResolvedValue({ + data: { + custom_deployment_protection_rules: [] + } + }) + }); + + when(github.request) + .calledWith('GET /repos/:org/:repo/environments/:environment_name/deployment-branch-policies', { org, repo, environment_name: 'deployment-branch-policy-custom' }) + .mockResolvedValue({ + data: { + branch_policies: [] + } + }); + + when(github.request) + .calledWith('DELETE /repos/:org/:repo/environments/:environment_name/deployment-branch-policies/:branch_policy_id') + .mockResolvedValue({}); + + when(github.request) + .calledWith('POST /repos/:org/:repo/environments/:environment_name/deployment-branch-policies') + .mockResolvedValue({}); + + when(github.request) + .calledWith('PUT /repos/:org/:repo/environments/:environment_name') + .mockResolvedValue({}); + + when(github.request) + .calledWith('POST /repos/:org/:repo/environments/:environment_name/variables') + .mockResolvedValue({}); + + when(github.request) + .calledWith('POST /repos/:org/:repo/environments/:environment_name/deployment_protection_rules') + .mockResolvedValue({}); + + when(github.request) + .calledWith('DELETE /repos/:org/:repo/environments/:environment_name/deployment_protection_rules/:rule_id') + .mockResolvedValue({}); + + + return plugin.sync().then(() => { + expect(github.request).toHaveBeenCalledWith('GET /repos/:org/:repo/environments', { org, repo }); + + ['wait-timer', 'reviewers', 'prevent-self-review', 'deployment-branch-policy', 'deployment-branch-policy-custom', 'variables', 'deployment-protection-rules'].forEach((environment_name) => { + expect(github.request).toHaveBeenCalledWith('GET /repos/:org/:repo/environments/:environment_name/variables', { org, repo, environment_name }); + + expect(github.request).toHaveBeenCalledWith('GET /repos/:org/:repo/environments/:environment_name/deployment_protection_rules', { org, repo, environment_name }); + }); + + expect(github.request).toHaveBeenCalledWith('PUT /repos/:org/:repo/environments/:environment_name', expect.objectContaining({ + org, + repo, + environment_name: 'wait-timer', + wait_timer: 1 + })); + + expect(github.request).toHaveBeenCalledWith('PUT /repos/:org/:repo/environments/:environment_name', expect.objectContaining({ + org, + repo, + environment_name: 'reviewers', + reviewers: [ + { + type: 'User', + id: 1 + }, + { + type: 'Team', + id: 2 + } + ] + })); + + expect(github.request).toHaveBeenCalledWith('PUT /repos/:org/:repo/environments/:environment_name', expect.objectContaining({ + org, + repo, + environment_name: 'prevent-self-review', + prevent_self_review: true + })); + + expect(github.request).toHaveBeenCalledWith('PUT /repos/:org/:repo/environments/:environment_name', expect.objectContaining({ + org, + repo, + environment_name: 'prevent-self-review', + prevent_self_review: true + })); + + expect(github.request).toHaveBeenCalledWith('PUT /repos/:org/:repo/environments/:environment_name', expect.objectContaining({ + org, + repo, + environment_name: 'deployment-branch-policy', + deployment_branch_policy: { + protected_branches: true, + custom_branch_policies: false + } + })); + + expect(github.request).toHaveBeenCalledWith('PUT /repos/:org/:repo/environments/:environment_name', expect.objectContaining({ + org, + repo, + environment_name: 'deployment-branch-policy-custom', + deployment_branch_policy: { + protected_branches: false, + custom_branch_policies: true + } + })); + + expect(github.request).toHaveBeenCalledWith('POST /repos/:org/:repo/environments/:environment_name/deployment-branch-policies', expect.objectContaining({ + org, + repo, + environment_name: 'deployment-branch-policy-custom', + name: 'master' + })); + + expect(github.request).toHaveBeenCalledWith('POST /repos/:org/:repo/environments/:environment_name/deployment-branch-policies', expect.objectContaining({ + org, + repo, + environment_name: 'deployment-branch-policy-custom', + name: 'dev' + })); + + expect(github.request).toHaveBeenCalledWith('POST /repos/:org/:repo/environments/:environment_name/variables', expect.objectContaining({ + org, + repo, + environment_name: 'variables', + name: 'test', + value: 'test' + })); + + expect(github.request).toHaveBeenCalledWith('POST /repos/:org/:repo/environments/:environment_name/deployment_protection_rules', expect.objectContaining({ + org, + repo, + environment_name: 'deployment-protection-rules', + integration_id: 1 + })); + }) + }) +}) \ No newline at end of file