🐙

GitHub Actionsでtodo-mitsukeru-kunを自作と公開してみた

2023/12/11に公開

https://adventar.org/calendars/9453

どうも、ネカフェ暮らしのGOD-odaです。

今回は土日の空いた時間にガガっと作ったカスタムアクションをアドベントカレンダーネタとして書きました。
初めてGitHub Actionsを自作する方に向けて書いておりますので、ぜひ参考にしてみてください!

todo-mitsukeru-kunとは

その名の通りTODOコメントを見つけて、その行数とコメントのIssueを作ってくれるGitHub Actionsのことです。

実際に動作するとこのようにIssueが作られるようになります。

背景

皆さんは「割れ窓理論」をご存知でしょうか?

割れ窓理論(われまどりろん、英: Broken Windows Theory)とは、軽微な犯罪も徹底的に取り締まることで、凶悪犯罪を含めた犯罪を抑止できるとする環境犯罪学上の理論。アメリカの犯罪学者ジョージ・ケリングが考案した。

https://ja.wikipedia.org/wiki/割れ窓理論

プログラミングにおいてはこういうことだと思います。

「悪い設計」や「汚いコード」がいくつかかあるだけで、「別に誰も質を高めようと意識してなさそう。自分も適当なコードでいいかな...うん。大丈夫でしょ。だってもう汚いし」という考え方に繋がってしまう。
その結果、アプリーケーションが質が下がり、どんどん腐敗していく結果に。

https://zenn.dev/miya_tech/articles/4e1807d05525df#プログラミングにおける割れ窓理論

ありがちなTODOコメントがこの理論の良い例だと思います。

「後で修正する」などのようなTODOコメントをつけてもその時が来ないことをエンジニアは知っています。
そして日常的にそれを意識することもほとんどないはずです。
残したままだと悪化の一途を辿るだけなのでどうにか手をいれる足がかりになればという想いから作ってみることにしました。

そしてこれにより2つのことが期待できると思います。

1つは改修着手までのコストを下げることです。
想定している問題の核心ですね。

もう1つは新しくジョインした方がコードの雰囲気を掴むことです。
その人のスキルレベルに関係なく新しい環境ではいきなり結果を出すことは容易ではないです。
TODOの難易度にもよりますが、仕様確認、実装、レビューまでをサクッと回せるタスクをこなすことで大きなタスクでも自然とこなせる流れを作りたいです。

ちなみに自分が業務で関わっているリポジトリには2023年12月9日時点で136のTODOがありました。(駆逐せねば)

実装

カスタムアクションは基本的に公式ドキュメントを読めば良いです。
https://docs.github.com/ja/actions/creating-actions/about-custom-actions

今回はGoで実装します。
Goでのカスタムアクションは「複合アクション」として作成します。

action.yaml

まずはカスタムアクションのメタデータファイルを作ります。
構文の詳細は「複合アクションのruns」を参照してください。

ポイントは以下です。

  • runs.usingは必須でcompositeにします。
  • runs.steps[*].runでスクリプトを実行してます。
  • runs.steps[*].shellでスクリプト実行時のshellを指定しています。
  • brandingは公開する際に必要な項目です。
name: "todo-mitsukeru-kun"
description: "Compile a list of TODO comments for each file into an issue."
runs:
  using: "composite"
  steps:
    - run: go run ${{ github.action_path }}/src/main.go 
      shell: bash
branding:
  color: "blue"
  icon: "check-square"

main.go

実行したいスクリプトの処理を書いていきます。
自分はGoを趣味程度しか書いたことがないのでChatGPTとGitHub Copilotをの力を借りて実装しています。

全てを書くと少し長いのでポイントを抑えます。(全コードはここから確認できます。)

main()

スクリプトのエントリポイントです。

ポイント

  • 「環境変数の読み込み」と「対象のディレクトリ配下のファイル走査」を行ってます。
