補習ほぼ確

学びや好きなことをただ自由に書く

Google Apps Script で issue 確認リマインドを作る

業務でメンバーへの依頼・共有がある時 issue を立て、そんな時コメント中でメンションをつけたり関係者を assign しておくということはどこでもやっていることだと思うのだけど、特に作業依頼の場合そんな issue が見逃されて放置されてしまうのはかなしい。
ということで例によって GAS & GitHub API を使い、issue が立って一定期間放置された(クローズされない)ままだと Slack にリマインドしてくれるやつを作ってみる。
Google Apps Script の作成方法としては以前の記事に書いてあるので割愛。

1. issue の一覧を取得

前回 と同じ要領で UrlFetchApp.fetch でGETリクエストを送信し、その後パースする

 var options = {
    'method': 'GET',
    'muteHttpExceptions': true,
    'Content-Type': 'application/json',
    'headers': {
      'Authorization': ' token '+ ACCESS_TOKEN,
    }
  };
  var issue_info = UrlFetchApp.fetch(ISSUES_URL, options).getContentText();
  var issues = JSON.parse(issue_info);

リクエストする ISSUES_URL(issue一覧) は今回 まだクローズされていない && 特定のラベルがついているもの に限りたいので

https://api.github.com/repos/hoge/fuga/issues?state=open&labels=調査依頼

という感じで設定。parametersは filter, sort, since,...なども設定可能

2. 作成からの経過日数を抽出

一定期間クローズされてないものを確認するため、取得した issue 一覧のそれぞれの経過日数を取得するだけのメソッド用意
issue の created_at と現在日時差のミリ秒を日数として出す

function progressDay(created_at) {
  var milli_sec_diff = new Date().getTime() - new Date(created_at).getTime();
  return milli_sec_diff / (1000 * 60 * 60 * 24); 
}

3. assign されている人がいたら通知内容に出力

今回は「一定期間クローズされてない issue 」を以下のリスト形式にし一回の通知でまとめてリマインドする、ということにする。

- issue の作成者
- issue の概要
- 作成から何日経ってしまっているのか
- (いる場合)アサインされている人

Slack webhook にリクエストを送信する際 payloadattachments に array を渡してリッチな感じに装飾できるのでこれを使う。

先ほど取得した issues を元に以下のように処理

  attachment_array = [];  
  issues.forEach(
    function(issue) {
      progress_day = progressDay(issue.created_at); // 作成からの経過日数を抽出
      if (progress_day >= 5) {
        assign_member = [];
        issue.assignees.forEach(
          function(assign) {
            assign_member.push(assign.login);
          }
        )
        assign_message = (assign_member.length) ? ':point_right: *' + assign_member.join(',') + "さんがアサインされてるよ〜!*" : '';
        
        attachment_array.push({
          title: issue.title,
          title_link: issue.html_url,
          text: issue.user.login + " さんが作成し *" + Math.floor(progress_day) + "日* 経過 " + assign_message
        });
      }
    }
  )

issue の assignees(array)からアサインされている人が見つかればそれを text に追記しているという感じ

ただここでの assign.loginGitHubのユーザ名なので、Slack上で通知してかつアサインされている人にメンションを飛ばしたいとなった場合は SlackとGitHubのユーザ名が一致してない場合厳しい & 後述の通り user_id を指定しないとメンションされない ので別にusers list APIから取得したり、何かしら GitHub のユーザから Slack の user_id をマッピングさせないといけないのでちょっと面倒。

4. webhookにリクエストを送信

前回と同様に webhook にPOSTする。
Slack の API 経由でのメンション方法は <@user_id> としなければならない( user_id はユーザープロフィールから確認できる)

function callSlackWebhook(attachments_array) {
  var options = {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify({
      channel: '#general',
      text: "<@HOGEHOGE> 5日以上閉じられていないissueがあります!確認しよう:rotating_light:",
      attachments: attachments_array,
    })
  };
  var response = UrlFetchApp.fetch(SLACK_WEBHOOK_URL, options);
  return response;
}

