GithubActionsを使って月末処理を忘れないようにする

## はじめに

会社に勤めている方であれば、多くの人が月末になると何かしらの定型作業が発生すると思います｡

- 勤怠レポート
- 領収書の提出
- その他の月末業務

しかし、他のタスクに埋もれてしまい、後回しにして忘れてしまった…そして、上司や他部署に迷惑をかけてしまった、なんてことは誰しも一度は経験があるのではないでしょうか。

かくいう私自身も月末処理を忘れがちな人間です。そこで、もう二度と月末処理を忘れないようにGitHub Actionsで仕組みを作りました｡

今回は、その過程を紹介したいと思います。

## とりあえずコードが見たい方

こちらでサンプルコードを公開してます！

https://github.com/masato1224/MySamples/pull/1

※ 実際に書いたコードから記事用にWorkflowとTypeScriptのみを抜粋して配置しています｡

## 要件定義

たちのチームでは、GitHub Projectsを使ってタスク管理をしています。一日の始まりと終わりにGitHub Projectsをチェックするのが習慣になっているので、月末処理もそこに登録してしまえば忘れることはないと考えました。

さらに、メンバー全員の月末処理のステータスが見えるようになれば、上司も確認しやすくて助かるんじゃないかと。

というわけで、以下の要件を設定しました。

- **メンバー全員分の月末処理のIssueを作成する**
- **作成したIssueをGitHub Projectsに登録する**
- **GitHub Projectsに登録したIssueに対して、以下の設定を行う**
  - **スプリント**
  - **タスクカテゴリ**
- **上記を毎月25日に自動実行する**

## 実装

ざっくりと要件定義ができたので、ステップバイステップで実装していきます。わかりやすいように、各作業にコミットリンクを貼っておきます。

## ベース作成

