GitHub Projects で開発環境に投入するブランチをActionsで管理してみる

こんにちは。

(ニコニコ)プレミアム課金開発チームに所属しております @ingen084 です。

今までは [一昨年](https://qiita.com/ingen084/items/17cc2be96727271061a8)、[昨年](https://qiita.com/ingen084/items/73ae7233b8c0c45ce359) と改善やリファクタのお話をしてきましたが今年はあんまり関係のないお話です。

現在弊チームでは CI/CD ツール等見直しを行っており、しばらく利用していた Jenkins から GitHub Actions へ移行を検討しています。
そこで開発環境へのデプロイにちょっと代わった方法を思いつきチャレンジしてみたというお話です。

**まだ本格運用は始めていません。あくまで実装してみたという記事ですのでご了承ください。**

# 完成したもの

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100304/333824a3-d286-c8ca-2434-5bfbd4005a60.png)

実際の環境のスクショのためぼかしだらけですみません。
PullRequest を作成すると自動でその PR のカードが追加されます。状況に応じてそのカードを `dev投入対象` と `dev優先投入対象` のカラムに入れると、その PR が開発環境へのデプロイ用ブランチとして自動でマージされて開発環境に投入されます。
まだアイテムの移動をフックする仕組みを作ってないのでデプロイの開始は手動で行うようになっていますが、この仕組み自体の使い勝手も見ながら調整していこうと思っています。

# 経緯

弊チームでは今のところ開発環境は(完全に動作する物は)1環境で運用されています。
1つになっている主な理由は決済代行会社との接続等があります。

今までは Jenkins のジョブを実行するときにマージする対象のブランチをカンマで区切って文字列で送っていました。(それでも、以前はマージする仕組みがなくデプロイするためのブランチを作ったりしていたので大きな改善といえます。)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100304/d1341e55-26d6-828f-bdc4-2803d4eca436.png)

## 課題

しかし新機能の追加やリニューアル･リファクタなどが活発になり開発環境では常に入れておきたいブランチが増えてきており、デプロイのたびに投入すべきブランチの把握やコピペが煩雑になっていました。コピペしたブランチの1つはすでにマージ済みでブランチが存在しないためエラーになってしまう、といったこともあります。
もっと規模が大きいチームであればプロダクトの採用や内製ツールの作成なども検討できますが、まだそれほどの規模ではないためコストをかけずにわかりやすくする方法を探していました。

## [GitHub Projects](https://docs.github.com/ja/issues/planning-and-tracking-with-projects/learning-about-projects/about-projects)

GitHub には Projects と呼ばれる Issue や PullRequest 等をボードで管理できる機能があります。
プロジェクトは組織やユーザー単位での管理になるため、複数のリポジトリの PR を入れることができます。

これを利用し、

- PR の作成に合わせ自動登録
    - マージやクローズに合わせて自動削除
- 投入のためのラベルがついたカンバンボード上で PR のカードを移動
- 状況に合わせてビルド時にマージ

みたいな感じにできると実質追加コストなしで投入する対象となる PR の判別がわかりやすくなりそうと考えました。

# 実装

## PR の状態に応じて自動で Projects にカードを追加する

