CrashlyticsのWebHookをGoogle Spreadsheetで受け取って、Backlog APIでIssue登録を行う方法

# はじめに
先日、[Google Spreadsheet を簡易 Webサーバーとして動かして、手軽にWebHookを受け取る方法](https://qiita.com/kunichiko/items/7f64c7c80b44b15371a3) という記事を書かせていただきましたが、この記事の中で、WebHookの例として [fabric.io](https://fabric.io) のクラッシュレポートサービスである [Crashlytics](https://fabric.io/kits/ios/crashlytics)と、ヌーラボさんが運営している [Backlog](http://www.backlog.jp) を例に出させていただきました。

<img src="https://qiita-image-store.s3.amazonaws.com/0/41616/e2fa1d32-80cd-f372-88a0-ddebd5451705.png" width=70%>

Crashlyticsには、特定のクラッシュの発生件数があるレベル以上になると WebHookで通知してくれる機能があります。
記事の中で、**「それをGoogle Spreadsheetで受けとってBacklogに自動的に issue として登録することができますよ」**、という説明をしたのですが、実際に書いたのは Google Spereadsheetで WebHook を受け取るところまでで、Backlog APIを叩いてる issueを登録するところは省略しておりました。

本投稿ではその続きとして、実際にBacklog APIを叩いて、2つのサービスを接続するところを解説いたします。


# Google Spreadsheetの設定

まず最初に、[Google Spreadsheet を簡易 Webサーバーとして動かして、手軽にWebHookを受け取る方法](https://qiita.com/kunichiko/items/7f64c7c80b44b15371a3)を参照して、WebHookを受け取れる Spreadsheetを１つ作ってください。

その際、シートのヘッダは以下のように、`date`、`mimeType`、`event`、`payload_type`、`payload`としておいてください。
![スクリーンショット 2018-03-21 19.18.55.png](https://qiita-image-store.s3.amazonaws.com/0/41616/47e84c82-3543-9cb4-1e03-79f96750674a.png)



# CrashlyticsのWebHookの設定方法と詳細仕様

次に、Crashlytics側の設定を行います。ここでは必要最低限のことだけ解説しますので、詳細な仕様については [Crashlyticsのドキュメント](https://support.crashlytics.com/knowledgebase/articles/102391-how-do-i-configure-a-custom-web-hook)を参照して下さい。

## Crashlyticsのアプリへの組み込み
Crashlyticsをアプリへの組み込むところはすでに済んでいるという前提でお話しさせていただきますので、この設定については省略させていただきます。

## WebHookの設定

[Fabricの設定画面](https://www.fabric.io/settings/apps)を開いてください。すると、登録されているアプリの一覧が表示されますので、今回設定したいアプリを選択します。

すると、以下のような画面が表示されます。一番左の**「Service Hooks」**から**「WebHook」**を選んで、先ほど作成したスプレッドシートの**サーバーURL**を入力します(スプレッドシート自体のURLではありませんのでご注意!)。

![スクリーンショット 2018-03-21 19.15.45.png](https://qiita-image-store.s3.amazonaws.com/0/41616/e03a2fd5-15bd-cf61-1441-ebdb1902da3d.png)

URLを入力したら「Verify」ボタンを押します。上手く認識されれば、以下のように「Successfully verified WebHook!」と表示されるはずです。

<img src="https://qiita-image-store.s3.amazonaws.com/0/41616/1e6ecd85-1a20-fabd-0427-d814985f2800.png" width=50%>

さて、ここでスプレッドシートの方を開いてみると、以下のようなイベントが記録されているのがわかります。

![スクリーンショット 2018-03-21 19.20.34.png](https://qiita-image-store.s3.amazonaws.com/0/41616/c3a248e4-b097-1761-b5d8-1289ae692658.png)

これは、Crashlyticsがサーバの正常性を確認(verify)するために今まさに送ってきたリクエストです。このリクエストの詳細は[CrashlyticsのWebHookの仕様](https://support.crashlytics.com/knowledgebase/articles/102391-how-do-i-configure-a-custom-web-hook)に書かれている通り、以下のような JSONリクエストになっています。

```
{
  "event": "verification",
  "payload_type": "none"
}
```

`event`に `verification`、`payload_type`に`none`が入ったJSONで、まさにその通りにスプレッドシートに記録されているのがわかります。

続いて、CrashlyticsのWebHook設定画面で「Send Test」という青いボタンを押してみましょう。すると以下の様なイベントが記録されます。

![スクリーンショット 2018-03-21 19.25.09.png](https://qiita-image-store.s3.amazonaws.com/0/41616/62f66084-1970-60a5-e30d-64bd246d0eb3.png)

payloadには以下の様なオブジェクト(が文字列化されたもの)が入っています。(見やすいように整形しています)

```
{
  app={
    name=test-app-name,
    bundle_identifier=test-bundle-identifier,
    platform=test-platform
  },
  method=some_crashed_method,
  display_id=0,
  crashes_count=0,
  impacted_devices_count=0,
  impact_level=0,
  title=Fabric Integration Test,
  url=http://example.com/path/to/fabric/issue
}
```

以下、プロパティの詳細です。

 - app
   - 公式ドキュメントには書かれていないプロパティですが、以下のような情報が入っています。
     - name : アプリ名
     - bundle_identifier : iOSアプリの bundle_identifier (iOSアプリの場合)
     - platform : プラットフォーム名。テストリクエストの場合は **"test-platform"** となっていますが、実際には **"iOS"** などが入ります。
 - method
    - クラッシュが起こったメソッド名です。
    - 例えば、Objective-Cのクラスの場合は以下の様な文字列が入ります。
      - `"-[MyClass myMethodWithArg:mode:]`
 - display_id
    - CrashのIDです。
 - crashes_count
    - このイベントが発生した際の総クラッシュ数です。 
 - impact_level
    - クラッシュの[インパクトレベル](https://docs.fabric.io/android/crashlytics/crashes-and-issues.html#defining-impact-level)です。インパクトレベルについてはあまり詳細な仕様が公開されていないのですが、私の感覚的には以下の様な感じです。
        - Level 1 - クラッシュが1回検出された
        - Level 2 - クラッシュが50回検出された
 - title
   - クラッシュのタイトルです。例えば `MyClass.m line 1234` などが入ります。
 - url
   - Crashlytics上に記録されたクラッシュのURLです。  

# Backlogに課題として登録する

この様に受け取ったクラッシュの情報を、Backlogに以下の様に自動的に登録される様にしてみたいと思います。

 - 課題の種類
   - 「`クラッシュ`」 など (課題の種類はバックログ上で自由に定義できます)
 - 課題のタイトル
   - `display_id + title + method`
   - 具体例:
     - `#1234 MyClass.m line 1234 (-[MyClass my myMethodWithArg:mode:])`
 - 課題の本文
   - `以下のクラッシュが発生しました。 <url>`
   - 具体例:
     - `以下のクラッシュが発生しました。 https://fabric.io/xxxx/ios/apps/`

以下、Backlog APIを使ってこの仕様の通りに課題を登録する方法を解説します。

## Backlog APIを使える様に準備する

### API Key方式と OAuth 2.0 方式

[Backlog APIの公式ドキュメント](https://developer.nulab-inc.com/ja/docs/backlog/)によると、Backlog APIを利用する際の認証方法として以下の２つが用意されているとあります。

 - API Key 方式
 - OAuth 2.0 方式

これらの違いは[認証と認可](https://developer.nulab-inc.com/ja/docs/backlog/auth/)のページに詳しく書かれています。

今回は **API Key 方式を採用** します。API Key方式の場合は、以下の様にリクエストURLにパラメータとして`apiKey`を追加するだけで使えてお手軽なためです。

```
https://xx.backlog.jp/api/v2/users/myself?apiKey=abcdefghijklmn 
```

### API Keyの取得

[Backlogのユーザーガイド](https://backlog.com/ja/help/usersguide/personal-settings/userguide2378/)をみて、あらかじめ `API Key` を取得しておいてください。

## プロジェクト一覧を取得してみる

APIの準備ができたら、まずは、Backlogの[プロジェクト一覧の取得API](https://developer.nulab-inc.com/ja/docs/backlog/api/2/get-project-list/)を使って、プロジェクト一覧を取得してみましょう。

スクリプトに以下の関数を追加してみて下さい。（urlの`xxxx`の部分と`(取得したAPIKey)`の部分はご自身の環境に合わせて下さい）

```
var apiKey = "(取得したAPIKey)";
var hostname = "xxxx.backlog.jp";

// Backlogからオブジェクト一覧を取得してシートに格納するユーティリティ
function getObjects(api,name,propertyNames) {
  var url = "https://" + hostname + "/api/v2/"+api+"?apiKey=" + apiKey;
  var r = UrlFetchApp.fetch(url);
  var objects = JSON.parse(r);

  var ss = SpreadsheetApp.getActive()
  var sheet = ss.getSheetByName(name);
  if ( sheet == null ) {
    sheet = ss.insertSheet(name)
  }
  sheet.deleteColumns(1,propertyNames.length);
  sheet.insertColumns(1,propertyNames.length);
  var cell = sheet.getRange('a1');
  for (i in objects) {
    var object = objects[i];
    for (j in propertyNames) {
      cell.offset(0,j).setValue(object[propertyNames[j]]);
    }
    cell = cell.offset(1,0);    
  }
  SpreadsheetApp.flush();
}
```

この関数を使って、Backlogからユーザー一覧を取得するには、以下の様にします。

```
// Backlogのプロジェクト一覧を取得してPROJECTSシートに保存する関数
function getProjects() {
  getObjects("projects","PROJECTS",["id","name","projectKey"]);
}
```

 - 第一引数
   - BacklogのAPI
 - 第二引数
   - 結果を保存するシートの名前
 - 第三引数
   - Backlogから取得されたオブジェクトのうち、保存したいプロパティ名のリスト

作成した`getProjects`関数はエディタ上部のプロダウンメニューで選択し、再生ボタン「▶︎」を押すことで実行できます。

![スクリーンショット 2018-03-21 20.42.07.png](https://qiita-image-store.s3.amazonaws.com/0/41616/0e14d205-dda8-7bb2-7fb4-d4fd4833e262.png)

実行すると、`PROJECTS`という名前のシートが作られ、以下の様に結果が格納されます。

![スクリーンショット 2018-03-21 22.57.04.png](https://qiita-image-store.s3.amazonaws.com/0/41616/98b79d9d-3a10-9aac-b010-7729240e5790.png)

A列にはプロジェクトのID、B列はプロジェクトの名前、C列はプロジェクトキーです。このうち、プロジェクトのIDは後で必要になります。

## ユーザー一覧を取得する

プロジェクト一覧が取得できたら、クラッシュを登録したいプロジェクトのIDを確認してみて下さい。もし、MyProject1にクラッシュを登録したい場合は、`12345`というIDを使用します。
 
IDがわかったら、今度は[プロジェクトユーザー一覧の取得API](https://developer.nulab-inc.com/ja/docs/backlog/api/2/get-project-user-list/)を使って、このプロジェクトの参加ユーザー一覧を取得してみます。

プロジェクト一覧と同様に以下の関数を追加して下さい。(`(プロジェクトのID)`の部分はご使用の環境に合わせて変更して下さい)

```
// プロジェクト参加ユーザーの一覧を取得してUSERSシートに保存する関数
function getUsers() {
  getObjects("projects/(プロジェクトのID)/users","USERS", ["id","name","userId"]);
}
```

すると、今度は `USERS`というシートが追加され、参加ユーザの一覧が取得できます。

## カテゴリの一覧、課題の種類一覧を取得する

同じ様にして、[カテゴリー一覧の取得API](https://developer.nulab-inc.com/ja/docs/backlog/api/2/get-category-list/)、[種別一覧の取得API](https://developer.nulab-inc.com/ja/docs/backlog/api/2/get-issue-type-list/)を使用してカテゴリの一覧、課題の種類一覧を取得してみます。

```
// プロジェクトのカテゴリー一覧を取得してCATEGORIESシートに保存する関数
function getCategories() {
  getObjects("projects/(プロジェクトのID)/categories","CATEGORIES", ["id","name"]);
}
```

```
// プロジェクトの種別一覧を取得してISSUE_TYPESシートに保存する関数
function getIssueTypes() {
  getObjects("projects/(プロジェクトのID)/issueTypes","ISSUE_TYPES", ["id","name"]);
}
```

それぞれの関数を一度実行し、シートに結果を保存しておいて下さい。

## CrashlyticsからのWebHookを受け取って、課題を自動登録する

さて、各種オブジェクトが一通り取得できたところで、いよいよWebHookを処理して課題を登録してみたいと思います。

`doPost()`関数の「他のサービスのAPIを呼び出す」のところを以下の様に変更します。

```
// POSTリクエストに対する処理
function doPost(e) {

    :
    :
 
  //
  // Crashlyticsからの通知を解釈して、Backlogに課題を追加する
  //

  // eventが "issue_impact_change"の場合のみ処理する
  if ( requestObj.event == "issue_impact_change" ) {
    var p = requestObj.payload;

    var payload = {
      "projectId": "(プロジェクトID)",
      "summary": ("#" + p.displayId + " " + p.title + " - " + p.method),
      "description": "以下の新しいクラッシュが報告されました。\n" + p.impactUrl,
      "issueTypeId": "(課題種別のID)",
      "priorityId": "3",
      "categoryId[0]": "(カテゴリーのID)",
      "notifiedUserId[0]": "(ユーザーのID1)", // 「お知らせ」を送りたいユーザのID(一人目)
      "notifiedUserId[1]": "(ユーザーのID2)", // 「お知らせ」を送りたいユーザのID(二人目)
      "notifiedUserId[2]": "(ユーザーのID3)"  // 「お知らせ」を送りたいユーザのID(三人目)
    };
    var options = {
      "method" : "post",
      "payload" : payload
    };
    var url = "https://" + hostname + "/api/v2/issues?apiKey="+apiKey;
    UrlFetchApp.fetch(url, options);
  }
```

上記のコードのうち、以下の値はお使いの環境に合わせて変更して下さい。

- プロジェクトID
  - getProjects() で得られた一覧から課題を登録したいプロジェクトを選んで ID(数値)を設定
- 課題種別のID 
 - getIssueTypes() で得られた一覧から課題に設定したい種別を選んで ID(数値)を設定
- カテゴリーのID 
 - getCategories() で得られた一覧から課題に設定したいカテゴリーを選んで ID(数値)を設定
- ユーザーのID1,2,3 
 - getUsers() で得られた一覧から、お知らせを送りたいユーザを選んで ID(数値)を設定


# 動作確認

## テストコードを実行してみる

以下の様に、`doPostTest()`関数を用意して実行してみて下さい。うまく動いていればBacklogに課題が追加されているはずです。

```
function doPostTest() {
  var e = new Object();
  var postData = new Object();
  var contents = new Object();
  var payload = new Object();
  payload.display_id = 1;
  payload.impact_level = 2;
  payload.method = "method";
  payload.title = "MyClass.c line 123";
  payload.crashes_count = 10;
  payload.impacted_devices_count = 100;
  payload.url = "http://example.com";
  contents.event = "issue_impact_change";
  contents.payload_type = "issue";
  contents.payload = payload;
  postData.type = "application/json";
  postData.contents = JSON.stringify(contents);
  e.postData = postData;

  doPost(e);
}
```

登録されない場合は、各IDが正しく設定されているかなどをよく確認した上で、デバッグ実行などを使って動作を確かめてみて下さい。

## プログラムの新バージョンを公開する

テストがうまくいったら、「公開」-「ウェブアプリケーションとして導入...」を選び、新しいバージョンとしてプログラムを公開します。

![スクリーンショット 2018-03-04 23.53.22.png](https://qiita-image-store.s3.amazonaws.com/0/41616/c36d2c55-ea61-5b81-24b4-22a76267a56a.png)

これを忘れると古いバージョンのプログラムが動き続けてしまい、思った動作をしませんので忘れずに行って下さい。

## CrashlyticsからWebHookを発行してみる

ここまでできたら、いよいよCrashlyticsからプログラムを叩いてみます。CrashlyticsのWebHook設定画面で「Send Test」ボタンを押してみましょう。

<img src="https://qiita-image-store.s3.amazonaws.com/0/41616/1e6ecd85-1a20-fabd-0427-d814985f2800.png" width=50%>

うまく動いていれば Backlogに課題が追加されているはずです。

あとは実際に本当のクラッシュが発生するのを待つだけです（嬉しくない話ですが……）。

# その他
## 課題の登録ユーザー
Backlogの課題の登録者として専用のユーザー(`Crashlytics`など)を作ると良いかもしれません。

![スクリーンショット 2018-03-21 23.50.52.png](https://qiita-image-store.s3.amazonaws.com/0/41616/2f1148d4-4d18-bd92-d2a6-ebdebb596e41.png)

この様にしたい場合は、Backlog上にユーザを作成し、そのユーザでログインして API Keyを発行してキーを差し替えればOKです。

## Impact Levelの設定

Crashlyticsの WebHookの設定を「At Level 1」にしておくと、新しいクラッシュが発生するとすぐに Backlogの課題が作成されます。

頻度が多すぎる場合は、Level 2などに変更してみると良いでしょう。

# コード全体

最後に、コード全体を載せておきます。
API KeyやプロジェクトIDなどの環境依存の定数はプログラム上部に移動してありますので、適宜変更して下さい。

```
var apiKey = "";
var hostname = "xxxx.backlog.jp";
var projectId = "12345";
var issueTypeId = "12345";
var categoryId = "12345";
var notifiedUserId0 = "12345";

// GETリクエストに対する処理
function doGet(e) {
  var output = ContentService.createTextOutput("hoge");
  output.setMimeType(ContentService.MimeType.TEXT);
  return output;
}

// POSTリクエストに対する処理
function doPost(e) {
  // JSONをパース
  if (e == null || e.postData == null || e.postData.contents == null) {
    return;
  }
  var requestJSON = e.postData.contents;
  var requestObj = JSON.parse(requestJSON);

  date = new Date();
  mimeType = e.type;

  //
  // Crashlyticsからの通知を解釈して、Backlogに課題を追加する
  //

  // eventが "issue_impact_change"の場合のみ処理する
  if ( requestObj.event == "issue_impact_change" ) {
    var p = requestObj.payload;

    var payload = {
      "projectId": projectId,
      "summary": ("#" + p.displayId + " " + p.title + " - " + p.method),
      "description": "以下の新しいクラッシュが報告されました。\n" + p.impactUrl,
      "issueTypeId": issueTypeId,
      "priorityId": "3",
      "categoryId[0]": categoryId,
      "notifiedUserId[0]": notifiedUserId0 // 「お知らせ」を送りたいユーザのID(一人目)
//      "notifiedUserId[1]": "(ユーザー2のID)", // 「お知らせ」を送りたいユーザのID(二人目)
//      "notifiedUserId[2]": "(ユーザー3のID)"  // 「お知らせ」を送りたいユーザのID(三人目)
    };
    var options = {
      "method" : "post",
      "payload" : payload
    };
    var url = "https://" + hostname + "/api/v2/issues?apiKey="+apiKey;
    UrlFetchApp.fetch(url, options);
  }

  //  
  // アクセスログをスプレッドシートに追記
  //

  var ss = SpreadsheetApp.getActive()
  var sheet = ss.getActiveSheet();

  // ヘッダ行を取得
  var headers = sheet.getRange(1,1,1,sheet.getLastColumn()).getValues()[0];

  // ヘッダに対応するデータを取得
  var values = [];
  for (i in headers){
    var header = headers[i];
    var val = "";
    switch(header) {
      case "date":
        val = new Date();
        break;
      case "mimeType":
        val = e.postData.type;
        break;
      case "result":
        val = result;
        break;
      default:
        val = requestObj[header];
        break;
    }
    values.push(val);
  }

  // 行を追加
  sheet.appendRow(values);
}

// Backlogのプロジェクト一覧を取得してPROJECTSシートに保存する関数
function getProjects() {
  getObjects("projects","PROJECTS",["id","name","projectKey"]);
}

// プロジェクト参加ユーザーの一覧を取得してUSERSシートに保存する関数
function getUsers() {
  getObjects("projects/"+projectId+"/users","USERS", ["id","name","userId"]);
}

// プロジェクトのカテゴリー一覧を取得してCATEGORIESシートに保存する関数
function getCategories() {
  getObjects("projects/"+projectId+"/categories","CATEGORIES", ["id","name"]);
}

// プロジェクトの種別一覧を取得してISSUE_TYPESシートに保存する関数
function getIssueTypes() {
  getObjects("projects/"+projectId+"/issueTypes","ISSUE_TYPES", ["id","name"]);
}

// Backlogからオブジェクト一覧を取得してシートに格納するユーティリティ
function getObjects(api,name,propertyNames) {
  var url = "https://" + hostname + "/api/v2/"+api+"?apiKey=" + apiKey;
  var r = UrlFetchApp.fetch(url);
  var objects = JSON.parse(r);

  var ss = SpreadsheetApp.getActive()
  var sheet = ss.getSheetByName(name);
  if ( sheet == null ) {
    sheet = ss.insertSheet(name)
  }
  sheet.deleteColumns(1,propertyNames.length);
  sheet.insertColumns(1,propertyNames.length);
  var cell = sheet.getRange('a1');
  for (i in objects) {
    var object = objects[i];
    for (j in propertyNames) {
      cell.offset(0,j).setValue(object[propertyNames[j]]);
    }
    cell = cell.offset(1,0);    
  }
  SpreadsheetApp.flush();
}

function doPostTest() {
  var e = new Object();
  var postData = new Object();
  var contents = new Object();
  var payload = new Object();
  payload.display_id = 1;
  payload.impact_level = 2;
  payload.method = "method";
  payload.title = "MyClass.c line 123";
  payload.crashes_count = 10;
  payload.impacted_devices_count = 100;
  payload.url = "http://example.com";
  contents.event = "issue_impact_change";
  contents.payload_type = "issue";
  contents.payload = payload;
  postData.type = "application/json";
  postData.contents = JSON.stringify(contents);
  e.postData = postData;

  doPost(e);
}
```