
はじめに
はじめまして。株式会社スタメンのエンジニアの鈴木( @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」として起票できる点も大きなメリットです。
同様の課題を抱えるチームの参考になれば幸いです。