いいものを広め隊

みんなの生活をより良くしたい

GitHub GraphQL APIを利用し、複数リポジトリが紐づくGitHub ProjectsのIssueリストを取得する

はじめに

複数リポジトリが紐づくGitHub Projectsで、特定のステータスのIssueだけ取得しようとした時、GitHub REST APIでは不可能だったので、GitHub GraphQL APIを使ってみました。
意外とネットに情報がなかったので、メモを残しておきます。

今回想定するGitHub Projects

今回想定するGitHub Projects
ステータスはわかりやすくデフォルトのTodo、In Progress、Doneにしました。

クラシックではないほうのGitHub Projectsです。

複数のリポジトリが紐づいています。

GitHub GraphQL APIを使うまでの準備

以下の説明は省きます。

  • GitHubアカウントの作り方

  • GitHub Projectsの作り方

  • GraphQLの説明(最後の方に参考リンク貼ってます)

1. アクセストークンの登録

GitHubのAPIを使うにはアクセストークンが必要になります。 私はクラシックの方を使っています。
以下の公式ページを参考に作ってください。

個人用アクセス トークンを管理する - GitHub Enterprise Cloud Docs

権限範囲は各自で決めてください。

すでにアクセストークンある人も「project」にチェックがついているか確認してください。

この権限がないと動きません。

2. Issueを取得したいGitHub Projectsのidを取得する

ここで、いきなりGitHub GraphQL APIを叩きます。
GraphQLの概念では、それぞれの要素にidが設定されています。
GitHub Projectsのidを取得して、クエリに使用します。

  • {アクセストークン}を各自のアクセストークン

  • {ユーザー名}をGitHubのユーザー名

  • {projectsの番号}はGitHub ProjectsにアクセスしたときのURLから取得してください。画像上だと3になります。

GitHub ProjectsのnumberはURLに書いてある

curl -H "Authorization: bearer {アクセストークン}"    -X POST -d "{ \"query\": \"query{ user(login: \\\"{ユーザー名}\\\") { projectV2(number: {projectsの番号}) { title id } } }\"}" https://api.github.com/graphql

レスポンスはこんな感じ

{"data":{"user":{"projectV2":{"title":"test project","id":"xxxxxxxxxxxxxxxxxx"}}}}

idはメモしておきましょう。

今回はPythonで実行します

私が勉強を始めたときは、気軽にAPI実行できる環境があればなあと思っていました。
GitHub GraphQL APIをcURLで叩こうとすると、改行が多すぎてかなり使いづらいので、誰でも使えるであろうPythonのurl.libでAPIを叩きます。
コピペで動くように標準ライブラリだけ利用しています。

私はAWS Lambdaで定期実行して、特定のステータスのIssueを通知させるようにしています。

GitHub Projectsに紐づくIssue一覧を取得する

早速ソースコードです。 以下の行を修正して実行してください。

  • 5行目にアクセストークンを

  • 39行目にGitHub Projectsのidを

import urllib.request
import json
import time

# GitHubアクセストークンを設定
access_token = ''
# node(id: "各自のID") {  の行で、各自のIDに書き換える
# ↑fstring使うと、エスケープが多くなってしまうので、直接指定している

items_after_value = '""'
review_issue = []


def post(query):
    url ='https://api.github.com/graphql'

    # リクエストヘッダー
    headers = {
        "Authorization": f"Bearer {access_token}",
        "Accept": "application/vnd.github.starfox-preview+json"
    }

    # リクエストデータをJSON形式にエンコード
    data = json.dumps(query).encode()

    # リクエストを作成
    # dataがあると、POST
    req = urllib.request.Request(url, data=data, headers=headers)

    # APIにリクエストを送信
    with urllib.request.urlopen(req) as res:
        # レスポンスデータを取得
        res_body = res.read().decode('utf-8')
        res_body = json.loads(res_body)
    return res_body

def get_project_issue():
    # 全ての情報をとるまでやる
    i = 0
    hasNextPage = ""
    items_after_value = ""
    page_info = []
    while (i < 10):
        i += 1
        if hasNextPage or i == 1:
            query = {'query': '''query($items_after: String!){
                node(id: "各自のID") {
                    ... on ProjectV2 {
                    items(first:100, after: $items_after) {
                        totalCount
                        pageInfo {
                        endCursor
                        hasNextPage
                        hasPreviousPage
                        startCursor
                        }
                        nodes{
                        content{
                            ...on Issue {
                            title
                            url
                            state
                            updatedAt
                            repository{
                                name
                            }
                            assignees(first: 1) {
                                nodes{
                                login
                                }
                            }
                            labels(first: 5) {
                                nodes{
                                name
                                }
                            }
                            projectItems(first: 1, includeArchived: false){
                                nodes{
                                fieldValues(first: 8) {
                                    nodes{                
                                    ... on ProjectV2ItemFieldSingleSelectValue{
                                        name
                                    }
                                    }
                                }
                                }
                            }
                            }
                        }
                        }
                    }
                    }
                }
                }''',
            'variables': f'''{{
            "items_after": "{items_after_value}"
            }}'''
            }
            print("items_after")
            print(items_after_value)
            res_body = post(query)
            time.sleep(5)
        else:
            break
        items = res_body['data']['node']['items']
        nodes = items['nodes']
        page_info = items['pageInfo']
        hasNextPage = page_info['hasNextPage']
        items_after_value = page_info['endCursor']

    return res_body


