Gemini でお問い合わせ内容を要約して Issue を自動起票するワークフローを構築しました

はじめに

はじめまして。株式会社スタメンのエンジニアの鈴木( @16suzu )です。

弊社スタメンでは、組織のエンゲージメントを高めるためのサービスである TUNAG と、チャットサービスの TUNAGチャット を開発・運営しています。 弊社ではドッグフーディングの一環としまして、この TUNAG と TUNAGチャットを全社で業務利用しています。

日々、これらのサービスを運営する中で、CSチーム経由でお客様からお問い合わせをいただきます。 これまでは、 TUNAGチャット上で連絡が来た際に、PdMが手動で GitHub Issue を起票し、エンジニアチームが対応していました。 しかし、このやり方ではPdMが介在してしまうため工数負荷が課題となっておりました。今回、私はこの課題を解決するために、Google Forms をトリガーに TUNAGチャットの投稿を収集し、Gemini で要約した上で GitHub に Issue を自動起票するワークフローを Google Apps Script(GAS)で構築しました。本記事ではその設計と実装を紹介します。


※注意:本記事では検証のため AI Studio の API を使用していますが、実業務で顧客データなど機密情報を扱う場合は、データが学習に利用されない有料プランや Google Cloud Vertex AI の利用を推奨します。


課題

従来のフローには以下の問題点がありました。

  • TUNAGチャットの投稿内容を手動でコピーして、Issue を作成していた
  • 起票のタイミングが担当者に依存し、対応漏れが発生することがあった

ワークフローの全体像

そこで、以下のようなワークフローを構築しました。

CS メンバーが Priority を判断し Google Forms を送信
    ↓
GAS がフォーム回答を受信
    ↓
TUNAG チャットの API で問い合わせチャンネルの投稿とスレッド情報を取得
    ↓
Gemini API で投稿内容を要約・整形
    ↓
GitHub API で Issue を自動起票
    ↓
Issue をお問い合わせ用の GitHub Projects に入れ Priority や Type を設定する

フォームには「Tunag Chat Link」「Priority」「Ticket type」「メールアドレス」の入力項目を設け、起票時に Priority やチケットタイプを設定できるようにしました。


実装

次にコードを示します。

1. Google Formsの送信をトリガーに処理を開始

エントリーポイントとなるファイルです。 Google Forms の投稿をトリガーとし、各モジュールを順番に実行していきます。

// main.gs
const TC_CHANNEL_ID = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
const GITHUB_OWNER  = "your-org"
const GITHUB_REPO   = "your-repo"

function submitForm(event) {
  console.info(event.namedValues);
  const namedValues = event.namedValues;

  const tunagChatLink = namedValues["Tunag Chat Link"][0];
  const priority = namedValues["Priority"][0];
  const ticketType = namedValues["Ticket type"][0];
  const chatId = tunagChatLink.split("/pl/")[1]; // 問い合わせスレッドのID
  const email = namedValues["メールアドレス"][0];

  const chatMessages = getChatThread(chatId);
  const image_links  = imageLinks(chatMessages);

  const title = createIssueTitle(JSON.stringify(chatMessages));
  const body  = createIssueBody(JSON.stringify(chatMessages), tunagChatLink);
  const body_with_image = body + `\n### 画像 \n ${image_links.join("\n")}`;

  // GitHub Issueを作成
  const issue_url = createGitHubIssue(GITHUB_OWNER, GITHUB_REPO, title, body_with_image, priority, ticketType);

  // TUNAGチャットに投稿. 引数は channelId, rootId, issue_url
  postIssueUrl(TC_CHANNEL_ID, chatId, issue_url, priority, ticketType, email);
}

2. TUNAGチャット から投稿を取得

TUNAGチャット上にお問合せの投稿がされるので、それを取得します。工夫した点として、スレッド内で行われるCS、エンジニアメンバー同士の議論も取得しています。 この議論も含めてGeminiに要約させることで、GitHub Issueに起票される際の文章の信頼度を高めています。

// tunag_chat.gs
const CHAT_URL = "https://your-tunag-chat.example.com/"; // tunag chat の url
const BOT_ID   = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; // botの投稿をgeminiの解析対象から外すのに使う