[作業commit](https://github.com/masato1224/MySamples/pull/1/commits/ebb859d9bf85413bd05ca7551ddf11cdc46f8c47)

動作確認用のベースを作成し、`main`ブランチにマージしておきます。


```yaml:.github/workflows/register_monthly_task.yml
name: 月末処理のisseu作成とプロジェクトへのリンク

on:
  workflow_dispatch:
    inputs:
      branch:
        description: 'ブランチ名'
        type: string

jobs:
  register_monthly_issue:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ inputs.branch || github.ref }}
```

## メンバー全員分の月末処理のissueを作成する

[作業commit](https://github.com/masato1224/MySamples/pull/1/commits/1b1e0b56ebd059f1b010f2d4b6833841582b65e1)

まずは、GitHub ActionsからIssueを作成するところからスタートします。

[GitHub Marketplace](https://github.com/marketplace)を見てみると、Issueを作成するアクションがいくつかありました。その中から、Starが多かった[Create Issue](https://github.com/marketplace/actions/create-an-issue)を選択しました。このアクションは、[Matrix](https://docs.github.com/ja/actions/writing-workflows/choosing-what-your-workflow-does/running-variations-of-jobs-in-a-workflow)と併用することで、メンバー全員分のIssueを作成し、アサインを行うことができます。

https://github.com/marketplace/actions/create-an-issue

```diff_yaml:.github/workflows/register_monthly_task.yml
jobs:
  register_monthly_issue:
+   strategy:
+     matrix:
+       assignee: [hoge, fuga]
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ inputs.branch || github.ref }}

+    - uses: JasonEtco/create-an-issue@v2.9.2
+      id: create_issue
+      env:
+        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+      with:
+        filename: .github/ISSUE_TEMPLATE/monthly_task.md
+        assignees: ${{ matrix.assignee }}
```

```md:.github/ISSUE_TEMPLATE/monthly_task.md
---
name: 月末処理
about: 月末処理用のissue templateです
title: "[月末処理]:{{ date | date('YYYY/MM') }}"
labels: ['non_auto_adding_project']
---

## タスクリスト

- [ ] 勤怠レポート
  - [ ] 修正
  - [ ] 提出
- [ ] 〇〇〇〇〇〇〇〇
- [ ] 〇〇〇〇〇〇〇〇
- [ ] 〇〇〇〇〇〇〇〇

### 補足

- 〇〇〇〇〇〇〇〇〇〇〇〇〇〇〇〇
- 〇〇〇〇〇〇〇〇〇〇〇〇〇〇〇〇
- 〇〇〇〇〇〇〇〇〇〇〇〇〇〇〇〇

```

### Tips

GitHub Projectsで[アイテムの自動追加機能](https://docs.github.com/ja/issues/planning-and-tracking-with-projects/automating-your-project/adding-items-automatically)を有効にしている場合は、除外設定を忘れずに行ってください。

「自動追加したほうが楽じゃん！」と思われるかもしれませんが、GitHub Projectsから特定のアイテムを探すのは意外と大変です。そのため、次のステップで明示的に追加することにしました。

私たちのチームでは自動追加機能を有効にしていたので、`non_auto_adding_project`ラベルを除外設定に追加した上で、Issueに付与しました。

```sh
-label:non_auto_adding_project
```

## 作成したIssueをGitHub Projectsに登録する

Issueの作成ができたので、次はGitHub Projectsに登録します。

### GitHub Projectsに対する操作

GitHub Projectsに対する操作に関しては、Marketplaceで探してみたんですが良いものが見つからず…。なので、GitHub APIを使って実装することにしました。

GitHubでは[REST API](https://docs.github.com/ja/rest?apiVersion=2022-11-28)と[GraphQL API](https://docs.github.com/ja/graphql)の2つのAPIが提供されていますが、GitHub Projectsに対するエンドポイントがREST APIにはないので、GraphQL APIを使います。

### どう実装するか？

`GraphQL API`を使うことが決まったので、どのように実装するか考えていきます。結論から言うと、TypeScriptで処理を記述し、Workflowから呼び出すことにしました。

他の方法としては、Workflowに直接GraphQLを書くこともできます。実際に試してみましたが、手軽な反面、戻り値を受け取って何かしようとすると可読性が悪くなるため、その選択肢は見送りました。

### 事前準備

[作業commit](https://github.com/masato1224/MySamples/pull/1/commits/2b65cb4ce9f4a5274c4548f49bc26f97451a0b64)

TypeScriptで実装することが決まったので、諸々の準備をしておきます。

#### ① TypeScriptの実行環境を準備して、GitHub Actionsから呼び出せるようにする

```diff_yaml:.github/workflows/register_monthly_task.yml
+
+     - name: Setup Node.js
+       uses: actions/setup-node@v4
+       with:
+         node-version: '20'
+
+     - name: Cache Node.js modules
+       uses: actions/cache@v4
+       with:
+         path: ./node_modules
+         key: ${{ runner.os }}-node-${{ hashFiles('./yarn.lock') }}
+         restore-keys: |
+           ${{ runner.os }}-node-
+
+     - name: install dependencies
+       run: yarn install --frozen-lockfile
+
+     - name: execute
+       run: yarn tsx src/index.ts
+       env:
+         ISSUE_NUMBER: ${{ steps.create_issue.outputs.number }}
```

```ts: src/index.ts
const main = async () => {

  if (!process.env.GITHUB_REPOSITORY) {
    throw new Error('GITHUB_REPOSITORY environment variable is not defined')
  }
  if (!process.env.ISSUE_NUMBER) {
    throw new Error('ISSUE_NUMBER environment variable is not defined')
  }

  const [ownerName, repoName] = process.env.GITHUB_REPOSITORY.split('/')
  const issueNumber = Number(process.env.ISSUE_NUMBER)

  console.log('ownerName:', ownerName)
  console.log('repoName:', repoName)
  console.log('issueNumber:', issueNumber)
}

await main()

```

#### ②ライブラリ選定

TypeScriptで効率的に実装を行うために、いくつかのライブラリを利用します。

- **octokit/action.js**
  - GitHub API クライアント
  - 認証やエンドポイントへのアクセスを簡単に行ってくれます
- **apollographql/graphql-tag**
  - JavaScriptのテンプレートリテラルをAST（抽象構文木）に変換します
  - Syntax highlightingが有効になり、GraphQLが見やすくなります
  - 後述するGraphQL: Language Feature Supportと併用することで、コード補完が有効になります

https://github.com/octokit/action.js

https://github.com/apollographql/graphql-tag

#### ③ GitHub Appsの作成

GitHub Projectsを操作するためには、`PAT`（Personal Access Token）または`GitHub App`を用意してアクセス権限を与える必要があります。`PAT`を利用することは管理面で好ましくないため、`GitHub App`を作成し、アクセス権限を与えることにしました。

必要なアクセス権は`issues`と`organization projects || repository projects`です。`APP ID`と`PEM`を取得し、リポジトリの`Actions secrets and variables`に登録しておきます。

GitHub Appの作成については、[こちら](https://docs.github.com/ja/apps/creating-github-apps/about-creating-github-apps/about-creating-github-apps)を参考にしました｡


#### ④ GitHub Projectsのプロジェクトナンバーの確認

登録したいGitHub Projectsを開いてURLを確認します。最後にある数字がプロジェクトナンバーとなります。

```sh
# この場合、1がプロジェクトナンバーです
https://github.com/orgs/xxxx/projects/1
```

#### ⑤ VSCodeの拡張機能の有効化

`GraphQL: Language Feature Support`をインストールして、設定を終わらせておきます。

https://marketplace.visualstudio.com/items?itemName=GraphQL.vscode-graphql

GraphQLのスキーマは以下からダウンロードできます。

https://docs.github.com/ja/graphql/overview/public-schema


### ようやく本実装

もろもろの準備が終わったのでメインロジックを実装していきます｡

#### まずは認証

[作業commit](https://github.com/masato1224/MySamples/pull/1/commits/afdcafd3fcae5aa50f5c46d8877d2ccb8b9c790a)

GitHub APIクライアントを利用するための認証トークンを生成し、問題なく利用できるかを確認します。

```diff_yaml:.github/workflows/register_monthly_task.yml
      - name: install dependencies
        run: yarn install --frozen-lockfile

+     - name: Generate token
+       id: generate-token
+       uses: actions/create-github-app-token@v1
+       with:
+         app-id: ${{ vars.APP_ID }}
+         private-key: ${{ secrets.APP_PEM }}

      - name: execute
        run: yarn tsx src/index.ts
        env:
          ISSUE_NUMBER: ${{ steps.create_issue.outputs.number }}
+         GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
```

```diff_typescript:src/index.ts
+ import { Octokit } from '@octokit/action'

const main = async () => {
...
+ const octokit = new Octokit()
+ const response: { viewer: { login: string } } = await octokit.graphql(`{
+   viewer {
+     login
+   }
+ }`)
+ const {
+   viewer: { login }
+ } = response
+  console.log(`Hello, ${login}!`)
}
```


#### issueとprojectの`グローバルノードID`を取得する

[作業commit](https://github.com/masato1224/MySamples/pull/1/commits/cff93a69c412a740fd6b2cf6b1f490dfafe4f799)

GraphQL APIでリソースを操作するためには、`グローバルノードID`が必要です。`repository`クエリを利用して、`issue`と`project`の`グローバルノードID`を一気に取得します。

https://docs.github.com/ja/graphql/guides/using-global-node-ids

https://docs.github.com/ja/graphql/reference/queries#repository

```diff_yaml:.github/workflows/register_monthly_task.yml
      - name: execute
        run: yarn tsx src/index.ts
        env:
          ISSUE_NUMBER: ${{ steps.create_issue.outputs.number }}
          GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
+         PROJECT_NUMBER: 1
```

```typescript:src/graphql/query/issueAndProject.ts
import { gql } from 'graphql-tag'

export type ResIssueAndProject = {
  repository: {
    issue: {
      id: string
    }
    projectV2: {
      id: string
    }
  }
}

export const issueAndProject = gql`
  query ($ownerName: String!, $repoName: String!, $issueNumber: Int!, $projectNumber: Int!) {
    repository(owner: $ownerName, name: $repoName) {
      issue(number: $issueNumber) {
        id
      }
      projectV2(number: $projectNumber) {
        id
      }
    }
  }
`
```

```diff_typescript:src/index.ts
import { Octokit } from '@octokit/action'
+ import { print } from 'graphql'
+ import { issueAndProject, ResIssueAndProject } from './graphql/query/issueAndProject.js'

...
  if (!process.env.ISSUE_NUMBER) {
    throw new Error('ISSUE_NUMBER environment variable is not defined')
  }
+  
+  if (!process.env.PROJECT_NUMBER) {
+   throw new Error('PROJECT_NUMBER environment variable is not defined')
+ }

  const [ownerName, repoName] = process.env.GITHUB_REPOSITORY.split('/')
  const issueNumber = Number(process.env.ISSUE_NUMBER)
+ const projectNumber = Number(process.env.PROJECT_NUMBER)

  const octokit = new Octokit()

+ const issueAndProjectData = await fetchIssueAndProject(octokit, ownerName, repoName, issueNumber, projectNumber)
-  const response: { viewer: { login: string } } = await octokit.graphql(`{
-    viewer {
-      login
-    }
-  }`)
-  const {
-    viewer: { login }
-  } = response
-  console.log(`Hello, ${login}!`)
}

+ const fetchIssueAndProject = async (
+   octokit: Octokit,
+   ownerName: string,
+   repoName: string,
+   issueNumber: number,
+   projectNumber: number
+ ) => {
+   const response = await octokit.graphql<ResIssueAndProject>(print(issueAndProject), {
+     ownerName,
+     repoName,
+     issueNumber,
+     projectNumber
+   })
+   return response
+ }

```


#### issueをGithubProjectsに登録する

[作業commit](https://github.com/masato1224/MySamples/pull/1/commits/bb1d9e4c83fa92c10934a360dafb88ce7d938329
)

`グローバルノードID`の取得ができたら、`addProjectV2ItemById`ミューテーションを利用して、IssueをGitHub Projectsに登録します。

https://docs.github.com/ja/graphql/reference/mutations#addprojectv2itembyid

```ts:src/graphql/mutation/linkIssueToProject.ts
import { gql } from 'graphql-tag'

export type ResLinkIssueToProject = {
  addProjectV2ItemById: {
    item: {
      id: string
      type: string
    }
  }
}

export const linkIssueToProject = gql`
  mutation ($projectId: ID!, $issueId: ID!) {
    addProjectV2ItemById(input: { projectId: $projectId, contentId: $issueId }) {
      item {
        id
        type
      }
    }
  }
`
```

```diff_typescript:src/index.ts
import { issueAndProject, ResIssueAndProject } from './graphql/query/issueAndProject.js'
+ import { linkIssueToProject, ResLinkIssueToProject } from './graphql/mutation/linkIssueToProject.js'

const main = async () => {
...

  const issueAndProjectData = await fetchIssueAndProject(octokit, ownerName, repoName, issueNumber, projectNumber)
+ const projectItem = await AddProjectItem(octokit, issueAndProjectData)
}

+ const AddProjectItem = async (octokit: Octokit, issueAndProjectData: ResIssueAndProject) => {
+   const item = await octokit.graphql<ResLinkIssueToProject>(print(linkIssueToProject), {
+     projectId: issueAndProjectData.repository.projectV2.id,
+     issueId: issueAndProjectData.repository.issue.id
+   })
+   return item
+ }
```

### Tips

GraphQLのクエリやミューテーションを検証する場合、`Altair GraphQL クライアント IDE`を利用することで、効率よく検証を行うことができます。やはり手元ですぐに動かせる環境があると理解が進みます。

https://docs.github.com/ja/graphql/guides/using-the-explorer#using-the-altair-graphql-client-ide

## GitHub Projectsに登録したアイテムに対して各種設定を行う

無事にIssueをGitHub Projectsに登録できました。続いて、可視化のために以下のカスタムフィールドに対して設定を行っていきます。

- スプリント: `Current Sprint`
- タスクカテゴリ: `チーム運営`

#### カスタムフィールド情報を取得する

[作業commit](https://github.com/masato1224/MySamples/pull/1/commits/e890ca386d00e9522e965d98d06f00d1220f3d6a)

GitHub Projectsに設定したカスタムフィールドの情報は、`field`という属性にアクセスすることで取得できます。

`field`は`Union型`で定義されており､それぞれの型は以下のようになっています｡
- スプリント: `projectv2iterationfield`
- タスクカテゴリ: `ProjectV2SingleSelectField`

`issueAndProject`クエリを修正して、カスタムフィールドの情報を取得できるようにします。

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

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

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

```diff_typescript:src/graphql/query/issueAndProject.ts
import { gql } from 'graphql-tag'

export type ResIssueAndProject = {
  repository: {
    issue: {
      id: string
    }
    projectV2: {
      id: string
+     sprint_field: {
+       id: string
+       name: string
+       dataType: string
+       configuration: {
+         iterations: {
+           id: string
+           title: string
+           startDate: string
+         }[]
+       }
+     }
+     category_field: {
+       id: string
+       name: string
+       options: {
+         id: string
+         name: string
+       }[]
+     }
    }
  }
}

export const issueAndProject = gql`
  query ($ownerName: String!, $repoName: String!, $issueNumber: Int!, $projectNumber: Int!) {
    repository(owner: $ownerName, name: $repoName) {
      issue(number: $issueNumber) {
        id
      }
      projectV2(number: $projectNumber) {
        id
+       sprint_field: field(name: "Sprint") {
+         ... on ProjectV2IterationField {
+           id
+           name
+           dataType
+           configuration {
+             iterations {
+               id
+               title
+               startDate
+             }
+           }
+         }
+       }
+       category_field: field(name: "Category") {
+         ... on ProjectV2SingleSelectField {
+           id
+           name
+           options {
+             id
+             name
+           }
+         }
+       }
      }
    }
  }
`

```


### GitHub Projectsのフィールド設定を更新する

[作業commit](https://github.com/masato1224/MySamples/pull/1/commits/58a0a144fdb473a75489680cccb6b6b56e358355)

GitHub Projectsのカスタムフィールドをの値を更新するには、`updateProjectV2ItemFieldValue`ミューテーションを利用します。先ほど取得したカスタムフィールドの情報を利用して値を更新します。

https://docs.github.com/ja/graphql/reference/mutations#updateprojectv2itemfieldvalue

```typescript:src/graphql/mutation/updateProjectItem.ts
import { gql } from 'graphql-tag'

export type ResUpdateProjectItem = {
  updateSprint: {
    clientMutationId: string
  }
  updateCategory: {
    clientMutationId: string
  }
}

export const updateProjectItem = gql`
  mutation (
    $project_id: ID!
    $itemId: ID!
    $sprintFieldId: ID!
    $iterationId: String
    $categoryFieldId: ID!
    $singleSelectOptionId: String
  ) {
    updateSprint: updateProjectV2ItemFieldValue(
      input: { projectId: $project_id, itemId: $itemId, fieldId: $sprintFieldId, value: { iterationId: $iterationId } }
    ) {
      clientMutationId
    }
    updateCategory: updateProjectV2ItemFieldValue(
      input: {
        projectId: $project_id
        itemId: $itemId
        fieldId: $categoryFieldId
        value: { singleSelectOptionId: $singleSelectOptionId }
      }
    ) {
      clientMutationId
    }
  }
`
```

```diff_typescript:src/index.ts
import { linkIssueToProject, ResLinkIssueToProject } from './graphql/mutation/linkIssueToProject.js'
+ import { ResUpdateProjectItem, updateProjectItem } from './graphql/mutation/updateProjectItem.js'

const main = async () => {
  ...
  const issueAndProjectData = await fetchIssueAndProject(octokit, ownerName, repoName, issueNumber, projectNumber)
  const projectItem = await AddProjectItem(octokit, issueAndProjectData)
+  await updateIssueAttribute(octokit, issueAndProjectData, projectItem)
}

+ const updateIssueAttribute = async (
+   octokit: Octokit,
+   issueAndProjectData: ResIssueAndProject,
+   projectItem: ResLinkIssueToProject
+ ) => {
+   const optionsId = issueAndProjectData.repository.projectV2.category_field.options.find(
+     field => field.name === 'チーム運営'
+   )?.id
+   await octokit.graphql<ResUpdateProjectItem>(print(updateProjectItem), {
+     project_id: issueAndProjectData.repository.projectV2.id,
+     itemId: projectItem.addProjectV2ItemById.item.id,
+     sprintFieldId: issueAndProjectData.repository.projectV2.sprint_field.id,
+     iterationId: issueAndProjectData.repository.projectV2.sprint_field.configuration.iterations[0].id,
+     categoryFieldId: issueAndProjectData.repository.projectV2.category_field.id,
+     singleSelectOptionId: optionsId
+   })
+ }
```

## 毎月25日に自動実行する

[作業commit](https://github.com/masato1224/MySamples/pull/1/commits/0a359961654bffb584cdc785f2c990eff36f87a0)

最後の仕上げとして、毎月25日に自動実行するように設定します。

```diff_yaml:.github/workflows/register_monthly_task.yml
on:
+ schedule:
+   - cron: '0 0 25 * *'
  workflow_dispatch:
    inputs:
      branch:
        description: 'ブランチ名'
        type: string

```


これで、毎月25日に各メンバーがアサインされた月末処理Issueが作成され、GitHub Projectsにも登録されるようになりました｡スプリントも設定されているので、目につくこと間違いなしです。もうこれで月末処理を忘れることはなくなりました！

## 最後に

ここまで読んでいただいて、「カレンダーに定期予定を登録すれば良くない？」と思われた方もいるかもしれません。

まさにその通りですwww


他にもSlackの定期リマインドやノーコードツールなど、わざわざコードを書かなくてもいい方法は世の中に溢れています。

でもやっぱり、コードを書くのって楽しいですよね :v::v::v:

私事ですが、1年ほど前に部署が変わりました。前の部署ではプロダクト開発をメインにやっていたのですが、今は開発組織全体のインフラのコスト最適化や監視基盤の整備をメインに行っています。もちろん、今の仕事も楽しいですが、前と比べてコードを書く機会が減ってしまい､少し寂しいと感じていました｡

なので、息抜きついでに口実を作ってはスキマ時間にちょこちょこコードを書いています（笑）。他にもGitHub Actionsを使ったプチ仕組み化をやっているので、また記事にしたいと思います！

長文にお付き合いいただき、ありがとうございました。良い年末年始をお過ごしください！

## 謝罪

途中まで`index.js`ファイルの配置を間違っていました...

https://github.com/masato1224/MySamples/pull/1/commits/3b3889c2661af02a3ef3c9c3e8374fd073250301