【FedEx】Google App Script(GAS)からJIRA APIにアクセスする

ZAICOでは、Android・iOS・Rubyエンジニアを絶賛募集中です! 詳しくは、採用ページをご覧ください。

好きな場所で働こう

はじめに

こんにちは。ZAICO開発チームです。
先日弊社で行われた社内ハッカソンにて、定期的にJIRAのissueを集計してSlackに結果を通知する、という処理をGoogle App Script(GAS)で実装したので紹介したいと思います。

背景

弊社ではタスク管理ツールとしてJIRAを使っていて、締め切り日(due date)を入力するようにしていますが、時々入力を忘れてしまいます。そこで毎週水曜日、入力を忘れている人にSlackでメンションしてくれるスクリプトをGASで作りました。

できたもの

こんな感じでSlackに通知してくれます。

リンクをクリックするとJIRAのページが開き、start date/due dateが入力されていないチケットを一覧できます。

実装について

Google SpreadSheetにJIRAのユーザーIDとSlackのユーザーIDをセットにしたものを記入しておきます。
JIRAのIssueのAssigneeになっている人にSlackで通知するため、JIRAのユーザーとSlackのユーザーをセットにするためにこれらを使います。

このGoogle SpreadSheetで開いたGASで、下記のような実装をしました。

// notify_over_due_date_issues.gs

// due dateが過ぎているissueについてSlackでAssigneeに通知する
function notifyOverDueDateIssues() {
  var issues = getOverDueDateIssues();
  if (issues.length == 0) {
    Logger.log("issueがありませんでした。");
    exit;
  }

  // AssigneeごとにissueのKeyをまとめる
  var keysByAssigneeId = extractIssueKeysByAssigneeId(issues);
  // 通知内容を作成
  var message = createMessageForOverDueDate(keysByAssigneeId);

  if (message == "") {
    Logger.log("通知対象がいませんでした。");
    return;
  }

  // Slackに通知
  postToChannel(message);
}

// due dateが過ぎているissueを取得する。
// @return [Array<Object>] Assigneeとissueのリストのペアになったリスト。
function getOverDueDateIssues() {
  // JQLを作成
  var jql = "(status = \"In Progress\" or status = ToDo) and assignee IS NOT Empty and duedate < now() and updated >= endOfMonth(-3)";
  // JQLを実行
  var issues = fetchByJql(jql);
  return issues
}

// Slackに通知する内容をAssigneeとIssueのセットから生成する。
// @return [String]
function createMessageForOverDueDate(keysByAssigneeId) {
  var message = "";
  // 冒頭の文
  message += "Due dateを過ぎているJIRAチケットがあります。Due dateの更新をお願いします🙇🏼\n";
  // メンション対象の人数
  var mentionedAssigneesCount = 0;

  // アクティブなシートを取得
  var sheet = SpreadsheetApp.getActiveSheet();
  var db = new DB(sheet);

  for (var assigneeId in keysByAssigneeId) {
    // assigneeからslackIDに変換
    var slackId = db.convertJiraUserIdToSlackId(assigneeId);
    if (!slackId) {
      continue;
    }

    // assigneeにメンション
    message += "<@" + slackId + ">";
    // issueのkeyからjqlのurlを作成
    var url = createIssuesUrl(keysByAssigneeId[assigneeId]);
    // 件数をurlリンクで貼り付け
    message += " <" + url + "|" + keysByAssigneeId[assigneeId].length + "件お願いします。>\n";
    mentionedAssigneesCount++;
  }

  if (mentionedAssigneesCount == 0) {
    return "";
  }

  return message;
}

JIRAでissueを検索する際に使用したJQLは下記です。3ヶ月以上更新がないissueは取得しないようにしています。
(status = "In Progress" or status = ToDo) and assignee IS NOT Empty and duedate < now() and updated >= endOfMonth(-3)

ユーティリティメソッドを書いたファイルは下記になります。

// common.gs

var JIRA_USER_NAME = "hoge@example.com";
var API_TOKEN = PropertiesService.getScriptProperties().getProperty("JIRA_API_TOKEN")
var encCred = Utilities.base64Encode(JIRA_USER_NAME + ":" + API_TOKEN);
var WEBHOOK_URL_TO_SLACK = PropertiesService.getScriptProperties().getProperty("WEBHOOK_URL_TO_SLACK");


// 引数のissueを変換し、AssigneeのユーザーIDごとのIssueのKeyの配列を返す。
// @return [Obj] AssigneeのユーザーIDをキーとして、Issueのkeyの配列を格納した連想配列
function extractIssueKeysByAssigneeId(issues) {
  var keysByAssigneeId = {}

  for (var i = 0; i < issues.length; i++) {
    var issue = issues[i];
    var assignee = issue.fields.assignee;
    if (!assignee){
      continue;
    }

    var keys = keysByAssigneeId[assignee.accountId];
    if (!keys) {
      keysByAssigneeId[assignee.accountId] = [issue.key];
    } else {
      keys.push(issue.key);
    }
  }
  
  return keysByAssigneeId;
}

