AsanaのAPIを使ってタスクのストーリー（アクティビティ）までを取得してみる

こんにちは。

これはHameeの2021年のアドベントカレンダー、6日目の記事です。

https://qiita.com/advent-calendar/2021/hamee

同じくアドベントカレンダーの @yamamoto_hiroya さんの[4日目の記事](https://qiita.com/yamamoto_hiroya/items/4063e8b31c2ecd9193c7)にもあるのですが、この11月からHameeの開発部署のプロジェクト管理をRedmineからAsanaに変更しました。その関係で、旧来できていた運用の再現や、手間をはぶく新しい運用の適用やツールの作成が急ピッチで進められていました。

https://qiita.com/yamamoto_hiroya/items/4063e8b31c2ecd9193c7

そしてタスクのステータスをAsana上のセクションで管理するようになったのですが、あるとき、そのタスクの差し戻し回数や、保留されている時間を知り、プロジェクト管理の見える化をはかろうとしました。ここではそのためにおこなったAsanaのAPIの疎通方法を記載します。

## Asanaとは

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/146958/e0da06d1-26e5-793c-f29d-f8e2b7c0adf1.png)
*https://asana.com/ja*

いま一つだけプロジェクトを管理ツールを選べと言われたらAsanaを選ぶ、僕にとってAsanaはいまやそういう存在のプロジェクト管理ツールになりました。ですが本当は「プロジェクト管理ツール」ではないらしいです。「ワークマネジメントツール」らしいです。なので、プロジェクトを管理する以上の機能がAsanaには詰め込まれています。

例えばポートフォリオやレポートといった機能でしょうか。メンバーがどのような稼働をしているのか、プロジェクトを横断的に確認することができたり、プロジェクトのステータスを収集したりしてくれます。

