Google App Script を使って Bitbucket Pipelines の動いている状況を見える化した

某所で Bitbucket Cloud を使っていて、そのうえで動く CI 、 Bitbucket Pipelines を利用しています。

Bitbucket Pipelines | Atlassian

CI 時間が月 500 分を超えるまでは無料で利用できるのですが(すごい)、あっちこっちのリポジトリで使い始めたり、プッシュ頻度の高いリポジトリで使ったり
あるいは コード量が多くて CI の時間がかかるようなリポジトリ、そんなところで使い始めると途端に課金額がマッハになってしまいます
(ブランチを絞る、手動だけなどしてもいいけどそういうことじゃない)

いきなり XXXXX ドルの請求です!なんて届くと超ビビってしまうので、
毎月どれくらいの請求額になりそうか、過去はどれくらいだったのかを記録して通知するものを スプレッドシート + GAS で作ってみました。

なぜスプレッドシートと GAS なのか

同某所では GSuite を利用しているので、 Google Drive を組織的に利用できます。
GAS を使うことで、定期的にプログラムを実行することができるうえ、他の外部の何か(例えばサーバなど)について管理することが不要になります。
管理コストが少なく、かつ、スプレッドシートを基軸として、今後の分析にも使えるのでは?と考えると、スプレッドシート最高だなぁという結論になりました。

何も管理する必要なくオンラインにエクセルがいて、マクロの自動実行が出来るよって考えたらやばいと思うんだけど。

Google スプレッドシート - オンラインでスプレッドシートを作成、編集できる無料サービス

Apps Script | Google Developers
Spreadsheet Service | Apps Script | Google Developers

どのように Pipelines の利用状況を集めるか

Pipelines 、というか Bitbucket にはプログラム的に操作やデータ取得を行えるよう API が提供されています。
この中に Pipelines に関するものがあるので、これを利用します。

Bitbucket API

リポジトリ一覧を取得 → 各リポジトリに対して Pipelines の利用状況を確認 というながれで情報を集めます。

リポジトリ一覧 API : Bitbucket API
Pipelines状況 API : Bitbucket API

GAS から API を利用してデータを集める

というわけで出来上がったものがこちらです。

var BitbucketApi = {};

BitbucketApi.getAuth = function () {
  var options = {};
  options.headers = {};
  options.headers.Authorization = "Basic " + "ここに base64(ID:PW) したものを入れる";
  
  return options;
};

BitbucketApi.fetch = function (api) {
  var url = "https://api.bitbucket.org" + api;
  try {
    var rawResponse = UrlFetchApp.fetch(url, BitbucketApi.getAuth());
  } catch (ex) {
    Logger.log(ex);
    return null;
  }
  return JSON.parse(rawResponse.getContentText());
};


var Util = {};

Util.each = function(ary, cb) {
  for (var i=0, m=ary.length; i<m; i++) {
    if(cb(ary[i], i) === false) break;
  }
};

Util.getSheetByName = function (name) {
  if (Util.SpreadSheet == null) {
    Util.SpreadSheet = SpreadsheetApp.getActive();
  }
  
  return Util.SpreadSheet.getSheetByName(name);
};

Util.vlookup = function (searchValue, targetValues, column) {
  var result = null;
  Util.each(targetValues, function(row) {
    if (row[0] === searchValue) {
      result = row[column];
      return false;
    }
  });
  
  return result;
};

Util.yesterDay = function(date) {
  date = date || new Date();
  date.setDate(date.getDate() - 1);
  return date;
};

Util.startDay = function(date) {
  date = date || new Date();
  var d = new Date(date);
  d.setHours(0);
  d.setMinutes(0);
  d.setSeconds(0);
  return d;
};

Util.endDay = function(date) {
  date = date || new Date();
  var d = new Date(date);
  d.setHours(23);
  d.setMinutes(59);
  d.setSeconds(59);
  return d;
};


// reposシート、リポジトリチェック済みフラグのカラム番号
var REPOSITORY_CHECK_COLUMN = 4;