// 渡されたissueのKey全てを表示するURLを生成する。
function createIssuesUrl(issues) {
  var url = "https://hogehoge.atlassian.net/issues/?jql=";
  for (var i = 0; i < issues.length; i++) {
    var issue = issues[i];
    if (i > 0) {
      url += " or ";
    }
    url += "key = " + issue;
  }

  url = encodeURI(url);
  return url;
}

// JIRA APIにアクセスしてJQLでissueを取得する。
function fetchByJql(jql) {
  var baseURL = "https://hogehoge.atlassian.net/rest/api/3/search";
  var url = encodeURI(baseURL + "?jql=" + jql + "&fields=*all");
  var data = fetchFromJiraApi(url);
  var issues = [];
  for (var id in data["issues"]) {
    if (data["issues"][id] && data["issues"][id].fields) {
      issues.push(data["issues"][id])
    }
  }

  return issues;
}

// JIRA APIにアクセスしてレスポンスを取得する。
function fetchFromJiraApi(url) {

  var fetchArgs = {
    contentType: "application/json",
    headers: { "Authorization": "Basic " + encCred },
    muteHttpExceptions: true
  };

  var httpResponse = UrlFetchApp.fetch(url, fetchArgs);
  if (httpResponse) {
    var rspns = httpResponse.getResponseCode();
    switch (rspns) {
      case 200:
        var data = JSON.parse(httpResponse.getContentText());
        return data;
      case 404:
        Logger.log("Response error, No item found");
        exit();
      default:
        Logger.log("Error: " + data.errorMessages.join(","));
        exit();
    }
  } else {
    Logger.log("Jira Error", "Unable to make requests to Jira!");
    exit();
  }
}

// Slackのチャネルに投稿する
// @param [String] message メッセージ
function postToChannel(message) {
  postToSlack(message, WEBHOOK_URL_TO_SLACK);
}

// SlackにメッセージをPOSTする。
// @param [String] message メッセージ
// @param [String] webhookUrl Webhookの送るURL
function postToSlack(message, webhookUrl) {
  var data = { text: message };
  var payload = JSON.stringify(data);

  var options = {
    method: "post",
    contentType: "application/json",
    payload: payload
  };

  UrlFetchApp.fetch(webhookUrl, options);
}

// シートの内容を扱いやすいクラスにしたもの
class DB {
  constructor(sheet) {
    this.rows = [];
    this.sheet = sheet;
    this.parseSheet();
  }

  parseSheet() {
    this.rows = [];
    for (var row = 2; row < 50; row++) {
      const name = this.sheet.getRange(`B${row}`).getValue();
      if (name == '') continue;

      const rowData = {
        row: row,
        name: name,
        jiraUserId: this.sheet.getRange(`B${row}`).getValue(),
        slackId: this.sheet.getRange(`C${row}`).getValue()
      };

      this.rows.push(rowData);
    }
  }

  // rowsをフィルタリングする
  // @param [Function] filterFunc フィルタリングする関数。 `Array.filter` へ渡す。
  // @return [Array<Map>] フィルタリングした結果
  filter(filterFunc) {
    if (typeof filterFunc !== 'function') {
      throw new Error("filterには関数を指定してください。");
    }

    return this.rows.filter(filterFunc);
  }

  // jira User IDからSlack IDに変換する
  // @return [String] Slack ID
  convertJiraUserIdToSlackId(userId) {
    var person = this.filter((r) => r.jiraUserId == userId)[0];
    if (!person) {
      return null;
    }

    return person.slackId;
  }
}

JIRA APIにアクセスするためにはJIRAのユーザーIDとアクセストークンが必要です。取得方法はJIRAの公式ヘルプをご確認ください。

ユーザーIDとアクセストークンをコードに書き込んでもいいですが、今回はGASの「スクリプト プロパティ」に設定して、下記のようにコードから取得するようにしました。
PropertiesService.getScriptProperties().getProperty()
スクリプト プロパティは環境変数のようなもので、GASに設定することができます。
今回、GASのコードをgithubにアップロードしたかったので、JIRAのアクセストークンなどをコードに含めないために、スクリプト プロパティを使いました。

GASのコードをgithubにアップロードするためにGoogle Apps Script GitHub アシスタントを使いました。

メソッドgetOverDueDateIssuesを定期的に実行されるようGASで設定すれば、定期的にSlackに通知されるようになります。

最後に

今回はDue dateが過ぎているチケットだけ通知対象としましたが、必要な入力が抜けているなどさらに応用ができそうです。
今後も改善していきたいと思います。

ZAICOでは、新しいテクノロジーの力でモノの状態・流れを把握する仕組みに一緒に取り組む仲間を募集しております。
詳しくは、採用ページをご覧ください。

好きな場所で働こう