【Redmine API + GAS】Googleフォームの投稿内容からRedmineチケットを自動起票する

## はじめに
業務で「**Googleフォームで受け付けたお問い合わせ内容を元に、Redmineチケットを自動起票するGASを作成してほしい**」という要望があったので対応しました。

ざっくりと、以下のような動きとなります。
①Googleフォーム送信、スプシへ自動でフォーム投稿内容が集積される。
②Googleフォーム送信をトリガーにGASスクリプトが起動。
③スクリプト内で、スプシからフォーム回答内容を取得。
④スクリプト内でRedmine API呼び出し、チケットを自動起票する。

当記事では、私が作成したGASスクリプトをサンプルとして展開、またそのサンプルコードの解説を行っています。
ご自分の実装したい内容に沿うよう、適宜カスタマイズしてお使いください。

## GASサンプルコード全量
<details><summary>＜サンプルコード👇＞</summary>

``` TypeScript
// メイン処理
function mainFunction() {
  console.log('処理開始：mainFunction')

  const account = new RedmineAccountForCreateTicket();
  const groupMenbers = new GroupMembersId();

  const user = account.user;                 // ログインID
  const pass = account.pass;                 // パスワード
  const apiKey = account.apiKey;             // APIキー
  const GL = groupMenbers.GL;                // GL
  const menberList = groupMenbers.idList;    // グループメンバーリスト

  createIssue(user, pass, apiKey, GL, menberList);

  console.log('処理終了：mainFunction')
}

// チケット起票Redmineアカウント
class RedmineAccountForCreateTicket {
  constructor() {
    const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
    const sheet = spreadsheet.getSheetByName('チケット起票アカウント');

    this.user = sheet.getRange(2, 1).getValue();   // ログインID
    this.pass = sheet.getRange(2, 2).getValue();   // パスワード
    this.apiKey = sheet.getRange(2, 3).getValue(); // APIキー
  }
}

// グループメンバーID
class GroupMembersId {
  constructor() {
    const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
    const sheet = spreadsheet.getSheetByName('個人情報マスタ');

    this.idList = new Array;

    for (var i = 2; i <= sheet.getLastRow(); i++) {
      const kbn = sheet.getRange(i, 3).getValue();  // メンバー区分（1:GL、2:メンバー）
      const id = sheet.getRange(i, 2).getValue();   // ユーザーID

      // 区分がGL（1）の場合
      if (kbn == 1) {
        // GLにIDを設定
        this.GL = id
        // リストにIDを追加
        this.idList.push(id)

        // 区分がメンバー（2）の場合
      } else if (kbn == 2) {
        // リストにIDを追加
        this.idList.push(id)
      }
    }
  }
}


// チケット作成
function createIssue(user, pass, apiKey, GL, menberList) {
  console.log('処理開始：createIssue')

  var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = spreadsheet.getSheetByName('フォーム回答_チケット作成用');
  var redmine_url = 'https://[redmine server]/issues.json';
  var project_id = [プロジェクトID];            // プロジェクト指定
  var assigned_to_id = GL;                     // 担当者ID（GL）
  var parent_issue_id = [親チケットNo];         // 親チケットNo

  // 質問毎にRedmineチケットを作成
  for (var i = 2; i <= sheet.getLastRow(); i++) {
    var start_date = sheet.getRange(i, 1).getValue();                  // 開始日
    var due_date = sheet.getRange(i, 2).getValue();                    // 期日
    var title = sheet.getRange(i, 3).getValue();                       // タイトル
    var description = sheet.getRange(i, 4).getValue();                 // チケット説明欄
    var ticket_flg = sheet.getRange(i, 5).getValue();                  // チケットが既に作成されているか？

    // チケット作成前判定（チケットが既に作成されている場合、処理をスキップする）
    if (ticket_flg === 1)  {
      continue;
    }

    // Redmineチケット発行に必要な情報を設定
    var issue = {
      'subject': title,
      'description': description,
      'tracker_id': 36,                                                 // トラッカー「QA」
      'status_id': 1,                                                   // ステータス「新規」
      'priority_id': 2,                                                 // 優先度「通常」
      'start_date': start_date,
      'due_date': due_date,
      'assigned_to_id': assigned_to_id,
      'parent_issue_id': parent_issue_id,
      'watcher_user_ids': menberList
    }

    var payload = {
      'issue': issue,
      'project_id': project_id
    };

    payload = JSON.stringify(payload);

    var headers = {
      "Authorization": " Basic " + Utilities.base64Encode(user + ":" + pass),
      'X-Redmine-API-Key': apiKey,
      'X-Redmine-Switch-User': user,
      'Content-Type': 'application/json',
    };

    var options = {
      'method': 'POST',
      'headers': headers,
      'payload': payload,
      'contentType': 'application/json',
    };

    // 送信内容確認
    console.log("タイトル：" + title)
    console.log("説明：" + description)
    console.log('オプション：', JSON.stringify(options, null, 2));

    var response = UrlFetchApp.fetch(redmine_url, options);
    console.log(i - 2 + "件目の問い合わせ、チケット起票終了")

    // チケット作成フラグを立てる
    sheet.getRange(i, 5).setValue(1);
  }
  console.log('処理終了：createIssue')
  return response;
}

```

