kintone + Backlog API 連携やってみた（その３）

追記： 2019-12-24
[kintone-cli](https://github.com/kintone/kintone-cli)を使った開発環境のセットアップ情報を追記しました。

追記： 2019-12-21
[JBUG静岡 #0](https://jbug.connpass.com/event/156541/)の為にシステム構成図などを追記しました。

---
kintoneアドベントカレンダーで書いた記事の続きです。
一旦やりたい事は出来たので終わりにしたいと思います。

* [kintone + Backlog API 連携やってみた](https://qiita.com/sy250f/items/18364ced702b1dfcc198)
* [kintone + Backlog API 連携やってみた（その２）](https://qiita.com/sy250f/items/aaffa6c65a212dc1d350)

# 概要
kintoneからBacklogの「[ユーザーの最近の活動の取得](https://developer.nulab.com/ja/docs/backlog/api/2/get-user-recent-updates/#%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC%E3%81%AE%E6%9C%80%E8%BF%91%E3%81%AE%E6%B4%BB%E5%8B%95%E3%81%AE%E5%8F%96%E5%BE%97)」APIを使って、データを取得して、それを別の集計用のkintoneアプリにレコードを登録します。

## できる事

普段Backlogとkintoneを使っている方なら、Backlogの活動履歴（例えば、課題を作成したとか、GitにPushしたとか、Wikiを更新したなどなど）をkintoneに渡して、kintoneの機能を使ってグラフ集計したり出来ます。

![スクリーンショット 2019-12-11 10.37.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214523/6085853e-26c2-0fde-36f7-e5c1a3e82979.png)


## 用途

会議でエンジニアがどのくらい作業をしているかの一つの指標として、アウトプットをどれくらいしているかがあると思っています。
それをある程度見える化出来ます。
Backlogを更新してなくても、仕事はちゃんとやっているという方もいるかと思いますが、自分の作業を見えるようにアウトプットする事は大事だと思います。
![スクリーンショット 2019-12-11 10.39.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214523/7cdd44fe-af5b-9535-df14-c494c79b8312.png)

# 環境

* macOS 10.14.6
* kintone-cli 0.1.0

その他は設定ファイル等をご確認ください。

# システム構成図
システム構成のイメージです。
2019-12-21追記
![BacklogToKintone.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214523/eb63df5e-7d19-b85a-015a-767a1a175a0d.png)


# 管理画面

<img width="1141" alt="スクリーンショット 2019-12-11 9.23.29.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214523/5e02f096-e99e-9cbd-0adc-401a340054c6.png">

シンプルにデータを取得する先のBacklogの情報を入力するフィールドと取得したデータを登録する先のkintoneアプリIDを入力するフィールドがあるだけです。

以下に貼り付けるコードでは、新規に登録するだけなので、再度登録する時にはレコードを削除してから登録するなどしてください。

あと、エラーチェックとかバリデーションなどはほぼやっておりませんのでご注意ください。

# コード

## カスタマイズビューのHTML

```html
<div id="backlog"></div>
```

## index.jsx
カスタマイズビューにフォームを描画する処理です。

```index.jsx
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import * as App from './js/App';
import * as Common from './js/common';

(() => {
  kintone.events.on('app.record.index.show', event => {
    if (Common.getViewId(event) !== Common.VIEW_ID) {
      return;
    }
    ReactDOM.render(
      <App.App />,
      document.getElementById('backlog')
    );
    return event;
  });
})();
```

## App.js
フォームの共通コンポーネントの作成とデータ取得・登録する処理です。

```App.js
import * as React from 'react';
import { Text, Button } from '@kintone/kintone-ui-component';
import * as Common from './common';
import '../css/index.css';
import 'bulma/css/bulma.css'

export class App extends React.Component {
  constructor (props) {
    super(props)
    // 親コンポーネントstate
    this.state = {
      account: '',
      apikey: '',
      appid: ''
    }
  }
  accountChange(value) {
    console.log(value)
    this.setState({account: value})
  }
  apikeyChange(value) {
    console.log(value)
    this.setState({apikey: value})
  }  
  appidChange(value) {
    console.log(value)
    this.setState({appid: value})
  }  
  clickSubmit() {
    console.log(`Account: ${this.state.account} APIKey: ${this.state.apikey} AppId: ${this.state.appid}`)
    getBacklogMyActivities(this.state.account, this.state.apikey, this.state.appid)
  }  
  render () {
    return (
        <div className="container">
          <div className="notification">
            <h1 className="title">
              Backlog設定
            </h1>
            <div className="field is-horizontal">
              <div className="field-label is-normal">
                <label className="label">Backlog URL</label>
              </div>
              <div className="field-body">
                <div className="field">
                  <p className="control is-expanded">
                    <UIText
                      onChange={(value) => this.accountChange(value)}
                    />
                  </p>
                </div>  
              </div>
            </div>
            <div className="field is-horizontal">
              <div className="field-label is-normal">
                    <label className="label">Backlog API Key</label>
              </div>
              <div className="field-body">
                <div className="field">
                  <p className="control is-expanded">
                    <UIText
                      onChange={(value) => this.apikeyChange(value)}
                    />
                  </p>
                </div>
              </div>
            </div>
            <div className="field is-horizontal">
              <div className="field-label is-normal">
                    <label className="label">kintoneアプリID</label>
              </div>
              <div className="field-body">
                <div className="field">
                  <p className="control is-expanded">
                    <UIText
                      onChange={(value) => this.appidChange(value)}
                    />
                  </p>
                </div>
              </div>
            </div>
            <div className="field is-horizontal">
              <div className="field-label is-normal">
                    <label className="label"></label>
              </div>
              <div className="field-body">
                <div className="field">
                  <p className="control is-expanded">
                    <UIButton
                      onClickSubmit={(event) => this.clickSubmit(event)}
                    />
                  </p>
                </div>  
              </div>
            </div>
          </div>  
        </div>
      )
  }
}


// Textコンポーネント
export class UIText extends React.Component {
  constructor(props) {
      super(props);
      this.state = {
          value: '',
      }
  }
  render() {
      return (
          <Text
            value={this.state.value}
            onChange={this.onChange.bind(this)}
          />
      );
  };
  onChange = (value) => {
      this.setState({value});
      this.props.onChange(value);
  }
};

// Submitボタンコンポーネント
export class UIButton extends React.Component {
    constructor(props) {
        super(props);
    }
    render() {
        return (
            <Button text='Submit' type='submit' isDisabled={false} isVisible={true} onClick={this.handleButtonClick} />
        );
    }
    handleButtonClick = (event) => {
        this.props.onClickSubmit(event)
    }
}

// Backlog API を叩く
const getBacklogMyActivities = (url, apikey, appid) => {
  const BACKLOG_URL = url; // https://<My Backlog Account>.backlog.jp or backlog.com
  const APIKEY = apikey;
  const APPID = appid;
  // debug用
  console.log(`URL=>${url}, APIKey=>${apikey}, AppId=>${appid}`);

  kintone.proxy('https://' + BACKLOG_URL + '/api/v2/users/myself?apiKey=' + APIKEY, 'GET', {}, {})
  .then(function(resp)
  {
    const body = JSON.parse(resp[0]);
    kintone.proxy('https://' + BACKLOG_URL + '/api/v2/users/' + body.id + '/activities?apiKey=' + APIKEY, 'GET', {}, {})
    .then(function(resp)
    {
      const body = JSON.parse(resp[0]);
      const records = [];
      body.map( value => {
        records.push(Common.preparePostData(value));
        console.log(
          `${value.id},${value.createdUser.id},${value.createdUser.name},${value.created},${value.type},${value.project.projectKey}`,
          Common.isInclude(value.type, [5, 6, 7]) ? `${value.content.name},` : `${value.content.summary},`
        );
      });
      const post_body = {"app": APPID, "records": records};
      console.log(post_body);
      kintone.api(kintone.api.url('/k/v1/records', true), 'POST', post_body)
      .then(function(resp){
        console.log(resp);
      });
    })
  }).catch(function(error) {
    console.log(error);
  });
}
```

## common.js
共通の関数をまとめてあります。

```common.js
export const VIEW_ID = 5737650;
export const getViewId = (kintone_event) => {
  return kintone_event.viewId;
};
export function isInclude(type, arrayType) {
  if (arrayType.includes(type)) {
    return true;
  }
  return false;
}
export function preparePostData(resp) {
  const ret = {
    "activities_id": {"value": resp.id},
    "user_id": {"value": resp.createdUser.id},
    "datetime": {"value": resp.created},
    "type": {"value": resp.type},
    "projectkey": {"value": resp.project.projectKey},
    "ticket_title": {"value": isInclude(resp.type, [5, 6, 7]) ? resp.content.name : resp.content.summary}
  };
  return ret;
}
```

## index.css
これ以外のスタイルは[Bluma](https://bulma.io/)というCSSフレームワークを使っています。

```index.css
.App {
    margin: 1rem;
    font-family: Arial, Helvetica, sans-serif;
}
.kuc-input-text {
    display: inline-block;
    width: 30em;
}
```

## 設定ファイルなど
基本的には kintone-cli を使ってプロジェクト以下の設定ファイル等を自動で作成しています。

### package.json

```package.json
{
  "name": "b2k01",
  "version": "0.0.1",
  "description": "kintone customization project",
  "author": "K.Y",
  "license": "MIT",
  "dependencies": {
    "@kintone/kintone-js-sdk": "^0.6.2",
    "@kintone/kintone-ui-component": "^0.4.0",
    "bulma": "^0.8.0",
    "bulma-start": "0.0.3",
    "react": "^16.8.6",
    "react-bulma-components": "^3.1.3",
    "react-dom": "^16.7.0"
  },
  "devDependencies": {
    "@babel/cli": "^7.7.4",
    "@babel/core": "^7.3.3",
    "@babel/plugin-proposal-class-properties": "^7.3.3",
    "@babel/plugin-syntax-dynamic-import": "^7.2.0",
    "@babel/plugin-transform-modules-commonjs": "^7.7.4",
    "@babel/polyfill": "^7.7.0",
    "@babel/preset-env": "^7.3.1",
    "@babel/preset-react": "^7.0.0",
    "@cybozu/eslint-config": ">=7.1.0",
    "@kintone/customize-uploader": "^2.0.5",
    "babel-jest": "^24.9.0",
    "babel-loader": "^8.0.5",
    "core-js": "^3.2.1",
    "css-loader": "^2.1.0",
    "eslint": "^6.5.1",
    "jest": "^24.9.0",
    "local-web-server": "^2.6.1",
    "regenerator-runtime": "^0.13.3",
    "style-loader": "^0.23.1",
    "webpack": "^4.30.0",
    "webpack-cli": "^3.2.3"
  },
  "scripts": {
    "dev": "ws",
    "build-backlog2kintone": "webpack --config backlog2kintone/webpack.config.js",
    "lint-all": "eslint . --ext .js,.jsx,.ts,.tsx",
    "lint-all-fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
    "lint-backlog2kintone": "eslint backlog2kintone/ --ext .jsx",
    "lint-backlog2kintone-fix": "eslint backlog2kintone/ --ext .jsx --fix",
    "test": "jest --no-chache --watchAll --coverage"
  }
}
```

### webpack.config.js
kintone-cli で設定したままです。

```webpack.config.js
const path = require('path');
const config = {
  entry: path.resolve('backlog2kintone/source/index.jsx'),
  resolve: {
    extensions: ['.ts', '.tsx', '.js']
  },
  output: {
    path: path.resolve('backlog2kintone/dist'),
    filename: 'backlog2kintone.min.js'
  },
  module: {
    rules: [
      {
        test: /.js?$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader'
        }
      },
      {
        test: /.jsx?$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/react']
          }
        }
      },
      {
        test: /.css$/,
        use: ['style-loader', 'css-loader']
      }
    ]
  }
};

module.exports = (env, argv) => {
  if (argv.mode === 'development') {
    config.devtool = 'source-map';
  }

  if (argv.mode === 'production') {
    //...
  }
  return [config];
};
```

kintone-cli で作成されるファイルには、その他に auth.jsonとconfig.jsonがありますがこちらは割愛します。

## ユニットテスト
Jestを利用しました。

## Jest セットアップ

[拙稿](https://qiita.com/sy250f/items/2186c29f865d6e555ba5#jest-%E3%83%A6%E3%83%8B%E3%83%83%E3%83%88%E3%83%86%E3%82%B9%E3%83%88%E7%92%B0%E5%A2%83%E3%82%BB%E3%83%83%E3%83%88%E3%82%A2%E3%83%83%E3%83%97)を参考に。

### テストコード

```javascript
import * as common from '../source/js/common';

describe('jest 共通関数の実行を確認するテスト', ()=>{
    const typeArray = [5, 6, 7];
    const kintone_record_json = `{
        "activities_id": {
          "value": 3153
        },
        "user_id": {
          "value": 1
        },
        "datetime": {
          "value": "2013-12-27T07:50:44Z"
        },
        "type": {
          "value": 2
        },
        "type_string": {
          "value": "課題の更新"
        },
        "projectkey": {
          "value": "SUB"
        },
        "ticket_title": {
          "value": "コメント"
        }
      }`;
    const backlog_resp_json = `{
        "id": 3153,
        "project": {
          "id": 92,
          "projectKey": "SUB",
          "name": "サブタスク",
          "chartEnabled": true,
          "subtaskingEnabled": true,
          "projectLeaderCanEditProjectLeader": false,
          "textFormattingRule": null,
          "archived": false,
          "displayOrder": 0
        },
        "type": 2,
        "content": {
          "id": 4809,
          "key_id": 121,
          "summary": "コメント",
          "description": "",
          "comment": {
            "id": 7237,
            "content": ""
          },
          "changes": [
            {
              "field": "milestone",
              "new_value": " R2014-07-23",
              "old_value": "",
              "type": "standard"
            },
            {
              "field": "status",
              "new_value": "4",
              "old_value": "1",
              "type": "standard"
            }
          ]
        },
        "notifications": [
          {
            "id": 25,
            "alreadyRead": false,
            "reason": 2,
            "user": {
              "id": 5686,
              "userId": "takada",
              "name": "takada",
              "roleType": 2,
              "lang": "ja",
              "mailAddress": "takada@nulab.example"
            },
            "resourceAlreadyRead": false
          }
        ],
        "createdUser": {
          "id": 1,
          "userId": "admin",
          "name": "admin",
          "roleType": 1,
          "lang": "ja",
          "mailAddress": "eguchi@nulab.example"
        },
        "created": "2013-12-27T07:50:44Z"
      }`;

    beforeAll(() => {
        // return event;
    })
    test('失敗するテスト', ()=>{
        expect(1+1).toBe('2');
    })
    test('成功するテスト', ()=>{
        expect(1+2).toBe(3);
    })
    test('typeを渡すとそれが含まれているかを返すテスト', ()=>{
        expect(common.isInclude(5, typeArray)).toBe(true);
    })
    test('typeを渡すとそれが含まれているかを返すテスト', ()=>{
        expect(common.isInclude(2, typeArray)).toBe(false);
    })
    test('Backlogのデータを渡すとJSONで返すテスト。成功する', ()=>{
        expect(common.preparePostData(JSON.parse(backlog_resp_json))).toEqual(JSON.parse(kintone_record_json));
    })
    test('Backlogのデータを渡すとJSONで返すテスト。成功する。', ()=>{
        expect(common.preparePostData(JSON.parse(backlog_resp_json))).toStrictEqual(JSON.parse(kintone_record_json));
    })
    test('Backlogのデータを渡すとJSONで返すテスト。失敗する。Objectの比較はマッチャーにtoEqualを使う', ()=>{
        expect(common.preparePostData(JSON.parse(backlog_resp_json))).toBe(JSON.parse(kintone_record_json));
    })
    test('Backlogのデータを渡すとJSONで返すテスト。失敗する。JSON.parseして比較しないとエラーになる', ()=>{
        expect(common.preparePostData(backlog_resp_json)).toEqual(kintone_record_json);
    })
    test('種別のTypeを数値で渡すとテキストで返すテスト', ()=>{
      expect(common.fetchActivityTypeToText(1)).toBe('課題の追加');
      expect(common.fetchActivityTypeToText(2)).toBe('課題の更新');
      expect(common.fetchActivityTypeToText(6)).toBe('Wikiを更新');
      expect(common.fetchActivityTypeToText(12)).toBe('GITプッシュ');
      expect(common.fetchActivityTypeToText(0)).toBe(undefined);
      expect(common.fetchActivityTypeToText(27)).toBe(undefined);
  })

});
```

### テスト対象コード

```javascript
export function preparePostData(resp) {
  const ret = {
    "activities_id": {"value": resp.id},
    "user_id": {"value": resp.createdUser.id},
    "datetime": {"value": resp.created},
    "type": {"value": resp.type},
    "type_string": {"value": fetchActivityTypeToText(resp.type)},
    "projectkey": {"value": resp.project.projectKey},
    "ticket_title": {"value": isInclude(resp.type, [5, 6, 7]) ? resp.content.name : resp.content.summary}
  };
  return ret;
}
export function fetchActivityTypeToText(type_number) {
  const TypeStringArray = [[1, "課題の追加"],[2, "課題の更新"],[3, "課題にコメント"],[4, "課題の削除"],
                           [5, "Wikiを追加"],[6, "Wikiを更新"],[7, "Wikiを削除"],[8, "共有ファイルを追加"],
                           [9, "共有ファイルを更新"],[10, "共有ファイルを削除"], [11, "Subversionコミット"],[12, "GITプッシュ"],
                           [13, "GITリポジトリ作成"],[14, "課題をまとめて更新"], [15, "プロジェクトに参加"],[16, "プロジェクトから脱退"],
                           [17, "コメントにお知らせを追加"],[18, "プルリクエストの追加"],[19, "プルリクエストの更新"],[20, "プルリクエストにコメント"],
                           [21, "プルリクエストの削除"],[22, "マイルストーンの追加"],[23, "マイルストーンの更新"], [24, "マイルストーンの削除"],
                           [25, "グループがプロジェクトに参加"],[26, "グループがプロジェクトから脱退"]];
  const TypeStringMap = new Map(TypeStringArray);
  return TypeStringMap.get(type_number);
}
```


### 設定ファイル

jest.config.js

```jest.config.js
module.exports = {
    transform: {
      '^.+\\.[t|j]sx?$'  : '<rootDir>/node_modules/babel-jest',
    },
    moduleFileExtensions: ['js', 'jsx']
  };
```

.babelrc

```.babelrc
{
    "plugins": [
        "@babel/plugin-proposal-class-properties",
        "@babel/plugin-syntax-dynamic-import",
        "@babel/plugin-transform-modules-commonjs"
    ],
    "presets": [
        [
            "@babel/preset-env",
            {
                "useBuiltIns": "usage",
                "corejs": {
                    "version": 3,
                    "proposals": true
                }
            }
        ]
    ],
    "env": {
        "test": {
            "plugins": [
                "@babel/plugin-transform-modules-commonjs"
            ]
        }
    }
}
```

# レコード登録先アプリ
Backlogのレコードが首尾よく登録されると、以下のような画面が表示されます。
あとはもう少し件数を増やしたり、集計方法を工夫したり、Backlogの取得データを違うものにしたりなどなど。
良い感じでお使いいただければと思います。

一覧画面
![スクリーンショット 2019-12-11 9.27.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214523/247b6e81-ded9-7513-07e5-0e40bd558229.png)

日別のグラフ
![スクリーンショット 2019-12-11 9.27.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214523/46cc53e5-da33-553f-5d29-43f81b96e9a7.png)

Backlogの操作種別の円グラフ
![スクリーンショット 2019-12-11 9.27.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214523/808a681c-a0e6-bb19-82dd-500ce89748c1.png)

それでは良い年末を:santa:

---
# 開発環境の構築
開発環境の構築方法を追記します。(2019-12-24)

環境構築には kintone-cli を使っています。
ES6やReactなどの環境も良い感じで作ってくれます。便利。

## 1. プロジェクトフォルダの初期化

`$ kintone-cli init --install -p b2k01`

b2k01は任意のプロジェクトフォルダ名を指定してください。

```
$ tree ./b2k01/ -L 1 -a
./b2k01/
├── .git
├── .gitignore
├── node_modules
├── package-lock.json
└── package.json
```

指定したフォルダ配下にこんな感じでフォルダやら設定ファイルやらが作られます。
`git init`もしてくれて、初めからGit管理ができます。

## 2. kintoneカスタマイズJS用テンプレート作成
指定したオプションでkintoneカスタマイズJSのスケルトンを作ってくれます。
1.で作成されたプロジェクトフォルダにcdしてから実行してください。

今回は [kintone-ui-component](https://github.com/kintone/kintone-ui-component)をReactで使うので、オプションにReactの使用を指定しています。

`$ kintone-cli create-template -t Customization -r -w -l -i 100 -n backlog2kintone`

以下オプションの説明です。

* -t Customization or Plugin のどちらかを指定
* -r Reactを使う
* -w webpackを使う
* -l cybozu eslint rulesを使う
* -i カスタマイズする appID を指定
* -n appNameを指定

この時点ではappIDは仮でも良いです。後で設定ファイル(config.json)を開いて変更できます。

```
$ tree ./backlog2kintone/ -L 1 -a
./backlog2kintone/
├── .babelrc
├── .eslintrc.js
├── auth.json
├── config.json
├── dist
├── source
└── webpack.config.js
```

実行後は指定したappNameでフォルダが作成されて、こんな感じにファイル・フォルダが作られます。

## 3. 認証設定
kintone-cliは内部で[kintone-customize-uploader](https://github.com/kintone/customize-uploader)を利用して、deployコマンドにより自動でファイルをアップロード出来ます。そのためのkintoneログイン情報を設定ファイル(auth.json)として作成します。

`$ kintone-cli auth --app-name backlog2kintone`

auth.jsonは下記の内容で作ってもOKです。

```
{
    "domain": “サブドメイン.cybozu.com",
    "username": “ユーザーID”,
    "password": “パスワード”
}
```

## 4. 開発環境で実行
`kintone-cli dev`コマンドを使えば、一時的にローカルサーバーを使ってカスタマイズしたファイルをkintone環境にアップロード出来ます。（実際はローカルサーバーのリンクが設定されます）

`$ kintone-cli dev --app-name backlog2kintone`

コマンド実行後に、ブラウザで下記リンクを開いて、ブラウザでフォルダが見える状態にしておきます。
`https://:8000`

その後、コマンドを実行したターミナル画面でEnterすると、ファイルがアップロードされます。
（実際はファイルのリンクが設定される）

## 5. 本環境用にビルド
本環境用にJSファイルをビルドします。
ビルドはwebpack.configや.babelrcファイルの設定から自動でビルドしてくれます。
特別なことが無ければ設定ファイルを変更する必要は無いです。

`$ kintone-cli build --app-name backlog2kintone`

## 6. 本環境にデプロイ
作成されたファイルをkintoneにデプロイします。

`$ kintone-cli deploy --app-name backlog2kintone`

良いクリスマスを:gift: