【GAS】GoogleカレンダーからPagerDutyのMaintenance Windowを作成できるようにする

# 作ったもの

Googleカレンダーに予定を作成/変更/削除すると、PagerDutyのメンテナンスウィンドウが作成/変更/削除されます👍

![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/502255/7e467ed3-eb1f-9b87-e3f1-6b76c0cdbfbd.png)

![2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/502255/6b1b821c-e91a-0d42-c474-cb3f94de974b.png)

既存のAppでは**ScheduleをGoogleカレンダーに出力することしかできない**ので作成しました。たぶん先行事例はありません。

ref: [Schedules in Third-Party Apps](https://support.pagerduty.com/docs/schedules-in-apps)

# コード（全文）

↓をクリックすると開きます

<details>

```javascript
// スクリプトプロパティ（Key-Valueストア）を使う
const db = PropertiesService.getScriptProperties();
// 定数
const PD_TOKEN = 'Token token=' + db.getProperty('PD_TOKEN');
const PD_HEADERS = {
  "Accept": "application/vnd.pagerduty+json;version=2",
  "Authorization": PD_TOKEN
}
const PD_SERVICE_IDS = [
  { "id": "PVG3BVI", "type": "service_reference" }, // service-A
  { "id": "PHN2FVI", "type": "service_reference" } // service-B
]
const PD_API = 'https://api.pagerduty.com'

// メイン関数
function calendarUpdated(e) {
  let options = {
    'showDeleted': true,
    'maxResults': 100
  };

  // syncTokenを読み込む
  const syncToken = db.getProperty('SYNC_TOKEN');
  if (syncToken) {
    options.syncToken = syncToken;
  } else {
    // syncTokenがない場合(初回)、過去7日間の予定を対象にする
    options.timeMin = getDate7daysAgo();
  }

  // 予定の取得
  const events = Calendar.Events.list(e.calendarId, options);

  for (const event of events.items) {
    // eventIdに紐づくmaintenanceWindowIdを読み込む
    let maintenanceWindowId = db.getProperty(event.id);

    // maintenanceWindowIdがnullならCreate
    // event.statusがcancelledならDelete
    // それ以外はUpdate
    if (maintenanceWindowId == null) {
      maintenanceWindowId = CreateMaintenanceWindow(event.start.dateTime, event.end.dateTime, event.summary);
      db.setProperty(event.id, maintenanceWindowId);
    } else if (event.status == 'cancelled') {
      DeleteMaintenanceWindow(maintenanceWindowId);
      db.deleteProperty(event.id);
    } else {
      UpdateMaintenanceWindow(maintenanceWindowId, event.start.dateTime, event.end.dateTime, event.summary);
    }
  }

  // syncTokenを保存する
  db.setProperty('SYNC_TOKEN', events.nextSyncToken);
}

// Create
function CreateMaintenanceWindow(startTime, endTime, description) {
  const data = {
    "maintenance_window": {
      "type": "maintenance_window",
      "start_time": startTime,
      "end_time": endTime,
      "description": description,
      "services": PD_SERVICE_IDS
    }
  };
  const options = {
    'method': 'post',
    'contentType': 'application/json',
    'headers': PD_HEADERS,
    'payload': JSON.stringify(data)
  };
  const url = PD_API + '/maintenance_windows';

  const response = UrlFetchApp.fetch(url, options);
  const responseJSON = JSON.parse(response.getContentText());
  return responseJSON.maintenance_window.id;
}

// Delete
function DeleteMaintenanceWindow(id) {
  const options = {
    'method': 'delete',
    'headers': PD_HEADERS,
  };
  const url = PD_API + '/maintenance_windows/' + id;

  UrlFetchApp.fetch(url, options);
}

// Update
function UpdateMaintenanceWindow(id, startTime, endTime, description) {
  const data = {
    "maintenance_window": {
      "type": "maintenance_window",
      "start_time": startTime,
      "end_time": endTime,
      "description": description,
      "services": PD_SERVICE_IDS
    }
  };
  const options = {
    'method': 'put',
    'contentType': 'application/json',
    'headers': PD_HEADERS,
    'payload': JSON.stringify(data)
  };
  const url = PD_API + '/maintenance_windows/' + id;

  UrlFetchApp.fetch(url, options);
}

// 7日前の日付を返す関数
function getDate7daysAgo() {
  const date = new Date();
  date.setDate(date.getDate() - 7);
  return date.toISOString();
}
```

</details>

# コード以外で必要な作業

先にGoogle Cloud Platformのコンソールで次の作業をします。

1. 適当な名前でGCPプロジェクトを作成する
2. `APIとサービス`→`APIとサービスの有効化`で「Google Calendar API」を有効にする
3. `APIとサービス`→`OAuth 同意画面`で同意画面を新規作成する。スコープは「すべてのカレンダーの予定を表示」

次にGoogle Apps Script（以下、GAS）で次の作業をします。

1. `プロジェクトの設定`で先ほどのGCPプロジェクトを紐付ける
2. `エディター`→`サービスを追加`で「Google Calendar API」を有効にする
3. `トリガー`→`トリガーの追加`でイベントソースに「カレンダーから」を選ぶ。連携するGoogleカレンダーのメールアドレスを入れ、遷移先でOAuth認証を行う

# 解説

### 1. なぜGASなのか？

まずはノーコードで実現できないか調べました。[IFTTT](https://ifttt.com/google_calendar/details)には「予定の追加」というトリガーがありますが、変更と削除はなし。
![IFTTT.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/502255/b31af008-b871-6a66-369f-a84297de3421.png)

[Zapier](https://zapier.com/apps/google-calendar/integrations/triggerapp)には変更と削除もありました。
![Zapier.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/502255/07f0783e-1e80-12f9-2eeb-e7c511394db6.png)

ただしZapierは無料プランだと毎月100タスクしか実行できません。開発で試行錯誤していたら100回はあっという間に到達しそうなのでやめました。が、この記事を書くために再度調べたら「最初の2週間は1000タスクを実行できる」だと!? Oh...

ref: [Get started with your free Zapier trial](https://help.zapier.com/hc/en-us/articles/8496197192461-Get-started-with-your-free-Zapier-trial)

コードを書くなら、簡単にGoogleカレンダーと連携できるGASはとても便利です。学習コストや運用コストも低いです。

### 2. GASでGoogleカレンダーをトリガーにするときの概要

https://developers.google.com/apps-script/advanced/calendar

**Advanced Calendar Service**はGASでGoogle Calendar APIを使うためのサービスです。前述の通りGASの`エディター`→`サービスを追加`から有効にできます。

予定をハンドリングする方法は下記の記事がとても参考になりました。

https://qiita.com/howdy39/items/b92c9ba0b050151a889b

追加/変更/削除された予定を取得するには`syncToken`をリクエストに含めてAPIを叩きます。

この`syncToken`はAPIを叩くと`nextSyncToken`という名前でレスポンスに入ってくるものです。

初回のみ`syncToken`を使わずにAPIを叩いて`nextSyncToken`をスクリプトプロパティに保存し、2回目以降はそれを使ってAPIを叩き、また返ってきた`nextSyncToken`を保存する、という流れにしています。

### 3. スクリプトプロパティ

https://developers.google.com/apps-script/guides/properties

スクリプトプロパティはGASで使えるKey-Valueストアの1つです。簡単かつ便利なので、ついつい雑に使ってしまいます。

```javascript:コードの抜粋
const db = PropertiesService.getScriptProperties();
// READ
const syncToken = db.getProperty('SYNC_TOKEN');
// WRITE
db.setProperty('SYNC_TOKEN', events.nextSyncToken);
```

### 4. 予定とメンテナンスウィンドウの紐付けを保存する

APIについては後述しますが、変更と削除はメンテナンスウィンドウのIDを指定してAPIを叩く必要があります。

そのため、メンテナンスウィンドウの作成後に**Googleカレンダーの予定のID**（`event.id`）と**メンテナンスウィンドウのID**（`maintenanceWindowId`）の紐付けを保存しておく必要があります。

**そこで**`event.id`**をキー、**`maintenanceWindowId`**を値にしてスクリプトプロパティに保存しました。**

メインのロジックは次のとおりです。

```javascript:コードの抜粋（一部改変済み）
// 予定に紐づくメンテナンスウィンドウのIDを取得する
// 新しい予定の場合、まだ存在しないのでnullが返る
let maintenanceWindowId = db.getProperty(event.id);

if (maintenanceWindowId == null) {
  maintenanceWindowId = CreateMaintenanceWindow(引数省略); // 新規作成
  db.setProperty(event.id, maintenanceWindowId); // 作成されたメンテナンスウィンドウのIDを保存する
} else if (event.status == 'cancelled') {
  DeleteMaintenanceWindow(maintenanceWindowId); // 削除
  db.deleteProperty(event.id); // 保存されていたIDも削除する
} else {
  UpdateMaintenanceWindow(maintenanceWindowId, 以下引数省略); // 変更
}
```

### 5. PagerDuty APIを叩く

[API Reference](https://developer.pagerduty.com/api-reference/a450bc9b9ea6f-create-a-maintenance-window)が使いやすくて神です。

メンテナンスウィンドウのCreate(POST)、Update(PUT)、Delete(DELETE)をそれぞれの関数で叩いています。

GASで外部APIを叩く際は`UrlFetchApp.fetch(url, options)`を使うと便利です。

https://developers.google.com/apps-script/reference/url-fetch/url-fetch-app

例えばUpdateの関数は次のようになります。

```javascript:コードの抜粋
function UpdateMaintenanceWindow(id, startTime, endTime, description) {
  const data = {
    "maintenance_window": {
      "type": "maintenance_window",
      "start_time": startTime,
      "end_time": endTime,
      "description": description,
      "services": PD_SERVICE_IDS
    }
  };
  const options = {
    'method': 'put',
    'contentType': 'application/json',
    'headers': PD_HEADERS,
    'payload': JSON.stringify(data)
  };
  const url = PD_API + '/maintenance_windows/' + id;

  UrlFetchApp.fetch(url, options);
}
```

# 課題と今後の展望

- エラーハンドリング
- スクリプトプロパティに古いデータが溜まっていく問題
- `PD_SERVICE_IDS`に対象にするサービスを書いたが、スプレッドシートから読み込んでも良さそう
- 複数のチームで使えるようにしたい（複数のカレンダーへの対応・予定のタイトルや本文に応じた処理の分岐）
- **ScheduleのOverrideも作れるようにしたい！！**（オンコール担当者の上書き）

関連記事: [PagerDuty CLIでscheduleをoverrideする方法](https://qiita.com/tippy/items/17bf07a2cd2fd471ad21)