【GAS】SlackワークフローからRedmineチケットを自動作成できるツールを作ってみた

# はじめに
今回、業務で社内改善ツールとしてSlackワークフローからRedmineチケットを自動作成するツールを作成する機会があったので、開発内容を備忘録として記録したいと思います。

# 完成イメージ
① ユーザアクション：Slackワークフローからチケット情報を入力

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2741747/aa7a77e5-51ef-03b0-837b-b095760a707b.png)

② ツール実行：Redmineチケットが自動起票される

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2741747/88751cfa-8788-05ed-ce58-3f83113f1633.png)

③ ツール実行：Redmine URLを含むメッセージをSlackに通知

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2741747/01368179-3352-6ab8-6f5f-8f6d9353f1f3.png)


# 構成
- Slackワークフロー
- Slackカスタムアプリ
    - Incoming　WebHooksアプリ
    - Userリスト取得アプリ
- Googleスプレッドシート
- Google Apps Script（以下GAS）
- RedmineAPI

カスタムインテグレーションが非推奨となったため、カスタムインテグレーションとしてのOutgoing Webhooks、 Incoming WebHooksを使わないことを前提に構成しました。

今回はSlackカスタムアプリとしてIncoming WebHooksを作成しております。

SlackワークフローのステップからはRedmineに直接アクセスできないため、ワークフローからの「Googleスプレッドシートへのデータ追加」をトリガーに、GAS経由でRedmineAPIを実行しています。

# 処理の流れ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2741747/9289fc4f-9dd5-c684-3213-0942fe5d23e3.png)

- Slackワークフローより
    - チケット情報を記入
    - ワークフローステップからデータをGoogleスプレッドシートに記入
- Googleスプレッドシートより
    - 変更を感知してGASを発火
- GASより
    - Googleスプレッドシートからデータを取得して整形
    - RedmineAPIを実行してチケット作成
    - 作成されたチケットURLを記載したメッセージをSlack通知

# 実装方法
### １） データ記入用スプレッドシート作成
新規スプレッドシートを作成します。
「拡張機能」 ＞ 「Apps Script」からGASを開始します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2741747/49f2f1ee-cca3-962a-88f7-e55eb6ab369f.png)


### ２） curlでRedmine APIを実行してデータ構造を確認する
以下の記事を参考にRedmine APIを有効にし、APIアクセスキーを取得します。

https://redmine.jp/glossary/r/rest-api/


以下の記事を参考にチケットの情報を確認します。
確認した内容は以降の手順でデータの整形時などに参考にします。

https://qiita.com/n_takechi/private/25137b70eba02ad0014d

### ３）スプレッドシートに項目列を追加
手順2で確認したRedmineのチケット情報を参考に、先ほど作成したスプレッドシートの1行目にRedmineへ登録する項目の行を追加します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2741747/6edd2ad7-dbf9-e0d1-a6bd-32402d8eba23.png)


### ４）Slackワークフロー作成
情報を取得するワークフローを作成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2741747/bec37f5f-d85d-70a5-c524-e42532998b7c.png)

ステップ２では先ほど作成したスプレッドシートを選択し、「スプレッドシートの対象列」と「フォームで収集する項目」が対応するよう指定します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2741747/02c56be1-0f07-882b-6fd9-799949ff5ffc.png)

### ５）GASに変更感知のトリガーを設定
GASのトリガーを開きます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2741747/4f2987aa-6db8-652c-755b-d57ae3b90ece.png)

以下のようにトリガーを設定します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2741747/5c06677f-a88e-a497-8e71-483022494a6b.png)


### ６）Slackカスタムアプリ作成
① 以下の記事を参考にIncoming Webhookアプリを作成し、メッセージを投稿したいチャネルのWebhook URLを取得します。

https://qiita.com/miriwo/items/cbb4b37b3f5abecc835f

② 以下の記事を参考にUserリスト取得アプリを作成し、User Tokenを取得します。

https://qiita.com/kmori/items/837351d8499d4f3eebed

### ７）スクリプトプロパティの設定

手順6で取得したWebhook URLやUser Tokenをスクリプトプロパティに設定します。
「プロジェクトの設定」を開きます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2741747/6573a1a8-d11d-98c7-d701-1ec897124ba0.png)

