 9ce7c52a10
			
		
	
	
		9ce7c52a10
		
	
	
	
	
		
			
			This includes mainly fixes of string literals using f-strings or .format(...), as well as unpacking of list comprehensions.
		
			
				
	
	
		
			347 lines
		
	
	
	
		
			12 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable file
		
	
	
	
	
			
		
		
	
	
			347 lines
		
	
	
	
		
			12 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable file
		
	
	
	
	
| #!/usr/bin/env python3
 | |
| 
 | |
| # Zulip mirror of Codebase HQ activity
 | |
| # The "zulip_codebase_mirror" script is run continuously, possibly on a work
 | |
| # computer or preferably on a server.
 | |
| #
 | |
| # When restarted, it will attempt to pick up where it left off.
 | |
| #
 | |
| # python-dateutil is a dependency for this script.
 | |
| 
 | |
| import logging
 | |
| import os
 | |
| import sys
 | |
| import time
 | |
| from datetime import datetime, timedelta
 | |
| 
 | |
| import pytz
 | |
| import requests
 | |
| 
 | |
| try:
 | |
|     import dateutil.parser
 | |
| except ImportError as e:
 | |
|     print(e, file=sys.stderr)
 | |
|     print("Please install the python-dateutil package.", file=sys.stderr)
 | |
|     exit(1)
 | |
| 
 | |
| sys.path.insert(0, os.path.dirname(__file__))
 | |
| import zulip_codebase_config as config
 | |
| 
 | |
| VERSION = "0.9"
 | |
| 
 | |
| if config.ZULIP_API_PATH is not None:
 | |
|     sys.path.append(config.ZULIP_API_PATH)
 | |
| from typing import Any, Dict, List, Optional
 | |
| 
 | |
| import zulip
 | |
| 
 | |
| client = zulip.Client(
 | |
|     email=config.ZULIP_USER,
 | |
|     site=config.ZULIP_SITE,
 | |
|     api_key=config.ZULIP_API_KEY,
 | |
|     client="ZulipCodebase/" + VERSION,
 | |
| )
 | |
| user_agent = "Codebase To Zulip Mirroring script (zulip-devel@googlegroups.com)"
 | |
| 
 | |
| # find some form of JSON loader/dumper, with a preference order for speed.
 | |
| json_implementations = ["ujson", "cjson", "simplejson", "json"]
 | |
| 
 | |
| while len(json_implementations):
 | |
|     try:
 | |
|         json = __import__(json_implementations.pop(0))
 | |
|         break
 | |
|     except ImportError:
 | |
|         continue
 | |
| 
 | |
| 
 | |
| def make_api_call(path: str) -> Optional[List[Dict[str, Any]]]:
 | |
|     response = requests.get(
 | |
|         f"https://api3.codebasehq.com/{path}",
 | |
|         auth=(config.CODEBASE_API_USERNAME, config.CODEBASE_API_KEY),
 | |
|         params={"raw": "True"},
 | |
|         headers={
 | |
|             "User-Agent": user_agent,
 | |
|             "Content-Type": "application/json",
 | |
|             "Accept": "application/json",
 | |
|         },
 | |
|     )
 | |
|     if response.status_code == 200:
 | |
|         return json.loads(response.text)
 | |
| 
 | |
|     if response.status_code >= 500:
 | |
|         logging.error(str(response.status_code))
 | |
|         return None
 | |
|     if response.status_code == 403:
 | |
|         logging.error("Bad authorization from Codebase. Please check your credentials")
 | |
|         sys.exit(-1)
 | |
|     else:
 | |
|         logging.warn(
 | |
|             f"Found non-success response status code: {response.status_code} {response.text}"
 | |
|         )
 | |
|         return None
 | |
| 
 | |
| 
 | |
| def make_url(path: str) -> str:
 | |
|     return f"{config.CODEBASE_ROOT_URL}/{path}"
 | |
| 
 | |
| 
 | |
| def handle_event(event: Dict[str, Any]) -> None:
 | |
|     event = event["event"]
 | |
|     event_type = event["type"]
 | |
|     actor_name = event["actor_name"]
 | |
| 
 | |
|     raw_props = event.get("raw_properties", {})
 | |
| 
 | |
|     project_link = raw_props.get("project_permalink")
 | |
| 
 | |
|     subject = None
 | |
|     content = None
 | |
|     if event_type == "repository_creation":
 | |
|         stream = config.ZULIP_COMMITS_STREAM_NAME
 | |
| 
 | |
|         project_name = raw_props.get("name")
 | |
|         project_repo_type = raw_props.get("scm_type")
 | |
| 
 | |
|         url = make_url(f"projects/{project_link}")
 | |
|         scm = f"of type {project_repo_type}" if project_repo_type else ""
 | |
| 
 | |
|         subject = f"Repository {project_name} Created"
 | |
|         content = f"{actor_name} created a new repository {scm} [{project_name}]({url})"
 | |
