79
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

もう手動編集しない!GitHubラベルをファイル管理&自動更新してみた

Last updated at Posted at 2025-12-06

Gemini_Generated_Image_1mvrsa1mvrsa1mvr.png
※ Nano Banana Pro にサムネ生成してもらいました。めっちゃいい感じ :thumbsup:

はじめに

突然ですが、以下のように感じたことはありませんか?

  • デフォルトのラベルが業務で使うとちょっと扱いづらい
  • バックエンドを開発するリポジトリと、フロントエンドを開発するリポジトリで同じラベルを使いたい
  • GitHub Projects に紐づいているリポジトリでは同じラベルを使いたい
  • GitHubのラベルを手で作っていくのがめんどい...
  • ラベルの更新もバージョン管理したい

私も最近この悩みに直面しました。
特に、GitHub Projects を使って複数リポジトリを1つの Project で管理している場合、ラベルが揃っていないといろいろ辛いんですよね。
かといって、一つ一つラベルを手で作成しようとすると、更新時に全リポジトリで変更する手間も発生して、それもそれで辛いです。

なので、今回はエンジニアらしくこの悩みを解決することにしました。
同じ悩みを感じている方の参考になれば幸いです。

実装

さて、それでは実装を初めていきましょう。
大きく分けて以下のような手順で実装していきます。

  1. ラベル定義を書く
  2. スクリプトを配置する
  3. GitHub Actions を設定する
  4. 動作テスト

1. ラベル定義を書く

まずは、ラベル一覧を「構造化されたデータ」として管理します。
私はたまたま QiitaでJSONCを扱う記事を見つけ、「コメント書けて便利そうじゃん」という興味本位で JSONC を採用しました。

おそらく YAML や XML でも同じことができると思います。

ということで以下のような形でラベルを定義してみました。

[
  // color プロパティは下記のようなサイトから色を探すと便利です:
  // https://pallet-pally.vercel.app/
  {
    "name": "bug",
    "color": "d73a4a",
    "description": "Something isn't working(バグ)"
  },
  {
    "name": "enhancement",
    "color": "a2eeef",
    "description": "An enhancement of an existing feature(既存の機能の拡張)"
  },
  {
    "name": "feature",
    "color": "FBEE04",
    "description": "New feature or request (新機能)"
  },
  {
    "name": "help wanted",
    "color": "008672",
    "description": "Contributions welcome(手伝ってくださる方を歓迎します)"
  },
  {
    "name": "chore",
    "color": "BFD4F2",
    "description": "Cleaning, deleting, refactoring(雑務)"
  },
  {
    "name": "documentation",
    "color": "0075ca",
    "description": "Improvements or additions to documentation(ドキュメントの追加や改善)"
  },
  {
    "name": "duplicate",
    "color": "cfd3d7",
    "description": "This issue or pull request already exists"
  }
]

2. スクリプトを書く

次に、JSON ファイルを読み込んで GitHub でラベルを作成するスクリプトを記載していきます。言語は何でも良いですが、ここでは普段良く書いているという理由で JavaScript を使用します。

しかし、前ステップで JSONC を使うことにしたため、Node.js で普通に JSON.parse() しようとするとコメントをパースできなくて死ぬという点が問題になりました。
そこで、コメントを除去する以下のような簡単な処理を書いて対処しました。
正規表現でざっくりやっていますが、今回の用途では十分でした。

