この記事は GMOインターネットグループ Advent Calendar 2024 22日目の記事です。こんにちは!GMO NIKKO株式会社の堤です。この度は Backlog と GAS 、BigQuery、そして Slack を組み合わせて、スプリントごとのバーンダウンチャートを自動更新・共有する運用フローを構築した事例について紹介します。
はじめに
今回のシステムの実装は毎朝のデイリースクラムの後にチームの進捗状況のサマリーを事業側含めた関係者に共有することが目的になります。
まずチームのスクラム開発の前提として以下のような運用を行っています。
Backlogでタスクとマイルストンを管理1スプリント5営業日。火曜始まりの月曜終わり。特定タスクをスプリントゴールタスクとして管理。毎朝デイリースクラム、月曜日にレビューやプランニングを行う。
Backlogにはバーンダウンチャート機能がすでにありますが、チームの細かいユースケースにマッチしない点があり今回独自で実装する流れになりました。
実装の流れ
スプレッドシートの準備
一旦タスクを書き出した上で扱いやすくするためにスプレッドシートを用意しました。
「タスク一覧」シートそのスプリントのタスクのマスターシートとして使います。
「タスク履歴」シート日々の進捗履歴を残しており、前日との進捗差分の計算に使います。
「進捗集計」シート最新の状態を更新しています。
タスクの履歴からバーンダウンチャートを作成しSlackでサマリーと一緒に画像添付して共有します。
BigQueryテーブルの準備
日々のデータを蓄積するための「daily_task_status」とサマリーを格納する「sprint_summary」の二つのテーブルを準備しました。
GASの実装
あとはGASの実装をしていくのみです。コードはほぼ100%生成AIによって実装しています。コード量が意外と多くなってしまったので主要な処理を行っている部分のみ抜粋してご紹介いたします。まず、mainProcessは以下のようになっています。
function mainProcess(isPreviousSprint, resetSprint) {
  const API_KEY = 'xxxxxxx';
  const SPACE_ID = 'xxxxxxx';
  const PROJECT_KEY = 'xxxx';
  const SLACK_TOKEN = 'xxxxxxx';
  const SLACK_CHANNEL_ID = 'xxxxxx';
  const PROJECT_ID = getProjectID(SPACE_ID, PROJECT_KEY, API_KEY);
  var milestone = getMilestone(SPACE_ID, PROJECT_KEY, API_KEY, isPreviousSprint);
  if (!milestone) {
    Logger.log('マイルストーンが見つかりませんでした。');
    return;
  }
  var milestoneId = milestone.id;
  var issues = fetchBacklogData(SPACE_ID, PROJECT_ID, API_KEY, milestoneId);
  // スプレッドシート更新
  updateSpreadsheet(issues, milestone.startDate, resetSprint);
  // BigQuery更新
  var insertSummaryFlag = isPreviousSprint; // 前スプリントの場合のみ summary を挿入
  insertDataToBigQuery(issues, insertSummaryFlag);
  // Slackへの通知を実行
  postBurndownChartToSlack(SPACE_ID, API_KEY, SLACK_TOKEN, SLACK_CHANNEL_ID, issues, milestone.name);
}
スプレッドシート更新の処理
function updateSpreadsheet(issues, milestoneStartDate, resetSprint) {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var taskSheet = ss.getSheetByName('タスク一覧');
  var progressSheet = ss.getSheetByName('進捗集計');
  // タスク履歴の更新
  appendToTaskHistory(issues);
  // タスク一覧のクリア
  taskSheet.clearContents();
  taskSheet.appendRow(['タスク一覧', '合計']);
  taskSheet.appendRow(['タスク名', 'ストーリーポイント', '状態', '残ストーリーポイント', 'スプリントゴールタスク']);
  var totalStoryPoints = 0;
  var totalSprintGoalStoryPoints = 0;
  var totalRemainingStoryPoints = 0;
  var totalSprintGoalRemainingSP = 0;
  issues.forEach(function(issue) {
    var storyPoint = getCustomFieldValue(issue, 'ストーリーポイント') || 0;
    var remainingStoryPoint = getCustomFieldValue(issue, '残ストーリーポイント') || 0;
    // カテゴリ情報を取得してスプリントゴールタスクか判定
    var categories = issue.category; // 配列
    var isSprintGoal = false;
    if (categories && categories.length > 0) {
      for (var i = 0; i < categories.length; i++) {
        if (categories[i].name === 'スプリントゴール') {
          isSprintGoal = true;
          break;
        }
      }
    }
    // スプリントゴールタスクの残SPを合計
    if (isSprintGoal) {
      totalSprintGoalStoryPoints += storyPoint;
      totalSprintGoalRemainingSP += remainingStoryPoint;
    }
    // スプリントゴールタスクには「○」を記入
    var sprintGoalMark = isSprintGoal ? '○' : '';
    taskSheet.appendRow([
      issue.summary,
      storyPoint,
      issue.status.name,
      remainingStoryPoint,
      sprintGoalMark
    ]);
    totalStoryPoints += storyPoint;
    totalRemainingStoryPoints += remainingStoryPoint;
  });
  // ストーリーポイントの合計をC1に設定
  taskSheet.getRange('C1').setValue(totalStoryPoints);
  // 進捗集計の更新
  updateProgressSheet(progressSheet, totalRemainingStoryPoints, totalSprintGoalRemainingSP, milestoneStartDate, resetSprint, totalStoryPoints, totalSprintGoalStoryPoints);
}
BigQuery更新
function insertDataToBigQuery(issues, insertSummaryFlag) {
  var projectId = 'xxxx';
  var datasetId = 'xxxx';
  var dailyTableId = 'daily_task_status';
  var summaryTableId = 'sprint_summary';
  var dailyRows = [];
  var summaryRows = [];
  var milestoneDataMap = {};
  // 現在の日付とタイムスタンプを取得
  var currentDate = new Date();
  var createdAt = currentDate.toISOString(); // TIMESTAMP フォーマット
  // スプリントゴールのタスクを格納する配列
  var sprintGoalTasks = [];
  
  issues.forEach(function(issue) {
    var taskId = issue.issueKey;
    var taskName = issue.summary;
    var status = issue.status.name;
    var storyPoint = getCustomFieldValue(issue, 'ストーリーポイント') || 0;
    var remainingStoryPoint = getCustomFieldValue(issue, '残ストーリーポイント') || 0;
    // カテゴリ情報を取得
    var categories = issue.category; // 配列
    var isSprintGoal = false;
    if (categories && categories.length > 0) {
      for (var i = 0; i < categories.length; i++) {
        if (categories[i].name === 'スプリントゴール') {
          isSprintGoal = true;
          break;
        }
      }
    }
    // スプリントゴールのタスクを収集
    if (isSprintGoal) {
      sprintGoalTasks.push({
        remainingStoryPoint: remainingStoryPoint
      });
    }
    // マイルストーン情報を取得(複数ある場合は最初のものを使用)
    var milestone = issue.milestone && issue.milestone.length > 0 ? issue.milestone[0] : null;
    var milestoneId = milestone ? milestone.id : null;
    var milestoneName = milestone ? milestone.name : '';
    var milestoneStart = milestone ? milestone.startDate : null; // DATE 型
    var milestoneEnd = milestone ? milestone.releaseDueDate : null; // DATE 型
    // day の計算(マイルストーン開始日からの経過日数)
    var day = calculateDay(milestoneStart, currentDate);
    // daily_task_status テーブルへのデータ
    var dailyRow = {
      json: {
        'task_id': taskId,
        'task_name': taskName,
        'status': status,
        'day': day,
        'milestone_id': milestoneId,
        'milestone_name': milestoneName,
        'story_point': storyPoint,
        'remaining_story_point': remainingStoryPoint,
        'sprint_goal': isSprintGoal, // 追加
        'created_at': createdAt
      }
    };
    dailyRows.push(dailyRow);
    // insertSummaryFlag が true の場合、マイルストーンごとに集計
    if (insertSummaryFlag && milestoneId) {
      if (!milestoneDataMap[milestoneId]) {
        milestoneDataMap[milestoneId] = {
          'milestone_name': milestoneName,
          'milestone_id': milestoneId,
          'milestone_start': milestoneStart,
          'milestone_end': milestoneEnd,
          'total_story_points': 0,
          'completed_story_points': 0,
          'goal_achievement': false, // 追加
          'created_at': createdAt
        };
      }
      milestoneDataMap[milestoneId]['total_story_points'] += storyPoint;
      if (status === '完了' || remainingStoryPoint === 0) {
        milestoneDataMap[milestoneId]['completed_story_points'] += storyPoint;
      }
    }
  });
  // BigQuery へのデータ挿入(daily_task_status テーブル)
  if (dailyRows.length > 0) {
    var dailyRequest = {
      rows: dailyRows
    };
    BigQuery.Tabledata.insertAll(dailyRequest, projectId, datasetId, dailyTableId);
  }
  // insertSummaryFlag が true の場合、sprint_summary テーブルにもデータを挿入
  if (insertSummaryFlag && Object.keys(milestoneDataMap).length > 0) {
    // goal_achievement を計算
    var allSprintGoalsCompleted = true;
    if (sprintGoalTasks.length > 0) {
      for (var i = 0; i < sprintGoalTasks.length; i++) {
        if (sprintGoalTasks[i].remainingStoryPoint > 0) {
          allSprintGoalsCompleted = false;
          break;
        }
      }
    } else {
      // スプリントゴールのタスクがない場合の処理(要件に応じて設定)
      allSprintGoalsCompleted = false;
    }
    // milestoneDataMap の各マイルストーンに goal_achievement を設定
    for (var key in milestoneDataMap) {
      milestoneDataMap[key]['goal_achievement'] = allSprintGoalsCompleted;
      summaryRows.push({ json: milestoneDataMap[key] });
    }
    var summaryRequest = {
      rows: summaryRows
    };
    BigQuery.Tabledata.insertAll(summaryRequest, projectId, datasetId, summaryTableId);
  }
}
Slack投稿
function postBurndownChartToSlack(SPACE_ID, API_KEY, SLACK_TOKEN, SLACK_CHANNEL_ID, issues,milestonName) {
  // 全体進捗用変数
  var totalInitialSP = 0;
  var totalRemainingSP = 0;
  // スプリントゴール進捗用変数
  var sprintGoalInitialSP = 0;
  var sprintGoalRemainingSP = 0;
  // スプリントゴール判定関数(必要に応じて変更)
  function isSprintGoalIssue(issue) {
    var categories = issue.category || [];
    for (var i = 0; i < categories.length; i++) {
      if (categories[i].name === 'スプリントゴール') {
        return true;
      }
    }
    return false;
  }
  var taskDetails = '';
  issues.forEach(function(issue) {
    var initialSP = getCustomFieldValue(issue, 'ストーリーポイント') || 0;
    var remainingSP = getCustomFieldValue(issue, '残ストーリーポイント') || 0;
    // 全体合計
    totalInitialSP += initialSP;
    totalRemainingSP += remainingSP;
    var spgTag = '';
    // スプリントゴールタスクのみ集計
    if (isSprintGoalIssue(issue)) {
      sprintGoalInitialSP += initialSP;
      sprintGoalRemainingSP += remainingSP;
      spgTag = '`スプリントゴール`';
    }
    // タスク詳細行の構築
    var taskId = issue.issueKey;
    var taskName = issue.summary;
    var previousRemainingSP = getPreviousRemainingStoryPointFromHistory(taskId);
    if (previousRemainingSP === null) {
      previousRemainingSP = initialSP;
    }
    var consumPoint = initialSP - remainingSP;
    var progressRate = initialSP > 0 ? Math.floor(((consumPoint) / initialSP) * 100) : 0;
    var latestComment = getLatestComment(SPACE_ID, API_KEY, taskId);
    var detailUrl = `https://${SPACE_ID}.backlog.jp/view/${taskId}`;
    taskDetails += 
      `${spgTag} <${detailUrl}|${taskId} ${taskName}>\n` +
      `消化:${consumPoint}ポイント(${previousRemainingSP} → ${remainingSP}) 進捗率:${progressRate}%\n` +
      `${latestComment}\n` +
      "-----------------------------------------------------\n\n";
  });
  var totalProgressRate = totalInitialSP > 0 ? Math.floor(((totalInitialSP - totalRemainingSP) / totalInitialSP) * 100) : 0;
  var sprintGoalRate = sprintGoalInitialSP > 0 ? Math.floor(((sprintGoalInitialSP - sprintGoalRemainingSP) / sprintGoalInitialSP) * 100) : 0;
  var date = new Date();
  var wMap = {Sun:'(日)',Mon:'(月)',Tue:'(火)',Wed:'(水)',Thu:'(木)',Fri:'(金)',Sat:'(土)'};
  var todayDate = Utilities.formatDate(date, 'Asia/Tokyo', 'yyyy/MM/dd') + wMap[Utilities.formatDate(date, 'Asia/Tokyo', 'E')];
  var messageText =
    `スプリントタスク進捗を共有します。\n\n${todayDate}\n\nマイルストン:【${milestonName}】\n\n` +
    `全体進捗:${totalInitialSP}ポイント(${totalInitialSP} → ${totalRemainingSP}) 進捗率:${totalProgressRate}%\n` +
    `スプリントゴール進捗:${sprintGoalInitialSP}ポイント(${sprintGoalInitialSP} → ${sprintGoalRemainingSP}) 進捗率:${sprintGoalRate}%\n\n` +
    `-----------------------------------------\n` +
    taskDetails;
  // テキストメッセージを投稿
  var payload = { token: SLACK_TOKEN, channel: SLACK_CHANNEL_ID, text: messageText};
  var options = { method: 'post', payload: payload };
  var response = UrlFetchApp.fetch('https://slack.com/api/chat.postMessage', options);
  var result = JSON.parse(response.getContentText());
  if (!result.ok) {
    Logger.log('Slack へのメッセージ投稿に失敗しました: ' + result.error);
    return;
  }
  // 画像の投稿(必要な場合)
  var imageBlob = getBurndownChartImage();
  if (imageBlob) {
    var formData = {token: SLACK_TOKEN, channels: SLACK_CHANNEL_ID, file: imageBlob};
    var imgOptions = {method: 'post', payload: formData};
    var imgResponse = UrlFetchApp.fetch('https://slack.com/api/files.upload', imgOptions);
    var imgResult = JSON.parse(imgResponse.getContentText());
    if (!imgResult.ok) {
      Logger.log('Slack へのファイルアップロードに失敗しました: ' + imgResult.error);
    }
  } else {
    Logger.log('バーンダウンチャートの画像が取得できませんでした。');
  }
}
Slackの投稿のサンプル
スプリントタスク進捗を共有します。
2024/12/06(金)
マイルストン:【2024-12-03 ~ 2024-12-09】
全体進捗:30ポイント (30 → 15) 進捗率:50%
スプリントゴール進捗:10ポイント (10 → 5) 進捗率:50%
-----------------------------------------
xxxxxx-001 サンプルタスク
消化:3ポイント (6 → 3) 進捗率:50%
〇〇実装済み
-----------------------------------------------------
スプリントゴール xxxxx-002 サンプルタスク2
消化:8ポイント(8 → 0) 進捗率:100%
リリース済み
-----------------------------
~~~~
※添付画像
まとめ
今回紹介した実装により効率よく関係者にタスク状況の共有ができるようになりました。今後発展としてはBigQueryのデータからベロシティなどを可視化して生産性の確認にも役立てたいです。また、今回は簡易的にスプレッドシートやGASを使って実装しましたが管理しづらい面もあるのでCloud Run functionsへの移行なども検討したいです。同様の仕組みを導入してみたい方の参考になれば幸いです。読んでいただきありがとうございました。