func main() {
	params := GetParams()
	err := filepath.WalkDir(params.TargetDir, visitFile)
	if err != nil {
		fmt.Println("Error walking the path:", err)
	}
}

processFile()

これは1つのファイルに対する処理で、「TODOコメントの行数」と「TODOコメント行の文字列取得」を行ってます。

ポイント

  • todoPrefixは各プログラミング言語でヒットさせたいTODOコメントのprefixです。(rubyなら# TODOなどをmapで持ち、処理に入る前に拡張子から判断してます。)
  • todoTODOのどちらでもヒットするように大文字に揃えてます。
func processFile(filePath string, todoPrefix string) ([]Comment, error) {
	file, err := os.Open(filePath)
	if err != nil {
		return nil, err
	}
	defer file.Close()

	fmt.Println("Processing file:", filePath)

	var commentLines []Comment
	lineNumber := 0
	scanner := bufio.NewScanner(file)
	for scanner.Scan() {
		line := scanner.Text()
		lineNumber++
		if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(line)), todoPrefix) {
			commentLines = append(commentLines, Comment{Body: line, LineNumber: lineNumber})
		}
	}

	if err := scanner.Err(); err != nil {
		return nil, err
	}

	return commentLines, nil
}

createIssue()

これはIssueを作成する関数です。

ポイント

  • GITHUB_REPOSITORYは自動的に定義される環境変数で、アクションが実行されるリポジトリ名になります。
  • INPUT_GITHUB_TOKENはGitHubのREST APIにリクエストする際のBearerトークンです。
  • strings.ReplaceAll(comment.Body, "\t", "")はGoのインデントがタブなのでそれに対応するためです。
func createIssue(filePath string, comments []Comment) {
	if comments == nil || len(comments) < 1 {
		return
	}

	token := os.Getenv("INPUT_GITHUB_TOKEN")
	repoName := os.Getenv("GITHUB_REPOSITORY")
	issueTitle := fmt.Sprintf("[todo-mitsukeru-kun] %s", filePath)
	issueBody := "<details>\\n<summary>Todo Comments</summary>\\n"
	for _, comment := range comments {
		issueBody += fmt.Sprintf("%d: %s\\n\\n", comment.LineNumber, strings.ReplaceAll(comment.Body, "\t", ""))
	}
	issueBody += "</details>\\n"

	url := fmt.Sprintf("https://api.github.com/repos/%s/issues", repoName)
	jsonData := fmt.Sprintf(`{"title": "%s", "body": "%s"}`, issueTitle, issueBody)

	req, err := http.NewRequest("POST", url, bytes.NewBufferString(jsonData))
	if err != nil {
		fmt.Println("Error creating request:", err)
		return
	}

	req.Header.Set("Content-Type", "application/vnd.github+json")
	req.Header.Set("Authorization", "Bearer "+token)
	req.Header.Set("X-GitHub-Api-Version", "2022-11-28")

	client := http.Client{}

	resp, err := client.Do(req)
	if err != nil {
		fmt.Println("Error making request:", err)
		return
	}
	defer resp.Body.Close()
}

カスタムアクションの実行

泥臭いですが、別リポジトリを用意して毎回プッシュでデバッグしました。

利用方法は普段のGitHub Actionsの使い方と同じです。

ポイント

  • ${{ secrets.GITHUB_TOKEN }}はGitHub Actionsが実行される際に自動で生成されるトークンで、main.go#createIssue()で使います。
  • INPUT_TARGET_DIRはTODOコメントを見つける対象のディレクトリを指定します。
.github/workflows/test.yaml
on: [push]

jobs:
  test_job:
    runs-on: ubuntu-latest
    name: test
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Use todo-mitsukeru-kun
        uses: GOD-oda/todo-mitsukeru-kun@v0.0.1
        env:
          INPUT_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          INPUT_TARGET_DIR: "src"

デバッグはローカルでも出来ますが、実際の動作確認においてこういう感じでリクエストやレスポンスなどを確認していきました。

タグの作成

