SlackにいるZenHubさんにIn Progressにイシューがないと怒られる話

# はじめに
弊社ではチャットに[Slack](https://slack.com/intl/ja-jp)、タスク管理に[ZenHub](https://www.zenhub.com/)を使っています。

タスク管理ツールを使ってると、タスクの状態を更新し忘れることが多いですよね。

弊社でも、イシューをIn Progressにするのを忘れて、今何やってるんでしたっけ…？ と聞かれるという事案が発生していました。

「〜することを忘れないようにする」というTryはエンジニアとしての敗北なので、ボットにお知らせしてもらうようにしましょう。

# Kotlin製ボットフレームワークBotlin
Kotlinが大好きなので、ノリでボットフレームワークをつくりました。

- [mizoguche/botlin: Bot framework built with Kotlin](https://github.com/mizoguche/botlin)
- [Ktorが面白かったのでインスパイアしてボットフレームワークをつくった話 // Speaker Deck](https://speakerdeck.com/mizoguche/ktorgamian-bai-katutafalsedeinsupaiasitebotutohuremuwakuwotukututahua)

これをしれっと業務で使っていきます。

# Featureをつくる
Speaker Deckのスライドにもある通り、[Ktor](http://ktor.io/)に影響を受けていますので、なにかやりたいことが生まれたらFeatureをつくっていきます。

GitHubクライアントには[kohsuke/github-api: Java API for GitHub](https://github.com/kohsuke/github-api)、ZenHubのAPIは[kittinunf/Fuel: The easiest HTTP networking library for Kotlin/Android](https://github.com/kittinunf/Fuel)で直接叩きます。

今回はZenHubの特定のパイプライン(今回はIn Progress)にイシューがない人を探して通知するというFeatureを作っていきます。

Botlinには組み込みでCronというFeatureがあり定期的にコマンドを実行できるので、このFeatureが平日の定時内に30分に1回呼ばれるようにします。

実装の主要なところをダイジェストでご紹介します。

```kotlin:ZenHub.kt
// ...

class ZenHub(internal val configuration: Configuration) : BotFeature {
    // ...

    override fun install(context: BotFeatureContext) {
        FuelManager.instance.baseHeaders = mapOf("X-Authentication-Token" to configuration.authenticationToken)

        context.pipelineOf<BotMessageCommand>().intercept {
            if (it.command != "zenhub") {
                return@intercept
            }

            if (it.args.startsWith("in-progress")) {
                executeCheckIssuesInProgress(context, it, it.args.contains("--silent"))
                return@intercept
            }

            postMessage(context, it, """|使い方
                |```
                |zenhub in-progress
                |    パイプラインIn Progressにイシューがない人を怒る
                |```
            """.trimMargin())
        }
    }

    internal fun getBoardPipelines(context: BotFeatureContext, command: BotMessageCommand, callback: (Pipelines) -> Unit) {

        "https://api.zenhub.io/p1/repositories/${configuration.repositoryId}/board"
                .httpGet()
                .responseObject<Pipelines> { _, _, result ->
                    val (pipelines, error) = result

                    // エラーハンドリング...

                    callback.invoke(pipelines)
                }
    }

    internal fun postMessage(context: BotFeatureContext, command: BotMessageCommand, message: String) {
        val message = BotMessageRequest(BotEngineId("Slack"), command.channelId, message)
        context.pipelineOf<BotMessageRequest>().execute(message)
    }

    class Configuration {
        lateinit var authenticationToken: String
        // ...
    }

    companion object ZenHubFactory : BotFeatureFactory<Configuration> {
        override fun create(configure: Configuration.() -> Unit): BotFeature {
            val conf = Configuration().apply(configure)
            return ZenHub(conf)
        }
    }
}
```

```kotlin:InProgress.kt
// ...

fun ZenHub.executeCheckIssuesInProgress(context: BotFeatureContext, command: BotMessageCommand, shouldBeSilent: Boolean) {
    getBoardPipelines(context, command) { pipelines ->
        val issueNumbers = pipelines.issueIdsInPipeline(configuration.pipelineName)
        val repository = getRepository()
        val issues = issueNumbers.mapToGitHubIssues(repository)
        val assignedUsers = issues.distinctBy { it.number }.mapNotNull {
            if (it.assignee == null) {
                val message = "In Progressにアサインされてないイシュー(#${it.number})があるよ (#^ω^)ﾋﾞｷﾋﾞｷ\nhttps://github.com/${configuration.gitHubOrganization}/${configuration.gitHubRepositoryName}/issues/${it.number}"
                postMessage(context, command, message)
                null
            } else {
                it.assignees
            }
        }.flatten().map { it.login }

        val usersNotAssigned = configuration.usernamePairs.usersNotIncluded(assignedUsers)

        if (usersNotAssigned.count() > 0) {
            usersNotAssigned.forEach {
                val message = "@${configuration.usernamePairs.slackUsername(it)} In Progressにイシューがないよ〜〜 :innocent: :innocent: :innocent:"
                postMessage(context, command, message)
            }
        } else {
            if (!shouldBeSilent) {
                postMessage(context, command, "【朗報】全員In Progressのイシューがある【:tada::tada::tada:】")
            }
        }
    }
}
```

```kotlin:Main.kt
fun main(args: Array<String>) {
    botlin {
        install(SlackEngine) {
            token = System.getenv("SLACK_TOKEN")
        }

        install(RedisStorage) {
            uri = URI(System.getenv("REDIS_URL"))
        }

        install(MessageCommand)
        install(Cron)
        install(Echo)
        install(ZenHub) {
            authenticationToken = System.getenv("ZENHUB_TOKEN")
            repositoryId = System.getenv("ZENHUB_REPOSITORY_ID")
            pipelineName = System.getenv("ZENHUB_PIPELINE_NAME")
            gitHubUsername = System.getenv("GITHUB_USERNAME")
            gitHubToken = System.getenv("GITHUB_TOKEN")
            gitHubOrganization = System.getenv("GITHUB_ORGANIZATION")
            gitHubRepositoryName = System.getenv("GITHUB_REPOSITORY_NAME")

             // GitHubとSlackのユーザー名が違う人のためのマッピング
            usernamePairs = setOf(
                    UsernamePair(github = "mizoguche", slack = "mizoguche"),
                    // ...
            )
        }
    }.start()
}
```

# 結果
<img width="571" alt="怒られの発生" src="https://qiita-image-store.s3.amazonaws.com/0/4552/2ea43c97-7ff6-f37f-3d5a-47c0569e887f.png">

怒られが発生しました😇

# まとめ
- ちゃんとイシューのステータスを更新していきましょう🎉
- Tryは気持ちではなく仕組みにしていきましょう🤖
- Kotlinかわいい🐣