寝ている間に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 を選びました。
以下のような理由がありました。
- スプレッドシートでタスク管理できる:エンジニア以外でも編集できます。「次はこのファイルのテストを書いて」という依頼を非エンジニアが追加することも可能です
- デプロイ・インフラ管理不要:サーバーを立てる必要がありません
- 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