// TUNAGチャット 側の投稿を全て取得する
function getChatThread(chat_id) {
  const tunag_chat_api_token = PropertiesService.getScriptProperties().getProperty('tunag_chat_api_token')
  const url = `${CHAT_URL}/api/v4/posts/${chat_id}/thread?per_page=200`;

  const headers = {
    'Authorization': 'Bearer '+ tunag_chat_api_token
  };
  const options = {
    'method': "GET",
    'headers': headers,
  };

  const response = UrlFetchApp.fetch(url, options);
  var jsonData = JSON.parse(response.getContentText());

  let res = [];
  for (const key of jsonData.order) {
    let post = jsonData.posts[key]

    // botからの投稿は含めない
    if(post.user_id == BOT_ID) { continue }

    res.push( { 
      id: post.id,
      message: post.message,
      user_id: post.user_id,
      file_ids: post.file_ids,
      created_at: formatedDate(post.create_at),
    });
  }

  return res;
}

// TUNAGチャット の元のスレッドに作成したIssueのURLを投稿する
function postIssueUrl(channelId, rootId, issue_url, priority, ticketType, email) {
  const issuerName = email.split('@')[0];
  const message = `${issuerName} が Priority \`${priority}\` TicketType \`${ticketType}\` でIssueを作成しました。 ${issue_url}`
  const tunag_chat_api_token = PropertiesService.getScriptProperties().getProperty('tunag_chat_api_token')

  const postUrl = `${CHAT_URL}/api/v4/posts`;
  const payload = {
    channel_id: channelId,
    message: message,
    root_id: rootId, // ここで返信先のメッセージIDを指定
  };
  const options = {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify(payload),
    headers: {
      'Authorization': `Bearer ${tunag_chat_api_token}`
    },
    muteHttpExceptions: true
  };

  try {
    const response = UrlFetchApp.fetch(postUrl, options);
    const responseCode = response.getResponseCode();
    
    if (responseCode === 201) {
      Logger.log('メッセージがスレッドに正常に投稿されました。');
      return JSON.parse(response.getContentText());
    } else {
      Logger.log(`投稿に失敗しました。ステータスコード: ${responseCode}`);
      Logger.log(`レスポンス内容: ${response.getContentText()}`);
      return null;
    }
  } catch (e) {
    Logger.log(`APIリクエスト中にエラーが発生しました: ${e.message}`);
    return null;
  }
}

// unix timeを日本語の年月日の日付に変換
function formatedDate(created_at){
  const date = new Date(created_at); // timestampはミリ秒単位かDate文字列
  const year = date.getFullYear();
  const month = String(date.getMonth() + 1).padStart(2, '0'); // 月は0始まりなので+1
  const day = String(date.getDate()).padStart(2, '0');
  const hours = String(date.getHours()).padStart(2, '0');
  const minutes = String(date.getMinutes()).padStart(2, '0');
  return `${year}/${month}/${day} ${hours}:${minutes}`;
}

/**
 * ファイルIDを持つチャットメッセージから、画像リンクの配列を生成する関数。
 * @param {Array<Object>} chatMessages - 各要素がIDとファイルIDを持つチャットメッセージオブジェクトの配列。
 * @returns {Array<string>} 画像へのURLリンクの配列。
 */
function imageLinks(chatMessages) {
  return chatMessages
    .filter(message => message.file_ids && message.file_ids.length > 0)
    .map(message => `${CHAT_URL}chat/pl/${message.id}`);
}

3. Gemini で要約

TUNAGチャットから取得したデータを、Gemini API を使ってフォーマットに従って要約します。 prompt に入っているのが Gemini に実際に投げているプロンプト文になります。

