はじめに
会社に勤めている方であれば、多くの人が月末になると何かしらの定型作業が発生すると思います。
- 勤怠レポート
- 領収書の提出
- その他の月末業務
しかし、他のタスクに埋もれてしまい、後回しにして忘れてしまった…そして、上司や他部署に迷惑をかけてしまった、なんてことは誰しも一度は経験があるのではないでしょうか。
かくいう私自身も月末処理を忘れがちな人間です。そこで、もう二度と月末処理を忘れないようにGitHub Actionsで仕組みを作りました。
今回は、その過程を紹介したいと思います。
とりあえずコードが見たい方
こちらでサンプルコードを公開してます!
※ 実際に書いたコードから記事用にWorkflowとTypeScriptのみを抜粋して配置しています。
要件定義
たちのチームでは、GitHub Projectsを使ってタスク管理をしています。一日の始まりと終わりにGitHub Projectsをチェックするのが習慣になっているので、月末処理もそこに登録してしまえば忘れることはないと考えました。
さらに、メンバー全員の月末処理のステータスが見えるようになれば、上司も確認しやすくて助かるんじゃないかと。
というわけで、以下の要件を設定しました。
- メンバー全員分の月末処理のIssueを作成する
- 作成したIssueをGitHub Projectsに登録する
-
GitHub Projectsに登録したIssueに対して、以下の設定を行う
- スプリント
- タスクカテゴリ
- 上記を毎月25日に自動実行する
実装
ざっくりと要件定義ができたので、ステップバイステップで実装していきます。わかりやすいように、各作業にコミットリンクを貼っておきます。
ベース作成
動作確認用のベースを作成し、main
ブランチにマージしておきます。
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を作成する
まずは、GitHub ActionsからIssueを作成するところからスタートします。
GitHub Marketplaceを見てみると、Issueを作成するアクションがいくつかありました。その中から、Starが多かったCreate Issueを選択しました。このアクションは、Matrixと併用することで、メンバー全員分のIssueを作成し、アサインを行うことができます。
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 }}
---
name: 月末処理
about: 月末処理用のissue templateです
title: "[月末処理]:{{ date | date('YYYY/MM') }}"
labels: ['non_auto_adding_project']
---
## タスクリスト
- [ ] 勤怠レポート
- [ ] 修正
- [ ] 提出
- [ ] 〇〇〇〇〇〇〇〇
- [ ] 〇〇〇〇〇〇〇〇
- [ ] 〇〇〇〇〇〇〇〇
### 補足
- 〇〇〇〇〇〇〇〇〇〇〇〇〇〇〇〇
- 〇〇〇〇〇〇〇〇〇〇〇〇〇〇〇〇
- 〇〇〇〇〇〇〇〇〇〇〇〇〇〇〇〇
Tips
GitHub Projectsでアイテムの自動追加機能を有効にしている場合は、除外設定を忘れずに行ってください。
「自動追加したほうが楽じゃん!」と思われるかもしれませんが、GitHub Projectsから特定のアイテムを探すのは意外と大変です。そのため、次のステップで明示的に追加することにしました。
私たちのチームでは自動追加機能を有効にしていたので、non_auto_adding_project
ラベルを除外設定に追加した上で、Issueに付与しました。
-label:non_auto_adding_project
作成したIssueをGitHub Projectsに登録する
Issueの作成ができたので、次はGitHub Projectsに登録します。
GitHub Projectsに対する操作
GitHub Projectsに対する操作に関しては、Marketplaceで探してみたんですが良いものが見つからず…。なので、GitHub APIを使って実装することにしました。
GitHubではREST APIとGraphQL APIの2つのAPIが提供されていますが、GitHub Projectsに対するエンドポイントがREST APIにはないので、GraphQL APIを使います。
どう実装するか?
GraphQL API
を使うことが決まったので、どのように実装するか考えていきます。結論から言うと、TypeScriptで処理を記述し、Workflowから呼び出すことにしました。
他の方法としては、Workflowに直接GraphQLを書くこともできます。実際に試してみましたが、手軽な反面、戻り値を受け取って何かしようとすると可読性が悪くなるため、その選択肢は見送りました。
事前準備
TypeScriptで実装することが決まったので、諸々の準備をしておきます。
① TypeScriptの実行環境を準備して、GitHub Actionsから呼び出せるようにする
+
+ - 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 }}
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と併用することで、コード補完が有効になります
③ 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の作成については、こちらを参考にしました。
④ GitHub Projectsのプロジェクトナンバーの確認
登録したいGitHub Projectsを開いてURLを確認します。最後にある数字がプロジェクトナンバーとなります。
# この場合、1がプロジェクトナンバーです
https://github.com/orgs/xxxx/projects/1
⑤ VSCodeの拡張機能の有効化
GraphQL: Language Feature Support
をインストールして、設定を終わらせておきます。
GraphQLのスキーマは以下からダウンロードできます。
ようやく本実装
もろもろの準備が終わったのでメインロジックを実装していきます。
まずは認証
GitHub APIクライアントを利用するための認証トークンを生成し、問題なく利用できるかを確認します。
- 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 }}
+ 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
を取得する
GraphQL APIでリソースを操作するためには、グローバルノードID
が必要です。repository
クエリを利用して、issue
とproject
のグローバルノードID
を一気に取得します。
- 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
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
}
}
}
`
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に登録する
グローバルノードID
の取得ができたら、addProjectV2ItemById
ミューテーションを利用して、IssueをGitHub Projectsに登録します。
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
}
}
}
`
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
を利用することで、効率よく検証を行うことができます。やはり手元ですぐに動かせる環境があると理解が進みます。
GitHub Projectsに登録したアイテムに対して各種設定を行う
無事にIssueをGitHub Projectsに登録できました。続いて、可視化のために以下のカスタムフィールドに対して設定を行っていきます。
- スプリント:
Current Sprint
- タスクカテゴリ:
チーム運営
カスタムフィールド情報を取得する
GitHub Projectsに設定したカスタムフィールドの情報は、field
という属性にアクセスすることで取得できます。
field
はUnion型
で定義されており、それぞれの型は以下のようになっています。
- スプリント:
projectv2iterationfield
- タスクカテゴリ:
ProjectV2SingleSelectField
issueAndProject
クエリを修正して、カスタムフィールドの情報を取得できるようにします。
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のフィールド設定を更新する
GitHub Projectsのカスタムフィールドをの値を更新するには、updateProjectV2ItemFieldValue
ミューテーションを利用します。先ほど取得したカスタムフィールドの情報を利用して値を更新します。
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
}
}
`
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日に自動実行する
最後の仕上げとして、毎月25日に自動実行するように設定します。
on:
+ schedule:
+ - cron: '0 0 25 * *'
workflow_dispatch:
inputs:
branch:
description: 'ブランチ名'
type: string
これで、毎月25日に各メンバーがアサインされた月末処理Issueが作成され、GitHub Projectsにも登録されるようになりました。スプリントも設定されているので、目につくこと間違いなしです。もうこれで月末処理を忘れることはなくなりました!
最後に
ここまで読んでいただいて、「カレンダーに定期予定を登録すれば良くない?」と思われた方もいるかもしれません。
まさにその通りですwww
他にもSlackの定期リマインドやノーコードツールなど、わざわざコードを書かなくてもいい方法は世の中に溢れています。
でもやっぱり、コードを書くのって楽しいですよね
私事ですが、1年ほど前に部署が変わりました。前の部署ではプロダクト開発をメインにやっていたのですが、今は開発組織全体のインフラのコスト最適化や監視基盤の整備をメインに行っています。もちろん、今の仕事も楽しいですが、前と比べてコードを書く機会が減ってしまい、少し寂しいと感じていました。
なので、息抜きついでに口実を作ってはスキマ時間にちょこちょこコードを書いています(笑)。他にもGitHub Actionsを使ったプチ仕組み化をやっているので、また記事にしたいと思います!
長文にお付き合いいただき、ありがとうございました。良い年末年始をお過ごしください!
謝罪
途中までindex.js
ファイルの配置を間違っていました...