[アイテムを自動的に追加する](https://docs.github.com/ja/issues/planning-and-tracking-with-projects/automating-your-project/adding-items-automatically) と [アイテムを自動的にアーカイブする](https://docs.github.com/ja/issues/planning-and-tracking-with-projects/automating-your-project/using-the-built-in-automations) を参考に、PullRequest が作成されたときにカードを作成、閉じられたときにアーカイブするように設定を追加します。

![2bac98cdb6b5dbed4590d3ec2005d5b9.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100304/42ff09a9-2b81-a89e-f7f1-999e9ee8d492.png)
アーカイブは即時に行わせるため `is:pr is:closed,merged` のみでよいです。

しかし、作成時にカードを追加するときはリポジトリ単位に作成する必要があるため、対象となるリポジトリの数が多いとプランによっては制限に引っかかってしまいます。
そこで、今回は Actions のワークフローからカードを追加できるようにもしています。
け、決して自動化できることを知らなかった訳じゃないですよ！(震え声)

## GraphQL API のコード生成

Projects の操作は [GraphQL API](https://docs.github.com/ja/graphql) から行います。
複雑な操作は Actions のワークフローに直接書いて行うとしんどいので、 action 自体を TypeScript で作ることにしました。
https://github.com/actions/typescript-action をベースに GraphQL のコード生成を行いたいため `@graphql-codegen` も導入しておきます。

今回使う GraphQL のクエリ(と mutation)は

```graphql:各操作で使用する project id の取得
query getProjectId($org: String!, $number: Int!) {
  organization(login: $org){
    projectV2(number: $number) {
      id
    }
  }
}
```


```graphql:カードの追加
mutation addCard($project:ID!, $pr:ID!) {
  addProjectV2ItemById(input: {projectId: $project, contentId: $pr}) {
    item {
      id
    }
  }
}
```

```graphql:カードの削除
mutation deleteCard($projectId: ID!, $itemId: ID!) {
  deleteProjectV2Item(input: {projectId: $projectId, itemId: $itemId}) {
    deletedItemId
  }
}
```

```graphql:カードの取得
query getCards($org: String!, $number: Int!) {
  organization(login: $org) {
    projectV2(number: $number) {
      id
      items(first: 100) {
        nodes {
          id, type, fieldValueByName(name: "Status") {
            __typename
            ... on ProjectV2ItemFieldSingleSelectValue {
              name
            }
          },
          content {
            __typename
            ... on PullRequest {
              headRepository {
                nameWithOwner
              },
              number,
              headRefName
            }
          }
        }
      }
    }
  }
}
```

の4つです。
テキトーに設定して

```typescript:src/codegen.ts
import type { CodegenConfig } from "@graphql-codegen/cli"

const config: CodegenConfig = {
  overwrite: true,
  schema: "https://docs.github.com/public/schema.docs.graphql",
  documents: "graphql/**/*.graphql",
  generates: {
    "src/generated/graphql.ts": {
      plugins: [
        "typescript",
        "typescript-operations",
        "typescript-graphql-request",
      ],
      config: {
        enumsAsTypes: true,
        avoidOptionals: true,
      },
    },
  },
}

export default config
```

コードを生成します。

```shell
graphql-codegen --config src/codegen.ts
```

## Projects の操作

とりあえず一旦雑に作ることを目的に、PR のオープン･クローズの際には追加･削除を、それ以外の場合にはマージ対象となるブランチを取得できるようにしてみます。

```typescript
// パラメータの取得
const token = core.getInput("token", { required: true })
const organization = core.getInput("organization", { required: true })
const projectNumber = Number(core.getInput("project-number", { required: true }))

// GraphQL クライアントの作成
const graphQLClient = new GraphQLClient("https://api.github.com/graphql", {
  headers: {
    Authorization: `Bearer ${token}`,
  },
})
const qlClient = getSdk(graphQLClient)

// PR の場合はイベント同期
if (github.context.eventName == "pull_request") {
  if (!github.context.payload.pull_request) return
  // プロジェクトIDを取得
  const fields = await qlClient.getProjectId({org: organization, number: projectNumber})

  switch (github.context.payload.action) {
    // 開かれたときは projects に追加
    case "opened":
    case "reopened":
      qlClient.addCard({project: fields.organization?.projectV2?.id ?? "", pr: github.context.payload.pull_request.node_id});
      break;
    
    // 閉じられたときは削除
    case "closed":
      // カードをリストアップしてイベントを発生させた PullRequest の物をリストアップ
      const cards = (await qlClient.getCards({org: organization, number: projectNumber}))
        .organization?.projectV2?.items.nodes?.filter(n =>
          n?.type == "PULL_REQUEST" && n.content?.__typename == "PullRequest" &&
          n.content.number == github.context.payload.pull_request?.number && n.content.headRepository?.nameWithOwner == github.context.payload.repository?.full_name
        )
      // すべて削除
      await Promise.all(cards?.map(async c => {
        await qlClient.deleteCard({ projectId: fields.organization?.projectV2?.id ?? "", itemId: c?.id ?? "" })
      }) ?? []);
      break;
  }
  return
}

// それ以外の時にはブランチ名収集モード
const targetRepositoryName = core.getInput("target-repository-name", { required: true })
const priorityTargetStatusName = core.getInput("priority-target-status-name", { required: true })
const targetStatusName = core.getInput("target-status-name", { required: true })

// パラメータに記載されているリポジトリの PR のカードを取得
// (雑実装のためカードが100件以上になると全部取得できなくなる、、、)
const cards = (await qlClient.getCards({org: organization, number: projectNumber}))
.organization?.projectV2?.items.nodes?.filter(n =>
  n?.type == "PULL_REQUEST" && n.content?.__typename == "PullRequest" &&
  n.content.headRepository?.nameWithOwner == targetRepositoryName
)

// 優先投入対象のカードを確認
const priorityBranches = cards
  ?.map(n => (n?.fieldValueByName?.__typename == "ProjectV2ItemFieldSingleSelectValue" && n.fieldValueByName.name == priorityTargetStatusName && n.content?.__typename == "PullRequest") ? n.content.headRefName : null)
  .filter(b => b !== null)
// 存在すればそれを返す
if ((priorityBranches?.length ?? 0) > 0) {
  core.info('優先投入対象を利用しました')
  core.setOutput('branches', priorityBranches)
  return
}

// 投入対象のカードを確認
const branches = cards
  ?.map(n => (n?.fieldValueByName?.__typename == "ProjectV2ItemFieldSingleSelectValue" && n.fieldValueByName.name == targetStatusName && n.content?.__typename == "PullRequest") ? n.content.headRefName : null)
  .filter(b => b !== null)
core.info('優先投入対象は利用していません')
core.setOutput('branches', branches)
```

で、このパラメータ情報を `action.yml` に書けば action は完成です。

```yml:action.yml
name: 'manage-projectv2-for-pr'
description: 'PullRequestに対してのProjectV2の操作をするヤツ'
inputs:
  token:
    description: 'GitHubのAPIトークン'
    required: true
  organization:
    description: '組織'
    required: true
  project-number:
    description: 'プロジェクトID'
    required: true
  target-repository-name:
    description: '取得モード時のターゲットとなるリポジトリ名(フル)'
  target-status-name:
    description: '取得モード時のターゲットとなるステータス名'
  priority-target-status-name:
    description: '取得モード時の優先ターゲットとなるステータス名'
runs:
  using: 'node16'
  main: 'dist/index.js'
```

これを実際にプッシュして実行させるときはビルドしてからコミットすることを忘れないようにしましょう、何も考えていなかった僕は2時間無駄にしました。

## PullRequest の同期

PR Actions を使用して同期させたい各リポジトリの actions に PR のイベントで実行される actions のワークフローを書きます。
もちろんですが Projects の機能を使用して自動化する場合は必要ありません。

```yml:sync-pr.yml
name: Sync PullRequest Item

on:
  pull_request:
    types: [opened, reopened, closed]

jobs:
  sync-pullrequest:
    name: Sync PullRequest Item
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v3
      - name: generate token
        id: generate_token
        uses: actions/create-github-app-token@v1
        with:
          app-id: ${{ vars.HOGE_APP_ID }}
          private-key: ${{ secrets.HOGE_PRIVATE_KEY }}
      - name: sync item
        uses: hoge/manage-projectv2-for-pr@master
        with:
          token: ${{ steps.generate_token.outputs.token }}
          organization: hogehoge
          project-number: 1
```

これで指定した project-number に対して PullRequest のカードが同期されるようになりました。
**Projects はリポジトリ外のリソースのため GitHub Apps などでトークンを取得しておく必要がある点に注意してください。** organization 全体に secrets などを設定できるようになったのでおすすめです。

## マージ(してビルド)

```yml
- name: Checkout hoge repository
  uses: actions/checkout@v3
  with:
    fetch-depth: 0
    repository: hoge
    path: hoge
    ref: master
    token: ${{ token }}
- name: Get nanka branches
  uses: hoge/manage-projectv2-for-pr@master
  id: branches
  with:
    token: ${{ token }}
    organization: hoge
    project-number: 1
    target-repository-name: hoge/hoge
    priority-target-status-name: dev優先投入対象
    target-status-name: dev投入対象
- name: Merge branches
  if: steps.branches.outputs.branches != '[]'
  working-directory: hoge
  run: |
    git config user.name  "actions"
    git config user.email "actions@github.com"
    git fetch
    git merge origin/${{ join(fromJson(steps.branches.outputs.branches), ' origin/') }}
```

ビルド処理は省きますが、このようにさっき作った action にパラメータを付与して呼び出し、その結果が存在すれば適当に git コマンドでマージします。
あとはそのディレクトリを煮るなり焼くなりするだけです。

### 注意すべきポイント

- `actions/checkout` で `fetch-depth` を `0` にしないと過去のコミットが存在しない状態になるためマージ対象が同じ親を持たない状態として扱われてしまいマージすることができない
- GitHub のトークンを取得するときにデフォルトでは実行中のリポジトリのみにしか許可が与えられていないトークンが取得される
    - この状態では各カードが `REDACTED` というレスポンスになりブランチ名などが取得できなくなってしまいますので、しっかりリポジトリを含む該当のプロジェクトを持っているユーザーを指定してトークンを取得するようにしておきましょう。(僕はこれに気付かず1日消し飛ばしました)

# 課題

とりあえずこれで雑にマージまではできるようになったのですが、ぱっと見便利(個人の感想)ですが、問題はいっぱいあります。

- dev 環境が複数できたらどうする？
- いくつかのブランチを組み合わせてプリセット作りたくなったらどうする？
- 投入時にコンフリクト起こしたらわざわざ見に行く必要がある
- ブランチをプッシュした後 PullRequest を作成しなければならない

うーん厳しいかも！機能的にはカンマ区切りの頃から大きく変わってはないはずなので、実際に少しずつ利用するようにしていって様子や使い勝手を見ていきたいと思います。
(さっさとコンテナ化して複数の環境をさっと建てられるようにした方が良い気もしてきた。)

# さいごに

かなり雑にはなってしまいましたが、GitHub Projects で省コスト？で投入対象となるブランチの管理ができるようにしてみました。

これぐらいのネタなら誰でも思いつきそうだな！？そもそもPR出した時点でdev用のブランチにマージしてコミットさせるようなギミックでも良かったのでは！？など、いろんな意見が出てきそう(記事書いてて思った)ですが、アドベントカレンダーのネタとして一旦目をつむっておくことにします…。

ちなみに優先投入対象についてですが、これは投入前などに PR 単体で開発環境にデプロイさせて動作確認させると行ったことを想定しています。

それとすみません、よく考えたら僕 TypeScript ほとんど書いたことないのであまりにもひどいコードかもしれません。あくまでこういうことができるんだなあぐらいの認識でお願いします。

本当に最後に、勝手な宣伝にはなりますが、趣味の方で [防災アプリ開発アドベントカレンダー 2023](https://adventar.org/calendars/9301) に参加しており、2つほど記事を公開していますので興味のある方は見ていただけるとうれしいです。

ではでは。