diff options
| author | 2019-02-05 15:54:20 +0000 | |
|---|---|---|
| committer | 2019-02-05 15:54:20 +0000 | |
| commit | 1fe20a2c98ed5e4042d10c415d7923aebfaa362d (patch) | |
| tree | 677385905a109b6760258842b18643b8bf7c9fd8 /modules/github/__init__.py | |
| parent | Switch to using __init__.py as main file of directory modules, so they behave (diff) | |
| signature | ||
Move sasl.py to a directory module and move SCRAM logic to a different file,
move `github/module.py` to `github/__init__.py`
Diffstat (limited to 'modules/github/__init__.py')
| -rw-r--r-- | modules/github/__init__.py | 547 |
1 files changed, 547 insertions, 0 deletions
diff --git a/modules/github/__init__.py b/modules/github/__init__.py new file mode 100644 index 00000000..88ba324d --- /dev/null +++ b/modules/github/__init__.py @@ -0,0 +1,547 @@ +import itertools, json, urllib.parse +from src import ModuleManager, utils + +FORM_ENCODED = "application/x-www-form-urlencoded" + +COMMIT_URL = "https://github.com/%s/commit/%s" +COMMIT_RANGE_URL = "https://github.com/%s/compare/%s...%s" +CREATE_URL = "https://github.com/%s/tree/%s" + +API_ISSUE_URL = "https://api.github.com/repos/%s/%s/issues/%s" +API_PULL_URL = "https://api.github.com/repos/%s/%s/pulls/%s" + +DEFAULT_EVENT_CATEGORIES = [ + "ping", "code", "pr", "issue", "repo" +] +EVENT_CATEGORIES = { + "ping": [ + "ping" # new webhook received + ], + "code": [ + "push", "commit_comment" + ], + "pr-minimal": [ + "pull_request/opened", "pull_request/closed", "pull_request/reopened" + ], + "pr": [ + "pull_request/opened", "pull_request/closed", "pull_request/reopened", + "pull_request/edited", "pull_request/assigned", + "pull_request/unassigned", "pull_request_review", + "pull_request_review_comment" + ], + "pr-all": [ + "pull_request", "pull_request_review", "pull_request_review_comment" + ], + "issue-minimal": [ + "issues/opened", "issues/closed", "issues/reopened", "issues/deleted" + ], + "issue": [ + "issues/opened", "issues/closed", "issues/reopened", "issues/deleted", + "issues/edited", "issues/assigned", "issues/unassigned", "issue_comment" + ], + "issue-all": [ + "issues", "issue_comment" + ], + "repo": [ + "create", # a repository, branch or tage has been created + "delete", # same as above but deleted + "release", + "fork" + ], + "team": [ + "membership" + ], + "star": [ + # "watch" is a misleading name for this event so this add "star" as an + # alias for "watch" + "watch" + ] +} + +COMMENT_ACTIONS = { + "created": "commented", + "edited": "edited a comment", + "deleted": "deleted a comment" +} + +@utils.export("channelset", {"setting": "github-hide-prefix", + "help": "Hide/show command-like prefix on Github hook outputs", + "validate": utils.bool_or_none}) +@utils.export("channelset", {"setting": "github-default-repo", + "help": "Set the default github repo for the current channel"}) +@utils.export("channelset", {"setting": "github-prevent-highlight", + "help": "Enable/disable preventing highlights", + "validate": utils.bool_or_none}) +class Module(ModuleManager.BaseModule): + def _parse_ref(self, channel, ref): + repo, _, number = ref.rpartition("#") + if not repo: + repo = channel.get_setting("github-default-repo", None) + + username, repository = None, None + if repo: + username, _, repository = repo.partition("/") + + if not username or not repository or not number: + raise utils.EventError("Please provide username/repo#number") + if not number.isdigit(): + raise utils.EventError("Issue number must be a number") + return username, repository, number + + def _gh_issue(self, event, page, username, repository, number): + labels = [label["name"] for label in page.data["labels"]] + url = self._short_url(page.data["html_url"]) + + event["stdout"].write("(%s/%s issue#%s, %s) %s [%s] %s" % ( + username, repository, number, page.data["state"], + page.data["title"], ", ".join(labels), url)) + def _gh_get_issue(self, username, repository, number): + return utils.http.request( + API_ISSUE_URL % (username, repository, number), + json=True) + + @utils.hook("received.command.ghissue", min_args=1) + def github_issue(self, event): + username, repository, number = self._parse_ref( + event["target"], event["args_split"][0]) + + page = self._gh_get_issue(username, repository, number) + if page and page.code == 200: + self._gh_issue(event, page, username, repository, number) + else: + event["stderr"].write("Could not find issue") + + def _gh_pull(self, event, page, username, repository, number): + repo_from = page.data["head"]["label"] + repo_to = page.data["base"]["label"] + added = self._added(page.data["additions"]) + removed = self._removed(page.data["deletions"]) + url = self._short_url(page.data["html_url"]) + + event["stdout"].write( + "(%s/%s pull#%s, %s) [%s/%s] %s→%s - %s %s" % ( + username, repository, number, page.data["state"], + added, removed, repo_from, repo_to, page.data["title"], url)) + def _gh_get_pull(self, username, repository, number): + return utils.http.request( + API_PULL_URL % (username, repository, number), + json=True) + @utils.hook("received.command.ghpull", min_args=1) + def github_pull(self, event): + username, repository, number = self._parse_ref( + event["target"], event["args_split"][0]) + page = self._gh_get_pull(username, repository, number) + + if page and page.code == 200: + self._gh_pull(event, page, username, repository, number) + else: + event["stderr"].write("Could not find pull request") + + @utils.hook("received.command.gh", alias_of="github") + @utils.hook("received.command.github", min_args=1) + def github(self, event): + username, repository, number = self._parse_ref( + event["target"], event["args_split"][0]) + page = self._gh_get_issue(username, repository, number) + if page and page.code == 200: + if "pull_request" in page.data: + pull = self._gh_get_pull(username, repository, number) + self._gh_pull(event, pull, username, repository, number) + else: + self._gh_issue(event, page, username, repository, number) + else: + event["stderr"].write("Issue/PR not found") + + @utils.hook("received.command.ghwebhook", min_args=2, channel_only=True) + def github_webhook(self, event): + """ + :help: Add/remove/modify a github webhook + :require_mode: high + :permission: githuboverride + :usage: list + :usage: add <hook> + :usage: remove <hook> + :usage: events <hook> [category [category ...]] + :usage: branches <hook> [branch [branch ...]] + """ + all_hooks = event["target"].get_setting("github-hooks", {}) + hook = event["args_split"][1] + existing_hook = None + for existing_hook_name in all_hooks.keys(): + if existing_hook_name.lower() == hook.lower(): + existing_hook = existing_hook_name + break + + subcommand = event["args_split"][0].lower() + if subcommand == "list": + event["stdout"].write("Registered web hooks: %s" % + ", ".join(all_hooks.keys())) + elif subcommand == "add": + if existing_hook: + event["stderr"].write("There's already a hook for %s" % hook) + return + + all_hooks[hook] = { + "events": DEFAULT_EVENT_CATEGORIES.copy(), + "branches": [] + } + event["target"].set_setting("github-hooks", all_hooks) + event["stdout"].write("Added hook for %s" % hook) + elif subcommand == "remove": + if not existing_hook: + event["stderr"].write("No hook found for %s" % hook) + return + del all_hooks[existing_hook] + if all_hooks: + event["target"].set_setting("github-hooks", all_hooks) + else: + event["target"].del_setting("github-hooks") + event["stdout"].write("Removed hook for %s" % hook) + elif subcommand == "events": + if not existing_hook: + event["stderr"].write("No hook found for %s" % hook) + return + + if len(event["args_split"]) < 3: + event["stdout"].write("Events for hook %s: %s" % + (hook, " ".join(all_hooks[existing_hook]["events"]))) + else: + new_events = [e.lower() for e in event["args_split"][2:]] + all_hooks[existing_hook]["events"] = new_events + event["target"].set_setting("github-hooks", all_hooks) + event["stdout"].write("Updated events for hook %s" % hook) + elif subcommand == "branches": + if not existing_hook: + event["stderr"].write("No hook found for %s" % hook) + return + + if len(event["args_split"]) < 3: + event["stdout"].write("Branches shown for hook %s: %s" % + (hook, ", ".join(all_hooks[existing_hook]["branches"]))) + else: + all_hooks[existing_hook]["branches"] = event["args_split"][2:] + event["target"].set_setting("github-hooks", all_hooks) + event["stdout"].write("Updated shown branches for hook %s" % + hook) + else: + event["stderr"].write("Unknown command '%s'" % + event["args_split"][0]) + + @utils.hook("api.post.github") + def webhook(self, event): + payload = event["data"].decode("utf8") + if event["headers"]["Content-Type"] == FORM_ENCODED: + payload = urllib.parse.unquote(urllib.parse.parse_qs(payload)[ + "payload"][0]) + data = json.loads(payload) + + github_event = event["headers"]["X-GitHub-Event"] + + full_name = None + repo_username = None + repo_name = None + if "repository" in data: + full_name = data["repository"]["full_name"] + repo_username, repo_name = full_name.split("/", 1) + + organisation = None + if "organization" in data: + organisation = data["organization"]["login"] + + event_action = None + if "action" in data: + event_action = "%s/%s" % (github_event, data["action"]) + + branch = None + if "ref" in data: + _, _, branch = data["ref"].rpartition("/") + + hooks = self.bot.database.channel_settings.find_by_setting( + "github-hooks") + targets = [] + + repo_hooked = False + for server_id, channel_name, hooked_repos in hooks: + found_hook = None + if full_name and full_name in hooked_repos: + found_hook = hooked_repos[full_name] + elif repo_username and repo_username in hooked_repos: + found_hook = hooked_repos[repo_username] + elif organisation and organisation in hooked_repos: + found_hook = hooked_repos[organisation] + + if found_hook: + repo_hooked = True + server = self.bot.get_server(server_id) + if server and channel_name in server.channels: + if (branch and + found_hook["branches"] and + not branch in found_hook["branches"]): + continue + + github_events = [] + for event in found_hook["events"]: + github_events.append(EVENT_CATEGORIES.get( + event, [event])) + github_events = list(itertools.chain(*github_events)) + + channel = server.channels.get(channel_name) + if (github_event in github_events or + (event_action and event_action in github_events)): + targets.append([server, channel]) + + if not targets: + return "" if repo_hooked else None + + outputs = None + if github_event == "push": + outputs = self.push(full_name, data) + elif github_event == "commit_comment": + outputs = self.commit_comment(full_name, data) + elif github_event == "pull_request": + outputs = self.pull_request(full_name, data) + elif github_event == "pull_request_review": + outputs = self.pull_request_review(full_name, data) + elif github_event == "pull_request_review_comment": + outputs = self.pull_request_review_comment(full_name, data) + elif github_event == "issue_comment": + outputs = self.issue_comment(full_name, data) + elif github_event == "issues": + outputs = self.issues(full_name, data) + elif github_event == "create": + outputs = self.create(full_name, data) + elif github_event == "delete": + outputs = self.delete(full_name, data) + elif github_event == "release": + outputs = self.release(full_name, data) + elif github_event == "status": + outputs = self.status(full_name, data) + elif github_event == "fork": + outputs = self.fork(full_name, data) + elif github_event == "ping": + outputs = self.ping(data) + elif github_event == "membership": + outputs = self.membership(organisation, data) + elif github_event == "watch": + outputs = self.watch(data) + + if outputs: + for server, channel in targets: + for output in outputs: + source = full_name or organisation + output = "(%s) %s" % (source, output) + if channel.get_setting("github-prevent-highlight", False): + output = self._prevent_highlight(channel, output) + + self.events.on("send.stdout").call(target=channel, + module_name="Github", server=server, message=output, + hide_prefix=channel.get_setting( + "github-hide-prefix", False)) + + return "" + + def _prevent_highlight(self, channel, s): + for user in channel.users: + while user.nickname.lower() in s.lower(): + index = s.lower().index(user.nickname.lower()) + length = len(user.nickname.lower()) + + original = s[index:index+length] + original = utils.prevent_highlight(original) + + s = s[:index] + original + s[index+length:] + return s + + def _short_url(self, url): + try: + page = utils.http.request("https://git.io", method="POST", + post_data={"url": url}) + return page.headers["Location"] + except utils.http.HTTPTimeoutException: + self.log.warn( + "HTTPTimeoutException while waiting for github short URL") + return url + + def ping(self, data): + return ["Received new webhook"] + + def _change_count(self, n, symbol, color): + return utils.irc.color("%s%d" % (symbol, n), color)+utils.irc.bold("") + def _added(self, n): + return self._change_count(n, "+", utils.consts.GREEN) + def _removed(self, n): + return self._change_count(n, "-", utils.consts.RED) + def _modified(self, n): + return self._change_count(n, "~", utils.consts.PURPLE) + + def _short_hash(self, hash): + return hash[:8] + + def _flat_unique(self, commits, key): + return set(itertools.chain(*(commit[key] for commit in commits))) + + def push(self, full_name, data): + outputs = [] + branch = data["ref"].split("/", 2)[2] + branch = utils.irc.color(branch, utils.consts.LIGHTBLUE) + + if len(data["commits"]) <= 3: + for commit in data["commits"]: + id = self._short_hash(commit["id"]) + message = commit["message"].split("\n")[0].strip() + author = utils.irc.bold(data["pusher"]["name"]) + url = self._short_url(COMMIT_URL % (full_name, id)) + + added = self._added(len(commit["added"])) + removed = self._removed(len(commit["removed"])) + modified = self._modified(len(commit["modified"])) + + outputs.append("[%s/%s/%s files] commit by %s to %s: %s - %s" + % (added, removed, modified, author, branch, message, url)) + else: + first_id = self._short_hash(data["before"]) + last_id = self._short_hash(data["commits"][-1]["id"]) + pusher = utils.irc.bold(data["pusher"]["name"]) + url = self._short_url( + COMMIT_RANGE_URL % (full_name, first_id, last_id)) + + commits = data["commits"] + added = self._added(len(self._flat_unique(commits, "added"))) + removed = self._removed(len(self._flat_unique(commits, "removed"))) + modified = self._modified(len(self._flat_unique(commits, + "modified"))) + + outputs.append("[%s/%s/%s files] %s pushed %d commits to %s - %s" + % (added, removed, modified, pusher, len(data["commits"]), + branch, url)) + + return outputs + + + def commit_comment(self, full_name, data): + action = data["action"] + commit = data["commit_id"][:8] + commenter = utils.irc.bold(data["comment"]["user"]["login"]) + url = self._short_url(data["comment"]["html_url"]) + return ["[commit/%s] %s commented" % (commit, commenter, action)] + + def pull_request(self, full_name, data): + number = data["pull_request"]["number"] + action = data["action"] + action_desc = action + branch = data["pull_request"]["base"]["ref"] + colored_branch = utils.irc.color(branch, utils.consts.LIGHTBLUE) + + if action == "opened": + action_desc = "requested merge into %s" % colored_branch + elif action == "closed": + if data["pull_request"]["merged"]: + action_desc = "%s into %s" % ( + utils.irc.color("merged", utils.consts.GREEN), + colored_branch) + else: + action_desc = utils.irc.color("closed without merging", + utils.consts.RED) + elif action == "synchronize": + action_desc = "committed to" + + pr_title = data["pull_request"]["title"] + author = utils.irc.bold(data["sender"]["login"]) + url = self._short_url(data["pull_request"]["html_url"]) + return ["[pr #%d] %s %s: %s - %s" % ( + number, author, action_desc, pr_title, url)] + + def pull_request_review(self, full_name, data): + if data["review"]["state"] == "commented": + return [] + + number = data["pull_request"]["number"] + action = data["action"] + pr_title = data["pull_request"]["title"] + reviewer = utils.irc.bold(data["sender"]["login"]) + url = self._short_url(data["review"]["html_url"]) + return ["[pr #%d] %s %s a review on: %s - %s" % ( + number, reviewer, action, pr_title, url)] + + def pull_request_review_comment(self, full_name, data): + number = data["pull_request"]["number"] + action = data["action"] + pr_title = data["pull_request"]["title"] + sender = utils.irc.bold(data["sender"]["login"]) + url = self._short_url(data["comment"]["html_url"]) + return ["[pr #%d] %s %s on a review: %s - %s" % + (number, sender, COMMENT_ACTIONS[action], pr_title, url)] + + def issues(self, full_name, data): + number = data["issue"]["number"] + action = data["action"] + action_desc = action + + issue_title = data["issue"]["title"] + author = utils.irc.bold(data["sender"]["login"]) + url = self._short_url(data["issue"]["html_url"]) + return ["[issue #%d] %s %s: %s - %s" % + (number, author, action_desc, issue_title, url)] + def issue_comment(self, full_name, data): + if "changes" in data: + # don't show this event when nothing has actually changed + if data["changes"]["body"]["from"] == data["comment"]["body"]: + return + + number = data["issue"]["number"] + action = data["action"] + issue_title = data["issue"]["title"] + type = "pr" if "pull_request" in data["issue"] else "issue" + commenter = utils.irc.bold(data["comment"]["user"]["login"]) + url = self._short_url(data["comment"]["html_url"]) + return ["[%s #%d] %s %s on: %s - %s" % + (type, number, commenter, COMMENT_ACTIONS[action], issue_title, + url)] + + def create(self, full_name, data): + ref = data["ref"] + ref_color = utils.irc.color(ref, utils.consts.LIGHTBLUE) + type = data["ref_type"] + sender = utils.irc.bold(data["sender"]["login"]) + url = self._short_url(CREATE_URL % (full_name, ref)) + return ["%s created a %s: %s - %s" % (sender, type, ref_color, url)] + + def delete(self, full_name, data): + ref = data["ref"] + type = data["ref_type"] + sender = utils.irc.bold(data["sender"]["login"]) + return ["%s deleted a %s: %s" % (sender, type, ref)] + + def release(self, full_name, data): + action = data["action"] + tag = data["release"]["tag_name"] + name = data["release"]["name"] or "" + if name: + name = ": %s" + author = utils.irc.bold(data["release"]["author"]["login"]) + url = self._short_url(data["release"]["html_url"]) + return ["%s %s a release%s - %s" % (author, action, name, url)] + + def status(self, full_name, data): + context = data["context"] + state = data["state"] + url = data["target_url"] + commit = self._short_id(data["sha"]) + return ["[%s status] %s is '%s' - %s" % + (commit, context, state, url)] + + def fork(self, full_name, data): + forker = utils.irc.bold(data["sender"]["login"]) + fork_full_name = utils.irc.color(data["forkee"]["full_name"], + utils.consts.LIGHTBLUE) + url = self._short_url(data["forkee"]["html_url"]) + return ["%s forked into %s - %s" % + (forker, fork_full_name, url)] + + def membership(self, organisation, data): + return ["%s %s %s to team %s" % + (data["sender"]["login"], data["action"], data["member"]["login"], + data["team"]["name"])] + + def watch(self, data): + return ["%s starred the repository" % data["sender"]["login"]] |
