blob: 5cf509c0c2edc7b353d24ea0e74571fb1b3cdd25 [file] [log] [blame]
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
""" Notifies projects via email about GitHub activities """
import glob
import asfpy.pubsub
import asfpy.messaging
import asfpy.syslog
import yaml
import os
import uuid
import git
import re
import time
import typing
import requests
print = asfpy.syslog.Printer(identity='github-event-notifier')
CONFIG_FILE = "github-event-notifier.yaml"
SEND_EMAIL = True
RE_PROJECT = re.compile(r"(?:incubator-)?([^-]+)")
RE_JIRA_TICKET = re.compile(r"\b([A-Z0-9]+-\d+)\b")
DEFAULT_DIFF_WAIT = 10
DEBUG = False
DIFF_COMMENT_BLURB = """
##########
%(filename)s:
##########
%(diff)s
Review Comment:
%(text)s
"""
JIRA_CREDENTIALS = '/x1/jirauser.txt'
JIRA_AUTH = tuple(open(JIRA_CREDENTIALS).read().strip().split(':', 1))
JIRA_HEADERS = {
"Content-type": "application/json",
"Accept": "*/*",
}
class DiffComments:
def __init__(self, uid, original_payload):
self.created = time.time()
self.diffs = []
self.payload = original_payload
def add(self, filename, diff, text):
difftext = DIFF_COMMENT_BLURB % locals()
self.diffs.append(difftext)
class Notifier:
def __init__(self, cfg_file: str):
self.config = yaml.safe_load(open(cfg_file))
self.templates = {}
self.diffcomments: typing.Dict[str, DiffComments] = {}
for key, tmpl_file in self.config["templates"].items():
if os.path.exists(tmpl_file):
print("Loading template " + tmpl_file)
subject, contents = open(tmpl_file).read().split("\n", 1)
subject = subject.replace("subject: ", "")
contents = contents.strip()
self.templates[key] = (
subject,
contents,
)
def get_recipient(self, repository, itype, action="comment"):
m = RE_PROJECT.match(repository)
if m:
project = m.group(1)
else:
project = "infra"
repo_path = None
scheme = {}
for root_dir in self.config["repository_paths"]:
for path in glob.glob(root_dir):
if os.path.basename(path) == f"{repository}.git":
repo_path = path
break
if repo_path:
scheme_path = os.path.join(repo_path, self.config["scheme_file"])
if os.path.exists(scheme_path):
try:
scheme = yaml.safe_load(open(scheme_path))
except:
pass
# Check standard git config
cfg_path = os.path.join(repo_path, "config")
cfg = git.GitConfigParser(cfg_path)
if not "commits" in scheme:
scheme["commits"] = (
cfg.get("hooks.asfgit", "recips")
or self.config["default_recipient"]
)
if cfg.has_option("apache", "dev"):
default_issue = cfg.get("apache", "dev")
if not "issues" in scheme:
scheme["issues"] = default_issue
if not "pullrequests" in scheme:
scheme["pullrequests"] = default_issue
if cfg.has_option("apache", "jira"):
default_jira = cfg.get("apache", "jira")
if not "jira_options" in scheme:
scheme["jira_options"] = default_jira
if scheme:
if itype not in ["commit", "jira"]:
it = "pullrequests"
if itype == "issue":
it = "issues"
if action in ["comment", "diffcomment", "diffcomment_collated", "edited", "deleted", "created"]:
if ("%s_comment" % it) in scheme:
return scheme["%s_comment" % it]
elif it in scheme:
return scheme.get(it, self.config["default_recipient"])
elif action in ["open", "close", "merge"]:
if ("%s_status" % it) in scheme:
return scheme["%s_status" % it]
elif it in scheme:
return scheme.get(it, self.config["default_recipient"])
elif itype == "commit" and "commits" in scheme:
return scheme["commits"]
elif itype == "jira":
return scheme.get(
"jira_options", self.config["jira"]["default_options"]
)
if itype == "jira":
return self.config["jira"]["default_options"]
return "dev@%s.apache.org" % project
def flush(self):
to_remove = []
for uid, diffcomment in self.diffcomments.items():
if diffcomment.created < time.time() - DEFAULT_DIFF_WAIT:
print(f"Writing collated diff with {len(diffcomment.diffs)} items...")
payload = diffcomment.payload
payload["diff"] = "\n\n".join(diffcomment.diffs)
payload["action"] = "diffcomment_collated"
self.handle_payload({"payload": payload})
to_remove.append(uid)
for uid in to_remove:
del self.diffcomments[uid]
def handle_payload(self, raw):
payload = raw.get("payload")
if not payload: # Pong, use this for pushing collated items
self.flush()
return
user = payload.get("user")
action = payload.get(
"action"
) # open = new ticket, created = commented, edited = changed text, close = closed ticket, diffcomment = comment on file
repository = payload.get("repo")
if "only" in self.config and repository not in self.config["only"]:
return
title = payload.get("title", "")
text = payload.get("text", "")
issue_id = payload.get("id", "")
link = payload.get("link", "")
filename = payload.get("filename", "")
diff = payload.get("diff", "")
pr_id = issue_id
node_id = payload.get("node_id") # Used for message references/threading
real_action = (
action + "_" + (payload.get("type") == "issue" and "issue" or "pr")
)
if action == "diffcomment":
uid = f"{repository}-{pr_id}-{user}"
if uid not in self.diffcomments:
self.diffcomments[uid] = DiffComments(uid, payload)
self.diffcomments[uid].add(filename, diff, text)
ml = self.get_recipient(repository, payload.get("type", "pullrequest"), action)
print("notifying", ml)
ml_list, ml_domain = ml.split("@", 1)
if real_action in self.templates:
try:
real_subject = self.templates[real_action][0] % locals()
real_text = self.templates[real_action][1] % locals()
except (KeyError, ValueError) as e: # Template breakage can happen, ignore
print(e)
return
msg_headers = {}
msgid = "<%s-%s@gitbox.apache.org>" % (node_id, str(uuid.uuid4()))
msgid_OP = "<%s@gitbox.apache.org>" % node_id
if action == "open":
msgid = (
msgid_OP # This is the first email, make a deterministic message id
)
else:
msg_headers = {
"In-Reply-To": msgid_OP
} # Thread from the first PR/issue email
print(real_subject)
# print(msgid)
# print(msg_headers)
if SEND_EMAIL:
recipient = ml
asfpy.messaging.mail(
sender="GitBox <git@apache.org>",
recipient=recipient,
subject=real_subject,
message=real_text,
messageid=msgid,
headers=msg_headers,
)
jopts = self.get_recipient(repository, "jira")
if jopts:
jira_text = real_text.split("-- ", 1)[0]
self.notify_jira(jopts, pr_id, title, jira_text, link)
def listen(self):
auth = None
if 'pubsub_user' in self.config:
auth = (self.config['pubsub_user'], self.config['pubsub_pass'])
listener = asfpy.pubsub.Listener(self.config["pubsub_url"])
listener.attach(self.handle_payload, raw=True, auth=auth)
def jira_update_ticket(self, ticket, txt, worklog=False):
""" Post JIRA comment or worklog entry """
where = 'comment'
data = {
'body': txt
}
if worklog:
where = 'worklog'
data = {
'timeSpent': "10m",
'comment': txt
}
rv = requests.post(
"https://issues.apache.org/jira/rest/api/latest/issue/%s/%s" % (ticket, where),
headers=JIRA_HEADERS,
auth=JIRA_AUTH,
json=data
)
if rv.status_code == 200 or rv.status_code == 201:
return "Updated JIRA Ticket %s" % ticket
else:
raise Exception(rv.text)
def jira_remote_link(self, ticket, url, prno):
""" Post JIRA remote link to GitHub PR/Issue """
urlid = url.split('#')[0] # Crop out anchor
data = {
'globalId': "github=%s" % urlid,
'object':
{
'url': urlid,
'title': "GitHub Pull Request #%s" % prno,
'icon': {
'url16x16': "https://github.com/favicon.ico"
}
}
}
rv = requests.post(
"https://issues.apache.org/jira/rest/api/latest/issue/%s/remotelink" % ticket,
headers=JIRA_HEADERS,
auth=JIRA_AUTH,
json=data
)
if rv.status_code == 200 or rv.status_code == 201:
return "Updated JIRA Ticket %s" % ticket
else:
raise Exception(rv.text)
def jira_add_label(self, ticket):
""" Add a "PR available" label to JIRA """
data = {
"update": {
"labels": [
{"add": "pull-request-available"}
]
}
}
rv = requests.put(
"https://issues.apache.org/jira/rest/api/latest/issue/%s" % ticket,
headers=JIRA_HEADERS,
auth=JIRA_AUTH,
json=data
)
if rv.status_code == 200 or rv.status_code == 201:
return "Added PR label to Ticket %s\n" % ticket
else:
raise Exception(rv.text)
def notify_jira(self, jopts, prid, prtitle, prmessage, prlink):
try:
m = RE_JIRA_TICKET.search(prtitle)
if m:
jira_ticket = m.group(1)
if 'worklog' in jopts or 'comment' in jopts:
print("[INFO] Adding comment to %s" % jira_ticket)
if not DEBUG:
self.jira_update_ticket(jira_ticket, prmessage, True if 'worklog' in jopts else False)
if 'link' in jopts:
print("[INFO] Setting JIRA link for %s to %s" % (jira_ticket, prlink))
if not DEBUG:
self.jira_remote_link(jira_ticket, prlink, prid)
if 'label' in jopts:
print("[INFO] Setting JIRA label for %s" % jira_ticket)
if not DEBUG:
self.jira_add_label(jira_ticket)
except Exception as e:
print("[WARNING] Could not update JIRA: %s" % e)
def main():
notifier = Notifier(CONFIG_FILE)
notifier.listen()
if __name__ == "__main__":
main()