blog

DeNAのエンジニアが考えていることや、担当しているサービスについて情報発信しています

2025.07.30 技術記事

Claude Code Actions を活用した継続的なリサーチシステム構築の試み

by yuto shida

#Claude Code #gemini-cli #GitHub Actions #llm #AI

こんにちは!品質管理部 SWET 第二グループ の志田です。普段は QA(Quality Assurance)業務の効率化に取り組んでいます。

先日、mizchi さんを講師に迎えた Claude Code のハンズオンセッションに参加しました。現在、そこで得た知識を実務で活かすために、様々な試行錯誤を行っています。

本記事では、Claude Code(または Gemini CLI)と GitHub Actions を組み合わせ、リサーチタスクの自動化を行う取り組みについてご紹介します。特に、過去の調査結果をナレッジとして蓄積し、それを活用して次の調査を自動的に実施することで、リサーチそのものを継続的に進化させていく実験的な取り組みについて、具体的な方法や得られた知見をお伝えします。

Deep Research の課題

AI を使ったリサーチと聞くと、多くの方が「Deep Research」のような機能を想像すると思います。私自身も初めて利用した際、その正確さと情報の網羅性に驚きました。しかし、実務で繰り返し活用するうちに、以下のような用途には不向きだと気づきました。

1. 継続的なリサーチ 毎週特定の技術トレンドを追いかけたり、競合サービスを継続的にモニタリングしたりする場合、ChatGPT や Gemini のような AI はセッション単位でしか記憶を持たないため、過去の調査結果との比較や差分抽出が困難です。

2. 複雑な情報の構造化 Deep Research が最も得意とするのは、1 枚のレポートにまとめる程度の調査です。業務上のリサーチは、情報を多層的なディレクトリ構造や複数のドキュメントに分割して管理する必要があり、現状の AI ツールでは十分対応できません。

もちろん、ChatGPT のメモリー機能を使ったり、前回の調査結果を手動で貼り付けたりする方法もありますが、できるだけ手間を省きたいですよね。そこで、こうした課題を根本的に解決するために、新たなアプローチとして Claude Code Actions を用いたリサーチシステムを構築しました。

Claude Code Actions を活用した解決策

全体像

構築した仕組みは、以下の主要コンポーネントと処理シーケンスから構成されています。

主要コンポーネント:

  • Claude Code Actions(または Gemini CLI Actions)

    • GitHub Actions をスケジューラーとして活用し、cron を用いて定期的に自動実行します。これにより、チャット UI を使わずに調査タスクを完全に自動化することが可能です。
  • GitHub リポジトリ(ナレッジベース)

    • 調査結果を GitHub のリポジトリに永続的に保存します。AI が過去のデータを参照できるようになるため、以前の調査結果との比較分析や新規情報の抽出が容易になります。

処理のシーケンス:

実際には、GitHub Actions ワークフローが中継役として機能しています。cron による定期実行をトリガーにしてワークフローが起動します。ワークフローは調査対象の Issue や PR を準備し、Claude Code Actions を起動するためのコメントを PR に投稿します。

一連の流れは以下の通りです。

architecture

GitHub Actions ワークフローの説明

GitHub の Actions と Issue/PR 機能を利用して、このシステムを以下のように運用しています。

  1. 調査テーマを GitHub Issue として登録し、特定のラベルを付与します。
  2. GitHub Actions が定期的に Issue を監視し、対応する PR を作成して AI 調査を実行するためのコメントを投稿します。
  3. Claude Code Actions がコメントをトリガーとして起動し、過去の情報を参照しつつ、差分分析を行い、結果をコメントやファイルとしてコミットします。

この仕組みで理論上は永続的なリサーチが可能ですが、実際には課題も見えてきました。

課題

ハルシネーション