スクリプトプロパティに以下3つを設定します。
- 手順6で取得したWebhook URL
- 手順6で取得したUser Token
- 手順2で取得したRedmineAPIキー

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2741747/6793eff1-9c49-4de4-5dc2-cb57bc257900.png)


### ８）GASの実装
以下5ファイルを作成します。
- main.gs
- forms.gs
- redmine.gs
- slack.gs
- tools.gs

```js:main.gs
var prop = PropertiesService.getScriptProperties().getProperties();

function main() {
  let mySheet = SpreadsheetApp.getActiveSheet();
  let sheetName = mySheet.getSheetName();

  let data = {};
  let slackMessage = {};
  let response = {}

  // フォーム追加時はここに分岐を追加
  response = formMain(sheetName)

  data = response.data
  slackMessage = response.slackMessage

  let ticketUrl = createTicket(data)
  post_slack(ticketUrl, slackMessage);
}

```

```js:form.gs
function formMain(sheetName) {
  let data = srformatData(sheetName)
  let slackMessage = srSlackMessage(data)
  return {"data": data, "slackMessage": slackMessage}
}

// Redmineチケット作成用フォーマット
function srformatData(sheetName) {
  let response = {};
  var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = spreadsheet.getSheetByName(sheetName);
  var lastRow = sheet.getLastRow();

  // 以下、手順2で確認したRedmineのチケット項目に合わせて任意に変更してください。
  // トラッカー
  response['tracker'] = 31;

  // 期日
  response['due_date'] = formatDate(sheet.getRange(lastRow, 1).getValue());

  // 対象顧客
  response['target_clients'] = sheet.getRange(lastRow, 2).getValue();

  // 題名
  response['subject'] = sheet.getRange(lastRow, 10).getValue();

  // 説明
  response['description'] = "♦︎依頼詳細\n" + sheet.getRange(lastRow, 3).getValue()

  // 調査依頼内容
  response['request_detail'] = sheet.getRange(lastRow, 3).getValue();

  // 案件番号
  response['case_number'] = sheet.getRange(lastRow, 4).getValue();

  // 通知メンバー1
  response['notice_1'] = removeAtSymbol(sheet.getRange(lastRow, 5).getValue());

  // 通知メンバー2
  response['notice_2'] = removeAtSymbol(sheet.getRange(lastRow, 6).getValue());

  // 通知メンバー3
  response['notice_3'] = removeAtSymbol(sheet.getRange(lastRow, 7).getValue());

  // 作成者
  response['created_user'] = removeAtSymbol(sheet.getRange(lastRow, 8).getValue());

  return response;
}

// Slackメッセージ送信用フォーマット
function srSlackMessage(data) {
  let users = getUsers()

  let notice_1 = fetchUserIdByDisplayName(users, data['notice_1'])
  let notice_2 = fetchUserIdByDisplayName(users, data['notice_2'])
  let notice_3 = fetchUserIdByDisplayName(users, data['notice_3'])
  let created_user = fetchUserIdByDisplayName(users, data['created_user'])

  let notice_3_text = ''
  if (notice_3 !== undefined) {
     notice_3_text = ' *通知メンバー③* \n' + '<@' + notice_3 + '>'
  }
  
  // 以下、Slack通知フォーマットを任意に変更してください。
  const json =
  {
    'text': '<@' + created_user + '>さんからの *Discoveriez調査依頼フォーム* の送信\n\n'
     + ' *期日* \n' + data['due_date'] + '\n\n'
     + ' *概要* \n' + data['subject'] + '\n\n'
     + ' *対象顧客* \n' + data['target_clients'] + '\n\n'
     + ' *調査依頼内容* \n' + data['request_detail'] + '\n\n'
     + ' *通知メンバー①* \n<@' + notice_1 + '>\n\n'
     + ' *通知メンバー②* \n<@' + notice_2 + '>\n\n'
     + notice_3_text
  };
  
  return json;
}
```

