Githubに標準搭載されているGithub ProjectsなのですがGithubのPull RequestとGithub Projectsへの連携についてはそのものズバリな記事も見つからず公式のリファレンスを見ても基本的なGitHub GraphQL API v4を理解した上でないと利用できないため手っ取り早く連携したい人向けに記事を書いていきます。
Personal Access Tokensの準備
Github ProjectsはOrganizationに紐づくためrepoだけではない比較的広い権限のPATが必要になります secrets.GITHUB_TOKEN
の権限だけでは実行できません。
具体的には以下の☑が付いている部分の権限が必要になります。
-
repo
- repo:status
- repo_deployment
- public_repo
- repo:invite
- securiry_events
-
write:packages
- read:packages
-
admin:org
- read:org
-
project
- 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
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
実績値の計算は日付を跨いだ場合に非稼働時間を省く計算をしています必要であれば適宜修正してください。
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値になっているため1回のRequestでは100件ずつしか取得できません。
デフォルトでは昇順で取得してしまうのでProjectsに紐付いているCardが100件超えるとPRのCardを取得出来ません。
新しい順に取得すればまあ大体100件以内にはターゲットのCardはあるだろうという作りになっています。使用する際はそこらへんを気をつけてください。
終わりに
もう少し詳しく知りたい人はH.sakiさんが書いた以下の記事がよくまとまっているので、これを読んで大体の作りを理解すれば後はReferenceを読むだけでQuery/Mutation双方を使いこなせるようになると思います。
更新したいFieldIDの取得などそのものズバリを一発で取得するのは難しいのでそこら辺のコツは必要です。
それでは今年も頑張って行きましょう!