タグはv0.0.1としました。
また、何度も書き換えて動作確認することになるのでタグ作成用シェルスクリプトを準備しました。

create_release_tag.sh
#!/bin/bash

git tag -d v0.0.1 | true
git push -d origin tag v0.0.1 | true
git tag -a v0.0.1 -m "v0.0.1"
git push --follow-tags

公開

自作のアクションを広く知ってもらうためGitHub Marketplaceに公開します。
公開までの流れは公式を見ればおおよそ掴めるかと思います。

action.yamlがあるとリリースタグを作成する際に「Publish this Action to the GitHub Marketplace」というチェックボックスが表示されるので、チェックを入れて公開に必要な項目を確認します。
README.mdも必要なのでここで作成しておきましょう。

リリースタグが切られるとこのようにMarketplaceというリンクが表示されているのがわかります。

リンクを辿るとアクションの詳細ページに飛びます。
これで無事公開されました!!!

完成されたコードはこちらに置いておきます。
https://github.com/marketplace/actions/todo-mitsukeru-kun

ハマりポイント

with構文のパラメータをGoで受け取れなかった

読み取るTODOコメントは任意のディレクトリを指定できる方がユーザーフレンドリーだと思います。(というか、どういう設計にするかはユーザーが決めることなので)
なので必須で満たしたい仕様ですが、調べても記事はあまりない上にうまく動作しませんでした。

自分の理解が足りないこともあると思ったのですが、環境変数ならうまく動作することがわかったのでとりあえずそちらで実装しました。

後から知りましたが、カスタムアクションを作るときはTypescriptのほうが周辺ツールなど揃っているのでそちらの方が開発体験は良かったかもしれません。
Go実装のツールキットはあるのですが、インストールの時にハマったので使ってません。(これも時間あれば調べたいです。)

感想

GitHub Actionsの自作は初めてでしたが、全体を通してすんなりと公開まで出来たのは良かったです。
公開までの実働は約10時間くらいで、そのうち6 ~ 7時間くらいはGitHub Actionsの仕様とその確認でした。(仕様理解は大事です。)
残りはコードを書いた時間ですが、大まかな実装はGitHub Copilotに任せて変数定義や処理の変更を自分で書きました。実現したいことが書けない時はChatGPTに頼ります。
開発を進める時には公式ドキュメントがちゃんと書かれているのでそちらを見ることお勧めします。

また、AIに頼った開発が出来ると新しいことに挑戦する心理的ハードルも下がり、失敗→調査→成功のサイクルを素早く回せるので、より多くのことに挑戦出来ます。(Goはほとんど書いたことなかったけどなんとか実装しきれたといった感じ)

とは言えやり残したこともあり、例えば「既に作られたIssueと同じ内容のIssueは作らない」ことも実現したいことです。
人はTODOコメントを自ら意識しないのでPULL型よりPUSH型のほうが考えるコストが少なくなって良いはずで、アクションの実行はスケジュール実行されることを想定しているからです。

ただし「同じ内容をどう定義するか」や「どうやって検知するのが楽か」など考えることが多かったので考慮しませんでした。
この仕様を満たせていないので業務で使うリポジトリには導入していません。
なので課題に対して解決に至ってないのは唯一の心残りです。

そのうちv0.0.2などで入れられたらな〜と思ってます。
その際はテストもちゃんと書いていきたいですね。(GitHub Actionの実装自体が面白かったのでヨシ。)

最後に

IVRyではシステム改善、新機能実装、運用フロー効率化などやりたことが多いです!
人的リソースを言い訳にしたくはないですが、それでも全てやっていくためには圧倒的に足りません!

これから最高の未来を目指すその過程をガンガン進めていきたい人、心からお待ちしております!
ぜひ一緒に歩んでいきましょう!

https://ivry-jp.notion.site/IVRy-e1d47e4a79ba4f9d8a891fc938e02271

https://ivry.jp/

IVRyテックブログ

Discussion