2020-04-02 09:59:28 -04:00
|
|
|
#!/usr/bin/env python3
|
2020-03-23 00:55:21 -04:00
|
|
|
|
2013-10-01 15:38:07 -04:00
|
|
|
# Zulip mirror of Codebase HQ activity
|
2014-03-12 13:34:28 -04:00
|
|
|
# The "zulip_codebase_mirror" script is run continuously, possibly on a work
|
|
|
|
# computer or preferably on a server.
|
2013-10-01 15:38:07 -04:00
|
|
|
#
|
|
|
|
# When restarted, it will attempt to pick up where it left off.
|
|
|
|
#
|
2014-03-12 13:34:28 -04:00
|
|
|
# python-dateutil is a dependency for this script.
|
2013-10-01 15:38:07 -04:00
|
|
|
|
|
|
|
import logging
|
|
|
|
import os
|
2021-05-28 05:00:04 -04:00
|
|
|
import sys
|
|
|
|
import time
|
2013-10-01 15:38:07 -04:00
|
|
|
from datetime import datetime, timedelta
|
2014-03-12 13:34:28 -04:00
|
|
|
|
2021-05-28 05:00:04 -04:00
|
|
|
import pytz
|
|
|
|
import requests
|
|
|
|
|
2014-03-12 13:34:28 -04:00
|
|
|
try:
|
|
|
|
import dateutil.parser
|
2016-03-10 07:53:26 -05:00
|
|
|
except ImportError as e:
|
2016-03-10 11:15:34 -05:00
|
|
|
print(e, file=sys.stderr)
|
|
|
|
print("Please install the python-dateutil package.", file=sys.stderr)
|
2014-03-12 13:34:28 -04:00
|
|
|
exit(1)
|
2013-10-01 15:38:07 -04:00
|
|
|
|
|
|
|
sys.path.insert(0, os.path.dirname(__file__))
|
|
|
|
import zulip_codebase_config as config
|
2021-05-28 05:00:04 -04:00
|
|
|
|
2013-12-05 17:42:33 -05:00
|
|
|
VERSION = "0.9"
|
2013-10-01 15:38:07 -04:00
|
|
|
|
|
|
|
if config.ZULIP_API_PATH is not None:
|
|
|
|
sys.path.append(config.ZULIP_API_PATH)
|
2021-05-28 05:00:04 -04:00
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
|
2013-10-01 15:38:07 -04:00
|
|
|
import zulip
|
|
|
|
|
|
|
|
client = zulip.Client(
|
|
|
|
email=config.ZULIP_USER,
|
|
|
|
site=config.ZULIP_SITE,
|
2013-12-05 17:42:33 -05:00
|
|
|
api_key=config.ZULIP_API_KEY,
|
2021-05-28 05:03:46 -04:00
|
|
|
client="ZulipCodebase/" + VERSION,
|
|
|
|
)
|
2015-09-29 00:17:08 -04:00
|
|
|
user_agent = "Codebase To Zulip Mirroring script (zulip-devel@googlegroups.com)"
|
2013-10-01 15:38:07 -04:00
|
|
|
|
|
|
|
# find some form of JSON loader/dumper, with a preference order for speed.
|
2021-05-28 05:05:11 -04:00
|
|
|
json_implementations = ["ujson", "cjson", "simplejson", "json"]
|
2013-10-01 15:38:07 -04:00
|
|
|
|
|
|
|
while len(json_implementations):
|
|
|
|
try:
|
|
|
|
json = __import__(json_implementations.pop(0))
|
|
|
|
break
|
|
|
|
except ImportError:
|
|
|
|
continue
|
|
|
|
|
2021-05-28 05:03:46 -04:00
|
|
|
|
2020-04-18 18:59:12 -04:00
|
|
|
def make_api_call(path: str) -> Optional[List[Dict[str, Any]]]:
|
2021-05-28 05:03:46 -04:00
|
|
|
response = requests.get(
|
|
|
|
"https://api3.codebasehq.com/%s" % (path,),
|
|
|
|
auth=(config.CODEBASE_API_USERNAME, config.CODEBASE_API_KEY),
|
2021-05-28 05:05:11 -04:00
|
|
|
params={"raw": "True"},
|
2021-05-28 05:03:46 -04:00
|
|
|
headers={
|
|
|
|
"User-Agent": user_agent,
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
"Accept": "application/json",
|
|
|
|
},
|
|
|
|
)
|
2013-10-01 15:38:07 -04:00
|
|
|
if response.status_code == 200:
|
2021-03-04 18:09:27 -05:00
|
|
|
return json.loads(response.text)
|
2013-10-01 15:38:07 -04:00
|
|
|
|
|
|
|
if response.status_code >= 500:
|
2017-01-03 14:03:45 -05:00
|
|
|
logging.error(str(response.status_code))
|
2013-10-01 15:38:07 -04:00
|
|
|
return None
|
|
|
|
if response.status_code == 403:
|
|
|
|
logging.error("Bad authorization from Codebase. Please check your credentials")
|
|
|
|
sys.exit(-1)
|
|
|
|
else:
|
2021-05-28 05:03:46 -04:00
|
|
|
logging.warn(
|
|
|
|
"Found non-success response status code: %s %s" % (response.status_code, response.text)
|
|
|
|
)
|
2013-10-01 15:38:07 -04:00
|
|
|
return None
|
|
|
|
|
2021-05-28 05:03:46 -04:00
|
|
|
|
2020-04-18 18:59:12 -04:00
|
|
|
def make_url(path: str) -> str:
|
2013-10-01 15:38:07 -04:00
|
|
|
return "%s/%s" % (config.CODEBASE_ROOT_URL, path)
|
|
|
|
|
2021-05-28 05:03:46 -04:00
|
|
|
|
2020-04-18 18:59:12 -04:00
|
|
|
def handle_event(event: Dict[str, Any]) -> None:
|
2021-05-28 05:05:11 -04:00
|
|
|
event = event["event"]
|
|
|
|
event_type = event["type"]
|
|
|
|
actor_name = event["actor_name"]
|
2013-10-01 15:38:07 -04:00
|
|
|
|
2021-05-28 05:05:11 -04:00
|
|
|
raw_props = event.get("raw_properties", {})
|
2013-10-01 15:38:07 -04:00
|
|
|
|
2021-05-28 05:05:11 -04:00
|
|
|
project_link = raw_props.get("project_permalink")
|
2013-10-01 15:38:07 -04:00
|
|
|
|
|
|
|
subject = None
|
|
|
|
content = None
|
2021-05-28 05:05:11 -04:00
|
|
|
if event_type == "repository_creation":
|
2013-10-01 15:38:07 -04:00
|
|
|
stream = config.ZULIP_COMMITS_STREAM_NAME
|
|
|
|
|
2021-05-28 05:05:11 -04:00
|
|
|
project_name = raw_props.get("name")
|
|
|
|
project_repo_type = raw_props.get("scm_type")
|
2013-10-01 15:38:07 -04:00
|
|
|
|
2015-12-01 11:11:16 -05:00
|
|
|
url = make_url("projects/%s" % (project_link,))
|
2013-10-01 15:38:07 -04:00
|
|
|
scm = "of type %s" % (project_repo_type,) if project_repo_type else ""
|
|
|
|
|
|
|
|
subject = "Repository %s Created" % (project_name,)
|
|
|
|
content = "%s created a new repository %s [%s](%s)" % (actor_name, scm, project_name, url)
|
2021-05-28 05:05:11 -04:00
|
|
|
elif event_type == "push":
|
2013-10-01 15:38:07 -04:00
|
|
|
stream = config.ZULIP_COMMITS_STREAM_NAME
|
|
|
|
|
2021-05-28 05:05:11 -04:00
|
|
|
num_commits = raw_props.get("commits_count")
|
|
|
|
branch = raw_props.get("ref_name")
|
|
|
|
project = raw_props.get("project_name")
|
|
|
|
repo_link = raw_props.get("repository_permalink")
|
|
|
|
deleted_ref = raw_props.get("deleted_ref")
|
|
|
|
new_ref = raw_props.get("new_ref")
|
2013-10-01 15:38:07 -04:00
|
|
|
|
|
|
|
subject = "Push to %s on %s" % (branch, project)
|
|
|
|
|
|
|
|
if deleted_ref:
|
|
|
|
content = "%s deleted branch %s from %s" % (actor_name, branch, project)
|
|
|
|
else:
|
|
|
|
if new_ref:
|
2016-05-04 17:16:27 -04:00
|
|
|
branch = "new branch %s" % (branch,)
|
2021-05-28 05:03:46 -04:00
|
|
|
content = "%s pushed %s commit(s) to %s in project %s:\n\n" % (
|
|
|
|
actor_name,
|
|
|
|
num_commits,
|
|
|
|
branch,
|
|
|
|
project,
|
|
|
|
)
|
2021-05-28 05:05:11 -04:00
|
|
|
for commit in raw_props.get("commits"):
|
|
|
|
ref = commit.get("ref")
|
2021-05-28 05:03:46 -04:00
|
|
|
url = make_url(
|
|
|
|
"projects/%s/repositories/%s/commit/%s" % (project_link, repo_link, ref)
|
|
|
|
)
|
2021-05-28 05:05:11 -04:00
|
|
|
message = commit.get("message")
|
2013-10-01 15:38:07 -04:00
|
|
|
content += "* [%s](%s): %s\n" % (ref, url, message)
|
2021-05-28 05:05:11 -04:00
|
|
|
elif event_type == "ticketing_ticket":
|
2013-10-01 15:38:07 -04:00
|
|
|
stream = config.ZULIP_TICKETS_STREAM_NAME
|
|
|
|
|
2021-05-28 05:05:11 -04:00
|
|
|
num = raw_props.get("number")
|
|
|
|
name = raw_props.get("subject")
|
|
|
|
assignee = raw_props.get("assignee")
|
|
|
|
priority = raw_props.get("priority")
|
2013-10-08 18:53:42 -04:00
|
|
|
url = make_url("projects/%s/tickets/%s" % (project_link, num))
|
2013-10-01 15:38:07 -04:00
|
|
|
|
|
|
|
if assignee is None:
|
|
|
|
assignee = "no one"
|
|
|
|
subject = "#%s: %s" % (num, name)
|
2021-05-28 05:03:46 -04:00
|
|
|
content = (
|
|
|
|
"""%s created a new ticket [#%s](%s) priority **%s** assigned to %s:\n\n~~~ quote\n %s"""
|
|
|
|
% (actor_name, num, url, priority, assignee, name)
|
|
|
|
)
|
2021-05-28 05:05:11 -04:00
|
|
|
elif event_type == "ticketing_note":
|
2013-10-01 15:38:07 -04:00
|
|
|
stream = config.ZULIP_TICKETS_STREAM_NAME
|
|
|
|
|
2021-05-28 05:05:11 -04:00
|
|
|
num = raw_props.get("number")
|
|
|
|
name = raw_props.get("subject")
|
|
|
|
body = raw_props.get("content")
|
|
|
|
changes = raw_props.get("changes")
|
2013-10-01 15:38:07 -04:00
|
|
|
|
2013-10-08 18:53:42 -04:00
|
|
|
url = make_url("projects/%s/tickets/%s" % (project_link, num))
|
2013-10-01 15:38:07 -04:00
|
|
|
subject = "#%s: %s" % (num, name)
|
|
|
|
|
|
|
|
content = ""
|
|
|
|
if body is not None and len(body) > 0:
|
2021-05-28 05:03:46 -04:00
|
|
|
content = "%s added a comment to ticket [#%s](%s):\n\n~~~ quote\n%s\n\n" % (
|
|
|
|
actor_name,
|
|
|
|
num,
|
|
|
|
url,
|
|
|
|
body,
|
|
|
|
)
|
2013-10-01 15:38:07 -04:00
|
|
|
|
2021-05-28 05:05:11 -04:00
|
|
|
if "status_id" in changes:
|
|
|
|
status_change = changes.get("status_id")
|
2021-05-28 05:03:46 -04:00
|
|
|
content += "Status changed from **%s** to **%s**\n\n" % (
|
|
|
|
status_change[0],
|
|
|
|
status_change[1],
|
|
|
|
)
|
2021-05-28 05:05:11 -04:00
|
|
|
elif event_type == "ticketing_milestone":
|
2013-10-01 15:38:07 -04:00
|
|
|
stream = config.ZULIP_TICKETS_STREAM_NAME
|
|
|
|
|
2021-05-28 05:05:11 -04:00
|
|
|
name = raw_props.get("name")
|
|
|
|
identifier = raw_props.get("identifier")
|
2013-10-08 18:53:42 -04:00
|
|
|
url = make_url("projects/%s/milestone/%s" % (project_link, identifier))
|
2013-10-01 15:38:07 -04:00
|
|
|
|
|
|
|
subject = name
|
|
|
|
content = "%s created a new milestone [%s](%s)" % (actor_name, name, url)
|
2021-05-28 05:05:11 -04:00
|
|
|
elif event_type == "comment":
|
2013-10-01 15:38:07 -04:00
|
|
|
stream = config.ZULIP_COMMITS_STREAM_NAME
|
|
|
|
|
2021-05-28 05:05:11 -04:00
|
|
|
comment = raw_props.get("content")
|
|
|
|
commit = raw_props.get("commit_ref")
|
2013-10-01 15:38:07 -04:00
|
|
|
|
2013-10-14 08:05:11 -04:00
|
|
|
# If there's a commit id, it's a comment to a commit
|
|
|
|
if commit:
|
2021-05-28 05:05:11 -04:00
|
|
|
repo_link = raw_props.get("repository_permalink")
|
2013-10-14 08:05:11 -04:00
|
|
|
|
2021-05-28 05:03:46 -04:00
|
|
|
url = make_url(
|
2021-05-28 05:05:11 -04:00
|
|
|
"projects/%s/repositories/%s/commit/%s" % (project_link, repo_link, commit)
|
2021-05-28 05:03:46 -04:00
|
|
|
)
|
2013-10-14 08:05:11 -04:00
|
|
|
|
|
|
|
subject = "%s commented on %s" % (actor_name, commit)
|
2021-05-28 05:03:46 -04:00
|
|
|
content = "%s commented on [%s](%s):\n\n~~~ quote\n%s" % (
|
|
|
|
actor_name,
|
|
|
|
commit,
|
|
|
|
url,
|
|
|
|
comment,
|
|
|
|
)
|
2013-10-14 08:05:11 -04:00
|
|
|
else:
|
|
|
|
# Otherwise, this is a Discussion item, and handle it
|
|
|
|
subj = raw_props.get("subject")
|
|
|
|
category = raw_props.get("category")
|
|
|
|
comment_content = raw_props.get("content")
|
|
|
|
|
|
|
|
subject = "Discussion: %s" % (subj,)
|
|
|
|
|
|
|
|
if category:
|
2016-07-02 13:53:19 -04:00
|
|
|
format_str = "%s started a new discussion in %s:\n\n~~~ quote\n%s\n~~~"
|
|
|
|
content = format_str % (actor_name, category, comment_content)
|
2013-10-14 08:05:11 -04:00
|
|
|
else:
|
|
|
|
content = "%s posted:\n\n~~~ quote\n%s\n~~~" % (actor_name, comment_content)
|
2013-10-01 15:38:07 -04:00
|
|
|
|
2021-05-28 05:05:11 -04:00
|
|
|
elif event_type == "deployment":
|
2013-10-01 15:38:07 -04:00
|
|
|
stream = config.ZULIP_COMMITS_STREAM_NAME
|
|
|
|
|
2021-05-28 05:05:11 -04:00
|
|
|
start_ref = raw_props.get("start_ref")
|
|
|
|
end_ref = raw_props.get("end_ref")
|
|
|
|
environment = raw_props.get("environment")
|
|
|
|
servers = raw_props.get("servers")
|
|
|
|
repo_link = raw_props.get("repository_permalink")
|
2013-10-01 15:38:07 -04:00
|
|
|
|
2021-05-28 05:03:46 -04:00
|
|
|
start_ref_url = make_url(
|
|
|
|
"projects/%s/repositories/%s/commit/%s" % (project_link, repo_link, start_ref)
|
|
|
|
)
|
|
|
|
end_ref_url = make_url(
|
|
|
|
"projects/%s/repositories/%s/commit/%s" % (project_link, repo_link, end_ref)
|
|
|
|
)
|
|
|
|
between_url = make_url(
|
|
|
|
"projects/%s/repositories/%s/compare/%s...%s"
|
|
|
|
% (project_link, repo_link, start_ref, end_ref)
|
|
|
|
)
|
2013-10-01 15:38:07 -04:00
|
|
|
|
|
|
|
subject = "Deployment to %s" % (environment,)
|
|
|
|
|
2021-05-28 05:03:46 -04:00
|
|
|
content = "%s deployed [%s](%s) [through](%s) [%s](%s) to the **%s** environment." % (
|
|
|
|
actor_name,
|
|
|
|
start_ref,
|
|
|
|
start_ref_url,
|
|
|
|
between_url,
|
|
|
|
end_ref,
|
|
|
|
end_ref_url,
|
|
|
|
environment,
|
|
|
|
)
|
2013-10-01 15:38:07 -04:00
|
|
|
if servers is not None:
|
2021-05-28 05:03:46 -04:00
|
|
|
content += "\n\nServers deployed to: %s" % (
|
|
|
|
", ".join(["`%s`" % (server,) for server in servers])
|
|
|
|
)
|
2013-10-01 15:38:07 -04:00
|
|
|
|
2021-05-28 05:05:11 -04:00
|
|
|
elif event_type == "named_tree":
|
2013-10-01 15:38:07 -04:00
|
|
|
# Docs say named_tree type used for new/deleting branches and tags,
|
|
|
|
# but experimental testing showed that they were all sent as 'push' events
|
|
|
|
pass
|
2021-05-28 05:05:11 -04:00
|
|
|
elif event_type == "wiki_page":
|
2013-10-01 15:38:07 -04:00
|
|
|
logging.warn("Wiki page notifications not yet implemented")
|
2021-05-28 05:05:11 -04:00
|
|
|
elif event_type == "sprint_creation":
|
2013-10-01 15:38:07 -04:00
|
|
|
logging.warn("Sprint notifications not yet implemented")
|
2021-05-28 05:05:11 -04:00
|
|
|
elif event_type == "sprint_ended":
|
2013-10-01 15:38:07 -04:00
|
|
|
logging.warn("Sprint notifications not yet implemented")
|
|
|
|
else:
|
|
|
|
logging.info("Unknown event type %s, ignoring!" % (event_type,))
|
|
|
|
|
|
|
|
if subject and content:
|
2013-10-08 18:54:03 -04:00
|
|
|
if len(subject) > 60:
|
2021-05-28 05:05:11 -04:00
|
|
|
subject = subject[:57].rstrip() + "..."
|
2013-10-08 18:54:03 -04:00
|
|
|
|
2021-05-28 05:03:46 -04:00
|
|
|
res = client.send_message(
|
|
|
|
{"type": "stream", "to": stream, "subject": subject, "content": content}
|
|
|
|
)
|
2021-05-28 05:05:11 -04:00
|
|
|
if res["result"] == "success":
|
|
|
|
logging.info("Successfully sent Zulip with id: %s" % (res["id"],))
|
2013-10-01 15:38:07 -04:00
|
|
|
else:
|
2021-05-28 05:05:11 -04:00
|
|
|
logging.warn("Failed to send Zulip: %s %s" % (res["result"], res["msg"]))
|
2013-10-01 15:38:07 -04:00
|
|
|
|
|
|
|
|
|
|
|
# the main run loop for this mirror script
|
2020-04-18 18:59:12 -04:00
|
|
|
def run_mirror() -> None:
|
2013-10-01 15:38:07 -04:00
|
|
|
# we should have the right (write) permissions on the resume file, as seen
|
|
|
|
# in check_permissions, but it may still be empty or corrupted
|
2020-04-18 18:59:12 -04:00
|
|
|
def default_since() -> datetime:
|
2017-03-06 15:57:48 -05:00
|
|
|
return datetime.now(tz=pytz.utc) - timedelta(hours=config.CODEBASE_INITIAL_HISTORY_HOURS)
|
2013-10-01 15:38:07 -04:00
|
|
|
|
|
|
|
try:
|
|
|
|
with open(config.RESUME_FILE) as f:
|
|
|
|
timestamp = f.read()
|
2021-05-28 05:05:11 -04:00
|
|
|
if timestamp == "":
|
2013-10-01 15:38:07 -04:00
|
|
|
since = default_since()
|
|
|
|
else:
|
2017-03-06 15:57:48 -05:00
|
|
|
since = datetime.fromtimestamp(float(timestamp), tz=pytz.utc)
|
2020-04-09 20:14:01 -04:00
|
|
|
except (ValueError, OSError) as e:
|
2021-03-04 18:02:39 -05:00
|
|
|
logging.warn("Could not open resume file: %s" % (str(e),))
|
2013-10-01 15:38:07 -04:00
|
|
|
since = default_since()
|
|
|
|
|
|
|
|
try:
|
|
|
|
sleepInterval = 1
|
2016-03-10 08:52:28 -05:00
|
|
|
while True:
|
2017-12-22 12:51:14 -05:00
|
|
|
events = make_api_call("activity")
|
2013-10-01 15:38:07 -04:00
|
|
|
if events is not None:
|
|
|
|
sleepInterval = 1
|
2017-12-22 12:51:14 -05:00
|
|
|
for event in events[::-1]:
|
2021-05-28 05:05:11 -04:00
|
|
|
timestamp = event.get("event", {}).get("timestamp", "")
|
2017-03-06 15:57:48 -05:00
|
|
|
event_date = dateutil.parser.parse(timestamp)
|
2013-10-01 15:38:07 -04:00
|
|
|
if event_date > since:
|
|
|
|
handle_event(event)
|
|
|
|
since = event_date
|
|
|
|
else:
|
|
|
|
# back off a bit
|
|
|
|
if sleepInterval < 22:
|
|
|
|
sleepInterval += 4
|
|
|
|
time.sleep(sleepInterval)
|
|
|
|
|
|
|
|
except KeyboardInterrupt:
|
2021-05-28 05:05:11 -04:00
|
|
|
open(config.RESUME_FILE, "w").write(since.strftime("%s"))
|
2013-10-01 15:38:07 -04:00
|
|
|
logging.info("Shutting down Codebase mirror")
|
|
|
|
|
2021-05-28 05:03:46 -04:00
|
|
|
|
2013-10-01 15:38:07 -04:00
|
|
|
# void function that checks the permissions of the files this script needs.
|
2020-04-18 18:59:12 -04:00
|
|
|
def check_permissions() -> None:
|
2013-10-01 15:38:07 -04:00
|
|
|
# check that the log file can be written
|
|
|
|
if config.LOG_FILE:
|
|
|
|
try:
|
|
|
|
open(config.LOG_FILE, "w")
|
2020-04-09 20:14:01 -04:00
|
|
|
except OSError as e:
|
2017-01-03 14:03:45 -05:00
|
|
|
sys.stderr.write("Could not open up log for writing:")
|
|
|
|
sys.stderr.write(str(e))
|
2013-10-01 15:38:07 -04:00
|
|
|
# check that the resume file can be written (this creates if it doesn't exist)
|
|
|
|
try:
|
|
|
|
open(config.RESUME_FILE, "a+")
|
2020-04-09 20:14:01 -04:00
|
|
|
except OSError as e:
|
2021-05-28 05:03:46 -04:00
|
|
|
sys.stderr.write(
|
|
|
|
"Could not open up the file %s for reading and writing" % (config.RESUME_FILE,)
|
|
|
|
)
|
2017-01-03 14:03:45 -05:00
|
|
|
sys.stderr.write(str(e))
|
2013-10-01 15:38:07 -04:00
|
|
|
|
2021-05-28 05:03:46 -04:00
|
|
|
|
2013-10-01 15:38:07 -04:00
|
|
|
if __name__ == "__main__":
|
2021-03-04 18:17:09 -05:00
|
|
|
assert isinstance(config.RESUME_FILE, str), "RESUME_FILE path not given; refusing to continue"
|
2013-10-01 15:38:07 -04:00
|
|
|
check_permissions()
|
|
|
|
if config.LOG_FILE:
|
|
|
|
logging.basicConfig(filename=config.LOG_FILE, level=logging.WARNING)
|
|
|
|
else:
|
|
|
|
logging.basicConfig(level=logging.WARNING)
|
|
|
|
run_mirror()
|