function stripJsoncComments(input) {
  // - Removes /* ... */ blocks
  // - Removes // line comments
  // - Removes # line comments
  return input
    .replace(/\/\*[\s\S]*?\*\//g, "")
    .replace(/(^|[ \t])\/\/[^\n\r]*/gm, "$1")
    .replace(/(^|[ \t])#[^\n\r]*/gm, "$1");
}

コメントの除去はできたので、後は GitHub API (Octokit)を使って、ラベルの作成と更新をするスクリプトをNode.jsで書きました。
スクリプト全文はこちらです:

// - Uses Octokit
// - Accepts a positional `{filepath}` argument
// - Reads repo from env: `REPO` or `GITHUB_REPOSITORY`
// - Keeps "last definition wins" behavior for duplicate names

import { Octokit } from "octokit";
import fs from "node:fs/promises";
import process from "node:process";

function printUsageAndExit() {
  console.error(
    "Usage: node .github/scripts/deploy_labels.js <filepath>\n" +
      "  - repo is taken from env: REPO or GITHUB_REPOSITORY"
  );
  process.exit(2);
}

function parseFilepath(argv) {
  let filepath;
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    if (a === "--help" || a === "-h") printUsageAndExit();
    if (a === "--filepath" || a === "-f") {
      filepath = argv[++i];
    } else if (!a.startsWith("-")) {
      filepath = a; // positional
    }
  }
  return filepath;
}

function stripJsoncComments(input) {
  return input
    .replace(/\/\*[\s\S]*?\*\//g, "")
    .replace(/(^|[ \t])\/\/[^\n\r]*/gm, "$1")
    .replace(/(^|[ \t])#[^\n\r]*/gm, "$1");
}

function normalizeColor(color) {
  if (!color) return undefined;
  const c = String(color).trim();
  return c.startsWith("#") ? c.slice(1) : c;
}

function previewFirstLines(text, lines = 80) {
  return text
    .split(/\r?\n/)
    .slice(0, lines)
    .join("\n");
}

async function main() {
  const filepath = parseFilepath(process.argv.slice(2)) || ".github/labels.jsonc";

  const repo = process.env.REPO || process.env.GITHUB_REPOSITORY;
  if (!repo) {
    console.error("Error: repo not set.");
    printUsageAndExit();
  }
  const [owner, repository] = repo.split("/", 2);

  let raw;
  try {
    raw = await fs.readFile(filepath, "utf8");
  } catch (e) {
    console.error(`Error: cannot read file: ${filepath}`);
    process.exit(1);
  }

  const cleaned = stripJsoncComments(raw);

  let labels;
  try {
    labels = JSON.parse(cleaned);
  } catch (e) {
    console.error("Error: Invalid JSON after comment removal");
    console.error("---- cleaned preview ----");
    console.error(previewFirstLines(cleaned));
    process.exit(1);
  }

  const byName = new Map();
  for (let i = labels.length - 1; i >= 0; i--) {
    const item = labels[i] || {};
    const name = (item.name || "").trim();
    if (name && !byName.has(name)) byName.set(name, item);
  }
  const finalLabels = Array.from(byName.values()).reverse();

  const token =
    process.env.GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_PAT;
  if (!token) {
    console.error("Error: GITHUB_TOKEN must be set.");
    process.exit(1);
  }

  const octokit = new Octokit({ auth: token });

  for (const label of finalLabels) {
    const name = label.name.trim();
    const color = normalizeColor(label.color);
    const description = label.description?.trim() || undefined;

    try {
      await octokit.rest.issues.createLabel({
        owner,
        repo: repository,
        name,
        ...(color ? { color } : {}),
        ...(description ? { description } : {}),
      });
      console.log(`created: ${name}`);
    } catch {
      try {
        await octokit.rest.issues.updateLabel({
          owner,
          repo: repository,
          name,
          ...(color ? { color } : {}),
          ...(description ? { description } : {}),
        });
        console.log(`updated: ${name}`);
      } catch (err2) {
        console.error(`failed: ${name}`);
        console.error(String(err2?.message || err2));
      }
    }
  }
}

main().catch((e) => {
  console.error(String(e?.message || e));
  process.exit(1);
});

3. GitHub Actions を設定する

最後に、上のスクリプトを GitHub Actions で「main ブランチが更新されたとき」に実行するように設定します。

今回のワークフローは以下のとおりです。

name: Update Labels

on:
  push:
    branches:
      - main
  workflow_dispatch:

jobs:
  update-labels:
    name: Update GitHub labels
    runs-on: ubuntu-latest
    steps:
    - name: Checkout the branch
      uses: actions/checkout@v5
    - name: Set up Node.js
      uses: actions/setup-node@v4
      with:
        node-version: 20
    - name: Install dependencies
      run: npm ci
      working-directory: .github/workflows/scripts
    - run: "node .github/workflows/scripts/deploy_labels.js .github/labels.jsonc"
      env:
        GITHUB_TOKEN:  ${{ secrets.GITHUB_TOKEN }}
        GITHUB_REPOSITORY: ${{ github.repository }}

4. 動作テスト

最後に act を使用して、 workflow が正しく動作するかを確かめます。
act はGitHub Actions をローカルで実行できるツールです。個人的に結構愛用してます。

今回のワークフローの確認は以下でできます:

act -W .github/workflows/update_labels.yml

ローカルで成功したら、最後に main ブランチへ push して実際にラベルが自動更新されることを確認しましょう。

おわりに

今回は複数リポジトリを管理する GitHub Projects でのラベル管理が面倒だったので、GitHub Actions でラベル管理を自動化してみました。
これにより、ラベルの自動作成、更新はできるようになりました。
しかし、JSONから削除したラベルを自動削除したり、リポジトリ間の完全な同期はまだできていないので、今後の課題としてそれらの機能を実装していきたいと思います。

また、今回作成したGitHubワークフローが入ったリポジトリのテンプレートを作成してみました。ぜひ使ってみてください。

79
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
79
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?