// リポジトリのチェックフラグの初期化
function initPipelinesDuration() {
  var repoSheet = Util.getSheetByName("repos");
  var repoRange = repoSheet.getRange(2, REPOSITORY_CHECK_COLUMN, repoSheet.getMaxRows() - 1, 1);
  repoRange.setValue(0);
}


// pipelinesシートの初期化
function resetPipelinesDuration() {
  Logger.log('WARNING!! Reset record durations!!  exit script.');
  return; // warning, danger.
  
  var sheet = Util.getSheetByName("pipelines");
  if (sheet.getMaxRows() > 1) {
    sheet.deleteRows(2, sheet.getMaxRows() - 1);
  }
  sheet.getRange(1, 1, sheet.getMaxRows(), sheet.getMaxColumns()).setValue("");
  sheet.getRange(1, 1, 1, 4).setValues([["repo", "build_number", "build_seconds", "completed"]]);
}


// リポジトリ一覧の同期
function syncRepositories() {
  // reposシートをまっさらにする
  var sheet = Util.getSheetByName("repos");
  if (sheet.getMaxRows() > 1) {
    sheet.deleteRows(2, sheet.getMaxRows() - 1);
  }
  sheet.getRange(1, 1, 1, 4).setValues([["repo", "url", "project", "status"]]);

  // BitbucketAPIを使ってリポジトリ名一覧をシートに移す
  var page = 1;
  while (true) {
    var repositories = BitbucketApi.fetch("/2.0/repositories/チーム名/?sort=-created_on&pagelen=100&page=" + page);
    if (repositories == null || repositories.values == null || repositories.values.length == 0) {
      break;
    }
    Util.each(repositories.values, function(repo) {
      sheet.appendRow([
        repo.slug,
        repo.links.html.href,
        repo.project.name
      ]);
    });
        
    page++;
  }  
}

// pipelinesシートの更新
function syncPipelinesDuration() {
  var sheet =  Util.getSheetByName("pipelines");
  var pipelineSheetRows = sheet.getLastRow();
  var todayStart = Util.startDay(Util.yesterDay());
  var todayEnd = Util.endDay(Util.yesterDay());
  
  var repoSheet = Util.getSheetByName("repos");
  var repos = repoSheet.getSheetValues(2, 1, repoSheet.getMaxRows(), 3);
  
  // 既にシート上にそのビルドの記録があるかチェック
  var isExistsBuild = function(repo, num) {
    var find = false;
    Util.each(sheet.getSheetValues(2, 1, sheet.getLastRow(), 2), function(row) {
      if (row[0] + row[1] === repo + num) {
        find = true;
        return false;
      }
    });
    
    return find;
  };
  
  Util.each(repos, function(repo, rowIndex) {
    if (repo[0] == "" || repo[2] == "1") {
      Logger.log(repo[0] + " : skip");
      return;
    }
    Logger.log(repo[0] + " : sync");
      
    repoSheet.getRange(rowIndex + 1, REPOSITORY_CHECK_COLUMN).setValue(1);
    
    var page = 1;
    while(true) {
      Logger.log(repo[0] + " : sync : page " + page);
      var response = BitbucketApi.fetch("/2.0/repositories/チーム名/" + repo[0] + "/pipelines/?sort=-created_on&pagelen=100&page=" + page);
      if (response == null || response.values.length == 0) {
        break;
      }
      var breakFlag = false;
      
      Util.each(response.values, function(item) {
        // 完了していないビルドはスキップ
        if (item.completed_on == undefined) {
          return;
        }
        var formattedCompletedOn = item.completed_on.replace(/T.+Z/, "");
        var completedTime = new Date(formattedCompletedOn);
        if (completedTime.getTime() > todayEnd.getTime()) {
          return;
        }
        if (completedTime.getTime() < todayStart.getTime()) {
          breakFlag = true;
          return false;
        }
        
        if (isExistsBuild(item.repository.name, item.build_number)) {
          return;
        }
        
        sheet.appendRow([
          item.repository.name,
          item.build_number,
          item.build_seconds_used,
          completedTime.toLocaleDateString(),
          '=MONTH(D' + pipelineSheetRows + ')=month(now())',
        ]);
        pipelineSheetRows++;;
      });
      
      if (breakFlag) {
        break;
      }
      
      page++;
    }
  });
};