|     elif event_type == "push":
 | |
|         stream = config.ZULIP_COMMITS_STREAM_NAME
 | |
| 
 | |
|         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")
 | |
| 
 | |
|         subject = f"Push to {branch} on {project}"
 | |
| 
 | |
|         if deleted_ref:
 | |
|             content = f"{actor_name} deleted branch {branch} from {project}"
 | |
|         else:
 | |
|             if new_ref:
 | |
|                 branch = f"new branch {branch}"
 | |
|             content = "{} pushed {} commit(s) to {} in project {}:\n\n".format(
 | |
|                 actor_name,
 | |
|                 num_commits,
 | |
|                 branch,
 | |
|                 project,
 | |
|             )
 | |
|             for commit in raw_props.get("commits"):
 | |
|                 ref = commit.get("ref")
 | |
|                 url = make_url(f"projects/{project_link}/repositories/{repo_link}/commit/{ref}")
 | |
|                 message = commit.get("message")
 | |
|                 content += f"* [{ref}]({url}): {message}\n"
 | |
|     elif event_type == "ticketing_ticket":
 | |
|         stream = config.ZULIP_TICKETS_STREAM_NAME
 | |
| 
 | |
|         num = raw_props.get("number")
 | |
|         name = raw_props.get("subject")
 | |
|         assignee = raw_props.get("assignee")
 | |
|         priority = raw_props.get("priority")
 | |
|         url = make_url(f"projects/{project_link}/tickets/{num}")
 | |
| 
 | |
|         if assignee is None:
 | |
|             assignee = "no one"
 | |
|         subject = f"#{num}: {name}"
 | |
|         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)
 | |
|         )
 | |
|     elif event_type == "ticketing_note":
 | |
|         stream = config.ZULIP_TICKETS_STREAM_NAME
 | |
| 
 | |
|         num = raw_props.get("number")
 | |
|         name = raw_props.get("subject")
 | |
|         body = raw_props.get("content")
 | |
|         changes = raw_props.get("changes")
 | |
| 
 | |
|         url = make_url(f"projects/{project_link}/tickets/{num}")
 | |
|         subject = f"#{num}: {name}"
 | |
| 
 | |
|         content = ""
 | |
|         if body is not None and len(body) > 0:
 | |
|             content = "{} added a comment to ticket [#{}]({}):\n\n~~~ quote\n{}\n\n".format(
 | |
|                 actor_name,
 | |
|                 num,
 | |
|                 url,
 | |
|                 body,
 | |
|             )
 | |
| 
 | |
|         if "status_id" in changes:
 | |
|             status_change = changes.get("status_id")
 | |
|             content += "Status changed from **{}** to **{}**\n\n".format(
 | |
|                 status_change[0],
 | |
|                 status_change[1],
 | |
|             )
 | |
|     elif event_type == "ticketing_milestone":
 | |
|         stream = config.ZULIP_TICKETS_STREAM_NAME
 | |
| 
 | |
|         name = raw_props.get("name")
 | |
|         identifier = raw_props.get("identifier")
 | |
|         url = make_url(f"projects/{project_link}/milestone/{identifier}")
 | |
| 
 | |
|         subject = name
 | |
|         content = f"{actor_name} created a new milestone [{name}]({url})"
 | |
|     elif event_type == "comment":
 | |
|         stream = config.ZULIP_COMMITS_STREAM_NAME
 | |
| 
 | |
|         comment = raw_props.get("content")
 | |
|         commit = raw_props.get("commit_ref")
 | |
| 
 | |
|         # If there's a commit id, it's a comment to a commit
 | |
|         if commit:
 | |
|             repo_link = raw_props.get("repository_permalink")
 | |
| 
 | |
|             url = make_url(f"projects/{project_link}/repositories/{repo_link}/commit/{commit}")
 | |
| 
 | |
|             subject = f"{actor_name} commented on {commit}"
 | |
|             content = "{} commented on [{}]({}):\n\n~~~ quote\n{}".format(
 | |
|                 actor_name,
 | |
|                 commit,
 | |
|                 url,
 | |
|                 comment,
 | |
|             )
 | |
|         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 = f"Discussion: {subj}"
 | |
| 
 | |
|             if category:
 | |
|                 format_str = "%s started a new discussion in %s:\n\n~~~ quote\n%s\n~~~"
 | |
|                 content = format_str % (actor_name, category, comment_content)
 | |
|             else:
 | |
|                 content = f"{actor_name} posted:\n\n~~~ quote\n{comment_content}\n~~~"
 | |
| 
 | |
|     elif event_type == "deployment":
 | |
|         stream = config.ZULIP_COMMITS_STREAM_NAME
 | |
| 
 | |
|         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")
 | |
| 
 | |
|         start_ref_url = make_url(
 | |
|             f"projects/{project_link}/repositories/{repo_link}/commit/{start_ref}"
 | |
|         )
 | |
