スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

【本編】スクリプト言語と GitHub Actions で GitHub Wiki に秩序をもたらそう! Vol.2 -スクリプト実装編-

最終更新日: 2025年03月11日(火)

1. ご挨拶

こんにちは。
前回「【本編】スクリプト言語GitHub Actions で GitHub Wiki に秩序をもたらそう! Vol.1 -泥臭い手作業編-」の続編を投稿します、@hayat01sh1da です。

blog.studysapuri.jp

ソフトウェアエンジニアとして、以下のサービスの進路・学事向け機能(e.g. アンケート配信機能、進路希望調査配信機能、面談ダッシュボード)を開発・運用・保守しています(for SCHOOL ブランドは先生の管理画面である「先生アプリ」の開発に従事)。

前回の記事の中で、古の時代から現在まで多くのチームやメンバーが相乗りで Snippets 置き場として活用している GitHub Wiki を、Ownership 不在かつ要・不要が不明な玉石混交な状態 → Ownership 単位で Wiki が整理された状態に、重厚長大な手作業で持っていったお話をしました。
その中で私が強調したのは「仕組み化における要件や仕様が鮮明に理解出来るならば、かなりの Pain を伴う手作業でも十二分にやる価値がある」ということでした。

さて、今回は前回の記事で言及した要件内容を踏まえ、どのような仕様でスクリプトを組んだのかを共有させて頂きます。
非常にシンプルな実装ですので、Ruby/Python 歴の浅い方でも理解出来る内容かと思います。

【余談】
RubyPython の2つのスクリプト言語で同じアルゴリズムを実装しているのには、一応理由があります。
単純に「普段 Python を書く機会が乏しいので錆びつかせたくない」という個人的な思惑と「言語の制約でどのような実装差分が出るかや書き味の違いを知り続けたい」というプログラマーとしての思惑があります。

PyCall 使えばええやん!」というツッコミがありそうなので予防線張っておきました(今回はそもそも Python Library の API をコールしていないので不要)。

個人的には、最初の言語ということと業務で使う機会が多いこともあり、Ruby の方が好きです。
あと、Python は完全独学でレビュー受けたことがないので、どなたかツッコミください 🙏

2. 「スクリプト言語GitHub Actions で GitHub Wiki に秩序をもたらそう!」シリーズ

全4部作の構成でお届けしています!

blog.studysapuri.jp blog.studysapuri.jp blog.studysapuri.jp blog.studysapuri.jp

3. スクリプトの要件と仕様と登場クラス群

3-1. 要件

  1. 新しいドキュメントが追加されても、手作業時と同様の Ownership 単位で Wiki を Home と Sidebar 上に昇順でグルーピングされること。
  2. Wiki の行頭に掲載された Owner チーム名を基にグルーピングが行われる。記名のない Wiki は「Owner記名なし」としてグルーピングされる。
  3. 表示順は Ownership が明らかなページ群が一覧の上の方に、不明なページ群は下の方に来るように Wiki が Home と Sidebar 上に掲載される。
  4. Wiki の掲載内容や Revisions には一切の改変がなされない。

2 の実現のために Wiki の行頭に以下の要領で Owner の掲載を行い、今後新規作成するものに関しては執筆するメンバーに同様の掲載をしてもらうルールを定めました。

Owner: @OWNER_TEAM

---

This is a sample Wiki.

また、「Ownerチームが不明だが必要なページ群」「Ownerチーム・要or不要が不明なページ群」に分類した Wiki も、今後 Ownership や要・不要を明らかにしていく上でグルーピングが必要なので、以下のように掲載しました。

Owner: Ownerチームが不明だが必要なページ群

---

This is a sample Wiki.

※ 上記で例えばジャンル名を記載すればその単位で Wiki が一覧化されるなど、応用が利くスクリプトになっています。

