Redmineとプリザンターを連携してみた

# 概要
Redmineとプリザンターを連携する仕組みをプリザンターのサーバスクリプト機能で実装しました。

プリザンターとRedmineの連携について検索すると2019年の記事としてPowerShellを使った事例が見つかりました。
2019年以降、プリザンターは多くの機能が追加されており、特に「サーバスクリプト」は各種外部APIによる他システムとの連携が可能な機能です。
今回はこのサーバスクリプト機能を利用してRedmineと連携するアプリを作成しました。

# 実現したもの
今回作成したアプリは以下の仕様です。
・プリザンター側にボタンを配置し、クリックすることでRedmine側の全チケットをプリザンターにUpsert（あれば更新・なければ追加）を行う。
・プリザンター側でレコードを登録（更新）するとRedmine側にチケットを登録（更新）する。
・Redmineのチケットの取得・登録・更新はRedmineが提供するAPIを利用する。

# 動作イメージ
## 1. Redmineからレコード取込
* Redmineにチケット登録済み
![Redmine-01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3314422/ceb9434f-610c-2d0b-beba-4f4d0133c95f.png)    
* プリザンターの一覧画面下部の「Redmine取込」ボタンをクリック
![プリザンター-取込ボタン.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3314422/a6e2936c-390f-5f88-f700-289e79baac26.png)
* Redmineのチケットがプリザンターに取り込まれる
![プリザンター-取込後.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3314422/f752d3e2-7b37-6b1d-b2a3-a242e4fe272a.png)

## 2. Redmineへレコード登録更新
* プリザンターでレコードを新規登録
![プリザンターにデータ登録前.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3314422/b4d57a1c-5470-1647-b4a7-39e87120abb1.png)
![プリザンターにデータ登録後.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3314422/f4471864-a998-d769-f036-a2580346720b.png)

* Redmineに新しいチケットとして登録される
![プリザンターにデータ登録後-Redmine-01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3314422/e4d9b434-ffe8-d1fb-9f12-a0c7c4bd386d.png)
![プリザンターにデータ登録後-Redmine-02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3314422/d8510091-4b8a-dc37-2915-63b3271b9cce.png)

# スクリプト
作成したスクリプトは以下の通りです。

1. Redmineからレコード取込（サーバスクリプト）
    ```javascript
    // Redmineからレコード取込
    // サーバスクリプトの条件：画面表示の前
    let success = 0;
    let failue = 0;
    if (context.Forms.ControlId() === 'SyncRedmine') {
        try {
            if (context.Action === 'index') {
                httpClient.RequestUri= 'https://pleasanter.cloud.redmine.jp/issues.json';
                httpClient.RequestHeaders.Add("X-Redmine-API-Key", APIKEY);
                let results = httpClient.Get();
                results = JSON.parse(results);
                if (results.total_count > 0) {
                    for(let issue of results.issues) {
                        // プリザンターにデータ登録：Upsert
                        let data = {
                            Keys: ["IssueId"],
                            Title: issue.subject,
                            Body: issue.description,
                            Owner: convertToPleasanterUserId(isExistKey('assigned_to', issue) ? issue.assigned_to.name : ''),
                            StartTime: new Date(issue.start_date),
                            CompletionTime: (!issue.due_date) ? new Date() : new Date(issue.due_date),
                            WorkValue: issue.spent_hours,
                            ProgressRate: issue.done_ratio,
                            IssueId: parseInt((issue.custom_fields.find(cf => cf.id == 1)).value) || 0,
                            ClassHash: {
                                ClassA: (issue.custom_fields.find(cf => cf.id == 2)).value,
                                ClassB: (issue.custom_fields.find(cf => cf.id == 4)).value,
                                ClassC: (issue.custom_fields.find(cf => cf.id == 3)).value,
                                ClassD: issue.id,
                                ClassE: issue.status.id,
                                ClassF: issue.tracker.id
                            }
                        };
                        let ret = items.Upsert(SITEID, JSON.stringify(data));
                        context.Log(`同期結果: ${ret}, チケットID: ${issue.id}`);
                        ret ? success++ : failue++;
                    }
                }
            }
        } catch(e) {
            context.Log(e.stack);
        }
    }
    ```  
