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()
 |