🙌

寝ている間にAIがコードを改善してくれる仕組みを作った

に公開

はじめに

朝起きたら、自分の代わりに誰かがコードを改善してくれていたら嬉しいと思いませんか?

この記事では、夜間に Claude Code Action を自動起動して、コード改善を自動で行う仕組み について紹介していきたいと思います。

この記事は GMOペパボ エンジニア Advent Calendar 2025 の 8日目の記事になります。

TL;DR

  • Claude Code Action は Issue をトリガーに自動でコード改善 → PR 作成してくれる
  • GAS で深夜に Issue を自動作成することで、夜間に Claude が働いてくれる仕組みを実現
  • 朝起きたら PR が届いている、という体験ができる

解決したかった課題

開発を続けていると、こういった悩みを抱えている方も多いのではないでしょうか。

  • テストコードの拡充:やりたいけど、機能開発が優先されて後回しになりがち
  • リファクタリング:技術的負債は認識しているが、時間がなくてできない
  • コード整理:「いつかやる」リストが増え続ける

これらは重要だけど緊急ではないタスクです。だからこそ、いつまでも着手されないということになりがちです。

Claude Code Action とは

Claude Code Action とは、AIコーディングエージェントである Claude Code を GitHub Actions 上で使えるようにしたカスタムアクションです。

たとえば、リポジトリのIssueに @claude 〇〇しておいて と指示を記載したら、コードの調査や、実装とプルリクエストの作成なども実現できます。

これを使い始めたとき、このように考えました。

  • Issue を定期的に自動作成すれば、夜間に Claude が働いてくれるのでは?
  • 毎回、同じ内容でIssueを登録したとしても、内容を工夫すれば機能するのでは?
    • 例「テストコードが足りない箇所をピックアップして、その箇所のテストコード書いて」

システム全体像

実現した仕組みの全体像はこのようになっています。

[深夜3時・トリガー起動]
        ↓
Google Apps Script
(スプレッドシートからIssueデータを読み取り)
        ↓
GitHub API でIssue作成
        ↓
Claude Code Action が起動
        ↓
コード改善 → PR作成
        ↓
[朝、PRをレビュー]

なぜ Google Apps Script を選んだのか

定期的に Issue を作成する方法はいくつかあります。GitHub Actions の schedule トリガーを使う方法もありますが、今回は GAS を選びました。

以下のような理由がありました。

  1. スプレッドシートでタスク管理できる:エンジニア以外でも編集できます。「次はこのファイルのテストを書いて」という依頼を非エンジニアが追加することも可能です
  2. デプロイ・インフラ管理不要:サーバーを立てる必要がありません
  3. cron 的な定期実行が簡単:GUI でトリガー設定ができます

特に「スプレッドシートでタスクを管理できる」点が大きなメリットでした。

実装解説

スプレッドシートの設計

main シートに、作成したい Issue の情報を定義します。

有効 リポジトリ タイトル 本文 assignees labels
myorg/myrepo テストカバレッジ向上 @claude ◯◯に関連する処理を調査したうえで、テストが足りていない箇所を1つピックアップして、テストコードを追加してください。 username enhancement
myorg/myrepo リファクタリング @claude path/to 以下 について、以下の構造にすると決定した。それを踏まえて、1ページずつこの構造に寄せていきたい。そこで、どれか1つピックアップして、修正をしてほしい。(以下略) username refactor
myorg/other (無効なので実行されない) ...

各カラムの説明は以下の通りです。

  • 有効:チェックが入っている行のみ処理されます
  • 本文:Issueに登録する本文、Claude Code Action への指示を書きます。
  • assignees / labels:改行区切りで複数指定することができます

※ スプシのイメージ

Issue の本文(指示)の書き方

Claude Code Action に効果的に働いてもらうには、本文(body)の書き方が重要です。

あまりに大きなタスクを依頼すると、うまく実装できないケースもありますし、レビューも大変です。
なので、スコープを小さくした指示が重要になってくるのですが、この指示を毎回人間が考えるのは手間です。
そこで、どのスコープの範囲内で対応を行うかを、Claude自身に判断させる というアプローチ を取っています。

