diff --git a/integrations/asana/zulip_asana_config.py b/integrations/asana/zulip_asana_config.py new file mode 100644 index 0000000..72e5259 --- /dev/null +++ b/integrations/asana/zulip_asana_config.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright © 2013 Zulip, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + + +### REQUIRED CONFIGURATION ### + +# Change these values to your Asana credentials. +ASANA_API_KEY = "0123456789abcdef0123456789abcdef" + +# Change these values to the credentials for your Asana bot. +ZULIP_USER = "asana-bot@example.com" +ZULIP_API_KEY = "0123456789abcdef0123456789abcdef" + +# The Zulip stream that will receive Asana task updates. +ZULIP_STREAM_NAME = "asana" + + +### OPTIONAL CONFIGURATION ### + +# Set to None for logging to stdout when testing, and to a file for +# logging when deployed. +#LOG_FILE = "/var/tmp/zulip_asana.log" +LOG_FILE = None + +# This file is used to resume this mirror in case the script shuts down. +# It is required and needs to be writeable. +RESUME_FILE = "/var/tmp/zulip_asana.state" + +# When initially started, how many hours of messages to include. +ASANA_INITIAL_HISTORY_HOURS = 1 + +# This should not need to change unless you have a custom Zulip +# subdomain. +ZULIP_SITE = "https://api.zulip.com" + +# If properly installed, the Zulip API should be in your import +# path, but if not, set a custom path below +ZULIP_API_PATH = None diff --git a/integrations/asana/zulip_asana_mirror b/integrations/asana/zulip_asana_mirror new file mode 100644 index 0000000..ca7ac2a --- /dev/null +++ b/integrations/asana/zulip_asana_mirror @@ -0,0 +1,276 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Asana integration for Zulip +# +# Copyright © 2013 Zulip, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import base64 +from datetime import datetime, timedelta +import dateutil.parser +import dateutil.tz +import json +import logging +import os +import time +import urllib2 + +import sys +sys.path.insert(0, os.path.dirname(__file__)) +import zulip_asana_config as config + +if config.ZULIP_API_PATH is not None: + sys.path.append(config.ZULIP_API_PATH) +import zulip + +if config.LOG_FILE: + logging.basicConfig(filename=config.LOG_FILE, level=logging.WARNING) +else: + logging.basicConfig(level=logging.INFO) + +client = zulip.Client(email=config.ZULIP_USER, api_key=config.ZULIP_API_KEY, + site=config.ZULIP_SITE) + +def fetch_from_asana(path): + """ + Request a resource through the Asana API, authenticating using + HTTP basic auth. + """ + auth = base64.encodestring('%s:' % (config.ASANA_API_KEY,)) + headers = {"Authorization": "Basic %s" % auth} + + url = "https://app.asana.com/api/1.0" + path + request = urllib2.Request(url, None, headers) + result = urllib2.urlopen(request) + + return json.load(result) + +def send_zulip(topic, content): + """ + Send a message to Zulip using the configured stream and bot credentials. + """ + message = {"type": "stream", + "sender": config.ZULIP_USER, + "to": config.ZULIP_STREAM_NAME, + "subject": topic, + "content": content, + } + return client.send_message(message) + +def datestring_to_datetime(datestring): + """ + Given an ISO 8601 datestring, return the corresponding datetime object. + """ + return dateutil.parser.parse(datestring).replace( + tzinfo=dateutil.tz.gettz('Z')) + +class TaskDict(dict): + """ + A helper class to turn a dictionary with task information into an + object where each of the keys is an attribute for easy access. + """ + def __getattr__(self, field): + return self.get(field) + +def format_topic(task, projects): + """ + Return a string that will be the Zulip message topic for this task. + """ + # Tasks can be associated with multiple projects, but in practice they seem + # to mostly be associated with one. + project_name = projects[task.projects[0]["id"]] + return "%s: %s" % (project_name, task.name) + +def format_assignee(task, users): + """ + Return a string describing the task's assignee. + """ + if task.assignee: + assignee_name = users[task.assignee["id"]] + assignee_info = "**Assigned to**: %s (%s)" % ( + assignee_name, task.assignee_status) + else: + assignee_info = "**Status**: Unassigned" + + return assignee_info + +def format_due_date(task): + """ + Return a string describing the task's due date. + """ + if task.due_on: + due_date_info = "**Due on**: %s" % (task.due_on,) + else: + due_date_info = "**Due date**: None" + return due_date_info + +def format_task_creation_event(task, projects, users): + """ + Format the topic and content for a newly-created task. + """ + topic = format_topic(task, projects) + assignee_info = format_assignee(task, users) + due_date_info = format_due_date(task) + + content = """Task **%s** created: + +~~~ quote +%s +~~~ + +%s +%s +""" % (task.name, task.notes, assignee_info, due_date_info) + return topic, content + +def format_task_completion_event(task, projects, users): + """ + Format the topic and content for a completed task. + """ + topic = format_topic(task, projects) + assignee_info = format_assignee(task, users) + due_date_info = format_due_date(task) + + content = """Task **%s** completed. :white_check_mark: + +%s +%s +""" % (task.name, assignee_info, due_date_info) + return topic, content + +def since(): + """ + Return a newness threshold for task events to be processed. + """ + # If we have a record of the last event processed and it is recent, use it, + # else process everything from ASANA_INITIAL_HISTORY_HOURS ago. + def default_since(): + return datetime.utcnow() - timedelta( + hours=config.ASANA_INITIAL_HISTORY_HOURS) + + if os.path.exists(config.RESUME_FILE): + try: + with open(config.RESUME_FILE, "r") as f: + datestring = f.readline().strip() + timestamp = float(datestring) + max_timestamp_processed = datetime.fromtimestamp(timestamp) + logging.info("Reading from resume file: " + datestring) + except (ValueError,IOError) as e: + logging.warn("Could not open resume file: %s" % ( + e.message or e.strerror,)) + max_timestamp_processed = default_since() + else: + logging.info("No resume file, processing an initial history.") + max_timestamp_processed = default_since() + + # Even if we can read a timestamp from RESUME_FILE, if it is old don't use + # it. + return max(max_timestamp_processed, default_since()) + +def process_new_events(): + """ + Forward new Asana task events to Zulip. + """ + # In task queries, Asana only exposes IDs for projects and users, so we need + # to look up the mappings. + projects = dict((elt["id"], elt["name"]) for elt in \ + fetch_from_asana("/projects")["data"]) + users = dict((elt["id"], elt["name"]) for elt in \ + fetch_from_asana("/users")["data"]) + + cutoff = since() + max_timestamp_processed = cutoff + time_operations = (("created_at", format_task_creation_event), + ("completed_at", format_task_completion_event)) + task_fields = ["assignee", "assignee_status", "created_at", "completed_at", + "modified_at", "due_on", "name", "notes", "projects"] + + # First, gather all of the tasks that need processing. We'll + # process them in order. + new_events = [] + + for project_id in projects: + project_url = "/projects/%d/tasks?opt_fields=%s" % ( + project_id, ",".join(task_fields)) + tasks = fetch_from_asana(project_url)["data"] + + for task in tasks: + task = TaskDict(task) + + for time_field, operation in time_operations: + if task[time_field]: + operation_time = datestring_to_datetime(task[time_field]) + if operation_time > cutoff: + new_events.append((operation_time, time_field, operation, task)) + + new_events.sort() + now = datetime.utcnow() + + for operation_time, time_field, operation, task in new_events: + # Unfortunately, creating an Asana task is not an atomic operation. If + # the task was just created, or is missing basic information, it is + # probably because the task is still being filled out -- wait until the + # next round to process it. + if (time_field == "created_at") and \ + (now - operation_time < timedelta(seconds=30)): + # The task was just created, give the user some time to fill out + # more information. + return + + if (time_field == "created_at") and (not task.name) and \ + (now - operation_time < timedelta(seconds=60)): + # If this new task hasn't had a name for a full 30 seconds, assume + # you don't plan on giving it one. + return + + topic, content = operation(task, projects, users) + logging.info("Sending Zulip for " + topic) + result = send_zulip(topic, content) + + # If the Zulip wasn't sent successfully, don't update the + # max timestamp processed so the task has another change to + # be forwarded. Exit, giving temporary issues time to + # resolve. + if not result.get("result"): + logging.warn("Malformed result, exiting:") + logging.warn(result) + return + + if result["result"] != "success": + logging.warn(result["msg"]) + return + + if operation_time > max_timestamp_processed: + max_timestamp_processed = operation_time + + if max_timestamp_processed > cutoff: + max_datestring = max_timestamp_processed.strftime("%s.%f") + logging.info("Updating resume file: " + max_datestring) + open(config.RESUME_FILE, 'w').write(max_datestring) + +while True: + try: + process_new_events() + time.sleep(5) + except KeyboardInterrupt: + logging.info("Shutting down...") + logging.info("Set LOG_FILE to log to a file instead of stdout.") + break