- はじめに
- 今回想定するGitHub Projects
- GitHub GraphQL APIを使うまでの準備
- 今回はPythonで実行します
- GitHub Projectsに紐づくIssue一覧を取得する
- 特定のステータスのIssueを取得
- 特定のステータスでアサインなしのIssueを取得
- GitHub GraphQLの勉強の仕方
- まとめ
はじめに
複数リポジトリが紐づくGitHub Projectsで、特定のステータスのIssueだけ取得しようとした時、GitHub REST APIでは不可能だったので、GitHub GraphQL APIを使ってみました。
意外とネットに情報がなかったので、メモを残しておきます。
今回想定する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になります。
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を指定しよう。