diff --git a/.gitignore b/.gitignore index 7cda87d..f865277 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ build/ dist/ doc/api/ doc/_build/ +.tox/ diff --git a/baldrick/blueprints/github.py b/baldrick/blueprints/github.py index ba322dc..711a5a5 100644 --- a/baldrick/blueprints/github.py +++ b/baldrick/blueprints/github.py @@ -1,6 +1,7 @@ import json from flask import Blueprint, request +from loguru import logger from baldrick.github.github_api import RepoHandler @@ -29,13 +30,16 @@ def github_webhook_handler(func): def github_webhook(): if not request.data: + logger.trace("No payload received") return "No payload received" # Parse the JSON sent by GitHub payload = json.loads(request.data) if 'installation' not in payload: - return "No installation key found in payload" + msg = "No installation key found in payload" + logger.trace(msg) + return msg else: installation = payload['installation']['id'] diff --git a/baldrick/github/github_api.py b/baldrick/github/github_api.py index 8efeecd..b461496 100644 --- a/baldrick/github/github_api.py +++ b/baldrick/github/github_api.py @@ -322,6 +322,62 @@ def get_all_labels(self): return [label['name'] for label in result] + def create_file(self, path, message, content, branch, *, committer=None, author=None): + """ + Create a new file in the repo. + + Parameters + ---------- + path : `str` + The path to create the file at, from the base of the repo. + + message : `str` + The commit message to be used with this commit. + + content : `str` or `bytes` + The base64 encoded content to be added to the commit. If the type + `bytes` it is assumed to be already base64 encoded. If the type is + string it will be assumed to be a utf-8 string and encoded to bytes + and then base64. + + branch : `str` + The branch to add the file to. + + committer : `dict`, optional + The committer for the commit, should be a dict of ``{'name': str, + 'email': str}``. Will default to the bot user. + + author : `dict`, optional + The author for the commit, should be a dict of ``{'name': str, + 'email': str}``. Will default to the bot user. + + Returns + ------- + sha : `str` + The commit hash that was created. + + """ + if isinstance(content, str): + content = base64.b64encode(content.encode("utf-8")) + + url = f"{HOST}/repos/{self.repo}/contents/{path}" + data = { + "message": message, + "content": content, + "branch": branch, + } + + if committer: + data["committer"] = committer + + if author: + data["author"] = author + + resp = requests.put(url, data=data) + + return resp["commit"] + + class IssueHandler(GitHubHandler): def __init__(self, repo, number, installation=None): diff --git a/baldrick/plugins/github_comment_matcher.py b/baldrick/plugins/github_comment_matcher.py new file mode 100644 index 0000000..46d0435 --- /dev/null +++ b/baldrick/plugins/github_comment_matcher.py @@ -0,0 +1,71 @@ +""" +A plugin which reacts to comments on PRs and can add a changelog. +""" +import re +from loguru import logger + +from baldrick.github.github_api import PullRequestHandler, IssueHandler +from baldrick.blueprints.github import github_webhook_handler + +ISSUE_COMMENT_MATCHERS = {} +PR_COMMENT_MATCHERS = {} + + +@github_webhook_handler +def handle_issue_comments(repo_handler, payload, headers): + + event = headers['X-GitHub-Event'] + if event != 'issue_comment': + return "Not a pull_request or issues event" + + number = payload['issue']['number'] + + issue_handler = IssueHandler(repo_handler.repo, number, repo_handler.installation) + if 'pull_request' in payload['issue']: + issue_handler = PullRequestHandler(repo_handler.repo, number, repo_handler.installation) + + process_issue_comment(issue_handler, repo_handler, payload['comment']['body'], payload) + + +def match_issue_comment(regex, issue_type="both"): + """ + A decorator used to call a plugin on a issue comment. + + Parameters + ---------- + regex : `str` + The regex to apply to the comment. + + issue_type : `str` {'issue', 'pull', 'both'} + The type of issue to match on. Defaults to 'both' to match comments on issues and PRs. + """ + def wrapper(func): + if issue_type in ('issue', 'both'): + ISSUE_COMMENT_MATCHERS[regex] = func + return func + if issue_type in ('pull', 'both'): + PR_COMMENT_MATCHERS[regex] = func + return func + + raise ValueError("issue_type must be one one 'issue', 'pull' or 'both'") + + return wrapper + + +def process_issue_comment(issue_handler, repo_handler, comment, payload): + logger.trace(f"Processing comment {comment} on {issue_handler.repo}#{issue_handler.number}.") + + if 'pull_request' in payload['issue']: + for expression, func in PR_COMMENT_MATCHERS.items(): + logger.trace(f"Testing {expression} for pull_request.") + match = re.search(expression, comment, False) + if match: + logger.trace(f"{expression} matched calling {func}") + func(issue_handler, repo_handler, comment, payload) + else: + for expression, func in ISSUE_COMMENT_MATCHERS.items(): + logger.trace(f"Testing {expression} for issue.") + match = re.search(expression, comment, False) + if match: + logger.trace(f"{expression} matched calling {func}") + func(issue_handler, repo_handler, comment, payload) diff --git a/baldrick/plugins/github_towncrier_changelog.py b/baldrick/plugins/github_towncrier_changelog.py index bea19f7..70d5567 100644 --- a/baldrick/plugins/github_towncrier_changelog.py +++ b/baldrick/plugins/github_towncrier_changelog.py @@ -1,53 +1,12 @@ import os import re -from collections import OrderedDict from loguru import logger from toml import loads +from towncrier._settings import parse_toml from .github_pull_requests import pull_request_handler -try: - from towncrier._settings import parse_toml -except ImportError: # pragma: nocover - from towncrier._settings import _template_fname, _start_string, _title_format, _underlines, _default_types - - def parse_toml(config): # pragma: nocover - if 'tool' not in config: - raise ValueError("No [tool.towncrier] section.") - - config = config['tool']['towncrier'] - - sections = OrderedDict() - types = OrderedDict() - - if "section" in config: - for x in config["section"]: - sections[x.get('name', '')] = x['path'] - else: - sections[''] = '' - - if "type" in config: - for x in config["type"]: - types[x["directory"]] = {"name": x["name"], - "showcontent": x["showcontent"]} - else: - types = _default_types - - return { - 'package': config.get('package', ''), - 'package_dir': config.get('package_dir', '.'), - 'filename': config.get('filename', 'NEWS.rst'), - 'directory': config.get('directory'), - 'sections': sections, - 'types': types, - 'template': config.get('template', _template_fname), - 'start_line': config.get('start_string', _start_string), - 'title_format': config.get('title_format', _title_format), - 'issue_format': config.get('issue_format'), - 'underlines': config.get('underlines', _underlines) - } - def calculate_fragment_paths(config): diff --git a/baldrick/plugins/tests/issue_comment_payload.json b/baldrick/plugins/tests/issue_comment_payload.json new file mode 100644 index 0000000..57913e3 --- /dev/null +++ b/baldrick/plugins/tests/issue_comment_payload.json @@ -0,0 +1 @@ +{"action": "created", "issue": {"url": "https://api.github.com/repos/Cadair/testgiles/issues/15", "repository_url": "https://api.github.com/repos/Cadair/testgiles", "labels_url": "https://api.github.com/repos/Cadair/testgiles/issues/15/labels{/name}", "comments_url": "https://api.github.com/repos/Cadair/testgiles/issues/15/comments", "events_url": "https://api.github.com/repos/Cadair/testgiles/issues/15/events", "html_url": "https://github.com/Cadair/testgiles/pull/15", "id": 549197525, "node_id": "MDExOlB1bGxSZXF1ZXN0MzYyMzM0ODkw", "number": 15, "title": "Update README.md", "user": {"login": "Cadair", "id": 1391051, "node_id": "MDQ6VXNlcjEzOTEwNTE=", "avatar_url": "https://avatars2.githubusercontent.com/u/1391051?v=4", "gravatar_id": "", "url": "https://api.github.com/users/Cadair", "html_url": "https://github.com/Cadair", "followers_url": "https://api.github.com/users/Cadair/followers", "following_url": "https://api.github.com/users/Cadair/following{/other_user}", "gists_url": "https://api.github.com/users/Cadair/gists{/gist_id}", "starred_url": "https://api.github.com/users/Cadair/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/Cadair/subscriptions", "organizations_url": "https://api.github.com/users/Cadair/orgs", "repos_url": "https://api.github.com/users/Cadair/repos", "events_url": "https://api.github.com/users/Cadair/events{/privacy}", "received_events_url": "https://api.github.com/users/Cadair/received_events", "type": "User", "site_admin": false}, "labels": [], "state": "open", "locked": false, "assignee": null, "assignees": [], "milestone": null, "comments": 11, "created_at": "2020-01-13T21:40:45Z", "updated_at": "2020-01-14T17:43:03Z", "closed_at": null, "author_association": "OWNER", "pull_request": {"url": "https://api.github.com/repos/Cadair/testgiles/pulls/15", "html_url": "https://github.com/Cadair/testgiles/pull/15", "diff_url": "https://github.com/Cadair/testgiles/pull/15.diff", "patch_url": "https://github.com/Cadair/testgiles/pull/15.patch"}, "body": ""}, "comment": {"url": "https://api.github.com/repos/Cadair/testgiles/issues/comments/574291511", "html_url": "https://github.com/Cadair/testgiles/pull/15#issuecomment-574291511", "issue_url": "https://api.github.com/repos/Cadair/testgiles/issues/15", "id": 574291511, "node_id": "MDEyOklzc3VlQ29tbWVudDU3NDI5MTUxMQ==", "user": {"login": "Cadair", "id": 1391051, "node_id": "MDQ6VXNlcjEzOTEwNTE=", "avatar_url": "https://avatars2.githubusercontent.com/u/1391051?v=4", "gravatar_id": "", "url": "https://api.github.com/users/Cadair", "html_url": "https://github.com/Cadair", "followers_url": "https://api.github.com/users/Cadair/followers", "following_url": "https://api.github.com/users/Cadair/following{/other_user}", "gists_url": "https://api.github.com/users/Cadair/gists{/gist_id}", "starred_url": "https://api.github.com/users/Cadair/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/Cadair/subscriptions", "organizations_url": "https://api.github.com/users/Cadair/orgs", "repos_url": "https://api.github.com/users/Cadair/repos", "events_url": "https://api.github.com/users/Cadair/events{/privacy}", "received_events_url": "https://api.github.com/users/Cadair/received_events", "type": "User", "site_admin": false}, "created_at": "2020-01-14T17:43:03Z", "updated_at": "2020-01-14T17:43:03Z", "author_association": "OWNER", "body": "pr?"}, "repository": {"id": 160889443, "node_id": "MDEwOlJlcG9zaXRvcnkxNjA4ODk0NDM=", "name": "testgiles", "full_name": "Cadair/testgiles", "private": false, "owner": {"login": "Cadair", "id": 1391051, "node_id": "MDQ6VXNlcjEzOTEwNTE=", "avatar_url": "https://avatars2.githubusercontent.com/u/1391051?v=4", "gravatar_id": "", "url": "https://api.github.com/users/Cadair", "html_url": "https://github.com/Cadair", "followers_url": "https://api.github.com/users/Cadair/followers", "following_url": "https://api.github.com/users/Cadair/following{/other_user}", "gists_url": "https://api.github.com/users/Cadair/gists{/gist_id}", "starred_url": "https://api.github.com/users/Cadair/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/Cadair/subscriptions", "organizations_url": "https://api.github.com/users/Cadair/orgs", "repos_url": "https://api.github.com/users/Cadair/repos", "events_url": "https://api.github.com/users/Cadair/events{/privacy}", "received_events_url": "https://api.github.com/users/Cadair/received_events", "type": "User", "site_admin": false}, "html_url": "https://github.com/Cadair/testgiles", "description": "A repo for testing giles", "fork": false, "url": "https://api.github.com/repos/Cadair/testgiles", "forks_url": "https://api.github.com/repos/Cadair/testgiles/forks", "keys_url": "https://api.github.com/repos/Cadair/testgiles/keys{/key_id}", "collaborators_url": "https://api.github.com/repos/Cadair/testgiles/collaborators{/collaborator}", "teams_url": "https://api.github.com/repos/Cadair/testgiles/teams", "hooks_url": "https://api.github.com/repos/Cadair/testgiles/hooks", "issue_events_url": "https://api.github.com/repos/Cadair/testgiles/issues/events{/number}", "events_url": "https://api.github.com/repos/Cadair/testgiles/events", "assignees_url": "https://api.github.com/repos/Cadair/testgiles/assignees{/user}", "branches_url": "https://api.github.com/repos/Cadair/testgiles/branches{/branch}", "tags_url": "https://api.github.com/repos/Cadair/testgiles/tags", "blobs_url": "https://api.github.com/repos/Cadair/testgiles/git/blobs{/sha}", "git_tags_url": "https://api.github.com/repos/Cadair/testgiles/git/tags{/sha}", "git_refs_url": "https://api.github.com/repos/Cadair/testgiles/git/refs{/sha}", "trees_url": "https://api.github.com/repos/Cadair/testgiles/git/trees{/sha}", "statuses_url": "https://api.github.com/repos/Cadair/testgiles/statuses/{sha}", "languages_url": "https://api.github.com/repos/Cadair/testgiles/languages", "stargazers_url": "https://api.github.com/repos/Cadair/testgiles/stargazers", "contributors_url": "https://api.github.com/repos/Cadair/testgiles/contributors", "subscribers_url": "https://api.github.com/repos/Cadair/testgiles/subscribers", "subscription_url": "https://api.github.com/repos/Cadair/testgiles/subscription", "commits_url": "https://api.github.com/repos/Cadair/testgiles/commits{/sha}", "git_commits_url": "https://api.github.com/repos/Cadair/testgiles/git/commits{/sha}", "comments_url": "https://api.github.com/repos/Cadair/testgiles/comments{/number}", "issue_comment_url": "https://api.github.com/repos/Cadair/testgiles/issues/comments{/number}", "contents_url": "https://api.github.com/repos/Cadair/testgiles/contents/{+path}", "compare_url": "https://api.github.com/repos/Cadair/testgiles/compare/{base}...{head}", "merges_url": "https://api.github.com/repos/Cadair/testgiles/merges", "archive_url": "https://api.github.com/repos/Cadair/testgiles/{archive_format}{/ref}", "downloads_url": "https://api.github.com/repos/Cadair/testgiles/downloads", "issues_url": "https://api.github.com/repos/Cadair/testgiles/issues{/number}", "pulls_url": "https://api.github.com/repos/Cadair/testgiles/pulls{/number}", "milestones_url": "https://api.github.com/repos/Cadair/testgiles/milestones{/number}", "notifications_url": "https://api.github.com/repos/Cadair/testgiles/notifications{?since,all,participating}", "labels_url": "https://api.github.com/repos/Cadair/testgiles/labels{/name}", "releases_url": "https://api.github.com/repos/Cadair/testgiles/releases{/id}", "deployments_url": "https://api.github.com/repos/Cadair/testgiles/deployments", "created_at": "2018-12-08T00:33:47Z", "updated_at": "2020-01-13T21:44:09Z", "pushed_at": "2020-01-13T21:44:07Z", "git_url": "git://github.com/Cadair/testgiles.git", "ssh_url": "git@github.com:Cadair/testgiles.git", "clone_url": "https://github.com/Cadair/testgiles.git", "svn_url": "https://github.com/Cadair/testgiles", "homepage": null, "size": 28, "stargazers_count": 0, "watchers_count": 0, "language": null, "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "forks_count": 0, "mirror_url": null, "archived": false, "disabled": false, "open_issues_count": 6, "license": null, "forks": 0, "open_issues": 6, "watchers": 0, "default_branch": "master"}, "sender": {"login": "Cadair", "id": 1391051, "node_id": "MDQ6VXNlcjEzOTEwNTE=", "avatar_url": "https://avatars2.githubusercontent.com/u/1391051?v=4", "gravatar_id": "", "url": "https://api.github.com/users/Cadair", "html_url": "https://github.com/Cadair", "followers_url": "https://api.github.com/users/Cadair/followers", "following_url": "https://api.github.com/users/Cadair/following{/other_user}", "gists_url": "https://api.github.com/users/Cadair/gists{/gist_id}", "starred_url": "https://api.github.com/users/Cadair/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/Cadair/subscriptions", "organizations_url": "https://api.github.com/users/Cadair/orgs", "repos_url": "https://api.github.com/users/Cadair/repos", "events_url": "https://api.github.com/users/Cadair/events{/privacy}", "received_events_url": "https://api.github.com/users/Cadair/received_events", "type": "User", "site_admin": false}, "installation": {"id": 101055, "node_id": "MDIzOkludGVncmF0aW9uSW5zdGFsbGF0aW9uMTAxMDU1"}} diff --git a/baldrick/plugins/tests/test_add_changelog_towncrier.py b/baldrick/plugins/tests/test_add_changelog_towncrier.py new file mode 100644 index 0000000..6cefb24 --- /dev/null +++ b/baldrick/plugins/tests/test_add_changelog_towncrier.py @@ -0,0 +1,52 @@ +import json +from pathlib import Path +from unittest.mock import patch, PropertyMock + +from baldrick.github.github_api import FILE_CACHE +from baldrick.github.github_api import RepoHandler, PullRequestHandler +from baldrick.plugins.github_comment_matcher import handle_issue_comments +from baldrick.plugins.towncrier_add_entry import add_changelog_entry + + +CONFIG_TEMPLATE = """ +[ tool.testbot ] +[ tool.testbot.issue_comments ] +enabled=true +""" + + +class TestAddChangelogPlugin: + + def setup_method(self, method): + + self.get_file_contents_mock = patch('baldrick.github.github_api.PullRequestHandler.get_file_contents') + self.get_base_branch_mock = patch('baldrick.github.github_api.PullRequestHandler.base_branch') + a = self.get_base_branch_mock.start() + a.return_value = "master" + + self.repo_handler = RepoHandler("nota/repo", "1234") + self.pr_handler = PullRequestHandler("nota/repo", "1234") + + self.get_file_contents = self.get_file_contents_mock.start() + FILE_CACHE.clear() + + def teardown_method(self, method): + self.get_file_contents_mock.stop() + self.get_base_branch_mock.stop() + + @property + def issue_comment_payload(self): + with open(Path(__file__).parent / 'issue_comment_payload.json') as fobj: + return json.loads(fobj.read()) + + def test_handle_issue_comments(self, app): + handle_issue_comments(self.repo_handler, self.issue_comment_payload, + {'X-GitHub-Event': 'issue_comment'}) + + def test_milestone_present(self, app): + + self.get_file_contents.return_value = CONFIG_TEMPLATE.format(missing="missing milestone", + present="milestone present") + with app.app_context(): + ret = add_changelog_entry(self.pr_handler, self.repo_handler, "hello", None) + diff --git a/baldrick/plugins/towncrier_add_entry.py b/baldrick/plugins/towncrier_add_entry.py new file mode 100644 index 0000000..7e6c1fe --- /dev/null +++ b/baldrick/plugins/towncrier_add_entry.py @@ -0,0 +1,8 @@ +from loguru import logger + +from baldrick.plugins.github_comment_matcher import match_issue_comment + + +@match_issue_comment(".*", issue_type="pull") +def add_changelog_entry(pr_handler, repo_handler, comment, payload): + logger.debug(f"Adding changelog entry to {pr_handler.repo}#{pr_handler.number}") diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..0a5e8ef --- /dev/null +++ b/tox.ini @@ -0,0 +1,14 @@ +[tox] +envlist = py{36,37,38},build_docs + +[testenv] +extras = test +setenv = + HOME = {envtmpdir} +commands = + pytest . {posargs} + +[testenv:build_docs] +changedir = {toxinidir} +extras = docs +commands = sphinx-build docs docs/_build/html -W -b html -d docs/_build/.doctrees {posargs}