|
@ -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
|
|
@ -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
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 13 KiB |
After Width: | Height: | Size: 32 KiB |
After Width: | Height: | Size: 17 KiB |
After Width: | Height: | Size: 8.9 KiB |
After Width: | Height: | Size: 15 KiB |
After Width: | Height: | Size: 22 KiB |
After Width: | Height: | Size: 36 KiB |
After Width: | Height: | Size: 25 KiB |
|
@ -108,63 +108,80 @@
|
|||
|
||||
<div id="asana" class="integration-instructions">
|
||||
|
||||
<p>Get Zulip notifications for your Asana tasks!</p>
|
||||
<p>Get Zulip notifications for your Asana projects via Zapier!</p>
|
||||
|
||||
<p>First, create the stream you'd like to use for Asana notifications, and
|
||||
subscribe all interested parties to this stream. We recommend the
|
||||
name <code>asana</code>.</p>
|
||||
|
||||
<p>Next, on your {{ settings_html|safe }}, create an Asana bot. Please note the bot name and API key.</p>
|
||||
<p><code>{{ external_api_uri_subdomain }}/v1/external/zapier?api_key=abcdefgh&stream=asana</code></p>
|
||||
|
||||
<p>Then:</p>
|
||||
<ol>
|
||||
<li>
|
||||
<p>Download and install our <a href="/api">Python bindings</a> on the
|
||||
server where the Asana mirroring script will live. The Asana
|
||||
integration will be installed to a location
|
||||
like <code>/usr/local/share/zulip/integrations/asana/</code>.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>Visit your <a href="https://app.asana.com/-/account_api">Asana
|
||||
account settings page</a> and retrieve your API key.</p>
|
||||
<p>Start by setting up a <a href="https://zapier.com/">Zapier</a> account.</p>
|
||||
|
||||
<p>Edit <code>asana/zulip_asana_config.py</code> and
|
||||
change the following lines to configure the integration:</p>
|
||||
<p>
|
||||
Next, create a ZAP, picking Asana as the app you'd like
|
||||
to receive notifications from as <code>Trigger (Step 1)</code>:
|
||||
</p>
|
||||
<img class="screenshot" src="/static/images/integrations/asana/001.png"/>
|
||||
|
||||
<div class="codehilite">
|
||||
<pre>
|
||||
<span class="n">ASANA_API_KEY</span> <span class="o">=</span> <span class="s">0123456789abcdef0123456789abcdef</span>
|
||||
<span class="n">ZULIP_USER</span> <span class="o">=</span> <span class="s">"asana-bot@example.com"</span>
|
||||
<span class="n">ZULIP_API_KEY</span> <span class="o">=</span> <span class="s">"0123456789abcdef0123456789abcdef"</span>
|
||||
{% if api_site_required %}<span class="n">ZULIP_SITE</span> <span class="o">=</span> <span class="s">"{{ external_api_uri_subdomain }}"</span>{% endif %}</pre>
|
||||
</div>
|
||||
<p>
|
||||
Next, select the Asana event that you'd like to receive notifications
|
||||
for (<code>Choose Trigger</code>), such as when you add a new Task in
|
||||
an Asana project:
|
||||
</p>
|
||||
<img class="screenshot" src="/static/images/integrations/asana/002.png"/>
|
||||
|
||||
<p>If you are using a stream other than <code>asana</code>,
|
||||
set <code>ZULIP_STREAM_NAME</code> to the chosen stream name.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>Test your configuration by running the mirror with
|
||||
<code>asana/zulip_asana_mirror</code>. It will print
|
||||
some informational messages and process any recently created
|
||||
or completed tasks.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>This mirror is intended to be a long-running processing and should be
|
||||
hooked into your infrastructure for keeping services running (for
|
||||
example, auto-restarting through <code>supervisord</code>).</p>
|
||||
<p>
|
||||
Next, click on <code>Connect a New Account</code> and follow the steps
|
||||
to connect your Asana account to the Zap:
|
||||
</p>
|
||||
<img class="screenshot" src="/static/images/integrations/asana/003.png"/>
|
||||
|
||||
<p>Please
|
||||
contact <a href="mailto:zulip-devel@googlegroups.com?subject=Asana%20integration%20question">zulip-devel@googlegroups.com</a>
|
||||
if you'd like assistance with maintaining this integration.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
<p>
|
||||
Select the Asana project you'd like to receive notifications for:
|
||||
</p>
|
||||
<img class="screenshot" src="/static/images/integrations/asana/004.png"/>
|
||||
|
||||
<p><b>Congratulations! You're done!</b><br /> When team members create and
|
||||
complete tasks in Asana, you'll get a Zulip notification that looks like
|
||||
this:</p>
|
||||
<p>
|
||||
In <code>Action (Step 2)</code>, select <code>Webhooks by Zapier</code>
|
||||
as the app:
|
||||
</p>
|
||||
<img class="screenshot" src="/static/images/integrations/asana/005.png"/>
|
||||
|
||||
<img class="screenshot" src="/static/images/integrations/asana/001.png" />
|
||||
<p>and <code>POST</code> as the action:</p>
|
||||
<img class="screenshot" src="/static/images/integrations/asana/006.png"/>
|
||||
|
||||
<p>
|
||||
Configure <code>Set up Webhooks by Zapier POST</code> as follows:
|
||||
|
||||
<ul>
|
||||
<li><code>URL</code> is the URL we created above</li>
|
||||
<li><code>Payload Type</code> set to <code>JSON</code></li>
|
||||
</ul>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Finally, configure <code>Data</code>.
|
||||
You have to add 2 fields:
|
||||
<ul>
|
||||
<li><code>subject</code> is field corresponding to a subject of the message</li>
|
||||
<li><code>content</code> is field corresponding to a content of the message</li>
|
||||
</ul>
|
||||
You can format the content of the <code>content</code> and <code>subject</code>
|
||||
fields in a number of ways as per your requirements.
|
||||
</p>
|
||||
|
||||
<p>Here's an example configuration:</p>
|
||||
<img class="screenshot" src="/static/images/integrations/asana/007.png"/>
|
||||
|
||||
<p>You're done! Example message:</p>
|
||||
<img class="screenshot" src="/static/images/integrations/asana/008.png"/>
|
||||
|
||||
<p>
|
||||
You can repeat the above process and create Zaps for different projects
|
||||
and/or different kinds of Asana events that you'd like to receive
|
||||
notifications about.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="basecamp" class="integration-instructions">
|
||||
|
|
|
@ -16,7 +16,6 @@ TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|||
os.chdir(os.path.dirname(TOOLS_DIR))
|
||||
|
||||
exclude_common = """
|
||||
api/integrations/asana/zulip_asana_config.py
|
||||
api/integrations/basecamp/zulip_basecamp_config.py
|
||||
api/integrations/codebase/zulip_codebase_config.py
|
||||
api/integrations/git/zulip_git_config.py
|
||||
|
|