Pull Request作成時にGithub Projectsにメタデータを連携する - GitHub GraphQL API v4 on Github Actions -

Githubに標準搭載されているGithub ProjectsなのですがGithubのPull RequestとGithub　Projectsへの連携についてはそのものズバリな記事も見つからず公式のリファレンスを見ても基本的なGitHub GraphQL API v4を理解した上でないと利用できないため手っ取り早く連携したい人向けに記事を書いていきます。

# Personal Access Tokensの準備
[作成はここから](https://github.com/settings/tokens/new)

Github　ProjectsはOrganizationに紐づくためrepoだけではない比較的広い権限のPATが必要になります `secrets.GITHUB_TOKEN` の権限だけでは実行できません。

具体的には以下の☑が付いている部分の権限が必要になります。

- [x] repo
    - [x] repo:status
    - [x] repo_deployment
    - [x] public_repo
    - [x] repo:invite
    - [x] securiry_events
- [ ] write:packages
    - [x] read:packages
- [ ] admin:org
    - [x] read:org
- [x] project
    - [x] read:project

`read:packages`とか`read:org`とか不要なんじゃないかと思ったりしますが、どうも内部で参照しているらしく現段階(2023/1/3)では必要みたいです。

後はGithub Actionsを適用したい各Repositoryに任意の名称でSecrets Tokenを設定すれば完了。
ここでは `WRITABLE_GITHUB_TOKEN_FOR_PROJECT_UPDATE` としておきましょう。

# [Github Action] Pull Request作成時にProjectsに紐付ける部分のworkflow

```yaml
name: Auto Github Projects Update

on:
  pull_request:
    types:
      - opened
      - synchronize
      - closed

env:
  GH_TOKEN: ${{ secrets.WRITABLE_GITHUB_TOKEN_FOR_PROJECT_UPDATE }}
  GH_REPO: ${{ github.repository }}
  ISSUE_ID: ${{ github.event.pull_request.node_id }}
  PR_NO: ${{ github.event.number }}
  ORGANIZATION_NAME: ${{ github.event.organization.login }}
  PROJECT_NO: <<Github ProjectsのProjectNumber>>

jobs:
  create-pr-card:
    runs-on: ubuntu-20.04
    if: ${{ (github.event.pull_request.user.login != 'dependabot[bot]') && (github.event.pull_request.user.login != 'github-actions[bot]') }}
    timeout-minutes: 1
    steps:
      - name: Github Workflow Event JSONの中身を確認
        run: cat $GITHUB_EVENT_PATH
      - name: Github ProjectsのProjectIDを取得
        run: |
          # https://docs.github.com/en/graphql/reference/queries#organization
          # https://docs.github.com/en/graphql/reference/objects#projectv2
          PROJECT_ID=$(gh api graphql -f orgname="${ORGANIZATION_NAME}" -F projectno=${PROJECT_NO} -f query='
            query get_project_id($orgname: String!, $projectno: Int!) {
              organization(login: $orgname){
                projectV2(number: $projectno) {
                  id
                  fields(first:20) {
                    nodes {
                      ... on ProjectV2Field {
                        id
                        name
                      }
                      ... on ProjectV2SingleSelectField {
                        id
                        name
                        options {
                          id
                          name
                        }
                      }
                    }
                  }
                }
              }
            }' | jq -c -r '.data.organization.projectV2.id')
          echo PROJECT_ID=${PROJECT_ID} >> $GITHUB_ENV
      - name: Pull RequestをGithub Projectsに紐付け
        run: |
          gh api graphql -f projectid="${PROJECT_ID}" -f item="${ISSUE_ID}" -f query='
          mutation add_to_project($projectid: ID!, $item: ID!) {
          	addProjectV2ItemById(input: { projectId: $projectid, contentId: $item }) {
          		item {
          			id
          		}
          	}
          }'
```

GitHub GraphQL API v4の仕様というよりGraphQLの仕様としてQueryのFieldは必ずスカラ型にする必要があります。
Object型での取得は出来ないのでスカラ型が定義されている部分、今回の場合は`id`や`name`までしっかり記述してあげないとerrorになります。

QueryやMutationのパラメータを指定する場合は `-f` もしくは `-F` で指定が可能です。
https://cli.github.com/manual/gh_api

> Pass one or more -f/--raw-field values in "key=value" format to add static string parameters to the request payload. To add non-string or placeholder-determined values, see --field below.
>
> The -F/--field flag has magic type conversion based on the format of the value:
literal values "true", "false", "null", and integer numbers get converted to appropriate JSON types;
placeholder values "{owner}", "{repo}", and "{branch}" get populated with values from the repository of the current directory;
if the value starts with "@", the rest of the value is interpreted as a filename to read the value from. Pass "-" to read from standard input.
For GraphQL requests, all fields other than "query" and "operationName" are interpreted as GraphQL variables.
>
> To pass nested parameters in the request payload, use "key[subkey]=value" syntax when declaring fields. To pass nested values as arrays, declare multiple fields with the syntax "key[]=value1", "key[]=value2". To pass an empty array, use "key[]" without a value.
> 
> To pass pre-constructed JSON or payloads in other formats, a request body may be read from file specified by --input. Use "-" to read from standard input. When passing the request body this way, any parameters specified via field flags are added to the query string of the endpoint URL.

`-f` で指定した場合は `raw-field` つまり指定した値がそのまま渡されます。パラメータの値がString型やID型の場合は `-f`で良いでしょう。
`-F` はJSONの型に自動的に変換されます。パラメータの値が数値やBoolean型の場合は`-F`を指定する必要があります。もしかして、全部 `-F` を指定すれば問題ない……？

GraphQL初見でまず躓きそうなのはユニオン型です。
`node`には実行時に型が確定するFieldが存在するためインラインフラグメントで型を指定してあげる必要があります。
`... on ProjectV2Field { }`  `... on ProjectV2SingleSelectField { }` の部分ですね。
GitHub GraphQL API v4ではユニオン型が多用されているのでインターフェイスの構造を理解してから挑んだほうが良いと思います。

# [Github Action] Pull Requestの更新＆Close時に実績値をFieldに書き込む部分のworkflow

実績値の計算は日付を跨いだ場合に非稼働時間を省く計算をしています必要であれば適宜修正してください。

```yaml
  update-pr-card-field:
    runs-on: ubuntu-20.04
    if: ${{ (github.event.pull_request.user.login != 'dependabot[bot]') && (github.event.pull_request.user.login != 'github-actions[bot]') }}
    needs: create-pr-card
    timeout-minutes: 1
    env:
      ACTUAL_FIELD_NAME: <<実績値を格納するFieldのフィールド名>>
      NON_WORKING_HOUR_ACROSS_DATE: 15
      RES_PJ_JSON: ${{ github.event.number }}_pj_fields.json
      RES_PJ_CARD_JSON: ${{ github.event.number }}_pj_pr_nodes.json
    steps:
      - name: Github Workflow Event JSONの中身を確認
        run: cat $GITHUB_EVENT_PATH
      - name: Github Projectsの情報を取得
        run: |
          # https://docs.github.com/en/graphql/reference/queries#organization
          # https://docs.github.com/en/graphql/reference/objects#projectv2
          gh api graphql -f orgname="${ORGANIZATION_NAME}" -F projectno=${PROJECT_NO} -f query='
            query get_project_id($orgname: String!, $projectno: Int!) {
              organization(login: $orgname){
                projectV2(number: $projectno) {
                  id
                  fields(first:20) {
                    nodes {
                      ... on ProjectV2Field {
                        id
                        name
                      }
                      ... on ProjectV2SingleSelectField {
                        id
                        name
                        options {
                          id
                          name
                        }
                      }
                    }
                  }
                }
              }
            }' | jq -c '.data.organization.projectV2' > ${RES_PJ_JSON}
      - name: Github Projectsの情報を確認
        run: cat ${RES_PJ_JSON}
      - name: ProjectIDと実績値更新用のFieldIDを取得
        run: |
          PROJECT_ID=$(cat ${RES_PJ_JSON} | jq -r '.id')
          FIELD_ID=$(cat ${RES_PJ_JSON} | jq -c -r ".fields.nodes | .[] | select(.name == \"${ACTUAL_FIELD_NAME}\") | .id")

          echo PROJECT_ID=${PROJECT_ID} >> $GITHUB_ENV
          echo FIELD_ID=${FIELD_ID} >> $GITHUB_ENV
      - name: Github Projectsのカード一覧をposition降順で取得
        run: |
          # https://docs.github.com/en/graphql/reference/queries#node
          # https://docs.github.com/en/graphql/reference/queries#search
          # https://docs.github.com/en/graphql/reference/objects#projectv2
          gh api graphql -f projectid="${PROJECT_ID}" -f query='
            query get_project_nodes_desc($projectid: ID!) {
              node(id: $projectid) {
                ... on ProjectV2 {
                  items(first: 100, orderBy: { field: POSITION, direction: DESC }) {
                    nodes{
                      id
                      content {
                        ...on PullRequest {
                          number
                          createdAt
                          closedAt
                          mergedAt
                        }
                      }
                      fieldValues(first: 20) {
                        nodes{
                          ... on ProjectV2ItemFieldTextValue {
                            text
                            field {
                              ... on ProjectV2FieldCommon {
                                id
                                name
                              }
                            }
                          }
                          ... on ProjectV2ItemFieldNumberValue {
                            number
                            field {
                              ... on ProjectV2FieldCommon {
                                id
                                name
                              }
                            }
                          }
                          ... on ProjectV2ItemFieldDateValue {
                            date
                            field {
                              ... on ProjectV2FieldCommon {
                                id
                                name
                              }
                            }
                          }
                          ... on ProjectV2ItemFieldSingleSelectValue {
                            name
                            field {
                              ... on ProjectV2FieldCommon {
                                id
                                name
                              }
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }' | jq -c ".data.node.items.nodes | .[] | select(.content.number == ${PR_NO})" > ${RES_PJ_CARD_JSON}
      - name: Github Projectsのカード一覧情報を確認
        run: cat ${RES_PJ_CARD_JSON}
      - name: Project Field更新に必要な情報を取得
        run: |
          ITEM_ID=$(cat ${RES_PJ_CARD_JSON} | jq -c -r '.id')
          CREATE_UNIXTIME=$(cat ${RES_PJ_CARD_JSON} | jq -c -r '.content.createdAt | fromdate')
          CLOSE_UNIXTIME=$(cat ${RES_PJ_CARD_JSON} | jq -c -r '.content.closedAt | try fromdate catch now | floor')
          ACTUAL_VALUE=$(awk "BEGIN {printf \"%0.3f\", ($CLOSE_UNIXTIME - $CREATE_UNIXTIME) / 60 / 60}")

          # 日付を跨いだ場合、非稼働時間分の工数を引くために非稼働時間を算出
          CREATE_DATE=$(date --date @${CREATE_UNIXTIME} +"%Y-%m-%d")
          CLOSE_DATE=$(date --date @${CLOSE_UNIXTIME} +"%Y-%m-%d")
          CREATE_DATE_UNIXTIME=$(date -d "$CREATE_DATE" +%s)
          CLOSE_DATE_UNIXTIME=$(date -d "$CLOSE_DATE" +%s)
          ACRESS_DATE=$((($CLOSE_DATE_UNIXTIME - $CREATE_DATE_UNIXTIME) / 60 / 60 / 24))
          if [ $ACRESS_DATE -ne 0 ]; then
            # 経過時間から非稼働時間を引く
            ACTUAL_VALUE=$(awk "BEGIN {printf \"%0.3f\", $ACTUAL_VALUE - $ACRESS_DATE * $NON_WORKING_HOUR_ACROSS_DATE}")
          fi

          echo ITEM_ID=${ITEM_ID} >> $GITHUB_ENV
          echo FIELD_ID=${FIELD_ID} >> $GITHUB_ENV
          echo CREATE_UNIXTIME=${CREATE_UNIXTIME} >> $GITHUB_ENV
          echo CLOSE_UNIXTIME=${CLOSE_UNIXTIME} >> $GITHUB_ENV
          echo ACTUAL_VALUE=${ACTUAL_VALUE} >> $GITHUB_ENV
      - name: Github ProjectsのPull Requestに紐付いているカードの実績値を更新
        run: |
          # https://docs.github.com/en/graphql/reference/mutations#updateprojectv2itemfieldvalue
          gh api graphql -f projectid="${PROJECT_ID}" -f itemid="${ITEM_ID}" -F fieldid="${FIELD_ID}" -f query="
          mutation UpdateProjectItemActual(\$projectid: ID!, \$itemid: ID!, \$fieldid: ID!) {
          	updateProjectV2ItemFieldValue(input: {
              projectId: \$projectid, itemId: \$itemid, fieldId: \$fieldid, value: { number: ${ACTUAL_VALUE} }
            }) {
          		projectV2Item {
          			id
          		}
          	}
          }"
```

ProjectIDを取得する部分は共通です。
APIのリクエスト数を節約したい場合はWorkflowを分けずに共通化しても良いでしょう。

ProjectsのCard一覧をpositionの降順で取得しているのがポイントです。
searchで指定できるfirstの値の100件がMAX値になっているため１回のRequestでは１００件ずつしか取得できません。
デフォルトでは昇順で取得してしまうのでProjectsに紐付いているCardが１００件超えるとPRのCardを取得出来ません。
新しい順に取得すればまあ大体１００件以内にはターゲットのCardはあるだろうという作りになっています。使用する際はそこらへんを気をつけてください。

# 終わりに

もう少し詳しく知りたい人はH.sakiさんが書いた以下の記事がよくまとまっているので、これを読んで大体の作りを理解すれば後はReferenceを読むだけでQuery/Mutation双方を使いこなせるようになると思います。

https://zenn.dev/hsaki/articles/github-graphql

更新したいFieldIDの取得などそのものズバリを一発で取得するのは難しいのでそこら辺のコツは必要です。
それでは今年も頑張って行きましょう！