// gemini.gs
// geminiのモデルを指定
const MODEL_NAME = 'gemini-2.5-flash';
const GEMINI_API_URL = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL_NAME}:generateContent`;

// gemini にIssueのタイトルを作らせる
function createIssueTitle(chat_messages) {
  const prompt =
   "これはTUNAGというサービスの不具合のお問い合わせのチャットのスレッドです。 これらを要約してGitHub Issueを起票したいのでそのタイトルを考えてください。50文字以内でお願いします。 ただし最初に会社名と会社IDを[#133 会社名]の形式で入れてください。会社名が不明の場合は不要です。 [ここからチャットのスレッドです] " + chat_messages

  const API_KEY = PropertiesService.getScriptProperties().getProperty('gemini_api_token')
  const apiUrl = `${GEMINI_API_URL}?key=${API_KEY}`;  
  const payload = {
    contents: [
      {
        parts: [
          {
            text: prompt,
          },
        ],
      },
    ],
  };
  const options = {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify(payload),
    muteHttpExceptions: true, 
  };

  const response = UrlFetchApp.fetch(apiUrl, options); 
  const responseBody = response.getContentText();
  const data = JSON.parse(responseBody);
  const generatedText = data.candidates[0].content.parts[0].text;
  return generatedText;
}

// gemini にIssueの本文を作らせる
function createIssueBody(chat_messages, tunag_chat_link) {
  const format = "ここから「フォーマット」です。 ### 課題 ### 機能 ### 発生頻度 ### 緊急度と重要度 ### 発生環境 ### 再現手順 ### チャットリンク";
  const prompt = `入力された「チャットメッセージ」に対して、下記の不具合報告書の「フォーマット」の形に整えた上で出力してください。表現は簡潔にまとめてください。 フォーマットのチャットリンク項目に ${ tunag_chat_link } を記入してください。 ここから「チャットメッセージ」です。 ${chat_messages} ${format}`;

  const API_KEY = PropertiesService.getScriptProperties().getProperty('gemini_api_token');
  const apiUrl = `${GEMINI_API_URL}?key=${API_KEY}`;
  const payload = {
    contents: [
      {
        parts: [
          {
            text: prompt,
          },
        ],
      },
    ],
  };
  const options = {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify(payload),
    muteHttpExceptions: true, 
  };

  const response = UrlFetchApp.fetch(apiUrl, options);
  const responseBody = response.getContentText();
  const data = JSON.parse(responseBody);
  const generatedText = data.candidates[0].content.parts[0].text;

  return generatedText;
}

4. GitHub に Issue を起票

最後に、GitHub に Issue を起票します。起票後に Project への紐付けと、各 Field への値の設定を行います。

// github.gs
// GitHub GraphQL API で利用するID
const GITHUB_PROJECT_ID      = "xxxxxxxxxxxxxxxxxxxx";
const GITHUB_FIELD_ID_STATUS = "xxxxxxxxxxxxxxxxxxxxxxxxxxxx";
const GITHUB_OPTION_ID_TODO  = "xxxxxxxx";

const GITHUB_FIELD_ID_PRIORITY = "xxxxxxxxxxxxxxxxxxxxxxxxxxxx";
const GITHUB_OPTION_ID_LOW     = "xxxxxxxx";
const GITHUB_OPTION_ID_MIDDLE  = "xxxxxxxx";
const GITHUB_OPTION_ID_HIGH    = "xxxxxxxx";

const GITHUB_FIELD_ID_TICKET_TYPE = "xxxxxxxxxxxxxxxxxxxxxxxxxxxx";
const GITHUB_OPTION_ID_BUG        = "xxxxxxxx";
const GITHUB_OPTION_ID_UPDATE     = "xxxxxxxx";
const GITHUB_OPTION_ID_RESEARCH   = "xxxxxxxx";

function createGitHubIssue(owner="your-org", repo="your-repo", title="", body="", priority, ticketType) {
  const pat = PropertiesService.getScriptProperties().getProperty('GITHUB_PAT');
  const url = `https://api.github.com/repos/${owner}/${repo}/issues`;
  const payload = {
    title: title,
    body: body
  };
  const options = {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify(payload),
    headers: {
      'Accept': 'application/vnd.github.v3+json',
      'Authorization': `Bearer ${pat}`,
      'X-GitHub-Api-Version': '2022-11-28',
    },
    muteHttpExceptions: true
  };

  try {
    const response = UrlFetchApp.fetch(url, options);
    const result = JSON.parse(response.getContentText());

    // ステータスコードで成功/失敗を判定
    if (response.getResponseCode() === 201) {
      Logger.log('Issueが正常に作成されました: ' + result.html_url);
      Logger.log(result.node_id);
      
      // Project に追加する
      const result_add_to_project = addToProject(GITHUB_PROJECT_ID, result.node_id)
      const item_id = result_add_to_project.data.addProjectV2ItemById.item.id;

      // Todo をセットする
      setField(GITHUB_PROJECT_ID,
                item_id, // projectにおけるitem id
                GITHUB_FIELD_ID_STATUS, // fieldId(Status)
                GITHUB_OPTION_ID_TODO   // optionId(Todo)
                )
      // Priority をセットする
      setPriority(item_id, priority)
      // Ticket type をセットする
      setTicketType(item_id, ticketType)

      return result.html_url
    } else {
      Logger.log('Issue作成に失敗しました: ' + JSON.stringify(result));
    }
  } catch (e) {
    Logger.log('APIリクエスト中にエラーが発生しました: ' + e.message);
  }
}