issue_list = get_project_issue()
print(issue_list)

実行してみると、こんな感じにデータが返ってきます。

{'data': {'node': {'items': {'totalCount': 7,
 'pageInfo': {'endCursor': 'Nw',
 'hasNextPage': False,
 'hasPreviousPage': False,
 'startCursor': 'MQ'},
 'nodes': [{'content': {'title': '環境構築する',
 'url': 'https://github.com/HK3330/voice_timer/issues/1',
 'state': 'OPEN',
 'updatedAt': '2023-06-03T07:37:54Z',
 'repository': {'name': 'voice_timer'},
 'assignees': {'nodes': [{'login': 'HK3330'}]},
 'labels': {'nodes': []},
 'projectItems': {'nodes': [{'fieldValues': {'nodes': [{},
 {},
 {},
 {'name': 'Todo'}]}}]}}},
 {'content': {'title': 'cdk v2対応',
 'url': 'https://github.com/HK3330/cdk-python-lambda-sample/issues/1',
 'state': 'OPEN',
 'updatedAt': '2023-05-31T11:52:59Z',
 'repository': {'name': 'cdk-python-lambda-sample'},
 'assignees': {'nodes': []},
 'labels': {'nodes': []},
 'projectItems': {'nodes': [{'fieldValues': {'nodes': [{},
 {},
 {'name': 'Todo'}]}}]}}},
 {'content': {'title': 'READMEの準備の部分を詳しく追記する',
 'url': 'https://github.com/HK3330/terraform-lambda-sample/issues/1',
 'state': 'OPEN',
 'updatedAt': '2023-05-31T12:05:05Z',
 'repository': {'name': 'terraform-lambda-sample'},
 'assignees': {'nodes': []},
 'labels': {'nodes': []},
 'projectItems': {'nodes': [{'fieldValues': {'nodes': [{},
 {},
 {'name': 'Todo'}]}}]}}},
 {'content': {'title': 'todo-test-issue',
 'url': 'https://github.com/HK3330/github-test/issues/3',
 'state': 'OPEN',
 'updatedAt': '2023-06-03T07:37:28Z',
 'repository': {'name': 'github-test'},
 'assignees': {'nodes': []},
 'labels': {'nodes': []},
 'projectItems': {'nodes': [{'fieldValues': {'nodes': [{},
 {},
 {'name': 'Todo'}]}}]}}},
 {'content': {'title': 'doing-test-issue',
 'url': 'https://github.com/HK3330/github-test/issues/1',
 'state': 'OPEN',
 'updatedAt': '2023-06-03T07:37:24Z',
 'repository': {'name': 'github-test'},
 'assignees': {'nodes': [{'login': 'HK3330'}]},
 'labels': {'nodes': []},
 'projectItems': {'nodes': [{'fieldValues': {'nodes': [{},
 {},
 {},
 {'name': 'In Progress'}]}}]}}},
 {'content': {'title': 'inprogress-test-issue',
 'url': 'https://github.com/HK3330/github-test/issues/4',
 'state': 'OPEN',
 'updatedAt': '2023-05-31T12:05:41Z',
 'repository': {'name': 'github-test'},
 'assignees': {'nodes': [{'login': 'HK3330'}]},
 'labels': {'nodes': []},
 'projectItems': {'nodes': [{'fieldValues': {'nodes': [{},
 {},
 {},
 {'name': 'In Progress'}]}}]}}},
 {'content': {'title': 'done-test-issue',
 'url': 'https://github.com/HK3330/github-test/issues/2',
 'state': 'CLOSED',
 'updatedAt': '2023-05-31T12:05:51Z',
 'repository': {'name': 'github-test'},
 'assignees': {'nodes': [{'login': 'HK3330'}]},
 'labels': {'nodes': []},
 'projectItems': {'nodes': [{'fieldValues': {'nodes': [{},
 {},
 {},
 {'name': 'Done'}]}}]}}}]}}}}

GraphQLはデータ取得のやり方が自由なので、雑なクエリを作るとこんな感じに見づらいです。 ただ必要な情報は取れていることがわかります。