```js:redmine.gs
function createTicket(data) {
  // 以下、手順2で確認したチケット項目に合わせて変更してください。
  const payload = {
    "issue": {
         "project_id": 237
       	,"tracker_id": data['tracker']
        ,"status_id": 1
        ,"priority_id": 2
        ,"description": data['description']
        ,"subject": data['subject']
        ,"due_date": data['due_date']
        ,"custom_field_values": {
           "66": data['case_number']
        }
    }
  };
  
  let redmine_url = 'https://{RedmineURL}/issues.json';
  let api_key = prop.REDMINE_API_KEY; // RedmineのAPI key を入力
  let headers = {
      'X-Redmine-API-Key': api_key,
      'Content-Type': 'application/json',
  };
  let options = {
      'method': 'POST',
      'contentType': 'application/json',
      'headers': headers,
      'payload': JSON.stringify(payload),
  };
  let response = UrlFetchApp.fetch(redmine_url, options);
  let id = JSON.parse(response).issue.id;
  return 'https://{RedmineURL}/issues/' + id;
}

```

```js:slack.gs
function post_slack(ticketUrl, json) {
  // SlackのWebhookURL
  const slack_webhook_url = prop.WEBHOOK_TEST_DM; // webhook url入力
  
  json.text += '\n\n*チケットURL* \n' + ticketUrl

  // SlackのWebhook URLに送信するデータをJSONに変換する
  const payload = JSON.stringify(json);

  // UrlFetchAppで使用するメソッドやコンテントタイプを指定
  const options =
  {
    'method': 'post',
    'contentType': 'application/json',
    'payload': payload
  };

  // Slackに送信
  UrlFetchApp.fetch(slack_webhook_url, options);
}

function getUsers() {
  var slackToken = prop.BOT_USER_OAUTH_TOKEN;

  // ユーザーリストを取得するためのSlack APIエンドポイント
  var url = "https://slack.com/api/users.list";

  // APIリクエストのオプション
  var headers = {
    'Authorization': 'Bearer '+ slackToken
  };
  var options = {
     "method": "get"
    ,"headers": headers
  };
  
  // Slack APIからユーザーリストを取得
  var response = UrlFetchApp.fetch(url, options);
  var users = JSON.parse(response.getContentText()).members;

  return users;
}

function fetchUserIdByDisplayName(users, displayName) {
  // 表示名からユーザーIDを検索
  var userId = null;
  for (var i = 0; i < users.length; i++) {
    if (users[i].profile.display_name === displayName || users[i].name === displayName) {
      userId = users[i].id;
      break;
    }
  }
  
  // ユーザーIDが見つからない場合の処理
  if (userId === null) {
    Logger.log("指定された表示名のユーザーが見つかりません: " + displayName);
    return;
  }
  return userId;
}

```

```js:tools.gs
function formatDate(date) {
  var date = new Date(date);
  var format = "yyyy-MM-dd";
  var timeZone = Session.getScriptTimeZone();
  var formattedDate = Utilities.formatDate(date, timeZone, format);
  return formattedDate;
}

function removeAtSymbol(str) {
  if (str.charAt(0) === '@') {
    return str.substring(1);
  }
  return str;
}
```

### 新規フォームの追加方法

複数のフォームを扱えるように、共通部分は切り出しています。
フォームを追加するときは以下の手順で追加可能です。

① スプレッドシートに**新規シート**を追加
② Slackワークフローを新規作成し、データの追加先に上記のシートを指定
③ form.gsをもとに**該当フォーム用のgsファイル**を作成し、項目調整
④ 以下のようにmain.gsに分岐追加

```js:main.gs 分岐追加例
  //シート名が「①で追加したシート」であれば「②で追加したgsファイルのmain関数」を実行する
  if (sheetName == '見積もり依頼フォーム') {
    response = qrMain(sheetName)
  
  } else if(sheetName == '新規機能開発依頼フォーム' ) {
    response = drMain(sheetName)
  } 
```

※ 関数は全ファイルでユニークになるよう設定してください。
GASの関数はすべてグローバル関数となるので、複数ファイルで同一の名前の関数名をつけてはならないため（泣）

# 動作確認
Slackワークフローを実行し、Redmineチケット作成とSlack通知が完了すればOK！

ちなみに、GASの実行ログは以下「実行数」で確認できます。
スクリプト内で```Logger.log()```を記載すればここにログが出力されるのでデバッグも可能です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2741747/c4025339-2816-f9fc-b606-2f4f9a43c69f.png)

# おわりに
GASは初めて触りましたが、簡単にツールを作成することができて楽しかったです。
Notion APIなどでも遊んでみたいと思います！誰かの参考になれば幸いです。

# 参考記事
https://engineers.weddingpark.co.jp/slack-google_apps_cript/