TECH PLAY

GraphQL

イベント

該当するコンテンツが見つかりませんでした

マガジン

該当するコンテンツが見つかりませんでした

技術ブログ

はじめに モバイルアプリチームのリードを務めている北沢です。 myTOKYOGASは2022年5月から内製化に着手し、Webアプリについては2023年11月に内製化を完了しました。一方、モバイルアプリについては、さまざまな事情により内製化が先送りになっていました。myTOKYOGASの内製化の経緯については、以下の記事をぜひご覧ください。 tech-blog.tokyo-gas.co.jp 今回は、Web アプリの内製化以降に取り組んできたモバイルアプリの内製化と、2026年1月のリリースに至るまでの経緯についてお話ししたいと思います。 モバイルアプリ内製化の経緯 モバイルアプリ内製化チームの立ち上げ 先に述べた通り、myTOKYOGAS の Web アプリは 2023 年 11 月に内製化が完了しました。その後、Web アプリチームからのれん分けする形でモバイルアプリの内製化チームが発足し、私がリードエンジニアを務めさせていただくことになりました。 モバイルアプリの開発休止 myTOKYOGAS の開発チームは、「技術力を大事にしながらも自らが携わったプロダクトが事業や会社に貢献することを楽しむ」というプロダクト志向を大事にしています。 その中で、myTOKYOGASの開発チームのリーダーから「マイクロサービスの開発が最重要案件となったため、一度モバイルアプリの開発を停止し、マイクロサービスの開発に参画してほしい」との打診がありました。そのため、モバイルアプリ開発チームは一時解散し、メンバーは別の開発チームに一時移籍することになりました。 マイクロサービスの取り組みについては、以下の記事がございますので、ぜひご覧ください。 tech-blog.tokyo-gas.co.jp 設計を進めている最中であったこともあり、一度チームを解散することになるのは残念でしたが、この時マイクロサービスチームに入り、マイクロサービスの開発に関わったことは、技術面の成長はもちろんのこと、このあとお話するモバイルアプリチームの再結成とモバイルアプリ内製化再開時に、システム全体を見通し、設計を行うための貴重な経験となりました。 モバイルアプリチームの再結成とモバイルアプリ内製化の再開 マイクロサービスは2024年度に無事リリースされました。その間に、myTOKYOGASの開発チームに多くのメンバーが参画してくれました。そして、2025年度再度モバイルアプリチームが再結成され、内製化が再開されることになりました。私は、モバイルアプリチームに復帰し、再度チームのリードエンジニアを務めさせていただくことになりました。 新チームでの開発とリリースまで モバイルアプリチームの人数が増えたため、まず全員が同じ品質基準・設計思想で開発できるよう、アーキテクチャガイドラインやコーディング規約を整備しました。 その中でまとめていたアーキテクチャについて簡単にご紹介します。 ディレクトリ構成や Lint のような設計やコード品質を守る仕組みに関しては機会があれば別ブログで紹介させていただきます。 【システムアーキテクチャ】 システム全体の構成としては、モバイルアプリ(Flutter)が BFF(Backend For Frontend)と通信し、BFF がバックエンドのマイクロサービス群を束ねる形になっています。BFF との通信には主に GraphQL を採用していますが、一部の API では REST も併用しています。 この構成により、アプリ側は BFF が提供するインターフェースのみを意識すればよく、ビジネスロジックをサーバー側に寄せつつ、アプリは UI 表示と状態管理に集中できる設計にしました。 【モバイルアプリのアプリケーションアーキテクチャ】 フレームワークには Flutter を採用しています。アーキテクチャは Store パターンを採用し、単方向データフローによる状態管理を基本としています。 ローカルな UI 状態(例: トグルの ON/OFF)は Widget 自身が管理 API から取得するデータなどのアプリケーション状態は Store に集約し、Riverpod で管理 Store は状態の保持と更新を担い、API 呼び出し等の副作用もこの層で処理(責務を分離し、UI 層は UI 表示とユーザー操作に集中) 加えて先のシステムアーキテクチャで述べた通り、BFF がモバイルに最適化された API を提供してくれるため、アプリ側に持つビジネスロジックを最小限に抑えられ、表示の正しさ(VRT)やユーザー操作フローの検証(シナリオテスト)に注力できる設計としました。 この「設計」と「共通ルールづくり」を整備する際に、マイクロサービス開発へ参画した経験が良い効果をもたらしました。具体例として、アプリ内のスロット機能はマイクロサービス基盤と連携して動作しており、2024年度にマイクロサービスチームへ横断的に所属したことで、API の設計思想やエラー分類の考え方を理解した状態で BFF とアプリ側の設計に臨むことができました。この経験は、アプリと BFF 間のエラーハンドリングの整合性や、Store での状態管理ポリシーを決める際に大きな助けになりました。 こうして共通の土台と設計方針を持てたことで、開発方針も具体性を持って整理でき、規約やガイドラインの整備にもつながりました。その上で、チーム全員が構成を理解し、一丸となって開発に取り組み、無事リリースを迎えることができました。 当然ですが、これは私一人の力で達成できたものではありません。ガイドラインをもとに丁寧にコードを書き、レビューし合い、改善を重ねてくれたチームメンバーの貢献があってこそ実現できたものです。この場をお借りして、チームのみなさんに感謝を申し上げます。 myTOKYOGASモバイルアプリのこれから 無事に内製化が完了しましたが、内製化そのものは目的ではなく、日々変化するビジネス要望に柔軟に対応し、より大きな事業貢献をするためのスタートラインに立った状態だと考えています。Webアプリだけでなくモバイルアプリにおいても、お客さまに役立つ機能を積極的に実装していくことで、myTOKYOGASという東京ガスにとって貴重なお客さまとの接点を有効活用し、より大きな価値を提供していけると考えています。今後は内製化によって得たアジリティを活かし、お客さまに価値を感じていただける機能をスピーディーにリリースしていきたいと考えています。 おわりに 今回、モバイルアプリの内製化が完了したことで、myTOKYOGASというアプリケーション全体で迅速な開発が行える体制が整いました。今後はmyTOKYOGAS全体で、よりお客さまに価値を感じていただける機能を提供していきたいと考えています。 また、今回私自身は、モバイルアプリチームとマイクロサービスチームの両方に所属したことで、幅広い技術スタックやシステム構成を把握しながら、組織にとって最適な構成とは何かを考えて内製化を進めることができました。myTOKYOGASチームとしても、内製化が成熟してきた今、Webやモバイルといった区分けにとらわれず価値提供ができるフィーチャーチームや、全体最適を追求するLeSSのような開発体制への進化を目指しています。myTOKYOGASというプロダクトはもちろんのこと、エンジニア個人として、開発チームとしても、より大きな貢献ができるよう成長していきたいと考えています。 当チームは積極的な採用を行っています!もしこうした環境やチームに魅力を感じる方がいらっしゃいましたら、ぜひお気軽にお話をしましょう! ソフトウェアエンジニア(プロダクトエンジニア)はこちらから! tokyo-gas.snar.jp
はじめに こんにちは。人材プラットフォーム ジョブメドレーアカデミー開発グループの池田です。ジョブメドレーアカデミーは、介護や障がい福祉、在宅医療などの各業種に特化した「オンライン動画研修サービス」と「勤怠・シフト管理サービス」をWeb・アプリの両方で提供しています。開発グループでは、これら両サービスの開発・運用を担当しています。 これまで、オンライン動画研修サービスのWebフロントエンドは Next.js で構築されていましたが、長期的な運用を見据えて Vite + TanStack Router への移行を行いました。本記事では、移行に至った理由と移行作業を紹介します。 移行理由について Next.jsからViteへの移行を決定した背景は、以下の3つの理由が同時期に重なったことです。 依存関係による開発の制約 当初、デザインシステムの構築を見据え、Tailwind CSSやPanda CSSといったモダンなCSSライブラリの導入を検討していました。 しかし、当時使用していたNext.js v12が内部で保持するPostCSSのバージョンが古いために設定が競合し、導入には複雑な設定が必要であることが判明しました。また、v12自体がすでにサポート終了(EOL)を迎えており、セキュリティやメンテナンスの面でも使い続けるリスクが高まっていたため、バージョンアップまたは別構成への移行が必要な状況にありました。 デプロイツール「serverless-nextjs」のアーカイブ インフラ構築には serverless-nextjs を使用していました。しかし、同ライブラリは現在アーカイブ(開発停止)されています。将来的なバグ修正などが困難になるため、配信基盤構築の手段を別の選択肢へ置き換える必要が生じていました。 プロダクト特性に合わせた技術スタックの見直し Next.js採用時と現在ではフロントエンドのエコシステムが異なり、現在は多様な選択が可能になったと思います。そこで、改めてプロダクト特性などを踏まえて検討した結果、以下の観点からよりシンプルな構成が最適であると判断しました。 to Bサービスであり、ユーザー体験を優先する性質上、SEO観点での数値最適化(Core Web Vitals等)の必要性が比較的低い バックエンドにGraphQLを採用しており、SSRを活用しようとするとキャッシュ管理等の設計が必要であり、実装コストがかかる 検索やページネーション等のユーザー操作に伴う動的データが画面の大半を占めており、RSCを使用する恩恵を十分に享受できない 以上の理由から、Next.js の使用を継続するよりも、今のサービス特性に合わせた構成にするのが適していると考え、バージョンアップではなく Vite + TanStack Router へのリプレイスを決断しました。 移行作業について ここからは、具体的な移行作業について紹介します。多岐にわたる作業の中から、本記事では特に大きな変更点である「ルーティング」と「インフラ」の2点を取り上げます。 TanStack Routerへの移行 next/router と next/link を使用していたルーティングは、 TanStack Router へ移行しました。採用理由は、次の3点です。 パスやクエリパラメータを型安全に扱い、それに関する実装ミスをコンパイル時に検知したいため 勤怠・シフト管理サービスと技術スタックを揃え、グループ内での知見共有をスムーズにしたいため Next.js使用時と同じようにファイルベースルーティングを維持し、移行に伴う認知負荷を最小限に抑えるため 結果、クエリパラメータが型で管理されるようになったことで、これまで手動テストやE2Eテストでしか気付けなかったような不具合を、コードを書いている段階で(型エラーとして)検知できるようになりました。 加えて、どの画面でどのパラメータが使われているかが型から追いやすくなり、長期的な保守性の向上にもつながっています。 CloudFront Continuous Deployment の活用 Viteアプリケーションを配信するために、serverless-nextjs で構築されたCloudFront + S3 + Lambda@Edgeの構成から、CloudFront + S3の構成に移行することを行いました。 インフラ構成を簡素化できる一方で、大規模な刷新となるため、リリースにあたっては以下の要件を重視しました。 万が一の際に、即座に旧構成(Next.js)へロールバックできること ダウンタイムをゼロにすること(本番環境での正常動作を保証すること) まず、これまで serverless-nextjs が構築していたリソース(CloudFront・Lambda@Edge)をTerraformで再定義しました。ライブラリ任せだったインフラ構成が可視化されたことに加え、旧構成に切り戻せる体制も整えることができました。 一方、ダウンタイムなしの切り替えを実現するために、CloudFront Continuous Deployment を活用しました。 出典 : CloudFront の継続的デプロイを使用して CDN 設定の変更を安全にテストする - Amazon CloudFront (Amazon Web Services, Inc.) 具体的には、既存の Next.js 配信用の設定をPrimary distribution(画像参照)におき、新しく構築したViteアプリケーション配信用の設定をStaging distributionとして用意しました。 CloudFront Continuous Deploymentでは、リクエストヘッダーまたはトラフィックレートに基づいてリクエストを振り分けることができます。今回は特定のヘッダーを付与した場合のみ Staging distribution へリクエストが流れるよう設定し、本番ドメインかつ本番環境と同じ条件下でViteアプリケーションの検証を行いました。 検証の結果、問題がないことを確認した上で、Staging distributionの設定を Primary distributionへ上書き(昇格)させました。このプロセスにより、ダウンタイムを発生させることなく、安全に新環境への切り替えを完了できました。 おわりに 今回は、ジョブメドレーアカデミーが提供するオンライン動画研修サービスのWebフロントエンドを Next.js から Vite + TanStack Router へ移行した取り組みを紹介しました。移行理由として挙げていた、プロダクト特性に合った技術スタックの見直しの達成に加え、他2点の課題も以下のように解決されました。 課題 移行後 依存関係による制約 Vite への移行により、PostCSS起因の競合が消え、Panda CSS などのライブラリが容易に導入可能になった。 デプロイツールの アーカイブ Terraform による IaC 管理に移行し、ライブラリに依存していたインフラ構成が可視化され、メンテナンスが容易になった。 また、上記の内容に加え、ビルド速度や画面遷移速度の向上といった改善も見られました。 プロダクトを長期的に提供できるよう、今後も機能開発と並行して技術基盤の見直しや改善に継続的に取り組んでいきます。 We’re hiring! メドレーでは、プロダクトエンジニアをはじめ「医療ヘルスケアの未来」を共に創っていくエンジニアを大募集中です!少しでもご興味をお持ちいただけましたらぜひ、ご応募お待ちしております! 株式会社メドレー エンジニア の求人一覧 株式会社メドレー エンジニア の求人一覧です。| HRMOS hrmos.co ※カジュアル面談も大歓迎です!ご希望の際は、「その他の項目(希望記入欄)」にてその旨をご記載ください。
はじめに はじめまして。株式会社スタメンのエンジニアの鈴木( @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

動画

該当するコンテンツが見つかりませんでした

書籍