【GAS】Google Calendar のイベントから Redmine の作業時間を登録する

# やりたいこと

毎日 Redmine に作業時間を登録するという作業を行なっているのですが、 Google Calendar に登録されたミーティングなどの時間も、 Redmine に登録しており、二重作業となっています。すでにカレンダーに登録しているイベントについては、 Redmine に作業時間として自動で登録できるように、 Google Apps Script を書くことにしました。

# 仕様

スクリプトを実行する当日のカレンダーから、自分が主催者、もしくは出席するイベントについて開始時刻と終了時刻から所要時間を求め、 Redmine の作業時間として登録します。なお、終日イベントは登録しないようにします。
Redmine のチケット番号は、ミーティング用のチケット番号をスクリプトプロパティ「 MEETING_ISSUE_ID 」に、ミーティング用の活動 ID は「 MEETING_ACTIVITY_ID 」に設定し、そこから読み込むようにします。
もし、イベントの詳細 (description) に、「 Redmine: (番号) 」の記載があればそのチケット番号を登録、同様に「 Activity: (番号) 」の記載があればその活動タイプを登録します。

Redmine の作業時間の API の使い方は以下のページを確認します。

https://www.redmine.org/projects/redmine/wiki/Rest_TimeEntries


# 準備

## Redmine API アクセスキーの用意

Redmine に作業時間を登録するのに、作業時間登録 API を利用します。
API で Redmine を操作するためには API アクセスキーが必要になりますので、これを取得します。
Redmine の右上のメニューから「個人設定」を開きます。右メニューの一番下に「 API アクセスキー」という項目がありますのでここで「リセット」のリンクをクリックします。
これにより API アクセスキーが作成されますので、「表示」をクリックして、 API アクセスキーをコピーします。

![redmine-personal.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/926617/fc822c40-f859-e68b-68f5-c3bf3f13a494.png)


## 活動 ID の確認

作業時間の活動にひもづく活動 ID は、作業時間の絞り込みを行うことで確認できます。
作業時間のフィルタから「活動」を選び、確認したい活動(ここでは「ミーティング」)を選択すると、 Redmine の URL のパラメーターに activity_id として表示されますのでこれを控えておきます。

![activity_id2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/926617/3619fcf3-bd22-f4e6-38dc-9bb339d8fe0c.png)


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

Google Apps Script では、 Redmine の APIアクセスキーをスクリプトプロパティに設定して、そこから読み込むようにします。 APIアクセスキー以外にも Redmine の URL や、ミーティングの作業時間記録用のチケット番号、活動 ID なども、スクリプトプロパティから読み込むようにします。

スクリプトプロパティは、右側の歯車のアイコンから設定できます。

![gas_properties.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/926617/bfe228b1-b7cc-c163-9bff-a5b9c675794b.png)


# Google Apps Script のソース

Google Calendar の Event の扱いについては以下のページを確認。

https://developers.google.com/apps-script/reference/calendar/calendar-event?hl=ja

Redmine API を叩くのには UrlFetchApp を利用します。

https://developers.google.com/apps-script/reference/url-fetch/url-fetch-app?hl=ja

できあがったスクリプトがこちら。

```javascript
function cal2redmine() {

  let n = 0 // n日前のカレンダー
  let cal = CalendarApp.getDefaultCalendar();
  let targetDate = new Date();
  targetDate.setDate(targetDate.getDate() - n);
  let events = cal.getEventsForDay(targetDate);

  for (let i = 0; i < events.length; i++) {
    let event = events[i];
    let startTime = formatTime(event.getStartTime());
    let startDate = formatDate(event.getStartTime());
    let endTime = formatTime(event.getEndTime());
    let hours = (event.getEndTime() - event.getStartTime())/3600000;
    let eventStatus = event.getMyStatus(); // 【参加状況】 YES:参加, NO:不参加, OWNER:主催者
    let title = event.getTitle();
    let description = event.getDescription();
    let colorId = event.getColor();
    
    // 自分が出席しないかつ主催者でないイベントは登録しない
    if (eventStatus != 'YES' && eventStatus != 'OWNER') {
      continue;
    }
    // 終日イベントは登録しない
    if (hours >= 24) {
      continue
    }

    description.split(/(<br>|\n)/i).forEach(function(eventText){
      // 詳細に「Redmine: (番号)」の記載があればそのチケット番号を登録
      let searched = /^Redmine:\s?(\d+)/i.exec(eventText);
      if (searched !== null) {
        issueId = searched[1];
      }
      // 詳細に「Activity: (番号)」の記載があればその活動タイプを登録
      searched = /^Activity:\s?(\d+)/i.exec(eventText);
      if (searched !== null) {
        activityId = searched[1];
      }
    });

    let eventDetails = "";
    eventDetails += "タイトル: " + title + "\n";
    eventDetails += "カラーID: " + colorId + "\n";
    eventDetails += "出欠状況: " + eventStatus + "\n";
    eventDetails += "開始時刻: " + startTime + "\n";
    eventDetails += "終了時刻: " + endTime + "\n";
    eventDetails += "所要時間: " + hours + "\n";
    eventDetails += "Activity: " + activityId + "\n";
    eventDetails += "Redmine: " + issueId + "\n\n";
    Logger.log(eventDetails);

    // Redmineへの作業時間登録
    postTimeEntry(issueId, startDate, hours, activityId, title);

  }
}

// 時間を日本時間表記（HH:mm）に変換する関数
function formatTime(date) {
  let japanTime = Utilities.formatDate(date, Session.getScriptTimeZone(), "HH:mm");
  return japanTime;
}

// 時間を日本日付表記（YYYY-MM-DD）に変換する関数
function formatDate(date) {
  let japanDate = Utilities.formatDate(date, Session.getScriptTimeZone(), "yyyy-MM-dd");
  return japanDate;
}

// Redmineへの作業時間登録
function postTimeEntry(issueId, spentOn, hours, activityId, comments) {
  // スクリプトプロパティの読み込み
  const API_URL = PropertiesService.getScriptProperties().getProperty("REDMINE_API_URL")+'time_entries.json';
  const API_KEY = PropertiesService.getScriptProperties().getProperty("REDMINE_API_KEY");
  const MEETING_ISSUE_ID = PropertiesService.getScriptProperties().getProperty("MEETING_ISSUE_ID");
  const MEETING_ACTIVITY_ID = PropertiesService.getScriptProperties().getProperty("MEETING_ACTIVITY_ID");

  // 指定がなければミーティングとして登録
  if (issueId == null) {
    issueId = MEETING_ISSUE_ID; // ミーティングの作業時間登録用Redmineチケット
  }
  if (activityId == null) {
    activityId = MEETING_ACTIVITY_ID; // 作業時間の活動タイプ「ミーティング」
  }
  
  // 作業時間登録パラメーター設定
  let payload = {
    'key': API_KEY,
    'time_entry[issue_id]': issueId,
    'time_entry[spent_on]': spentOn,
    'time_entry[hours]': hours,
    'time_entry[activity_id]': activityId,
    'time_entry[comments]': comments,
  };
  let options = {
    'method' : 'post',
    'payload' : payload
  };
  let responseDataPOST = UrlFetchApp.fetch(API_URL, options).getContentText();
  return;
}
```

# さらなる自動化

カレンダーを色分けしている方は、イベントの色 (colorId) によって、登録内容を制御してもいいかもしれません。
時刻を決めて、1日の終わりに定期実行することもできます。

工夫次第で、カレンダーと Redmine の連携が楽になるかと思います。ご参考まで。