3 で issue 作成からの経過日数が5日以上の場合に attachment の text などを作成したので、issue 作成からの経過日数が5日以上のものが一つでもあれば 通知させる

  if (attachment_array.length) {
    callSlackWebhook(attachment_array);
  }

5. 休日・祝日を判定

GAS の時間主導型のトリガ設定では毎日特定の時間に起動するようにはできるものの Googleカレンダーの予定作成時のように「毎週平日」の設定はない。
休みの日に通知投げられるのはつらいので、営業日(休日・祝日除く平日)のみ行う

Googleカレンダー日本の祝日カレンダー のIDが ja.japanese#holiday@group.v.calendar.google.com なのでCalendarApp.getCalendarById() からカレンダー情報取得&イベント(祝日)があるかを判定できる。っていうか「毎週平日」の設定デフォルトで欲しい...

function isHoliday() {
  var today = new Date();

  // 土日
  var weekInt = today.getDay();
  if (weekInt <= 0 || 6 <= weekInt) {
    return true;
  }

  // 祝日
  var calendarId = "ja.japanese#holiday@group.v.calendar.google.com";
  var calendar = CalendarApp.getCalendarById(calendarId);
  var todayEvents = calendar.getEventsForDay(today);
  if (todayEvents.length > 0){
    return true;
  }
  return false;
}

6. いざ実行

ここまでの処理を組み合わせるとこんな感じになるので、トリガより実行する関数を reminder にして設定。

const SLACK_WEBHOOK_URL = PropertiesService.getScriptProperties().getProperty('slack_webhook_url');
const ACCESS_TOKEN = PropertiesService.getScriptProperties().getProperty('access_token');
const ISSUES_URL = PropertiesService.getScriptProperties().getProperty('issue_url');

// リマインド実行
function reminder() {
  if (isHoliday() == true) {
    return;
  }

  var options = {
    'method': 'GET',
    'muteHttpExceptions': true,
    'Content-Type': 'application/json',
    'headers': {
      'Authorization': ' token '+ ACCESS_TOKEN,
    }
  };
  var issue_info = UrlFetchApp.fetch(ISSUES_URL, options).getContentText();
  var issues = JSON.parse(issue_info);

  attachment_array = [];
  issues.forEach(
    function(issue) {
      progress_days = checkProgress(issue.created_at);
      if (progress_days >= 5) {
        assign_member = [];
        issue.assignees.forEach(
          function(assign) {
            assign_member.push(assign.login);
          }
        )
        assign_message = (assign_member.length) ? ':point_right: *' + assign_member.join(',') + 'さんがアサインされてるよ〜!*' : '';

        attachment_array.push({
          title: issue.title,
          title_link: issue.html_url,
          text: issue.user.login + ' さんが作成し *' + Math.floor(progress_days) + '日* 経過 ' + assign_message
        });
      }
    }
  )
  if (attachment_array.length) {
    callSlackWebhook(attachment_array);
  }
  return;
}

function checkProgress(created_at) {
  var milli_sec_diff = new Date().getTime() - new Date(created_at).getTime();
  return milli_sec_diff / (1000 * 60 * 60 * 24);
}

// Slackのwebhookにリクエストを送る
function callSlackWebhook(attachments_array) {
  var options = {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify({
      channel: '#general',
      text: '<@HOGEHOGE> 5日以上閉じられていないissueがあります!確認しよう:rotating_light:',
      attachments: attachments_array,
    })
  };
  var response = UrlFetchApp.fetch(SLACK_WEBHOOK_URL, options);
  return response;
}

// 休日・祝日を判定
function isHoliday() {
  var today = new Date();

  // 土日
  var weekInt = today.getDay();
  if (weekInt <= 0 || 6 <= weekInt) {
    return true;
  }

  // 祝日
  var calendarId = "ja.japanese#holiday@group.v.calendar.google.com";
  var calendar = CalendarApp.getCalendarById(calendarId);
  var todayEvents = calendar.getEventsForDay(today);
  if (todayEvents.length > 0){
    return true;
  }
  return false;
}

こんな感じに飛ぶ

スクリーンショット 2020-09-20 16 53 37

業務で使っている Slack に導入してみると検出されまくってバラエティ豊かな通知に・・・