</details>

## サンプルコード解説
### ①チケット起票Redmineアカウントをスプシから取得
メイン処理にて、Redmine APIを利用するアカウントの情報を
`new RedmineAccountForCreateTicket()`で取得する。
``` TypeScript
  // mainFunction
  const account = new RedmineAccountForCreateTicket();
  //（中略）
  const user = account.user;                 // ログインID
  const pass = account.pass;                 // パスワード
  const apiKey = account.apiKey;             // APIキー
```

`RedmineAccountForCreateTicket`で、「**チケット起票アカウント**」というシートから **RedmineアカウントのログインID・パスワード・APIキー** 等のAPIを利用する上で必要となる認証情報を取得する。
（ソースにパスワード等をベタ書きするのが嫌だったのと、使用するAPIキーなどが変更となった場合にスプシ側を修正するだけで済むので、これらの情報はシートに外出ししています。）
``` TypeScript
    // RedmineAccountForCreateTicket
    const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
    const sheet = spreadsheet.getSheetByName('チケット起票アカウント');

    this.user = sheet.getRange(2, 1).getValue();   // ログインID
    this.pass = sheet.getRange(2, 2).getValue();   // パスワード
    this.apiKey = sheet.getRange(2, 3).getValue(); // APIキー
```

#### 「チケット起票アカウント」シート構成
`sheet.getRange(行, 列)`で上手に値を取得してください。

|   | A | B | C |
|:-:|:-:|:-:|:-:|
| 1 | ログインID | パスワード | APIキー |
| 2 | [ログインID 値] | [パスワード 値]  | [APIキー 値] |


### ②ウォッチャーに追加したい人、チケット担当者に設定したい人をスプシから取得
メイン処理にて`new GroupMembersId()`を呼び出す。
※内輪の話ですが、`Group`とはフォームからの問い合わせに対応する社内グループのことを指しています。チケットを連携したい人達のことだと捉えてください。
グループメンバーの変動に備えて私はシートへ情報を外だししましたが、チケット担当者・ウォッチャーが固定であるならソースにユーザーIDをベタ書きで良いと思います。
``` TypeScript
  // mainFunction
  const groupMenbers = new GroupMembersId();
  // (中略)
  const GL = groupMenbers.GL;                // GL
  const menberList = groupMenbers.idList;    // グループメンバーリスト
```

