Unityエディタでテストプレイ中にバグ報告する機能を作る

この記事は[【unityプロ技】 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/unityforpro)の11日目の記事です。

#はじめに

こんにちは、天神いな（[@ina_amagami](https://twitter.com/ina_amagami)）です。スマートフォンゲーム会社でUnityを使用したゲーム開発をしつつ、2019年現在は開発チームのリードエンジニアとしてプロジェクト効率化業務なども行っています。

個人で[アマガミナブログ](https://amagamina.jp/)という技術ブログを書いていますが、Qiita初投稿です。よろしくお願いします！

チーム開発をしていると、Unityエディタでテストプレイ中にバグを見つけることが多々ありますよね。
**バグだ！**と思った時にすぐ担当者に修正依頼を投げられると便利です。

そこで今回はUnityエディタから[Backlog](https://backlog.com/ja/)にバグ報告チケットを追加する機能を作ったので、実装方法を解説します。

この記事は実装方法がメインなので、**導入方法だけ知りたい**という方に向けて個人ブログで導入方法の解説を用意しました。

[Unityでテストプレイ中にBacklogにバグチケットを登録するツール](https://amagamina.jp/unity-bugreporter/)

##1. Backlogとは
https://backlog.com/ja/

nurab社によって運営されているタスク管理サービスです。Backlogではタスクの単位を「課題」と呼び、プロダクトのバグ管理にも利用することが可能です。（プログラム上はTicketでややこしいので課題のことを記事内ではチケットと呼びます）

今回は「1プロジェクト10人まで」といった制約が付いている**フリープラン**で利用できる機能の範囲内を想定しています。

制限例として、チケットに添付できるファイル数は**1つまで**です。そのため今回はGameビューのスクリーンショット添付のみ作成しますが、有料プランをお使いの方は他のファイルを追加で添付するのも良いでしょう。

例えば僕が開発チームに導入しているものはログファイルを添付しています。

##2. 作ったもの

こんな感じのウィンドウに情報を入力してバグ報告します。

<img width="638" alt="スクリーンショット 2019-12-14 17.11.31.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/530201/dbee92d4-5a1a-4b5f-461c-ea010139375f.png">

チケットの作成が完了したら、すぐにブラウザで確認できます。

<img width="419" alt="スクリーンショット 2019-12-04 10.16.09.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/530201/f519772d-2598-1a8d-2e94-f50f9f4cccb8.png">

Backlog上にはこんな感じで登録されます。

<img width="417" alt="スクリーンショット 2019-12-04 10.21.08.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/530201/d7b63861-dfb4-54bd-bd5e-baa9ccd1a709.png">

<img width="1141" alt="スクリーンショット 2019-12-04 10.23.16.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/530201/167a72a5-b922-bd8f-9844-c88a83e90895.png">

ツール化しておくことで、報告時の詳細文フォーマットを固定する効果もあります。

BacklogにはSlackやChatworkへの通知設定もあるので、併せて設定しておくと捗ります。

##3. ダウンロード

個人開発用にフリープランで使うため作り直したものをGitHubにて公開中です。あくまで作例なので、ご利用する環境に応じてカスタマイズして頂ければと思います。

[unity-bugreporter-example](https://github.com/ina-amagami/unity-bugreporter-example)

`Assets/Editor/BugReporter`以下にバグ報告機能の作例が入っています。

*作例と記事内で公開しているコードは全てパブリックドメインです（ライセンスを主張しません）*

作例内で使用している各種プラグインやパッケージについては、各々のライセンスに従って下さい。

##4. 確認環境

- macOS High Sierra
- Unity2018.3.12f1 / 2019.1.8f1 / 2019.2.13f1（**Unity2018.3以上必須**）
- Google Chrome 78.0

##5. 今回やること

- BacklogAPI認証
- SettingsProviderでツールの設定をProjectSettingsから行う
- ScriptableWizardで報告ウィンドウを作成
- 直近のエラーログを本文に追加する
- スクリーンショット添付機能の作成

など

##6. スタンス

このような効率化ツールを作成する上で重要なのは、いかに**ローコストで最良の結果を得るか**です。

- 使えるライブラリがあるならそれを使って自作はしない
- プロダクト本体ほど設計にこだわらない
- 実装コストが高い機能を無理に導入しない

勉強目的であれば話は変わりますが、今回はプロジェクト効率化業務としての観点を重視していきます。

入門記事ではないため、前提知識の解説はある程度スキップして進めていきます。ご了承下さい。

#実装準備
作例プロジェクトでは`5. Backlogアプリケーション登録`以外は済ませてある状態なので作例からコピーでもOKです。

`Plugins/`内の`NBacklog`と`unity-backlog`にはAssembly Definitionを設定しています。これらが原因でうまく動作しない場合は`.asmdef`ファイルを削除するか、以下を参考にして下さい。

参考：[Unity Assembly Definition 完全に理解した](https://qiita.com/toRisouP/items/d206af3029c7d80326ed)

##1. NBacklogの導入
BacklogAPIを叩くためのライブラリは[BacklogAPIのマニュアル](https://developer.nulab.com/ja/docs/backlog/libraries/#%E3%83%A9%E3%82%A4%E3%83%96%E3%83%A9%E3%83%AA)にまとまっています。

C#向けの公式ライブラリはありませんが、[hal1932](https://github.com/hal1932)氏が作成したNBacklogがGitHubで公開されているので、こちらをプロジェクト内に導入します。

[NBacklog](https://github.com/hal1932/NBacklog)

##2. NuGet / Newtonsoft.Jsonの導入
NBacklogを導入すると[Newtonsoft.Json](https://www.newtonsoft.com/json)が無いためエラーが出ます。

今回は[NuGetForUnity](https://github.com/GlitchEnzo/NuGetForUnity)を使ってインストールします。

NuGetForUnityをプロジェクトに追加したら、メニューから

`NuGet > Manage NuGet Packages` を開きます。

<img width="517" alt="json-net.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/530201/e21a1add-194a-ebf3-9691-dacabe6277ee.png">

リストからJson.NETをインストールします。

NuGetを導入することで`Assets/`直下にファイルが色々と増えてしまうので、これが気になる方はNewtonsoft.Jsonのdllを直接プロジェクトに導入して下さい。

(2020/2/7追記) Unity2019.1.4f1を使用しているプロジェクトにおいて、NuGetが原因でAssetBundleビルド時にプレハブにシリアライズされたデータが初期化されてしまう現象が起きました。他の用途で使用しない場合はNewtonsoft.JsonをインストールしたらNuGetを削除してしまうことを推奨します。

##3. NBacklogのコードを一部修正

`NBacklog/Rest/RestClient.cs`を修正します。

Unityに入っているバージョンの`HttpClient`では`MaxConnectionsPerServer`に対応していないため、こちらを以下のように書き換えます

```RestClient.cs
public RestClient(string baseUri, int maxConnectionPerServer = 256, JsonSerializer serializer = null)
{
//    _client = new HttpClient(new HttpClientHandler()
//    {
//        MaxConnectionsPerServer = maxConnectionPerServer,
//    });
    _client = new HttpClient(new HttpClientHandler());
```

##4. ビルドに含まれないようにする

NBacklog、NuGetでインポートしたパッケージをバグ報告機能以外で利用しない場合は、アプリサイズを肥大化させないためにビルドに含まれないようにします。

###4-1. NBacklogをEditorフォルダ以下に移動

NBacklogを`Editor`フォルダ以下に移動させます。

その際、`System.Drawing`と`System.Web`がうまく読み込めずにエラーが出ます。また、Unity2019.2以降ではEditorフォルダ以下に入れなくてもアセンブリの仕様が変わったためか同様のエラーが出ます。

これらを解消するには`csc.rsp`というファイルを作成します。

参考：[Unity - Manual: Referencing additional class library assemblies](https://docs.unity3d.com/Manual/dotnetProfileAssemblies.html)

`csc.rsp`を既に他の用途で作成している場合は以下を追記、ない場合は`Assets/`直下に作成して下さい。

```csc.rsp
-r:System.Drawing.dll
-r:System.Web.dll
```

対応としてこれが最適なのかは分からないですが、Android/iOSでNBacklogをアプリに含めるよりはビルド後のサイズが削減できていることは確認済みです。

###4-2. NuGet / packagesのImport設定

`Assets/`直下に`NuGet.config`と`packages.config`というファイルが作成されているので、これらのInspectorから設定を変更します。

<img width="352" alt="スクリーンショット 2019-12-09 13.51.00.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/530201/f5516e03-231c-5d23-0c82-c45113e62e3b.png">

Include Platformsの**Editorだけ**チェックを入れてApplyして下さい。

##5. Backlogアプリケーション登録
NBacklogを利用したBacklogAPIの認証にはOAuth2を利用します。

通信処理を用意すればAPIKeyを利用することも可能ですが、**チケットの登録者がAPIKeyを発行した人になってしまう**ことで誰からのバグ報告なのか分からなくなる問題もあり、OAuth2を推奨します。

認証に利用する`client_id`と`client_secret`を取得するには[Backlogアプリケーション登録](https://backlog.com/developer/applications/)のページからログインしてアプリケーション登録を行います。

新規登録ボタンをクリックして登録画面に入ります。

<img width="546" alt="スクリーンショット 2019-11-29 9.14.34.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/530201/f11d4f5a-e1a7-4472-bcca-dfac9cf8a037.png">

###5-1. RedirectURL

<img width="374" alt="スクリーンショット 2019-11-29 10.23.45.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/530201/e5168bae-b12c-bf63-e2b6-82fae06ae024.png">

今回はWebサイトからの認証ではないので特に使いません。指定は必要なので`http://localhost:12345`とか`http://localhost:54321`とか適当なURIでOKです。

###5-2. 認証画面に表示される情報を設定

デフォルトは`English`になっています。そのままでもいいですが、今回は日本語に変更しておきます。

<img width="385" alt="スクリーンショット 2019-11-29 10.20.40.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/530201/2561efe3-0042-c21e-b113-e9ce3c71616f.png">

下までスクロールすると「この言語を削除する」というボタンがあるのでこれを押して削除します。

消えたら日本語で新しく追加します。追加できたら「この言語をデフォルトの表示にする」にチェックを入れて下さい。このチェックを付けないと登録に失敗します。

<img width="849" alt="スクリーンショット 2019-11-29 10.21.03.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/530201/cb6edde6-257e-e975-a022-17e8a99e2d18.png">

アプリケーション名とアプリケーションの説明を入力します。今回はバグ報告にしか利用しないので説明は「バグ報告する機能です」としていますが、同一の認証で他の機能も使えるようにする場合はそれも書き加えておきます。

<img width="618" alt="スクリーンショット 2019-11-29 10.21.51.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/530201/7830d9d7-3a65-9a31-cd89-73f2b97cdec6.png">

サイトURLは不要です。これで登録すると`client_id`と`client_secret`が発行されます。

<img width="682" alt="スクリーンショット 2019-12-01 16.58.21.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/530201/d217515b-4712-f1c3-6ad0-d2ce8debf0e0.png">

以上で準備は完了です。

#BacklogAPI認証

NBacklogをエディタ拡張で扱いやすいようにラップしたものを用意しました。
※今回のバグ報告ツールに必要な機能以外は特に用意していませんのでご了承下さい。

[unity-backlog](https://github.com/ina-amagami/unity-backlog)

こちらはMITライセンスで公開していますが、記事内に抜粋したコードはパブリックドメインです。

重要なポイントを解説していきます。

##1. 設定ファイルの用意

プロジェクト毎に変更しやすいように設定ファイルをScriptableObjectで作成します。

```BacklogAPIData.cs
using UnityEngine;
using UnityEditor;
using System.IO;

/// <summary>
/// Backlog API関連のプロジェクト依存データ
/// </summary>
public class BacklogAPIData : ScriptableObject
{
	/// <summary>
	/// アセットパス
	/// </summary>
	private const string AssetPath = "Backlog/BacklogAPI.asset";

	[Header("Backlog Developerサイトで登録して下さい")]
	public string RedirectURI;
	public string ClientId;
	public string ClientSecretId;

	[Header("対象プロジェクト")]
	public string SpaceKey;
	public string Domain;
	public string ProjectKey;

	[Header("認証情報のキャッシュファイル名（gitignoreで除外して下さい）")]
	public string CacheFileName = "backlog_oauth2cache.json";

	/// <summary>
	/// APIデータをロード
	/// </summary>
	public static BacklogAPIData Load()
	{
		var asset = EditorGUIUtility.Load(AssetPath);
		if (!asset)
		{
			// 無かったら作成
			CreateAsset();
			asset = EditorGUIUtility.Load(AssetPath);
		}
		return asset as BacklogAPIData;
	}
		
	/// <summary>
	/// アセット作成
	/// </summary>
	public static void CreateAsset()
	{
		var outputPath = "Assets/Editor Default Resources/" + AssetPath;

		var fullDirPath = Path.GetDirectoryName(Application.dataPath.Replace("Assets", outputPath));
		if (!Directory.Exists(fullDirPath))
		{
			Directory.CreateDirectory(fullDirPath);
		}
			
		AssetDatabase.CreateAsset(CreateInstance<BacklogAPIData>(), outputPath);
		AssetDatabase.Refresh();
	}
		
	/// <summary>
	/// SerializedObjectで取得
	/// </summary>
	public static SerializedObject GetSerializedObject()
	{
		return new SerializedObject(Load());
	}
}
```

設定項目については後ほど説明します。

###1-1. データが無ければ作成する

複数作るScriptableObjectであれば右クリックメニューから作成できるようにしておいても良いと思いますが、今回はプロジェクトに1つだけあれば大丈夫な設定ファイルなので最初にロードしようとした時に作成するようにしました。

ロードには`EditorGUIUtility.Load()`を使用しています。このメソッドではロード先を`Assets/Editor Default Resources/`内で指定するので、ファイルがなかった時にはこの中に作成しています。

他の保存先にしたい場合は書き換えて使って下さい。

###1-2. SerializedObjectでの取得

エディタ拡張ではSerializedObjectで取り回すことが多いので取得メソッドを用意しています。

##2. ProjectSettingsから設定可能にする

Unity2018.3から、UIElementsの機能で`SettingsProvider`を継承したクラスを作成することでProjectSettingsに設定項目を追加できるようになったのでこちらを利用します。

参考：[【Unity】Unity 2018.3 で Project Settings ウィンドウに独自のメニューを追加する方法](http://baba-s.hatenablog.com/entry/2018/12/17/085000)

ちなみに以前は`PreferenceItem`属性を使ってPreferencesに設定項目を追加できましたが、こちらはUnity2019.1から非推奨になっています。

```BacklogAPISettings.cs
using UnityEditor;
using UnityEngine;
#if UNITY_2019_1_OR_NEWER
using UnityEngine.UIElements;
#else
using UnityEngine.Experimental.UIElements;
#endif

/// <summary>
/// BacklogAPIの設定ファイルをProjectSettingsから編集できるようにする
/// </summary>
public class BacklogAPISettings : SettingsProvider
{
	private const string Path = "Project/BacklogAPI";

	public BacklogAPISettings(string path, SettingsScope scope) : base(path, scope) { }
		
	/// <summary>
	/// ProjectSettingsに項目追加
	/// </summary>
	[SettingsProvider]
	private static SettingsProvider Create()
	{
		var provider = new BacklogAPISettings(Path, SettingsScope.Project)
		{
			// 検索対象のキーワード登録（SerializedObjectから自動で取得）
			keywords = GetSearchKeywordsFromSerializedObject(BacklogAPIData.GetSerializedObject())
		};
		return provider;
	}
		
	private static SerializedObject so;
		
	public override void OnActivate(string searchContext, VisualElement rootElement)
	{
		// 設定ファイル取得
		so = BacklogAPIData.GetSerializedObject();
	}

	public override void OnGUI(string searchContext)
	{
		// プロパティの表示
		var iterator = so.GetIterator();
		EditorGUI.BeginChangeCheck();
		while (iterator.NextVisible(true))
		{
			bool isScript = iterator.name.Equals("m_Script");
			if (isScript) { GUI.enabled = false; }
			
			EditorGUILayout.PropertyField(iterator);
			
			if (isScript) { GUI.enabled = true; }
		}
		if (EditorGUI.EndChangeCheck())
		{
			so.ApplyModifiedProperties();
		}
		
		EditorGUILayout.Space();
		
		// データ検証用ボタン
		using (new EditorGUILayout.HorizontalScope())
		{
			if (GUILayout.Button("スペースを開く"))
			{
				var data = BacklogAPIData.Load();
				Application.OpenURL($"https://{data.SpaceKey}.{data.Domain}/projects/{data.ProjectKey}");
			}
			if (GUILayout.Button("認証テスト"))
			{
				var backlogAPI = new BacklogAPI();
				try
				{
					backlogAPI.LoadProjectInfo(() =>
					{
						EditorUtility.DisplayDialog("認証成功", "BacklogAPIの認証に成功しました。", "OK");
					});
				}
				catch (System.Exception e)
				{
					Debug.LogException(e);
				}
			}
		}
	}
}
```

ポイントをいくつか解説します。

###2-1. SettingsProviderの作成
`SettingsProvider`属性を付けたstaticメソッドでインスタンスを返すことで設定項目として追加されます。

```cs
private const string Path = "Project/BacklogAPI";

[SettingsProvider]
private static SettingsProvider Create()
{
    var provider = new BacklogAPISettings(Path, SettingsScope.Project)
    {
        // 検索対象のキーワード登録（SerializedObjectから自動で取得）
        keywords = GetSearchKeywordsFromSerializedObject(BacklogAPIData.GetSerializedObject())
    };
    return provider;
}
```


`GetSearchKeywordsFromSerializedObject`でSerializedObjectに含まれるプロパティからキーワードを抽出して設定しておきます。

これだけでProjectSettingsの右上にある検索窓から項目を検索できるようになります。SettingsProvider最高！

<img width="806" alt="スクリーンショット 2019-12-02 9.16.40.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/530201/675c1376-0da6-fda6-7da1-a409b35010e4.png">

###2-2. プロパティの表示

```cs
// プロパティの表示
var iterator = so.GetIterator();
EditorGUI.BeginChangeCheck();
while (iterator.NextVisible(true))
{
	bool isScript = iterator.name.Equals("m_Script");
	if (isScript) { GUI.enabled = false; }
	
	EditorGUILayout.PropertyField(iterator);
	
	if (isScript) { GUI.enabled = true; }
}
if (EditorGUI.EndChangeCheck())
{
	so.ApplyModifiedProperties();
}
```

今回は特に表示のカスタマイズは行わないので、イテレータでぶん回してPropertyFieldを表示していきます。

Scriptは変更できてしまうと困るので、Inspectorと同じように編集不可で表示します。

編集したのに保存されない問題に遭遇しないよう、最後に`ApplyModifiedProperties()`を呼ぶのを忘れずに。

###2-3. テストボタン
入力したデータが正しいかどうか検証するためのボタンを用意します。

```cs
// データ検証用ボタン
using (new EditorGUILayout.HorizontalScope())
{
	if (GUILayout.Button("スペースを開く"))
	{
		var data = BacklogAPIData.Load();
		Application.OpenURL($"https://{data.SpaceKey}.{data.Domain}/projects/{data.ProjectKey}");
	}
	if (GUILayout.Button("認証テスト"))
	{
		var backlogAPI = new BacklogAPI();
		try
		{
			backlogAPI.LoadProjectInfo(() =>
			{
				EditorUtility.DisplayDialog("認証成功", "BacklogAPIの認証に成功しました。", "OK");
			});
		}
		catch (System.Exception e)
		{
			Debug.LogException(e);
		}
	}
}
```

**スペースを開く**

Backlog対象プロジェクトのホーム画面をブラウザで開きます。

**認証テスト**

API認証が通って、プロジェクト情報が正しくロードできるかテストします。バグ報告を実行する時も同じですが、認証時はブラウザが起動します。

<img width="497" alt="スクリーンショット 2019-12-02 8.30.38.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/530201/739d85bf-93f8-a30e-b308-b14d13aa931c.png">

「許可する」を押したらリダイレクト先のページ（今回の例では`http://localhost:12345`）が開きますが、必要ないので閉じます。

Unity側に戻ると認証成功のダイアログが出ます。

一度認証に成功するとキャッシュファイルが作られるので、以降ブラウザは起動しません。

###2-4. 設定項目

ProjectSettingsではこのように表示されます。

<img width="809" alt="スクリーンショット 2019-12-02 9.09.48.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/530201/cd0cad3a-e6b2-e8fe-a9cd-bb92aedac540.png">

**Backlog Developerサイトで発行した情報**

`Redirect URI` `Client Id` `Client Secret` は、Backlog Developerサイトで発行した情報を入力して下さい。

**対象プロジェクトの情報**

Backlogプロジェクトのホーム画面URLから各項目が分かります。

`https://{Space Key}.{Domain}/projects/{Project Key}`

例）`https://amagamina.backlog.com/projects/BUG_REP`

この例であれば `Space Key`が`amagamina`、`Domain`が`backlog.com`、`Project Key`が`BUG_REP` です。

プロジェクト情報を間違えてしまうと全然関係ないプロジェクトに対してバグ報告してしまうことになるので注意して下さい。
また、認証でログインしたアカウントが対象プロジェクトに参加していない人であればバグ報告に失敗します。

**認証情報のキャッシュファイル名（パス）**

このキャッシュファイルはプロジェクトのルート（Assetsフォルダと同じ階層）を基準にしたパスで作成されます。

git管理している場合であれば共有されないようignoreに追加するか、既にignore対象になっているパスに書き換えて下さい。

###2-Ex. レイアウト調整

このままでも問題ないですが几帳面な僕はBacklogAPIの設定項目の中身が他の設定に比べて左側に寄っているのが気になるので、直しておきます。

**Editor設定**
<img width="213" alt="スクリーンショット 2019-12-07 12.19.30.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/530201/158b6d6c-bb26-9532-48ad-3533c057e2a5.png">

**BacklogAPI設定**
<img width="154" alt="スクリーンショット 2019-12-07 20.45.46.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/530201/971f60d1-5650-9c10-4428-70bf8e33c03b.png">

他の設定がやっていることと同じことをやればいいよね。と思って[UnityCsReference](https://github.com/Unity-Technologies/UnityCsReference)を漁ってみたら[SettingsWindow.cs](https://github.com/Unity-Technologies/UnityCsReference/blob/master/Editor/Mono/Settings/SettingsWindow.cs)が機能を持っていることが分かったのですが、internal…。

仕様変更になる可能性もあるので、やり方だけ参考にして自前で用意することにしました。

`HorizontalScope`や`VerticalScope`と同じようなGUIScopeを作成します。

```BacklogAPISettings.cs
/// <summary>
/// 他の設定項目と比べて左側の余白が無いので、GUIScopeを作って付ける
/// </summary>
internal class GUIScope : GUI.Scope
{
	public GUIScope()
	{
		GUILayout.BeginHorizontal();
		GUILayout.Space(6f);
		GUILayout.BeginVertical();
	}

	protected override void CloseScope()
	{
		GUILayout.EndVertical();
		GUILayout.EndHorizontal();
	}
}
```

中に書かれてるものを`OnGUI()`の中にそのまま書いても同じですが、usingを使えばコードがスッキリするので、これを使います。

```BacklogAPISettings.cs
using (new GUIScope())
{
	// プロパティの表示
	...
}
```

これで余白ができました。

<img width="158" alt="スクリーンショット 2019-12-07 21.04.15.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/530201/f8a9457b-2eb9-cb04-dc07-ce4dc31e75bf.png">

##3. BacklogAPI認証処理

```BacklogAPI.cs
using System.Linq;
using UnityEngine;
using NBacklog;
using NBacklog.DataTypes;
using NBacklog.OAuth2;

/// <summary>
/// BacklogAPI
/// </summary>
public class BacklogAPI
{
	/// <summary>
	/// APIデータ
	/// </summary>
	public BacklogAPIData APIData { get; private set; }
	/// <summary>
	/// スペース
	/// </summary>
	public NBacklog.DataTypes.Space Space { get; private set; }
	/// <summary>
	/// プロジェクト
	/// </summary>
	public Project Project { get; private set; }

	/// <summary>
	/// プロジェクト情報
	/// </summary>
	public class ProjectData
	{
		/// <summary>
		/// チケットタイプ
		/// </summary>
		public TicketType[] TicketTypes;
		/// <summary>
		/// 優先度
		/// </summary>
		public Priority[] Priorities;
		/// <summary>
		/// カテゴリ
		/// </summary>
		public Category[] Categories;
		/// <summary>
		/// マイルストーン
		/// </summary>
		public Milestone[] Milestones;
		/// <summary>
		/// ユーザー
		/// </summary>
		public User[] Users;
	}
	public ProjectData Data { get; } = new ProjectData();

	/// <summary>
	/// プロジェクト情報の取得
	/// </summary>
	public async void LoadProjectInfo(System.Action onSuccess = null)
	{
		APIData = BacklogAPIData.Load();

		// 認証
		var client = new BacklogClient(APIData.SpaceKey, APIData.Domain);
		await client.AuthorizeAsync(new OAuth2App()
		{
			ClientId = APIData.ClientId,
			ClientSecret = APIData.ClientSecretId,
			RedirectUri = APIData.RedirectURI,
			CredentialsCachePath = APIData.CacheFileName,
		});

		// 各種データ取得
		Space = client.GetSpaceAsync().Result.Content;
		Project = client.GetProjectAsync(APIData.ProjectKey).Result.Content;
		Data.TicketTypes = Project.GetTicketTypesAsync().Result.Content;
		Data.Priorities = client.GetPriorityTypesAsync().Result.Content;
		Data.Categories = Project.GetCategoriesAsync().Result.Content;
		Data.Milestones = Project.GetMilestonesAsync().Result.Content;
		Data.Users = Project.GetUsersAsync().Result.Content;
			
		onSuccess?.Invoke();
	}
}
```
良い作りとは言えませんが呼び元がasyncでなくとも問題ないように戻り値をvoidにして成功コールバックを返す形にしています。気になる方は`async Task`に書き換えて下さい。

`AuthorizeAsync()`は**awaitが必須**です。同期処理で書いてしまうとブラウザを起動して認証が完了するまで拘束されるので、認証が終わってもUnityエディタに戻ってこれなくなります。

認証するついでにプロジェクトの各種情報も取得しておきます。例えば`GetUsersAsync()`はプロジェクトメンバーの一覧が取得できるので、バグ報告UIから担当者を選ぶドロップダウンリストに使用します。

#バグ報告用の設定ファイルを作成

バグ報告する時にカテゴリや担当者をドロップダウンから選ぶようにしますが、デフォルト値を設定しておきたいのでこちらも設定ファイルを作ります。

作成方法はBacklogAPIの設定ファイルと同じなので省略します。

先述した左側の余白だけはBacklogAPIでは6pxだったのに対してここでは10pxにしないと同じになりませんでした。原因はよくわからないです…。

<img width="355" alt="スクリーンショット 2019-12-07 21.15.23.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/530201/816cd434-3a4a-64dd-d385-a937e1c0916d.png">

発生バージョンやカテゴリはBacklog上のプロジェクト設定で作成しておきます。

発生バージョンについてはバージョンが上がる度にこの設定も更新しないと報告者が古いバージョンを指定したまま報告するミスが発生する可能性もあります。

そのため、もしBacklog上で設定している発生バージョンとUnityのPlayerSettingsで設定しているバージョンが同じで問題ないなら同期させておくのがいいでしょう。

#バグ報告ウィザードの作成

それではバグ報告機能の作成に入ります。今回はScriptableWizardを使用します。

##1. ScriptableWizardって何？

ちょっとした設定を入れてアセットを作成したりとか複雑なウィンドウが必要ない時にサクっと使えるウィンドウです。個人的には気に入っていてよく使っています。

設定機能はSettingsProviderを使用しましたが、直近だとUnity2018.4でバージョンを止めるプロジェクトも多いと思うのでここにはUIElementsは使っていません。

##2. ウィザードの生成
まずはUnityのメニューに`Backlog/バグ報告`の項目を追加して、クリックしたらウィザードが生成されるようにします。

ポイントは以下の4つです。

1. プレイ中のみ開けるようにする
2. BacklogAPIの認証＆プロジェクト情報のロードが終わってからウィザードを開く
3. ヘッダでスペース名とプロジェクト名が分かるようにする（違うプロジェクトに対してバグ報告しようとしてないか確認する意味も込めて）
4. 項目がちゃんと収まるようにウィンドウの最小サイズを設定する

```BugReportWizard.cs
/// <summary>
/// Backlogにバグ報告チケットを追加する
/// </summary>
public class BugReportWizard : ScriptableWizard
{
	private const string MenuPath = "Backlog/バグ報告";
	
	/// <summary>
	/// BacklogAPI
	/// </summary>
	private static readonly BacklogAPI m_BacklogAPI = new BacklogAPI();
	private static BacklogAPI.ProjectData ProjectData => m_BacklogAPI.Data;
	
	// 1. プレイ中のみ開けるようにする
	[MenuItem(MenuPath, validate = true)]
	static bool OpenValidate()
	{
		return EditorApplication.isPlaying;
	}

	[MenuItem(MenuPath)]
	static void Open()
	{
		try
		{
			EditorUtility.DisplayProgressBar("Backlog", "プロジェクト情報をロード中です...", 0f);
			// 2. BacklogAPIの認証＆プロジェクト情報のロードが終わってからウィザードを開く
			m_BacklogAPI.LoadProjectInfo(OpenWizard);
		}
		catch (Exception e)
		{
			Debug.LogException(e);
		}
		finally
		{
			EditorUtility.ClearProgressBar();
		}
	}

	private static void OpenWizard()
	{
		// 3. ヘッダでスペース名とプロジェクト名が分かるようにする
		string header = $"{m_BacklogAPI.Space.Name}:{m_BacklogAPI.Project.Name}:バグ報告";
		var wizard = DisplayWizard<BugReportWizard>(header, "バグ報告する");
		// 4. 項目がちゃんと収まるようにウィンドウの最小サイズを設定する
		wizard.minSize = new Vector2(640f, 440f);
	}

	...
}
```

ウィザードの表示は`DisplayWizard`を使用します。第2引数に渡しているのは作成ボタン（今回はバグ報告ボタン）に表示する文字です。

##3. デフォルト値の取得

ロードしたプロジェクト情報から各項目のプルダウンリストの元になるstringのリストを作成しつつ、ProjectSettingsで設定したデフォルト値のインデックスを取得しておきます。

```BugReportWizard.cs
// 種別は固定
private TicketType ticketType;

// プルダウンで選択するもの
private int priorityIndex;
private List<string> priorityDropDown;

private int versionIndex;
private List<string> versionDropDown;

private int categoryIndex;
private List<string> categoryPullDown;

private int assigneeIndex;
private List<string> assigneePullDown;

// ウィザードが有効になる時に自動で呼ばれる
private void OnEnable()
{
	var defaultValues = BugReportData.Load();

	// 種別（固定）
	ticketType = ProjectData.TicketTypes.FirstOrDefault(x => x.Name == defaultValues.TicketType);

	// 優先度
	priorityDropDown = ProjectData.Priorities.Select(x => x.Name).ToList();
	priorityIndex = priorityDropDown.FindIndex(x => x.Equals(defaultValues.Priority));

	// 発生バージョン
	versionDropDown = ProjectData.Milestones.Select(x => x.Name).ToList();
	versionIndex = versionDropDown.FindIndex(x => x.Equals(defaultValues.CurrentVersion));

	// カテゴリ
	categoryPullDown = ProjectData.Categories.Select(x => x.Name).ToList();
	categoryIndex = categoryPullDown.FindIndex(x => x.Equals(defaultValues.Category));

	// 担当者
	assigneePullDown = ProjectData.Users.Select(x => x.Name).ToList();
	assigneeIndex = assigneePullDown.FindIndex(x => x.Equals(defaultValues.Assignee));
}
```

##4. ウィンドウの描画

描画される時は`DrawWizardGUI()`がコールされます。この中で項目の配置を書いていきます。

```BugReportWizard.cs
// 設定項目
private string ticketTitle;
private string content;
private string howTo;
private bool isCaptureScreenShot = true;
private bool isSendLog = true;

private string searchText;

protected override bool DrawWizardGUI()
{
	if (m_BacklogAPI.Project == null || ticketType == null)
	{
		// 初期化失敗
		Close();
		return false;
	}

	EditorGUILayout.LabelField("タイトル（必須）");
	ticketTitle = EditorGUILayout.TextField(ticketTitle);
	if (string.IsNullOrEmpty(ticketTitle))
	{
		Color cCache = GUI.contentColor;
		GUI.contentColor = Color.red;
		EditorGUILayout.LabelField("タイトルを入力して下さい");
		GUI.contentColor = cCache;
	}
	EditorGUILayout.Space();

	const float minHeight = 42f;
	EditorGUILayout.LabelField("バグ内容");
	content = EditorGUILayout.TextArea(content, GUILayout.MinHeight(minHeight));
	EditorGUILayout.Space();

	EditorGUILayout.LabelField("再現方法");
	howTo = EditorGUILayout.TextArea(howTo, GUILayout.MinHeight(minHeight));
	EditorGUILayout.Space();

	using (new EditorGUILayout.HorizontalScope())
	{
		priorityIndex = EditorGUILayout.Popup("優先度", priorityIndex, priorityDropDown.ToArray());
		versionIndex = EditorGUILayout.Popup("発生バージョン", versionIndex, versionDropDown.ToArray());
	}
	EditorGUILayout.Space();

	using (new EditorGUILayout.HorizontalScope())
	{
		categoryIndex = EditorGUILayout.Popup("カテゴリ", categoryIndex, categoryPullDown.ToArray());
		assigneeIndex = EditorGUILayout.Popup("担当者", assigneeIndex, assigneePullDown.ToArray());
	}
	EditorGUILayout.Space();

	using (new EditorGUILayout.HorizontalScope())
	{
		isCaptureScreenShot = EditorGUILayout.Toggle("スクリーンショットを送る", isCaptureScreenShot);
		isSendLog = EditorGUILayout.Toggle("ログを送る", isSendLog);
	}
	EditorGUILayout.Space();

	// キーワードバグ検索機能
	EditorGUILayout.Space();
	EditorGUILayout.LabelField("キーワードバグ検索（ブラウザで開きます）");
	using (new EditorGUILayout.HorizontalScope())
	{
		searchText = GUILayout.TextField(searchText, "SearchTextField", GUILayout.Width(200));
		GUI.enabled = !string.IsNullOrEmpty(searchText);
		if (GUILayout.Button("Clear", "SearchCancelButton"))
		{
			searchText = string.Empty;
		}
		GUI.enabled = true;
		if (GUILayout.Button("検索", GUILayout.Width(60f)))
		{
			string searchURL = "https://{0}.{1}/find/{2}?condition.projectId={3}&condition.issueTypeId={4}&condition.statusId=1&condition.statusId=2&condition.statusId=3&condition.limit=20&condition.offset=0&condition.query={5}&condition.sort=UPDATED&condition.order=false&condition.simpleSearch=false&condition.allOver=false";
			var uri = new Uri(string.Format(
				searchURL,
				m_BacklogAPI.Space.Key,
				m_BacklogAPI.APIData.Domain,
				m_BacklogAPI.Project.Key,
				m_BacklogAPI.Project.Id,
				ticketType.Id.ToString(),
				searchText));
			Application.OpenURL(uri.AbsoluteUri);
		}
	}
	EditorGUILayout.Space();

	return true;
}
```

**キーワードバグ検索機能**

これはBacklog上に登録されているバグをキーワード検索する機能です。ブラウザで開くだけですが…。

今から報告しようとしているバグが既に登録されてるかも！と思った時に調べることができます。

検索画面のURLにパラメータをゴリっと埋め込んでいるので、URL仕様が変わることがあれば修正が必要になりそうです（ないことを祈ります）。

TextFieldのスタイルに`SearchTextField`、Buttonに`SearchCancelButton`を使えばよくある検索窓を作れるので、覚えておくと何かと便利かと思います。

##5. タイトルを入力するまで報告ボタンを押せないようにする

BacklogAPIの仕様でチケットのタイトルは必須項目です。

ScriptableWizardに`isValid`というプロパティが定義されていて、これが作成ボタン（報告ボタン）がアクティブかどうか判定するために使われています。

テキストが入力されたりなど、ウィザードに何かしら更新があった時は`OnWizardUpdate()`がコールされるので、この中でタイトルが入力されているかどうかチェックします。

```BugReportWizard.cs
private void OnWizardUpdate()
{
	isValid = !string.IsNullOrEmpty(ticketTitle);
}
```

#チケットの登録

ウィザードの準備は終わりました。最後にバグ報告ボタンが押された後のチケット登録処理を見ていきます。

```BugReportWizard.cs
private async void OnWizardCreate()
{
	EditorUtility.DisplayProgressBar("Backlog", "準備中です...", 0f);

	...
```

ボタンが押されると、`OnWizardCreate()`がコールされます。

スクリーンショットを撮影する際に非同期待ちできると楽だったので`async`を付けて非同期で呼ばれるようにしておきます。

手順を見ていきます。

##1. 詳細文の作成

入力された情報を基に詳細文を作成します。バグ内容、再現方法は入力された時のみ見出しを追加するようにしておきます。

```BugReportWizard.cs
//-- 詳細作成
string desc = "";

// バグ内容
if (!string.IsNullOrEmpty(content))
{
	desc += $"【バグ内容】\n{content}\n\n";
}

// 再現方法
if (!string.IsNullOrEmpty(howTo))
{
	desc += $"【再現方法】\n{howTo}\n\n";
}

// 発生OS
desc += "【環境】\n";
#if UNITY_EDITOR_OSX
desc += "発生OS:Mac\n";
#elif UNITY_EDITOR_WIN
desc += "発生OS:Windows\n";
#endif
```

ついでに発生OSを知りたいこともあると思うので簡単にくっつけておきます。

Unity2019.3からLinux版のエディターも提供されているので必要であれば対応しておくと良いでしょう。

もっと細かい情報を取得したい場合は[SystemInfo](https://docs.unity3d.com/ja/current/ScriptReference/SystemInfo.html)クラスを使用して下さい。

##2. ログを詳細文に入れる

フリープランは1つしかファイルを添付できず、その枠はスクリーンショットに利用したいのでログは詳細文の中に詰め込みます。

```BugReportWizard.cs
if (isSendLog)
{
	desc += "\n【ログ】\n";
	desc += LogRecorder.GetBacklogLogText();
}
```

直近で発生したログを記録しておくための`LogRecorder`クラスを作成します。

```LogRecorder.cs
using System.Collections.Generic;
using UnityEngine;
using System;

public static class LogRecorder
{
	/// <summary>
	/// ログデータ
	/// </summary>
	private struct LogData
	{
		/// <summary>
		/// メッセージ
		/// </summary>
		public string Message;
		/// <summary>
		/// スタックトレース
		/// </summary>
		public string StackTrace;
		/// <summary>
		/// ログタイプ
		/// </summary>
		public LogType Type;
		/// <summary>
		/// 最終発生日時
		/// </summary>
		public DateTime LastDate;
	}

	// 最大保持件数（重複は排除した後）
	private const int maxLog = 32;

	// ログリスト
	private static List<LogData> logList = new List<LogData>(maxLog);
		
	[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] 
	private static void RegisterReceiveLog()
	{
		Application.logMessageReceived += ReceiveLog;
	}

	private static void ReceiveLog(string logMessage, string logStackTrace, LogType logType)
	{
		// 通常のログは無視
		if (logType == LogType.Log)
		{
			return;
		}

		// 既に追加されているものと同じなら最終発生時間だけ更新
		for (int i = 0; i < logList.Count; ++i)
		{
			var log = logList[i];
			if (log.Type == logType &&
				log.Message.Equals(logMessage) &&
				log.StackTrace.Equals(logStackTrace))
			{
				log.LastDate = DateTime.Now;
				logList[i] = log;
				return;
			}
		}
			
		if (logList.Count >= maxLog)
		{
			// 発生時間が最も古いものを取り除く
			SortListByLastDate();
			logList.RemoveAt(logList.Count - 1);
		}

		logList.Add(new LogData
		{
			Message = logMessage,
			StackTrace = logStackTrace,
			Type = logType,
			LastDate = DateTime.Now
		});
	}

	// 最終発生時間でソート
	private static void SortListByLastDate()
	{
		logList.Sort((x, y) => DateTime.Compare(y.LastDate, x.LastDate));
	}

	...
}
```

`Application.logMessageReceived`にコールバックを設定することでログが記録されたタイミングで取得できます。

このコールバック登録タイミングですが、エディタ停止時なども受け取ってしまうと困るのでプレイ開始直後に登録します。

MonoBehaviour継承のスクリプトを用意しておいてAwakeで…というのはイマイチなので、`RuntimeInitializeOnLoadMethod`属性を付けたメソッドを作ります。

`RuntimeInitializeLoadType.BeforeSceneLoad`を指定することで、シーンがロードされる前のタイミングで登録可能です。

```csharp
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] 
private static void RegisterReceiveLog()
{
    Application.logMessageReceived += ReceiveLog;
}
```

ログファイルではなく詳細文に差し込む都合上、ログの量が多いとチケットが見づらくなってしまいます。

そのためここでは重複は排除した上で32件まで、デバッグ用ログは含めないようにしています。

作成したログリストは、Backlogの詳細文に適した形でフォーマットして取得できるようにしておきます。

```LogRecorder.cs
public static string GetBacklogLogText()
{
	SortListByLastDate();
	
	string text = "";
	foreach (var log in logList)
	{
		switch (log.Type)
		{
			case LogType.Error:
			case LogType.Exception:
			case LogType.Assert:
				text += "''&color(#ff0000) { ";
				break;
			case LogType.Warning:
				text += "''&color(#bbbb00) { ";
				break;
			default:
				continue;
		}
		text += $"{log.Type.ToString()}''";
		text += " }\n";
		text += $"''LastDate'' {log.LastDate}\n";
		text += $"''Message'' {log.Message}\n";
		text += $"''StackTrace'' {log.StackTrace}\n";
	}
	return text;
}
```

例えば`&color(カラーコード) {文字}`で文字色を変更できるので、エラー系統は赤、Warningは黄色などにしています。

なおフォーマットはMarkdownかBacklog独自フォーマットかをプロジェクトの基本設定で選択できるようになっています。

<img width="464" alt="スクリーンショット 2019-12-06 8.39.28.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/530201/81c8c3c3-d294-0262-18fd-582671afe776.png">

ここがMarkdownになっている場合は上記の例ではうまくフォーマットできないので、Markdownに書き直すか設定を変更する必要があります。

##3. スクリーンショットの添付

続いてスクリーンショットの添付です。

###3-1. ファイル添付の方法

添付というとチケット登録の通信と同時に送るイメージを持つかもしれませんが、これは違います。

Backlogの添付ファイルは、まず最初に仮アップロードします。

するとレスポンスで添付ファイルに振られたIDなどの情報を取得できます（NBacklog上はAttachmentクラス）。

この情報をチケット登録する際に指定することで、仮アップロードしたファイルは削除され、チケット情報と一緒に添付ファイルとして登録されます。

###3-2. スクリーンショット添付の手順

今回は以下のような手順でスクリーンショットを添付しています。

1. Gameビューのキャプチャをjpgで保存
2. Backlog上に仮アップロード
3. jpgファイルを削除

```BugReportWizard.cs
//-- 添付ファイルの作成
var attachments = new List<Attachment>();

// 画面スクショ
string screenShotName = string.Empty;
if (isCaptureScreenShot)
{
	EditorUtility.DisplayProgressBar("Backlog", "スクリーンショットを添付しています...", 0.25f);

	// 1. Gameビューのキャプチャをjpgで保存
	screenShotName = $"capture_{DateTime.Now.ToString("yyyy_MM_dd_H-mm-ss")}.jpg";
	ScreenCapture.CaptureScreenshot(screenShotName);
	string path = $"{Application.dataPath}/../{screenShotName}";

	// 撮影完了待ち
	bool isPaused = EditorApplication.isPaused;
	EditorApplication.isPaused = false;
	
	var startTime = DateTime.Now;
	var timeLimit = new TimeSpan(0,0,5);
	while (!File.Exists(path))
	{
		await Task.Delay(500);
		if (DateTime.Now - startTime > timeLimit)
		{
			// タイムアウト
			EditorUtility.ClearProgressBar();
			EditorUtility.DisplayDialog("エラー", "スクリーンショットの撮影に失敗しました", "OK");
			return;
		}
	}
	
	EditorApplication.isPaused = isPaused;
	
	try
	{
		// 2. Backlog上に仮アップロード
		var attachment = m_BacklogAPI.AddAttachment(path);
		if (attachment == null)
		{
			EditorUtility.ClearProgressBar();
			EditorUtility.DisplayDialog("エラー", "スクリーンショットの添付に失敗しました", "OK");
			return;
		}
		attachments.Add(attachment);
	}
	catch (Exception e)
	{
		EditorUtility.ClearProgressBar();
		Debug.LogException(e);
	}
	finally
	{
		// 3. jpgファイルを削除
		File.Delete(path);
	}
}
```

ファイル名は後ほど使うので、キャッシュしておきます。保存先は`Assets`と同じ階層です。

Gameビューをキャプチャする`ScreenCapture.CaptureScreenshot()`の注意点として、保存したファイルを利用する際は撮影完了待ちをしないといけません。

ここでは簡単な対応法として0.5秒ごとにファイルの存在をチェックして完了するまで待機しています。永久に終わらないことも考えられるのでタイムアウトも付けておきます。

この際の注意点として、エディタが再生中かつ一時停止しているとawaitのところから先に進まなくなってしまいます。

そのため一時停止されている場合は解除して、完了待ちが終わったら元に戻します。

```csharp
bool isPaused = EditorApplication.isPaused;
EditorApplication.isPaused = false;

...

EditorApplication.isPaused = isPaused;
```

スクリーンショットを送る時はその場面を逃さないために一時停止してからバグ報告を起動することが多いはずなので、この対応は必須です。

**BacklogAPIの認証処理もawaitで止まる**

エディタが一時停止しているとawaitから進まなくなる問題はAPIの認証処理でも起こります（という事に記事を書いている途中で気づきました…）。

ProjectSettingsの認証テストから認証する場合は再生停止中でも認証ができるので問題ないですが、初めてバグ報告を利用する人が一時停止した状態でウィザードを開く可能性もあるので、対応を入れておきます。

```BacklogAPI.cs
// エディタが再生中かつ一時停止中だと認証時にawaitで止まってしまうので、キャッシュがない時は一時停止を解除する
bool isPaused = EditorApplication.isPaused;
if (EditorApplication.isPlaying)
{
	bool isCached = File.Exists($"{Application.dataPath}/../{APIData.CacheFileName}");
	if (!isCached)
	{
		EditorApplication.isPaused = false;
	}
}

// 認証
var client = new BacklogClient(APIData.SpaceKey, APIData.Domain);
await client.AuthorizeAsync(new OAuth2App() {...});

EditorApplication.isPaused = isPaused;
```

初回だけ一時停止で残した場面を逃してしまうことになりますが、ウィザードが開かず混乱するよりは良いでしょうという事で今回はこれくらいの対応に留めておきます。

###3-3. ファイルの仮アップロード処理

BacklogAPI.csに実装しておきました。こちらを使用します。

なお、ファイルはプロジェクトをまとめた組織単位にあたる**スペース**に対して追加します。

```BacklogAPI.cs
/// <summary>
/// スペースに添付ファイルを追加
/// </summary>
public Attachment AddAttachment(string filePath)
{
	var fileInfo = new System.IO.FileInfo(filePath);
	var res = Space.AddAttachment(fileInfo).Result;
	if (CheckIsRetry(res))
	{
		// トランザクション系のエラーだったらリトライ
		res = Space.AddAttachment(fileInfo).Result;
	}
	return GetResult(res);
}

/// <summary>
/// トランザクション系のエラーで失敗しているかチェック
/// </summary>
public bool CheckIsRetry<T>(BacklogResponse<T> res)
{
	return !res.IsSuccess && res.Errors.Any(x => x.Message.StartsWith("Deadlock"));
}

/// <summary>
/// レスポンスの結果を取得
/// </summary>
public T GetResult<T>(BacklogResponse<T> res) where T : BacklogItem
{
	if (res.IsSuccess)
	{
		return res.Content;
	}
	Debug.LogError(string.Join(", ", res.Errors.Select(x => x.Message)));
	return null;
}
```

NBacklogのテストコードに書かれていたものを参考に、トランザクション系のエラーで失敗した時は1度だけリトライするようにしておきます。

レスポンスのエラーチェック周りはチケット登録処理でも利用するので汎用的に使えるよう、メソッドに切り出してあります。

##4. チケット登録処理

ここまで準備した内容を基にチケットを登録して完了です。

```BugReportWizard.cs
//-- チケット作成
EditorUtility.DisplayProgressBar("Backlog", "バグ報告チケットを追加しています...", 0.75f);

// 優先度
string priorityName = priorityDropDown[priorityIndex];
var priority = ProjectData.Priorities.FirstOrDefault((x) => x.Name == priorityName);

// バージョン
string versionName = versionDropDown[versionIndex];
var version = ProjectData.Milestones.FirstOrDefault((x) => x.Name == versionName);

// カテゴリ
string categoryName = categoryPullDown[categoryIndex];
var category = ProjectData.Categories.FirstOrDefault((x) => x.Name == categoryName);

// 担当者
string assigneeName = assigneePullDown[assigneeIndex];
var assignee = ProjectData.Users.FirstOrDefault((x) => x.Name == assigneeName);		

var ticket = new Ticket(ticketTitle, ticketType, priority);
ticket.Description = desc;
ticket.Versions = new[] { version };
ticket.Categories = new[] { category };
ticket.Assignee = assignee;
if (attachments.Count > 0)
{
	ticket.Attachments = attachments.ToArray();
}

try
{
	var result = m_BacklogAPI.AddTicket(ticket);
	if (result == null)
	{
		EditorUtility.ClearProgressBar();
		EditorUtility.DisplayDialog("エラー", "チケット作成に失敗しました", "OK");
		return;
	}

	// サムネをを加える（後述）
	...

	EditorUtility.ClearProgressBar();
	if (EditorUtility.DisplayDialog("Backlog", " バグ報告が完了しました", "チケットを開く", "閉じる"))
	{
		m_BacklogAPI.OpenBacklogTicket(result);
	}
}
catch (Exception e)
{
	EditorUtility.ClearProgressBar();
	Debug.LogException(e);
}
```

プルダウンで選択したものはそのままだと文字列なので、適切なデータ型に直します（プロジェクト情報から取得し直す）。

配列で渡す必要があるもの（Backlog上で複数登録できる仕様になっているもの）については配列に直しておきます。

`AddTicket()`の処理内容です。流れ自体は添付ファイルの仮アップロードと同じです。

```BacklogAPI.cs
/// <summary>
/// チケットを追加
/// </summary>
public Ticket AddTicket(Ticket ticket)
{
	var res = Project.AddTicketAsync(ticket).Result;
	if (CheckIsRetry(res))
	{
		// トランザクション系のエラーだったらリトライ
		res = Project.AddTicketAsync(ticket).Result;
	}
	return GetResult(res);
}
```

最後に完了を知らせるダイアログでチケットページを開けるようにしておきます。`OpenBacklogTicket()`
の中身は以下の通りです。

```BacklogAPI.cs
/// <summary>
/// Backlogのチケットページを開く
/// </summary>
public void OpenBacklogTicket(Ticket ticket)
{
	Application.OpenURL($"https://{Space.Key}.{APIData.Domain}/view/{ticket.Key}");
}
```

チケット登録のレスポンスで取得したTicketクラスにURLで使用されるキーが入っているので、これを使えばブラウザで開くためのURLが作成できます。

##Ex. サムネイルの追加

ここまでの内容で最低限必要そうな要件は満たしていますが、添付されたスクリーンショットを見るのにチケットページの下までスクロールして添付ファイルの一覧から開く必要があってちょっと面倒です。

そこでチケットページの冒頭にスクリーンショットのサムネイルが表示されるようにしてみます。

チケット登録が完了した直後に以下の処理を追加します。

```BugReportWizard.cs
// サムネをを加える
// [注意] カスタム属性があるプロジェクトの場合、カスタム属性の対応を入れないと更新に失敗する
if (isCaptureScreenShot)
{
	EditorUtility.DisplayProgressBar("Backlog", "チケットに情報を追加しています...", 0.9f);

	// 一度スペースに追加した添付ファイルは、実際にチケットが発行されると削除され新しくIDが振られるので、
	// このタイミングでないと付け加えられない
	int thumbnailId = result.Attachments.First(x => x.Name.Equals(screenShotName)).Id;
	result.Description = $"#thumbnail({thumbnailId.ToString()})\n\n" + desc;
	result = m_BacklogAPI.UpdateTicket(result);

	if (result == null)
	{
		EditorUtility.ClearProgressBar();
		EditorUtility.DisplayDialog("エラー", "情報の追加に失敗しました", "OK");
		return;
	}
}
```

詳細文でサムネイルを表示するには、文中に`#thumbnail(添付ファイルID)`を加えます。

先に詳細文に加えておかない理由として、先述した通りスペースに対して仮アップロードされた添付ファイルは削除されてしまうからです。

IDも新しいものが振られるので、チケットを登録したあと、新しく発行されたIDを使って**チケットを更新する**という流れでしか実現できません。

添付ファイルが複数ある場合はどれがスクリーンショットなのか調べないといけないので、ファイルを保存する際に利用したファイル名をキャッシュしていたわけです。

チケットの更新処理は以下の通りです。これも流れは登録と同じです。

```BacklogAPI.cs
/// <summary>
/// チケットの更新
/// </summary>
public Ticket UpdateTicket(Ticket ticket)
{
	var res = Project.UpdateTicketAsync(ticket).Result;
	if (CheckIsRetry(res))
	{
		// トランザクション系のエラーだったらリトライ
		res = Project.UpdateTicketAsync(ticket).Result;
	}
	return GetResult(res);
}
```

更新を行う都合上、Backlogホーム画面に表示される更新一覧にも表示されてしまうというデメリットもあるので、ホーム画面をすっきりさせたい場合は導入するかどうか要検討です。

<img width="421" alt="スクリーンショット 2019-12-06 10.23.11.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/530201/7738ac7e-5e9c-6155-a763-dfe251ae40b6.png">

こちらを見れば分かる通り、記事中にサムネを加えなくてもホーム画面ではスクリーンショットのサムネが表示されます。

**カスタム属性に注意**

フリープランでは利用できませんが、Backlogでは既に用意されている項目に加えて独自に項目を追加できるカスタム属性という仕様があります。

カスタム属性の対応を入れないと更新に失敗してしまうので、使っている場合は注意して下さい。

僕は対応する気になれなかったのでBacklog側に設定していたカスタム属性を削除してしまいました（元々あまり使ってなかったのもあり）。

参考：[Backlogの課題追加のAPIで分かりにくい点](https://qiita.com/gam0022/items/404eacf974c406c2c76e)

#まとめ

##1. 拡張案

今回は最低限これくらい入っていれば活用してもらえるかなというラインに留めたので、もっと使いやすくしたいならこんなのはどうでしょうというのを書いておきます。

**バグ内容、再現方法にBacklogフォーマットを適用するボタン**

ブラウザでチケットを編集する時はこういうボタンがあるので、これもあったら嬉しいですよね。太字とか箇条書きは使いたいこともありそうです。

<img width="417" alt="スクリーンショット 2019-12-06 13.55.40.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/530201/2b1f909a-80fc-d2e9-03f5-816fb944b10c.png">

**スクリーンショットのペイント機能**

枠で囲んだり、矢印を付けたいことってありますよね。それくらいの簡単な加工ができると便利そうです。

そこまでしたい場合は「OSのペイントツールで加工してBacklog上で直接アップロードしてね」でも問題ないかと思っているので今後も特に入れる予定はないです…。

**動画撮影と添付**

動画でないと状況が伝わりにくいバグもありますよね。実装するならNintendoSwitchについてる機能みたいに、直近の数十秒を動画ファイルにするとかでしょうか。

常に記録し続けるとエディタが重くなりそうなのでメニューから動画撮影のON/OFFが必要そうですね。

**任意ファイル添付**

Unityエディタ自体のバグを報告する機能には任意ファイルを添付する機能が付いていますね。

任意ファイルはBacklog上に直接アップロードで問題ないかとは思っていますが、実装は簡単なので入れても良いでしょう。（ただしフリープランの場合はスクリーンショットと合わせて1つまでなので注意）

##2. 参考

- [BacklogAPIとは](https://developer.nulab.com/ja/docs/backlog/)
- [NBacklog](https://github.com/hal1932/NBacklog)
- [【Unity】Unity 2018.3 で Project Settings ウィンドウに独自のメニューを追加する方法](http://baba-s.hatenablog.com/entry/2018/12/17/085000)
- [Editor拡張 - ScriptableWizardについて](https://qiita.com/messhi/items/3c5b668059ed147aac01)

##3. 最後に

`ツール製作コスト > ツールで削減できるコスト`になってしまってはダメですが、このバグ報告機能は比較的手軽に作れて（と言って良いのかどうかは怪しいですが）、**報告する側のコストも、バグ調査/修正する側のコストも削減できる**ので効果が大きいのではないでしょうか。

専属デバッガーがいない状況なら特に、Unityでプロダクト開発を行っているメンバーからも気軽にバグ報告できるようになるメリットは大きいでしょう。

ぜひ皆さんのプロジェクトに応じた形でカスタマイズしてご利用頂ければと思います！