From b84ccc5a647c994007b2ff776cd3c3c27b1c1d9f Mon Sep 17 00:00:00 2001 From: Orta Therox Date: Mon, 10 Feb 2020 16:03:11 -0500 Subject: [PATCH 1/2] Allows codeowners to say a 'merge on green'-like catchphrase to merge a PR --- TypeScriptRepoPullRequestWebhook/index.ts | 3 +- fixtures/pulls/api-pr-closed.json | 358 ++++++++++++++++++ package-lock.json | 101 ++++- package.json | 5 +- src/anyRepoHandleIssueComment.ts | 29 ++ src/checks/mergeThroughCodeOwners.test.ts | 63 +++ src/checks/mergeThroughCodeOwners.ts | 34 ++ src/pr_meta/hasAccessToMergePR.test.ts | 45 +++ src/pr_meta/hasAccessToMergePR.ts | 119 ++++++ src/pr_meta/mergeOrAddMergeLabel.ts | 21 + .../TypeScriptRepoPullRequestWebhook.test.ts | 9 + src/util/getContents.ts | 8 + src/util/tests/createMockContext.ts | 14 +- src/util/tests/createMockGitHubClient.ts | 19 +- 14 files changed, 813 insertions(+), 15 deletions(-) create mode 100644 fixtures/pulls/api-pr-closed.json create mode 100644 src/anyRepoHandleIssueComment.ts create mode 100644 src/checks/mergeThroughCodeOwners.test.ts create mode 100644 src/checks/mergeThroughCodeOwners.ts create mode 100644 src/pr_meta/hasAccessToMergePR.test.ts create mode 100644 src/pr_meta/hasAccessToMergePR.ts create mode 100644 src/pr_meta/mergeOrAddMergeLabel.ts create mode 100644 src/util/getContents.ts diff --git a/TypeScriptRepoPullRequestWebhook/index.ts b/TypeScriptRepoPullRequestWebhook/index.ts index 90c9dfe..167d35d 100644 --- a/TypeScriptRepoPullRequestWebhook/index.ts +++ b/TypeScriptRepoPullRequestWebhook/index.ts @@ -4,6 +4,7 @@ import sign = require("@octokit/webhooks/sign"); import { handlePullRequestPayload } from "../src/typeScriptHandlePullRequest"; import { anyRepoHandleStatusUpdate } from "../src/anyRepoHandleStatusUpdate"; +import { anyRepoHandleIssueCommentPayload } from "../src/anyRepoHandleIssueComment"; // The goal of these functions is to validate the call is real, then as quickly as possible get out of the azure // context and into the `src` directory, where work can be done against tests instead requiring changes to happen @@ -37,7 +38,7 @@ const httpTrigger: AzureFunction = async function(context: Context, req: HttpReq break; case "issue_comment": - // NOOP for now + await anyRepoHandleIssueCommentPayload(req.body, context) break; } diff --git a/fixtures/pulls/api-pr-closed.json b/fixtures/pulls/api-pr-closed.json new file mode 100644 index 0000000..677d524 --- /dev/null +++ b/fixtures/pulls/api-pr-closed.json @@ -0,0 +1,358 @@ +{ + "url": "https://api.github.com/repos/microsoft/TypeScript-Website/pulls/227", + "id": 372766396, + "node_id": "MDExOlB1bGxSZXF1ZXN0MzcyNzY2Mzk2", + "html_url": "https://github.com/microsoft/TypeScript-Website/pull/227", + "diff_url": "https://github.com/microsoft/TypeScript-Website/pull/227.diff", + "patch_url": "https://github.com/microsoft/TypeScript-Website/pull/227.patch", + "issue_url": "https://api.github.com/repos/microsoft/TypeScript-Website/issues/227", + "number": 227, + "state": "closed", + "locked": false, + "title": "Adds the index page to the site", + "user": { + "login": "orta", + "id": 49038, + "node_id": "MDQ6VXNlcjQ5MDM4", + "avatar_url": "https://avatars2.githubusercontent.com/u/49038?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/orta", + "html_url": "https://github.com/orta", + "followers_url": "https://api.github.com/users/orta/followers", + "following_url": "https://api.github.com/users/orta/following{/other_user}", + "gists_url": "https://api.github.com/users/orta/gists{/gist_id}", + "starred_url": "https://api.github.com/users/orta/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/orta/subscriptions", + "organizations_url": "https://api.github.com/users/orta/orgs", + "repos_url": "https://api.github.com/users/orta/repos", + "events_url": "https://api.github.com/users/orta/events{/privacy}", + "received_events_url": "https://api.github.com/users/orta/received_events", + "type": "User", + "site_admin": false + }, + "body": "Still a WIP, but I'd like PR builds to test android ", + "created_at": "2020-02-08T22:43:13Z", + "updated_at": "2020-02-10T18:27:42Z", + "closed_at": "2020-02-10T18:27:42Z", + "merged_at": "2020-02-10T18:27:42Z", + "merge_commit_sha": "9d1f1f822332db437fd8943eb4bad24f384d1db4", + "assignee": null, + "assignees": [], + "requested_reviewers": [], + "requested_teams": [], + "labels": [], + "milestone": null, + "commits_url": "https://api.github.com/repos/microsoft/TypeScript-Website/pulls/227/commits", + "review_comments_url": "https://api.github.com/repos/microsoft/TypeScript-Website/pulls/227/comments", + "review_comment_url": "https://api.github.com/repos/microsoft/TypeScript-Website/pulls/comments{/number}", + "comments_url": "https://api.github.com/repos/microsoft/TypeScript-Website/issues/227/comments", + "statuses_url": "https://api.github.com/repos/microsoft/TypeScript-Website/statuses/b240c6171f94ef11f0251bb8639bb591d76c9e59", + "head": { + "label": "microsoft:index_new", + "ref": "index_new", + "sha": "b240c6171f94ef11f0251bb8639bb591d76c9e59", + "user": { + "login": "microsoft", + "id": 6154722, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjYxNTQ3MjI=", + "avatar_url": "https://avatars2.githubusercontent.com/u/6154722?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/microsoft", + "html_url": "https://github.com/microsoft", + "followers_url": "https://api.github.com/users/microsoft/followers", + "following_url": "https://api.github.com/users/microsoft/following{/other_user}", + "gists_url": "https://api.github.com/users/microsoft/gists{/gist_id}", + "starred_url": "https://api.github.com/users/microsoft/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/microsoft/subscriptions", + "organizations_url": "https://api.github.com/users/microsoft/orgs", + "repos_url": "https://api.github.com/users/microsoft/repos", + "events_url": "https://api.github.com/users/microsoft/events{/privacy}", + "received_events_url": "https://api.github.com/users/microsoft/received_events", + "type": "Organization", + "site_admin": false + }, + "repo": { + "id": 193992134, + "node_id": "MDEwOlJlcG9zaXRvcnkxOTM5OTIxMzQ=", + "name": "TypeScript-Website", + "full_name": "microsoft/TypeScript-Website", + "private": false, + "owner": { + "login": "microsoft", + "id": 6154722, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjYxNTQ3MjI=", + "avatar_url": "https://avatars2.githubusercontent.com/u/6154722?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/microsoft", + "html_url": "https://github.com/microsoft", + "followers_url": "https://api.github.com/users/microsoft/followers", + "following_url": "https://api.github.com/users/microsoft/following{/other_user}", + "gists_url": "https://api.github.com/users/microsoft/gists{/gist_id}", + "starred_url": "https://api.github.com/users/microsoft/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/microsoft/subscriptions", + "organizations_url": "https://api.github.com/users/microsoft/orgs", + "repos_url": "https://api.github.com/users/microsoft/repos", + "events_url": "https://api.github.com/users/microsoft/events{/privacy}", + "received_events_url": "https://api.github.com/users/microsoft/received_events", + "type": "Organization", + "site_admin": false + }, + "html_url": "https://github.com/microsoft/TypeScript-Website", + "description": "The Website for TypeScript", + "fork": false, + "url": "https://api.github.com/repos/microsoft/TypeScript-Website", + "forks_url": "https://api.github.com/repos/microsoft/TypeScript-Website/forks", + "keys_url": "https://api.github.com/repos/microsoft/TypeScript-Website/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/microsoft/TypeScript-Website/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/microsoft/TypeScript-Website/teams", + "hooks_url": "https://api.github.com/repos/microsoft/TypeScript-Website/hooks", + "issue_events_url": "https://api.github.com/repos/microsoft/TypeScript-Website/issues/events{/number}", + "events_url": "https://api.github.com/repos/microsoft/TypeScript-Website/events", + "assignees_url": "https://api.github.com/repos/microsoft/TypeScript-Website/assignees{/user}", + "branches_url": "https://api.github.com/repos/microsoft/TypeScript-Website/branches{/branch}", + "tags_url": "https://api.github.com/repos/microsoft/TypeScript-Website/tags", + "blobs_url": "https://api.github.com/repos/microsoft/TypeScript-Website/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/microsoft/TypeScript-Website/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/microsoft/TypeScript-Website/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/microsoft/TypeScript-Website/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/microsoft/TypeScript-Website/statuses/{sha}", + "languages_url": "https://api.github.com/repos/microsoft/TypeScript-Website/languages", + "stargazers_url": "https://api.github.com/repos/microsoft/TypeScript-Website/stargazers", + "contributors_url": "https://api.github.com/repos/microsoft/TypeScript-Website/contributors", + "subscribers_url": "https://api.github.com/repos/microsoft/TypeScript-Website/subscribers", + "subscription_url": "https://api.github.com/repos/microsoft/TypeScript-Website/subscription", + "commits_url": "https://api.github.com/repos/microsoft/TypeScript-Website/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/microsoft/TypeScript-Website/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/microsoft/TypeScript-Website/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/microsoft/TypeScript-Website/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/microsoft/TypeScript-Website/contents/{+path}", + "compare_url": "https://api.github.com/repos/microsoft/TypeScript-Website/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/microsoft/TypeScript-Website/merges", + "archive_url": "https://api.github.com/repos/microsoft/TypeScript-Website/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/microsoft/TypeScript-Website/downloads", + "issues_url": "https://api.github.com/repos/microsoft/TypeScript-Website/issues{/number}", + "pulls_url": "https://api.github.com/repos/microsoft/TypeScript-Website/pulls{/number}", + "milestones_url": "https://api.github.com/repos/microsoft/TypeScript-Website/milestones{/number}", + "notifications_url": "https://api.github.com/repos/microsoft/TypeScript-Website/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/microsoft/TypeScript-Website/labels{/name}", + "releases_url": "https://api.github.com/repos/microsoft/TypeScript-Website/releases{/id}", + "deployments_url": "https://api.github.com/repos/microsoft/TypeScript-Website/deployments", + "created_at": "2019-06-26T23:47:29Z", + "updated_at": "2020-02-10T18:28:14Z", + "pushed_at": "2020-02-10T19:07:11Z", + "git_url": "git://github.com/microsoft/TypeScript-Website.git", + "ssh_url": "git@github.com:microsoft/TypeScript-Website.git", + "clone_url": "https://github.com/microsoft/TypeScript-Website.git", + "svn_url": "https://github.com/microsoft/TypeScript-Website", + "homepage": "https://www.typescriptlang.org", + "size": 41825, + "stargazers_count": 113, + "watchers_count": 113, + "language": "TypeScript", + "has_issues": true, + "has_projects": false, + "has_downloads": true, + "has_wiki": false, + "has_pages": false, + "forks_count": 52, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 85, + "license": { + "key": "other", + "name": "Other", + "spdx_id": "NOASSERTION", + "url": null, + "node_id": "MDc6TGljZW5zZTA=" + }, + "forks": 52, + "open_issues": 85, + "watchers": 113, + "default_branch": "v2" + } + }, + "base": { + "label": "microsoft:v2", + "ref": "v2", + "sha": "5b138d71b6f4148de519a9775331ec7c11bf051a", + "user": { + "login": "microsoft", + "id": 6154722, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjYxNTQ3MjI=", + "avatar_url": "https://avatars2.githubusercontent.com/u/6154722?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/microsoft", + "html_url": "https://github.com/microsoft", + "followers_url": "https://api.github.com/users/microsoft/followers", + "following_url": "https://api.github.com/users/microsoft/following{/other_user}", + "gists_url": "https://api.github.com/users/microsoft/gists{/gist_id}", + "starred_url": "https://api.github.com/users/microsoft/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/microsoft/subscriptions", + "organizations_url": "https://api.github.com/users/microsoft/orgs", + "repos_url": "https://api.github.com/users/microsoft/repos", + "events_url": "https://api.github.com/users/microsoft/events{/privacy}", + "received_events_url": "https://api.github.com/users/microsoft/received_events", + "type": "Organization", + "site_admin": false + }, + "repo": { + "id": 193992134, + "node_id": "MDEwOlJlcG9zaXRvcnkxOTM5OTIxMzQ=", + "name": "TypeScript-Website", + "full_name": "microsoft/TypeScript-Website", + "private": false, + "owner": { + "login": "microsoft", + "id": 6154722, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjYxNTQ3MjI=", + "avatar_url": "https://avatars2.githubusercontent.com/u/6154722?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/microsoft", + "html_url": "https://github.com/microsoft", + "followers_url": "https://api.github.com/users/microsoft/followers", + "following_url": "https://api.github.com/users/microsoft/following{/other_user}", + "gists_url": "https://api.github.com/users/microsoft/gists{/gist_id}", + "starred_url": "https://api.github.com/users/microsoft/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/microsoft/subscriptions", + "organizations_url": "https://api.github.com/users/microsoft/orgs", + "repos_url": "https://api.github.com/users/microsoft/repos", + "events_url": "https://api.github.com/users/microsoft/events{/privacy}", + "received_events_url": "https://api.github.com/users/microsoft/received_events", + "type": "Organization", + "site_admin": false + }, + "html_url": "https://github.com/microsoft/TypeScript-Website", + "description": "The Website for TypeScript", + "fork": false, + "url": "https://api.github.com/repos/microsoft/TypeScript-Website", + "forks_url": "https://api.github.com/repos/microsoft/TypeScript-Website/forks", + "keys_url": "https://api.github.com/repos/microsoft/TypeScript-Website/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/microsoft/TypeScript-Website/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/microsoft/TypeScript-Website/teams", + "hooks_url": "https://api.github.com/repos/microsoft/TypeScript-Website/hooks", + "issue_events_url": "https://api.github.com/repos/microsoft/TypeScript-Website/issues/events{/number}", + "events_url": "https://api.github.com/repos/microsoft/TypeScript-Website/events", + "assignees_url": "https://api.github.com/repos/microsoft/TypeScript-Website/assignees{/user}", + "branches_url": "https://api.github.com/repos/microsoft/TypeScript-Website/branches{/branch}", + "tags_url": "https://api.github.com/repos/microsoft/TypeScript-Website/tags", + "blobs_url": "https://api.github.com/repos/microsoft/TypeScript-Website/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/microsoft/TypeScript-Website/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/microsoft/TypeScript-Website/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/microsoft/TypeScript-Website/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/microsoft/TypeScript-Website/statuses/{sha}", + "languages_url": "https://api.github.com/repos/microsoft/TypeScript-Website/languages", + "stargazers_url": "https://api.github.com/repos/microsoft/TypeScript-Website/stargazers", + "contributors_url": "https://api.github.com/repos/microsoft/TypeScript-Website/contributors", + "subscribers_url": "https://api.github.com/repos/microsoft/TypeScript-Website/subscribers", + "subscription_url": "https://api.github.com/repos/microsoft/TypeScript-Website/subscription", + "commits_url": "https://api.github.com/repos/microsoft/TypeScript-Website/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/microsoft/TypeScript-Website/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/microsoft/TypeScript-Website/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/microsoft/TypeScript-Website/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/microsoft/TypeScript-Website/contents/{+path}", + "compare_url": "https://api.github.com/repos/microsoft/TypeScript-Website/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/microsoft/TypeScript-Website/merges", + "archive_url": "https://api.github.com/repos/microsoft/TypeScript-Website/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/microsoft/TypeScript-Website/downloads", + "issues_url": "https://api.github.com/repos/microsoft/TypeScript-Website/issues{/number}", + "pulls_url": "https://api.github.com/repos/microsoft/TypeScript-Website/pulls{/number}", + "milestones_url": "https://api.github.com/repos/microsoft/TypeScript-Website/milestones{/number}", + "notifications_url": "https://api.github.com/repos/microsoft/TypeScript-Website/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/microsoft/TypeScript-Website/labels{/name}", + "releases_url": "https://api.github.com/repos/microsoft/TypeScript-Website/releases{/id}", + "deployments_url": "https://api.github.com/repos/microsoft/TypeScript-Website/deployments", + "created_at": "2019-06-26T23:47:29Z", + "updated_at": "2020-02-10T18:28:14Z", + "pushed_at": "2020-02-10T19:07:11Z", + "git_url": "git://github.com/microsoft/TypeScript-Website.git", + "ssh_url": "git@github.com:microsoft/TypeScript-Website.git", + "clone_url": "https://github.com/microsoft/TypeScript-Website.git", + "svn_url": "https://github.com/microsoft/TypeScript-Website", + "homepage": "https://www.typescriptlang.org", + "size": 41825, + "stargazers_count": 113, + "watchers_count": 113, + "language": "TypeScript", + "has_issues": true, + "has_projects": false, + "has_downloads": true, + "has_wiki": false, + "has_pages": false, + "forks_count": 52, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 85, + "license": { + "key": "other", + "name": "Other", + "spdx_id": "NOASSERTION", + "url": null, + "node_id": "MDc6TGljZW5zZTA=" + }, + "forks": 52, + "open_issues": 85, + "watchers": 113, + "default_branch": "v2" + } + }, + "_links": { + "self": { + "href": "https://api.github.com/repos/microsoft/TypeScript-Website/pulls/227" + }, + "html": { + "href": "https://github.com/microsoft/TypeScript-Website/pull/227" + }, + "issue": { + "href": "https://api.github.com/repos/microsoft/TypeScript-Website/issues/227" + }, + "comments": { + "href": "https://api.github.com/repos/microsoft/TypeScript-Website/issues/227/comments" + }, + "review_comments": { + "href": "https://api.github.com/repos/microsoft/TypeScript-Website/pulls/227/comments" + }, + "review_comment": { + "href": "https://api.github.com/repos/microsoft/TypeScript-Website/pulls/comments{/number}" + }, + "commits": { + "href": "https://api.github.com/repos/microsoft/TypeScript-Website/pulls/227/commits" + }, + "statuses": { + "href": "https://api.github.com/repos/microsoft/TypeScript-Website/statuses/b240c6171f94ef11f0251bb8639bb591d76c9e59" + } + }, + "author_association": "MEMBER", + "merged": true, + "mergeable": null, + "rebaseable": null, + "mergeable_state": "unknown", + "merged_by": { + "login": "orta", + "id": 49038, + "node_id": "MDQ6VXNlcjQ5MDM4", + "avatar_url": "https://avatars2.githubusercontent.com/u/49038?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/orta", + "html_url": "https://github.com/orta", + "followers_url": "https://api.github.com/users/orta/followers", + "following_url": "https://api.github.com/users/orta/following{/other_user}", + "gists_url": "https://api.github.com/users/orta/gists{/gist_id}", + "starred_url": "https://api.github.com/users/orta/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/orta/subscriptions", + "organizations_url": "https://api.github.com/users/orta/orgs", + "repos_url": "https://api.github.com/users/orta/repos", + "events_url": "https://api.github.com/users/orta/events{/privacy}", + "received_events_url": "https://api.github.com/users/orta/received_events", + "type": "User", + "site_admin": false + }, + "comments": 1, + "review_comments": 0, + "maintainer_can_modify": false, + "commits": 7, + "additions": 942, + "deletions": 109, + "changed_files": 29 +} diff --git a/package-lock.json b/package-lock.json index c481b67..789df00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -549,6 +549,11 @@ "jest-diff": "^24.3.0" } }, + "@types/minimatch": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", + "integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==" + }, "@types/node": { "version": "12.12.14", "resolved": "https://registry.npmjs.org/@types/node/-/node-12.12.14.tgz", @@ -1091,6 +1096,66 @@ "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=", "dev": true }, + "codeowners": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/codeowners/-/codeowners-4.1.1.tgz", + "integrity": "sha512-XgCTRnp2p/3TFw2DaH+UvrhyWAJEzglWkdnYlirTd0TXEWMFhLO/e7vo72P9HTi2bp3D1I4mUo+B2Gjqr+qhVA==", + "dev": true, + "requires": { + "commander": "^2.11.0", + "find-up": "^2.1.0", + "ignore": "^3.3.3", + "is-directory": "^0.3.1", + "lodash.maxby": "^4.6.0", + "lodash.padend": "^4.6.1", + "true-case-path": "^1.0.2" + }, + "dependencies": { + "find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=", + "dev": true, + "requires": { + "locate-path": "^2.0.0" + } + }, + "locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=", + "dev": true, + "requires": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + } + }, + "p-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", + "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", + "dev": true, + "requires": { + "p-try": "^1.0.0" + } + }, + "p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=", + "dev": true, + "requires": { + "p-limit": "^1.1.0" + } + }, + "p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=", + "dev": true + } + } + }, "collection-visit": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", @@ -1129,8 +1194,7 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "optional": true + "dev": true }, "component-emitter": { "version": "1.3.0", @@ -2469,6 +2533,12 @@ "safer-buffer": ">= 2.1.2 < 3" } }, + "ignore": { + "version": "3.3.10", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.10.tgz", + "integrity": "sha512-Pgs951kaMm5GXP7MOvxERINe3gsaVjUWFm+UZPSq9xYriQAksyhg0csnS0KXSNRD5NmNdapXEpjxG49+AKh/ug==", + "dev": true + }, "import-local": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-2.0.0.tgz", @@ -2602,6 +2672,12 @@ } } }, + "is-directory": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/is-directory/-/is-directory-0.3.1.tgz", + "integrity": "sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE=", + "dev": true + }, "is-extendable": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", @@ -3391,12 +3467,24 @@ "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=" }, + "lodash.maxby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.maxby/-/lodash.maxby-4.6.0.tgz", + "integrity": "sha1-CCJABo88eiJ6oAqDgOTzjPB4bj0=", + "dev": true + }, "lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=", "dev": true }, + "lodash.padend": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/lodash.padend/-/lodash.padend-4.6.1.tgz", + "integrity": "sha1-U8y6BH0G4VjTEfRdpiX05J5vFm4=", + "dev": true + }, "lodash.set": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz", @@ -4797,6 +4885,15 @@ "punycode": "^2.1.0" } }, + "true-case-path": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/true-case-path/-/true-case-path-1.0.3.tgz", + "integrity": "sha512-m6s2OdQe5wgpFMC+pAJ+q9djG82O2jcHPOI6RNg1yy9rCYR+WD6Nbpl32fDpfC56nirdRy+opFa/Vk7HYhqaew==", + "dev": true, + "requires": { + "glob": "^7.1.2" + } + }, "ts-jest": { "version": "24.2.0", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-24.2.0.tgz", diff --git a/package.json b/package.json index c376f1e..6f44e51 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "@azure/functions": "^1.0.2-beta2", "@octokit/rest": "^16.35.0", "@octokit/webhooks": "^6.3.2", + "@types/minimatch": "^3.0.3", + "minimatch": "^3.0.4", "parse-diff": "^0.6.0" }, "devDependencies": { @@ -22,7 +24,8 @@ "jest": "^24.9.0", "ts-jest": "^24.2.0", "ts-node": "^8.5.4", - "typescript": "3.8.0-beta" + "typescript": "3.8.0-beta", + "codeowners": "^4.1.1" }, "jest": { "preset": "ts-jest", diff --git a/src/anyRepoHandleIssueComment.ts b/src/anyRepoHandleIssueComment.ts new file mode 100644 index 0000000..80b8357 --- /dev/null +++ b/src/anyRepoHandleIssueComment.ts @@ -0,0 +1,29 @@ +import { WebhookPayloadIssueComment } from "@octokit/webhooks"; +import { Context, Logger } from "@azure/functions"; +import { createGitHubClient } from "./util/createGitHubClient"; +import Octokit = require("@octokit/rest"); +import {sha} from "./sha" +import { mergeThroughCodeOwners } from "./checks/mergeThroughCodeOwners"; + + +export const anyRepoHandleIssueCommentPayload = async (payload: WebhookPayloadIssueComment, context: Context) => { + const api = createGitHubClient(); + const ran = [] as string[] + + const run = (name: string, fn: (api: Octokit, payload: WebhookPayloadIssueComment, logger: Logger) => Promise) => { + context.log.info(`\n\n## ${name}\n`) + ran.push(name) + return fn(api, payload, context.log) + } + + // Making this one whitelisted to the website for now + if (payload.repository.name === "TypeScript-Website") { + await run("Checking if we should merge from codeowners", mergeThroughCodeOwners) + } + + context.res = { + status: 200, + headers: { sha: sha }, + body: `Success, ran: ${ran.join(", ")}` + }; +}; diff --git a/src/checks/mergeThroughCodeOwners.test.ts b/src/checks/mergeThroughCodeOwners.test.ts new file mode 100644 index 0000000..5ef5222 --- /dev/null +++ b/src/checks/mergeThroughCodeOwners.test.ts @@ -0,0 +1,63 @@ +jest.mock("../util/getContents"); + +import { createMockGitHubClient, getPRFixture } from "../util/tests/createMockGitHubClient"; +import { getFakeLogger } from "../util/tests/createMockContext"; + +import { mergeThroughCodeOwners, mergePhrase } from "./mergeThroughCodeOwners"; +import { getContents } from "../util/getContents"; + +const genericWebhook = { + comment: { + body: mergePhrase, + user: { + login: "orta" + } + }, + issue: { + number: 1234 + }, + repository: { + owner: { + login: "microsoft" + }, + name: "TypeScript-Website" + } +} + +describe("for handling merging when green", () => { + it("bails when the keyword aren't used", async () => { + const { api } = createMockGitHubClient(); + const logger = getFakeLogger(); + + await mergeThroughCodeOwners(api, { comment: { body: "Good Joke"} } as any, logger); + expect(logger.info).toBeCalledWith("No included message, not trying to merge through code owners"); + }); + + it("handles the phrase in an issue", async () => { + const { api, mockAPI } = createMockGitHubClient(); + mockAPI.pulls.get.mockRejectedValue(new Error("not found")) + const logger = getFakeLogger(); + + await mergeThroughCodeOwners(api, genericWebhook as any, logger); + expect(logger.info).toBeCalledWith("Comment was in an issue"); + }); + + it("handles the phrase in an pr", async () => { + const { api, mockAPI } = createMockGitHubClient(); + const logger = getFakeLogger(); + const pr = getPRFixture("api-pr-closed") + // @ts-ignore + getContents.mockReturnValue("/hello.txt @orta") + + // Getting the PR form the API + mockAPI.pulls.get.mockResolvedValue({ data: pr }) + + // Getting the files + mockAPI.pulls.listFiles.endpoint.merge.mockResolvedValue({}) + mockAPI.paginate.mockResolvedValue([{ filename: "/hello.txt"}]) + + await mergeThroughCodeOwners(api, genericWebhook as any, logger); + + expect(logger.info).toBeCalledWith("Accepting as reasonable to merge"); + }); +}); diff --git a/src/checks/mergeThroughCodeOwners.ts b/src/checks/mergeThroughCodeOwners.ts new file mode 100644 index 0000000..6ebc137 --- /dev/null +++ b/src/checks/mergeThroughCodeOwners.ts @@ -0,0 +1,34 @@ +import { WebhookPayloadIssueComment } from "@octokit/webhooks"; +import * as Octokit from "@octokit/rest"; +import { Logger } from "@azure/functions"; +import { hasAccessToMergePRs } from "../pr_meta/hasAccessToMergePR"; +import { mergeOrAddMergeLabel } from "../pr_meta/mergeOrAddMergeLabel"; + +export const mergePhrase = "OK, merge" + +/** + * Allow someone to declare a PR should be merged if they have access rights via code owners + */ +export const mergeThroughCodeOwners = async (api: Octokit, payload: WebhookPayloadIssueComment, logger: Logger) => { + if (!payload.comment.body.includes(mergePhrase)) { + return logger.info(`No included message, not trying to merge through code owners`) + } + + // Grab the correlating PR + let pull: Octokit.Response["data"] + + try { + const response = await api.pulls.get({ owner: payload.repository.owner.login, repo: payload.repository.name, pull_number: payload.issue.number, }) + pull = response.data + } catch (error) { + return logger.info(`Comment was in an issue`) + } + + const canMerge = await hasAccessToMergePRs(payload.comment.user.login, api, pull, logger) + if (!canMerge) { + return // it logs in the function above + } + + logger.info("Looks good to merge") + mergeOrAddMergeLabel(api, { number: pull.number, repo: pull.base.repo.name, owner: pull.base.repo.owner.login }, pull.head.sha, logger) +} diff --git a/src/pr_meta/hasAccessToMergePR.test.ts b/src/pr_meta/hasAccessToMergePR.test.ts new file mode 100644 index 0000000..b1a3cf6 --- /dev/null +++ b/src/pr_meta/hasAccessToMergePR.test.ts @@ -0,0 +1,45 @@ +import { getFilesNotOwnedByCodeOwner, getCodeOwnersInfo } from "./hasAccessToMergePR" + +it("matches simple files", () => { + const codeOwnersText = +` +/a/b @orta +` + const files = ["/a/b"] + + const result = getFilesNotOwnedByCodeOwner("orta", files, getCodeOwnersInfo(codeOwnersText)) + expect(result).toEqual([]) +}) + +it("matches simple files", () => { + const codeOwnersText = +` +/a/b/c/* @orta +/a/b/d/* @orta +/b/c @hayes +` + const files = ["/a/b", "/a/b/c/deff", "/b/c"] + + const result = getFilesNotOwnedByCodeOwner("orta", files, getCodeOwnersInfo(codeOwnersText)) + expect(result).toEqual([ + "/a/b", + "/b/c" + ]) +}) + + +it("matches simple files", () => { + const codeOwnersText = +` +/packages/**/ja/** @hayes +/a/b/d/* @orta +/b/c @hayes +` + const files = [ + "/packages/playground-examples/copy/ja/TypeScript/Type Primitives/Built-in Utility Types.ts ", + "/b/c" + ] + + const result = getFilesNotOwnedByCodeOwner("hayes", files, getCodeOwnersInfo(codeOwnersText)) + expect(result).toEqual([]) +}) diff --git a/src/pr_meta/hasAccessToMergePR.ts b/src/pr_meta/hasAccessToMergePR.ts new file mode 100644 index 0000000..faa07cf --- /dev/null +++ b/src/pr_meta/hasAccessToMergePR.ts @@ -0,0 +1,119 @@ +import Octokit = require("@octokit/rest"); +import { Logger } from "@azure/functions"; +import { getContents } from "../util/getContents"; +import * as minimatch from "minimatch" + +type PR = Octokit.Response["data"] + +/** + * Checks if a user has access to merge via a comment + * + * @param commenterLogin the username of who we're checking if they can merge + * @param octokit authed api for github + * @param pr the JSON from a get request for a PR + * @param logger logs + */ +export const hasAccessToMergePRs = async ( + commenterLogin: string, + octokit: Octokit, + pr: PR, + logger: Logger +) => { + const codeownersText = await getCodeOwnersFileForRepo(octokit, pr); + if (codeownersText === "") { + logger.info("Skipping because there is no code-owners file in the repo"); + return false; + } + + const changedFiles = await getPRChangedFiles(octokit, pr); + const codeOwners = getCodeOwnersInfo(codeownersText); + + const prAuthor = pr.user.login; + + const filesWhichArentOwned = getFilesNotOwnedByCodeOwner(commenterLogin, changedFiles, codeOwners); + + if (filesWhichArentOwned.length > 0) { + logger.info(`- ${prAuthor}: Bailing because not all files were covered by the codeowners for this review`); + logger.info(` Missing: ${filesWhichArentOwned.join(", ")}`); + return false; + } else { + logger.info("Accepting as reasonable to merge"); + return true; + } +}; + +// async function comm +export function getFilesNotOwnedByCodeOwner(commenterLogin: string, files: string[], codeOwners: CodeOwner[]) { + const atUser = "@" + commenterLogin; + const activeCodeOwners = codeOwners.filter(c => c.usernames.includes(atUser)); + + if (activeCodeOwners.length == 0) { + // Couldn't find anything, so return all the files + return files; + } + + // Make a copy of all matches, then loop through known codeowners removing anything which passes + let matched = [...files] + activeCodeOwners.forEach(owners => { + matched.forEach((file, index) => { + if(minimatch(file, owners.path)) { + matched.splice(index,1); + } + }); + }); + + + return matched; +} + +async function getPRChangedFiles(octokit: Octokit, webhook: PR) { + // https://developer.github.com/v3/pulls/#list-pull-requests-files + const repo = webhook.base.repo + const options = octokit.pulls.listFiles.endpoint.merge({ + owner: repo.owner.login, + repo: repo.name, + pull_number: webhook.number + }); + + const files = await octokit.paginate(options); + const fileStrings = files.map(f => `/${f.filename}`); + return fileStrings; +} + +const getCodeOwnersFileForRepo = (api: Octokit, webhook: PR) => { + const repo = webhook.base.repo; + try { + return getContents(api, { owner: repo.owner.login, repo: repo.name, path: ".github/CODEOWNERS" }); + } catch (error) { + return ""; + } +}; + +type CodeOwner = { + path: string; + usernames: string[]; +}; + +export const getCodeOwnersInfo = (codeownerContent: string): CodeOwner[] => { + const lines = codeownerContent.split("\n"); + const ownerEntries = [] as CodeOwner[]; + + for (const line of lines) { + if (!line) { + continue; + } + + if (line.startsWith("#")) { + continue; + } + + const [pathString, ...usernames] = line.split(/\s+/); + + ownerEntries.push({ + path: pathString, + usernames: usernames + }); + } + + return ownerEntries; +}; diff --git a/src/pr_meta/mergeOrAddMergeLabel.ts b/src/pr_meta/mergeOrAddMergeLabel.ts new file mode 100644 index 0000000..7a8e9e1 --- /dev/null +++ b/src/pr_meta/mergeOrAddMergeLabel.ts @@ -0,0 +1,21 @@ +import * as Octokit from "@octokit/rest"; +import { Logger } from "@azure/functions"; + +type PullMeta = { + repo: string, + owner: string, + number: number +} + +export const mergeOrAddMergeLabel = async (api: Octokit, pullMeta: PullMeta, headCommitSHA: string, logger: Logger) => { + const allGreen = await api.repos.getCombinedStatusForRef({ ...pullMeta, ref: headCommitSHA }) + + if (allGreen.data.state === "success") { + // Merge now + const commitTitle = "Merged automatically" + await api.pulls.merge({ ...pullMeta, commit_title: commitTitle }) + } else { + // Merge when green + await api.issues.addLabels({ ...pullMeta, labels: ["Merge on Green"] }) + } +} diff --git a/src/tests/TypeScriptRepoPullRequestWebhook.test.ts b/src/tests/TypeScriptRepoPullRequestWebhook.test.ts index 740ef79..30d2408 100644 --- a/src/tests/TypeScriptRepoPullRequestWebhook.test.ts +++ b/src/tests/TypeScriptRepoPullRequestWebhook.test.ts @@ -1,9 +1,11 @@ jest.mock("../typeScriptHandlePullRequest", () => ({ handlePullRequestPayload: jest.fn() })) jest.mock("../anyRepoHandleStatusUpdate", () => ({ anyRepoHandleStatusUpdate: jest.fn() })) +jest.mock("../anyRepoHandleIssueComment", () => ({ anyRepoHandleIssueCommentPayload: jest.fn() })) import webhook from "../../TypeScriptRepoPullRequestWebhook/index" import { handlePullRequestPayload } from "../typeScriptHandlePullRequest" import { anyRepoHandleStatusUpdate } from "../anyRepoHandleStatusUpdate" +import { anyRepoHandleIssueCommentPayload } from "../anyRepoHandleIssueComment" it("calls handle PR from the webhook main", () => { process.env.AZURE_FUNCTIONS_ENVIRONMENT = "Development" @@ -18,3 +20,10 @@ it("calls handle status from the webhook main", () => { expect(anyRepoHandleStatusUpdate).toHaveBeenCalled() }) + +it("calls handle cmments from the webhook main", () => { + process.env.AZURE_FUNCTIONS_ENVIRONMENT = "Development" + webhook({ log: { info: () => "" }} as any, { body: "{}", headers: { "X-GitHub-Event": "issue_comment" }}) + + expect(anyRepoHandleIssueCommentPayload).toHaveBeenCalled() +}) diff --git a/src/util/getContents.ts b/src/util/getContents.ts new file mode 100644 index 0000000..4a28cfb --- /dev/null +++ b/src/util/getContents.ts @@ -0,0 +1,8 @@ +import Octokit = require('@octokit/rest'); + +export const getContents = async (api: Octokit, opts: Octokit.ReposGetContentsParams) => { + const contentResponse = await api.repos.getContents(opts) +// @ts-ignore types are mismatched + const text = Buffer.from(contentResponse.data.content, 'base64').toString() + return text +} diff --git a/src/util/tests/createMockContext.ts b/src/util/tests/createMockContext.ts index dada7fb..53f6b0f 100644 --- a/src/util/tests/createMockContext.ts +++ b/src/util/tests/createMockContext.ts @@ -1,13 +1,15 @@ import {Logger, Context} from "@azure/functions" -const cliLogger = jest.fn() as any -cliLogger.error = jest.fn() -cliLogger.warn = jest.fn() -cliLogger.info = jest.fn() -cliLogger.verbose = jest.fn() /** Returns a logger which conforms to the Azure logger interface */ -export const getFakeLogger = (): Logger => cliLogger +export const getFakeLogger = (): Logger => { + const cliLogger = jest.fn() as any + cliLogger.error = jest.fn() + cliLogger.warn = jest.fn() + cliLogger.info = jest.fn() + cliLogger.verbose = jest.fn() + return cliLogger +} /** * Create a mock context which eats all logs, only contains the logging subset diff --git a/src/util/tests/createMockGitHubClient.ts b/src/util/tests/createMockGitHubClient.ts index c8a40d0..a147bf6 100644 --- a/src/util/tests/createMockGitHubClient.ts +++ b/src/util/tests/createMockGitHubClient.ts @@ -33,7 +33,12 @@ export const createMockGitHubClient = () => { }, pulls: { get: jest.fn(), - merge: jest.fn() + merge: jest.fn(), + listFiles: { + endpoint: { + merge: jest.fn() + } + } }, teams: { getByName: jest.fn(), @@ -41,7 +46,8 @@ export const createMockGitHubClient = () => { }, search: { issues: jest.fn() - } + }, + paginate: jest.fn() }; return { @@ -77,7 +83,8 @@ export const createFakeGitHubClient = () => { }, pulls: { get: Promise.resolve({}), - merge: Promise.resolve({}) + merge: Promise.resolve({}), + listFiles: Promise.resolve({}) }, teams: { getByName: Promise.resolve({ }), @@ -85,7 +92,8 @@ export const createFakeGitHubClient = () => { }, search: { issues: Promise.resolve({}) - } + }, + paginate: Promise.resolve({}) as any }; return (fake as unknown) as Octokit; @@ -102,7 +110,8 @@ export const convertToOctokitAPI = (mock: {}) => { }; /** Grabs a known PR fixture */ -export const getPRFixture = (fixture: "closed" | "opened"): WebhookPayloadPullRequest => +export const getPRFixture = (fixture: "closed" | "opened" | "api-pr-closed", +): WebhookPayloadPullRequest => JSON.parse(readFileSync(join(__dirname, "..", "..", "..", "fixtures", "pulls", fixture + ".json"), "utf8")); /** Grabs a known issue fixture */ From dc9e52cdded3df5e9253a7b85244b50b492edcff Mon Sep 17 00:00:00 2001 From: Orta Therox Date: Mon, 10 Feb 2020 17:14:30 -0500 Subject: [PATCH 2/2] Change phrase --- src/checks/mergeThroughCodeOwners.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/checks/mergeThroughCodeOwners.ts b/src/checks/mergeThroughCodeOwners.ts index 6ebc137..22aa51d 100644 --- a/src/checks/mergeThroughCodeOwners.ts +++ b/src/checks/mergeThroughCodeOwners.ts @@ -4,7 +4,7 @@ import { Logger } from "@azure/functions"; import { hasAccessToMergePRs } from "../pr_meta/hasAccessToMergePR"; import { mergeOrAddMergeLabel } from "../pr_meta/mergeOrAddMergeLabel"; -export const mergePhrase = "OK, merge" +export const mergePhrase = "Ready to merge" /** * Allow someone to declare a PR should be merged if they have access rights via code owners