`GroupMembersId`にて、「**個人情報マスタ**」シートに記載のユーザー一覧を取得。
メンバー区分より以下のような判定を行う。
* メンバー区分「1（グループリーダー）」の場合：チケット担当者 ＆ ウォッチャーに指定
* メンバー区分「2（通常メンバー）」の場合　　：ウォッチャーに指定
``` TypeScript
    // GroupMembersId
    const sheet = spreadsheet.getSheetByName('個人情報マスタ');

    this.idList = new Array;

    for (var i = 2; i <= sheet.getLastRow(); i++) {
      const kbn = sheet.getRange(i, 3).getValue();  // メンバー区分（1:GL、2:メンバー）
      const id = sheet.getRange(i, 2).getValue();   // ユーザーID

      // 区分がGL（1）の場合
      if (kbn == 1) {
        // GLにIDを設定
        this.GL = id
        // リストにIDを追加
        this.idList.push(id)

        // 区分がメンバー（2）の場合
      } else if (kbn == 2) {
        // リストにIDを追加
        this.idList.push(id)
      }
    }
```
#### 「個人情報マスタ」シート構成
|   | A | B | C |
|:-:|:-:|:-:|:-:|
| 1 | 氏名 | ユーザーID | メンバー区分 |
| 2 | [氏名 値] | [ユーザーID 値]  | [メンバー区分 値] |

::: note info
ユーザーIDの確認方法は以下のQiita記事をご参照ください。
:::

https://qiita.com/akt_3310/items/ff926ac65316184b4b7d

### ③チケット起票メソッド呼び出し、プロジェクトIDなどの情報指定
メイン処理にて①、②で取得した情報を引数に、`createIssue()`メソッドを呼び出す。
``` Typescript
// mainFunction
createIssue(user, pass, apiKey, GL, menberList);
```

