バーンダウンチャートを送信するSlackBotの作成

## 概要
近年、スクラムを導入した開発チームが増えつつあります。
スクラムチームの質を向上させるため、プロジェクト管理者だけではなくて、チームメンバーにも開発状況を把握しやすくすることが重要だと思います。

今回は、JIRA REST APIを使って、最新のスプリント情報を取得すると、[バーンダウンチャート](https://en.wikipedia.org/wiki/Burn_down_chart)を生成して、日常的にSlackのChannelに送信するBotを紹介します。

## 要求仕様
node 12.20.1
npm 6.14.10

## JIRA REST API テスト
まずは[Chrome API Tester Tool](https://chrome.google.com/webstore/detail/talend-api-tester-free-ed/aejoelaoggembcahagimdiliamlcdmfm)を使って、[JIRA Agile REST API](https://community.atlassian.com/t5/Answers-Developer-Questions/Jira-7-Agile-REST-API-for-sprint-reports/qaq-p/529103)のテストを行う。（スクラムボードのみ支持）
METHOD: GET
URL: http://{your-domain}.atlassian.net/rest/greenhopper/latest/rapid/charts/sprintreport?rapidViewId={rapidViewId}&sprintId={sprintId}

こんな感じのレスポンスで問題ないかと思います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1109922/d3d59741-fa4a-bc19-3653-a16e1fff7f4b.png)

## SlackBot を作成する
Botの作り方は[ワークスペースで利用するボットの作成](https://slack.com/intl/ja-jp/help/articles/115005265703-%E3%83%AF%E3%83%BC%E3%82%AF%E3%82%B9%E3%83%9A%E3%83%BC%E3%82%B9%E3%81%A7%E5%88%A9%E7%94%A8%E3%81%99%E3%82%8B%E3%83%9C%E3%83%83%E3%83%88%E3%81%AE%E4%BD%9C%E6%88%90)を参照してください。
現在、[元宵節](https://ja.wikipedia.org/wiki/%E5%85%83%E5%AE%B5%E7%AF%80)を迎えることになりますので、``MagicalYuanxiao``（中国語：神奇小元宵）と名付けました。
![slack-app.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1109922/dbce8b2e-451f-16eb-ef0c-794ee1eb1c60.png)
次に、[アプリにスコープを設定する](https://qiita.com/kou_pg_0131/items/56dd81f2f4716ca292ef#%E3%82%A2%E3%83%97%E3%83%AA%E3%81%AB%E3%82%B9%E3%82%B3%E3%83%BC%E3%83%97%E3%82%92%E8%A8%AD%E5%AE%9A%E3%81%99%E3%82%8B)ように(``	chat:write``、``files:write``)の権限を追加しました。
設定後：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1109922/6dc5c1f8-0c9f-066c-a3c7-9f59371f680d.png)
さらに、作成済みのAppを送信したいChannelに追加しました。
![addapp.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1109922/36dfdb51-1bf1-a08f-c180-3f390b8d899f.png)

## SlackBot 送信テスト
HTTPクライアントのインストール

```
$ npm install axios
```
メッセージを送信する

```js:sendMsg.js
// test file
// Run "node sendMsg.js" to test access slack token
// If send message to channel is successful, the token is OK
const axios = require("axios");
const MSG_URL = "https://slack.com/api/chat.postMessage";
const slackToken = "Your slack token"; // *Slack Bot User OAuth Access Token*

async function run() {
  const res = await axios.post(
    MSG_URL,
    {
      channel: "#test", // *send to target channel*
      text: "Hello, I am magical yuanxiao!", // message content
    },
    {
      headers: {
        authorization: `Bearer ${slackToken}`,
      },
    }
  );
  console.log("Done", res.data);
}

run().catch((err) => console.log(err));
```

実行コマンド

```
$ node sendMsg.js
```

テスト結果
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1109922/7b173468-cc5a-a073-2f71-c456fa51be0c.png)

## SlackBot 設計と実装
ここからバーンダウンチャートを送信するSlackBot ``MagicalYuanxiao``のワークフローや実装部分を簡単に説明します。
### ワークフロー
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1109922/b545be7b-52e5-ac67-daaa-a09d018f6337.png)

### ライブラリの導入
・JIRAアクセス(https://www.npmjs.com/package/jira-client)
・ファイルとディレクトリの読み取りと書き込み(https://www.npmjs.com/package/fs)
・HTTPリクエスト(https://www.npmjs.com/package/request)
・時間データの操作(https://www.npmjs.com/package/moment)
・グラフ描画(https://github.com/SeanSobey/ChartjsNodeCanvas)
・環境変数の設定(https://www.npmjs.com/package/dotenv)

### スプリントデータを保存する
```js:app.js
// jira API options
const jira = new JiraApi({
  protocol: "https",
  host: jiraHost,
  username: jiraUsername,
  password: jiraPassword,
  apiVersion: "3",
  strictSSL: true,
});

// today's date
const YYYYMMDD = moment().format("YYYYMMDD");

// step1: get sprint info to generate json from jira
  let sprint = {};
  await jira
    .getSprintIssues(rapidViewId, sprintId)
    .then(function (issues) {
      sprint["id"] = issues.sprint.id; // スプリントID
      sprint["name"] = issues.sprint.name; // スプリント名
      sprint["goal"] = issues.sprint.goal ? issues.sprint.goal : 0; // 目標点数
      sprint["startDate"] = moment(issues.sprint.isoStartDate).format(
        "YYYYMMDD"
      ); // 開始日
      sprint["endDate"] = moment(issues.sprint.isoEndDate).format("YYYYMMDD"); // 終了日
      sprint["issuesPointSum"] = issues.contents.completedIssuesEstimateSum
        .value
        ? issues.contents.completedIssuesEstimateSum.value
        : 0 + issues.contents.issuesNotCompletedEstimateSum.value
        ? issues.contents.issuesNotCompletedEstimateSum.value
        : 0; // ストーリー点数合計
      sprint["notCompletedIssuesPointSum"] = issues.contents
        .issuesNotCompletedEstimateSum.value
        ? issues.contents.issuesNotCompletedEstimateSum.value
        : 0; // 未完了ストーリー点数合計(include: todo, doing, review...)
    })
    .catch(function (err) {
      console.error(err);
    });

  // json data to be written
  let jsonData = {
    code: 0,
    data: sprint,
    updateDate: moment().format("YYYY/MM/DD HH:mm:ss"),
    msg: "success",
  };
  // format json
  let text = JSON.stringify(jsonData);
  // params: directory and file name
  let file = path.join("./output/", YYYYMMDD + "_sprint_data.json");
  // write into json
  await fs.writeFile(file, text, function (err) {
    if (err) {
      console.log(err);
    } else {
      console.log("File was successfully created: " + file);
    }
  });
```

### チャートデータを用意する
```js:app.js
 // step2: get data for line chart
  var startAndEndDateDiff = moment(sprint.endDate).diff(
    moment(sprint.startDate),
    "days"
  );
  // X axis labels for line chart
  var xLabels = [];
  for (let i = 0; i <= startAndEndDateDiff; i++) {
    xLabels[i] = moment(sprint.startDate).add(i, "days").format("MM/DD");
  }

  // Y axis values for line chart
  var yValues = [];
  // from 0 to date difference(from today to startDate)
  var dateDifferenceArray = [];
  for (let i = 0; i <= moment().diff(moment(sprint.startDate), "days"); i++) {
    dateDifferenceArray.push(i);
  }
  var jsonArray = dateDifferenceArray.reverse().map(getJsonAsync);
  await Promise.all(jsonArray)
    .then(function (jsonData) {
      // 本スプリントの日別残ポイントデータを埋め込む
      yValues = jsonData.map((s) => s.data.notCompletedIssuesPointSum);
    })
    .catch(function (err) {
      console.error(err);
    });

  // guideline Y axis values
  var guidelineValues = [];
  for (let i = 0; i <= startAndEndDateDiff; i++) {
    guidelineValues.push(
      sprint.goal - Math.floor((sprint.goal * i) / startAndEndDateDiff)
    );
  }
```

### バーンダウンチャートを作成する
```js:app.js
 // step3: create burn down chart image
  const height = 400;
  const width = 700;
  const chartJSNodeCanvas = new ChartJSNodeCanvas({ width, height });
  (async () => {
    const configuration = {
      type: "line",
      data: {
        labels: xLabels,
        datasets: [
          {
            label: "Story Points remaining",
            borderColor: "rgba(255, 100, 100, 1)",
            data: yValues,
            fill: false,
            tension: 0, // straight line
          },
          {
            label: "Guideline",
            borderColor: "rgba(122, 122, 122, 1)",
            borderDash: [10, 3], // dotted line
            data: guidelineValues,
            fill: false,
            borderWidth: 1,
            tension: 0,
          },
        ],
      },
      options: {
        scales: {
          yAxes: [
            {
              ticks: {
                beginAtZero: true, // set Y min value to 0
              },
            },
          ],
        },
        elements: {
          point: {
            radius: 0, // do not show points
          },
        },
        title: {
          display: true,
          fontSize: 16,
          text:
            sprint.name +
            "(" +
            moment(sprint.startDate).format("MM/DD") +
            "~" +
            moment(sprint.endDate).format("MM/DD") +
            ")", // chart title
        },
      },
    };
    const dataUrl = await chartJSNodeCanvas.renderToDataURL(configuration);
    const base64Data = dataUrl.replace(/^data:image\/png;base64,/, "");
    await fs.writeFile(
      "./output/" + YYYYMMDD + "_burn_down_chart.png",
      base64Data,
      "base64",
      function (err) {
        if (err) {
          console.log(err);
        }
      }
    );
  })();
```

### Slackにバーンダウンチャートを送信する
```js:app.js
// step4: upload created chart to slack
  request.post(
    {
      url: UPLOAD_URL,
      formData: {
        file: fs.createReadStream(
          "./output/" + YYYYMMDD + "_burn_down_chart.png"
        ),
        token: slackToken,
        filetype: "png",
        filename: YYYYMMDD + "_burn_down_chart.png",
        channels: slackChannel, // send to XXX channel
        title: YYYYMMDD + "_burn_down_chart.png", // show this name in slack
      },
    },
    function (error, response, body) {
      if (error) {
        console.log(error);
      } else {
        console.log("Send burn down chart to slack at " + moment().format("YYYY/MM/DD HH:mm:ss"));
      }
    }
  );
```

### 実行結果
実行コマンド

```
$ node app.js
```

Slackのスクリーンショット
![burndown.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1109922/18633266-0cc0-ecd6-496f-49b20b0fc144.png)
以上でnodejsの実装部分は完成です。

## 定期実行する方法
・[Node.jsのコードを定期実行する（crontabを利用）](https://qiita.com/takashi53/items/1e8c376185c3c0ec5e18)　←おすすめ方法

例：平日18時に自動投稿を実行したい

```
$ crontab -e

00 18 * * 1-5 {nodeのフルパス} {実行ファイルのパス}

$ crontab -l
00 18 * * 1-5 {nodeのフルパス} {実行ファイルのパス}
```

・[DockerでNode.jsアプリを起動する](https://qiita.com/zhaoshibo/items/5630ebbb2fede1e48bae)


## Source Code (GitHub)
https://github.com/peepa857/magical-yuanxiao

## 今後の課題
・ガイドラインについて、祝日と休日の判定処理
・JSON読み取りについて、本スプリント先日のデータが存在していないエラーの対処方法（期間中のみ）
・カスタム看板に対して戻り値が正しいかどうか検証する

## まとめ
そもそも、本課題の解決策として、既存製品Standuply、Trooprなどは無料ではなくて、更にJIRAの管理権限が持っていない状況で、Slack自体のリマインド機能(/remind)を使って、基本的なJIRAリンクを送るリマインダー機能を実現できますけど、実感がないと思います。なので、既存製品のようなSlackBotを開発する必要があります。

## 参照サイト
[Python編記事-Slackに定期的にバーンダウンチャートを投稿するBotの作成方法](https://qiita.com/naoyukisugi/items/5a01bbdb862bcb363f98)
[Jira Software でバーンダウンチャートを使用する方法](https://www.atlassian.com/ja/agile/tutorials/burndown-charts)
[JIRA Agile REST API Reference - JIRA Agile 7.0.4](https://docs.atlassian.com/jira-software/REST/7.0.4/#agile/1.0)
[JIRA REST APIで課題情報を取得（jira-client-npmを使用）](https://qiita.com/UC-SADA/items/1824aa5d5f3b2b21afef)
[Working with the Slack API in Node.js](https://thecodebarbarian.com/working-with-the-slack-api-in-node-js.html)
[node.jsでファイルの入出力操作](https://qiita.com/shirokuman/items/509b159bf4b8dd1c41ef)
[Chart.jsによるチャート作成](http://www.kogures.com/hitoshi/javascript/chartjs/index.html)