Slack___minne_cs___GMO_Pepabo__Inc_

そして導入して検出された 見逃されて放置されてしまっていた作業依頼の issue の数も日に日に減っていっている結果に 🎉

ここでは固定で <@HOGEHOGE> 5日以上閉じられていないissueがあります!確認しよう というメンション付きメッセージを飛ばしているけど検出の上で「誰もアサインされていない issue があれば」メンションを付けるなど、チーム運用ルールと照らし合わせて改善していくとより良くできそう🤝🤝

おまけ - payload で attachments ではなく blocks を使ってみる

3 で書いた payload の attachments について reference には

An array of legacy secondary attachments. We recommend you use blocks instead.

legasy なので代わりに blocks 使って〜となっている。
Transforming your legacy message compositions with blocks でも blocks への移行について記載がある模様

そしてブラウザで今回のようなチャット上のメッセージ、モーダル、Homeタブのような UI の表示を確認したり、JSONで簡単にプロトタイピングすることができる Block Kit Builder があることを知った。これは!!めちゃくちゃ便利!!!

ということで今回のコードも blocks を使うように変えてみる。

// reminder() の中身
blocks_array = [];

// 中略

blocks_array.push({
  type: 'section',
  text: {
    type: 'mrkdwn',
    text: '<' + issue.html_url + '|' + issue.title + '> \n' + issue.user.login + ' さんが作成し *' + Math.floor(progress_day) + '日* 経過 ' + assign_message
  }
});
  // callSlackWebhook() の中身
  var options = {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify({
      channel: '#general',
      link_names: 1,
      text: '<@HOGEHOGE> 5日以上閉じられていないissueがあります!確認しよう:rotating_light:',
      blocks: blocks_array,
    })
  };
  var response = UrlFetchApp.fetch(SLACK_WEBHOOK_URL, options);

Reference: Composition objects より元の attachments と同じようにリンク付き文字列を表示するには typeフィールドで mrkdwn を宣言しないといけない。
そうするとこうなる。

スクリーンショット 2020-09-20 18 16 14

payload に設定している text の「5日以上閉じられていないissueがあります!」が表示されない 💭

ここでblocks を使用している場合に text の値が Slack 上に表示されないというのがわかり、
blocks に「5日以上閉じられていない」という概要部分を含めて表示させるため webhook にリクエストを投げる前で以下のように .unshift() した

  // reminder() の中身
  if (blocks_array.length) {
    blocks_array.unshift({
      type: 'section',
      text: {
        type: 'mrkdwn',
        text: '<@HOGEHOGE> 5日以上閉じられていないissueがあります!確認しよう:rotating_light:'
      }
    });
    callSlackWebhook(blocks_array);
  }

しかし text の値が表示されないのはチャットメッセージ上のみであり、デスクトップ通知には text の値は表示されるようだった。

チャットに出ないのであれば text 削っちゃっていいじゃんとなったのだがそうなると通知が一目でわかりにくくなってしまうので(そして required だった)、blocks と text に同じメッセージを入れることにした

payload に text を設定している 設定していない
スクリーンショット 2020-09-20 17 46 07 スクリーンショット 2020-09-20 17 46 27

text がチャットメッセージに出なくなったのはわかりにくいな〜と思いつつ、Block Kit を触っているとチャット上に出すコンテンツは全て blocks でまかなえるという風にシフトしている感じがあるので納得感はある

書いてる内容を置き換えただけだと以下のようになったが、見た目的にも使い勝手的にも attachments の方が好みではある(markdownの記法がまじるとよりコードが視認しづらい)

attachments blocks
スクリーンショット 2020-09-20 16 53 37 スクリーンショット 2020-09-20 17 57 06

がチャットのメッセージ含め色んな通知のUIを Block Kit 触りつつ blocks でいじれるのは魅力的 🦊

参考
- slackのIncoming webhookが新しくなっていたのでまとめてみた - Qiita
- Block Kit Builder を使ってインタラクティブな Slack アプリをプロトタイピングしよう - Qiita
- [Slack Bot] blocksを使ってURLが含まれるメッセージを送信した時、Slack上でURLを展開する方法 - Qiita