3-2. 仕様

  1. Home と Sidebar を除く全 Wiki{ 'owner_1' => [wiki_1, ..., wiki_N], ..., 'owner_N' => [wiki_1, ..., wiki_N]}マッピングに変換します。
    • Ownership あり Wiki 群 と Ownership なし Wiki 群に分割する際に活用出来るように昇順ソートします(以下、マッピング①)。
    • 使い回す際に再計算を行うことで CPU に負荷をかけないようにメモリ上にキャッシュしておきます
    • この際、Wiki の行頭に Owner: @OWNER_TEAM の記載のないものは「Owner記名なし」というキーでマッピングします
  2. マッピング① から Ownership あり Wiki 群(以下、マッピング②) と Ownership なし Wiki 群(以下、マッピング③)を生成します。
  3. Home と Sidebar に Ownership 単位での Wiki 一覧を書き込みます。
    • Home では h2(Markdown 記法の ##)で名前空間を作り、その単位で Wiki を一覧化します
    • Sidebar では ul > li(Markdown 記法の - or *)のツリー構造で Wiki を一覧化します
    • 同じデータで違う一覧化形式を取っているため、実装ではクラスを分割しています

3-3. 登場クラス群

github.com

3-3-1. Ruby

クラス名 役割 説明
Application 基底クラス HomeSidebar が共通で扱うデータを保持します。#run は継承先で実装されることが期待されているので、直接呼び出すと NotImplementedError を Raise します。
対応するテストクラスは ApplicationTest です。
Home Home 書込みクラス h2(Markdown 記法の ##)で名前空間を作り、その単位で Wiki を一覧化します。
対応するテストクラスは HomeTest です。
Sidebar Sidebar 書込みクラス ul > li(Markdown 記法の - or *)のツリー構造で Wiki を一覧化します。
対応するテストクラスは SidebarTest です。
exec/update_wiki_list_on_home_and_sidebar.rb 実行ファイル 実行時に README > 2. Execution > 2-1. Update Wiki List on Home and Sidebar 記載の標準出力を行います。

3-3-2. Python

クラス名 役割 説明
Application 基底クラス HomeSidebar が共通で扱うデータを保持します。#run は継承先で実装されることが期待されているので、直接呼び出すと NotImplementedError を Raise します。
対応するテストクラスは TestApplication です。
Home Home 書込みクラス h2(Markdown 記法の ##)で名前空間を作り、その単位で Wiki を一覧化します。
対応するテストクラスは TestHome です。
Sidebar Sidebar 書込みクラス ul > li(Markdown 記法の - or *)のツリー構造で Wiki を一覧化します。
対応するテストクラスは TestSidebar です。
exec/update_wiki_list_on_home_and_sidebar.py 実行ファイル 実行時に README > 2. Execution > 2-1. Update Wiki List on Home and Sidebar 記載の標準出力を行います。

4. スクリプト実装

ここではそれぞれのクラスの実装を説明します。
ソースコードハイパーリンクの遷移先をご参照下さい。

説明にあたり、手元に Clone した Wiki は以下のディレクトリ構造を持つものとします。

$ tree
.
├── Home.md
├── Ownerチーム・要or不要が不明なページ.md
├── Owner記名ありページ.md
├── Owner記名なしページ1.md
├── Owner記名なしページ2.md
├── Ownerチームが不明だが必要なページ.md
├── _Sidebar.md
├── export_unowned_wiki_list.sh
├── github-wiki-organisers
│   ├── LICENCE.txt
│   ├── README.md
│   ├── python
│   │   ├── README.md
│   │   ├── exec
│   │   │   ├── export_unowned_wiki_list.py
│   │   │   └── update_wiki_list_on_home_and_sidebar.py
│   │   ├── src
│   │   │   ├── application.py
│   │   │   ├── home.py
│   │   │   ├── sidebar.py
│   │   │   └── unowned_wiki_list_exporter.py
│   │   └── test
│   │       ├── test_application.py
│   │       ├── test_home.py
│   │       ├── test_sidebar.py
│   │       └── test_unowned_wiki_list_exporter.py
│   └── ruby
│       ├── README.md
│       ├── Rakefile
│       ├── exec
│       │   ├── export_unowned_wiki_list.rb
│       │   └── update_wiki_list_on_home_and_sidebar.rb
│       ├── src
│       │   ├── application.rb
│       │   ├── home.rb
│       │   ├── sidebar.rb
│       │   └── unowned_wiki_list_exporter.rb
│       └── test
│           ├── application_test.rb
│           ├── home_test.rb
│           ├── sidebar_test.rb
│           └── unowned_wiki_list_exporter_test.rb
└── update_wiki_list_on_home_and_sidebar.sh

10 directories, 34 files

4-1. Application クラス

Application クラスでは以下の処理を行います。

  1. 全ての Wiki 一覧を Array で取得し、昇順でソートし ./Home.md./Sidebar を除いてキャッシュします。
  2. Wiki の行頭を読み込み Owner 名を読み取り、{ '@test-owner' => ['Owner記名ありページ.md'], 'Ownerチームが不明だが必要なページ群' => ['Ownerチームが不明だが必要なページ.md'], 'Ownerチーム・要or不要が不明なページ群' => ['Ownerチーム・要or不要が不明なページ.md'], 'Owner記名なし' => ['Owner記名なしページ1.md', 'Owner記名なしページ2.md'] }マッピングをキャッシュします(以下、マッピング①)。
    • Wiki の行頭に Owner 記名がない場合は「Owner記名なし」をキー名とします
    • ディレクトリ構造がフラットである都合上、Home と Sidebar での Wiki 一覧化時の要件を満たすため Owner 名で昇順ソートをかけます
    • Ruby では Hash#sortArray[Array<String>] の二重配列が返るため、Array#to_h を実行して Hash に戻しておきます
    • Python では sorted(dict.items())list[tuple<str>] の二重配列が返るため、dict() の引数に取って dict に戻しておきます
  3. マッピング①から Ownership あり Wiki 群(以下、マッピング②)を生成・キャッシュします。
  4. マッピング①から Ownership なし Wiki 群(以下、マッピング③)を生成・キャッシュします。

4-2. Home クラス

Home クラスでは以下の処理を行います。

  1. Ruby では空配列を、Python では空文字をインスタンス変数にキャッシュします。
    • Ruby で初期値を空文字にしなかったのは Chilled String 関連の警告を回避するためです
    • Python では許容されているということは、やはり文字列は Mutable であるべき!
  2. 固定文言を配列に1行ずつ格納します。
  3. マッピング②から h2(Markdown 記法の ##)で名前空間を作り、その単位で Wiki を 2 の配列に追加します。
    • キーの Owner 名から @ を、値の WikiMarkdown ファイル名から拡張子をそれぞれ取り除きます
  4. マッピング③から h2(Markdown 記法の ##)で名前空間を作り、その単位で Wiki を 2 の配列に追加します。
    • 値の WikiMarkdown ファイル名から拡張子を取り除きます
  5. 文字列を ./Home.md に書き込みます。
    • Ruby では Array#join で格納した文字列要素を連結して String オブジェクト化します

4-3. Sidebar クラス

Sidebar クラスでは以下の処理を行います。

  1. Ruby では空配列を、Python では空文字をインスタンス変数にキャッシュします。
    • Ruby で初期値を空文字にしなかったのは Chilled String 関連の警告を回避するためです
    • Python では許容されているということは、やはり文字列は Mutable であるべき!
  2. マッピング②から ul > li(Markdown 記法の - or *)のツリー構造を作り、その単位で Wiki 1 の配列に追加します。
    • キーの Owner 名から @ を、値の WikiMarkdown ファイル名から拡張子をそれぞれ取り除きます
  3. マッピング③から ul > li(Markdown 記法の - or *)のツリー構造でを作り、その単位で Wiki 1 の配列に追加します。
    • 値の WikiMarkdown ファイル名から拡張子を取り除きます
  4. 文字列を ./_Sidebar.md に書き込みます。
    • Ruby では Array#join で格納した文字列要素を連結して String オブジェクト化します

4-4. 実行ファイル

実行ファイルでは以下の処理を行います。

  1. Home クラス(Ruby/Python) と Sidebar クラス(Ruby/Python) をインポートします。
  2. 処理開始の旨を標準出力で通知します。
  3. Homeh2(Markdown 記法の ##)で名前空間を作り、その単位で Wiki を一覧化します。
  4. Sidebarul > li(Markdown 記法の - or *)のツリー構造で Wiki を一覧化します。
  5. 実行結果を標準出力で通知します。
  6. 処理完了の旨を標準出力で通知します。

5. 実行結果・差分

以上のようなシンプルな実装で簡単に GitHub Wiki の Home と Sidebar が Ownership 単位で整理出来ました 🎉
サンプルなので効果を実感しづらいですが、混沌とした GitHub Wiki であればあるほど威力を発揮します(当部署の GitHub Wiki がそうでした)!

実行結果

$ bash update_wiki_list_on_home_and_sidebar.sh 
==================== Categorizing the Entire github-wiki-organisers Wiki Pages... ====================
========== Organising Home... ==========
Check out An Up-to-date Wiki List on Home at https://github.com/hayat01sh1da/github-wiki-organisers/wiki !!
========== Done Organising Home 🎉 ==========

========== Organising Sidebar... ==========
Check out An Up-to-date Wiki List on Sidebar at https://github.com/hayat01sh1da/github-wiki-organisers/wiki !!
========== Done Organising Home 🎉 ==========
==================== Done Categorizing the Entire github-wiki-organisers Wiki Pages 🎉 ====================

差分

diff --git a/Home.md b/Home.md
index e69de29..e1f1630 100644
--- a/Home.md
+++ b/Home.md
@@ -0,0 +1,24 @@
+このページは Owner チームごとに Wiki をグルーピングして一覧化しています。
+
+## Wiki ページの運用ルール
+
+Ownership をどのチームが持つのかが不明だと、責任の所在が不明瞭になり、保守性の悪化に伴うノイズの増加と検索性の悪化が発生します。  
+治安維持のため、各ページの冒頭に `Owner: {オーナーチーム名}` を明記して頂きますようよろしくお願いします。  
+なお、Home・Sidebar は専用のスクリプトで自動更新しますので編集は不要です。
+
+## [@test-owner](https://github.com/orgs/hayat01sh1da/teams/test-owner)
+
+- [[Owner記名ありページ]]
+
+## Ownerチームが不明だが必要なページ群
+
+- [[Ownerチームが不明だが必要なページ]]
+
+## Ownerチーム・要or不要が不明なページ群
+
+- [[Ownerチーム・要or不要が不明なページ]]
+
+## Owner記名なし
+
+- [[Owner記名なしページ1]]
+- [[Owner記名なしページ2]]
diff --git a/_Sidebar.md b/_Sidebar.md
index e69de29..4105d09 100644
--- a/_Sidebar.md
+++ b/_Sidebar.md
@@ -0,0 +1,9 @@
+- [@test-owner](https://github.com/orgs/hayat01sh1da/teams/test-owner)
+  - [[Owner記名ありページ]]
+- Ownerチームが不明だが必要なページ群
+  - [[Ownerチームが不明だが必要なページ]]
+- Ownerチーム・要or不要が不明なページ群
+  - [[Ownerチーム・要or不要が不明なページ]]
+- Owner記名なし
+  - [[Owner記名なしページ1]]
+  - [[Owner記名なしページ2]]

6. 「スクリプト実装編」の教訓

さて、前章までスクリプトによって多数のチーム・メンバーが相乗りで使っていた混沌とした GitHub Wiki に Ownership と要・不要の観点で責任の所在と情報資産の有効性を明らかにし、それらを基にグルーピングした話をしました。
しかし、この段階では依然として自動化には至っておらず、以下の課題が残っていました。

  1. 誰か(主に私)がスクリプトを実行して差分をコミットしない限り、再び混沌とした GitHub Wiki に逆戻りしてしまう。
  2. 属人性を排除するため、GitHub Actions を使って定期実行をし、その結果を Slack チャンネルに通知させたかった。
  3. 「Ownerチームが不明だが必要なページ群」「Ownerチーム・要or不要が不明なページ群」「Owner記名なし」は情報の信頼性を担保する上で Ownership と要・不要を出来る限り明確にする必要があるので、週初めにそれぞれ何件ずつ該当する Wiki があるかを Slack チャンネルに通知させたかった。
  4. しかし、根本的な問題として GitHub Wiki では GitHub Actions の Workflows を動かせない。

ここでの教訓は「スクリプトを組んだだけでは完全な自動化ではない、その実行を含めてシステムに任せてこそ属人性が排除出来る」でした。
上記の課題を解決するための要件は以下の2つでした。

  1. GitHub Wiki の Home と Sidebar を最新化する GItHub Actions の Workflows が平日の 12:00 と 22:00 の2回走り、その実行結果が Slack の指定チャンネルに通知されること。
  2. 月曜日の 09:00 に「Ownerチームが不明だが必要なページ群」「Ownerチーム・要or不要が不明なページ群」「Owner記名なし」のそれぞれの件数が Slack の指定チャンネルに通知されること。

結論から言えば、GitHub Actions を使って解決することが出来ました。
その方法論とそれに至るまでの試行錯誤を次回共有させて頂きます!

7. 次回予告

次回は「【本編】スクリプト言語GitHub Actions で GitHub Wiki に秩序をもたらそう! Vol.3 -自動化ワークフロー実装編-」をお届けします!
少しだけタイトルに違反し、GitHub Actions の実行に必要なスクリプトの追加実装がありますが悪しからず 🙇🏻‍♂️
それではまた、お楽しみに 👋🏻

blog.studysapuri.jp

8. バックナンバー

blog.studysapuri.jp blog.studysapuri.jp blog.studysapuri.jp blog.studysapuri.jp