1. Redmineへレコード登録更新（サーバスクリプト）
    ``` javascript
    // Redmineへレコード登録更新
    // サーバスクリプトの条件：作成後、更新後
    try {
        let start_date = model.StartTime;
        let due_date = model.CompletionTime;
        // デモ環境はUTCなので、JSTに変換する
        start_date.setMinutes(start_date.getMinutes() + 540);
        due_date.setMinutes(due_date.getMinutes() + 540);
        // sv-SEロケールはYYYY-MM-DD形式の日付文字列を戻す
        start_date = start_date.toLocaleDateString('sv-SE');
        due_date = due_date.toLocaleDateString('sv-SE');
        let data = {
            issue : {
                "subject": model.Title,
                "project_id": 1,
                "tracker_id": model.ClassF,
                "id": model.ClassD,
                "status_id": model.ClassE,
                "description": model.Body,
                "start_date": start_date,
                "due_date": due_date,
                "assigned_to_id": convertToRedmineUserId(model.Owner),
                "custom_fields":[
                    {
                        "id":1,
                        "value": model.IssueId
                    },
                    {
                        "id":2,
                        "value": model.ClassA
                    },
                    {
                        "id":3,
                        "value": model.ClassC
                    },
                    {
                        "id":4,
                        "value": model.ClassB
                    }
                ]
            }
        }
        httpClient.Content = JSON.stringify(data);
        httpClient.RequestHeaders.Add("X-Redmine-API-Key", APIKEY);
        // ClassD（Redmine側のチケットID）が空欄ならRedmineへ新規登録する
        if (model.ClassD === '') {
            // 新規登録
            httpClient.RequestUri= "https://pleasanter.cloud.redmine.jp/issues.json";
            let result = httpClient.Post();
            result = JSON.parse(result);
            // Redmine新規登録で採番されたチケットIDをClassDに登録
            model.ClassD = result.issue.id;
            model.UpdateOnExit = true;
            context.Log(`新規登録: チケットID ${model.ClassD}`);
        } else {
            // 更新
            httpClient.RequestUri= `https://pleasanter.cloud.redmine.jp/issues/${model.ClassD}.json`;
            let result = httpClient.Put();
            context.Log(`更新: チケットID ${model.ClassD}`);
        }
    } catch(e) {
        context.Log(e.stack);
    }
    ```

1. 共通変数・関数（サーバスクリプト）
    ```javascript
    // 共通変数・関数
    // サーバスクリプトの条件：共有

    // RedmineのApiKey
    const APIKEY = 'xxxx....';

    // プリザンター WBSテーブルのサイトID
    const SITEID = 123456;

    // Redmine、プリザンター ユーザIDリスト
    const USERLIST = [
        { p_userid: 101, r_userid: 5, name: "テナント 管理者" },
        { p_userid: 102, r_userid: 6, name: "村上 佳奈" },
        { p_userid: 103, r_userid: 7, name: "井上 健一" },
        { p_userid: 104, r_userid: 8, name: "松本 美咲" },
        { p_userid: 104, r_userid: 9, name: "山口 太郎" },
        { p_userid: 105, r_userid: 10, name: "佐々木 春香" },
        { p_userid: 106, r_userid: 11, name: "伊藤 大輔" },
        { p_userid: 107, r_userid: 12, name: "吉田 結" },
        { p_userid: 108, r_userid: 13, name: "加藤 誠" },
        { p_userid: 109, r_userid: 14, name: "小林 佳子" },
        { p_userid: 110, r_userid: 15, name: "中村 隆" },
        { p_userid: 111, r_userid: 16, name: "山本 陽子" },
        { p_userid: 112, r_userid: 17, name: "渡邉 博" },
        { p_userid: 113, r_userid: 18, name: "田中 恵子" },
        { p_userid: 114, r_userid: 19, name: "鈴木 洋一" },
        { p_userid: 115, r_userid: 20, name: "Brown Emily" },
        { p_userid: 116, r_userid: 21, name: "Wilson Daniel" },
        { p_userid: 117, r_userid: 22, name: "斎藤 美樹" },
        { p_userid: 118, r_userid: 23, name: "高橋 一郎" },
        { p_userid: 119, r_userid: 24, name: "佐藤 由香" },
        { p_userid: 120, r_userid: 1, name: "Admin Redmine" }
    ]

    // 名前からプリザンターのUserIdを取得
    const convertToPleasanterUserId = function(name) {
        return USERLIST.find(ul => ul.name == name)?.p_userid || '';
    }
    // プリザンターUserIdからRedmineのUserIdを取得
    const convertToRedmineUserId = function(id) {
        return USERLIST.find(ul => ul.p_userid == id)?.r_userid || '';
    }

    // キー存在チェック
    const isExistKey = function(key, json) {
        return (key in json);
    }
    ```

1. ボタン配置（スクリプト）
    ```javascript
    // Redmine同期ボタン設置
    // スクリプトの条件：一覧

    $p.events.on_grid_load = function() {
        $('#MainCommands').append($('<button id="SyncRedmine" onclick="$p.send($(this));" data-method="post">Redmine同期</button>').button({ icon: 'ui-icon-refresh' }));
    }
    ```

# 解説
## 1. Redmineからレコード取込
この処理は「画面表示の前」で実行するように設定します。ただし、「画面表示の前」はメニュー画面からテーブルをクリックした際や、直接URLアクセスした場合でも実行するので、取込処理を実行する条件として、6行目のif文で画面表示時の送信元コントロールのIDが「Redmine取込」ボタンのIDかをチェックします。
この送信元コントロールとは、今回の場合は画面表示のトリガーとなったコントロールのことです。で、どうやって画面表示をさせたかというと、スクリプトで配置した「Redmine取込」ボタンクリックでサーバへの送信メソッド```$p.send($(this));```で実行させます。
$p.sendメソッドはポストバックのような動作となり、画面リロードを行うので、その結果サーバスクリプトの「画面表示の前」の処理が実行するという仕掛けです。
以上のように
* IDを指定し、OnClickに```$p.send($(this));```を指定したボタンを画面に配置
* クリック後に処理させたいサーバスクリプトは「画面表示の前」に記述
* 処理内部で送信元コントロールIDをチェック
という流れは、様々な処理をサーバスクリプトで実現させる場合に有効なテクニックです。

7行目ではアクション名のチェックですが、大雑把に言うと「画面表示の前」で表示しようとしている画面の種類をチェックします。今回は一覧画面表示ですので"index"となります。
8～10行目ではサーバスクリプトの[httpClient](https://pleasanter.org/manual/server-script-http-client)を用いてRedmineのAPIを実行しています。ここではRedmineのチケット取得用APIに対してGetを行います。
Redmineからレコードが取得出来たら、プリザンターの[items.Upsert](https://pleasanter.org/manual/server-script-items-upsert)を用いてチケット内容をUpsertします。Upsertのキー項目は期限付きテーブルのレコードIDであるIssueIdを指定しますが、Redmine側にプリザンターのレコードID（IssueId）を格納するカスタムフィールドをあらかじめ設定しておきます。このカスタムフィールドへの登録は次項で説明します。
また、プリザンター上にはあらかじめRedmine側のチケットIDを格納する項目を配置し、Upsertする際にチケットIDも合わせて登録します。このチケットIDは2.の処理で利用します。

## 2. Redmineへレコード登録更新
こちらは「作成後」「更新後」で実行するように設定した処理で、内容はプリザンター上で入力した情報をRedmineへ登録するためのデータとしてJSON形式で加工し、サーバスクリプトのhttpClientを用いてRedmineの登録／更新APIを呼び出すという内容です。登録／更新の判断は、あらかじめプリザンター上に配置したRedmineチケットIDを格納する項目に値が登録済みか否かで判断します。なおRedmineのチケットIDは、登録API呼出し後の戻り値に含まれるので、52行目のように戻り値のチケットIDを画面の項目にセットし、[UpdateOnExit](https://pleasanter.org/manual/server-script-model)を利用して登録します。
また、「作成後」で実行するように設定してあるので、1.のRedmineからレコード取込した際にも実行します。取込時（プリザンター側に新規登録した際）にチケットIDは登録済みであるため、このチケットIDをもとにしたRedmineの更新APIを呼び出し、Redmine側にプリザンターのレコードIDを登録することで双方のレコードの紐づけが完了します。

## 3. その他
共通変数・関数は条件を「共有」で設定したサーバスクリプトです。主にRedmineのAPIを実行するためのAPIキー、プリザンター側のチケット登録用テーブルIDを管理します。またプリザンター側のユーザとRedmine側のユーザを管理するIDを読み替える定義ファイルと読み替えロジックを記述しています。
その他、Redmineで配置した項目で選択肢を作成した場合、内部上のコード値は自動付与されます。プリザンターに同じ項目を連携する場合はプリザンター側の分類項目の選択肢でRedmine側の内部コード値を設定するか、上記ユーザのような読み替え用の定義ファイルと読み替えロジックが必要になります。

# 実際の利用では
以上の処理では双方でレコードを削除した場合に未対応ですので、興味のある方はぜひチャレンジしてください。

# まとめ
プリザンターのサーバスクリプト機能はすでにご利用いただいていると思いますが、その他システムとの連携も実現できる強力な機能で、いろいろな活用方法があると思います。
引き続きプリザンターをよろしくお願いします！