Redmineから期日超過および期日が近いチケットを抽出してチャットワークに通知する

# 概要
Redmineから期日超過および期日が近いチケットを抽出してチャットワークに通知する。

Redmine APIでステータスが「終了」（closed）でないチケットを抽出してから以下の条件で絞り込む
* 期日超過しているチケット
* 指定した日数後に期日超過になり、ステータスが「解決」でないチケット

※チケットに期日が設定されていなければ親チケット（Versionを想定）の期日を見る
※ステータスは各々の環境で異なると思うので要調整
　（期日超過で終了になっていないが作業は完了しているみたいな状態は除外した方がよいでしょう）

チケットをちゃんと「終了」させるという習慣がなかなか根付かないのでグループチャットにブン投げるシステム。

# 通知サンプル

こんな感じで通知される。
![Redmine通知サンプル.png](https://qiita-image-store.s3.amazonaws.com/0/178991/f68ba321-d1c0-da16-ef03-61504386a317.png)

# サンプルコード

```php:期日超過と○日後に期日超過になるチケットを抽出して通知
$date = Carbon::now()->hour(0)->minute(0)->second(0)->subDays(1);

$offset = 0;
$limit = 100;
$feature = 2;   // 指定日数後に期日超過になるチケットを抽出する
$api_token = '[Redmine Api Token]';
$api_endpoint = '[Redmine Api Uri]'; // http://[ホスト名 or IPアドレス]
$project_name = '[Redmine Project Name]]';
$chatwork_api_token = '[Chatwork Api Token]';
$chatwork_room_id = '[Chatwork Room ID]';
$due_date = $date->toDateString();
$feature_date = $date->copy()->addDays($feature)->toDateString();
$created_on = 'YYYY-MM-DD';     // チケット作成日：指定日以降を抽出対象とする（絞り込みが必要なければ空にする）
$due_version_ids = [];
$versions = [];
$expire_data = [];
$feature_data = [];
$feature_ok_status = ['解決'];    //　抽出から除外するステータス
while (true) {
    $url = "{$api_endpoint}/projects/{$project_name}/versions.json?due_date=<={$feature_date}&offset={$offset}&limit={$limit}";
    $result = Redmine::get_ticket($api_token, $url);
    if (!empty($result)) {
        $json = json_decode($result, true);
        if (!empty($json)) {
            foreach ($json['versions'] as $v) {
                // バージョン期日超過
                if ($v['status'] !== 'closed' && !empty($v['due_date']) && $v['due_date'] <= $due_date) {
                    $due_version_ids[$v['id']] = $v['id'];
                }
                $versions[$v['id']] = $v['name'];
            }
        }
        if (empty($json) || $json['total_count'] < ($offset + $limit)) {
            break;
        }
    }
    $offset += $limit;
}
$offset = 0;
while (true) {
    $url = "{$api_endpoint}/issues.json?status_id=open&offset={$offset}&limit={$limit}";
    if (!empty($created_on)) {
        $url .= "&created_on=%3E%3D{$created_on}";
    }
    $result = Redmine::get_ticket($api_token, $url);
    if (!empty($result)) {
        $json = json_decode($result, true);
        if (!empty($json)) {
            foreach ($json['issues'] as $v) {
                $expire = false;
                $next_expire = false;
                $text = '[' . $v['project']['name'] . ':' . $v['id'] . ' ' . ($v['status']['name'] ?? '') . ']';
                $text .= ' ' . ($v['subject'] ?? '');
                $text .= '（担当：' . ($v['author']['name'] ?? 'なし') . ', 期限：' . ($v['due_date'] ?? 'なし') . ')';
                // バージョン
                $version_id = $v['fixed_version']['id'] ?? 0;
                // チケット期日超過
                if (!empty($v['due_date']) && $v['due_date'] <= $due_date) {
                    $expire = true;
                } else {
                    // バージョン期日超過
                    if (empty($v['due_date']) && !empty($v['fixed_version']) && isset($due_version_ids[$v['fixed_version']['id']])) {
                        $expire = true;
                    }
                }
                if ($expire) {
                    $expire_data[$version_id][] = $text;
                } else {
                    // 次期バージョンチケットでステータス「解決」以外
                    if (!empty($v['due_date']) && $v['due_date'] > $due_date && $v['due_date'] <= $feature_date && !in_array($v['status']['name'], $feature_ok_status)) {
                        $next_expire = true;
                    }
                    if ($next_expire) {
                        $feature_data[$version_id][] = $text;
                    }
                }
            }
        }
        if (empty($json) || $json['total_count'] < ($offset + $limit)) {
            break;
        }
    }
    $offset += $limit;
}
if (!empty($expire_data) || !empty($feature_data)) {
    $message = '[info][title]自動通知：Redmineチケット（期日超過＆' . $feature . '日以内で期日）[/title]';
    $message .= '【期日超過＆ステータス「終了」ではないチケット】' . PHP_EOL;
    if (empty($expire_data)) {
        $message .= '　なし' . PHP_EOL;
    } else {
        foreach ($expire_data as $version_id => $v) {
            $message .= '・' . ($versions[$version_id] ?? 'バージョン未設定') . PHP_EOL;
            $message .= '　' . (implode(PHP_EOL . '　', $v ?? [])) . PHP_EOL;
            $message .= PHP_EOL;
        }
    }
    $message .= '【' . $feature . '日以内に期日＆ステータス「解決」「終了」ではないチケット】' . PHP_EOL;
    if (empty($feature_data)) {
        $message .= '　なし' . PHP_EOL;
    } else {
        foreach ($feature_data as $version_id => $v) {
            $message .= '・' . ($versions[$version_id] ?? 'バージョン未設定') . PHP_EOL;
            $message .= '　' . (implode(PHP_EOL . '　', $v ?? [])) . PHP_EOL;
            $message .= PHP_EOL;
        }
    }
    $message .= '[/info]';
    Chatwork::post_message($chatwork_api_token, $chatwork_room_id, $message);
}
```

```php:Redmineクラス
class Redmine
{
    const RETRY_COUNT = 3;

    public static function get_ticket($redmine_api_token, $api_uri)
    {
        $result = null;
        $headers = [
            'X-Redmine-API-Key: ' . $redmine_api_token,
        ];
        $options = [
            CURLOPT_SSL_VERIFYPEER => false,
            CURLOPT_SSL_VERIFYHOST => false,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_HTTPHEADER => $headers,
        ];
        for ($retry_count = 0; $retry_count < 3; ++$retry_count) {
            try {
                $curl = curl_init($api_uri);
                curl_setopt_array($curl, $options);
                $result = curl_exec($curl);
                if ($result === false) {
                    throw new \Exception();
                }
            } catch (\Exception $e) {
                sleep(1);
                continue;
            }
            break;
        }
        return $result;
    }
}
```

```php:Chatworkクラス
class Chatwork
{
    const RETRY_COUNT = 3;

    public static function post_message($chatwork_api_token, $chatwork_room_id, $message)
    {
        $headers = [
            'X-ChatWorkToken: ' . $chatwork_api_token,
        ];
        $data = [
            'body' => $message
        ];
        $options = [
            CURLOPT_POST => true,
            CURLOPT_SSL_VERIFYPEER => false,
            CURLOPT_SSL_VERIFYHOST => false,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_POSTFIELDS => http_build_query($data),
        ];
        for ($retry_count = 0; $retry_count < self::RETRY_COUNT; ++$retry_count) {
            try {
                $curl = curl_init("https://api.chatwork.com/v2/rooms/{$chatwork_room_id}/messages");
                curl_setopt_array($curl, $options);
                $result = curl_exec($curl);
                if ($result === false) {
                    throw new \Exception();
                }
            } catch (\Exception $e) {
                sleep(1);
                continue;
            }
            break;
        }
    }
}
```