AI による誤情報(ハルシネーション)が度々発生しました。出典の明示を指示するなどの対策を講じたものの、完全な抑制には至っていません。精度を向上させるために、Claude Code が利用する MCP として o3 や Deep Research API の活用など、より信頼性の高いツール導入を検討する必要があるかもしれません。

ナレッジベースの構造化

GitHub をナレッジベースとして利用する際、特に工夫がないと日次レポートをただ吐き出すだけになったり、内容が大まかすぎて実用性に欠けたりする問題が発生しました。AI の挙動を制御し、効果的に情報を蓄積するには、プロンプトを明確に定義するか、ナレッジベースにディレクトリ構造やフォーマットといったガイドラインを設けるなど、情報設計の強化が求められます。

おわりに

今回ご紹介したワークフローはまだ実用段階には至っていません。ハルシネーション対策やナレッジベースの構造化など、解決すべき課題がいくつか残っています。しかし、プロンプトやリポジトリ設計を改善することで、定期的なリサーチタスクの完全自動化だけでなく、使い続けるほどナレッジベースが成熟していく仕組みを構築できる可能性を感じています。

この記事が、生成 AI や CLI ベースの自動化ツール、GitHub Actions の活用に関心を持つ皆さんにとって、新たな挑戦のきっかけとなれば幸いです。

最後に、ワークフロー構築中に直面したいくつかの課題とそれに対する対処法、実際に使用しているワークフローファイルの構成を共有します。 GitHub Actions から Claude Code を利用する際に注意しなければいけない点も含まれていますので、是非ご活用ください。

参考情報

Claude Code Actions を利用する際の注意点

GitHub App や Bot からのコメントではトリガーできない

本記事では、Claude Code Actions を起動するために、GitHub Actions のワークフローからコメントを投稿しています。GitHub Actions で特別な設定をしない場合、リポジトリ標準の Bot アカウントであるgithub-actions[bot]がコメントを投稿しますが、Claude Code Actions は GitHub App や Bot が投稿したコメントをトリガーとして認識しない仕様です。1 そのため、ここでは**Personal Access Token (PAT)**を利用し、通常のユーザーアカウントとしてコメントを投稿することで、Claude Code Actions のトリガーが正常に動作するようにしています。

Issue からトリガーすると毎回ユニークなブランチを作成してしまう

当初は、GitHub の Issue にコメントを投稿して調査をトリガーする形式を考えていました。しかし、Claude Code Actions の仕様上、毎回ユニークなブランチが生成されてしまうことが判明しました2。そのため、継続的な調査を行うためには利用できませんでした。

この問題を解決するため、GitHub Actions で PR の作成と Claude Code Actions をトリガーするコメントの投稿を行い、PR のヘッドブランチ上で作業させる方法を採用しています。

ワークフローファイル

セットアップ方法

まず、調査タスクとなる Issue を作成します。以下のラベルを付けることで、ワークフローが認識してくれます:

必須ラベル:

  • research-task - これがないとワークフローが認識しません

調査タイプ(どちらか 1 つ):

  • recurring-research - 継続的に調査する場合
  • (ラベルなし) - 一回きりの調査の場合

調査頻度(継続的調査の場合のみ):

  • research-interval-daily - 毎日実行
  • research-interval-weekly - 週 1 回実行
  • research-interval-monthly - 月 1 回実行

優先度(オプション):

  • priority-high - 優先的に実行
  • priority-medium - 通常優先度(デフォルト)
  • priority-low - 低優先度

ワークフローファイル本体

name: Research Task Scheduler

on:
  schedule:
    # 夜間(21時〜3時)に30分ごとに実行(JST)
    - cron: "0,30 12-18 * * *" # 21:00-03:00 JST (12:00-18:00 UTC)

