diff --git a/.env.example b/.env.example index 6e106aa..698cdb8 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,8 @@ LOG_LEVEL=debug # Go to https://smee.io/new set this to the URL that you are redirected to. WEBHOOK_PROXY_URL= + +# A comma-separated list of GitHub user IDs to bypass the approval process. +# For users, you can find the ID by visiting `https://api.github.com/users/` +# For apps, you can find the ID by visiting `https://api.github.com/users/%5Bbot%5D` +BYPASS_ACTORS= diff --git a/README.md b/README.md index 5c03e4a..a14d856 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,25 @@ docker build -t deploynaut . docker run -e APP_ID= -e PRIVATE_KEY= deploynaut ``` +## Environment Variables + +- `BYPASS_ACTORS`: A comma-separated list of GitHub user IDs to bypass the approval process. + + For users, you can find the ID by visiting `https://api.github.com/users/` + For apps, you can find the ID by visiting `https://api.github.com/users/%5Bbot%5D` + + ```shell + # https://api.github.com/users/flowzone-app%5Bbot%5D + # https://api.github.com/users/balena-renovate%5Bbot%5D + BYPASS_ACTORS=124931076,133977723 + ``` + +- `APP_ID`: The ID of the GitHub App. +- `PRIVATE_KEY`: The private key of the GitHub App. +- `WEBHOOK_SECRET`: The secret used to verify the authenticity of the webhook. +- `WEBHOOK_PROXY_URL`: The URL to proxy the webhook to. +- `LOG_LEVEL`: Defaults to `info`. + ## Contributing If you have suggestions for how deploynaut could be improved, or want to report a bug, open an issue! We'd love all and any contributions. diff --git a/src/client.ts b/src/client.ts index f0e59f8..a0edab2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -69,25 +69,25 @@ // return deployments; // } -export async function listPullRequestContributors( - context: any, - prNumber: number, -): Promise { - const commits = await this.listPullRequestCommits(context, prNumber); - return commits.map((c: any) => c.author.id); -} +// export async function listPullRequestContributors( +// context: any, +// prNumber: number, +// ): Promise { +// const commits = await this.listPullRequestCommits(context, prNumber); +// return commits.map((c: any) => c.author.id); +// } -export async function listPullRequestCommits( - context: any, - prNumber: number, -): Promise { - const request = context.repo({ - pull_number: prNumber, - }); - const { data: commits } = - await context.octokit.rest.pulls.listPullRequestCommits(request); - return commits; -} +// export async function listPullRequestCommits( +// context: any, +// prNumber: number, +// ): Promise { +// const request = context.repo({ +// pull_number: prNumber, +// }); +// const { data: commits } = +// await context.octokit.rest.pulls.listPullRequestCommits(request); +// return commits; +// } // https://octokit.github.io/rest.js/v21/#pulls-list-reviews // https://docs.github.com/en/rest/pulls/reviews?apiVersion=2022-11-28#list-reviews-for-a-pull-request @@ -156,3 +156,45 @@ export async function reviewWorkflowRun( await context.octokit.rest.actions.reviewCustomGatesForRun(request); return review; } + +// https://octokit.github.io/rest.js/v18/#issues-list-comments +// https://docs.github.com/en/rest/issues/comments#list-issue-comments +export async function listIssueComments( + context: any, + issueNumber: number, +): Promise { + const request = context.repo({ + issue_number: issueNumber, + }); + const { data: comments } = + await context.octokit.rest.issues.listComments(request); + return comments; +} + +// https://octokit.github.io/rest.js/v18/#issues-create-comment +// https://docs.github.com/en/rest/issues/comments#create-an-issue-comment +export async function createIssueComment( + context: any, + issueNumber: number, + body: string, +): Promise { + const request = context.repo({ + issue_number: issueNumber, + body, + }); + const { data: comment } = + await context.octokit.rest.issues.createComment(request); + return comment; +} + +// // https://octokit.github.io/rest.js/v18/#issues-delete-comment +// // https://docs.github.com/en/rest/issues/comments#delete-an-issue-comment +// export async function deleteIssueComment( +// context: any, +// commentId: number, +// ): Promise { +// const request = context.repo({ +// comment_id: commentId, +// }); +// await context.octokit.rest.issues.deleteComment(request); +// } diff --git a/src/index.ts b/src/index.ts index ee7486b..5074c93 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,10 @@ import type { } from '@octokit/webhooks-types'; import * as GitHubClient from './client.js'; +export const instructionalComment = + 'One or more environments associated with this pull request require approval before deploying workflow runs.\n\n' + + 'Maintainers can approve by submitting a [Review](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/reviewing-proposed-changes-in-a-pull-request#submitting-your-review) with the comment `/deploy please`.'; + export default (app: Probot) => { app.on('deployment_protection_rule.requested', async (context: Context) => { const { @@ -31,25 +35,18 @@ export default (app: Probot) => { }, }; - context.log.info( + app.log.info( 'Received deployment protection rule event: %s', JSON.stringify(eventDetails, null, 2), ); if (!deployment || !event || !environment || !callbackUrl) { - context.log.error('Payload is missing required properties'); + app.log.error('Payload is missing required properties'); return; } - // app.log.info('Received Deployment Protection Rule with Deployment ID: %s', deployment.id); - // app.log.info(JSON.stringify(context.payload, null, 2)); - - // const client = await app.auth(); // Gets an authenticated Octokit client - // const { data: appDetails } = await client.apps.getAuthenticated(); // Retrieves details about the authenticated app - // // app.log.info(JSON.stringify(appDetails, null, 2)); // Logs details about the app - if (!['pull_request', 'pull_request_target', 'push'].includes(event)) { - context.log.info( + app.log.info( 'Ignoring unsupported deployment protection rule event: %s', event, ); @@ -65,11 +62,15 @@ export default (app: Probot) => { }); } - context.log.debug( + app.log.debug( 'Actor is not included in bypass actors: %s', deployment.creator.login, ); + const client = await app.auth(); // Gets an authenticated Octokit client + const { data: appDetails } = await client.apps.getAuthenticated(); // Retrieves details about the authenticated app + // app.log.info(JSON.stringify(appDetails, null, 2)); // Logs details about the app + if (pullRequests) { for (const pull of pullRequests) { // get all reviews for the pull request @@ -93,6 +94,22 @@ export default (app: Probot) => { comment: `Approved by ${deployReview.user.login} via [review](${deployReview.html_url})`, }); } + + const comments = await filterIssueComments( + context, + pull.number, + appDetails.id, + instructionalComment, + ); + + // Try to avoid creating duplicate comments but there will always be a race condition + if (comments.length === 0) { + await GitHubClient.createIssueComment( + context, + pull.number, + instructionalComment, + ); + } } } }); @@ -112,18 +129,18 @@ export default (app: Probot) => { }, }; - context.log.info( + app.log.info( 'Received pull request review event: %s', JSON.stringify(eventDetails, null, 2), ); if (review.user.type === 'Bot') { - context.log.info('Ignoring bot review'); + app.log.info('Ignoring bot review'); return; } if (!review.body?.startsWith('/deploy')) { - context.log.info('Ignoring unsupported comment'); + app.log.info('Ignoring unsupported comment'); return; } @@ -140,7 +157,7 @@ export default (app: Probot) => { ); if (deployments.length === 0) { - context.log.info( + app.log.info( 'No pending deployments found for workflow run %s', run.id, ); @@ -169,3 +186,19 @@ export default (app: Probot) => { // To get your app running against GitHub, see: // https://probot.github.io/docs/development/ }; + +// Find existing comments that match the provided criteria +async function filterIssueComments( + context: any, + issueNumber: number, + appId: number, + startsWith: string, +): Promise { + const comments = await GitHubClient.listIssueComments(context, issueNumber); + return comments.filter( + (c) => + c.body.startsWith(startsWith) && + c.performed_via_github_app.id === appId && + c.created_at === c.updated_at, + ); +} diff --git a/test/index.test.ts b/test/index.test.ts index b5b76dd..8cb5fde 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,5 +1,5 @@ import nock from 'nock'; -import myProbotApp from '../src/index.js'; +import myProbotApp, { instructionalComment } from '../src/index.js'; import { Probot, ProbotOctokit } from 'probot'; import fs from 'fs'; import path from 'path'; @@ -61,7 +61,7 @@ describe('GitHub Deployment App', () => { beforeEach(() => { nock.disableNetConnect(); - process.env.BYPASS_ACTORS = '5'; + process.env.BYPASS_ACTORS = '5,10'; probot = new Probot({ appId: 456, privateKey, @@ -80,7 +80,7 @@ describe('GitHub Deployment App', () => { }); describe('deployment_protection_rule.requested', () => { - test('approves deployment for allowed user', async () => { + test('approves deployment created by bypass user', async () => { const mock = nock('https://api.github.com') .post('/app/installations/12345678/access_tokens') .reply(200, { token: 'test', permissions: { issues: 'write' } }) @@ -97,7 +97,7 @@ describe('GitHub Deployment App', () => { expect(mock.pendingMocks()).toStrictEqual([]); }); - test('ignores events with missing properties', async () => { + test('skips events with missing properties', async () => { const payload = { ...testFixtures.deployment_protection_rule, event: null, @@ -112,7 +112,7 @@ describe('GitHub Deployment App', () => { expect(nock.pendingMocks()).toStrictEqual([]); }); - test('ignores unsupported events', async () => { + test('skips unsupported events', async () => { const payload = { ...testFixtures.deployment_protection_rule, event: 'workflow_run', @@ -127,56 +127,88 @@ describe('GitHub Deployment App', () => { expect(nock.pendingMocks()).toStrictEqual([]); }); - test('ignores deployment from unauthorized user', async () => { + // test('ignores deployment from unauthorized user', async () => { + // const mock = nock('https://api.github.com') + // .post('/app/installations/12345678/access_tokens') + // .reply(200, { token: 'test', permissions: { issues: 'write' } }) + // .get('/app') + // .reply(200, { id: 456 }) + // .get('/repos/test-org/test-repo/pulls/123/reviews') + // .reply(200, [{ commit_id: 'test-sha', body: '/deploy please' }]); + // const payload = { + // ...testFixtures.deployment_protection_rule, + // deployment: { + // creator: { + // login: 'unauthorized-user', + // id: 789, + // }, + // }, + // }; + + // await probot.receive({ + // name: 'deployment_protection_rule', + // payload, + // }); + + // expect(mock.pendingMocks()).toStrictEqual([]); + // }); + + // test('handles undefined BYPASS_ACTORS', async () => { + // process.env.BYPASS_ACTORS = ''; + + // const mock = nock('https://api.github.com') + // .post('/app/installations/12345678/access_tokens') + // .reply(200, { token: 'test', permissions: { issues: 'write' } }) + // .get('/repos/test-org/test-repo/pulls/123/reviews') + // .reply(200, []); + + // await probot.receive({ + // name: 'deployment_protection_rule', + // payload: testFixtures.deployment_protection_rule, + // }); + + // expect(mock.pendingMocks()).toStrictEqual([]); + // }); + + // test('handles defined bypass actors with multiple values', async () => { + // process.env.BYPASS_ACTORS = '5,10,15'; + + // const mock = nock('https://api.github.com') + // .post('/app/installations/12345678/access_tokens') + // .reply(200, { token: 'test', permissions: { issues: 'write' } }) + // .get('/repos/test-org/test-repo/pulls/123/reviews') + // .reply(200, []); + + // await probot.receive({ + // name: 'deployment_protection_rule', + // payload: testFixtures.deployment_protection_rule, + // }); + + // expect(mock.pendingMocks()).toStrictEqual([]); + // }); + + test('approves deployment for APPROVED pull request review', async () => { const mock = nock('https://api.github.com') .post('/app/installations/12345678/access_tokens') .reply(200, { token: 'test', permissions: { issues: 'write' } }) + .get('/app') + .reply(200, { id: 456 }) .get('/repos/test-org/test-repo/pulls/123/reviews') - .reply(200, [{ commit_id: 'test-sha', body: '/deploy please' }]); - const payload = { - ...testFixtures.deployment_protection_rule, - deployment: { - creator: { - login: 'unauthorized-user', - id: 789, + .reply(200, [ + { + commit_id: 'test-sha', + body: '/deploy please', + state: 'APPROVED', + user: { login: 'test-user', id: 789 }, }, - }, - }; - - await probot.receive({ - name: 'deployment_protection_rule', - payload, - }); - - expect(mock.pendingMocks()).toStrictEqual([]); - }); + ]) + .post( + '/repos/test-org/test-repo/actions/runs/1234/deployment_protection_rule', + ) + .reply(200); - test('handles undefined BYPASS_ACTORS', async () => { process.env.BYPASS_ACTORS = ''; - const mock = nock('https://api.github.com') - .post('/app/installations/12345678/access_tokens') - .reply(200, { token: 'test', permissions: { issues: 'write' } }) - .get('/repos/test-org/test-repo/pulls/123/reviews') - .reply(200, []); - - await probot.receive({ - name: 'deployment_protection_rule', - payload: testFixtures.deployment_protection_rule, - }); - - expect(mock.pendingMocks()).toStrictEqual([]); - }); - - test('handles defined bypass actors with multiple values', async () => { - process.env.BYPASS_ACTORS = '1,2,3'; - - const mock = nock('https://api.github.com') - .post('/app/installations/12345678/access_tokens') - .reply(200, { token: 'test', permissions: { issues: 'write' } }) - .get('/repos/test-org/test-repo/pulls/123/reviews') - .reply(200, []); - await probot.receive({ name: 'deployment_protection_rule', payload: testFixtures.deployment_protection_rule, @@ -185,16 +217,18 @@ describe('GitHub Deployment App', () => { expect(mock.pendingMocks()).toStrictEqual([]); }); - test('approves deployment for APPROVED pull request review', async () => { + test('approves deployment for COMMENTED pull request review', async () => { const mock = nock('https://api.github.com') .post('/app/installations/12345678/access_tokens') .reply(200, { token: 'test', permissions: { issues: 'write' } }) + .get('/app') + .reply(200, { id: 456 }) .get('/repos/test-org/test-repo/pulls/123/reviews') .reply(200, [ { commit_id: 'test-sha', body: '/deploy please', - state: 'APPROVED', + state: 'commented', user: { login: 'test-user', id: 789 }, }, ]) @@ -213,22 +247,26 @@ describe('GitHub Deployment App', () => { expect(mock.pendingMocks()).toStrictEqual([]); }); - test('approves deployment for COMMENTED pull request review', async () => { + test('creates instructional comment for CHANGES_REQUESTED pull request review', async () => { const mock = nock('https://api.github.com') .post('/app/installations/12345678/access_tokens') .reply(200, { token: 'test', permissions: { issues: 'write' } }) + .get('/app') + .reply(200, { id: 456 }) .get('/repos/test-org/test-repo/pulls/123/reviews') .reply(200, [ { commit_id: 'test-sha', body: '/deploy please', - state: 'commented', + state: 'CHANGES_REQUESTED', user: { login: 'test-user', id: 789 }, }, ]) - .post( - '/repos/test-org/test-repo/actions/runs/1234/deployment_protection_rule', - ) + .get('/repos/test-org/test-repo/issues/123/comments') + .reply(200, []) + .post('/repos/test-org/test-repo/issues/123/comments', { + body: instructionalComment, + }) .reply(200); process.env.BYPASS_ACTORS = ''; @@ -241,17 +279,20 @@ describe('GitHub Deployment App', () => { expect(mock.pendingMocks()).toStrictEqual([]); }); - test('ignores deployment for CHANGES_REQUESTED pull request review', async () => { + test('avoids creating duplicate comments', async () => { const mock = nock('https://api.github.com') .post('/app/installations/12345678/access_tokens') .reply(200, { token: 'test', permissions: { issues: 'write' } }) + .get('/app') + .reply(200, { id: 456 }) .get('/repos/test-org/test-repo/pulls/123/reviews') + .reply(200, []) + .get('/repos/test-org/test-repo/issues/123/comments') .reply(200, [ { - commit_id: 'test-sha', - body: '/deploy please', - state: 'CHANGES_REQUESTED', - user: { login: 'test-user', id: 789 }, + id: 123, + body: instructionalComment, + performed_via_github_app: { id: 456 }, }, ]);