@claude ◯◯機能関連で、テストが足りてない箇所をひとつピックアップし、テストコードを追加してください。

なお、テストダブル(スタブ、モック、スパイ)を用いるのは、APIで外部通信している部分のみとし、それ以外ではテストダブルの使用を禁止します。

コード

  • TypeScriptで実装し、tscでトランスパイルして使用しています。
  • AppsScriptへの反映は clasp を使用しています。
  • ClaudeCode に実装してもらい、人間の手は一切加えていません。
  • コードの内容はおおむね以下のとおりです。
    • mainシートからIssueデータを読み取り、GitHub APIでIssue作成
    • 土日スキップ、深夜3時の定期実行トリガー
    • 実行結果をresultシートに記録
コード詳細
function onOpen(): void {
  const ui = SpreadsheetApp.getUi();
  ui.createMenu("カスタムメニュー")
    .addItem("Issue作成", "createIssuesFromMenu")
    .addItem("定期実行トリガー設定", "setupDailyTrigger")
    .addToUi();
}

// ========================================
// ユーティリティ関数
// ========================================

/**
 * 指定した日付が平日かどうかを判定する
 * @param date 判定する日付
 * @returns 平日ならtrue、土日ならfalse
 */
function isWeekday(date: Date): boolean {
  const day = date.getDay();
  return day !== 0 && day !== 6; // 0=日曜, 6=土曜
}

/**
 * 改行区切りの文字列を配列に変換する
 * @param value 改行区切りの文字列
 * @returns 配列(空白行は除外)
 */
function parseMultilineField(value: string): string[] {
  if (!value || value.trim() === "") {
    return [];
  }
  return value
    .split("\n")
    .map((line) => line.trim())
    .filter((line) => line.length > 0);
}

// ========================================
// データ読み取り機能
// ========================================

interface IssueData {
  enabled: boolean;
  repository: string;
  title: string;
  body: string;
  assignees: string[];
  labels: string[];
}

/**
 * スプレッドシートの「main」シートからIssueデータを読み取る
 * @returns IssueDataの配列
 */
function readSpreadsheetData(): IssueData[] {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName("main");

  if (!sheet) {
    throw new Error("「main」シートが見つかりません");
  }

  const lastRow = sheet.getLastRow();
  if (lastRow < 2) {
    Logger.log("データがありません(ヘッダー行のみ)");
    return [];
  }

  // 2行目から最終行までのデータを取得(1行目はヘッダー)
  const range = sheet.getRange(2, 1, lastRow - 1, 6);
  const values = range.getValues();

  const issueDataList: IssueData[] = [];

  for (const row of values) {
    const enabled = row[0] !== "" && row[0] !== null; // 1列目: 有効/無効
    const repository = String(row[1] || "").trim(); // 2列目: リポジトリ名
    const title = String(row[2] || "").trim(); // 3列目: タイトル
    const body = String(row[3] || "").trim(); // 4列目: 本文
    const assigneesRaw = String(row[4] || ""); // 5列目: assignees
    const labelsRaw = String(row[5] || ""); // 6列目: labels

    // 有効フラグがfalseまたは必須項目が空の場合はスキップ
    if (!enabled || !repository || !title) {
      continue;
    }

    issueDataList.push({
      enabled,
      repository,
      title,
      body,
      assignees: parseMultilineField(assigneesRaw),
      labels: parseMultilineField(labelsRaw),
    });
  }

  return issueDataList;
}

// ========================================
// GitHub Issue作成機能
// ========================================

interface IssueCreateResult {
  success: boolean;
  url?: string;
  error?: string;
  statusCode?: number;
  details?: string;
}

/**
 * GitHub Enterprise ServerにIssueを作成する
 * @param repo リポジトリ名(例: "hosting/apollo")
 * @param title Issueタイトル
 * @param body Issue本文
 * @param assignees assigneesの配列
 * @param labels labelsの配列
 * @returns 作成結果(成功/失敗の詳細情報)
 */