以下が1個分のIssueのデータです。 Todoが入っているところがステータスです。これを取るためにかなり苦労しました。

 {'name': 'Todo'}]}}]}}},
 {'content': {'title': 'READMEの準備の部分を詳しく追記する',
 'url': 'https://github.com/HK3330/terraform-lambda-sample/issues/1',
 'state': 'OPEN',
 'updatedAt': '2023-05-31T12:05:05Z',
 'repository': {'name': 'terraform-lambda-sample'},
 'assignees': {'nodes': []},
 'labels': {'nodes': []},
 'projectItems': {'nodes': [{'fieldValues': {'nodes': [{},
 {},

クエリのステータス取得する部分は以下です。

projectItems(first: 1, includeArchived: false){
    nodes{
    fieldValues(first: 8) {
        nodes{                
        ... on ProjectV2ItemFieldSingleSelectValue{
            name
        }
        }
    }
    }
}

ここまでの階層に行かないと取得できないです… REST APIでステータスとれれば、どんなに楽か…

実は一度に取得できるアイテムの数(今回で言うIssueの数)は制限があります。 クエリの以下の部分で、cursorの情報も取得し、100個以上Issueがある場合は、全て取得できるようにしています。

pageInfo {
endCursor
hasNextPage
hasPreviousPage
startCursor
}

特定のステータスのIssueを取得

たとえば、DoneステータスのIssueだけ取得したい場合は、以下のサンプルコードになります。
GitHub GraphQLのクエリのみで、ステータスを絞り込む方法は見つけられませんでした。
なので、リポジトリに紐づく全Issueのレスポンスの中身を見て、絞り込んでいるだけなので、Python話になっちゃうので、おまけです。

  • 9行目でステータスを指定します。

  • 98〜13行目の処理が追加されただけです。

出力を見てみるとたしかにDoneのIssueだけ取れてます。

[{'content': {'title': 'done-test-issue',
 'url': 'https://github.com/HK3330/github-test/issues/2',
 'state': 'CLOSED',
 'updatedAt': '2023-05-31T12:05:51Z',
 'repository': {'name': 'github-test'},
 'assignees': {'nodes': [{'login': 'HK3330'}]},
 'labels': {'nodes': []},
 'projectItems': {'nodes': [{'fieldValues': {'nodes': [{},
 {},
 {},
 {'name': 'Done'}]}}]}}}]

特定のステータスでアサインなしのIssueを取得

例えば複数人で開発するときにあるのが、レビュー待ち(レビュー待ちのステータスかつ、アサインなし)Issueを取得したいケースです。

以下のサンプルコードはTodoステータスかつ、アサインなしのIssueだけ取得します。
これもクエリのみで、アサインなしに絞り込む方法がわからなかったので、GitHub Projectsに紐づく全Issueのレスポンスを見て、絞り込んでいます。
Pythonの話になっちゃうので、おまけです。

  • 104,105行目が追加されただけです。

Todoステータス、アサインなしのIssueだけ取得できました。

[{'content': {'title': 'cdk v2対応',
 'url': 'https://github.com/HK3330/cdk-python-lambda-sample/issues/1',
 'state': 'OPEN',
 'updatedAt': '2023-05-31T11:52:59Z',
 'repository': {'name': 'cdk-python-lambda-sample'},
 'assignees': {'nodes': []},
 'labels': {'nodes': []},
 'projectItems': {'nodes': [{'fieldValues': {'nodes': [{},
 {},
 {'name': 'Todo'}]}}]}}},
 {'content': {'title': 'READMEの準備の部分を詳しく追記する',
 'url': 'https://github.com/HK3330/terraform-lambda-sample/issues/1',
 'state': 'OPEN',
 'updatedAt': '2023-05-31T12:05:05Z',
 'repository': {'name': 'terraform-lambda-sample'},
 'assignees': {'nodes': []},
 'labels': {'nodes': []},
 'projectItems': {'nodes': [{'fieldValues': {'nodes': [{},
 {},
 {'name': 'Todo'}]}}]}}},
 {'content': {'title': 'todo-test-issue',
 'url': 'https://github.com/HK3330/github-test/issues/3',
 'state': 'OPEN',
 'updatedAt': '2023-06-03T07:37:28Z',
 'repository': {'name': 'github-test'},
 'assignees': {'nodes': []},
 'labels': {'nodes': []},
 'projectItems': {'nodes': [{'fieldValues': {'nodes': [{},
 {},
 {'name': 'Todo'}]}}]}}}]

GitHub GraphQLの勉強の仕方

私は、GraphQLを使ったことがない状態で、GitHub GraphQL APIを使ってみましたが、このクエリを書くのにかなりの時間を使いました。
本当にきつかった…

苦労した点

  • GraphQL自体の知識がなかった

  • 気軽に実行できる環境がない

  • ネット上にサンプルが少ない

  • レスポンスの形式が自由に指定できるので、人それぞれの書き方

GraphQLってなんだ?というレベルの方

この方のページを何度も読み直してようやく全体像が掴めて、なんとかクエリを書くことができました。
本当にお世話になりました。
まず読んでみてください。

ここさえ抑えればGitHub API v4がわかる! GraphQL入門

GraphQLがなんとなくわかってきた方

私のサンプルコードのクエリ部分をいろいろといじってみながら、欲しい項目を取得できるようにいじってみてください。

取得できる項目は、公式ドキュメントとにらめっこして探してみてください。

https://docs.github.com/ja/graphql/reference/objects

実際に手を動かしてみたほうが理解が進むと思います。
誰でも簡単に実行できるサンプルがあったらいいなと思い、今回はPythonで動かすサンプルコードを作りました。

まとめ

  • GitHub Projects周り、IssueのステータスはREST APIでは情報取得できない。

  • Issueのステータスを取得したい場合は、GitHub GraphQLのクエリで、ProjectV2ItemFieldSingleSelectValueのnameを指定しよう。