|         end_ref_url = make_url(f"projects/{project_link}/repositories/{repo_link}/commit/{end_ref}")
 | |
|         between_url = make_url(
 | |
|             "projects/%s/repositories/%s/compare/%s...%s"
 | |
|             % (project_link, repo_link, start_ref, end_ref)
 | |
|         )
 | |
| 
 | |
|         subject = f"Deployment to {environment}"
 | |
| 
 | |
|         content = "{} deployed [{}]({}) [through]({}) [{}]({}) to the **{}** environment.".format(
 | |
|             actor_name,
 | |
|             start_ref,
 | |
|             start_ref_url,
 | |
|             between_url,
 | |
|             end_ref,
 | |
|             end_ref_url,
 | |
|             environment,
 | |
|         )
 | |
|         if servers is not None:
 | |
|             content += "\n\nServers deployed to: %s" % (
 | |
|                 ", ".join(f"`{server}`" for server in servers)
 | |
|             )
 | |
| 
 | |
|     elif event_type == "named_tree":
 | |
|         # 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
 | |
|     elif event_type == "wiki_page":
 | |
|         logging.warn("Wiki page notifications not yet implemented")
 | |
|     elif event_type == "sprint_creation":
 | |
|         logging.warn("Sprint notifications not yet implemented")
 | |
|     elif event_type == "sprint_ended":
 | |
|         logging.warn("Sprint notifications not yet implemented")
 | |
|     else:
 | |
|         logging.info(f"Unknown event type {event_type}, ignoring!")
 | |
| 
 | |
|     if subject and content:
 | |
|         if len(subject) > 60:
 | |
|             subject = subject[:57].rstrip() + "..."
 | |
| 
 | |
|         res = client.send_message(
 | |
|             {"type": "stream", "to": stream, "subject": subject, "content": content}
 | |
|         )
 | |
|         if res["result"] == "success":
 | |
|             logging.info("Successfully sent Zulip with id: {}".format(res["id"]))
 | |
|         else:
 | |
|             logging.warn("Failed to send Zulip: {} {}".format(res["result"], res["msg"]))
 | |
| 
 | |
| 
 | |
| # the main run loop for this mirror script
 | |
| def run_mirror() -> None:
 | |
|     # we should have the right (write) permissions on the resume file, as seen
 | |
|     # in check_permissions, but it may still be empty or corrupted
 | |
|     def default_since() -> datetime:
 | |
|         return datetime.now(tz=pytz.utc) - timedelta(hours=config.CODEBASE_INITIAL_HISTORY_HOURS)
 | |
| 
 | |
|     try:
 | |
|         with open(config.RESUME_FILE) as f:
 | |
|             timestamp = f.read()
 | |
|         if timestamp == "":
 | |
|             since = default_since()
 | |
|         else:
 | |
|             since = datetime.fromtimestamp(float(timestamp), tz=pytz.utc)
 | |
|     except (ValueError, OSError) as e:
 | |
|         logging.warn(f"Could not open resume file: {str(e)}")
 | |
|         since = default_since()
 | |
| 
 | |
|     try:
 | |
|         sleepInterval = 1
 | |
|         while True:
 | |
|             events = make_api_call("activity")
 | |
|             if events is not None:
 | |
|                 sleepInterval = 1
 | |
|                 for event in events[::-1]:
 | |
|                     timestamp = event.get("event", {}).get("timestamp", "")
 | |
|                     event_date = dateutil.parser.parse(timestamp)
 | |
|                     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:
 | |
|         open(config.RESUME_FILE, "w").write(since.strftime("%s"))
 | |
|         logging.info("Shutting down Codebase mirror")
 | |
| 
 | |
| 
 | |
| # void function that checks the permissions of the files this script needs.
 | |
| def check_permissions() -> None:
 | |
|     # check that the log file can be written
 | |
|     if config.LOG_FILE:
 | |
|         try:
 | |
|             open(config.LOG_FILE, "w")
 | |
|         except OSError as e:
 | |
|             sys.stderr.write("Could not open up log for writing:")
 | |
|             sys.stderr.write(str(e))
 | |
|     # check that the resume file can be written (this creates if it doesn't exist)
 | |
|     try:
 | |
|         open(config.RESUME_FILE, "a+")
 | |
|     except OSError as e:
 | |
|         sys.stderr.write(f"Could not open up the file {config.RESUME_FILE} for reading and writing")
 | |
|         sys.stderr.write(str(e))
 | |
| 
 | |
| 
 | |
| if __name__ == "__main__":
 | |
|     assert isinstance(config.RESUME_FILE, str), "RESUME_FILE path not given; refusing to continue"
 | |
|     check_permissions()
 | |
|     if config.LOG_FILE:
 | |
|         logging.basicConfig(filename=config.LOG_FILE, level=logging.WARNING)
 | |
|     else:
 | |
|         logging.basicConfig(level=logging.WARNING)
 | |
|     run_mirror()
 |