認証周りだけちょっとアレなので、なんとかしたいなあとか思いつつも直打ち。

実装としては次の 2 つの処理に分かれます。

  • リポジトリ一覧の同期

    • 1 日 1 回 午前中 に実行
  • Pipelines 状況の取得

    • 1 日 1 回 お昼ごろ に実行
    • 1 日 1 回 おやつごろ に実行
    • 1 日 1 回 夕方ごろ に実行

リアルタイムに動かすことが難しいので、一日一回動かして、昨日分のデータを取得するようにしています。
Pipelies は 1 度に取りきることが難しいので、何回か動かします。進捗はシートに記録されるので、再度取得することはありません。

あとは、これだけだとシートがなくて動かないので、空っぽのシートを用意したら使えるはず。

  • pipelines
  • repos

収集してからのこと、集計と通知

実行するとこんな感じに Pipelines のデータが収集できるので、ここからスプレッドシートの機能で集計していきます。

ピボットテーブルを使って、当月の、各リポジトリの、実行時間(秒)を合計します。

こんな具合で設定しています。

ここから更に、少々強引な関数を組んで、このデータを参照して日毎の実行時間(分)を算出し、月単位でどれくらいの実行時間になりそうかをざっくり予測できます。

ちなみに使った関数はこんなものです。

データの参照 ( B1 )
=index(aggregate!A1:AA20)


ビルド数の抽出 ( C24 ~ xx26 )
=COUNTIF(pipelines!$D:$D,C1)


総計の抽出行 ( C25 ~ xx26 )
=if(C1="", 0/0, if(C1<>"総計", INDEX(C1:C20, LOOKUP("総計",$B1:$B20,$A$1:$A$20), 1), 0/0))


総計の分 ( C26 ~ xx26 )
=iferror(C25/60, "")


今月予測(ビルド分) ( C27 )
=average(C26:AB26)*22


今月予測(課金額) ( C28 )
=round(((C27-500)/1000)/10*10)*10

で、このときの C28 を UrlFetch で Slack に送るよう、トリガーで日々実行しています。

var SlackApi = {};
SlackApi.config = {};
SlackApi.config.webhook = "";
SlackApi.config.testMode = false;

SlackApi.sendMessage = function (text) {
  var payload = {
    text: text
  };
  
  var options = {
    method: 'post',
    payload: JSON.stringify(payload),
  };
  
  if (SlackApi.config.testMode) {
    Logger.log(SlackApi.config.webhook);
    Logger.log(options);
  } else {
    return UrlFetchApp.fetch(SlackApi.config.webhook, options);
  }
};


function sendSlack() {
  SlackApi.config.webhook = "https://hooks.slack.com/services/....";

  var sheet = Util.getSheetByName("aggregate");
  var usages = sheet.getSheetValues(2, 1, sheet.getLastRow(), sheet.getLastColumn());

  var resultMessage = [];
  resultMessage.push("> *Pipelines利用状況* (" + (new Date()) + ")");
  resultMessage.push("```");
  Util.each(usages, function(usage) {
    if (usage[0] == "") return;
    resultMessage.push(usage[0] + ": " + usage[sheet.getLastColumn() - 1] + "sec");
  });
  resultMessage.push("");
  
  try {
    var paceSheet = Util.getSheetByName("課金ペースをみたい");
    var paces = paceSheet.getSheetValues(27, 2, 2, 2);
    resultMessage.push(paces[0][0] + ": " + paces[0][1]);
    resultMessage.push(paces[1][0] + ": " + paces[1][1]);
    resultMessage.push("");
  } catch (ex) {
    Logger.log(ex);
  }

  resultMessage.push("詳細はここ> https://docs.google.com/spreadsheets/....");
  resultMessage.push("```");
  
  SlackApi.sendMessage(resultMessage.join("\n"));
}

これでいきなり多額の請求が来ることに怯えることが少なくなりました。遅くても 1 日で oops! なことに気づけるはずです。

前後の記事

Next:
Prev: