From 91707f292be6028f598270d2ed6fedeba18670be Mon Sep 17 00:00:00 2001 From: Kyle Harding Date: Sun, 24 Nov 2024 19:13:43 -0500 Subject: [PATCH] Approve via pull request review comments Signed-off-by: Kyle Harding --- app.yml | 50 ++++++------- src/client.ts | 76 +++++++++++--------- src/index.ts | 69 ++++++++---------- test/index.test.ts | 170 ++++++++++++++++++++++++++++----------------- 4 files changed, 204 insertions(+), 161 deletions(-) diff --git a/app.yml b/app.yml index e931fae..516c60f 100644 --- a/app.yml +++ b/app.yml @@ -23,30 +23,30 @@ default_events: - deployment_protection_rule # - fork # - gollum - - issue_comment + # - issue_comment # - issues -# - label -# - milestone -# - member -# - membership -# - org_block -# - organization -# - page_build -# - project -# - project_card -# - project_column -# - public -# - pull_request -# - pull_request_review -# - pull_request_review_comment -# - push -# - release -# - repository -# - repository_import -# - status -# - team -# - team_add -# - watch + # - label + # - milestone + # - member + # - membership + # - org_block + # - organization + # - page_build + # - project + # - project_card + # - project_column + # - public + # - pull_request + # - pull_request_review + - pull_request_review_comment + # - push + # - release + # - repository + # - repository_import + # - status + # - team + # - team_add + # - watch # The set of permissions needed by the GitHub App. The format of the object uses # the permission name for the key (for example, issues) and the access type for @@ -74,7 +74,7 @@ default_permissions: # Issues and related comments, assignees, labels, and milestones. # https://developer.github.com/v3/apps/permissions/#permission-on-issues - issues: write + # issues: write # Search repositories, list collaborators, and access repository metadata. # https://developer.github.com/v3/apps/permissions/#metadata-permissions @@ -106,7 +106,7 @@ default_permissions: # Organization members and teams. # https://developer.github.com/v3/apps/permissions/#permission-on-members - members: read + # members: read # View and manage users blocked by the organization. # https://developer.github.com/v3/apps/permissions/#permission-on-organization-user-blocking diff --git a/src/client.ts b/src/client.ts index fa7f406..bc6a802 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5,28 +5,43 @@ export async function whoAmI(context: any): Promise { return { login: viewer.login, id: viewer.databaseId }; } -// https://octokit.github.io/rest.js/v21/#repos-get-collaborator-permission-level -// https://docs.github.com/en/rest/collaborators/collaborators#list-repository-collaborators -export async function hasRepoWriteAccess( - context: any, - username: string, -): Promise { - const request = context.repo({ - username, - }); +// // https://octokit.github.io/rest.js/v21/#repos-get-collaborator-permission-level +// // https://docs.github.com/en/rest/collaborators/collaborators#list-repository-collaborators +// export async function hasRepoWriteAccess( +// context: any, +// username: string, +// ): Promise { +// const request = context.repo({ +// username, +// }); - const { - data: { permission }, - } = await context.octokit.rest.repos.getCollaboratorPermissionLevel(request); +// const { +// data: { permission }, +// } = await context.octokit.rest.repos.getCollaboratorPermissionLevel(request); - context.log.info( - `Permission level for ${username}: ${JSON.stringify(permission, null, 2)}`, - ); +// context.log.info( +// `Permission level for ${username}: ${JSON.stringify(permission, null, 2)}`, +// ); - return ['admin', 'write'].includes(permission); -} +// return ['admin', 'write'].includes(permission); +// } -export async function addCommentReaction( +// export async function addCommentReaction( +// context: any, +// commentId: number, +// content: string, +// ): Promise { +// const request = context.repo({ +// comment_id: commentId, +// content, +// }); + +// await context.octokit.reactions.createForIssueComment(request); +// } + +// https://octokit.github.io/rest.js/v21/#reactions-create-for-pull-request-review-comment +// https://docs.github.com/en/rest/reactions/reactions#create-reaction-for-a-pull-request-review-comment +export async function addPullRequestReviewCommentReaction( context: any, commentId: number, content: string, @@ -36,7 +51,7 @@ export async function addCommentReaction( content, }); - await context.octokit.reactions.createForIssueComment(request); + await context.octokit.reactions.createForPullRequestReviewComment(request); } // https://docs.github.com/en/rest/deployments/deployments#list-deployments @@ -72,29 +87,26 @@ export async function listPullRequestCommits( return commits; } -export async function getPullRequest( - context: any, - prNumber: number, -): Promise { - const request = context.repo({ - pull_number: prNumber, - }); - const { data: pullRequest } = await context.octokit.rest.pulls.get(request); - return pullRequest; -} +// export async function getPullRequest( +// context: any, +// prNumber: number, +// ): Promise { +// const request = context.repo({ +// pull_number: prNumber, +// }); +// const { data: pullRequest } = await context.octokit.rest.pulls.get(request); +// return pullRequest; +// } // https://octokit.github.io/rest.js/v21/#actions-list-workflow-runs-for-repo // https://docs.github.com/en/rest/actions/workflow-runs#list-workflow-runs-for-a-repository -// https://docs.github.com/en/search-github/getting-started-with-searching-on-github/understanding-the-search-syntax#query-for-dates export async function listWorkflowRuns( context: any, headSha: string, - created: string, ): Promise { // what is the status "requested" used for? const request = context.repo({ status: 'waiting', - created, head_sha: headSha, }); diff --git a/src/index.ts b/src/index.ts index 695d1a9..9d95068 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import type { Context, Probot } from 'probot'; import type { - IssueCommentCreatedEvent, + PullRequestReviewCommentCreatedEvent, DeploymentProtectionRuleRequestedEvent, } from '@octokit/webhooks-types'; import * as GitHubClient from './client.js'; @@ -71,16 +71,16 @@ export default (app: Probot) => { }); }); - app.on('issue_comment.created', async (context: Context) => { - const { issue, comment } = context.payload as IssueCommentCreatedEvent; - - console.log('Received event: issue_comment.created'); - // console.log(JSON.stringify(context.payload, null, 2)); + app.on('pull_request_review_comment.created', async (context: Context) => { + const { + comment, + pull_request: { + head: { sha }, + }, + } = context.payload as PullRequestReviewCommentCreatedEvent; - if (issue.pull_request == null) { - context.log.info('Ignoring non-pull request comment'); - return; - } + console.log('Received event: pull_request_review_comment.created'); + console.log(JSON.stringify(context.payload, null, 2)); if (comment.user.type === 'Bot') { context.log.info('Ignoring bot comment'); @@ -97,9 +97,6 @@ export default (app: Probot) => { return; } - // post a reaction to the comment with :eyes: - await GitHubClient.addCommentReaction(context, comment.id, 'eyes'); - let appUser; try { appUser = await GitHubClient.whoAmI(context); @@ -112,36 +109,11 @@ export default (app: Probot) => { return; } - const hasRepoWriteAccess = await GitHubClient.hasRepoWriteAccess( - context, - comment.user.login, - ); - - if (!hasRepoWriteAccess) { - context.log.info('User does not have write access'); - return; - } - - const { - head: { sha }, - } = await GitHubClient.getPullRequest(context, issue.number); - - // Get the ISO date 2-min before the comment.created_at - const created = new Date(comment.created_at); - created.setMinutes(created.getMinutes() - 2); + let approved = false; // filter workflow runs with the given hash and a creation date 2 minutes or more before the comment created date // https://docs.github.com/en/search-github/getting-started-with-searching-on-github/understanding-the-search-syntax#query-for-dates - const runs = await GitHubClient.listWorkflowRuns( - context, - sha, - `<${created.toISOString()}`, - ); - - if (runs.length === 0) { - context.log.info('No workflow runs found for sha %s', sha); - return; - } + const runs = await GitHubClient.listWorkflowRuns(context, sha); for (const run of runs) { const deployments = await GitHubClient.listPendingDeployments( @@ -175,8 +147,25 @@ export default (app: Probot) => { 'approved', `Approved by ${comment.user.login} via ${appUser.login}`, ); + approved = true; } } + + if (approved) { + // post a reaction to the comment with :rocket: + await GitHubClient.addPullRequestReviewCommentReaction( + context, + comment.id, + 'rocket', + ); + } else { + // post a reaction to the comment with :confused: + await GitHubClient.addPullRequestReviewCommentReaction( + context, + comment.id, + 'confused', + ); + } }); // For more information on building apps: // https://probot.github.io/docs/ diff --git a/test/index.test.ts b/test/index.test.ts index 7765785..13ec4cf 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -32,22 +32,22 @@ const testFixtures = { name: 'test-repo', }, }, - issue_comment: { + pull_request_review_comment: { action: 'created', - issue: { - // eslint-disable-next-line id-denylist - number: 123, - pull_request: {}, + pull_request: { + head: { + sha: 'test-sha', + }, }, comment: { id: 456, - user: { - login: 'test-user', - type: 'User', - }, body: '/deploy please', created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z', + user: { + login: 'test-user', + id: 789, + }, }, installation: { id: 12345678 }, repository: { @@ -221,12 +221,12 @@ describe('GitHub Deployment App', () => { }); }); - describe('issue_comment.created', () => { + describe('pull_request_review_comment.created', () => { test('processes valid deploy comment', async () => { const mock = nock('https://api.github.com') .post('/app/installations/12345678/access_tokens') .reply(200, { token: 'test', permissions: { issues: 'write' } }) - .post('/repos/test-org/test-repo/issues/comments/456/reactions') + .post('/repos/test-org/test-repo/pulls/comments/456/reactions') .reply(200) .post('/graphql') .reply(200, { @@ -237,19 +237,21 @@ describe('GitHub Deployment App', () => { }, }, }) - .get('/repos/test-org/test-repo/collaborators/test-user/permission') - .reply(200, { permission: 'admin' }) - .get('/repos/test-org/test-repo/pulls/123') - .reply(200, { - head: { sha: 'test-sha' }, - }) .get('/repos/test-org/test-repo/actions/runs') .query(true) - .reply(200, { workflow_runs: [] }); + .reply(200, { workflow_runs: [{ id: 1234 }] }) + .get('/repos/test-org/test-repo/actions/runs/1234/pending_deployments') + .reply(200, [ + { environment: { name: 'test' }, current_user_can_approve: true }, + ]) + .post( + '/repos/test-org/test-repo/actions/runs/1234/deployment_protection_rule', + ) + .reply(200); await probot.receive({ - name: 'issue_comment', - payload: testFixtures.issue_comment, + name: 'pull_request_review_comment', + payload: testFixtures.pull_request_review_comment, }); expect(mock.pendingMocks()).toStrictEqual([]); @@ -257,15 +259,15 @@ describe('GitHub Deployment App', () => { test('ignores non-deploy comments', async () => { const payload = { - ...testFixtures.issue_comment, + ...testFixtures.pull_request_review_comment, comment: { - ...testFixtures.issue_comment.comment, + ...testFixtures.pull_request_review_comment.comment, body: 'Just a regular comment', }, }; await probot.receive({ - name: 'issue_comment', + name: 'pull_request_review_comment', payload, }); @@ -274,18 +276,18 @@ describe('GitHub Deployment App', () => { test('ignores bot comments', async () => { const payload = { - ...testFixtures.issue_comment, + ...testFixtures.pull_request_review_comment, comment: { - ...testFixtures.issue_comment.comment, + ...testFixtures.pull_request_review_comment.comment, user: { - ...testFixtures.issue_comment.comment.user, + ...testFixtures.pull_request_review_comment.comment.user, type: 'Bot', }, }, }; await probot.receive({ - name: 'issue_comment', + name: 'pull_request_review_comment', payload, }); @@ -294,33 +296,16 @@ describe('GitHub Deployment App', () => { test('ignores edited comments', async () => { const payload = { - ...testFixtures.issue_comment, + ...testFixtures.pull_request_review_comment, comment: { - ...testFixtures.issue_comment.comment, + ...testFixtures.pull_request_review_comment.comment, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:01:00Z', }, }; await probot.receive({ - name: 'issue_comment', - payload, - }); - - expect(nock.pendingMocks()).toStrictEqual([]); - }); - - test('ignores comments on non-pull-requests', async () => { - const payload = { - ...testFixtures.issue_comment, - issue: { - ...testFixtures.issue_comment.issue, - pull_request: null, - }, - }; - - await probot.receive({ - name: 'issue_comment', + name: 'pull_request_review_comment', payload, }); @@ -331,15 +316,13 @@ describe('GitHub Deployment App', () => { const mock = nock('https://api.github.com') .post('/app/installations/12345678/access_tokens') .reply(200, { token: 'test', permissions: { issues: 'write' } }) - .post('/repos/test-org/test-repo/issues/comments/456/reactions') - .reply(200) .post('/graphql') .reply(404, { message: 'Not Found' }); await expect( probot.receive({ - name: 'issue_comment', - payload: testFixtures.issue_comment, + name: 'pull_request_review_comment', + payload: testFixtures.pull_request_review_comment, }), ).rejects.toThrow('Failed to get app user'); @@ -348,11 +331,11 @@ describe('GitHub Deployment App', () => { test('ignores comments from self', async () => { const payload = { - ...testFixtures.issue_comment, + ...testFixtures.pull_request_review_comment, comment: { - ...testFixtures.issue_comment.comment, + ...testFixtures.pull_request_review_comment.comment, user: { - ...testFixtures.issue_comment.comment.user, + ...testFixtures.pull_request_review_comment.comment.user, login: 'test-bot', }, }, @@ -361,8 +344,6 @@ describe('GitHub Deployment App', () => { const mock = nock('https://api.github.com') .post('/app/installations/12345678/access_tokens') .reply(200, { token: 'test', permissions: { issues: 'write' } }) - .post('/repos/test-org/test-repo/issues/comments/456/reactions') - .reply(200) .post('/graphql') .reply(200, { data: { @@ -374,19 +355,44 @@ describe('GitHub Deployment App', () => { }); await probot.receive({ - name: 'issue_comment', + name: 'pull_request_review_comment', payload, }); expect(mock.pendingMocks()).toStrictEqual([]); }); - test('ignores comments from users without write access', async () => { + test('exits early if no matching workflow runs', async () => { + const mock = nock('https://api.github.com') + .post('/app/installations/12345678/access_tokens') + .reply(200, { token: 'test', permissions: { issues: 'write' } }) + .post('/graphql') + .reply(200, { + data: { + viewer: { + login: 'test-bot', + databaseId: 123, + }, + }, + }) + .get('/repos/test-org/test-repo/actions/runs') + .query(true) + .reply(200, { workflow_runs: [] }) + .post('/repos/test-org/test-repo/pulls/comments/456/reactions') + .reply(200); + + await probot.receive({ + name: 'pull_request_review_comment', + payload: testFixtures.pull_request_review_comment, + }); + + expect(mock.pendingMocks()).toStrictEqual([]); + }); + + test('exits early if no pending deployments', async () => { const mock = nock('https://api.github.com') .post('/app/installations/12345678/access_tokens') .reply(200, { token: 'test', permissions: { issues: 'write' } }) - .post('/repos/test-org/test-repo/issues/comments/456/reactions') - .reply(200) .post('/graphql') .reply(200, { data: { @@ -396,12 +402,48 @@ describe('GitHub Deployment App', () => { }, }, }) - .get('/repos/test-org/test-repo/collaborators/test-user/permission') - .reply(200, { permission: 'read' }); + .get('/repos/test-org/test-repo/actions/runs') + .query(true) + .reply(200, { workflow_runs: [{ id: 1234 }] }) + .get('/repos/test-org/test-repo/actions/runs/1234/pending_deployments') + .reply(200, []) + .post('/repos/test-org/test-repo/pulls/comments/456/reactions') + .reply(200); + + await probot.receive({ + name: 'pull_request_review_comment', + payload: testFixtures.pull_request_review_comment, + }); + + expect(mock.pendingMocks()).toStrictEqual([]); + }); + + test('exits early if no deployments can be approved', async () => { + const mock = nock('https://api.github.com') + .post('/app/installations/12345678/access_tokens') + .reply(200, { token: 'test', permissions: { issues: 'write' } }) + .post('/graphql') + .reply(200, { + data: { + viewer: { + login: 'test-bot', + databaseId: 123, + }, + }, + }) + .get('/repos/test-org/test-repo/actions/runs') + .query(true) + .reply(200, { workflow_runs: [{ id: 1234 }] }) + .get('/repos/test-org/test-repo/actions/runs/1234/pending_deployments') + .reply(200, [ + { environment: { name: 'test' }, current_user_can_approve: false }, + ]) + .post('/repos/test-org/test-repo/pulls/comments/456/reactions') + .reply(200); await probot.receive({ - name: 'issue_comment', - payload: testFixtures.issue_comment, + name: 'pull_request_review_comment', + payload: testFixtures.pull_request_review_comment, }); expect(mock.pendingMocks()).toStrictEqual([]);