function createGitHubIssue(
  repo: string,
  title: string,
  body: string,
  assignees: string[],
  labels: string[]
): IssueCreateResult {
  const token =
    PropertiesService.getScriptProperties().getProperty("GITHUB_TOKEN");

  if (!token) {
    const error = "GITHUB_TOKENが設定されていません";
    Logger.log(`エラー: ${error}`);
    return {
      success: false,
      error: error,
      details: "スクリプトプロパティにGITHUB_TOKENを設定してください",
    };
  }

  // ※ GitHub EnterpriseのURLを入れます
  const url = `https://example.com/api/v3/repos/${repo}/issues`;

  const payload = {
    title: title,
    body: body,
    assignees: assignees,
    labels: labels,
  };

  const options: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions = {
    method: "post",
    contentType: "application/json",
    headers: {
      Authorization: `token ${token}`,
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true,
  };

  try {
    const response = UrlFetchApp.fetch(url, options);
    const statusCode = response.getResponseCode();
    const responseText = response.getContentText();

    if (statusCode === 201) {
      const result = JSON.parse(responseText);
      Logger.log(`✓ Issue作成成功: ${result.html_url}`);
      return {
        success: true,
        url: result.html_url,
      };
    } else {
      const errorMsg = `Issue作成失敗 (${statusCode})`;
      Logger.log(`${errorMsg}: ${repo} - ${title}\n${responseText}`);
      return {
        success: false,
        error: errorMsg,
        statusCode: statusCode,
        details: responseText,
      };
    }
  } catch (error) {
    const errorMsg = `Issue作成エラー: ${error}`;
    Logger.log(`${errorMsg}: ${repo} - ${title}`);
    return {
      success: false,
      error: errorMsg,
      details: String(error),
    };
  }
}

// ========================================
// メイン実行関数
// ========================================

/**
 * カスタムメニューから手動でIssueを作成する(土日でも実行可能)
 */
function createIssuesFromMenu(): void {
  executeIssueCreation(true);
}

/**
 * トリガーから自動でIssueを作成する(土日はスキップ)
 */
function createIssuesFromTrigger(): void {
  executeIssueCreation(false);
}

/**
 * スプレッドシートからデータを読み取り、GitHub Issueを作成する
 * @param skipWeekendCheck trueの場合、土日でも実行する
 */
function executeIssueCreation(skipWeekendCheck: boolean): void {
  const now = new Date();

  // 平日チェック(skipWeekendCheckがfalseの場合のみ)
  if (!skipWeekendCheck && !isWeekday(now)) {
    const message = "土日は実行されません";
    Logger.log(message);
    SpreadsheetApp.getActiveSpreadsheet().toast(message, "実行スキップ", 5);
    return;
  }

  try {
    const issueDataList = readSpreadsheetData();

    if (issueDataList.length === 0) {
      const message = "有効なIssueデータがありません";
      Logger.log(message);
      SpreadsheetApp.getActiveSpreadsheet().toast(message, "完了", 5);
      return;
    }

    Logger.log(`${issueDataList.length}件のIssueを作成します`);
    const ss = SpreadsheetApp.getActiveSpreadsheet();

    let successCount = 0;
    let failCount = 0;
    const results: ExecutionResult[] = [];

    for (const issueData of issueDataList) {
      // 実行中のIssueをトーストで表示
      ss.toast(
        `${issueData.repository} - ${issueData.title}`,
        "Issue作成中",
        3
      );

      const result = createGitHubIssue(
        issueData.repository,
        issueData.title,
        issueData.body,
        issueData.assignees,
        issueData.labels
      );

      if (result.success) {
        successCount++;
      } else {
        failCount++;
      }

      // 実行結果を記録
      results.push({
        timestamp: now,
        repo: issueData.repository,
        title: issueData.title,
        success: result.success,
        url: result.url,
        error: result.error,
        details: result.details,
      });
    }

    // 結果をシートに記録
    saveResultsToSheet(results);

    // 結果サマリーをトーストで表示
    const summaryMessage = `Issue作成完了: 成功 ${successCount}件, 失敗 ${failCount}`;
    Logger.log(summaryMessage);
    ss.toast(summaryMessage, "完了", 10);
  } catch (error) {
    const message = `エラーが発生しました: ${error}`;
    Logger.log(message);
    SpreadsheetApp.getActiveSpreadsheet().toast(message, "エラー", 10);
  }
}

// ========================================
// トリガー設定関数
// ========================================

/**
 * 毎日深夜3時(JST)にcreateIssuesFromTriggerを実行するトリガーを設定する
 */
function setupDailyTrigger(): void {
  // 既存の同名関数のトリガーを削除
  const triggers = ScriptApp.getProjectTriggers();
  for (const trigger of triggers) {
    if (trigger.getHandlerFunction() === "createIssuesFromTrigger") {
      ScriptApp.deleteTrigger(trigger);
    }
  }

  // 新しいトリガーを作成(毎日深夜3時)
  ScriptApp.newTrigger("createIssuesFromTrigger")
    .timeBased()
    .atHour(3)
    .everyDays(1)
    .create();

  const message =
    "定期実行トリガーを設定しました(毎日3時に実行、土日はスキップ)";
  Logger.log(message);
  SpreadsheetApp.getActiveSpreadsheet().toast(message, "設定完了", 5);
}

// ========================================
// 結果表示機能
// ========================================

interface ExecutionResult {
  timestamp: Date;
  repo: string;
  title: string;
  success: boolean;
  url?: string;
  error?: string;
  details?: string;
}

/**
 * 実行結果を「result」シートに記録する
 * @param results 実行結果の配列
 */
function saveResultsToSheet(results: ExecutionResult[]): void {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  let sheet = ss.getSheetByName("result");

  // シートが存在しない場合は作成
  if (!sheet) {
    sheet = ss.insertSheet("result");
    // ヘッダー行を追加
    const headers = [
      "実行日時",
      "リポジトリ",
      "タイトル",
      "ステータス",
      "URL/エラー",
      "詳細",
    ];
    sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
    sheet.getRange(1, 1, 1, headers.length).setFontWeight("bold");
    sheet.setFrozenRows(1);
  }

  // 新しい結果を2行目に挿入(最新を上に)
  for (let i = results.length - 1; i >= 0; i--) {
    const result = results[i];
    sheet.insertRowBefore(2);

    const row = [
      Utilities.formatDate(
        result.timestamp,
        Session.getScriptTimeZone(),
        "yyyy-MM-dd HH:mm:ss"
      ),
      result.repo,
      result.title,
      result.success ? "成功" : "失敗",
      result.success ? result.url || "" : result.error || "",
      result.details || "",
    ];

    sheet.getRange(2, 1, 1, row.length).setValues([row]);

    // 背景色を設定(成功=緑、失敗=赤)
    const bgColor = result.success ? "#d9ead3" : "#f4cccc";
    sheet.getRange(2, 1, 1, row.length).setBackground(bgColor);
  }

  // 列幅を自動調整
  sheet.autoResizeColumns(1, 4);
}

運用してみて

実際に運用してみると、いくつかの気づきがありました。

良かった点

  • 継続的に改善が進む:毎日少しずつコードが改善されていきます。
  • スプレッドシートでタスク管理できる:思いついたときにサッと追加できるのでラク。

課題だと感じた点

  • CIのエラーを人間が伝える必要がある:作成されたPRにおいてCIがエラーしたとしても、それを自動でClaudeにフィードバックする仕組みを準備していないため。
  • 量があるとレビューが大変:午前中すべてレビューだけに費やしたこともあった。

まとめ

Claude Code Action を夜間に自動起動することで、寝ている間に AI がコードを改善してくれる仕組みを実現しました。

Issue 作成はあくまで手段であり、目的は AI によるコード品質の継続的改善となります。GAS を使えば、スプレッドシートで、手軽に実現することができます。朝起きたら PR が届いているという体験は、なかなか新鮮なものでした。

「いつかやりたいリファクタリング」「後回しにしているテスト追加」があるのであれば、夜間にClaude に任せてみてはいかがでしょうか。

参考リンク

Discussion