function setPriority(item_id, priority) {
  var p_id = "";
    switch (priority) {
    case 'High':
      p_id = GITHUB_OPTION_ID_HIGH;
      break;
    case 'Middle':
      p_id = GITHUB_OPTION_ID_MIDDLE;
      break;
    case 'Low':
      p_id = GITHUB_OPTION_ID_LOW;
      break;
    default:
      // 未指定の場合はGitHub IssueのPriorityを設定しない
      return;
    }

  setField(GITHUB_PROJECT_ID, item_id, GITHUB_FIELD_ID_PRIORITY, p_id)
}

function setTicketType(item_id, ticketType){
  var tt_id = "";
    switch (ticketType) {
    case 'Bug':
      tt_id = GITHUB_OPTION_ID_BUG
      break;
    case 'Update':
      tt_id = GITHUB_OPTION_ID_UPDATE
      break;
    case '仕様調査':
      tt_id = GITHUB_OPTION_ID_RESEARCH
      break;
    default:
      // 未指定の場合はTicketTypeを設定しない
      return;
    }

  setField(GITHUB_PROJECT_ID, item_id, GITHUB_FIELD_ID_TICKET_TYPE, tt_id)
}

// add to github projects
function addToProject(projectId, issueNodeId){
  const mutation = `
    mutation addIssue($projectId: ID!, $issueNodeId: ID!) {
      addProjectV2ItemById(input: {
        projectId: $projectId,
        contentId: $issueNodeId
      }) {
        item {
          id
        }
      }
    }
  `;
  const variables = { projectId, issueNodeId };
  return callGitHubApi(mutation, variables); 
}

// Fieldをセットする関数
function setField(projectId, itemId, fieldId, optionId) {
   const mutation = `
    mutation updateItemStatus($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
      updateProjectV2ItemFieldValue(input: {
        projectId: $projectId,
        itemId: $itemId,
        fieldId: $fieldId,
        value: {
          singleSelectOptionId: $optionId
        }
      }) {
        projectV2Item {
          id
        }
      }
    }
  `;
  const variables = {
    projectId,
    itemId,
    fieldId,
    optionId
  };
  callGitHubApi(mutation, variables);
}

// GitHub GraphQL API にリクエストを送信する関数
function callGitHubApi(query, variables) {
  const GITHUB_TOKEN = PropertiesService.getScriptProperties().getProperty('GITHUB_PAT');
  const url = 'https://api.github.com/graphql';
  const headers = {
    'Authorization': `Bearer ${GITHUB_TOKEN}`,
    'Content-Type': 'application/json'
  };
  const payload = JSON.stringify({
    query: query,
    variables: variables
  });
  const options = {
    'method': 'post',
    'headers': headers,
    'payload': payload
  };
  
  try {
    const response = UrlFetchApp.fetch(url, options);
    const data = JSON.parse(response.getContentText());
    if (data.errors) {
      throw new Error(`GitHub API Error: ${JSON.stringify(data.errors)}`);
    }
    return data;
  } catch (e) {
    Logger.log(`API call failed: ${e.message}`);
    throw e;
  }
}

実際に起票された GitHub Issue

こちらが、実際に起票された Issue のサンプルです。 Projects がセットされ、StatusとPriorityも付与されています。

導入後の効果

  • フォームを送信するだけで Issue が作成されるため、担当者の負担が大幅に軽減されました
  • 起票が自動化されたことで、議論が Issue として確実に残るようになりました
  • Gemini での要約・整形により、Issue として読みやすい形式で残るようになりました

おわりに

このワークフローは Gemini と相談しながら約2日で作成することができました。 今回苦労したところは GitHub Projects に Issue をセットする部分で GitHub GraphQL を利用する点でした。 GraphQL の利用経験があまりないため、都度調査しながら進めました。

GAS は外部サービスとの連携が容易で、Google Forms のトリガーをそのまま利用できる点が今回の用途に適していました。 Gemini の活用により、単純な転記ではなく「整理された Issue」として起票できる点も大きなメリットです。

同様の課題を抱えるチームの参考になれば幸いです。

herp.careers