jobs:
  schedule-research:
    runs-on: ubuntu-latest
    permissions:
      issues: write
      contents: read
      pull-requests: read
      id-token: write

    steps:
      - name: Select and trigger research task
        uses: actions/github-script@v7
        with:
          github-token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
          script: |
            const now = new Date();
            const nowJST = new Date(now.getTime() + (9 * 60 * 60 * 1000));

            console.log(`🕐 実行時間: ${nowJST.toLocaleString('ja-JP', {timeZone: 'Asia/Tokyo'})}`);

            // 調査対象のIssueを取得
            const issues = await github.rest.issues.listForRepo({
              owner: context.repo.owner,
              repo: context.repo.repo,
              labels: 'research-task',
              state: 'open',
              sort: 'created',
              direction: 'asc'
            });

            console.log(`📋 調査対象Issue数: ${issues.data.length}`);

            const eligibleIssues = [];

            for (const issue of issues.data) {
              const labels = issue.labels.map(l => l.name);
              const isRecurring = labels.includes('recurring-research');

              if (!isRecurring) {
                // 一回きり調査: 未実行のもののみ対象
                const hasClaudeComment = await hasClaudeExecution(issue.number);
                if (!hasClaudeComment) {
                  eligibleIssues.push({
                    ...issue,
                    priority: getPriority(labels),
                    isRecurring: false
                  });
                  console.log(`✅ 一回きり調査対象: #${issue.number} - ${issue.title}`);
                }
              } else {
                // 継続的調査: 間隔チェック
                const interval = getResearchInterval(labels);
                const lastExecution = await getLastExecutionTime(issue.number);

                if (shouldExecuteRecurring(lastExecution, interval, nowJST)) {
                  eligibleIssues.push({
                    ...issue,
                    priority: getPriority(labels),
                    isRecurring: true
                  });
                  console.log(`🔄 継続的調査対象: #${issue.number} - ${issue.title} (間隔: ${interval})`);
                }
              }
            }

            if (eligibleIssues.length > 0) {
              // 優先度順でソート
              const priorityOrder = {'high': 1, 'medium': 2, 'low': 3};
              eligibleIssues.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);

              const selectedIssue = eligibleIssues[0];
              console.log(`🎯 選択されたIssue: #${selectedIssue.number} - ${selectedIssue.title}`);

              // PRを取得(全タスク必須)
              const existingPR = await findExistingPR(selectedIssue.number);

              if (existingPR) {
                const branchName = existingPR.head.ref;
                prNumber = existingPR.number; // 既存PRにコメント
                prInfo = `\n\n**調査PR**: この調査結果は既存のPR #${existingPR.number} に追加されます。\nPR URL: ${existingPR.html_url}\nブランチ: \`${branchName}\``;
                console.log(`🔗 既存PR発見: #${existingPR.number} (${branchName})`);
              } else {
                // PR が存在しない場合は新しくドラフト PR を作成
                  const repoInfo = await github.rest.repos.get({
                    owner: context.repo.owner,
                    repo: context.repo.repo
                  });
                  const baseBranch = repoInfo.data.default_branch;
                  const baseBranchInfo = await github.rest.repos.getBranch({
                    owner: context.repo.owner,
                    repo: context.repo.repo,
                    branch: baseBranch
                  });
                  const baseSha = baseBranchInfo.data.commit.sha;

                  // ブランチ名 (ハイフン区切り規約)
                  const stamp = `${nowJST.getFullYear()}${String(nowJST.getMonth() + 1).padStart(2, '0')}${String(nowJST.getDate()).padStart(2, '0')}-${String(nowJST.getHours()).padStart(2, '0')}${String(nowJST.getMinutes()).padStart(2, '0')}${String(nowJST.getSeconds()).padStart(2, '0')}`;
                  const branchName = `claude/issue-${selectedIssue.number}-${stamp}`;

                  // ブランチがまだ無ければ作成
                  try {
                    await github.rest.git.getRef({
                      owner: context.repo.owner,
                      repo: context.repo.repo,
                      ref: `heads/${branchName}`
                    });
                    console.log(`✅ 既にブランチ ${branchName} が存在します`);
                  } catch (error) {
                    if (error.status === 404) {
                      await github.rest.git.createRef({
                        owner: context.repo.owner,
                        repo: context.repo.repo,
                        ref: `refs/heads/${branchName}`,
                        sha: baseSha
                      });
                      console.log(`🌱 新規ブランチ作成: ${branchName}`);
                    } else {
                      throw error;
                    }
                  }

                  // --- 空コミットを追加して PR を作成できるようにする ---
                  let branchHeadSha;
                  try {
                    const branchInfo = await github.rest.repos.getBranch({
                      owner: context.repo.owner,
                      repo: context.repo.repo,
                      branch: branchName
                    });
                    branchHeadSha = branchInfo.data.commit.sha;
                  } catch (e) {
                    branchHeadSha = baseSha; // 直後に作成した場合は baseSha
                  }

                  if (branchHeadSha === baseSha) {
                    // tree SHA を取得
                    const treeSha = baseBranchInfo.data.commit.commit.tree.sha;
                    const emptyCommit = await github.rest.git.createCommit({
                      owner: context.repo.owner,
                      repo: context.repo.repo,
                      message: 'chore: init PR for issue #' + selectedIssue.number,
                      tree: treeSha,
                      parents: [baseSha]
                    });

                    await github.rest.git.updateRef({
                      owner: context.repo.owner,
                      repo: context.repo.repo,
                      ref: `heads/${branchName}`,
                      sha: emptyCommit.data.sha
                    });
                    console.log(`➕ 空コミットを追加 (commit: ${emptyCommit.data.sha})`);
                  }

                  // PR タイトルはイシュータイトルを流用
                  const prTitle = `Issue #${selectedIssue.number}: ${selectedIssue.title}`;

                  // PR本文: Issue本文を転記(1回きり調査の場合は Closes 行を追加)
                  const issueContent = selectedIssue.body ? `\n\n---\n\n## Issue 本文\n\n${selectedIssue.body}` : '';

                  const prBody = selectedIssue.isRecurring ?
                    `これは自動生成された下書きPRです。${issueContent}` :
                    `これは自動生成された下書きPRです。\nCloses #${selectedIssue.number}${issueContent}`;

                  const newPR = await github.rest.pulls.create({
                    owner: context.repo.owner,
                    repo: context.repo.repo,
                    title: prTitle,
                    head: branchName,
                    base: baseBranch,
                    body: prBody,
                    draft: true
                  });

                  prNumber = newPR.data.number; // 新規PRにコメント

                  prInfo = `\n\n**調査PR**: この調査結果で新しいPR #${newPR.data.number} を作成しました。\nPR URL: ${newPR.data.html_url}\nブランチ: \`${branchName}\``;
                  console.log(`📝 新しいPRを作成: #${newPR.data.number}`);
              }

              const commentBody = generateClaudeComment(selectedIssue, nowJST, selectedIssue.isRecurring) + prInfo;

              // @claudeメンション付きコメントを投稿 (必ずPRに投稿)
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: prNumber,
                body: commentBody
              });

              console.log(`✅ コメント投稿完了: PR #${prNumber}`);
            } else {
              console.log(`⏸️ 実行対象のIssueがありません`);
            }

            // ヘルパー関数群

            // 既存のPRを検索
            async function findExistingPR(issueNumber) {
              const prs = await github.rest.pulls.list({
                owner: context.repo.owner,
                repo: context.repo.repo,
                state: 'open'
              });

              return prs.data.find(pr =>
                pr.title.includes(`Issue #${issueNumber}`) ||
                pr.body.includes(`Issue #${issueNumber}`) ||
                pr.title.includes(`#${issueNumber}`)
              );
            }

            // Claude実行履歴をチェック
            async function hasClaudeExecution(issueNumber) {
              const comments = await github.rest.issues.listComments({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: issueNumber
              });
              return comments.data.some(comment =>
                comment.body.includes('@claude') &&
                comment.user.login === 'github-actions[bot]'
              );
            }

            // 最後の実行時間を取得
            async function getLastExecutionTime(issueNumber) {
              const comments = await github.rest.issues.listComments({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: issueNumber
              });

              const claudeComments = comments.data.filter(comment =>
                comment.body.includes('@claude') &&
                comment.user.login === 'github-actions[bot]'
              );

              if (claudeComments.length > 0) {
                return new Date(claudeComments[claudeComments.length - 1].created_at);
              }
              return null;
            }

            // 調査間隔を取得
            function getResearchInterval(labels) {
              if (labels.includes('research-interval-daily')) return 'daily';
              if (labels.includes('research-interval-weekly')) return 'weekly';
              if (labels.includes('research-interval-monthly')) return 'monthly';
              return 'daily'; // デフォルト
            }

            // 継続的調査の実行判定
            function shouldExecuteRecurring(lastExecution, interval, now) {
              if (!lastExecution) return true; // 初回実行

              const timeDiff = now - lastExecution;
              const oneDay = 24 * 60 * 60 * 1000;
              const oneWeek = 7 * oneDay;
              const oneMonth = 30 * oneDay;

              switch(interval) {
                case 'daily': return timeDiff >= oneDay;
                case 'weekly': return timeDiff >= oneWeek;
                case 'monthly': return timeDiff >= oneMonth;
                default: return false;
              }
            }

            // 優先度を取得
            function getPriority(labels) {
              if (labels.includes('priority-high')) return 'high';
              if (labels.includes('priority-low')) return 'low';
              return 'medium';
            }

            // Claudeコメントを生成
            function generateClaudeComment(issue, now, isRecurring) {
              // 日時を "YYYY/MM/DD HH:MM" 形式でフォーマット
              const formatDate = (d) => {
                const yyyy = d.getFullYear();
                const mm = String(d.getMonth() + 1).padStart(2, '0');
                const dd = String(d.getDate()).padStart(2, '0');
                const hh = String(d.getHours()).padStart(2, '0');
                const mi = String(d.getMinutes()).padStart(2, '0');
                return `${yyyy}/${mm}/${dd} ${hh}:${mi}`;
              };

              // 継続的調査用テンプレート
              if (isRecurring) {
                const formattedDate = formatDate(now);
                // ブランチ名生成用に "YYYYMMDD-HHMMSS" を作成
                const stamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;

                return [
                  '@claude',
                  '継続的調査タスクを開始してください。',
                  '',
                  `* **タスクID**: \`[RESEARCH] 継続調査 #${issue.number}\``,
                  `* **実行日時**: ${formattedDate}`,
                  '',
                  '詳細な指示(タスク概要)は **このPRの本文** を参照してください。',
                  '',
                  '#### **2. 調査・ドキュメント化の要件**',
                  '* **差分分析の重視**: 前回の調査結果(このPRの過去コメント **およびリポジトリ内の関連ドキュメント**)と比較し、**新しい情報や変化点**を重点的に調査・報告してください。',
                  '* **履歴の蓄積**: 調査結果は、時系列で変化を追跡できるよう、**新しいコメント**として投稿してください。',
                  '* **情報の信頼性**: 参照した情報は必ず一次ソース(公式ドキュメント、論文、公式発表など)の URL を直後に引用リンクとして添付してください。',
                  '* **確信度ラベル**: 各項目の末尾に (High / Medium / Low) の3段階で確信度を明示してください。Low の場合は「未確認情報につき要追加検証」と併記してください。',
                  '* **不確実性の表記**: 十分な根拠が得られない場合は「不明 / 要調査」と明記し、推測で埋めないでください。',
                  '* **最新性の確保**: 調査対象は **過去1ヶ月以内** に公開されたニュース/リリース情報に限定してください。',
                  '* **公開日の明記**: 各事実を報告する際、必ず情報源と共に **公開日(またはリリース日)** を記載してください。',
                  '* **ハルシネーション厳禁**: 不明な場合は「不明 / 要調査」とし、推測で補完しないでください。',
                  '* **断定表現の禁止**: 根拠のない断定表現(例: 〜に違いない、絶対〜)は避けてください。',
                  '* **重要事項の強調**: 重要な変化や新しい発見は **太字** や `引用ブロック` などで視覚的に強調してください。',
                  '* **時間制限**: 本タスクは **10分以内** に調査・報告を完了してください。',
                  '* **コミット頻度**: 調査結果を小さな単位で **コミット** し、必ず **PR ブランチ** へ **プッシュ** してください。',
                  '',
                  '---',
                  '',
                  '#### **3. 完了報告**',
                  '調査が完了したら、このPRに**調査結果**をコメントしてください。コメントには以下を含めてください。',
                  '1. 調査結果のサマリー',
                  '2. 重要な変化点や新しい発見',
                  '3. 参照元URLなどの情報ソース'
                ].join('\n');
              }

              // 一回きり調査用テンプレート
              return [
                '@claude 調査タスクを開始してください。',
                '',
                `**実行時間**: ${now.toLocaleString('ja-JP', {timeZone: 'Asia/Tokyo'})}`,
                '**調査タイプ**: 一回きり調査',
                '',
                '詳細な指示(タスク概要)は **このPRの本文** を参照してください。',
                '',
                '#### **2. 調査・ドキュメント化の要件**',
                '* **情報の信頼性**: 参照した情報は必ず一次ソース(公式ドキュメント、論文、公式発表など)の URL を直後に引用リンクとして添付してください。',
                '* **確信度ラベル**: 各項目の末尾に (High / Medium / Low) の3段階で確信度を明示してください。Low の場合は「未確認情報につき要追加検証」と併記してください。',
                '* **不確実性の表記**: 十分な根拠が得られない場合は「不明 / 要調査」と明記し、推測で埋めないでください。',
                '* **断定表現の禁止**: 根拠のない断定表現(例: 〜に違いない、絶対〜)は避けてください。',
                '* **重要事項の強調**: 重要な発見は **太字** や `引用ブロック` などで視覚的に強調してください。',
                '* **時間制限**: 本タスクは **10分以内** に調査・報告を完了してください。',
                '* **コミット頻度**: 調査結果を小さな単位でコミットし、**PR ブランチ**へプッシュしてください。',
                '* **最新性の確保**: 調査対象は **過去1ヶ月以内** に公開されたニュース/リリース情報に限定してください。',
                '* **公開日の明記**: 各事実を報告する際、必ず情報源と共に **公開日(またはリリース日)** を記載してください。',
                '* **ハルシネーション厳禁**: 不明な場合は「不明 / 要調査」とし、推測で補完しないでください。',
                '* **断定表現の禁止**: 根拠のない断定表現(例: 〜に違いない、絶対〜)は避けてください。',
                '',
                '---',
                '',
                '#### **3. 完了報告**',
                '調査が完了したら、このPRに**調査結果**をコメントしてください。'
              ].join('\n');
            }

脚注

  1. No Bot Triggers: GitHub Apps and bots cannot trigger this action. https://github.com/anthropics/claude-code-action#:~:text=No%20Bot%20Triggers%3A%20GitHub%20Apps%20and%20bots%20cannot%20trigger%20this%20action ↩︎
  2. https://github.com/anthropics/claude-code-action/blob/d4d7974604c97ec79208ca115b863b41a325d62d/src/github/operations/branch.ts#L3C1-L7C4 ↩︎

最後まで読んでいただき、ありがとうございます!
この記事をシェアしていただける方はこちらからお願いします。

recruit

DeNAでは、失敗を恐れず常に挑戦し続けるエンジニアを募集しています。