AsanaはもともとはFacebookのエンジニアが自分たちのプロジェクトを管理するためのツールとして生まれたそうです。多種多様で柔軟な機能群はそう言った実地で生み出されているのかもしれません。「それが欲しかったわー！」という、[ワクワクするような新機能が実装され続けています](https://forum.asana.com/c/community-forum-japanese/80-category/80)。凄いね！

とはいえAsanaのワクワク機能実装をもってしても、すべての自社のわがままに付き合ってくれるわけではありません。しかしAPIを使えばある程度の情報をAsanaから引き出せたり、情報更新ができたりします。

https://asana.com/ja/guide/help/api/api

ここでは試しに、Asana APIを使用し、あるプロジェクトに紐づくタスクのストーリー（アクティビティ）を持ってきます。

## Asana APIを利用するための準備

ひとまずAsanaのAPIトークンを払い出します。[Developer console](https://app.asana.com/0/my-apps)で新しいアプリを作り、APIトークンを払い出すのが個人で使用するなら早いでしょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/146958/58d6ccdd-9969-00de-6de3-239186a8b5d5.png)

下記以降、ここで取得したトークンは `ASANA_TOKEN` という環境変数に入っているものとします。内容理解のために自分でPython書いてますが、最初にお伝えすると、すでにオフィシャルでライブラリがあるので、実務へはそちらを使うのがよろしいでしょう。

https://github.com/Asana/python-asana

## Projectを取得する

```python
#!/usr/bin/env python

import os
import json
import urllib.request
from datetime import datetime, timezone
import dateutil.parser

class AsanaResource:
    end_point = 'https://app.asana.com/api/1.0'
    headers = {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer ' + os.environ.get('ASANA_TOKEN')
    }

    @classmethod
    def get_response(cls, path):
        response = ''

        url = cls.end_point + path
        req = urllib.request.Request(url, method='GET', headers=AsanaResource.headers)
        with urllib.request.urlopen(req) as response:
            response = response.read().decode("utf-8")

        return response

class Project(AsanaResource):

    def __init__(self, project_gid):
        self.project_gid = project_gid

    def load(self):
        path = '/projects/{project_gid}?opt_fields=gid,name'.replace('{project_gid}', self.project_gid)

        response = AsanaResource.get_response(path)
        project_data = json.loads(response)['data']
        self.load_from_response(project_data)

    def load_from_response(self, project_data):
        self.project_gid = project_data['gid']
        self.name = project_data['name']

def main():

    project = Project('12345678')
    project.load()
    print(project.name)

if __name__ == '__main__':
    main()
```

> project = Project('12345678')

この部分はプロジェクトのGIDとなります。（これは適当な値を入れています）基本、下記のようにURLに記載があるのでコピペで取得してください。あるいは、[この値もAPIで取得できます](https://developers.asana.com/docs/get-multiple-projects)。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/146958/97105d16-53f1-f58d-a4cb-90fac3f3c380.png)

このスクリプトを実行すると、下記のようにプロジェクト名が取得できているのが分かります。

```shell
ito.masakuni % python ./asana_report
TEMP/MASAKUNI_NO_PJ
```

## Project〜Task〜Storyを取得する

プロジェクトのデータ取得が確認したところで、ずあーっとタスクとそのアクティビティであるストーリーを取得します。全体的に下記のように変更します。

```python
#!/usr/bin/env python

import os
import json
import urllib.request
from datetime import datetime, timezone
import dateutil.parser

class AsanaResource:
    end_point = 'https://app.asana.com/api/1.0'
    headers = {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer ' + os.environ.get('ASANA_TOKEN')
    }

    @classmethod
    def get_response(cls, path):
        response = ''

        url = cls.end_point + path
        req = urllib.request.Request(url, method='GET', headers=AsanaResource.headers)
        with urllib.request.urlopen(req) as response:
            response = response.read().decode("utf-8")

        return response

class Project(AsanaResource):

    def __init__(self, project_gid):
        self.project_gid = project_gid

    def load(self):
        path = '/projects/{project_gid}?opt_fields=gid,name'.replace('{project_gid}', self.project_gid)

        response = AsanaResource.get_response(path)
        project_data = json.loads(response)['data']
        self.load_from_response(project_data)

    def load_from_response(self, project_data):
        self.project_gid = project_data['gid']
        self.name = project_data['name']

    def get_tasks(self):
        tasks = []
        path = '/projects/{project_gid}/tasks?opt_fields=gid,name,assignee.name,created_at,memberships.section.name'.replace('{project_gid}', self.project_gid)

        response = AsanaResource.get_response(path)
        tasks_data = json.loads(response)['data']

        tasks = []
        for task_data in tasks_data:
            task = Task(task_data['gid'])
            task.load_from_response(task_data)
            tasks.append(task)

        return tasks

class Task(AsanaResource):

    def __init__(self, task_gid):
        self.task_gid = task_gid

    def load(self):
        path = '/tasks/{task_gid}?opt_fields=gid,name,assignee.name,created_at,memberships.section.name'.replace('{task_gid}', self.task_gid)

        response = AsanaResource.get_response(path)
        self.load_from_response(json.loads(response)['data'])

    def load_from_response(self, task_data):
        self.task_gid = task_data['gid']
        self.name = task_data['name']
        self.created_at = task_data['created_at']

        self.assignee = None
        if not task_data['assignee'] is None:
            self.assignee = task_data['assignee']['name']

        self.section_name = None
        for membership in task_data['memberships']:
            if 'section' in membership:
                self.section_name = membership['section']['name']

    def get_stories(self):
        stories = []
        path = '/tasks/{task_gid}/stories?opt_fields=text,created_at'.replace('{task_gid}', self.task_gid)

        response = AsanaResource.get_response(path)
        stories_data = json.loads(response)['data']

        for story_data in stories_data:
            story = Story(story_data['gid'])
            story.load_from_response(story_data)
            stories.append(story)

        return stories

class Story(AsanaResource):
    text = None
    created_at = None

    def __init__(self, story_gid):
        self.story_gid = story_gid

    def load(self):
        path = '/stories/{story_id}?opt_fields=gid,text,created_at'.replace('{story_id}', self.story_gid)

        response = AsanaResource.get_response(path)
        self.load_from_response(json.loads(response)['data'])

    def load_from_response(self, story_data):
        self.story_gid = story_data['gid']
        self.text = story_data['text']
        self.created_at = story_data['created_at']

def main():

    project = Project('12345678')

    for task in project.get_tasks():
        for story in task.get_stories():
            print(story.text)

if __name__ == '__main__':
    main()
```

この実行結果は下記となり、各タスクのストーリーが取得できているのが分かります。

```shell
ito.masakuni % python ./asana_report
Masakuni Ito added to https://app.asana.com/0/12345678/987654321
コメントを追加
Masakuni Ito changed the due date to December 7
Masakuni Ito assigned to you
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/146958/69370ed0-755e-0188-1e79-1f1dd806ef52.png)

画面上のストーリーとも一致することが分かります。

## ちょっとだけ解説

> path = '/projects/{project_gid}/tasks?opt_fields=gid,name,assignee.name,created_at,memberships.section.name'.replace('{project_gid}', self.project_gid)

AsanaのAPIは各リソースに複数のアクセス方法があるようです。上記はプロジェクトに紐付いているタスクを取得します。

> path = '/tasks/{task_gid}?opt_fields=gid,name,assignee.name,created_at,memberships.section.name'.replace('{task_gid}', self.task_gid)

こちらはタスクのIDから取得します。

> opt_fields

`opt_fields` をつけないとAsanaで定められたデフォルトのデータしか取得できないのですが、 `opt_fields` でパラメータを指定すると、そのデータが取得できるようになっています。ドキュメントの [Input/Output Options](https://developers.asana.com/docs/input-output-options) をご覧ください。てゆーか、基本ここで取得できるものはCreate時のレスポンスと同じようなものだろうなと思っている（例えば[TaskのCreate時のデータ](https://developers.asana.com/docs/create-a-task)）のですが、誰かこの一覧をご存じの方いらっしゃいませんか。

## まとめ

APIを使用せずとも、さらに柔軟なレポートがAsana上で作れるようになると僕は信じています。