From dc3f0f35adb556f9eaddd3ca4aba039230479a85 Mon Sep 17 00:00:00 2001 From: Eeshan Garg Date: Mon, 20 Mar 2017 21:34:46 -0230 Subject: [PATCH] Document how to set up an Asana integration via Zapier. Fixes #3948. --- integrations/asana/zulip_asana_config.py | 56 ----- integrations/asana/zulip_asana_mirror | 306 ----------------------- 2 files changed, 362 deletions(-) delete mode 100644 integrations/asana/zulip_asana_config.py delete mode 100755 integrations/asana/zulip_asana_mirror diff --git a/integrations/asana/zulip_asana_config.py b/integrations/asana/zulip_asana_config.py deleted file mode 100644 index bfe9fd7..0000000 --- a/integrations/asana/zulip_asana_config.py +++ /dev/null @@ -1,56 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright © 2014 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 in production. -#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 - -# Set this to your Zulip API server URI -ZULIP_SITE = "https://zulip.example.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 deleted file mode 100755 index e15436a..0000000 --- a/integrations/asana/zulip_asana_mirror +++ /dev/null @@ -1,306 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -# -# Asana integration for Zulip -# -# Copyright © 2014 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. -# -# The "zulip_asana_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. - -from __future__ import print_function -import base64 -from datetime import datetime, timedelta -from typing import List, Dict, Optional, Any, Tuple - -import json -import logging -import os -import time -from six.moves import urllib -from six.moves.urllib import request as urllib_request -import sys - - -try: - import dateutil.parser - from dateutil.tz import gettz -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_asana_config as config -VERSION = "0.9" - -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, client="ZulipAsana/" + VERSION) - -def fetch_from_asana(path): - # type: (str) -> Optional[Dict[str, Any]] - """ - Request a resource through the Asana API, authenticating using - HTTP basic auth. - """ - auth = base64.encodestring(b'%s:' % (config.ASANA_API_KEY,)) - headers = {"Authorization": "Basic %s" % auth} - - url = "https://app.asana.com/api/1.0" + path - request = urllib_request.Request(url, None, headers) # type: ignore - result = urllib_request.urlopen(request) # type: ignore - - return json.load(result) - -def send_zulip(topic, content): - # type: (str, str) -> Dict[str, str] - """ - 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): - # type: (str) -> datetime - """ - Given an ISO 8601 datestring, return the corresponding datetime object. - """ - return dateutil.parser.parse(datestring).replace( - tzinfo=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): - # type: (TaskDict, str) -> Any - return self.get(field) - -def format_topic(task, projects): - # type: (TaskDict, Dict[str, str]) -> str - """ - 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): - # type: (TaskDict, Dict[str, str]) -> str - """ - 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): - # type: (TaskDict) -> str - """ - 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): - # type: (TaskDict, Dict[str, str], Dict[str, str]) -> Tuple[str, str] - """ - 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): - # type: (TaskDict, Dict[str, str], Dict[str, str]) -> Tuple[str, str] - """ - 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(): - # type: () -> datetime - """ - 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(): - # type: () -> datetime - 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: " + str(e)) - 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(): - # type: () -> None - """ - 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(str(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