`createIssue`メソッドの序盤で、各種設定値を指定。
* `sheet`：Googleフォームの回答を集積、チケットを起票するための情報をまとめたシートを指定。当サンプルでは「**フォーム回答_チケット作成用**」シートを作成してある。 
* `redmine_url`：POSTリクエストを送るためのエンドポイントを指定。 
* `project_id`：Redmine内のいずれのプロジェクト配下にチケットを作成するか指定。IDの確認方法は[ユーザーIDの確認方法と同じQiita記事](https://qiita.com/akt_3310/items/ff926ac65316184b4b7d) を参照。
* `assigned_to_id`：チケットの担当者を指定。 
* `parent_issue_id`：親チケットを指定。 
``` Typescript
  // createIssue
  var sheet = spreadsheet.getSheetByName('フォーム回答_チケット作成用');
  var redmine_url = 'https://[redmine server]/issues.json';
  var project_id = [プロジェクトID];            // プロジェクト指定
  var assigned_to_id = GL;                     // 担当者ID（GL）
  var parent_issue_id = [親チケットNo];         // 親チケットNo
```

### ④スプシからフォームの回答結果を1件ずつ取得
③で指定したシートより、以下の情報を取得。
* `start_date`：チケット開始日
* `due_date`：チケット期日
* `title`：チケットタイトル
* `description`：チケットの説明欄の記載
* `ticket_flg`：該当のフォーム回答をチケットに起票済みか？を判断するためのフラグ。このフラグがON（"1"）である場合、そのフォーム回答行におけるチケット起票処理をスキップする。

``` TypeScript
  // createIssue
  // 質問毎にRedmineチケットを作成
  for (var i = 2; i <= sheet.getLastRow(); i++) {
    var start_date = sheet.getRange(i, 1).getValue();                  // 開始日
    var due_date = sheet.getRange(i, 2).getValue();                    // 期日
    var title = sheet.getRange(i, 3).getValue();                       // タイトル
    var description = sheet.getRange(i, 4).getValue();                 // チケット説明欄
    var ticket_flg = sheet.getRange(i, 5).getValue();                  // チケットが既に作成されているか？

    // チケット作成前判定（チケットが既に作成されている場合、処理をスキップする）
    if (ticket_flg === 1)  {
      continue;
    }
```

#### 「フォーム回答_チケット作成用」シート構成
最低限、A～D列の情報があればチケットを起票できます。

|  | A | B | C | D | E |
|:-:|:-:|:-:|:-:|:-:|:-:|
| 1 | 開始日 | 期日 | チケットタイトル | チケット説明欄 | チケット作成済フラグ |
| 2 |  [チケット送信日など] | [任意の日付など] | [任意] | [任意] | [初期設定なし] |

:::note alert
日付項目の形式について
開始日・期日は「**yyyy-mm-dd**」形式にしてください。
「yyyy/mm/dd」や「yyyy-m-d」形式だと「`開始日は日付を入力してください`」というエラーが発生します。
:::

### ⑤チケット発行に必要な情報をリクエストペイロードに指定

* `issue`：③、④の手順で取得したチケット内容を指定。  
  * `tracker_id`：チケットのトラッカー（タスク、QAなど）を指定。トラッカーIDの確認方法は[ユーザーIDの確認方法と同じQiita記事](https://qiita.com/akt_3310/items/ff926ac65316184b4b7d) を参照。
  * `status_id`：チケットのステータス（新規、進行中など）を指定。デフォルトであれば「新規」のIDは「1」。　
  * `priority_id`：チケットの優先度（低め、通常など）を指定。デフォルトであれば「通常」のIDは「2」。  
* `payload`：作成した`issue`と、プロジェクトIDを元にペイロードを作成。 
```Typescript
    // createIssue
    // Redmineチケット発行に必要な情報を設定
    var issue = {
      'subject': title,
      'description': description,
      'tracker_id': 36,                                                 // トラッカー「QA」
      'status_id': 1,                                                   // ステータス「新規」
      'priority_id': 2,                                                 // 優先度「通常」
      'start_date': start_date,
      'due_date': due_date,
      'assigned_to_id': assigned_to_id,
      'parent_issue_id': parent_issue_id,
      'watcher_user_ids': menberList
    }

    var payload = {
      'issue': issue,
      'project_id': project_id
    };

    payload = JSON.stringify(payload);
```

### ⑥APIリクエストヘッダー作成、`UrlFetchApp.fetch`のオプション指定、API呼び出しの実行
サンプルコード通りにヘッダー・オプションを指定、Redmine APIを`UrlFetchApp.fetch`を使用して呼び出せばRedmineのチケットを起票できる。最後にスプシの「チケット作成フラグ」をONにしておく。

:::note alert
`X-Redmine-Switch-User`の指定について
チケット起票に使用するRedmineアカウントが**管理者アカウント**である場合、この指定は**必須**です。
（通常アカウントの場合は、`X-Redmine-Switch-User`を指定しても無視される。）
`X-Redmine-Switch-User`の指定が不適切だと412エラーが返却されます。
参考：https://www.redmine.org/projects/redmine/wiki/rest_api#User-Impersonation
:::
```Typescript
    var headers = {
      "Authorization": " Basic " + Utilities.base64Encode(user + ":" + pass),
      'X-Redmine-API-Key': apiKey,
      'X-Redmine-Switch-User': user,
      'Content-Type': 'application/json',
    };

    var options = {
      'method': 'POST',
      'headers': headers,
      'payload': payload,
      'contentType': 'application/json',
    };

    // （中略）
    var response = UrlFetchApp.fetch(redmine_url, options);

    // チケット作成フラグを立てる
    sheet.getRange(i, 5).setValue(1);
```

これでサンプルコードの説明は終わりです。

## GASトリガー設定
以下の通りトリガーを設定すれば、Googleフォーム送信時に自動でGASスクリプトが起動します。

* 実行する関数を選択：mainメソッドを指定
* イベントのソースを選択：スプレッドシートから
* イベントの種類を選択：フォーム送信時

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3483700/340886ff-dff2-8d91-642c-6d9aac8738de.png)


## おわりに
Redmine API、GASを併用して、意外と簡単にチケット自動起票スクリプトを作成することができました。

特にGASは工夫次第で柔軟な処理を実装できそうですね。
当記事では省略しましたが、「フォームで指定されたお問い合わせの対応期日が〇営業日以内か？」を判定して、設定された期日に余裕が無さすぎる場合は質問者へ期日の見直し依頼メールを自動送信するような処理も実装していました。各種Google Workspaceサービスに、複雑な環境構築などナシで連携できるのが魅力的だと感じました。

似たようなスクリプトを実装したいと思っている方に、この記事がお役に立てたら幸いです。

## 参考
https://www.redmine.org/projects/redmine/wiki/rest_api

https://www.r-labs.org/projects/r-labs/wiki/Redmine_REST_API

https://www.happylifecreators.com/blog/20220413/