diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a94b4fb7..e5c6a321 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,7 +9,7 @@ on: - main env: - IMAGE_NAME: ghcr.io/renovatebot/renovate-approve-bot-bitbucket-cloud + IMAGE_NAME: ghcr.io/renovatebot/renovate-approve-bot-bitbucket concurrency: group: ${{ github.workflow }}-${{ github.event.number || github.ref }} diff --git a/Dockerfile b/Dockerfile index 3d3386a8..aaaba9a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ FROM docker.io/library/node:14.19.1-alpine@sha256:8845b4f88f64f8c56a39236648ba22946e806a6153c10911f77b70e5a2edb4ca LABEL \ - org.opencontainers.image.source="https://github.com/renovatebot/renovate-approve-bot-bitbucket-cloud" \ - org.opencontainers.image.url="https://github.com/renovatebot/renovate-approve-bot-bitbucket-cloud" \ + org.opencontainers.image.source="https://github.com/renovatebot/renovate-approve-bot-bitbucket" \ + org.opencontainers.image.url="https://github.com/renovatebot/renovate-approve-bot-bitbucket" \ org.opencontainers.image.licenses="ISC" WORKDIR /opt/app diff --git a/README.md b/README.md index 4aa3b98b..7460d8a6 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# renovate-approve-bot - Bitbucket Cloud Edition +# renovate-approve-bot - Bitbucket Edition -A job to approve Pull Requests from [Renovate Bot](https://github.com/renovatebot/renovate) on Bitbucket Cloud. This enables you to require Pull Request approvals on your repository while also utilising Renovate's "automerge" feature. +A job to approve Pull Requests from [Renovate Bot](https://github.com/renovatebot/renovate) on Bitbucket Cloud and Data Center (and Server). This enables you to require Pull Request approvals on your repository while also utilising Renovate's "automerge" feature. For Github, see [renovatebot/renovate-approve-bot](https://github.com/renovatebot/renovate-approve-bot). -[![build](https://github.com/renovatebot/renovate-approve-bot-bitbucket-cloud/actions/workflows/build.yml/badge.svg)](https://github.com/renovatebot/renovate-approve-bot-bitbucket-cloud/actions/workflows/build.yml) +[![build](https://github.com/renovatebot/renovate-approve-bot-bitbucket/actions/workflows/build.yml/badge.svg)](https://github.com/renovatebot/renovate-approve-bot-bitbucket/actions/workflows/build.yml) ## How it works @@ -14,7 +14,7 @@ On each run, the bot will: 2. Filter out PRs where "automerge" is disabled 3. Approve the "automerge" PRs -## Usage +## Bitbucket Cloud usage 1. Create a Bitbucket Cloud account for the renovate-approve-bot and add it to your team (Recommended) 2. [Create an App password](https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/) with `pullrequest:write` scope @@ -33,7 +33,7 @@ On each run, the bot will: --env BITBUCKET_USERNAME \ --env BITBUCKET_PASSWORD \ --env RENOVATE_BOT_USER \ - ghcr.io/renovatebot/renovate-approve-bot-bitbucket-cloud:latest + ghcr.io/renovatebot/renovate-approve-bot-bitbucket:latest ``` - From source: @@ -43,7 +43,7 @@ On each run, the bot will: node ./index.js ``` -## Bitbucket Pipelines example +### Bitbucket Pipelines example Example to run renovate-approve-bot in a custom Bitbucket Pipeline on a schedule: @@ -56,7 +56,7 @@ Example to run renovate-approve-bot in a custom Bitbucket Pipeline on a schedule renovate-approve-bot: - step: name: Renovate Approve Bot - image: ghcr.io/renovatebot/renovate-approve-bot-bitbucket-cloud:latest + image: ghcr.io/renovatebot/renovate-approve-bot-bitbucket:latest script: - export RENOVATE_BOT_USER=your-renovate-bot-user - node /opt/app/index.js @@ -64,7 +64,38 @@ Example to run renovate-approve-bot in a custom Bitbucket Pipeline on a schedule 3. Create a [schedule](https://support.atlassian.com/bitbucket-cloud/docs/pipeline-triggers/#On-schedule) for the custom pipeline (e.g. Hourly) +## Bitbucket Data Center and Server usage + +1. Create an account for the renovate-approve-bot in your Bitbucket Data Center or Server instance +2. Create a [HTTP access token](https://confluence.atlassian.com/bitbucketserver/http-access-tokens-939515499.html) (formerly known as personal access token) for the renovate-approve-bot account +3. Grant read access on your repositories to the renovate-approve-bot account +4. Optionally, add the renovate-approve-bot account to the default reviewers if you require approval from default reviewers +5. Set the environment variables: + - `BITBUCKET_URL`: URL of your Bitbucket Data Center or Server instance + - `BITBUCKET_USERNAME`: Bitbucket username associated with the account used for renovate-approve-bot + - `BITBUCKET_PASSWORD`: HTTP access token of the renovate-approve-bot account + - `RENOVATE_BOT_USER`: Bitbucket username of your Renovate Bot +6. Run the bot (on a schedule similarly to Renovate Bot, e.g. as a [Cron](https://en.wikipedia.org/wiki/Cron) job): + + - With Docker: + + ```shell + docker run --rm \ + --env BITBUCKET_URL \ + --env BITBUCKET_USERNAME \ + --env BITBUCKET_PASSWORD \ + --env RENOVATE_BOT_USER \ + ghcr.io/renovatebot/renovate-approve-bot-bitbucket:latest + ``` + + - From source: + + ```shell + npm install --production + node ./index.js + ``` + ## Security / Disclosure -If you discover any important bug with `renovate-approve-bot-bitbucket-cloud` that may pose a security problem, please disclose it confidentially to renovate-disclosure@whitesourcesoftware.com first, so that it can be assessed and hopefully fixed prior to being exploited. +If you discover any important bug with `renovate-approve-bot-bitbucket` that may pose a security problem, please disclose it confidentially to renovate-disclosure@whitesourcesoftware.com first, so that it can be assessed and hopefully fixed prior to being exploited. Please do not raise GitHub issues for security-related doubts or problems. diff --git a/adapter/bitbucket-cloud.js b/adapter/bitbucket-cloud.js new file mode 100644 index 00000000..79a87cf9 --- /dev/null +++ b/adapter/bitbucket-cloud.js @@ -0,0 +1,72 @@ +const got = require('got'); + +const MANUAL_MERGE_MESSAGE = 'merge this manually'; + +class BitbucketCloudAdapter { + constructor(options) { + this.options = options; + this.log = options.log; + this.defaultRequestOptions = { + prefixUrl: 'https://api.bitbucket.org', + // If we use username/password options, they will be URL encoded + // https://github.com/sindresorhus/got/issues/1169 + // https://github.com/nodejs/node/issues/31439 + headers: { + Authorization: `Basic ${Buffer.from( + `${options.BITBUCKET_USERNAME}:${options.BITBUCKET_PASSWORD}` + ).toString('base64')}`, + }, + responseType: 'json', + }; + } + + isAutomerging(pr) { + try { + return !pr.description.includes(MANUAL_MERGE_MESSAGE); + } catch (error) { + this.log.error(error); + return false; + } + } + + getPullRequests() { + const prEndpoint = `/2.0/pullrequests/${this.options.RENOVATE_BOT_USER}`; + this.log.info( + 'Requesting %s%s...', + this.defaultRequestOptions.prefixUrl, + prEndpoint + ); + + return got.paginate('', { + ...this.defaultRequestOptions, + pathname: prEndpoint, + pagination: { + transform: (response) => + response.body.values + .filter((pr) => this.isAutomerging(pr)) + .map((pr) => pr.links.self.href), + paginate: (response) => { + if ('next' in response.body && response.body.next !== '') { + this.options.log.info('Requesting %s...', response.body.next); + return { + url: new URL(response.body.next), + }; + } + this.options.log.info('All pull-requests gathered.'); + return false; + }, + }, + }); + } + + approvePullRequest(prHref) { + return got('', { + ...this.defaultRequestOptions, + prefixUrl: `${prHref}/approve`, + method: 'POST', + throwHttpErrors: false, + }); + } +} + +module.exports = BitbucketCloudAdapter; diff --git a/adapter/bitbucket-cloud.spec.js b/adapter/bitbucket-cloud.spec.js new file mode 100644 index 00000000..4cbf3009 --- /dev/null +++ b/adapter/bitbucket-cloud.spec.js @@ -0,0 +1,250 @@ +const nock = require('nock'); +const BitbucketCloudAdapter = require('./bitbucket-cloud'); + +const BITBUCKET_USERNAME = 'renovate-approve-bot'; +const BITBUCKET_PASSWORD = 'r3novate-@pprove-b0t'; +const RENOVATE_BOT_USER = 'renovate-bot'; + +const API_BASE_URL = 'https://api.bitbucket.org'; +const BASIC_AUTH = { user: BITBUCKET_USERNAME, pass: BITBUCKET_PASSWORD }; + +const autoMergeDescription = '...\n\n🚦 **Automerge**: Enabled.\n\n...'; +const manualMergeDescription = + '...\n\n🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.\n\n...'; + +afterEach(() => { + if (!nock.isDone()) { + throw new Error( + `Not all nock interceptors were used: ${JSON.stringify( + nock.pendingMocks() + )}` + ); + } + nock.cleanAll(); +}); + +describe('isAutomerging', () => { + const adapter = new BitbucketCloudAdapter({}); + + it('is automerging', () => { + const pr = { + description: autoMergeDescription, + // omitted attributes... + }; + + expect(adapter.isAutomerging(pr)).toBe(true); + }); + + it('is not automerging', () => { + const pr = { + description: manualMergeDescription, + // omitted attributes... + }; + + expect(adapter.isAutomerging(pr)).toBe(false); + }); +}); + +describe('getPullRequests', () => { + const adapter = new BitbucketCloudAdapter({ + BITBUCKET_USERNAME, + BITBUCKET_PASSWORD, + RENOVATE_BOT_USER, + log: { info: jest.fn() }, + }); + const pullRequestsEndpoint = `/2.0/pullrequests/${RENOVATE_BOT_USER}`; + + it('gets pull-requests in a single page', async () => { + nock(API_BASE_URL) + .get(pullRequestsEndpoint) + .basicAuth(BASIC_AUTH) + .reply(200, { + values: [ + { + description: autoMergeDescription, + links: { + self: { + href: 'https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/1', + }, + // omitted attributes... + }, + // omitted attributes... + }, + { + description: manualMergeDescription, + links: { + self: { + href: 'https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/2', + }, + // omitted attributes... + }, + // omitted attributes... + }, + { + description: autoMergeDescription, + links: { + self: { + href: 'https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/3', + }, + // omitted attributes... + }, + // omitted attributes... + }, + ], + // omitted attributes... + }); + + const pullRequests = []; + const prGenerator = await adapter.getPullRequests(); + for await (const prHref of prGenerator) { + pullRequests.push(prHref); + } + + expect(pullRequests).toStrictEqual([ + 'https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/1', + 'https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/3', + ]); + }); + + it('gets pull-requests in multiple pages', async () => { + nock(API_BASE_URL) + .get(pullRequestsEndpoint) + .basicAuth(BASIC_AUTH) + .reply(200, { + values: [ + { + description: autoMergeDescription, + links: { + self: { + href: 'https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/1', + }, + // omitted attributes... + }, + // omitted attributes... + }, + { + description: manualMergeDescription, + links: { + self: { + href: 'https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/2', + }, + // omitted attributes... + }, + // omitted attributes... + }, + { + description: autoMergeDescription, + links: { + self: { + href: 'https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/3', + }, + // omitted attributes... + }, + // omitted attributes... + }, + ], + next: `${API_BASE_URL}${pullRequestsEndpoint}?page=2`, + // omitted attributes... + }); + + nock(API_BASE_URL) + .get(pullRequestsEndpoint) + .basicAuth(BASIC_AUTH) + .query({ page: 2 }) + .reply(200, { + values: [ + { + description: manualMergeDescription, + links: { + self: { + href: 'https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/5', + }, + // omitted attributes... + }, + // omitted attributes... + }, + { + description: autoMergeDescription, + links: { + self: { + href: 'https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/6', + }, + // omitted attributes... + }, + // omitted attributes... + }, + ], + // omitted attributes... + }); + + const prHrefs = []; + const prGenerator = await adapter.getPullRequests(); + for await (const prHref of prGenerator) { + prHrefs.push(prHref); + } + + expect(prHrefs).toStrictEqual([ + 'https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/1', + 'https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/3', + 'https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/6', + ]); + }); + + it('gets no pull-requests', async () => { + nock(API_BASE_URL) + .get(pullRequestsEndpoint) + .basicAuth(BASIC_AUTH) + .reply(200, { + values: [], + // omitted attributes... + }); + + const prHrefs = []; + const prGenerator = await adapter.getPullRequests(); + for await (const prHref of prGenerator) { + prHrefs.push(prHref); + } + + expect(prHrefs).toStrictEqual([]); + }); +}); + +describe('approvePullRequest', () => { + const adapter = new BitbucketCloudAdapter({ + BITBUCKET_USERNAME, + BITBUCKET_PASSWORD, + RENOVATE_BOT_USER, + log: { info: jest.fn() }, + }); + + it('approves', async () => { + const prHref = '/2.0/repositories/myworkspace/myrepo/pullrequests/1'; + nock(API_BASE_URL) + .post(`${prHref}/approve/`) + .basicAuth(BASIC_AUTH) + .reply(200, { + // omitted attributes... + }); + + const response = await adapter.approvePullRequest(API_BASE_URL + prHref); + + expect(response.statusCode).toBe(200); + }); + + it('is already approved', async () => { + const prHref = '/2.0/repositories/myworkspace/myrepo/pullrequests/2'; + nock(API_BASE_URL) + .post(`${prHref}/approve/`) + .basicAuth(BASIC_AUTH) + .reply(409, { + type: 'error', + error: { + message: 'You already approved this pull request.', + }, + }); + + const response = await adapter.approvePullRequest(API_BASE_URL + prHref); + + expect(response.statusCode).toBe(409); + }); +}); diff --git a/adapter/bitbucket-data-center.js b/adapter/bitbucket-data-center.js new file mode 100644 index 00000000..e626f8c4 --- /dev/null +++ b/adapter/bitbucket-data-center.js @@ -0,0 +1,105 @@ +const got = require('got'); + +const MANUAL_MERGE_MESSAGE = 'merge this manually'; + +class BitbucketDataCenterAdapter { + constructor(options) { + this.options = options; + this.log = options.log; + this.defaultRequestOptions = { + prefixUrl: options.BITBUCKET_URL, + // If we use username/password options, they will be URL encoded + // https://github.com/sindresorhus/got/issues/1169 + // https://github.com/nodejs/node/issues/31439 + headers: { + Authorization: `Basic ${Buffer.from( + `${options.BITBUCKET_USERNAME}:${options.BITBUCKET_PASSWORD}` + ).toString('base64')}`, + }, + responseType: 'json', + }; + } + + isCreatedByRenovateBot(pr) { + try { + return pr.author.user.slug === this.options.RENOVATE_BOT_USER; + } catch (error) { + this.log.error(error); + return false; + } + } + + isAutomerging(pr) { + try { + return !pr.description.includes(MANUAL_MERGE_MESSAGE); + } catch (error) { + this.log.error(error); + return false; + } + } + + extractApiUrl(pr) { + for (const link of pr.links.self) { + const match = link.href.match( + /\/((?:projects|users)\/\S+\/repos\/\S+\/pull-requests\/\d+)/ + ); + + if (match) { + return [`${this.options.BITBUCKET_URL}/rest/api/1.0/${match[1]}`]; + } + } + + this.log.error('Could not extract API URL for %s', pr.links.self[0].href); + return []; + } + + getPullRequests() { + const prEndpoint = 'rest/api/1.0/dashboard/pull-requests'; + this.log.info( + 'Requesting %s/%s...', + this.defaultRequestOptions.prefixUrl, + prEndpoint + ); + + return got.paginate(prEndpoint, { + ...this.defaultRequestOptions, + searchParams: { + role: 'REVIEWER', + state: 'OPEN', + }, + pagination: { + transform: (response) => + response.body.values + .filter((pr) => this.isCreatedByRenovateBot(pr)) + .filter((pr) => this.isAutomerging(pr)) + .flatMap((pr) => this.extractApiUrl(pr)), + paginate: (response) => { + if ('isLastPage' in response.body && !response.body.isLastPage) { + return { + searchParams: { + start: response.body.nextPageStart, + }, + }; + } + + this.log.info('All pull-requests gathered.'); + return false; + }, + }, + }); + } + + approvePullRequest(prHref) { + return got(`participants/${this.options.BITBUCKET_USERNAME}`, { + ...this.defaultRequestOptions, + prefixUrl: prHref, + method: 'PUT', + throwHttpErrors: false, + json: { + status: 'APPROVED', + }, + }); + } +} + +module.exports = BitbucketDataCenterAdapter; diff --git a/adapter/bitbucket-data-center.spec.js b/adapter/bitbucket-data-center.spec.js new file mode 100644 index 00000000..46a9c565 --- /dev/null +++ b/adapter/bitbucket-data-center.spec.js @@ -0,0 +1,366 @@ +const nock = require('nock'); +const BitbucketDataCenterAdapter = require('./bitbucket-data-center'); + +const BITBUCKET_URL = 'http://bitbucket.example'; +const BITBUCKET_USERNAME = 'renovate-approve-bot'; +const BITBUCKET_PASSWORD = 'r3novate-@pprove-b0t'; +const RENOVATE_BOT_USER = 'renovate-bot'; + +const BASIC_AUTH = { user: BITBUCKET_USERNAME, pass: BITBUCKET_PASSWORD }; + +const autoMergeDescription = '...\n\n🚦 **Automerge**: Enabled.\n\n...'; +const manualMergeDescription = + '...\n\n🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.\n\n...'; + +afterEach(() => { + if (!nock.isDone()) { + throw new Error( + `Not all nock interceptors were used: ${JSON.stringify( + nock.pendingMocks() + )}` + ); + } + nock.cleanAll(); +}); + +describe('isAutomerging', () => { + const adapter = new BitbucketDataCenterAdapter({}); + + it('is automerging', () => { + const pr = { + description: autoMergeDescription, + // omitted attributes... + }; + + expect(adapter.isAutomerging(pr)).toBe(true); + }); + + it('is not automerging', () => { + const pr = { + description: manualMergeDescription, + // omitted attributes... + }; + + expect(adapter.isAutomerging(pr)).toBe(false); + }); +}); + +describe('getPullRequests', () => { + const adapter = new BitbucketDataCenterAdapter({ + BITBUCKET_URL, + BITBUCKET_USERNAME, + BITBUCKET_PASSWORD, + RENOVATE_BOT_USER, + log: { info: jest.fn() }, + }); + const pullRequestsEndpoint = '/rest/api/1.0/dashboard/pull-requests'; + + it('gets pull-requests in a single page', async () => { + nock(BITBUCKET_URL) + .get(pullRequestsEndpoint) + .basicAuth(BASIC_AUTH) + .query({ + role: 'REVIEWER', + state: 'OPEN', + }) + .reply(200, { + values: [ + { + description: autoMergeDescription, + author: { + user: { + slug: RENOVATE_BOT_USER, + // omitted attributes... + }, + // omitted attributes... + }, + links: { + self: [ + { + href: 'https://bitbucket.example/users/myuser/repos/myrepo/pull-requests/1', + }, + ], + // omitted attributes... + }, + // omitted attributes... + }, + { + description: manualMergeDescription, + author: { + user: { + slug: RENOVATE_BOT_USER, + // omitted attributes... + }, + // omitted attributes... + }, + links: { + self: [ + { + href: 'https://bitbucket.example/projects/myproject/repos/myrepo/pull-requests/2', + }, + ], + // omitted attributes... + }, + // omitted attributes... + }, + { + description: autoMergeDescription, + author: { + user: { + slug: RENOVATE_BOT_USER, + // omitted attributes... + }, + // omitted attributes... + }, + links: { + self: [ + { + href: 'https://bitbucket.example/projects/myproject/repos/myrepo/pull-requests/3', + }, + ], + // omitted attributes... + }, + // omitted attributes... + }, + { + description: autoMergeDescription, + author: { + user: { + slug: `not-${RENOVATE_BOT_USER}`, + // omitted attributes... + }, + // omitted attributes... + }, + links: { + self: [ + { + href: 'https://bitbucket.example/projects/myproject/repos/myrepo/pull-requests/3', + }, + ], + // omitted attributes... + }, + // omitted attributes... + }, + ], + // omitted attributes... + }); + + const pullRequests = []; + const prGenerator = await adapter.getPullRequests(); + for await (const prHref of prGenerator) { + pullRequests.push(prHref); + } + + expect(pullRequests).toStrictEqual([ + 'http://bitbucket.example/rest/api/1.0/users/myuser/repos/myrepo/pull-requests/1', + 'http://bitbucket.example/rest/api/1.0/projects/myproject/repos/myrepo/pull-requests/3', + ]); + }); + + it('gets pull-requests in multiple pages', async () => { + nock(BITBUCKET_URL) + .get(pullRequestsEndpoint) + .basicAuth(BASIC_AUTH) + .query({ + role: 'REVIEWER', + state: 'OPEN', + }) + .reply(200, { + values: [ + { + description: autoMergeDescription, + author: { + user: { + slug: RENOVATE_BOT_USER, + // omitted attributes... + }, + // omitted attributes... + }, + links: { + self: [ + { + href: 'https://bitbucket.example/projects/myproject/repos/myrepo/pull-requests/1', + }, + ], + // omitted attributes... + }, + // omitted attributes... + }, + { + description: manualMergeDescription, + author: { + user: { + slug: RENOVATE_BOT_USER, + // omitted attributes... + }, + // omitted attributes... + }, + links: { + self: [ + { + href: 'https://bitbucket.example/projects/myproject/repos/myrepo/pull-requests/2', + }, + ], + // omitted attributes... + }, + // omitted attributes... + }, + { + description: autoMergeDescription, + author: { + user: { + slug: RENOVATE_BOT_USER, + // omitted attributes... + }, + // omitted attributes... + }, + links: { + self: [ + { + href: 'https://bitbucket.example/projects/myproject/repos/myrepo/pull-requests/3', + }, + ], + // omitted attributes... + }, + // omitted attributes... + }, + ], + isLastPage: false, + nextPageStart: 3, + // omitted attributes... + }); + + nock(BITBUCKET_URL) + .get(pullRequestsEndpoint) + .basicAuth(BASIC_AUTH) + .query({ + role: 'REVIEWER', + state: 'OPEN', + start: 3, + }) + .reply(200, { + values: [ + { + description: manualMergeDescription, + author: { + user: { + slug: RENOVATE_BOT_USER, + // omitted attributes... + }, + // omitted attributes... + }, + links: { + self: [ + { + href: 'https://bitbucket.example/projects/myproject/repos/myrepo/pull-requests/5', + }, + ], + // omitted attributes... + }, + // omitted attributes... + }, + { + description: autoMergeDescription, + author: { + user: { + slug: RENOVATE_BOT_USER, + // omitted attributes... + }, + // omitted attributes... + }, + links: { + self: [ + { + href: 'https://bitbucket.example/projects/myproject/repos/myrepo/pull-requests/6', + }, + ], + // omitted attributes... + }, + // omitted attributes... + }, + ], + isLastPage: true, + // omitted attributes... + }); + + const prHrefs = []; + const prGenerator = await adapter.getPullRequests(); + for await (const prHref of prGenerator) { + prHrefs.push(prHref); + } + + expect(prHrefs).toStrictEqual([ + 'http://bitbucket.example/rest/api/1.0/projects/myproject/repos/myrepo/pull-requests/1', + 'http://bitbucket.example/rest/api/1.0/projects/myproject/repos/myrepo/pull-requests/3', + 'http://bitbucket.example/rest/api/1.0/projects/myproject/repos/myrepo/pull-requests/6', + ]); + }); + + it('gets no pull-requests', async () => { + nock(BITBUCKET_URL) + .get(pullRequestsEndpoint) + .basicAuth(BASIC_AUTH) + .query({ + role: 'REVIEWER', + state: 'OPEN', + }) + .reply(200, { + values: [], + isLastPage: true, + // omitted attributes... + }); + + const prHrefs = []; + const prGenerator = await adapter.getPullRequests(); + for await (const prHref of prGenerator) { + prHrefs.push(prHref); + } + + expect(prHrefs).toStrictEqual([]); + }); +}); + +describe('approvePullRequest', () => { + const adapter = new BitbucketDataCenterAdapter({ + BITBUCKET_URL, + BITBUCKET_USERNAME, + BITBUCKET_PASSWORD, + RENOVATE_BOT_USER, + log: { info: jest.fn() }, + }); + + it('approves', async () => { + const prHref = + '/rest/api/1.0/projects/myproject/repos/myrepo/pull-requests/1'; + nock(BITBUCKET_URL) + .put(`${prHref}/participants/${BITBUCKET_USERNAME}`) + .basicAuth(BASIC_AUTH) + .reply(200, { + // omitted attributes... + }); + + const response = await adapter.approvePullRequest(BITBUCKET_URL + prHref); + + expect(response.statusCode).toBe(200); + }); + + it('is already approved', async () => { + const prHref = + '/rest/api/1.0/projects/myproject/repos/myrepo/pull-requests/2'; + nock(BITBUCKET_URL) + .put(`${prHref}/participants/${BITBUCKET_USERNAME}`, { + status: 'APPROVED', + }) + .basicAuth(BASIC_AUTH) + .reply(409, { + type: 'error', + error: { + message: 'You already approved this pull request.', + }, + }); + + const response = await adapter.approvePullRequest(BITBUCKET_URL + prHref); + + expect(response.statusCode).toBe(409); + }); +}); diff --git a/index.js b/index.js index 2912696a..dfaa09a6 100644 --- a/index.js +++ b/index.js @@ -1,21 +1,19 @@ const bunyan = require('bunyan'); -const got = require('got'); -const { BITBUCKET_USERNAME, BITBUCKET_PASSWORD, RENOVATE_BOT_USER } = - process.env; -const MANUAL_MERGE_MESSAGE = 'merge this manually'; +const BitbucketCloudAdapter = require('./adapter/bitbucket-cloud'); +const BitbucketDataCenterAdapter = require('./adapter/bitbucket-data-center'); -const DEFAULT_OPTIONS = { - prefixUrl: 'https://api.bitbucket.org', - // If we use username/password options, they will be URL encoded - // https://github.com/sindresorhus/got/issues/1169 - // https://github.com/nodejs/node/issues/31439 - headers: { - Authorization: `Basic ${Buffer.from( - `${BITBUCKET_USERNAME}:${BITBUCKET_PASSWORD}` - ).toString('base64')}`, - }, - responseType: 'json', +const { + BITBUCKET_URL, + BITBUCKET_USERNAME, + BITBUCKET_PASSWORD, + RENOVATE_BOT_USER, +} = process.env; + +const DEFAULT_ADAPTER_OPTIONS = { + BITBUCKET_USERNAME, + BITBUCKET_PASSWORD, + RENOVATE_BOT_USER, }; const log = bunyan.createLogger({ @@ -25,47 +23,15 @@ const log = bunyan.createLogger({ }, }); -function isAutomerging(pr) { - try { - return !pr.description.includes(MANUAL_MERGE_MESSAGE); - } catch (error) { - log.error(error); - return false; +function createAdapter() { + if (!BITBUCKET_URL) { + return new BitbucketCloudAdapter({ ...DEFAULT_ADAPTER_OPTIONS, log }); } -} - -function getPullRequests() { - const prEndpoint = `/2.0/pullrequests/${RENOVATE_BOT_USER}`; - log.info('Requesting %s%s...', DEFAULT_OPTIONS.prefixUrl, prEndpoint); - return got.paginate('', { - ...DEFAULT_OPTIONS, - pathname: prEndpoint, - pagination: { - transform: (response) => - response.body.values - .filter((pr) => isAutomerging(pr)) - .map((pr) => pr.links.self.href), - paginate: (response) => { - if ('next' in response.body && response.body.next !== '') { - log.info('Requesting %s...', response.body.next); - return { - url: new URL(response.body.next), - }; - } - log.info('All pull-requests gathered.'); - return false; - }, - }, - }); -} - -function approvePullRequest(prHref) { - return got('', { - ...DEFAULT_OPTIONS, - prefixUrl: `${prHref}/approve`, - method: 'POST', - throwHttpErrors: false, + return new BitbucketDataCenterAdapter(log, { + ...DEFAULT_ADAPTER_OPTIONS, + BITBUCKET_URL, + log, }); } @@ -77,9 +43,11 @@ async function main() { process.exit(1); } + const adapter = createAdapter(); + let prHrefs; try { - prHrefs = await getPullRequests(); + prHrefs = await adapter.getPullRequests(); } catch (error) { log.fatal(error); process.exit(1); @@ -87,7 +55,8 @@ async function main() { for await (const prHref of prHrefs) { log.info('Approving: %s', prHref); - approvePullRequest(prHref) + adapter + .approvePullRequest(prHref) .then((response) => { switch (response.statusCode) { case 200: diff --git a/index.spec.js b/index.spec.js index 1c4d6010..d1d52b5e 100644 --- a/index.spec.js +++ b/index.spec.js @@ -1,5 +1,4 @@ -const nock = require('nock'); - +const BITBUCKET_URL = 'http://bitbucket.example'; const BITBUCKET_USERNAME = 'renovate-approve-bot'; const BITBUCKET_PASSWORD = 'r3novate-@pprove-b0t'; const RENOVATE_BOT_USER = 'renovate-bot'; @@ -11,246 +10,20 @@ process.env = Object.assign(process.env, { }); const bot = require('./index'); +const BitbucketCloudAdapter = require('./adapter/bitbucket-cloud'); +const BitbucketDataCenterAdapter = require('./adapter/bitbucket-data-center'); -const API_BASE_URL = 'https://api.bitbucket.org'; -const BASIC_AUTH = { user: BITBUCKET_USERNAME, pass: BITBUCKET_PASSWORD }; - -const autoMergeDescription = '...\n\n🚦 **Automerge**: Enabled.\n\n...'; -const manualMergeDescription = - '...\n\n🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.\n\n...'; - -afterEach(() => { - if (!nock.isDone()) { - throw new Error( - `Not all nock interceptors were used: ${JSON.stringify( - nock.pendingMocks() - )}` - ); - } - nock.cleanAll(); -}); - -describe('isAutomerging', () => { - it('is automerging', () => { - const pr = { - description: autoMergeDescription, - // omitted attributes... - }; +describe('createAdapter', () => { + it('creates a Bitbucket Cloud adapter by default', () => { + const createAdapter = bot.__get__('createAdapter'); - const isAutomerging = bot.__get__('isAutomerging'); - - expect(isAutomerging(pr)).toBe(true); + expect(createAdapter()).toBeInstanceOf(BitbucketCloudAdapter); }); it('is not automerging', () => { - const pr = { - description: manualMergeDescription, - // omitted attributes... - }; - - const isAutomerging = bot.__get__('isAutomerging'); - - expect(isAutomerging(pr)).toBe(false); - }); -}); - -describe('getPullRequests', () => { - const pullRequestsEndpoint = `/2.0/pullrequests/${RENOVATE_BOT_USER}`; - - it('gets pull-requests in a single page', async () => { - nock(API_BASE_URL) - .get(pullRequestsEndpoint) - .basicAuth(BASIC_AUTH) - .reply(200, { - values: [ - { - description: autoMergeDescription, - links: { - self: { - href: 'https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/1', - }, - // omitted attributes... - }, - // omitted attributes... - }, - { - description: manualMergeDescription, - links: { - self: { - href: 'https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/2', - }, - // omitted attributes... - }, - // omitted attributes... - }, - { - description: autoMergeDescription, - links: { - self: { - href: 'https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/3', - }, - // omitted attributes... - }, - // omitted attributes... - }, - ], - // omitted attributes... - }); - - const getPullRequests = bot.__get__('getPullRequests'); - - const pullRequests = []; - const prGenerator = await getPullRequests(); - for await (const prHref of prGenerator) { - pullRequests.push(prHref); - } - - expect(pullRequests).toStrictEqual([ - 'https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/1', - 'https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/3', - ]); - }); - - it('gets pull-requests in multiple pages', async () => { - nock(API_BASE_URL) - .get(pullRequestsEndpoint) - .basicAuth(BASIC_AUTH) - .reply(200, { - values: [ - { - description: autoMergeDescription, - links: { - self: { - href: 'https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/1', - }, - // omitted attributes... - }, - // omitted attributes... - }, - { - description: manualMergeDescription, - links: { - self: { - href: 'https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/2', - }, - // omitted attributes... - }, - // omitted attributes... - }, - { - description: autoMergeDescription, - links: { - self: { - href: 'https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/3', - }, - // omitted attributes... - }, - // omitted attributes... - }, - ], - next: `${API_BASE_URL}${pullRequestsEndpoint}?page=2`, - // omitted attributes... - }); - - nock(API_BASE_URL) - .get(pullRequestsEndpoint) - .basicAuth(BASIC_AUTH) - .query({ page: 2 }) - .reply(200, { - values: [ - { - description: manualMergeDescription, - links: { - self: { - href: 'https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/5', - }, - // omitted attributes... - }, - // omitted attributes... - }, - { - description: autoMergeDescription, - links: { - self: { - href: 'https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/6', - }, - // omitted attributes... - }, - // omitted attributes... - }, - ], - // omitted attributes... - }); - - const getPullRequests = bot.__get__('getPullRequests'); - - const prHrefs = []; - const prGenerator = await getPullRequests(); - for await (const prHref of prGenerator) { - prHrefs.push(prHref); - } - - expect(prHrefs).toStrictEqual([ - 'https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/1', - 'https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/3', - 'https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/6', - ]); - }); - - it('gets no pull-requests', async () => { - nock(API_BASE_URL) - .get(pullRequestsEndpoint) - .basicAuth(BASIC_AUTH) - .reply(200, { - values: [], - // omitted attributes... - }); - - const getPullRequests = bot.__get__('getPullRequests'); - - const prHrefs = []; - const prGenerator = await getPullRequests(); - for await (const prHref of prGenerator) { - prHrefs.push(prHref); - } - - expect(prHrefs).toStrictEqual([]); - }); -}); - -describe('approvePullRequest', () => { - it('approves', async () => { - const prHref = '/2.0/repositories/myworkspace/myrepo/pullrequests/1'; - nock(API_BASE_URL) - .post(`${prHref}/approve/`) - .basicAuth(BASIC_AUTH) - .reply(200, { - // omitted attributes... - }); - - const approvePullRequest = bot.__get__('approvePullRequest'); - - const response = await approvePullRequest(API_BASE_URL + prHref); - - expect(response.statusCode).toBe(200); - }); - - it('is already approved', async () => { - const prHref = '/2.0/repositories/myworkspace/myrepo/pullrequests/2'; - nock(API_BASE_URL) - .post(`${prHref}/approve/`) - .basicAuth(BASIC_AUTH) - .reply(409, { - type: 'error', - error: { - message: 'You already approved this pull request.', - }, - }); - - const approvePullRequest = bot.__get__('approvePullRequest'); - - const response = await approvePullRequest(API_BASE_URL + prHref); + bot.__set__('BITBUCKET_URL', BITBUCKET_URL); + const createAdapter = bot.__get__('createAdapter'); - expect(response.statusCode).toBe(409); + expect(createAdapter()).toBeInstanceOf(BitbucketDataCenterAdapter); }); }); diff --git a/package-lock.json b/package-lock.json index 3cc1cf93..6f82a587 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "renovate-approve-bot-bitbucket-cloud", + "name": "renovate-approve-bot-bitbucket", "requires": true, "lockfileVersion": 1, "dependencies": { diff --git a/package.json b/package.json index fc7a7979..c0ba35a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "renovate-approve-bot-bitbucket-cloud", - "description": "A job to approve Pull Requests from Renovate Bot on Bitbucket Cloud.", + "name": "renovate-approve-bot-bitbucket", + "description": "A job to approve Pull Requests from Renovate Bot on Bitbucket Cloud and Data Center (and Server).", "private": true, "scripts": { "eslint": "eslint --ext .js .", @@ -15,11 +15,13 @@ }, "repository": { "type": "git", - "url": "https://github.com/renovatebot/renovate-approve-bot-bitbucket-cloud.git" + "url": "https://github.com/renovatebot/renovate-approve-bot-bitbucket.git" }, "keywords": [ "automerge", "bitbucket-cloud", + "bitbucket-data-center", + "bitbucket-server", "bitbucket", "bot", "renovate" @@ -27,11 +29,12 @@ "author": "Maxime Brunet", "contributors": [ "HonkingGoose", - "Michael Kriese " + "Michael Kriese ", + "Steffen Kreutz" ], "license": "ISC", "bugs": { - "url": "https://github.com/renovatebot/renovate-approve-bot-bitbucket-cloud/issues" + "url": "https://github.com/renovatebot/renovate-approve-bot-bitbucket/issues" }, "engines": { "node": ">=14.15.0"