Kotlin
bot
Slack
zenhub

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

More than 1 year has passed since last update.

はじめに

弊社ではチャットにSlack、タスク管理にZenHubを使っています。

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

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

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

Kotlin製ボットフレームワークBotlin

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

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

Featureをつくる

Speaker Deckのスライドにもある通り、Ktorに影響を受けていますので、なにかやりたいことが生まれたらFeatureをつくっていきます。

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

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

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

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

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)
        }
    }
}
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:】")
            }
        }
    }
}
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()
}

結果

怒られの発生

怒られが発生しました😇

まとめ

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