新卒で配属4か月目の私がGitHub Actionでリポジトリの大掃除をしてとっても喜んでもらえた話

世間のエンジニアの皆さんは、**GitHub Actions**ってどれくらい使ってるんでしょうか:thinking:

私が初めて配属された案件で使っていたリポジトリは、それはそれはひどい有様でした。
（先輩曰く、炎上の爪痕だそう…）

今後もこのリポジトリで作業をしていくのかと思うととても苦しかったため、
なんとかせねばと思いたどり着いたのが**GitHub Actions**でした。

## はじめに

#### この記事の内容

- なぜGitHub Actionsを使用することにしたのか
- 完成したソースコード（後述しますが、他の要素ありきなためコピペでは使えません）

#### 想定読者

- GitHubを使っている
- GitHub Actionsの知識がちょっとある
- リポジトリが散らばっている/複雑で困ってる

#### 作成したワークフロー

- [issue作成時に、対応する親issueと同じラベルとマイルストーンを設定する](#issue作成時に対応する親issueと同じラベルとマイルストーンを設定する)
- [PR作成時、対応するissueと同じラベルをPRに付与する](#PR作成時対応するissueと同じラベルをPRに付与する)
- [PRマージ時、マージ先ブランチによって対応するissueにラベルを付与し、クローズする](#PRマージ時マージ先ブランチによって対応するissueにラベルを付与しクローズする)

## GitHub Actionsとは？

GitHub Actionsとは、GitHubにおいて色々なワークフローを自動化してくれるツールです。

例えば、

- issueが新規作成されたら、そのissueに指定したラベルを付与する
- テストを自動実行する

みたいなことが出来ます。

ref: [GitHub Actionsについて](https://docs.github.com/ja/actions/about-github-actions/understanding-github-actions)


## まず現状の課題を洗い出してみる。

取り掛かる前にまず現状の把握と課題を整理しました。

### 現状の開発環境

- issue駆動開発(issueひとつにPRひとつで開発すること)
- 数か月単位のフェーズがあり、各フェーズの中で毎月リリースを行う
- 毎月リリースごとの対応をまとめた親ブランチを切り、親ブランチを上にマージしていく
- `develop`, `release(staging環境)`, `main(本番環境)`の順にマージする

なんとなく以下のようなイメージです。

```mermaid
gitGraph
    commit
    branch release
    checkout release
    branch develop
    checkout develop
    branch 2024/10
    checkout 2024/10
    branch no1
    checkout no1
    commit
    commit
    checkout 2024/10
    merge no1
    checkout develop
    merge 2024/10
    checkout release
    merge develop
    checkout main
    merge release
    checkout develop
    commit
    branch 2024/11
    checkout 2024/11
    commit
    branch no2
    checkout no2
    commit
    commit

```

ここは変えられないので、この中でうまい方法を見つけていくことになります。


### 現状の課題点と解決方法

まず現状の困りごとは、以下の通りです。

- **数年前からのissueが開きっぱなし**で、解決済みなのかすらよく分からない
- 誰が担当したissueなのか、どのPRに紐づいているのかわからない
- 別で管理しているWBSの課題番号と紐づかない

とくに1つ目はひどいですね。
初めて見たとき、openなissueが700個以上あって椅子から転げ落ちました。

さておき、これらの課題から以下の内容を実現することにしました。

- どの子issueがどのフェーズ・リリース時期に紐づいているのか明確にする
- 子issueがそれぞれどのブランチまでマージされたのかを明確にする
- 本番環境までマージされたら、親issueと子issueをすべてクローズする


## 実現のために用意したもの

実現のために、GitHubで提供されている要素をどのように使うかも考えました。

#### テンプレート

issueやPRのテンプレートを整備し、本文中に紐づくissueの番号を記載してもらうようにしました。

```md: issue_template.md
---
name: 実装依頼
about: 機能追加の要望はこちら
title: '[FEATURE] No.'
labels: ''
assignees: ''

---

## 概要

<!--マージ先のリリースIssue番号を記載する↓-->

- マージ先: #
- backlog: 
```

```md: PULL_REQUEST_TEMPLATE.md
# 概要

<!--対応したIssue番号を記載する↓-->
- Issue: #

## 補足


## 確認（※全てにチェックをつけてください）

- [ ] ローカル環境(自端末)で動作確認した
```

紐づくissue番号を、`#54`のように記載することで、以下のように表示されます。わかりやすい！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/361891/0c9f2c84-71e2-a982-a1e5-2f6eff51630f.png)


#### ラベル

ラベルは、issueやPRの状態と、案件フェーズの管理に使うことにしました。
PRマージで発火し、マージ先ブランチによって適切なラベルをissueに付与するよう実装しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/361891/ef49cff0-a0c9-3c9f-692f-1990621d45be.png "作成したラベル")

#### マイルストーン

`develop`以上のブランチへのマージでは、PRに子issueを直接関連付けることが出来ないため（できるけど面倒だし作業が増えてしまう）、
「PRマージ時点で、特定のマイルストーンに紐づき、特定のラベルの付いているissueすべて」を関連するissueとし、まとめてラベルを更新するようにしました。

#### テンプレート

テンプレート


## 完成したもの

実装は以下のようになりました。

### issue作成時に、対応する親issueと同じラベルとマイルストーンを設定する

```yml: issue_auto_settings.yml

name: Issue作成時 ラベル、マイルストーンを追加

on:
  issues:
    types: [ opened, edited ]

jobs:
  set-issues:
    runs-on: ubuntu-latest

    steps:
      - name: リポジトリをチェックアウト
        uses: actions/checkout@v3

      - name: ラベルとマイルストーンを設定
        uses: actions/github-script@v7
        with:
          script: |
            // ここに以下の処理(シンタックスハイライトのため分けています)

```

```javascript
// Issue作成時 ラベル、マイルストーンを追加

const issue = context.payload.issue;
if (!issue) {
    throw new Error("Issue payload is not available.");
}
const body = issue.body;
if (!body) {
    throw new Error("Issue body is empty.");
}

// 正規表現を使って本文からissueを抽出。
const parentIssueMatch = body.match(/マージ先: #(\d+)/);
if (!parentIssueMatch) {
    console.log("No issue found in body.");
    return;
}

let parentIssueData;
const parentIssueNumber = parentIssueMatch[1];

try {
    parentIssueData = await github.rest.issues.get({
        owner: context.repo.owner,
        repo: context.repo.repo,
        issue_number: parentIssueNumber,
    });

} catch (error) {
    console.error(`Failed to fetch issue #${parentIssueNumber}:`, error);
    return;
}

// ラベル取得
const labelsToSet = parentIssueData.data.labels
                    .filter(label => !["stg", "dev", "fixed", "parent issue"].includes(label.name))
                    .map(label => label.name);
const labelsForParentIssue = labelsToSet.concat("parent issue");
console.log(`labelsToSet: ${labelsToSet}`);

// マイルストーン取得
const milestoneToSet = parentIssueData.data.milestone.number;
console.log(`milestoneToSet: ${parentIssueData.data.milestone.title}`);

// issueのラベルとマイルストーンを更新
try {
    console.log(`Update issue #${issue.number}`);
    await github.rest.issues.update({
        owner: context.repo.owner,
        repo: context.repo.repo,
        issue_number: issue.number,
        labels: Array.from(labelsToSet),
        milestone: milestoneToSet,
    });

    console.log(`Update issue #${parentIssueNumber}`);
    await github.rest.issues.update({
        owner: context.repo.owner,
        repo: context.repo.repo,
        issue_number: parentIssueNumber,
        labels: Array.from(labelsForParentIssue),
    });

} catch (error) {
    console.error(`Failed to update issue:`, error);
    return;
}
```

### PR作成時、対応するissueと同じラベルをPRに付与する

```yml: pr_auto_settings.yml

name: PR作成時 ラベル、マイルストーン、担当者追加

on:
  pull_request:
    types: [ opened, edited ]

jobs:
  set-pr:
    runs-on: ubuntu-latest

    steps:
      - name: リポジトリをチェックアウト
        uses: actions/checkout@v3

      - name: ラベル、マイルストーン、担当者を設定
        uses: actions/github-script@v7
        with:
          script: |
            // ここに以下の処理(シンタックスハイライトのため分けています)

```

```javascript
const pr = context.payload.pull_request;
if (!pr) throw new Error("Issue payload is not available.");

const { body: prBody, number: prNumber, base: { ref: prBase }, user: { login: creator } } = pr;
if (!prBody) {
    console.log("PR body is empty.");
    return;
}

// PRの本文からIssue番号を抽出
const issueNumber = extractIssueNumber(prBody, /Issue: #(\d+)/);
if (!issueNumber) return console.log("No issue found in PR body.");

// Issueのデータを取得し、PRにラベル、マイルストーン、担当者を設定
try {
    const issueData = await getIssueData(issueNumber);
    const issueAssignees = (issueData.data.assignees?.length !== 0 || false)
                            ? issueData.data.assignees.map(assignee => assignee.login)
                            : [creator];
    const labelsToSet = filterLabels(issueData.data.labels, ["parent issue", "dev", "stg", "fixed"]);
    const milestoneToSet = issueData.data.milestone?.number || null;
    const isParentIssue = issueData.data.labels.some(label => label.name === "parent issue");
    const isHotFix = issueData.data.labels.some(label => label.name === "hotfix");

    if (labelsToSet.length > 0) {
        await updatePullRequestMilestoneAssignee(prNumber, milestoneToSet, issueAssignees);
        await addIssueLabels(prNumber, labelsToSet);
        if (!isParentIssue) {
            await updateIssueAssignee(issueNumber, issueAssignees);

            if (!isHotFix) {
                await addIssueLabels(issueNumber, ["ready for review"]);
            }
        }
    }
} catch (error) {
    console.error("Failed to update issue and PR:", error);
}

// 特定の正規表現からIssue番号を抽出する関数
function extractIssueNumber(body, regex) {
    const match = body.match(regex);
    return match ? match[1] : null;
}

// Issueデータを取得する関数
async function getIssueData(issueNumber) {
    try {
        return await github.rest.issues.get({
            owner: context.repo.owner,
            repo: context.repo.repo,
            issue_number: issueNumber,
        });
    } catch (error) {
        throw new Error(`Failed to fetch issue #${issueNumber}: ${error}`);
    }
}

// 特定のラベルを除外して残りを配列で返す関数
function filterLabels(labels, excludeLabels) {
    return labels
        .filter(label => !excludeLabels.includes(label.name))
        .map(label => label.name);
}

// PRにマイルストーンと担当者を設定する関数
async function updatePullRequestMilestoneAssignee(prNumber, milestone, assignees) {
    console.log(`Updating PR #${prNumber} milestone and assignee`);
    await github.rest.issues.update({
        owner: context.repo.owner,
        repo: context.repo.repo,
        issue_number: prNumber,
        milestone: milestone,
        assignees: assignees,
    });
}

// Issueの担当者を更新する関数
async function updateIssueAssignee(issueNumber, assignees) {
    console.log(`Updating assignee for issue #${issueNumber}`);
    await github.rest.issues.update({
        owner: context.repo.owner,
        repo: context.repo.repo,
        issue_number: issueNumber,
        assignees: assignees,
    });
}

// Issueにラベルを追加する関数
async function addIssueLabels(issueNumber, labels) {
    console.log(`Adding labels to issue #${issueNumber}`);
    await github.rest.issues.addLabels({
        owner: context.repo.owner,
        repo: context.repo.repo,
        issue_number: issueNumber,
        labels,
    });
}
```


### PRマージ時、マージ先ブランチによって対応するissueにラベルを付与し、クローズする

```yaml: issues_update_on_merge.yml

name: PRマージ時、Issueを更新する

on:
  pull_request:
    types:
      - closed

jobs:
  update-issue:
    runs-on: ubuntu-latest
    if: ${{ github.event.pull_request.merged == true }}

    steps:
      - name: リポジトリをチェックアウト
        uses: actions/checkout@v3

      - name: 関連するIssueを更新する
        uses: actions/github-script@v7
        with:
          script: |
            // ここに以下の処理(シンタックスハイライトのため分けています)
            
```

```javascript
// PRマージ時、Issueを更新する

const pr = context.payload.pull_request;
if (!pr) throw new Error("Issue payload is not available.");

const { body: prBody, number: prNumber, title: prTitle, base: { ref: prBase }, milestone: prMilestone, labels: prLabels } = pr;
if (!prBody) {
    console.log("PR body is empty.");
    return;
}

const isHotfix = prLabels.some(label => label.name === "hotfix");
if (isHotfix) console.log("hotfixのPRです。");

// 取得条件に基づくIssueをフィルタリングする関数
const filterIssues = (issues, excludeLabels, includeLabels) => {
    return issues.filter(issue => {
        const labelNames = issue.labels.map(label => label.name);
        const hasExcludeLabel = excludeLabels?.some(label => labelNames.includes(label)) ?? false;
        const hasAllIncludeLabels = includeLabels ? includeLabels.every(label => labelNames.includes(label)) : true;
        return !hasExcludeLabel && hasAllIncludeLabels;
    });
};

// マイルストーンとラベルからIssueを取得する
const fetchIssuesByMilestoneAndLabel = async (milestone, excludeLabels, includeLabels) => {
    if (!milestone) {
        console.log("No milestone found.");
        return;
    }
    try {
        const issues = await github.paginate(github.rest.issues.listForRepo, {
            owner: context.repo.owner,
            repo: context.repo.repo,
            milestone: milestone.number,
            state: "open",
        });
        return filterIssues(issues, excludeLabels, includeLabels);
    } catch (error) {
        console.error("Failed to fetch issues:", error);
    }
};

// Issueのラベルを更新
const updateIssueLabels = async (issueDatas, removeLabel, addLabel) => {
    for (const issueData of issueDatas) {
        const updatedLabels = issueData.labels
            .filter(label => label.name !== removeLabel)
            .map(label => label.name);
        updatedLabels.push(addLabel);

        try {
            await github.rest.issues.update({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: issueData.number,
                labels: Array.from(updatedLabels),
            });
        } catch (error) {
            console.error(`Failed to update issue #${issueData.number}:`, error);
        }
    }
};

// Issueをクローズ
const closeIssues = async (issueDatas) => {
    for (const issueData of issueDatas) {
        try {
            await github.rest.issues.update({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: issueData.number,
                state: "closed"
            });
        } catch (error) {
            console.error(`Failed to close issue #${issueData.number}:`, error);
        }
    }
};

// 正規表現でIssue情報を取得
const fetchIssueData = async (body, matchStr) => {
    const issueMatch = body.match(matchStr);
    if (!issueMatch) return console.log("No issue found in body.");

    const issueNumber = issueMatch[1];
    try {
        return await github.rest.issues.get({
            owner: context.repo.owner,
            repo: context.repo.repo,
            issue_number: issueNumber,
        });
    } catch (error) {
        console.error(`Failed to fetch issue #${issueNumber}:`, error);
    }
};

// PRマージ後の処理
const processMerge = async (baseBranch, exclude, include, oldLabel, newLabel, closeLabel = null) => {
    console.log(`${baseBranch}ブランチへのPRがマージされました。`);

    // マイルストーンとラベルから対象のissueのラベルを更新
    const targetIssueDatas = await fetchIssuesByMilestoneAndLabel(prMilestone, exclude, include);
    if (!targetIssueDatas || isHotfix) {
        await updateIssueLabels([fixedIssueData.data], oldLabel, newLabel);
    } else {
        await updateIssueLabels(targetIssueDatas, oldLabel, newLabel);
    }

    // masterへのPRの場合はissueをクローズする
    if (closeLabel) {
        const issuesToClose = await fetchIssuesByMilestoneAndLabel(prMilestone, exclude, [closeLabel]);
        if (!issuesToClose || isHotfix) {
            await closeIssues([fixedIssueData.data]);
        } else {
            await closeIssues(issuesToClose);
        }
    }
};

const fixedIssueData = await fetchIssueData(prBody, /Issue: #(\d+)/);
if (!fixedIssueData) {
    console.log("No issue found in body.");
    return;
}

// 各環境のPRマージ後の処理
if (prBase === "develop") {
    await processMerge("develop", isHotfix ? null : ["hotfix"], isHotfix ? ["hotfix", "fixed"] : ["fixed"], "fixed", "dev");
} else if (prBase === "release") {
    await processMerge("release", isHotfix ? null : ["hotfix"], isHotfix ? ["hotfix", "dev"] : ["dev"], "dev", "stg");
} else if (prBase === "master") {
    await processMerge("master", isHotfix ? null : ["hotfix"], isHotfix ? ["hotfix", "stg"] : ["stg"], "stg", "prd", "prd");
} else {
    console.log(`${prBase} へのPRがマージされました。`);
}

// 固有のIssueデータ処理
if (fixedIssueData) {
    console.log(`Issue #${fixedIssueData.data.number} に紐づくPRです。`);
    const parentIssueData = await fetchIssueData(fixedIssueData.data.body, /マージ先: #(\d+)/);

    if (parentIssueData) {
        console.log(`マージ先は Issue #${parentIssueData.data.number} です。`);
        let parentIssueBody = parentIssueData.data.body;
        const newEntry = `- #${fixedIssueData.data.number}`;
        const contentHeader = "## 対応内容";

        if (!parentIssueBody.includes(contentHeader)) {
            parentIssueBody += `\n\n${contentHeader}\n${newEntry}`;
        } else if (!parentIssueBody.includes(newEntry)) {
            parentIssueBody += `\n${newEntry}`;
        }

        const parentIssueLabels = parentIssueData.data.labels.map(label => label.name);
        const isFixed = !await fetchIssuesByMilestoneAndLabel(prMilestone, isHotfix ? ["parent issue", "fixed"] : ["hotfix", "parent issue", "fixed"], isHotfix ? ["hotfix"] : null).length;
        if (isFixed) parentIssueLabels.push("fixed");

        try {
            await github.rest.issues.update({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: parentIssueData.data.number,
                labels: Array.from(new Set(parentIssueLabels)),
                body: parentIssueBody,
            });
        } catch (error) {
            console.error(`Failed to update parent issue #${parentIssueData.data.number}:`, error);
        }

        await updateIssueLabels([fixedIssueData.data], "ready for review", "fixed");
    }
}
```

~~リポジトリの見本は[こちら]()です。よかったらご覧ください。~~
(すみません準備中です😿)

## 最後に

まだ運用仕立てではありますが、GitHubリポジトリがきれいになり、とても喜んでもらえました。
**嬉しい！(#^.^#)**
褒められて伸びるタイプなのでこれからもいっぱい褒められるべく精進してまいります。

今後運用していく中で、少しずつより扱いやすいワークフローにアップデートしていきたいと考えています。

javascriptもGitHubも初心者なので、「もっとこうしたほうがいいよ！」というご意見などあればコメントしていただけると幸いです。

最後までお読みいただきありがとうございました。