diff --git a/README.md b/README.md index 7b92f73b9e..b30bda0cb5 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,25 @@ collaborators: # * `maintain` - Recommended for project managers who need to manage the repository without access to sensitive or destructive actions. # * `triage` - Recommended for contributors who need to proactively manage issues and pull requests without write access. +# See https://docs.github.com/en/rest/deployments/environments#create-or-update-an-environment for available options +# Note: deployment_branch_policy differs from the API for ease of use. Either protected_branches (boolean) OR custom_branches (array of strings) can be provided; this will manage the API requirements under the hood. See https://docs.github.com/en/rest/deployments/branch-policies for documentation of custom_branches. If both are provided in an unexpected manner, protected_branches will be used. +# Either removing or simply not setting deployment_branch_policy will restore the default 'All branches' setting. +environments: + - name: production + wait_timer: 5 + reviewers: + - id: 1 + type: 'Team' + - id: 2 + type: 'User' + deployment_branch_policy: + protected_branches: true + - name: development + deployment_branch_policy: + custom_branches: + - main + - dev/* + # See https://docs.github.com/en/rest/reference/teams#add-or-update-team-repository-permissions for available options teams: - name: core diff --git a/lib/plugins/environments.js b/lib/plugins/environments.js new file mode 100644 index 0000000000..9e2f59eaf8 --- /dev/null +++ b/lib/plugins/environments.js @@ -0,0 +1,180 @@ +const Diffable = require('./diffable') + +const environmentRepoEndpoint = '/repos/:org/:repo/environments/:environment_name' + +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() + }) + } + } + + async find () { + const { + data: { environments } + } = await this.github.request('GET /repos/:org/:repo/environments', { + org: this.repo.owner, + repo: this.repo.repo + }) + return Promise.all( + environments.map(async environment => { + if (environment.deployment_branch_policy) { + if (environment.deployment_branch_policy.custom_branch_policies) { + const branchPolicies = await this.getDeploymentBranchPolicies( + this.repo.owner, + this.repo.repo, + environment.name + ) + environment.deployment_branch_policy = { + custom_branches: branchPolicies.map(_ => _.name) + } + } else { + environment.deployment_branch_policy = { + protected_branches: true + } + } + } + return { + ...environment, + // Force all names to lowercase to avoid comparison issues. + name: environment.name.toLowerCase() + } + }) + ) + } + + comparator (existing, attrs) { + return existing.name === attrs.name + } + + changed (existing, attrs) { + if (!attrs.wait_timer) attrs.wait_timer = 0 + return ( + (existing.wait_timer || 0) !== attrs.wait_timer || + this.reviewersToString(existing.reviewers) !== this.reviewersToString(attrs.reviewers) || + this.deploymentBranchPolicyToString(existing.deployment_branch_policy) !== + this.deploymentBranchPolicyToString(attrs.deployment_branch_policy) + ) + } + + async update (existing, attrs) { + if (existing.deployment_branch_policy && existing.deployment_branch_policy.custom_branches) { + const branchPolicies = await this.getDeploymentBranchPolicies(this.repo.owner, this.repo.repo, existing.name) + await Promise.all( + branchPolicies.map(branchPolicy => + this.github.request( + 'DELETE /repos/:org/:repo/environments/:environment_name/deployment-branch-policies/:id', + { + org: this.repo.owner, + repo: this.repo.repo, + environment_name: existing.name, + id: branchPolicy.id + } + ) + ) + ) + } + return this.add(attrs) + } + + async add (attrs) { + await this.github.request(`PUT ${environmentRepoEndpoint}`, this.toParams({ name: attrs.name }, attrs)) + if (attrs.deployment_branch_policy && attrs.deployment_branch_policy.custom_branches) { + await Promise.all( + attrs.deployment_branch_policy.custom_branches.map(name => + 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 + }) + ) + ) + } + } + + remove (existing) { + return this.github.request(`DELETE ${environmentRepoEndpoint}`, { + environment_name: existing.name, + repo: this.repo.repo, + org: this.repo.owner + }) + } + + reviewersToString (attrs) { + if (attrs === null || attrs === undefined) { + return '' + } else { + attrs.sort((a, b) => { + if (a.id < b.id) return -1 + if (a.id > b.id) return 1 + if (a.type < b.type) return -1 + if (a.type > b.type) return 1 + return 0 + }) + return JSON.stringify( + attrs.map(reviewer => { + return { + id: reviewer.id, + type: reviewer.type + } + }) + ) + } + } + + deploymentBranchPolicyToString (attrs) { + if (attrs === null || attrs === undefined) { + return '' + } else { + return JSON.stringify( + this.shouldUseProtectedBranches(attrs.protected_branches, attrs.custom_branches) + ? { protected_branches: true } + : { custom_branches: attrs.custom_branches.sort() } + ) + } + } + + async getDeploymentBranchPolicies (owner, repo, environmentName) { + const { + data: { branch_policies: branchPolicies } + } = await this.github.request('GET /repos/:org/:repo/environments/:environment_name/deployment-branch-policies', { + org: owner, + repo, + environment_name: environmentName + }) + return branchPolicies + } + + toParams (existing, attrs) { + const deploymentBranchPolicy = attrs.deployment_branch_policy + ? this.shouldUseProtectedBranches( + attrs.deployment_branch_policy.protected_branches, + attrs.deployment_branch_policy.custom_branches + ) + ? { protected_branches: true, custom_branch_policies: false } + : { protected_branches: false, custom_branch_policies: true } + : null + return { + environment_name: existing.name, + repo: this.repo.repo, + org: this.repo.owner, + wait_timer: attrs.wait_timer, + reviewers: attrs.reviewers, + deployment_branch_policy: deploymentBranchPolicy + } + } + + shouldUseProtectedBranches (protectedBranches, customBranchPolicies) { + if (protectedBranches || customBranchPolicies === undefined || customBranchPolicies === null) { + return true // Returning booleans like this to avoid unexpected datatypes that result in truthy values + } else { + return false + } + } +} diff --git a/lib/settings.js b/lib/settings.js index fe7e6f8780..cf2da95db5 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -38,6 +38,7 @@ Settings.PLUGINS = { repository: require('./plugins/repository'), labels: require('./plugins/labels'), collaborators: require('./plugins/collaborators'), + environments: require('./plugins/environments'), teams: require('./plugins/teams'), milestones: require('./plugins/milestones'), branches: require('./plugins/branches') diff --git a/package.json b/package.json index 7372a6b14b..d6aba3d2c1 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "lint:peer": "npm ls >/dev/null", "test:unit": "jest 'test/unit/'", "test:unit:watch": "npm run test:unit -- --watch", - "test:integration": "jest --test-timeout=10000 'test/integration/'", + "test:integration": "jest --test-timeout=20000 'test/integration/'", "test:integration:debug": "LOG_LEVEL=debug DEBUG=nock.* run-s test:integration" }, "author": "Brandon Keepers", diff --git a/test/fixtures/environments-config.yml b/test/fixtures/environments-config.yml new file mode 100644 index 0000000000..b9bd55058e --- /dev/null +++ b/test/fixtures/environments-config.yml @@ -0,0 +1,81 @@ +environments: + - name: changed-wait-timer + wait_timer: 10 + + - name: changed-reviewers-type + reviewers: + - id: 1 + type: User + - id: 2 + type: User + + - name: changed-reviewers-id + reviewers: + - id: 1 + type: Team + - id: 3 + type: User + + - name: changed-reviewers-remove + reviewers: + - id: 1 + type: Team + + - name: changed-reviewers-add + reviewers: + - id: 1 + type: Team + - id: 2 + type: User + - id: 3 + type: User + + - name: changed-deployment-branch-policy + deployment_branch_policy: + custom_branches: + - stage/* + - uat/* + + - name: changed-all + wait_timer: 10 + reviewers: + - id: 2 + type: User + deployment_branch_policy: + custom_branches: + - dev/* + + - name: new-environment + wait_timer: 1 + reviewers: + - id: 1 + type: Team + - id: 2 + type: User + deployment_branch_policy: + protected_branches: true + + - name: unchanged-default-wait-timer + + - name: unchanged-wait-timer + wait_timer: 1 + + - name: unchanged-reviewers-unsorted + reviewers: + - id: 2 + type: User + - id: 1 + type: Team + + - name: unchanged-reviewers-sorted + reviewers: + - id: 1 + type: Team + - id: 2 + type: User + + - name: unchanged-deployment-branch-policy + deployment_branch_policy: + custom_branches: + - dev/* + - dev-* diff --git a/test/integration/plugins/environments.test.js b/test/integration/plugins/environments.test.js new file mode 100644 index 0000000000..b0f23a7fb7 --- /dev/null +++ b/test/integration/plugins/environments.test.js @@ -0,0 +1,211 @@ +const path = require('path') +const fs = require('fs') +const { CREATED, NO_CONTENT, OK } = require('http-status-codes') +const settings = require('../../../lib/settings') +const { buildTriggerEvent, initializeNock, loadInstance, repository, teardownNock } = require('../common') + +describe('environments plugin', function () { + let probot, githubScope + + beforeEach(async () => { + githubScope = initializeNock() + probot = await loadInstance() + }) + + afterEach(() => { + teardownNock(githubScope) + }) + + it('syncs environments', async () => { + const pathToConfig = path.resolve(__dirname, '..', '..', 'fixtures', 'environments-config.yml') + const configFile = Buffer.from(fs.readFileSync(pathToConfig, 'utf8')) + const config = configFile.toString() + githubScope + .get(`/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent(settings.FILE_NAME)}`) + .reply(OK, config) + githubScope.get(`/repos/${repository.owner.name}/${repository.name}/environments`).reply(OK, { + environments: [ + { name: 'changed-wait-timer', wait_timer: 1 }, + { + name: 'changed-reviewers-type', + reviewers: [ + { id: 1, type: 'Team' }, + { id: 2, type: 'User' } + ] + }, + { + name: 'changed-reviewers-id', + reviewers: [ + { id: 1, type: 'Team' }, + { id: 2, type: 'User' } + ] + }, + { + name: 'changed-reviewers-remove', + reviewers: [ + { id: 1, type: 'Team' }, + { id: 2, type: 'User' } + ] + }, + { + name: 'changed-reviewers-add', + reviewers: [ + { id: 1, type: 'Team' }, + { id: 2, type: 'User' } + ] + }, + { + name: 'changed-deployment-branch-policy', + deployment_branch_policy: { protected_branches: true, custom_branch_policies: false } + }, + { name: 'changed-all', wait_timer: 0 }, + { name: 'unchanged-default-wait-timer', wait_timer: 0 }, + { name: 'unchanged-wait-timer', wait_timer: 1 }, + { + name: 'unchanged-reviewers-unsorted', + reviewers: [ + { id: 1, type: 'Team' }, + { id: 2, type: 'User' } + ] + }, + { + name: 'unchanged-reviewers-sorted', + reviewers: [ + { id: 1, type: 'Team' }, + { id: 2, type: 'User' } + ] + }, + { + name: 'unchanged-deployment-branch-policy', + deployment_branch_policy: { protected_branches: false, custom_branch_policies: true } + }, + { name: 'deleted', wait_timer: 0 } + ] + }) + githubScope + .get( + `/repos/${repository.owner.name}/${repository.name}/environments/unchanged-deployment-branch-policy/deployment-branch-policies` + ) + .reply(OK, { + branch_policies: [ + { + id: 1, + node_id: '1', + name: 'dev/*' + }, + { + id: 2, + node_id: '2', + name: 'dev-*' + } + ] + }) + githubScope + .put(`/repos/${repository.owner.name}/${repository.name}/environments/changed-wait-timer`, body => { + expect(body).toMatchObject({ wait_timer: 10 }) + return true + }) + .reply(CREATED) + githubScope + .put(`/repos/${repository.owner.name}/${repository.name}/environments/changed-reviewers-type`, body => { + expect(body).toMatchObject({ + reviewers: [ + { id: 1, type: 'User' }, + { id: 2, type: 'User' } + ] + }) + return true + }) + .reply(CREATED) + githubScope + .put(`/repos/${repository.owner.name}/${repository.name}/environments/changed-reviewers-id`, body => { + expect(body).toMatchObject({ + reviewers: [ + { id: 1, type: 'Team' }, + { id: 3, type: 'User' } + ] + }) + return true + }) + .reply(CREATED) + githubScope + .put(`/repos/${repository.owner.name}/${repository.name}/environments/changed-reviewers-remove`, body => { + expect(body).toMatchObject({ reviewers: [{ id: 1, type: 'Team' }] }) + return true + }) + .reply(CREATED) + githubScope + .put(`/repos/${repository.owner.name}/${repository.name}/environments/changed-reviewers-add`, body => { + expect(body).toMatchObject({ + reviewers: [ + { id: 1, type: 'Team' }, + { id: 2, type: 'User' }, + { id: 3, type: 'User' } + ] + }) + return true + }) + .reply(CREATED) + githubScope + .put(`/repos/${repository.owner.name}/${repository.name}/environments/changed-deployment-branch-policy`, body => { + expect(body).toMatchObject({ + deployment_branch_policy: { protected_branches: false, custom_branch_policies: true } + }) + return true + }) + .reply(CREATED) + githubScope + .post( + `/repos/${repository.owner.name}/${repository.name}/environments/changed-deployment-branch-policy/deployment-branch-policies`, + body => { + expect(body).toMatchObject({ name: 'stage/*' }) + return true + } + ) + .reply(OK, { id: 3, node_id: '3', name: 'stage/*' }) + githubScope + .post( + `/repos/${repository.owner.name}/${repository.name}/environments/changed-deployment-branch-policy/deployment-branch-policies`, + body => { + expect(body).toMatchObject({ name: 'uat/*' }) + return true + } + ) + .reply(OK, { id: 4, node_id: '4', name: 'uat/*' }) + githubScope + .put(`/repos/${repository.owner.name}/${repository.name}/environments/changed-all`, body => { + expect(body).toMatchObject({ + wait_timer: 10, + reviewers: [{ id: 2, type: 'User' }], + deployment_branch_policy: { protected_branches: false, custom_branch_policies: true } + }) + return true + }) + .reply(CREATED) + githubScope + .post( + `/repos/${repository.owner.name}/${repository.name}/environments/changed-all/deployment-branch-policies`, + body => { + expect(body).toMatchObject({ name: 'dev/*' }) + return true + } + ) + .reply(OK, { id: 5, node_id: '5', name: 'dev/*' }) + githubScope + .put(`/repos/${repository.owner.name}/${repository.name}/environments/new-environment`, body => { + expect(body).toMatchObject({ + wait_timer: 1, + reviewers: [ + { id: 1, type: 'Team' }, + { id: 2, type: 'User' } + ], + deployment_branch_policy: { protected_branches: true, custom_branch_policies: false } + }) + return true + }) + .reply(CREATED) + githubScope.delete(`/repos/${repository.owner.name}/${repository.name}/environments/deleted`).reply(NO_CONTENT) + + await probot.receive(buildTriggerEvent()) + }) +}) diff --git a/test/unit/lib/plugins/environments.test.js b/test/unit/lib/plugins/environments.test.js new file mode 100644 index 0000000000..bf399e9b49 --- /dev/null +++ b/test/unit/lib/plugins/environments.test.js @@ -0,0 +1,226 @@ +const { when } = require('jest-when') +const Environments = require('../../../../lib/plugins/environments') + +describe('Environments', () => { + let github + const org = 'bkeepers' + const repo = 'test' + + function configure (config) { + return new Environments(github, { owner: org, repo }, config) + } + + beforeEach(() => { + github = { + request: jest.fn().mockReturnValue(Promise.resolve(true)) + } + }) + + describe('sync', () => { + it('syncs environments', () => { + const plugin = configure([ + { name: 'changed-wait-timer', wait_timer: 10 }, + { + name: 'changed-reviewers-type', + reviewers: [ + { id: 1, type: 'User' }, + { id: 2, type: 'User' } + ] + }, + { + name: 'new-environment', + wait_timer: 1, + reviewers: [ + { + id: 1, + type: 'Team' + }, + { + id: 2, + type: 'User' + } + ], + deployment_branch_policy: { + custom_branches: ['dev/*', 'dev-*'] + } + }, + { + name: 'changed-branch-policy', + deployment_branch_policy: { + protected_branches: true + } + }, + { + name: 'unchanged-reviewers-unsorted', + reviewers: [ + { + id: 2, + type: 'User' + }, + { + id: 1, + type: 'Team' + } + ] + }, + { name: 'Different-case', wait_timer: 0 } + ]) + + when(github.request) + .calledWith('GET /repos/:org/:repo/environments', { org, repo }) + .mockResolvedValue({ + data: { + environments: [ + { name: 'different-Case', wait_timer: 0 }, + { name: 'changed-wait-timer', wait_timer: 0 }, + { + name: 'changed-reviewers-type', + reviewers: [ + { id: 1, type: 'Team' }, + { id: 2, type: 'User' } + ] + }, + { + name: 'changed-branch-policy', + deployment_branch_policy: { + protected_branches: false, + custom_branch_policies: true + } + }, + { + name: 'unchanged-reviewers-unsorted', + reviewers: [ + { id: 1, type: 'Team' }, + { id: 2, type: 'User' } + ] + }, + { name: 'deleted', wait_timer: 0 } + ] + } + }) + + when(github.request) + .calledWith('GET /repos/:org/:repo/environments/:environment_name/deployment-branch-policies', { + org, + repo, + environment_name: 'changed-branch-policy' + }) + .mockResolvedValue({ + data: { + branch_policies: [ + { + id: 2, + node_id: '2', + name: 'dev-*' + }, + { + id: 1, + node_id: '1', + name: 'dev/*' + } + ] + } + }) + + return plugin.sync().then(() => { + expect(github.request).toHaveBeenCalledWith('PUT /repos/:org/:repo/environments/:environment_name', { + org, + repo, + environment_name: 'changed-wait-timer', + deployment_branch_policy: null, + wait_timer: 10 + }) + + expect(github.request).toHaveBeenCalledWith('PUT /repos/:org/:repo/environments/:environment_name', { + org, + repo, + environment_name: 'changed-reviewers-type', + deployment_branch_policy: null, + wait_timer: 0, + reviewers: [ + { id: 1, type: 'User' }, + { id: 2, type: 'User' } + ] + }) + + expect(github.request).toHaveBeenCalledWith('PUT /repos/:org/:repo/environments/:environment_name', { + org, + repo, + environment_name: 'new-environment', + wait_timer: 1, + reviewers: [ + { + id: 1, + type: 'Team' + }, + { + id: 2, + type: 'User' + } + ], + deployment_branch_policy: { + protected_branches: false, + custom_branch_policies: true + } + }) + + expect(github.request).toHaveBeenCalledWith( + 'POST /repos/:org/:repo/environments/:environment_name/deployment-branch-policies', + { + org, + repo, + environment_name: 'new-environment', + name: 'dev/*' + } + ) + + expect(github.request).toHaveBeenCalledWith( + 'POST /repos/:org/:repo/environments/:environment_name/deployment-branch-policies', + { + org, + repo, + environment_name: 'new-environment', + name: 'dev-*' + } + ) + + expect(github.request).toHaveBeenCalledWith( + 'DELETE /repos/:org/:repo/environments/:environment_name/deployment-branch-policies/:id', + { + org, + repo, + environment_name: 'changed-branch-policy', + id: 1 + } + ) + + expect(github.request).toHaveBeenCalledWith( + 'DELETE /repos/:org/:repo/environments/:environment_name/deployment-branch-policies/:id', + { + org, + repo, + environment_name: 'changed-branch-policy', + id: 2 + } + ) + + expect(github.request).toHaveBeenCalledWith('PUT /repos/:org/:repo/environments/:environment_name', { + org, + repo, + environment_name: 'changed-branch-policy', + wait_timer: 0, + deployment_branch_policy: { + protected_branches: true, + custom_branch_policies: false + } + }) + + expect(github.request).toHaveBeenCalledWith('DELETE /repos/:org/:repo/environments/:environment_name', { + org, + repo, + environment_name: 'deleted' + }) + }) + }) + }) +})